Multiple printers, printer/slot in Spoolman, kept in sync
CI / test (push) Successful in 1m6s
CI / image (push) Successful in 43s

- One worker per printer (name, IP, CC2 Control URL, own pinned host key);
  a spool sits in at most one slot of one printer, moves between printers
- Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot"
  (created by the bridge), cleared and "Lager" on removal
- Full comparison every minute: the printer wins over manual edits of loaded
  spools; stale printer locations go to storage; own locations untouched
- Automatic migration of v1 settings, state and host key
- Status hint when a tag is still in a tray but unmapped (e.g. after
  "Take out" in CC2 Control)
- SSH test: connection drops during exec give a clean error, not a 500
- Mock environment with several printers; tests for all of the above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 03:53:01 +01:00
1 parent bf32667f77
commit a9041eb538
15 files changed
+1400 -684

No files matched your search

+89 -51
View File
@@ -1,10 +1,10 @@
"""Local stand-in for the printer (SSH), CC2 Control and Spoolman, for manual end-to-end tests.
python tools/mock_env.py --host-header mock:8081
python tools/mock_env.py --host mock --printers 2
Ports: 2222 SSH (any key listed in /mock/authorized), 8081 CC2 Control, 7912 Spoolman, 8099 control.
Control endpoints (port 8099): POST /insert?slot=&spool=, /remove?slot=, /consume?slot=&grams=,
/job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
Per printer i: SSH 2222+i (keys added via POST /authorize), CC2 Control 8081+i. Spoolman 7912, control 8099.
Control endpoints (port 8099, optional &printer=i): POST /insert?slot=&spool=, /remove?slot=,
/consume?slot=&grams=, /job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
"""
from __future__ import annotations
@@ -25,17 +25,21 @@ from fastapi.responses import JSONResponse
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tests.fakes import channel, record # noqa: E402
STATE = {
"record": record(),
"enabled": True,
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
"spools": [],
"job": {"active": False, "file": "", "slots": []},
"spoolman": {},
"requests": [],
}
STATE["slots"][4]["printer"] = None
IDS = (f"m{i:04d}" for i in itertools.count(1))
def printer_state():
st = {
"record": record(),
"enabled": True,
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
"spools": [],
"job": {"active": False, "file": "", "slots": []},
"ids": (f"m{i:04d}" for i in itertools.count(1)),
}
st["slots"][4]["printer"] = None
return st
PRINTERS: list[dict] = []
STATE = {"spoolman": {}, "fields": {}, "requests": []}
LOCK = threading.Lock()
@@ -46,6 +50,7 @@ def seed_spoolman():
STATE["spoolman"][sid] = {
"id": sid, "lot_nr": f"{vendor.upper()}{sid:03d}", "remaining_weight": rem, "used_weight": 1000 - rem,
"initial_weight": 1000.0, "spool_weight": 160.0, "location": "Lager", "archived": False, "price": 19.9,
"extra": {},
"filament": {"id": sid, "name": f"{mat} {'Silk Gold' if sid == 14 else color}", "material": mat,
"color_hex": color, "diameter": 1.75, "density": 1.24, "weight": 1000.0,
"settings_extruder_temp": 215, "vendor": {"id": 1, "name": vendor}},
@@ -54,7 +59,7 @@ def seed_spoolman():
# ---------- CC2 Control ----------
def make_cc2(host_header: str) -> FastAPI:
def make_cc2(host_header: str, P: dict) -> FastAPI:
app = FastAPI()
@app.middleware("http")
@@ -85,13 +90,13 @@ def make_cc2(host_header: str) -> FastAPI:
def spools():
with LOCK:
STATE["requests"].append("GET /api/spools")
return {"available": True, "enabled": STATE["enabled"], "slots": STATE["slots"], "spools": STATE["spools"],
"job": STATE["job"], "log": []}
return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"],
"job": P["job"], "log": []}
@app.get("/api/canvas")
def canvas():
return {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [
{"tray_id": s["slot"], "status": s["printer"]["status"]} for s in STATE["slots"][:4]]}]}}}}
{"tray_id": s["slot"], "status": s["printer"]["status"]} for s in P["slots"][:4]]}]}}}}
@app.post("/api/spools/enable")
async def enable(request: Request):
@@ -99,9 +104,9 @@ def make_cc2(host_header: str) -> FastAPI:
if body not in (b"on", b"off"):
raise HTTPException(400, "Invalid switch")
with LOCK:
STATE["enabled"] = body == b"on"
P["enabled"] = body == b"on"
STATE["requests"].append(f"POST enable {body.decode()}")
return {"ok": True, "enabled": STATE["enabled"]}
return {"ok": True, "enabled": P["enabled"]}
@app.post("/api/spools/save")
async def save(request: Request):
@@ -109,7 +114,7 @@ def make_cc2(host_header: str) -> FastAPI:
with LOCK:
STATE["requests"].append("POST save " + json.dumps(f, ensure_ascii=False))
if f.get("id"):
sp = next((s for s in STATE["spools"] if s["id"] == f["id"]), None)
sp = next((s for s in P["spools"] if s["id"] == f["id"]), None)
if not sp:
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
if "remaining" in f:
@@ -122,12 +127,12 @@ def make_cc2(host_header: str) -> FastAPI:
return {"ok": True, "id": sp["id"]}
if not f.get("material") or not f.get("color"):
return JSONResponse({"ok": False, "error": "A spool needs a material and a colour"}, 400)
sp = {"id": next(IDS), "name": f.get("name", ""), "brand": f.get("brand", ""), "material": f["material"],
sp = {"id": next(P["ids"]), "name": f.get("name", ""), "brand": f.get("brand", ""), "material": f["material"],
"color": f["color"], "diameter": float(f.get("diameter", 1.75)), "density": float(f.get("density", 1.24)),
"net": float(f.get("net", 1000)), "remaining": float(f.get("remaining", f.get("net", 1000))),
"tare": float(f.get("tare", 0)), "low": 0.0, "price": float(f.get("price", 0)),
"note": f.get("note", ""), "created": 0, "used": 0, "archived": False}
STATE["spools"].append(sp)
P["spools"].append(sp)
return JSONResponse({"ok": True, "id": sp["id"]}, 201)
@app.post("/api/spools/assign")
@@ -136,11 +141,11 @@ def make_cc2(host_header: str) -> FastAPI:
with LOCK:
STATE["requests"].append("POST assign " + json.dumps(f))
slot = int(f["slot"])
for s in STATE["slots"]:
for s in P["slots"]:
if f["spool"] and s["spool"] == f["spool"]:
s["spool"] = ""
STATE["slots"][slot]["spool"] = f["spool"]
STATE["slots"][slot]["question"] = ""
P["slots"][slot]["spool"] = f["spool"]
P["slots"][slot]["question"] = ""
return {"ok": True}
return app
@@ -169,10 +174,30 @@ def make_spoolman() -> FastAPI:
body = await request.json()
with LOCK:
sp = get(sid)
for key, value in (body.get("extra") or {}).items():
if value is not None and key not in STATE["fields"]:
raise HTTPException(400, f"Unknown extra field {key}")
sp.update({k: v for k, v in body.items() if k in ("location",)})
for key, value in (body.get("extra") or {}).items():
if value is None:
sp["extra"].pop(key, None)
else:
sp["extra"][key] = value
STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}")
return sp
@app.get("/api/v1/field/spool")
def fields():
return [{"key": k, "entity_type": "spool", **v} for k, v in STATE["fields"].items()]
@app.post("/api/v1/field/spool/{key}")
async def add_field(key: str, request: Request):
body = await request.json()
with LOCK:
STATE["fields"][key] = body
STATE["requests"].append(f"POST field {key} {json.dumps(body, ensure_ascii=False)}")
return fields()
@app.put("/api/v1/spool/{sid}/use")
async def use(sid: int, request: Request):
body = await request.json()
@@ -193,43 +218,48 @@ def make_control() -> FastAPI:
@app.get("/state")
def state():
return STATE
return {**STATE, "printers": [{k: v for k, v in p.items() if k != "ids"} for p in PRINTERS]}
@app.post("/insert")
def insert(slot: int, spool: int, color: str = "0x000000"):
def insert(slot: int, spool: int, color: str = "0x000000", printer: int = 0):
P = PRINTERS[printer]
with LOCK:
STATE["slots"][slot]["printer"] = {"status": 1}
STATE["slots"][slot]["question"] = "inserted"
STATE["slots"][slot]["spool"] = ""
STATE["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
P["slots"][slot]["printer"] = {"status": 1}
P["slots"][slot]["question"] = "inserted"
P["slots"][slot]["spool"] = ""
P["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
return {"ok": True}
@app.post("/remove")
def remove(slot: int):
def remove(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
STATE["slots"][slot]["printer"] = {"status": 0}
STATE["record"][f"channels{slot}"] = channel("")
P["slots"][slot]["printer"] = {"status": 0}
P["record"][f"channels{slot}"] = channel("")
return {"ok": True}
@app.post("/touch")
def touch(slot: int):
def touch(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
STATE["record"][f"channels{slot}"]["weight"] = "1000"
STATE["slots"][slot]["spool"] = ""
STATE["slots"][slot]["question"] = "changed"
P["record"][f"channels{slot}"]["weight"] = "1000"
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = "changed"
return {"ok": True}
@app.post("/job")
def job(active: int):
def job(active: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
STATE["job"]["active"] = bool(active)
P["job"]["active"] = bool(active)
return {"ok": True}
@app.post("/consume")
def consume(slot: int, grams: float):
def consume(slot: int, grams: float, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
sid = STATE["slots"][slot]["spool"]
sp = next(s for s in STATE["spools"] if s["id"] == sid)
sid = P["slots"][slot]["spool"]
sp = next(s for s in P["spools"] if s["id"] == sid)
sp["remaining"] -= grams
return {"ok": True, "remaining": sp["remaining"]}
@@ -247,6 +277,9 @@ AUTHORIZED: set[str] = set()
class SSHServer(paramiko.ServerInterface):
def __init__(self, P: dict):
self.P = P
def check_channel_request(self, kind, chanid):
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
@@ -259,7 +292,7 @@ class SSHServer(paramiko.ServerInterface):
def check_channel_exec_request(self, channel_, command):
def reply(): # forced command, whatever was sent
with LOCK:
data = json.dumps(STATE["record"]).encode()
data = json.dumps(self.P["record"]).encode()
channel_.sendall(data)
channel_.send_exit_status(0)
channel_.close()
@@ -267,7 +300,7 @@ class SSHServer(paramiko.ServerInterface):
return True
def ssh_server(port: int):
def ssh_server(port: int, P: dict):
host_key = paramiko.Ed25519Key.generate() if hasattr(paramiko.Ed25519Key, "generate") else None
if host_key is None:
from io import StringIO
@@ -287,22 +320,27 @@ def ssh_server(port: int):
t = paramiko.Transport(conn)
t.add_server_key(host_key)
try:
t.start_server(server=SSHServer())
t.start_server(server=SSHServer(P))
except Exception:
pass
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host-header", default="127.0.0.1:8081")
ap.add_argument("--host", default="127.0.0.1", help="host part the CC2 Control guard expects")
ap.add_argument("--printers", type=int, default=1)
args = ap.parse_args()
seed_spoolman()
threading.Thread(target=ssh_server, args=(2222,), daemon=True).start()
servers = [
uvicorn.Server(uvicorn.Config(make_cc2(args.host_header), host="0.0.0.0", port=8081, log_level="warning")),
uvicorn.Server(uvicorn.Config(make_spoolman(), host="0.0.0.0", port=7912, log_level="warning")),
uvicorn.Server(uvicorn.Config(make_control(), host="0.0.0.0", port=8099, log_level="warning")),
]
for i in range(args.printers):
P = printer_state()
PRINTERS.append(P)
threading.Thread(target=ssh_server, args=(2222 + i, P), daemon=True).start()
servers.append(uvicorn.Server(uvicorn.Config(make_cc2(f"{args.host}:{8081 + i}", P), host="0.0.0.0",
port=8081 + i, log_level="warning")))
threads = [threading.Thread(target=s.run, daemon=True) for s in servers[1:]]
for t in threads:
t.start()