diff --git a/.env.example b/.env.example index 0a9582a..475c0e6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ # Startwerte. Nach dem ersten Start gelten die Einstellungen aus der Oberfläche (/data/settings.json). +# Erster Drucker; weitere in der Oberfläche unter Einstellungen → Drucker. +PRINTER_NAME=Centauri Carbon 2 Rechts PRINTER_HOST=10.10.2.202 SSH_PORT=22 SSH_USER=root diff --git a/README.md b/README.md index e7c8d7d..45715f0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # cc2-spoolman-bridge -Bridge zwischen der Canvas des **Elegoo Centauri Carbon 2** (Community-Firmware, CC2 Control) und **Spoolman**, -mit Weboberfläche und **NFC-Tag-Generator**. +Bridge zwischen der Canvas eines oder mehrerer **Elegoo Centauri Carbon 2** (Community-Firmware, CC2 Control) und +**Spoolman**, mit Weboberfläche und **NFC-Tag-Generator**. -- Liegt eine Spule mit eigenem Tag in der Canvas, erkennt die Bridge die Spoolman-Spule am Tag, legt sie in - CC2 Control an (bzw. findet sie wieder), weist sie dem Slot zu und setzt in Spoolman den Ort („CC2 Slot 1–4“, - beim Entnehmen „Lager“). +- Liegt eine Spule mit eigenem Tag in einer Canvas, erkennt die Bridge die Spoolman-Spule am Tag, legt sie in der + CC2 Control dieses Druckers an (bzw. findet sie wieder) und weist sie dem Slot zu. +- In Spoolman steht dann, wo die Rolle liegt: Ort „ Slot N“ (z. B. „Centauri Carbon 2 Links Slot 3“) und + die Zusatzfelder **Drucker** und **Slot**. Entnommene Rollen kommen ins „Lager“, die Felder werden geleert. - Den von CC2 Control gemessenen Verbrauch bucht sie nach jedem Druck auf genau diese Spoolman-Spule. - Der Tag-Generator gibt für jede Spoolman-Spule die vollständige Befehlskette zum Beschreiben eines leeren NTAG213 aus. @@ -56,17 +57,23 @@ Oberfläche: `http://10.10.6.10:5066` ### 3. In der Oberfläche -1. *Einstellungen* prüfen (Drucker-IP, CC2-Control- und Spoolman-URL, ID-Offset 10000). -2. Privaten Schlüssel einfügen oder hochladen und speichern. Er liegt dann mit Rechten 600 in - `/data/ssh/id_key`; die Oberfläche zeigt nur Fingerprint und öffentlichen Schlüssel. -3. **Verbindung testen**: zeigt die 4 Slots und den Host-Key-Fingerprint des Druckers. Fingerprint vergleichen und - bestätigen. Danach wird er bei jeder Verbindung geprüft; ein geänderter Host-Key wird abgelehnt. +1. *Einstellungen* → **Drucker**: Name, IP, SSH-Port/-Benutzer und CC2-Control-URL je Drucker; „Drucker + hinzufügen“ für weitere. Speichern. Der Name erscheint so in Spoolman (Ort und Feld „Drucker“). +2. Privaten Schlüssel einfügen oder hochladen und speichern. Er gilt für alle Drucker (dieselbe eingeschränkte + `authorized_keys`-Zeile auf jedem Drucker) und liegt mit Rechten 600 in `/data/ssh/id_key`; die Oberfläche zeigt + nur Fingerprint und öffentlichen Schlüssel. +3. Je Drucker **Verbindung testen**: zeigt die 4 Slots und den Host-Key-Fingerprint. Vergleichen und bestätigen. + Danach wird er bei jeder Verbindung geprüft; ein geänderter Host-Key wird abgelehnt. 4. *Status* im Dry-Run beobachten. Wenn die „würde …“-Einträge stimmen, Dry-Run ausschalten. +Ein Update von v1 übernimmt Einstellungen, Zuordnungen, Buchungsstände und den Host-Key automatisch; der bisherige +Drucker heißt danach „CC2“ (Ort bleibt „CC2 Slot N“) und kann in den Einstellungen umbenannt werden – Ort und +Felder in Spoolman ziehen dann mit. + ### Umgebungsvariablen Siehe [`.env.example`](.env.example). `PRINTER_HOST`, `SSH_PORT`, `SSH_USER`, `CC2_URL`, `SPOOLMAN_URL`, -`ID_OFFSET`, `POLL_INTERVAL` und `DRY_RUN` sind nur Startwerte, bis die Einstellungen einmal in der Oberfläche +`ID_OFFSET`, `POLL_INTERVAL`, `DRY_RUN` und `PRINTER_NAME` (erster Drucker) sind nur Startwerte, bis die Einstellungen einmal in der Oberfläche gespeichert wurden (`/data/settings.json`). `BRIDGE_USER` und `BRIDGE_PASSWORD` schalten Basic-Auth ein (`/healthz` bleibt offen). `PORT` (Standard 5066), `LOG_LEVEL`. @@ -77,15 +84,27 @@ gespeichert wurden (`/data/settings.json`). `BRIDGE_USER` und `BRIDGE_PASSWORD` kleinere Werte sind echte Gewichte von Original- oder Fremdtags und werden ignoriert. Neu ist ein Tag, wenn sich die ID im Slot gegenüber dem letzten Datensatz ändert. -**Zuordnung.** Die Bridge speichert Slot ↔ Spule selbst (`/data/state.json`). Fällt `weight` nach einer -Bearbeitung am Display auf 1000 zurück, bleibt die Zuordnung. Aufgehoben wird sie nur, wenn +**Zuordnung.** Die Bridge speichert Drucker/Slot ↔ Spule selbst (`/data/state.json`). Eine Spule liegt höchstens +in einem Slot eines Druckers. Fällt `weight` nach einer Bearbeitung am Display auf 1000 zurück, bleibt die +Zuordnung. Aufgehoben wird sie nur, wenn - der Slot länger als 20 s leer ist (Tray-Status 0), -- CC2 Control die Spule ins Lager legt (Slot ohne Spule und ohne offene Rückfrage), +- CC2 Control die Spule herausnimmt („Take out“) oder ins Lager legt (Slot ohne Spule und ohne offene Rückfrage), - im Slot ein Tag mit einer anderen ID gescannt wird oder -- die Spule in einem anderen Slot gescannt wird (Umzug). +- die Spule in einem anderen Slot oder im anderen Drucker gescannt wird (Umzug). Weist du eine gespiegelte Spule in CC2 Control von Hand einem belegten Slot zu, übernimmt die Bridge das. +**„Take out“ in CC2 Control** zählt als Entnahme: Ort „Lager“, Felder leer. Bleibt die Rolle physisch im Tray, +rechnet CC2 Control ihren Verbrauch keiner Spule mehr zu; der Status zeigt dann „Tag steckt noch, ist aber keinem +Slot zugeordnet“. Zum erneuten Zuordnen die Rolle neu einlegen und den Scan bestätigen. + +**Spoolman synchron halten.** Nach jeder Änderung schreibt die Bridge Ort und Zusatzfelder (die Felder legt sie beim +ersten Mal selbst an). Zusätzlich vergleicht sie jede Minute alle Spoolman-Spulen: Geladene Rollen bekommen ihren +Ort/ihre Felder zurück, falls sie dort von Hand geändert wurden (der Drucker gewinnt). Rollen mit einem Druckerort +oder gesetztem Feld „Drucker“, die in keinem Slot liegen, kommen ins Lager. Eigene Orte wie „Regal 2“ bei nicht +geladenen Rollen bleiben unangetastet. Wird ein Drucker umbenannt oder entfernt, ziehen Ort und Felder mit bzw. seine +Rollen kommen ins Lager. + **CC2 Control.** Gespiegelte Spulen erkennt die Bridge an `note` = `spoolman:`. Fehlt die Spule, legt sie sie mit `name` = Label (`lot_nr`, sonst Hersteller + ID dreistellig), `brand`, `material`, `color`, `diameter`, `density`, `net`, `remaining`, `tare` und `price` aus Spoolman an und weist sie dem Slot zu. Damit ist auch die @@ -131,11 +150,13 @@ Optional kann statt der Elegoo-URL ein Link auf die Spule in Spoolman auf den Ta ```bash docker build --target test . # Tests (pytest) im Container docker build -t cc2-spoolman-bridge:dev . # Image -python tools/mock_env.py --host-header mock:8081 # Mock für Drucker (SSH :2222), CC2 Control, Spoolman +python tools/mock_env.py --host mock --printers 2 # Mock: 2 Drucker (SSH :2222/2223, CC2 :8081/8082), Spoolman ``` Die Tests decken ab: Tag-Kodierung gegen die verifizierte Vorlage, ID-Dekodierung, Palette und Materialvorschlag, -Zuordnung bei verschwindender ID, Verbrauchs-Delta und Idempotenz (auch nach Abbruch), Dry-Run, Request-Format für +Zuordnung bei verschwindender ID, Verbrauchs-Delta und Idempotenz (auch nach Abbruch), Dry-Run, mehrere Drucker +(Umzug, Ausfall eines Druckers, Entfernen, Umbenennen), Spoolman-Ort und Zusatzfelder inkl. Korrektur von Hand- +Änderungen, Migration von v1, Request-Format für CC2 Control (Host-Header, kein Origin, `X-CC2-Request`, `key=value`-Body, `enable` als nacktes `on`/`off`) und SSH mit Host-Key-Prüfung gegen einen lokalen SSH-Server. diff --git a/app/bridge.py b/app/bridge.py index 7b0726a..682702a 100644 --- a/app/bridge.py +++ b/app/bridge.py @@ -5,6 +5,9 @@ Rules: - Writes go to CC2 Control's spool endpoints and to Spoolman only, and only when dry-run is off. - Slot <-> spool mappings are kept by the bridge; a tag id vanishing from the record (weight falls back to 1000 after a touchscreen edit) does not drop a mapping. +- Several printers: one worker each; a spool is mapped to at most one slot of one printer. +- Spoolman shows where a loaded spool is (location " Slot N", extra fields + "drucker"/"slot"); the printer wins over manual edits there. """ from __future__ import annotations @@ -19,16 +22,18 @@ from pathlib import Path from . import tagcodec from .cc2control import CC2Control, CC2Error, slot_spool_id, spoolman_id_of, tray_statuses -from .spoolman import Spoolman, SpoolmanError, SpoolmanNotFound, color_hex, label, summary, vendor_name +from .spoolman import (FIELD_DEFS, FIELD_PRINTER, FIELD_SLOT, Spoolman, SpoolmanError, SpoolmanNotFound, color_hex, + extra_value, label, summary, vendor_name) from .sshsource import CanvasRecordReader, KeyStore, SSHError -from .storage import Settings, SettingsStore, StateStore +from .storage import PrinterConfig, Settings, SettingsStore, StateStore log = logging.getLogger("bridge") MIN_BOOKING = 0.05 # grams; smaller changes accumulate until they reach this SPOOL_CACHE_SECONDS = 300 +SYNC_SECONDS = 60 # full Spoolman comparison (locations, extra fields) LOG_KEEP = 1000 -SOURCE_NAMES = {"ssh": "Drucker (SSH)", "cc2": "CC2 Control", "spoolman": "Spoolman"} +SOURCE_NAMES = {"ssh": "SSH", "cc2": "CC2 Control"} class ActionLog: @@ -125,6 +130,355 @@ def cc2_spool_fields(sm: dict, channel: dict | None) -> tuple[dict, list[str]]: return fields, notes +class PrinterWorker: + """One printer: its SSH reader, its CC2 Control and its part of the state.""" + + def __init__(self, bridge: "Bridge", cfg: PrinterConfig): + self.bridge = bridge + self.cfg = cfg + self.reader = bridge.reader_factory(cfg) + self.cc2 = bridge.cc2_factory(cfg) + self.errors: dict[str, str | None] = {"ssh": None, "cc2": None} + self.empty_since: dict[int, float] = {} + self.remaining_seen: dict[str, tuple[float, float]] = {} # cc2 id -> (value, since) + self.record_slots: list[dict] = [] + self.tray_status: dict[int, int] = {} + self.record: dict | None = None + self.cc2_data: dict | None = None + + @property + def id(self) -> str: + return self.cfg.id + + @property + def name(self) -> str: + return self.cfg.name + + @property + def st(self) -> dict: + return self.bridge.state.printer(self.cfg.id) + + @property + def settings(self) -> Settings: + return self.bridge.settings + + def close(self) -> None: + for c in (self.reader, self.cc2): + try: + c.close() + except Exception: + pass + + def _log(self, level: str, msg: str) -> None: + self.bridge.log.add(level, f"{self.name}: {msg}") + + def _write(self, key: str, msg: str, fn): + return self.bridge._write(f"{self.id}:{key}", f"{self.name}: {msg}", fn) + + def _error(self, source: str, msg: str | None) -> None: + if msg != self.errors.get(source): + if msg: + self._log("error", msg) + elif self.errors.get(source): + self._log("info", f"{SOURCE_NAMES[source]} wieder erreichbar") + self.errors[source] = msg + + # ---------- one cycle ---------- + + def poll(self) -> None: + self.record = self.cc2_data = None + try: + self.record = self.reader.read() + self._error("ssh", None) + except SSHError as e: + self._error("ssh", str(e)) + try: + self.cc2_data = self.cc2.spools() + self._error("cc2", None) + except CC2Error as e: + self._error("cc2", str(e)) + if self.record is not None: + self._process_record(self.record) + self._process_scans() + cc2 = self.cc2_data + if cc2 is not None: + self._process_trays(cc2) + self._process_cc2_slots(cc2) + self._reconcile_cc2(cc2) + self._consumption(cc2) + + # ---------- record / scans ---------- + + def _process_record(self, record: dict) -> None: + offset = self.settings.id_offset + slots = tagcodec.parse_record(record, offset) + prev_record = self.st.get("last_record") + prev = tagcodec.parse_record(prev_record, offset) if prev_record else None + mappings = self.st["slots"] + for s in slots: + n, tid = s["slot"], s["spool_id"] + if tid is None: + continue + if prev is not None: + is_new = tid != prev[n]["spool_id"] + else: + m = mappings.get(str(n)) + is_new = m is None or m["spool"] != tid + if is_new: + self.st["scans"].append({"slot": n, "spool": tid, "time": self.bridge.clock()}) + self._log("info", f"Slot {n + 1}: Tag mit Spoolman-ID {tid} erkannt") + self.st["last_record"] = record + self.record_slots = slots + + def _process_scans(self) -> None: + pending = [] + for scan in self.st["scans"]: + n, tid = scan["slot"], scan["spool"] + try: + sm = self.bridge.spoolman.spool(tid) + self.bridge._spoolman_error(None) + except SpoolmanNotFound: + self._log("warn", f"Slot {n + 1}: Spoolman-Spule {tid} existiert nicht; nichts geändert") + continue + except SpoolmanError as e: + self.bridge._spoolman_error(str(e)) + pending.append(scan) # retry next cycle + continue + self.bridge.spool_cache[tid] = (self.bridge.clock(), sm) + self.bridge.map(self, n, tid, label(sm), reason="Tag gescannt") + self.st["scans"] = pending + + # ---------- CC2 Control ---------- + + def _process_trays(self, cc2: dict) -> None: + statuses = {} + for slot in cc2.get("slots") or []: + printer = slot.get("printer") + if isinstance(printer, dict) and slot.get("slot") in (0, 1, 2, 3): + try: + statuses[slot["slot"]] = int(printer.get("status")) + except (TypeError, ValueError): + pass + if not statuses: + try: + statuses = tray_statuses(self.cc2.canvas()) + except CC2Error: + statuses = {} + self.tray_status = statuses + now = self.bridge.clock() + for n in range(4): + st = statuses.get(n) + if st is None: + continue + if st == 0: + since = self.empty_since.setdefault(n, now) + if now - since >= self.settings.empty_grace and str(n) in self.st["slots"]: + self.bridge.release(self, n, f"Slot seit {int(now - since)} s leer") + else: + self.empty_since.pop(n, None) + + def _process_cc2_slots(self, cc2: dict) -> None: + by_id = {str(s.get("id")): s for s in cc2.get("spools") or []} + mappings = self.st["slots"] + for slot in cc2.get("slots") or []: + n = slot.get("slot") + if n not in (0, 1, 2, 3): + continue + sid = slot_spool_id(slot) + m = mappings.get(str(n)) + mirrored = spoolman_id_of(by_id[sid]) if sid in by_id else None + # A spool we already map elsewhere (fresh scan, maybe in the other printer) wins + # over this CC2 Control's older binding. + elsewhere = mirrored is not None and self.bridge.mapped_elsewhere(mirrored, self.id, n) + if m is None: + if mirrored is not None and not elsewhere and self.tray_status.get(n) in (1, 2): + # assigned in CC2 Control by hand + self.bridge.map(self, n, mirrored, by_id[sid].get("name") or str(mirrored), + "in CC2 Control zugewiesen", sid) + continue + if m.get("cc2") and sid == m["cc2"]: + m["cc2_seen"] = m["assigned"] = True + continue + if not m.get("cc2_seen"): + continue + if sid: + if elsewhere: + continue + if mirrored is not None and mirrored != m["spool"]: + self.bridge.map(self, n, mirrored, by_id[sid].get("name") or str(mirrored), + "in CC2 Control zugewiesen", sid) + elif mirrored is None: + self.bridge.release(self, n, "in CC2 Control mit anderer Spule belegt") + elif not slot.get("question"): + self.bridge.release(self, n, "in CC2 Control herausgenommen bzw. ins Lager gelegt") + + def _reconcile_cc2(self, cc2: dict) -> None: + mirrors: dict[int, dict] = {} + for sp in cc2.get("spools") or []: + smid = spoolman_id_of(sp) + if smid is not None and (smid not in mirrors or mirrors[smid].get("archived")): + mirrors[smid] = sp + slots = {s.get("slot"): s for s in cc2.get("slots") or []} + record_slots = {s["slot"]: s for s in self.record_slots} + for k, m in list(self.st["slots"].items()): + n = int(k) + if m.get("assigned"): + continue + try: + mirror = mirrors.get(m["spool"]) + if mirror is None: + sm = self.bridge.spool(m["spool"]) + if sm is None: + continue + channel = (record_slots.get(n) or {}).get("channel") + fields, notes = cc2_spool_fields(sm, channel) + for note in notes: + self._log("warn", f"{fields['name']}: {note}") + resp, done = self._write( + f"create:{m['spool']}", f"in CC2 Control Spule {fields['name']} anlegen " + f"({fields['material']}, {fields['color']}, Rest {fields.get('remaining', '?')} g)", + lambda: self.cc2.save_spool(fields)) + if not done: + self._write(f"assign:{n}", f"in CC2 Control {fields['name']} in Slot {n + 1} legen", lambda: None) + continue + cc2_id = str((resp or {}).get("id") or "") + if not cc2_id: + self._log("error", "CC2 Control hat keine Spulen-ID zurückgegeben") + continue + self.st["booked"][cc2_id] = { + "spoolman": m["spool"], "remaining": float(fields.get("remaining", fields.get("net", 1000))), + "pending": None, "last": None, + } + m["cc2"] = cc2_id + else: + m["cc2"] = str(mirror["id"]) + if mirror.get("archived"): + _, done = self._write(f"unarchive:{m['cc2']}", f"in CC2 Control {mirror.get('name')} aus dem Archiv holen", + lambda: self.cc2.save_spool({"id": m["cc2"], "archived": "0"})) + if not done: + continue + if slot_spool_id(slots.get(n) or {}) == m["cc2"]: + m["assigned"] = m["cc2_seen"] = True + continue + _, done = self._write(f"assign:{n}", f"in CC2 Control {m.get('name')} in Slot {n + 1} legen", + lambda: self.cc2.assign(n, m["cc2"])) + if done: + m["assigned"] = m["cc2_seen"] = True + except CC2Error as e: + self._log("error", f"Slot {n + 1}: {e}") + + # ---------- consumption ---------- + + def _consumption(self, cc2: dict) -> None: + job_active = bool((cc2.get("job") or {}).get("active")) + booked = self.st["booked"] + now = self.bridge.clock() + for sp in cc2.get("spools") or []: + smid = spoolman_id_of(sp) + if smid is None: + continue + cid = str(sp.get("id")) + try: + rem = float(sp.get("remaining")) + except (TypeError, ValueError): + continue + seen = self.remaining_seen.get(cid) + if seen is None or abs(seen[0] - rem) > 1e-6: + self.remaining_seen[cid] = (rem, now) + b = booked.get(cid) + if b is None or b.get("spoolman") != smid: + booked[cid] = {"spoolman": smid, "remaining": rem, "pending": None, "last": None} + self._log("info", f"{sp.get('name')}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})") + continue + try: + if b.get("pending") and not self._resolve_pending(cid, b): + continue + delta = b["remaining"] - rem + if delta < -MIN_BOOKING: + self._log("warn", f"{sp.get('name')}: Rest in CC2 Control um {-delta:.2f} g gestiegen; " + "nicht gebucht, neuer Ausgangsstand") + b["remaining"] = rem + continue + if delta < MIN_BOOKING or job_active: + continue + if now - self.remaining_seen[cid][1] < self.settings.settle_seconds: + continue + self._book(cid, b, sp.get("name") or cid, smid, delta, rem) + except SpoolmanNotFound: + self.bridge._once_error(f"{self.id}:book:{cid}", + f"{self.name}: {sp.get('name')}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht") + except SpoolmanError as e: + self.bridge._spoolman_error(str(e)) + + def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float) -> None: + if self.settings.dry_run: + self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen", lambda: None) + return + before = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0) + b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.bridge.clock()} + self.bridge.state.save() # intent first: a crash after the PUT must not book twice + self.bridge.spoolman.use(smid, delta) + self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht") + + def _booked(self, b: dict, grams: float, target: float, msg: str) -> None: + b["remaining"] = target + b["pending"] = None + b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3)} + self.bridge.state.save() + self._log("info", msg) + + def _resolve_pending(self, cid: str, b: dict) -> bool: + """A booking was interrupted: check whether Spoolman already has it.""" + p = b["pending"] + smid = b["spoolman"] + used = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0) + if used >= p["before"] + p["grams"] - 0.01: + self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman") + return True + if self.settings.dry_run: + self._write(f"book:{cid}", f"unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} wiederholen", + lambda: None) + return False + self.bridge.spoolman.use(smid, p["grams"]) + self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt") + return True + + # ---------- UI ---------- + + def snapshot(self) -> dict: + cc2 = self.cc2_data + record_slots = tagcodec.parse_record(self.record or self.st.get("last_record") or {}, self.settings.id_offset) + cc2_slots = {s.get("slot"): s for s in (cc2 or {}).get("slots") or []} + cc2_spools = {str(s.get("id")): s for s in (cc2 or {}).get("spools") or []} + slots = [] + for n in range(4): + m = self.st["slots"].get(str(n)) + cs = cc2_slots.get(n) or {} + sid = slot_spool_id(cs) + cached = self.bridge.spool_cache.get(m["spool"]) if m else None + b = self.st["booked"].get(m["cc2"]) if m and m.get("cc2") else None + slots.append({ + "slot": n, + "record": record_slots[n], + "tray_status": self.tray_status.get(n), + "mapping": m, + "location": self.settings.slot_location(self.cfg, n), + "spoolman": summary(cached[1]) if cached else None, + "cc2_spool": cc2_spools.get(sid) if sid else None, + "cc2_question": cs.get("question") or "", + "cc2_matches": bool(m and m.get("cc2") and sid == m["cc2"]), + "last_booking": (b or {}).get("last"), + }) + return { + "id": self.id, "name": self.name, "enabled": self.cfg.enabled, "host": self.cfg.host, + "errors": dict(self.errors), + "cc2_enabled": None if cc2 is None else bool(cc2.get("enabled")), + "job_active": None if cc2 is None else bool((cc2.get("job") or {}).get("active")), + "host_key": self.bridge.keys.host_key(self.id) is not None, + "slots": slots, + } + + class Bridge: def __init__(self, data_dir: Path, settings: SettingsStore, keys: KeyStore, *, reader_factory=None, cc2_factory=None, spoolman_factory=None, clock=time.time): @@ -132,52 +486,68 @@ class Bridge: self.settings_store = settings self.keys = keys self.clock = clock - self.state = StateStore(data_dir) + self.settings: Settings = settings.get() + first = self.settings.printers[0].id if self.settings.printers else "p1" + keys.migrate_host_key(first) + self.state = StateStore(data_dir, first) self.log = ActionLog(data_dir, clock) self.reader_factory = reader_factory or ( - lambda s: CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user)) - self.cc2_factory = cc2_factory or (lambda s: CC2Control(s.cc2_url)) + lambda p: CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user)) + self.cc2_factory = cc2_factory or (lambda p: CC2Control(p.cc2_url)) self.spoolman_factory = spoolman_factory or (lambda s: Spoolman(s.spoolman_url)) - self._clients_for: dict | None = None - self.reader = self.cc2 = self.spoolman = None + self.workers: dict[str, PrinterWorker] = {} + self._worker_cfg: dict[str, dict] = {} + self.spoolman = None + self._spoolman_url: str | None = None + self.spoolman_err: str | None = None + self.fields_ok = False + self.last_sync = 0.0 self.lock = threading.RLock() self.commands: queue.Queue = queue.Queue() self.wake = threading.Event() - self.errors: dict[str, str | None] = {"ssh": None, "cc2": None, "spoolman": None} - self.empty_since: dict[int, float] = {} - self.remaining_seen: dict[str, tuple[float, float]] = {} # cc2 id -> (value, since) self.dry_logged: dict[str, str] = {} self.spool_cache: dict[int, tuple[float, dict]] = {} - self.record_slots: list[dict] = [] - self.tray_status: dict[int, int] = {} - self.snapshot: dict = {"last_poll": None} - self.settings: Settings = settings.get() + self.snapshot: dict = {"last_poll": None, "printers": []} # ---------- infrastructure ---------- - def _clients(self) -> None: + def _sync_workers(self) -> None: s = self.settings - key = {k: getattr(s, k) for k in ("printer_host", "ssh_port", "ssh_user", "cc2_url", "spoolman_url")} - if key == self._clients_for: - return - for c in (self.reader, self.cc2, self.spoolman): - if c is not None: - try: - c.close() - except Exception: - pass - self.reader = self.reader_factory(s) - self.cc2 = self.cc2_factory(s) - self.spoolman = self.spoolman_factory(s) - self._clients_for = key + if s.spoolman_url != self._spoolman_url: + if self.spoolman is not None: + self.spoolman.close() + self.spoolman = self.spoolman_factory(s) + self._spoolman_url = s.spoolman_url + self.fields_ok = False + wanted = {p.id: p for p in s.printers} + for pid in list(self.workers): + if pid not in wanted or self._worker_cfg[pid] != wanted[pid].to_dict(): + self.workers.pop(pid).close() + self._worker_cfg.pop(pid) + for pid, cfg in wanted.items(): + if pid not in self.workers: + self.workers[pid] = PrinterWorker(self, cfg) + self._worker_cfg[pid] = cfg.to_dict() + else: + self.workers[pid].cfg = cfg + for pid in list(self.state.data["printers"]): + if pid not in wanted: + st = self.state.data["printers"].pop(pid) + if st.get("slots"): + self.log.add("info", f"Drucker {pid} entfernt; seine {len(st['slots'])} Spule(n) gehen ins Lager") - def _error(self, source: str, msg: str | None) -> None: - if msg != self.errors.get(source): + def _spoolman_error(self, msg: str | None) -> None: + if msg != self.spoolman_err: if msg: self.log.add("error", msg) - elif self.errors.get(source): - self.log.add("info", f"{SOURCE_NAMES[source]} wieder erreichbar") - self.errors[source] = msg + elif self.spoolman_err: + self.log.add("info", "Spoolman wieder erreichbar") + self.spoolman_err = msg + + def _once_error(self, key: str, msg: str) -> None: + if self.dry_logged.get(key) != msg: + self.dry_logged[key] = msg + self.log.add("error", msg) def _write(self, key: str, msg: str, fn): """Run a write, or in dry-run only log it once per key/message.""" @@ -208,362 +578,222 @@ class Bridge: def poll_once(self) -> None: with self.lock: self.settings = self.settings_store.get() - self._clients() + self._sync_workers() self._run_commands() - record = cc2 = None - try: - record = self.reader.read() - self._error("ssh", None) - except SSHError as e: - self._error("ssh", str(e)) - try: - cc2 = self.cc2.spools() - self._error("cc2", None) - except CC2Error as e: - self._error("cc2", str(e)) - - if record is not None: - self._process_record(record) - self._process_scans() - if cc2 is not None: - self._process_trays(cc2) - self._process_cc2_slots(cc2) - self._reconcile_cc2(cc2) - self._consumption(cc2) + for w in self.workers.values(): + if w.cfg.enabled: + try: + w.poll() + except Exception: + log.exception("printer %s failed", w.id) + w._log("error", "Interner Fehler (Details im Container-Log)") self._reconcile_locations() self._refresh_spool_cache() self.state.save() - self._build_snapshot(record, cc2) + self.snapshot = { + "last_poll": self.clock(), + "dry_run": self.settings.dry_run, + "spoolman_error": self.spoolman_err, + "ssh_key": self.keys.has_key(), + "printers": [w.snapshot() for w in self.workers.values()], + } def _run_commands(self) -> None: while True: try: - cmd, arg = self.commands.get_nowait() + cmd, pid, arg = self.commands.get_nowait() except queue.Empty: return + w = self.workers.get(pid) + if w is None: + continue try: if cmd == "enable": - self._write("enable", "Spulenverwaltung in CC2 Control einschalten", lambda: self.cc2.enable(True)) + w._write("enable", "Spulenverwaltung in CC2 Control einschalten", lambda: w.cc2.enable(True)) elif cmd == "reassign": - m = self.state.data["slots"].get(str(arg)) + m = w.st["slots"].get(str(arg)) if m: m["assigned"] = False - self.dry_logged.pop(f"assign:{arg}", None) - self.log.add("info", f"Slot {arg + 1}: Zuordnung wird erneut an CC2 Control gesendet") + self.dry_logged.pop(f"{pid}:assign:{arg}", None) + w._log("info", f"Slot {arg + 1}: Zuordnung wird erneut an CC2 Control gesendet") elif cmd == "release": - if str(arg) in self.state.data["slots"]: - self._release(arg, "manuell aufgehoben") + if str(arg) in w.st["slots"]: + self.release(w, arg, "manuell aufgehoben") except (CC2Error, SpoolmanError) as e: - self.log.add("error", str(e)) + w._log("error", str(e)) - # ---------- record / scans ---------- + # ---------- mappings across printers ---------- - def _process_record(self, record: dict) -> None: - offset = self.settings.id_offset - slots = tagcodec.parse_record(record, offset) - prev_record = self.state.data.get("last_record") - prev = tagcodec.parse_record(prev_record, offset) if prev_record else None - mappings = self.state.data["slots"] - for s in slots: - n, tid = s["slot"], s["spool_id"] - if tid is None: - continue - if prev is not None: - is_new = tid != prev[n]["spool_id"] - else: - m = mappings.get(str(n)) - is_new = m is None or m["spool"] != tid - if is_new: - self.state.data.setdefault("scans", []).append({"slot": n, "spool": tid, "time": self.clock()}) - self.log.add("info", f"Slot {n + 1}: Tag mit Spoolman-ID {tid} erkannt") - self.state.data["last_record"] = record - self.record_slots = slots + def mappings(self): + for pid, st in self.state.data["printers"].items(): + for k, m in st.get("slots", {}).items(): + yield pid, int(k), m - def _process_scans(self) -> None: - pending = [] - for scan in self.state.data.get("scans", []): - n, tid = scan["slot"], scan["spool"] - try: - sm = self.spoolman.spool(tid) - self._error("spoolman", None) - except SpoolmanNotFound: - self.log.add("warn", f"Slot {n + 1}: Spoolman-Spule {tid} existiert nicht; nichts geändert") - continue - except SpoolmanError as e: - self._error("spoolman", str(e)) - pending.append(scan) # retry next cycle - continue - self.spool_cache[tid] = (self.clock(), sm) - self._map(n, tid, label(sm), reason="Tag gescannt") - self.state.data["scans"] = pending + def mapped_elsewhere(self, spool_id: int, pid: str, n: int) -> bool: + return any(m["spool"] == spool_id and (p, k) != (pid, n) for p, k, m in self.mappings()) - def _map(self, n: int, tid: int, name: str, reason: str, cc2_id: str | None = None) -> None: - mappings = self.state.data["slots"] + def map(self, w: PrinterWorker, n: int, tid: int, name: str, reason: str, cc2_id: str | None = None) -> None: + mappings = w.st["slots"] cur = mappings.get(str(n)) if cur and cur["spool"] == tid: if cc2_id is None: cur["assigned"] = False # rescan: make sure CC2 Control has it in the slot - self.dry_logged.pop(f"assign:{n}", None) - self._want_location(tid, self.settings.slot_location(n)) + self.dry_logged.pop(f"{w.id}:assign:{n}", None) return - for k, m in list(mappings.items()): - if k != str(n) and m["spool"] == tid: - del mappings[k] - self.log.add("info", f"Spule {name} von Slot {int(k) + 1} nach Slot {n + 1} umgezogen") + for pid, k, m in list(self.mappings()): + if m["spool"] == tid and (pid, k) != (w.id, n): + del self.state.data["printers"][pid]["slots"][str(k)] + other = self.workers.get(pid) + where = f"Slot {k + 1}" if pid == w.id else f"{other.name if other else pid} Slot {k + 1}" + w._log("info", f"Spule {name} von {where} nach Slot {n + 1} umgezogen") if cur: - self._release(n, f"andere Spule ({name})") + self.release(w, n, f"andere Spule ({name})") mappings[str(n)] = { "spool": tid, "name": name, "since": self.clock(), "cc2": cc2_id, "assigned": cc2_id is not None, "cc2_seen": cc2_id is not None, } - self.empty_since.pop(n, None) - self.log.add("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})") - self._want_location(tid, self.settings.slot_location(n)) + w.empty_since.pop(n, None) + w._log("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})") - def _release(self, n: int, reason: str) -> None: - m = self.state.data["slots"].pop(str(n), None) + def release(self, w: PrinterWorker, n: int, reason: str) -> None: + m = w.st["slots"].pop(str(n), None) if not m: return - self.dry_logged.pop(f"assign:{n}", None) - self.log.add("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})") - if not any(o["spool"] == m["spool"] for o in self.state.data["slots"].values()): - self._want_location(m["spool"], self.settings.location_storage) + self.dry_logged.pop(f"{w.id}:assign:{n}", None) + w._log("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})") - # ---------- CC2 Control ---------- + # ---------- Spoolman locations and extra fields ---------- - def _process_trays(self, cc2: dict) -> None: - statuses = {} - for slot in cc2.get("slots") or []: - printer = slot.get("printer") - if isinstance(printer, dict) and slot.get("slot") in (0, 1, 2, 3): - try: - statuses[slot["slot"]] = int(printer.get("status")) - except (TypeError, ValueError): - pass - if not statuses: - try: - statuses = tray_statuses(self.cc2.canvas()) - except CC2Error: - statuses = {} - self.tray_status = statuses - now = self.clock() - for n in range(4): - st = statuses.get(n) - if st is None: + def _desired(self) -> dict[int, dict]: + out = {} + for pid, n, m in self.mappings(): + w = self.workers.get(pid) + if w is None: continue - if st == 0: - since = self.empty_since.setdefault(n, now) - if now - since >= self.settings.empty_grace and str(n) in self.state.data["slots"]: - self._release(n, f"Slot seit {int(now - since)} s leer") - else: - self.empty_since.pop(n, None) + out[m["spool"]] = {"location": self.settings.slot_location(w.cfg, n), "printer": w.name, "slot": n + 1} + return out - def _process_cc2_slots(self, cc2: dict) -> None: - by_id = {str(s.get("id")): s for s in cc2.get("spools") or []} - mappings = self.state.data["slots"] - for slot in cc2.get("slots") or []: - n = slot.get("slot") - if n not in (0, 1, 2, 3): - continue - sid = slot_spool_id(slot) - m = mappings.get(str(n)) - mirrored = spoolman_id_of(by_id[sid]) if sid in by_id else None - # A spool we already map to another slot (fresh scan) wins over CC2 Control's older binding. - elsewhere = mirrored is not None and any( - o["spool"] == mirrored for k, o in mappings.items() if k != str(n)) - if m is None: - if mirrored is not None and not elsewhere and self.tray_status.get(n) in (1, 2): - # assigned in CC2 Control by hand - self._map(n, mirrored, by_id[sid].get("name") or str(mirrored), "in CC2 Control zugewiesen", sid) - continue - if m.get("cc2") and sid == m["cc2"]: - m["cc2_seen"] = m["assigned"] = True - continue - if not m.get("cc2_seen"): - continue - if sid: - if elsewhere: - continue - if mirrored is not None and mirrored != m["spool"]: - self._map(n, mirrored, by_id[sid].get("name") or str(mirrored), "in CC2 Control zugewiesen", sid) - elif mirrored is None: - self._release(n, "in CC2 Control mit anderer Spule belegt") - elif not slot.get("question"): - self._release(n, "CC2 Control hat die Spule ins Lager gelegt") + def _storage(self) -> dict: + return {"location": self.settings.location_storage, "printer": None, "slot": None} - def _reconcile_cc2(self, cc2: dict) -> None: - mirrors: dict[int, dict] = {} - for sp in cc2.get("spools") or []: - smid = spoolman_id_of(sp) - if smid is not None and (smid not in mirrors or mirrors[smid].get("archived")): - mirrors[smid] = sp - slots = {s.get("slot"): s for s in cc2.get("slots") or []} - record_slots = {s["slot"]: s for s in self.record_slots} - for k, m in list(self.state.data["slots"].items()): - n = int(k) - if m.get("assigned"): - continue - try: - mirror = mirrors.get(m["spool"]) - if mirror is None: - sm = self._spool(m["spool"]) - if sm is None: - continue - channel = (record_slots.get(n) or {}).get("channel") - fields, notes = cc2_spool_fields(sm, channel) - for note in notes: - self.log.add("warn", f"{fields['name']}: {note}") - resp, done = self._write( - f"create:{m['spool']}", f"in CC2 Control Spule {fields['name']} anlegen " - f"({fields['material']}, {fields['color']}, Rest {fields.get('remaining', '?')} g)", - lambda: self.cc2.save_spool(fields)) - if not done: - self._write(f"assign:{n}", f"in CC2 Control {fields['name']} in Slot {n + 1} legen", lambda: None) - continue - cc2_id = str((resp or {}).get("id") or "") - if not cc2_id: - self.log.add("error", "CC2 Control hat keine Spulen-ID zurückgegeben") - continue - self.state.data["booked"][cc2_id] = { - "spoolman": m["spool"], "remaining": float(fields.get("remaining", fields.get("net", 1000))), - "pending": None, "last": None, - } - m["cc2"] = cc2_id - else: - m["cc2"] = str(mirror["id"]) - if mirror.get("archived"): - _, done = self._write(f"unarchive:{m['cc2']}", f"in CC2 Control {mirror.get('name')} aus dem Archiv holen", - lambda: self.cc2.save_spool({"id": m["cc2"], "archived": "0"})) - if not done: - continue - if slot_spool_id(slots.get(n) or {}) == m["cc2"]: - m["assigned"] = m["cc2_seen"] = True - continue - _, done = self._write(f"assign:{n}", f"in CC2 Control {m.get('name')} in Slot {n + 1} legen", - lambda: self.cc2.assign(n, m["cc2"])) - if done: - m["assigned"] = m["cc2_seen"] = True - except CC2Error as e: - self.log.add("error", f"Slot {n + 1}: {e}") + def _actual(self, sp: dict) -> dict: + return {"location": sp.get("location") or "", "printer": extra_value(sp, FIELD_PRINTER), + "slot": extra_value(sp, FIELD_SLOT)} - # ---------- consumption ---------- - - def _consumption(self, cc2: dict) -> None: - job_active = bool((cc2.get("job") or {}).get("active")) - booked = self.state.data["booked"] - now = self.clock() - for sp in cc2.get("spools") or []: - smid = spoolman_id_of(sp) - if smid is None: - continue - cid = str(sp.get("id")) - try: - rem = float(sp.get("remaining")) - except (TypeError, ValueError): - continue - seen = self.remaining_seen.get(cid) - if seen is None or abs(seen[0] - rem) > 1e-6: - self.remaining_seen[cid] = (rem, now) - b = booked.get(cid) - if b is None or b.get("spoolman") != smid: - booked[cid] = {"spoolman": smid, "remaining": rem, "pending": None, "last": None} - self.log.add("info", f"{sp.get('name')}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})") - continue - try: - if b.get("pending") and not self._resolve_pending(cid, b): - continue - delta = b["remaining"] - rem - if delta < -MIN_BOOKING: - self.log.add("warn", f"{sp.get('name')}: Rest in CC2 Control um {-delta:.2f} g gestiegen; " - "nicht gebucht, neuer Ausgangsstand") - b["remaining"] = rem - continue - if delta < MIN_BOOKING or job_active: - continue - if now - self.remaining_seen[cid][1] < self.settings.settle_seconds: - continue - self._book(cid, b, sp.get("name") or cid, smid, delta, rem) - except SpoolmanNotFound: - self._write_error(f"book:{cid}", f"{sp.get('name')}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht") - except SpoolmanError as e: - self._error("spoolman", str(e)) - - def _write_error(self, key: str, msg: str) -> None: - if self.dry_logged.get(key) != msg: - self.dry_logged[key] = msg - self.log.add("error", msg) - - def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float) -> None: - if self.settings.dry_run: - self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen", lambda: None) - return - before = float(self.spoolman.spool(smid).get("used_weight") or 0) - b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.clock()} - self.state.save() # intent first: a crash after the PUT must not book twice - self.spoolman.use(smid, delta) - self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht") - - def _booked(self, b: dict, grams: float, target: float, msg: str) -> None: - b["remaining"] = target - b["pending"] = None - b["last"] = {"time": self.clock(), "grams": round(grams, 3)} - self.state.save() - self.log.add("info", msg) - - def _resolve_pending(self, cid: str, b: dict) -> bool: - """A booking was interrupted: check whether Spoolman already has it.""" - p = b["pending"] - smid = b["spoolman"] - used = float(self.spoolman.spool(smid).get("used_weight") or 0) - if used >= p["before"] + p["grams"] - 0.01: - self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman") - return True - if self.settings.dry_run: - self._write(f"book:{cid}", f"unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} wiederholen", - lambda: None) + def _same(self, a: dict | None, b: dict) -> bool: + if a is None or a.get("location") != b["location"]: return False - self.spoolman.use(smid, p["grams"]) - self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt") + if not self.settings.spoolman_fields: + return True + return a.get("printer") == b["printer"] and a.get("slot") == b["slot"] + + def _our_locations(self) -> set[str]: + ours = set(self.state.data.get("written_locations", [])) + for w in self.workers.values(): + ours.update(self.settings.slot_location(w.cfg, n) for n in range(4)) + ours.discard(self.settings.location_storage) + return ours + + def _ensure_fields(self) -> bool: + if self.fields_ok or not self.settings.spoolman_fields: + return True + existing = {f.get("key") for f in self.spoolman.fields()} + missing = {k: v for k, v in FIELD_DEFS.items() if k not in existing} + for key, params in missing.items(): + _, done = self._write(f"field:{key}", f"in Spoolman das Zusatzfeld „{params['name']}“ ({key}) anlegen", + lambda: self.spoolman.add_field(key, params)) + if not done: + return False + self.fields_ok = True return True - # ---------- Spoolman locations ---------- - - def _want_location(self, smid: int, location: str) -> None: - locs = self.state.data["locations"] - entry = locs.setdefault(str(smid), {"want": None, "done": None}) - entry["want"] = location - def _reconcile_locations(self) -> None: - for k, entry in self.state.data["locations"].items(): - if entry["want"] == entry["done"]: - continue - smid = int(k) - name = self._name(smid) - try: - _, done = self._write(f"location:{k}", f"in Spoolman den Ort von {name} auf „{entry['want']}“ setzen", - lambda: self.spoolman.set_location(smid, entry["want"])) + if self.spoolman is None: + return + desired = self._desired() + storage = self._storage() + applied = self.state.data["applied"] + targets: dict[int, tuple[dict, str]] = {} + for sid, want in desired.items(): + if not self._same(applied.get(str(sid)), want): + targets[sid] = (want, "") + for k, a in applied.items(): + if int(k) not in desired and not self._same(a, storage): + targets[int(k)] = (storage, "") + names: dict[int, str] = {} + try: + if self.clock() - self.last_sync >= SYNC_SECONDS: + # Full comparison: the printer wins over manual edits in Spoolman. + ours = self._our_locations() + for sp in self.spoolman.spools(): + sid = int(sp["id"]) + names[sid] = label(sp) + actual = self._actual(sp) + want = desired.get(sid) + if want is None: + if actual["location"] not in ours and actual["printer"] is None: + # a place of yours (e.g. a shelf): not ours to touch + targets.pop(sid, None) + applied.pop(str(sid), None) + continue + want = storage + if self._same(actual, want): + targets.pop(sid, None) + applied[str(sid)] = want + elif sid not in targets: + targets[sid] = (want, " (korrigiert)") + self.last_sync = self.clock() + self._spoolman_error(None) + if targets and not self._ensure_fields(): + for sid, (t, why) in targets.items(): + self._write(f"location:{sid}", self._location_msg(names.get(sid) or self._name(sid), t, why), lambda: None) + return + for sid, (t, why) in targets.items(): + name = names.get(sid) or self._name(sid) + try: + _, done = self._write(f"location:{sid}", self._location_msg(name, t, why), + lambda: self.spoolman.update_spool(sid, self._patch_body(t))) + except SpoolmanNotFound: + self.log.add("warn", f"Spoolman-Spule {sid} existiert nicht mehr; Ort nicht gesetzt") + applied.pop(str(sid), None) + continue if done: - entry["done"] = entry["want"] - self._error("spoolman", None) - except SpoolmanNotFound: - self.log.add("warn", f"Spoolman-Spule {smid} existiert nicht mehr; Ort nicht gesetzt") - entry["done"] = entry["want"] - except SpoolmanError as e: - self._error("spoolman", str(e)) + applied[str(sid)] = t + if t["location"] != storage["location"]: + written = set(self.state.data["written_locations"]) | {t["location"]} + self.state.data["written_locations"] = sorted(written) + except SpoolmanError as e: + self.fields_ok = False + self._spoolman_error(str(e)) + + def _location_msg(self, name: str, t: dict, why: str) -> str: + extra = "" + if self.settings.spoolman_fields: + extra = f", Drucker {t['printer']}, Slot {t['slot']}" if t["printer"] else ", Drucker/Slot geleert" + return f"in Spoolman {name}: Ort „{t['location']}“{extra}{why}" + + def _patch_body(self, t: dict) -> dict: + body: dict = {"location": t["location"]} + if self.settings.spoolman_fields: + body["extra"] = { + FIELD_PRINTER: json.dumps(t["printer"], ensure_ascii=False) if t["printer"] else None, + FIELD_SLOT: json.dumps(t["slot"]) if t["slot"] else None, + } + return body # ---------- Spoolman cache ---------- - def _spool(self, smid: int) -> dict | None: + def spool(self, smid: int) -> dict | None: cached = self.spool_cache.get(smid) if cached and self.clock() - cached[0] < SPOOL_CACHE_SECONDS: return cached[1] try: sm = self.spoolman.spool(smid) except SpoolmanNotFound: - self._write_error(f"missing:{smid}", f"Spoolman-Spule {smid} existiert nicht") + self._once_error(f"missing:{smid}", f"Spoolman-Spule {smid} existiert nicht") return None except SpoolmanError as e: - self._error("spoolman", str(e)) + self._spoolman_error(str(e)) return cached[1] if cached else None self.spool_cache[smid] = (self.clock(), sm) return sm @@ -573,42 +803,7 @@ class Bridge: return label(cached[1]) if cached else f"Spoolman-Spule {smid}" def _refresh_spool_cache(self) -> None: - for m in self.state.data["slots"].values(): - self._spool(m["spool"]) - - # ---------- UI snapshot ---------- - - def _build_snapshot(self, record: dict | None, cc2: dict | None) -> None: - record_slots = tagcodec.parse_record(record or self.state.data.get("last_record") or {}, self.settings.id_offset) - cc2_slots = {s.get("slot"): s for s in (cc2 or {}).get("slots") or []} - cc2_spools = {str(s.get("id")): s for s in (cc2 or {}).get("spools") or []} - booked = self.state.data["booked"] - slots = [] - for n in range(4): - m = self.state.data["slots"].get(str(n)) - cs = cc2_slots.get(n) or {} - sid = slot_spool_id(cs) - cached = self.spool_cache.get(m["spool"]) if m else None - b = booked.get(m["cc2"]) if m and m.get("cc2") else None - slots.append({ - "slot": n, - "record": record_slots[n], - "tray_status": self.tray_status.get(n), - "mapping": m, - "spoolman": summary(cached[1]) if cached else None, - "cc2_spool": cc2_spools.get(sid) if sid else None, - "cc2_question": cs.get("question") or "", - "cc2_matches": bool(m and m.get("cc2") and sid == m["cc2"]), - "last_booking": (b or {}).get("last"), - }) - self.snapshot = { - "last_poll": self.clock(), - "dry_run": self.settings.dry_run, - "errors": dict(self.errors), - "cc2_enabled": None if cc2 is None else bool(cc2.get("enabled")), - "job_active": None if cc2 is None else bool((cc2.get("job") or {}).get("active")), - "slots": slots, - "ssh_key": self.keys.has_key(), - "host_key": self.keys.host_key() is not None, - } - + if self.spoolman is None: + return + for _, _, m in self.mappings(): + self.spool(m["spool"]) diff --git a/app/spoolman.py b/app/spoolman.py index 2fd1333..2f18aef 100644 --- a/app/spoolman.py +++ b/app/spoolman.py @@ -2,11 +2,21 @@ from __future__ import annotations +import json import re import httpx +# Extra fields the bridge keeps on loaded spools (Spoolman stores values JSON-encoded). +FIELD_PRINTER = "drucker" +FIELD_SLOT = "slot" +FIELD_DEFS = { + FIELD_PRINTER: {"name": "Drucker", "field_type": "text", "order": 100}, + FIELD_SLOT: {"name": "Slot", "field_type": "integer", "order": 101}, +} + + class SpoolmanError(RuntimeError): pass @@ -42,8 +52,14 @@ class Spoolman: def spools(self, archived: bool = False) -> list[dict]: return self._request("GET", "/spool", params={"allow_archived": str(archived).lower()}) - def set_location(self, spool_id: int, location: str) -> dict: - return self._request("PATCH", f"/spool/{int(spool_id)}", json={"location": location}) + def update_spool(self, spool_id: int, body: dict) -> dict: + return self._request("PATCH", f"/spool/{int(spool_id)}", json=body) + + def fields(self) -> list[dict]: + return self._request("GET", "/field/spool") + + def add_field(self, key: str, params: dict) -> list[dict]: + return self._request("POST", f"/field/spool/{key}", json=params) def use(self, spool_id: int, grams: float) -> dict: return self._request("PUT", f"/spool/{int(spool_id)}/use", json={"use_weight": round(float(grams), 3)}) @@ -73,6 +89,16 @@ def color_hex(spool: dict) -> str | None: return color if re.fullmatch(r"[0-9A-F]{6}", color) else None +def extra_value(spool: dict, key: str): + raw = (spool.get("extra") or {}).get(key) + if raw is None: + return None + try: + return json.loads(raw) + except (TypeError, ValueError): + return None + + def summary(spool: dict) -> dict: fil = spool.get("filament") or {} return { @@ -91,4 +117,6 @@ def summary(spool: dict) -> dict: "location": spool.get("location") or "", "archived": bool(spool.get("archived")), "used_weight": spool.get("used_weight"), + "printer": extra_value(spool, FIELD_PRINTER), + "slot": extra_value(spool, FIELD_SLOT), } diff --git a/app/sshsource.py b/app/sshsource.py index 1eeff2c..ff51747 100644 --- a/app/sshsource.py +++ b/app/sshsource.py @@ -108,25 +108,36 @@ class KeyStore: "public": f"{key.get_name()} {key.get_base64()} cc2-spoolman-bridge", } - def host_key(self) -> dict | None: - return read_json(self.host_path, None) + def _host_path(self, pid: str) -> Path: + if not pid.replace("-", "").replace("_", "").isalnum(): + raise ValueError("invalid printer id") + return self.dir / f"host_key_{pid}.json" - def pin_host_key(self, host: str, key: paramiko.PKey) -> None: + def migrate_host_key(self, pid: str) -> None: + """v1 kept one host key in host_key.json; it belongs to the first printer.""" + if self.host_path.exists() and not self._host_path(pid).exists(): + os.replace(self.host_path, self._host_path(pid)) + + def host_key(self, pid: str) -> dict | None: + return read_json(self._host_path(pid), None) + + def pin_host_key(self, pid: str, host: str, key: paramiko.PKey) -> None: self.dir.mkdir(parents=True, exist_ok=True) - atomic_write_json(self.host_path, { + atomic_write_json(self._host_path(pid), { "host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key), }, mode=0o600) - def forget_host_key(self) -> None: + def forget_host_key(self, pid: str) -> None: try: - self.host_path.unlink() + self._host_path(pid).unlink() except FileNotFoundError: pass class CanvasRecordReader: - def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0): + def __init__(self, keys: KeyStore, pid: str, host: str, port: int = 22, user: str = "root", timeout: float = 10.0): self.keys = keys + self.pid = pid self.host, self.port, self.user, self.timeout = host, port, user, timeout self._transport: paramiko.Transport | None = None self._lock = threading.Lock() @@ -155,7 +166,7 @@ class CanvasRecordReader: try: t.start_client(timeout=self.timeout) server_key = t.get_remote_server_key() - pinned = self.keys.host_key() + pinned = self.keys.host_key(self.pid) if pinned is None: if not accept_unknown: raise HostKeyUnknown(server_key) @@ -222,11 +233,11 @@ class CanvasRecordReader: try: server_key = self._connect(accept_unknown=True) raw = self._exec() - except HostKeyMismatch: - raise + except (paramiko.SSHException, OSError, EOFError) as e: + raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None finally: self._close() - pinned = self.keys.host_key() + pinned = self.keys.host_key(self.pid) return { "host_key": {"type": server_key.get_name(), "key": server_key.get_base64(), "fingerprint": fingerprint(server_key)}, diff --git a/app/storage.py b/app/storage.py index aadcbfd..7dbd33f 100644 --- a/app/storage.py +++ b/app/storage.py @@ -4,11 +4,15 @@ from __future__ import annotations import json import os +import re +import secrets import tempfile import threading -from dataclasses import asdict, dataclass, fields +from dataclasses import asdict, dataclass, field, fields from pathlib import Path +V1_LOCATION_SLOT = "CC2 Slot {n}" + def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -47,64 +51,6 @@ def read_json(path: Path, default): return default -@dataclass -class Settings: - printer_host: str = "10.10.2.202" - ssh_port: int = 22 - ssh_user: str = "root" - cc2_url: str = "http://10.10.2.202:8081" - spoolman_url: str = "http://10.10.6.10:7912" - id_offset: int = 10000 - poll_interval: float = 5.0 - location_slot: str = "CC2 Slot {n}" - location_storage: str = "Lager" - empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty - settle_seconds: float = 60.0 # `used` must be unchanged this long before booking - dry_run: bool = True - - # environment variable -> field (only used as defaults before settings.json exists) - ENV = { - "PRINTER_HOST": "printer_host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user", - "CC2_URL": "cc2_url", "SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset", - "POLL_INTERVAL": "poll_interval", "DRY_RUN": "dry_run", - } - - @classmethod - def from_dict(cls, d: dict) -> "Settings": - s = cls() - s.update(d) - return s - - def update(self, d: dict) -> None: - for f in fields(self): - if f.name in d and d[f.name] is not None: - setattr(self, f.name, _coerce(f.type, d[f.name], f.name)) - self.validate() - - def validate(self) -> None: - if not 1 <= self.ssh_port <= 65535: - raise ValueError("SSH-Port ungültig") - if not 0 <= self.id_offset <= 65000: - raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen") - if not 1 <= self.poll_interval <= 300: - raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen") - if "{n}" not in self.location_slot: - raise ValueError("Ortsname für Slots muss {n} enthalten") - for name in ("cc2_url", "spoolman_url"): - v = getattr(self, name) - if not v.startswith(("http://", "https://")): - raise ValueError(f"{name} muss mit http:// oder https:// beginnen") - setattr(self, name, v.rstrip("/")) - if not self.location_storage.strip(): - raise ValueError("Ortsname für das Lager fehlt") - - def to_dict(self) -> dict: - return asdict(self) - - def slot_location(self, slot: int) -> str: - return self.location_slot.replace("{n}", str(slot + 1)) - - def _coerce(typ, value, name): typ = typ if isinstance(typ, str) else typ.__name__ try: @@ -121,6 +67,138 @@ def _coerce(typ, value, name): raise ValueError(f"Ungültiger Wert für {name}") from None +def new_printer_id() -> str: + return "p" + secrets.token_hex(3) + + +@dataclass +class PrinterConfig: + id: str = "" + name: str = "CC2" + host: str = "10.10.2.202" + ssh_port: int = 22 + ssh_user: str = "root" + cc2_url: str = "http://10.10.2.202:8081" + enabled: bool = True + + @classmethod + def from_dict(cls, d: dict) -> "PrinterConfig": + p = cls() + for f in fields(p): + if f.name in d and d[f.name] is not None: + setattr(p, f.name, _coerce(f.type, d[f.name], f.name)) + if not p.id: + p.id = new_printer_id() + p.validate() + return p + + def validate(self) -> None: + if not re.fullmatch(r"[a-z0-9_-]{1,32}", self.id): + raise ValueError("Ungültige Drucker-ID") + if not self.name.strip(): + raise ValueError("Druckername fehlt") + if len(self.name) > 60: + raise ValueError("Druckername ist zu lang (max. 60 Zeichen)") + if not self.host.strip(): + raise ValueError(f"{self.name}: Drucker-IP fehlt") + if not 1 <= self.ssh_port <= 65535: + raise ValueError(f"{self.name}: SSH-Port ungültig") + if not self.cc2_url.startswith(("http://", "https://")): + raise ValueError(f"{self.name}: CC2-Control-URL muss mit http:// beginnen") + self.cc2_url = self.cc2_url.rstrip("/") + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class Settings: + spoolman_url: str = "http://10.10.6.10:7912" + id_offset: int = 10000 + poll_interval: float = 5.0 + location_slot: str = "{printer} Slot {n}" + location_storage: str = "Lager" + spoolman_fields: bool = True # also write the extra fields "drucker" and "slot" + empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty + settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking + dry_run: bool = True + printers: list[PrinterConfig] = field(default_factory=list) + + # environment variable -> field (only used as defaults before settings.json exists) + ENV = { + "SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset", "POLL_INTERVAL": "poll_interval", + "DRY_RUN": "dry_run", + } + PRINTER_ENV = { + "PRINTER_NAME": "name", "PRINTER_HOST": "host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user", + "CC2_URL": "cc2_url", + } + + @classmethod + def from_dict(cls, d: dict) -> "Settings": + s = cls() + s.update(migrate_settings(d)) + return s + + def update(self, d: dict) -> None: + for f in fields(self): + if f.name == "printers" or f.name not in d or d[f.name] is None: + continue + setattr(self, f.name, _coerce(f.type, d[f.name], f.name)) + if d.get("printers") is not None: + self.printers = [p if isinstance(p, PrinterConfig) else PrinterConfig.from_dict(p) for p in d["printers"]] + self.validate() + + def validate(self) -> None: + if not 0 <= self.id_offset <= 65000: + raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen") + if not 1 <= self.poll_interval <= 300: + raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen") + if "{n}" not in self.location_slot: + raise ValueError("Ortsname für Slots muss {n} enthalten") + if len(self.printers) > 1 and "{printer}" not in self.location_slot: + raise ValueError("Bei mehreren Druckern muss der Ortsname {printer} enthalten") + if not self.spoolman_url.startswith(("http://", "https://")): + raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen") + self.spoolman_url = self.spoolman_url.rstrip("/") + if not self.location_storage.strip(): + raise ValueError("Ortsname für das Lager fehlt") + ids = [p.id for p in self.printers] + names = [p.name.strip().lower() for p in self.printers] + if len(set(ids)) != len(ids) or len(set(names)) != len(names): + raise ValueError("Druckernamen müssen eindeutig sein") + if len(self.printers) > 8: + raise ValueError("Höchstens 8 Drucker") + + def to_dict(self) -> dict: + d = asdict(self) + d["printers"] = [p.to_dict() for p in self.printers] + return d + + def printer(self, pid: str) -> PrinterConfig | None: + return next((p for p in self.printers if p.id == pid), None) + + def slot_location(self, printer: PrinterConfig | str, slot: int) -> str: + name = printer.name if isinstance(printer, PrinterConfig) else printer + return self.location_slot.replace("{printer}", name).replace("{n}", str(slot + 1)) + + +def migrate_settings(d: dict) -> dict: + """v1 had one printer at the top level; v2 keeps a list of printers.""" + d = dict(d) + if "printers" not in d and "printer_host" in d: + d["printers"] = [{ + "id": "p1", "name": d.get("printer_name") or "CC2", "host": d.get("printer_host"), + "ssh_port": d.get("ssh_port", 22), "ssh_user": d.get("ssh_user", "root"), + "cc2_url": d.get("cc2_url"), "enabled": True, + }] + if d.get("location_slot") == V1_LOCATION_SLOT: + d["location_slot"] = Settings.location_slot + for key in ("printer_host", "printer_name", "ssh_port", "ssh_user", "cc2_url"): + d.pop(key, None) + return d + + class SettingsStore: def __init__(self, data_dir: Path, env: dict | None = None): self.path = data_dir / "settings.json" @@ -129,9 +207,13 @@ class SettingsStore: if stored is None: env = os.environ if env is None else env defaults = {f: env[k] for k, f in Settings.ENV.items() if env.get(k)} + printer = {f: env[k] for k, f in Settings.PRINTER_ENV.items() if env.get(k)} + defaults["printers"] = [{"id": "p1", **printer}] self.settings = Settings.from_dict(defaults) else: self.settings = Settings.from_dict(stored) + if "printer_host" in stored: # write the migrated form once + atomic_write_json(self.path, self.settings.to_dict()) def get(self) -> Settings: with self._lock: @@ -146,16 +228,39 @@ class SettingsStore: class StateStore: - """Bridge state: slot mappings, booked consumption, last record.""" + """Bridge state: per printer slot mappings, booked consumption and last record; Spoolman locations.""" - def __init__(self, data_dir: Path): + def __init__(self, data_dir: Path, first_printer: str = "p1"): self.path = data_dir / "state.json" - self.data = read_json(self.path, {}) - self.data.setdefault("version", 1) - self.data.setdefault("slots", {}) - self.data.setdefault("booked", {}) - self.data.setdefault("locations", {}) - self.data.setdefault("last_record", None) + self.data = migrate_state(read_json(self.path, {}), first_printer) + + def printer(self, pid: str) -> dict: + p = self.data["printers"].setdefault(pid, {}) + p.setdefault("slots", {}) + p.setdefault("booked", {}) + p.setdefault("last_record", None) + p.setdefault("scans", []) + return p def save(self) -> None: atomic_write_json(self.path, self.data) + + +def migrate_state(data: dict, first_printer: str) -> dict: + data.setdefault("printers", {}) + if data.get("version", 1) < 2: + if any(k in data for k in ("slots", "booked", "last_record", "scans")): + data["printers"][first_printer] = { + "slots": data.pop("slots", {}), "booked": data.pop("booked", {}), + "last_record": data.pop("last_record", None), "scans": data.pop("scans", []), + } + applied = {} + for sid, loc in (data.pop("locations", None) or {}).items(): + if loc.get("done"): + applied[sid] = {"location": loc["done"], "printer": None, "slot": None} + data["applied"] = applied + data["written_locations"] = sorted({a["location"] for a in applied.values()}) + data["version"] = 2 + data.setdefault("applied", {}) + data.setdefault("written_locations", []) + return data diff --git a/app/templates/settings.html b/app/templates/settings.html index 2f5d698..fa0555e 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -1,38 +1,44 @@ {% extends "base.html" %} {% block title %}CC2 Bridge – Einstellungen{% endblock %} {% block content %} -

Einstellungen

-
+

Drucker

+
+
+ + + +
+

Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.

+ +

Allgemein

+
-
-
-
-
-
-
+
-
-
+
- +
-
+
+ +
+
-

SSH-Schlüssel für den Drucker

+

SSH-Schlüssel für die Drucker

Lade …
- +
@@ -42,39 +48,75 @@

Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.

- -

Verbindung testen

-
-
-
-
-
-
{% endblock %} {% block script %}