Files
Jonas MenzelandClaude Opus 5.5 d863287f1f
CI / test (push) Successful in 2m46s
CI / image (push) Successful in 50s
CC2 Control copies take the colour the printer reports for the tray
CC2 Control compares a spool's colour exactly with the tray's, which is the
tag's colour from the display palette (e.g. #F72221 for Spoolman's #FF0000),
and flags the slot or asks again when they differ. New copies take the
tag's colour; copies in a slot are set to the tray's colour, others keep
theirs.

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

1275 lines
61 KiB
Python
Raw Permalink 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 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"
# CC2 Control compares the spool's colour exactly with what the printer reports for the tray, which is
# the tag's (a colour of the display palette): take that, so it does not flag the slot or ask again
color = tagcodec.normalize_hex_color(str(channel.get("color") or "").replace("0x", ""))
if color is None:
color = color_hex(sm)
if color is None:
color = "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(" – ")}
self.bridge.spool_cache.pop(b.get("spoolman"), None) # show Spoolman's new rest at once
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)}
tray_color = {} # copy in a slot -> the colour the printer reports there
for s in cc2.get("slots") or []:
c = (s.get("printer") or {}).get("color") if isinstance(s.get("printer"), dict) else None
c = tagcodec.normalize_hex_color(str(c or "").replace("0x", ""))
if slot_spool_id(s) and c:
tray_color[slot_spool_id(s)] = c
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, tray_color.get(cid))
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, tray_color: str | None) -> None:
"""Master data from Spoolman; the colour only for a copy in a slot, and then the printer's colour there."""
fields, _ = cc2_spool_fields(sm, {"color": tray_color} if tray_color else None)
if not tray_color:
fields.pop("color", 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
self.spool_cache.pop(sid, None) # the status page shows the new place at once
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 spool_whereabouts(self, smid: int) -> dict:
"""Where a Spoolman spool is as the bridge sees it, for the spool page."""
with self.lock:
slot = None
for pid, n, m in self.mappings():
if m["spool"] == smid:
w = self.workers.get(pid)
slot = {"printer": w.name if w else pid, "printer_id": pid, "slot": n + 1,
"since": m.get("since"), "assigned": bool(m.get("assigned"))}
copies, last = [], None
for w in self.workers.values():
st = w.st
for t in st["takeout"]:
if t["spool"] == smid and t["until"] is None and slot is None:
slot = {"printer": w.name, "printer_id": w.id, "slot": t["slot"] + 1, "takeout": True}
for sp in (w.cc2_data or {}).get("spools") or []:
if spoolman_id_of(sp) == smid:
copies.append({"printer": w.name, "remaining": _num(sp.get("remaining"), None),
"archived": bool(sp.get("archived"))})
for b in st["booked"].values():
if b.get("spoolman") == smid and b.get("last") and (last is None or b["last"]["time"] > last["time"]):
last = {**b["last"], "printer": w.name}
return {"slot": slot, "copies": copies, "last_booking": last}
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"])