Spoolman leads CC2 Control's copies; new spools from the bridge
- 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:
1 parent
d03668e3c5
commit
b37cd64909
13 files changed
+873
-27
No files matched your search
+137
-6
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user