Multiple printers, printer/slot in Spoolman, kept in sync
- 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:
1 parent
bf32667f77
commit
a9041eb538
15 files changed
+1400
-684
No files matched your search
+48
-33
@@ -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 ----------
|
||||
|
||||
Reference in new issue
Block a user