"""Bridge between the Canvas record (SSH), CC2 Control's spool library and Spoolman. Rules: - The printer is only read (SSH `cat` of the canvas record). No MQTT, no printer commands. - 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 import json import logging import queue import threading import time from collections import deque from pathlib import Path from . import tagcodec from .cc2control import CC2Control, CC2Error, slot_spool_id, spoolman_id_of, tray_statuses 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 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": "SSH", "cc2": "CC2 Control"} class ActionLog: def __init__(self, data_dir: Path, clock=time.time): self.path = data_dir / "actions.log" self.clock = clock self.entries: deque[dict] = deque(maxlen=300) self._lock = threading.Lock() try: lines = self.path.read_text().splitlines()[-LOG_KEEP:] self.path.write_text("".join(line + "\n" for line in lines)) for line in lines[-300:]: try: self.entries.append(json.loads(line)) except ValueError: pass except FileNotFoundError: pass def add(self, level: str, msg: str) -> None: entry = {"time": self.clock(), "level": level, "msg": msg} getattr(log, {"error": "error", "warn": "warning"}.get(level, "info"))("%s", msg) with self._lock: self.entries.append(entry) try: self.path.parent.mkdir(parents=True, exist_ok=True) with self.path.open("a") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") except OSError: pass def recent(self, n: int = 200) -> list[dict]: with self._lock: return list(self.entries)[-n:][::-1] def material_fits(material: str, tray_type: str, tray_name: str) -> bool: """CC2 Control's rule: same as the tray's type or name, or a product line of the type.""" m, t, n = material.upper().strip(), tray_type.upper().strip(), tray_name.upper().strip() if not m: return False if m in (t, n): return True return bool(t) and m.startswith(t) and len(m) > len(t) and m[len(t)] in " -+" def cc2_spool_fields(sm: dict, channel: dict | None) -> tuple[dict, list[str]]: """Fields for /api/spools/save from a Spoolman spool (and the tray it sits in).""" notes = [] fil = sm.get("filament") or {} channel = channel or {} tray_type = str(channel.get("type") or "") tray_name = str(channel.get("detailed_type") or "") material = (fil.get("material") or "").strip() if tray_type and not material_fits(material, tray_type, tray_name): new = tray_name or tray_type notes.append(f"Material {material or '(leer)'} passt nicht zum Tag ({tray_type}); verwende {new}") material = new if not material: material = "PLA" color = color_hex(sm) if color is None: color = tagcodec.normalize_hex_color(str(channel.get("color") or "").replace("0x", "")) or "000000" notes.append(f"Spoolman-Spule hat keine Farbe; verwende #{color}") fields: dict = { "name": label(sm), "brand": vendor_name(sm), "material": material, "color": "#" + color, "note": f"spoolman:{sm['id']}", } def num(key, value, lo, hi): try: v = float(value) except (TypeError, ValueError): return if lo <= v <= hi: fields[key] = round(v, 3) else: notes.append(f"{key}={v} außerhalb {lo}-{hi}, weggelassen") num("diameter", fil.get("diameter"), 1, 3.5) num("density", fil.get("density"), 0.5, 3) num("net", sm.get("initial_weight") or fil.get("weight"), 1, 20000) if sm.get("remaining_weight") is not None: num("remaining", max(0.0, float(sm["remaining_weight"])), 0, 20000) tare = sm.get("spool_weight") or fil.get("spool_weight") if tare: num("tare", tare, 0, 5000) price = sm.get("price") or fil.get("price") if price: num("price", price, 0, 1e7) 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): self.data_dir = data_dir self.settings_store = settings self.keys = keys self.clock = clock 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 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.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.dry_logged: dict[str, str] = {} self.spool_cache: dict[int, tuple[float, dict]] = {} self.snapshot: dict = {"last_poll": None, "printers": []} # ---------- infrastructure ---------- def _sync_workers(self) -> None: s = self.settings 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 _spoolman_error(self, msg: str | None) -> None: if msg != self.spoolman_err: if msg: self.log.add("error", 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.""" if self.settings.dry_run: if self.dry_logged.get(key) != msg: self.dry_logged[key] = msg self.log.add("dry", f"würde {msg}") return None, False self.dry_logged.pop(key, None) result = fn() self.log.add("info", f"erledigt: {msg}") return result, True def run(self, stop: threading.Event) -> None: while not stop.is_set(): started = self.clock() try: self.poll_once() except Exception: # never let the loop die log.exception("poll failed") self.log.add("error", "Interner Fehler im Abfragezyklus (Details im Container-Log)") interval = self.settings.poll_interval self.wake.wait(max(0.5, interval - (self.clock() - started))) self.wake.clear() # ---------- one cycle ---------- def poll_once(self) -> None: with self.lock: self.settings = self.settings_store.get() self._sync_workers() self._run_commands() 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.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, pid, arg = self.commands.get_nowait() except queue.Empty: return w = self.workers.get(pid) if w is None: continue try: if cmd == "enable": w._write("enable", "Spulenverwaltung in CC2 Control einschalten", lambda: w.cc2.enable(True)) elif cmd == "reassign": m = w.st["slots"].get(str(arg)) if m: m["assigned"] = False 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 w.st["slots"]: self.release(w, arg, "manuell aufgehoben") except (CC2Error, SpoolmanError) as e: w._log("error", str(e)) # ---------- mappings across printers ---------- 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 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, 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"{w.id}:assign:{n}", None) return 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(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, } w.empty_since.pop(n, None) w._log("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})") 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"{w.id}:assign:{n}", None) w._log("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})") # ---------- Spoolman locations and extra fields ---------- def _desired(self) -> dict[int, dict]: out = {} for pid, n, m in self.mappings(): w = self.workers.get(pid) if w is None: continue out[m["spool"]] = {"location": self.settings.slot_location(w.cfg, n), "printer": w.name, "slot": n + 1} return out def _storage(self) -> dict: return {"location": self.settings.location_storage, "printer": None, "slot": None} def _actual(self, sp: dict) -> dict: return {"location": sp.get("location") or "", "printer": extra_value(sp, FIELD_PRINTER), "slot": extra_value(sp, FIELD_SLOT)} def _same(self, a: dict | None, b: dict) -> bool: if a is None or a.get("location") != b["location"]: return False 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 def _reconcile_locations(self) -> None: 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: 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: 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._once_error(f"missing:{smid}", f"Spoolman-Spule {smid} existiert nicht") return None except SpoolmanError as e: self._spoolman_error(str(e)) return cached[1] if cached else None self.spool_cache[smid] = (self.clock(), sm) return sm def _name(self, smid: int) -> str: cached = self.spool_cache.get(smid) return label(cached[1]) if cached else f"Spoolman-Spule {smid}" def _refresh_spool_cache(self) -> None: if self.spoolman is None: return for _, _, m in self.mappings(): self.spool(m["spool"])