"""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 made by a scan. - The record keeps a tray's tag data after the roll is taken out. A roll put back into the slot it left, with the record unchanged, is taken to be that roll again ("reinserted"); if the touchscreen then replaces the id, it was another roll and the mapping goes. - 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. - Consumption is the drop of CC2 Control's remaining weight, booked once the printer is idle. CC2 Control's log adds what was printed (file, result), weigh-ins (Spoolman takes the weighed value) and usage of a tray left without spool after "Take out" (booked to the roll still in it). """ 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 TEXT_LIMITS, CC2Control, CC2Error, clean_text, 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 LOG_SEEN_KEEP = 400 # CC2 Control lists its newest 150 entries TAKEOUT_MARGIN = 120 # seconds: the printer's clock and ours may differ a little TAKEOUT_KEEP = 86400 REANSWER_SECONDS = 60 MIRROR_SYNC_SECONDS = 60 # compare CC2 Control's spool copies with Spoolman REMAINING_TOLERANCE = 1.0 # grams CC2 Control's rest may differ from Spoolman's before it is set again # CC2 Control's JSON precision per field, so a compare does not find differences it cannot store FIELD_PRECISION = {"diameter": 0.0005, "density": 0.0005, "net": 0.05, "tare": 0.05, "price": 0.005} FIELD_NAMES = {"name": "Name", "brand": "Hersteller", "color": "Farbe", "diameter": "Durchmesser", "density": "Dichte", "net": "Nettogewicht", "tare": "Leergewicht", "price": "Preis"} SOURCE_NAMES = {"ssh": "SSH", "cc2": "CC2 Control"} RESULTS = { "complete": "fertig", "completed": "fertig", "cancelled": "abgebrochen", "canceled": "abgebrochen", "error": "Fehler", "failed": "Fehler", "ended": "beendet", "changed": "Spule gewechselt", "moved": "Spule umgezogen", "removed": "entnommen", "runout": "leergelaufen", "late": "nachträglich zugeordnet", } def _num(value, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def log_key(e: dict) -> str: return f"{e.get('time')}|{e.get('slot')}|{e.get('kind')}|{e.get('spool')}|{_num(e.get('mm')):.1f}|{_num(e.get('grams')):.3f}" def job_text(e: dict) -> str: """'benchy.gcode (abgebrochen)' for a CC2 Control print log entry.""" if e.get("kind") == "runout": return "Rolle leergelaufen" job = str(e.get("job") or "").rsplit("/", 1)[-1] or "ohne Dateiname" result = str(e.get("result") or "") result = RESULTS.get(result, result) return f"{job} ({result})" if result else job def describe(entries: list[dict]) -> str: parts: list[str] = [] for e in entries: if e.get("kind") in ("print", "runout"): text = job_text(e) if text not in parts: parts.append(text) if not parts: return "" more = f" und {len(parts) - 4} weitere" if len(parts) > 4 else "" return " – " + ", ".join(parts[:4]) + more def grams_from_mm(mm: float, diameter: float, density: float) -> float: return mm * 3.141592653589793 * (diameter / 2) ** 2 * density / 1000 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 self.print_info: dict | None = None # CC2 Control /api/printer, only read while a job runs self.noted_questions: dict[int, str] = {} # slot -> question already reported self.last_mirror_sync = 0.0 @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)) self.print_info = None if self.cc2_data is not None and (self.cc2_data.get("job") or {}).get("active"): try: self.print_info = self.cc2.printer() except CC2Error: pass # progress is only shown in the UI; the spool list above is what counts 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._process_cc2_log(cc2) self._consumption(cc2) self._note_open_questions(cc2) self._sync_mirrors(cc2) def _record_id(self, n: int) -> int | None: for s in self.record_slots: if s["slot"] == n: return s["spool_id"] return None def _close_takeout(self, n: int | None = None, spool: int | None = None) -> None: now = self.bridge.clock() for w in self.st["takeout"]: if w.get("until") is None and (w["slot"] == n or w["spool"] == spool): w["until"] = now # ---------- 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 prev is not None and tid != prev[n]["spool_id"]: # the tray's tag data was rewritten: whatever was there before is gone self.st["last"].pop(str(n), None) self._close_takeout(n) m = mappings.get(str(n)) if tid is None and m and m.get("reinserted"): self.bridge.release(self, n, "Tag-Daten am Display ersetzt, also eine andere Rolle ohne Spoolman-Tag") 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) if sm.get("archived"): self._log("warn", f"Slot {n + 1}: {label(sm)} (Spoolman {tid}) ist archiviert, also leer; nicht zugeordnet. " "Tag für die neue Rolle neu beschreiben oder die Spule in Spoolman zurückholen.") continue 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: self._close_takeout(n) 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 question = str(slot.get("question") or "") loaded = self.tray_status.get(n) in (1, 2) if question: self._close_takeout(n) # filament went in or was changed: maybe another roll # 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 loaded: # 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) elif not sid and question == "inserted" and loaded: self._reinserted(n) 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 question: if loaded and self._record_id(n) == m["spool"]: # "Take out" with the roll still in the tray: Spoolman says storage, but what the # tray prints now is still that roll's self.st["takeout"].append({"slot": n, "spool": m["spool"], "name": m.get("name") or str(m["spool"]), "since": self.bridge.clock(), "until": None}) self.bridge.release(self, n, "in CC2 Control herausgenommen bzw. ins Lager gelegt") elif loaded: self._answer_again(n, m, question) def _note_open_questions(self, cc2: dict) -> None: """A tray printed while CC2 Control was still asking which spool it holds: say so once, after the print.""" if (cc2.get("job") or {}).get("active"): return for slot in cc2.get("slots") or []: n = slot.get("slot") mm = _num(slot.get("question_mm")) if n not in (0, 1, 2, 3) or not slot.get("question") or mm < 1 or str(n) in self.st["slots"]: continue key = f"{n}|{slot.get('question_since')}" if self.noted_questions.get(n) == key: continue self.noted_questions[n] = key self._log("warn", f"Slot {n + 1}: {mm:.0f} mm (ca. {grams_from_mm(mm, 1.75, 1.24):.1f} g) ohne Spule gedruckt; " "CC2 Control wartet auf die Zuordnung. Rolle taggen und scannen, dann wird der Verbrauch " "nachgebucht, oder in CC2 Control „keine Spule“ wählen.") def _reinserted(self, n: int) -> None: """No mapping, CC2 Control asks about filament put in, and the record names a roll that is nowhere else: the roll that last left this slot, or one scanned again with the same tag data as before (a roll that printed in the other printer and came back). If the touchscreen then replaces the id, it was another roll after all and the mapping goes.""" last = self.st["last"].get(str(n)) rid = self._record_id(n) if rid is None or (last and rid != last["spool"]) or self.bridge.mapped_elsewhere(rid, self.id, n): return if last: name, why = last["name"], "wieder eingelegt, Tag-Daten unverändert" else: sm = self.bridge.spool(rid) if sm is None or sm.get("archived"): return name, why = label(sm), "Tag erkannt, Tag-Daten wie beim letzten Scan in diesem Slot" self.bridge.map(self, n, rid, name, why) self.st["slots"][str(n)]["reinserted"] = True def _answer_again(self, n: int, m: dict, question: str) -> None: """CC2 Control unbound a mapped slot and asks which spool it is.""" rid = self._record_id(n) if question == "inserted": if rid != m["spool"]: self.bridge.release(self, n, "andere Rolle eingelegt") return m["reinserted"] = True # taken out and put back without a new scan elif rid != m["spool"] and (rid is not None or m.get("reinserted")): return # same roll (or a touchscreen edit of it): tell CC2 Control again, at most once a minute if not m.get("assigned") or self.bridge.clock() - m.get("answered_at", 0) < REANSWER_SECONDS: return m["assigned"] = False m["answered_at"] = self.bridge.clock() self.bridge.dry_logged.pop(f"{self.id}:assign:{n}", None) self._log("info", f"Slot {n + 1}: CC2 Control fragt nach der Spule ({question}); " f"{m.get('name') or m['spool']} wird erneut zugewiesen") 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, "log_time": 0.0, } 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")) if job_active: # a print ran with it: a later touchscreen edit is an edit of this roll for m in self.st["slots"].values(): m.pop("reinserted", None) booked = self.st["booked"] now = self.bridge.clock() entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)] 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 name = sp.get("name") or cid mine = [e for e in entries if str(e.get("spool")) == cid] newest = max((_num(e.get("time")) for e in mine), default=0.0) 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, "log_time": newest} self._log("info", f"{name}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})") continue b.setdefault("log_time", newest) # state from before the log was read fresh = [e for e in mine if _num(e.get("time")) > b["log_time"]] try: if b.get("pending") and not self._resolve_pending(cid, b): continue settled = not job_active and now - self.remaining_seen[cid][1] >= self.settings.settle_seconds if any(e.get("kind") == "correction" for e in fresh): adjusted = b.get("adjusted") if adjusted is not None and abs(rem - adjusted) < MIN_BOOKING: # our own correction from Spoolman's value: nothing to book or to send back b.pop("adjusted") b["remaining"] = rem b["log_time"] = newest continue if settled: self._weighin(cid, b, name, smid, rem, newest) continue if any(e.get("kind") == "runout" for e in fresh): # CC2 Control sets a roll that ran out to zero; Spoolman follows absolutely, so a count # that was off (e.g. a mirror left with an old value) does not book too much or too little if not settled or self._weighin(cid, b, name, smid, rem, newest, describe(fresh)): continue delta = b["remaining"] - rem if delta < -MIN_BOOKING: self._log("warn", f"{name}: Rest in CC2 Control um {-delta:.2f} g gestiegen; " "nicht gebucht, neuer Ausgangsstand") b["remaining"] = rem b["log_time"] = newest continue if delta < MIN_BOOKING or not settled: continue self._book(cid, b, name, smid, delta, rem, describe(fresh), newest) except SpoolmanNotFound: self.bridge._once_error(f"{self.id}:book:{cid}", f"{self.name}: {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, details: str = "", log_time: float | None = None) -> None: if self.settings.dry_run: self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen{details}", 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(), "details": details, "log_time": log_time} 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{details}", details, log_time) def _booked(self, b: dict, grams: float, target: float, msg: str, details: str = "", log_time: float | None = None) -> None: b["remaining"] = target b["pending"] = None b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3), "details": details.removeprefix(" – ")} if log_time is not None: b["log_time"] = max(b.get("log_time") or 0.0, log_time) 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"] details, log_time = p.get("details") or "", p.get("log_time") 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", details, log_time) 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", details, log_time) return True def _weighin(self, cid: str, b: dict, name: str, smid: int, rem: float, log_time: float, runout: str | None = None) -> bool: """Weighed in CC2 Control, or ran out (`runout` holds the print details): Spoolman takes CC2 Control's value as it is. False when Spoolman has no start weight to set it against.""" sm = self.bridge.spoolman.spool(smid) base = sm.get("initial_weight") or (sm.get("filament") or {}).get("weight") if not base: what = "Leerlaufen per Verbrauch gebucht" if runout is not None else "Nachwiegen nicht übernommen" self.bridge._once_error(f"{self.id}:weigh:{cid}", f"{self.name}: {name}: Spoolman-Spule {smid} hat kein " f"Startgewicht; {what}") return False target = max(0.0, rem) used = round(max(0.0, float(base) - target), 3) prev = _num(sm.get("remaining_weight"), float(base) - _num(sm.get("used_weight"))) why = f"(Rolle leergelaufen){runout}" if runout is not None else "(in CC2 Control nachgewogen)" body: dict = {"used_weight": used} if runout is not None and target <= 0 and self.settings.archive_empty: body["archived"] = True why += " und archivieren" _, done = self._write(f"weigh:{cid}", f"in Spoolman den Rest von {name} auf {target:.1f} g setzen {why}", lambda: self.bridge.spoolman.update_spool(smid, body)) if done: b["remaining"] = rem b["log_time"] = max(b.get("log_time") or 0.0, log_time) if runout is not None: b["last"] = {"time": self.bridge.clock(), "grams": round(max(0.0, prev - target), 3), "details": ("Rolle leergelaufen" + runout).replace("leergelaufen – ", "leergelaufen: ")} self.bridge.spool_cache.pop(smid, None) self.bridge.state.save() return True # ---------- CC2 Control's spool copies follow Spoolman ---------- def _sync_mirrors(self, cc2: dict) -> None: """Master data and, for loaded spools, the rest follow Spoolman; copies of spools that are archived or gone in Spoolman leave CC2 Control's library (it holds at most 128).""" now = self.bridge.clock() if (cc2.get("job") or {}).get("active") or now - self.last_mirror_sync < MIRROR_SYNC_SECONDS: return self.last_mirror_sync = now in_slot = {slot_spool_id(s): s.get("slot") for s in cc2.get("slots") or [] if slot_spool_id(s)} mapped = {m["cc2"]: int(k) for k, m in self.st["slots"].items() if m.get("cc2")} in_tray = {s["spool_id"] for s in self.record_slots if s["spool_id"] is not None and self.tray_status.get(s["slot"]) in (1, 2)} for sp in cc2.get("spools") or []: smid = spoolman_id_of(sp) if smid is None: continue # a spool of yours in CC2 Control, not a copy cid = str(sp.get("id")) name = sp.get("name") or cid loaded = cid in mapped or cid in in_slot try: # loaded spools fresh (a cached rest from before a booking would be sent back), others cached; # before a copy is deleted, Spoolman is asked again: an outage must not look like a deleted spool sm = self.bridge.spoolman.spool(smid) if loaded else self.bridge.spool(smid) if not loaded and (sm is None or sm.get("archived")): sm = self.bridge.spoolman.spool(smid) except SpoolmanNotFound: sm = None except SpoolmanError as e: self.bridge._spoolman_error(str(e)) return try: if sm is None or sm.get("archived"): if not loaded and smid not in in_tray: self._delete_mirror(cid, sp, name, "in Spoolman archiviert" if sm else "in Spoolman gelöscht") continue if sp.get("archived"): continue self._sync_fields(cid, sp, sm, name) if cid in mapped: self._sync_remaining(cid, sp, sm, name) except CC2Error as e: self._log("error", f"{name}: {e}") def _sync_fields(self, cid: str, sp: dict, sm: dict, name: str) -> None: fields, _ = cc2_spool_fields(sm, None) changed = {} for key, want in fields.items(): if key in ("remaining", "note", "material"): continue # material was fitted to the tray when the copy was made; changing it would make CC2 Control ask have = sp.get(key) if key in FIELD_PRECISION: if have is None or abs(_num(have) - float(want)) > FIELD_PRECISION[key]: changed[key] = want elif key == "color": if str(have or "").upper() != str(want).upper(): changed[key] = want elif str(have or "") != clean_text(want, TEXT_LIMITS.get(key)): changed[key] = want if not changed: return what = ", ".join(FIELD_NAMES.get(k, k) for k in changed) self._write(f"meta:{cid}", f"in CC2 Control {name} aus Spoolman aktualisieren ({what})", lambda: self.cc2.save_spool({"id": cid, **changed})) def _sync_remaining(self, cid: str, sp: dict, sm: dict, name: str) -> None: """A loaded spool's rest in CC2 Control is set to Spoolman's once everything is booked: the copy may be old (the roll printed in the other printer meanwhile) or the roll was weighed in Spoolman.""" b = self.st["booked"].get(cid) rem, target = _num(sp.get("remaining"), None), sm.get("remaining_weight") if b is None or b.get("pending") or rem is None or target is None: return if abs(b["remaining"] - rem) >= MIN_BOOKING: return # use not booked yet: first that seen = self.remaining_seen.get(cid) if seen is None or self.bridge.clock() - seen[1] < self.settings.settle_seconds: return target = min(20000.0, max(0.0, float(target))) if abs(target - rem) < REMAINING_TOLERANCE: return _, done = self._write(f"adjust:{cid}", f"in CC2 Control den Rest von {name} auf {target:.1f} g setzen " f"(Stand in Spoolman, bisher {rem:.1f} g)", lambda: self.cc2.adjust(cid, target)) if done: b["remaining"] = target b["adjusted"] = target self.bridge.state.save() def _delete_mirror(self, cid: str, sp: dict, name: str, why: str) -> None: b = self.st["booked"].get(cid) rem = _num(sp.get("remaining"), None) if b and (b.get("pending") or (rem is not None and abs(b["remaining"] - rem) >= MIN_BOOKING)): return # use not booked yet _, done = self._write(f"delete:{cid}", f"in CC2 Control die Spule {name} löschen ({why})", lambda: self.cc2.delete(cid)) if done: self.st["booked"].pop(cid, None) self.remaining_seen.pop(cid, None) # ---------- CC2 Control's log: use of a tray left without spool after "Take out" ---------- def _process_cc2_log(self, cc2: dict) -> None: st = self.st entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)] keys = [log_key(e) for e in entries] now = self.bridge.clock() if st.get("log_seen") is None: st["log_seen"] = keys[:LOG_SEEN_KEEP] # older entries are not ours to book else: seen = set(st["log_seen"]) for e, key in zip(entries, keys): if key in seen: continue st["log_seen"].append(key) seen.add(key) n, t, mm = e.get("slot"), _num(e.get("time")), _num(e.get("mm")) if e.get("kind") != "print" or e.get("spool") or n not in (0, 1, 2, 3) or mm <= 0: continue win = next((w for w in st["takeout"] if w["slot"] == n and w["since"] - TAKEOUT_MARGIN <= t <= (w["until"] or now) + TAKEOUT_MARGIN), None) if win: st["extra"].append({"key": key, "slot": n, "spool": win["spool"], "name": win["name"], "mm": mm, "job": job_text(e), "before": None, "grams": None}) st["log_seen"] = st["log_seen"][-LOG_SEEN_KEEP:] st["takeout"] = [w for w in st["takeout"] if w["until"] is None or now - w["until"] < TAKEOUT_KEEP] self._process_extra() def _process_extra(self) -> None: rest = [] for item in self.st["extra"]: smid = item["spool"] try: sm = self.bridge.spoolman.spool(smid) fil = sm.get("filament") or {} grams = round(grams_from_mm(item["mm"], _num(fil.get("diameter"), 1.75) or 1.75, _num(fil.get("density"), 1.24) or 1.24), 3) msg = (f"{grams:.2f} g von {item['name']} auf Spoolman-Spule {smid} nachgebucht (Slot {item['slot'] + 1} " f"druckte nach „Take out“ ohne Spule) – {item['job']}") if self.settings.dry_run: self._write(f"extra:{item['key']}", msg, lambda: None) rest.append(item) continue used = float(sm.get("used_weight") or 0) if item["before"] is not None and used >= item["before"] + item["grams"] - 0.01: self._log("info", msg) # booked before a crash continue item["before"], item["grams"] = used, grams self.bridge.state.save() self.bridge.spoolman.use(smid, grams) self._log("info", msg) except SpoolmanNotFound: self._log("warn", f"Spoolman-Spule {smid} existiert nicht; {item['mm']:.0f} mm aus Slot {item['slot'] + 1} nicht gebucht") except SpoolmanError as e: self.bridge._spoolman_error(str(e)) rest.append(item) self.st["extra"] = rest # ---------- 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 takeout = next((t for t in self.st["takeout"] if t["slot"] == n and t["until"] is None), None) slots.append({ "takeout": {"spool": takeout["spool"], "name": takeout["name"]} if takeout else None, "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_question_mm": _num(cs.get("question_mm")) if cs.get("question") else 0.0, "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, "job": self._job_summary(), "slots": slots, } def _job_summary(self) -> dict | None: job = (self.cc2_data or {}).get("job") or {} if not job.get("active"): return None used = [s for s in job.get("slots") or [] if isinstance(s, dict)] info = self.print_info or {} pr, machine = info.get("print") or {}, info.get("machine") or {} progress = machine.get("progress") return { "file": str(job.get("file") or pr.get("filename") or "").rsplit("/", 1)[-1], "started": job.get("started"), "grams": round(sum(_num(s.get("grams")) for s in used), 2), "slots": [{"slot": i, "grams": round(_num(s.get("grams")), 2)} for i, s in enumerate(used) if _num(s.get("grams")) > 0], "progress": progress if isinstance(progress, (int, float)) else None, "layer": pr.get("current_layer"), "layers": pr.get("total_layers"), "remaining": pr.get("remaining"), "state": pr.get("state"), } 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 cur.pop("reinserted", None) 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) for other in self.workers.values(): last = other.st["last"] for k in [k for k, v in last.items() if v["spool"] == tid or (other is w and k == str(n))]: del last[k] other._close_takeout(n if other is w else None, tid) 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 w.st["last"][str(n)] = {"spool": m["spool"], "name": m.get("name") or str(m["spool"])} 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 _our_printers(self) -> set[str]: """Printer names this bridge runs or has written. Spoolman's "drucker" field naming anyone else is not ours.""" return set(self.state.data.get("written_printers", [])) | {w.name for w in self.workers.values()} 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() our_printers = self._our_printers() 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"] not in our_printers: # a place of yours (e.g. a shelf), or a printer this bridge never ran: 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) if t.get("printer"): printers = set(self.state.data.get("written_printers", [])) | {t["printer"]} self.state.data["written_printers"] = sorted(printers) 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"])