Spoolman leads CC2 Control's copies; new spools from the bridge
CI / test (push) Successful in 2m35s
CI / image (push) Successful in 42s

- Master data of CC2 Control's copies follows Spoolman (name, brand,
  colour, diameter, density, net, tare, price; not the material, which was
  fitted to the tray).
- A loaded spool's rest in CC2 Control is set to Spoolman's via
  /api/spools/adjust once everything is booked (old copy after the roll
  printed in the other printer, weigh-in in Spoolman); the resulting
  correction is not booked back.
- Copies of spools archived or deleted in Spoolman are deleted from CC2
  Control when no tray holds them (library limit 128); an outage never
  counts as deleted.
- A roll that ran out is archived in Spoolman (setting, default on); a
  scan of an archived spool maps nothing.
- A roll scanned again with unchanged tag data and put back into its old
  slot (after printing in the other printer) is recognised.
- Tag generator: "Neue Spule" creates spools in Spoolman from an existing
  or a new filament (and vendor), labelled vendor + id, then opens the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 21:26:49 +01:00
1 parent d03668e3c5
commit b37cd64909
13 files changed
+873 -27

No files matched your search

+137 -6
View File
@@ -27,7 +27,7 @@ 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 .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
@@ -43,6 +43,12 @@ 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",
@@ -200,6 +206,7 @@ class PrinterWorker:
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:
@@ -269,6 +276,7 @@ class PrinterWorker:
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:
@@ -327,6 +335,10 @@ class PrinterWorker:
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
@@ -426,13 +438,22 @@ class PrinterWorker:
"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 still names the roll
that last left this slot: it is that roll again."""
"""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 not last or rid != last["spool"] or self.bridge.mapped_elsewhere(rid, self.id, n):
if rid is None or (last and 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")
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:
@@ -547,6 +568,13 @@ class PrinterWorker:
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
@@ -628,8 +656,12 @@ class PrinterWorker:
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, {"used_weight": used}))
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)
@@ -640,6 +672,105 @@ class PrinterWorker:
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: