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

+48 -33
View File
@@ -36,7 +36,7 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
keys = bridge.keys if bridge else KeyStore(data_dir)
bridge = bridge or Bridge(data_dir, settings, keys)
stop = threading.Event()
tested_host_keys: dict[str, object] = {} # fingerprint -> key from the last connection test
tested_host_keys: dict[str, dict] = {} # printer id -> host key from its last connection test
@asynccontextmanager
async def lifespan(app):
@@ -105,20 +105,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
def status():
return {**bridge.snapshot, "dry_run": settings.get().dry_run, "log": bridge.log.recent(200)}
@app.post("/api/cc2/enable")
async def cc2_enable(request: Request):
def printer_or_404(pid: str):
p = settings.get().printer(pid)
if p is None:
raise HTTPException(404, "Unbekannter Drucker")
return p
def close_readers():
for w in list(bridge.workers.values()):
w.reader.close()
@app.post("/api/printers/{pid}/cc2/enable")
async def cc2_enable(pid: str, request: Request):
printer_or_404(pid)
body = await _json(request)
if body.get("confirm") is not True:
raise HTTPException(400, "Bestätigung fehlt")
bridge.commands.put(("enable", None))
bridge.commands.put(("enable", pid, None))
bridge.wake.set()
return {"ok": True, "dry_run": settings.get().dry_run}
@app.post("/api/slots/{slot}/{action}")
def slot_action(slot: int, action: str):
@app.post("/api/printers/{pid}/slots/{slot}/{action}")
def slot_action(pid: str, slot: int, action: str):
printer_or_404(pid)
if slot not in range(4) or action not in ("reassign", "release"):
raise HTTPException(404)
bridge.commands.put((action, slot))
bridge.commands.put((action, pid, slot))
bridge.wake.set()
return {"ok": True}
@@ -131,11 +143,14 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
@app.post("/api/settings")
async def post_settings(request: Request):
body = await _json(request)
before = {p.id for p in settings.get().printers}
allowed = set(settings.get().to_dict())
try:
s = settings.save({k: v for k, v in body.items() if k in allowed})
except ValueError as e:
raise HTTPException(400, str(e)) from None
for pid in before - {p.id for p in s.printers}:
keys.forget_host_key(pid)
bridge.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
bridge.wake.set()
return s.to_dict()
@@ -144,7 +159,8 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
@app.get("/api/ssh")
def ssh_info():
return {"key": keys.key_info(), "host_key": keys.host_key()}
return {"key": keys.key_info(),
"host_keys": {p.id: keys.host_key(p.id) for p in settings.get().printers}}
@app.post("/api/ssh/key")
async def ssh_key(request: Request):
@@ -157,22 +173,20 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
except SSHError as e:
raise HTTPException(400, str(e)) from None
bridge.log.add("info", "SSH-Schlüssel gespeichert")
if bridge.reader:
bridge.reader.close()
close_readers()
return {"key": keys.key_info()}
@app.delete("/api/ssh/key")
def ssh_key_delete():
keys.delete_key()
if bridge.reader:
bridge.reader.close()
close_readers()
bridge.log.add("info", "SSH-Schlüssel gelöscht")
return {"key": None}
@app.post("/api/ssh/test")
def ssh_test():
s = settings.get()
reader = CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user)
@app.post("/api/printers/{pid}/ssh/test")
def ssh_test(pid: str):
p = printer_or_404(pid)
reader = CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user)
try:
result = reader.test()
except HostKeyMismatch as e:
@@ -182,31 +196,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
return {"ok": False, "error": str(e)}
hk = result["host_key"]
if not result["host_key_pinned"]:
tested_host_keys.clear()
tested_host_keys[hk["fingerprint"]] = hk
slots = tagcodec.parse_record(result["record"], s.id_offset)
tested_host_keys.pop(pid, None)
tested_host_keys[pid] = hk
slots = tagcodec.parse_record(result["record"], settings.get().id_offset)
return {"ok": True, "host_key": {"fingerprint": hk["fingerprint"], "type": hk["type"]},
"host_key_pinned": result["host_key_pinned"], "slots": slots}
@app.post("/api/ssh/hostkey")
async def ssh_hostkey(request: Request):
@app.post("/api/printers/{pid}/ssh/hostkey")
async def ssh_hostkey(pid: str, request: Request):
p = printer_or_404(pid)
body = await _json(request)
hk = tested_host_keys.get(body.get("fingerprint"))
if not hk:
hk = tested_host_keys.get(pid)
if not hk or hk["fingerprint"] != body.get("fingerprint"):
raise HTTPException(400, "Bitte zuerst „Verbindung testen“ und dann diesen Fingerprint bestätigen")
key = paramiko.PKey.from_type_string(hk["type"], base64.b64decode(hk["key"]))
keys.pin_host_key(settings.get().printer_host, key)
tested_host_keys.clear()
bridge.log.add("info", f"Host-Key des Druckers bestätigt ({hk['fingerprint']})")
keys.pin_host_key(pid, p.host, key)
tested_host_keys.pop(pid, None)
bridge.log.add("info", f"{p.name}: Host-Key bestätigt ({hk['fingerprint']})")
bridge.wake.set()
return {"host_key": keys.host_key()}
return {"host_key": keys.host_key(pid)}
@app.delete("/api/ssh/hostkey")
def ssh_hostkey_delete():
keys.forget_host_key()
if bridge.reader:
bridge.reader.close()
bridge.log.add("info", "Gespeicherter Host-Key des Druckers gelöscht")
@app.delete("/api/printers/{pid}/ssh/hostkey")
def ssh_hostkey_delete(pid: str):
p = printer_or_404(pid)
keys.forget_host_key(pid)
close_readers()
bridge.log.add("info", f"{p.name}: gespeicherter Host-Key gelöscht")
return {"host_key": None}
# ---------- tag generator ----------