Files
cc2-spoolman-bridge/app/bridge.py
T
Jonas MenzelandClaude Opus 5.5 c04964066f Never touch spools of printers this bridge never ran
A fresh instance (empty data volume, no printers configured yet) treated
every spool whose "drucker" field named a printer as stale and moved it to
storage. Now only spools at our slot locations or naming a printer this
bridge runs or has written are corrected; written printer names are kept
in the state (seeded from the applied locations on update).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
2026-10-25 20:32:51 +01:00

1072 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 "<printer> 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 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
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
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
@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)
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)
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 _reinserted(self, n: int) -> None:
"""No mapping, CC2 Control asks about filament put in, and the record still names the roll
that last left this slot: it is that roll again."""
last = self.st["last"].get(str(n))
rid = self._record_id(n)
if not last or rid != last["spool"] or self.bridge.mapped_elsewhere(rid, self.id, n):
return
self.bridge.map(self, n, rid, last["name"], "wieder eingelegt, Tag-Daten unverändert")
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):
if settled:
self._weighin(cid, b, name, smid, rem, newest)
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) -> None:
"""Weighed in CC2 Control: Spoolman takes the weighed value (and any use since) as it is."""
sm = self.bridge.spoolman.spool(smid)
base = sm.get("initial_weight") or (sm.get("filament") or {}).get("weight")
if not base:
self.bridge._once_error(f"{self.id}:weigh:{cid}", f"{self.name}: {name}: Spoolman-Spule {smid} hat kein "
"Startgewicht; Nachwiegen nicht übernommen")
return
target = max(0.0, rem)
used = round(max(0.0, float(base) - target), 3)
_, done = self._write(f"weigh:{cid}", f"in Spoolman den Rest von {name} auf {target:.1f} g setzen "
"(in CC2 Control nachgewogen)",
lambda: self.bridge.spoolman.update_spool(smid, {"used_weight": used}))
if done:
b["remaining"] = rem
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
self.bridge.spool_cache.pop(smid, None)
self.bridge.state.save()
# ---------- 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_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"])