- A roll that ran out is set to 0 g in Spoolman (like a weigh-in) instead of booking CC2 Control's delta, so a mirror whose count was off cannot book too much or too little. - A tray that printed while CC2 Control still asked which spool it holds (untagged roll) shows the waiting millimetres on the status page and gets one log warning after the print. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
1105 lines
51 KiB
Python
1105 lines
51 KiB
Python
"""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
|
||
self.noted_questions: dict[int, str] = {} # slot -> question already reported
|
||
|
||
@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)
|
||
|
||
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 _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 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
|
||
if any(e.get("kind") == "runout" for e in fresh):
|
||
# CC2 Control sets a roll that ran out to zero; Spoolman follows absolutely, so a count
|
||
# that was off (e.g. a mirror left with an old value) does not book too much or too little
|
||
if not settled or self._weighin(cid, b, name, smid, rem, newest, describe(fresh)):
|
||
continue
|
||
delta = b["remaining"] - rem
|
||
if delta < -MIN_BOOKING:
|
||
self._log("warn", f"{name}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
|
||
"nicht gebucht, neuer Ausgangsstand")
|
||
b["remaining"] = rem
|
||
b["log_time"] = newest
|
||
continue
|
||
if delta < MIN_BOOKING or not settled:
|
||
continue
|
||
self._book(cid, b, name, smid, delta, rem, describe(fresh), newest)
|
||
except SpoolmanNotFound:
|
||
self.bridge._once_error(f"{self.id}:book:{cid}",
|
||
f"{self.name}: {name}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
|
||
except SpoolmanError as e:
|
||
self.bridge._spoolman_error(str(e))
|
||
|
||
def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float,
|
||
details: str = "", log_time: float | None = None) -> None:
|
||
if self.settings.dry_run:
|
||
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen{details}", lambda: None)
|
||
return
|
||
before = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
|
||
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.bridge.clock(),
|
||
"details": details, "log_time": log_time}
|
||
self.bridge.state.save() # intent first: a crash after the PUT must not book twice
|
||
self.bridge.spoolman.use(smid, delta)
|
||
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht{details}",
|
||
details, log_time)
|
||
|
||
def _booked(self, b: dict, grams: float, target: float, msg: str, details: str = "",
|
||
log_time: float | None = None) -> None:
|
||
b["remaining"] = target
|
||
b["pending"] = None
|
||
b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3), "details": details.removeprefix(" – ")}
|
||
if log_time is not None:
|
||
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
|
||
self.bridge.state.save()
|
||
self._log("info", msg)
|
||
|
||
def _resolve_pending(self, cid: str, b: dict) -> bool:
|
||
"""A booking was interrupted: check whether Spoolman already has it."""
|
||
p = b["pending"]
|
||
smid = b["spoolman"]
|
||
details, log_time = p.get("details") or "", p.get("log_time")
|
||
used = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
|
||
if used >= p["before"] + p["grams"] - 0.01:
|
||
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman",
|
||
details, log_time)
|
||
return True
|
||
if self.settings.dry_run:
|
||
self._write(f"book:{cid}", f"unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} wiederholen",
|
||
lambda: None)
|
||
return False
|
||
self.bridge.spoolman.use(smid, p["grams"])
|
||
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt",
|
||
details, log_time)
|
||
return True
|
||
|
||
def _weighin(self, cid: str, b: dict, name: str, smid: int, rem: float, log_time: float,
|
||
runout: str | None = None) -> bool:
|
||
"""Weighed in CC2 Control, or ran out (`runout` holds the print details): Spoolman takes CC2 Control's
|
||
value as it is. False when Spoolman has no start weight to set it against."""
|
||
sm = self.bridge.spoolman.spool(smid)
|
||
base = sm.get("initial_weight") or (sm.get("filament") or {}).get("weight")
|
||
if not base:
|
||
what = "Leerlaufen per Verbrauch gebucht" if runout is not None else "Nachwiegen nicht übernommen"
|
||
self.bridge._once_error(f"{self.id}:weigh:{cid}", f"{self.name}: {name}: Spoolman-Spule {smid} hat kein "
|
||
f"Startgewicht; {what}")
|
||
return False
|
||
target = max(0.0, rem)
|
||
used = round(max(0.0, float(base) - target), 3)
|
||
prev = _num(sm.get("remaining_weight"), float(base) - _num(sm.get("used_weight")))
|
||
why = f"(Rolle leergelaufen){runout}" if runout is not None else "(in CC2 Control nachgewogen)"
|
||
_, 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}))
|
||
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 log: use of a tray left without spool after "Take out" ----------
|
||
|
||
def _process_cc2_log(self, cc2: dict) -> None:
|
||
st = self.st
|
||
entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)]
|
||
keys = [log_key(e) for e in entries]
|
||
now = self.bridge.clock()
|
||
if st.get("log_seen") is None:
|
||
st["log_seen"] = keys[:LOG_SEEN_KEEP] # older entries are not ours to book
|
||
else:
|
||
seen = set(st["log_seen"])
|
||
for e, key in zip(entries, keys):
|
||
if key in seen:
|
||
continue
|
||
st["log_seen"].append(key)
|
||
seen.add(key)
|
||
n, t, mm = e.get("slot"), _num(e.get("time")), _num(e.get("mm"))
|
||
if e.get("kind") != "print" or e.get("spool") or n not in (0, 1, 2, 3) or mm <= 0:
|
||
continue
|
||
win = next((w for w in st["takeout"] if w["slot"] == n and
|
||
w["since"] - TAKEOUT_MARGIN <= t <= (w["until"] or now) + TAKEOUT_MARGIN), None)
|
||
if win:
|
||
st["extra"].append({"key": key, "slot": n, "spool": win["spool"], "name": win["name"], "mm": mm,
|
||
"job": job_text(e), "before": None, "grams": None})
|
||
st["log_seen"] = st["log_seen"][-LOG_SEEN_KEEP:]
|
||
st["takeout"] = [w for w in st["takeout"] if w["until"] is None or now - w["until"] < TAKEOUT_KEEP]
|
||
self._process_extra()
|
||
|
||
def _process_extra(self) -> None:
|
||
rest = []
|
||
for item in self.st["extra"]:
|
||
smid = item["spool"]
|
||
try:
|
||
sm = self.bridge.spoolman.spool(smid)
|
||
fil = sm.get("filament") or {}
|
||
grams = round(grams_from_mm(item["mm"], _num(fil.get("diameter"), 1.75) or 1.75,
|
||
_num(fil.get("density"), 1.24) or 1.24), 3)
|
||
msg = (f"{grams:.2f} g von {item['name']} auf Spoolman-Spule {smid} nachgebucht (Slot {item['slot'] + 1} "
|
||
f"druckte nach „Take out“ ohne Spule) – {item['job']}")
|
||
if self.settings.dry_run:
|
||
self._write(f"extra:{item['key']}", msg, lambda: None)
|
||
rest.append(item)
|
||
continue
|
||
used = float(sm.get("used_weight") or 0)
|
||
if item["before"] is not None and used >= item["before"] + item["grams"] - 0.01:
|
||
self._log("info", msg) # booked before a crash
|
||
continue
|
||
item["before"], item["grams"] = used, grams
|
||
self.bridge.state.save()
|
||
self.bridge.spoolman.use(smid, grams)
|
||
self._log("info", msg)
|
||
except SpoolmanNotFound:
|
||
self._log("warn", f"Spoolman-Spule {smid} existiert nicht; {item['mm']:.0f} mm aus Slot {item['slot'] + 1} nicht gebucht")
|
||
except SpoolmanError as e:
|
||
self.bridge._spoolman_error(str(e))
|
||
rest.append(item)
|
||
self.st["extra"] = rest
|
||
|
||
# ---------- UI ----------
|
||
|
||
def snapshot(self) -> dict:
|
||
cc2 = self.cc2_data
|
||
record_slots = tagcodec.parse_record(self.record or self.st.get("last_record") or {}, self.settings.id_offset)
|
||
cc2_slots = {s.get("slot"): s for s in (cc2 or {}).get("slots") or []}
|
||
cc2_spools = {str(s.get("id")): s for s in (cc2 or {}).get("spools") or []}
|
||
slots = []
|
||
for n in range(4):
|
||
m = self.st["slots"].get(str(n))
|
||
cs = cc2_slots.get(n) or {}
|
||
sid = slot_spool_id(cs)
|
||
cached = self.bridge.spool_cache.get(m["spool"]) if m else None
|
||
b = self.st["booked"].get(m["cc2"]) if m and m.get("cc2") else None
|
||
takeout = next((t for t in self.st["takeout"] if t["slot"] == n and t["until"] is None), None)
|
||
slots.append({
|
||
"takeout": {"spool": takeout["spool"], "name": takeout["name"]} if takeout else None,
|
||
"slot": n,
|
||
"record": record_slots[n],
|
||
"tray_status": self.tray_status.get(n),
|
||
"mapping": m,
|
||
"location": self.settings.slot_location(self.cfg, n),
|
||
"spoolman": summary(cached[1]) if cached else None,
|
||
"cc2_spool": cc2_spools.get(sid) if sid else None,
|
||
"cc2_question": cs.get("question") or "",
|
||
"cc2_question_mm": _num(cs.get("question_mm")) if cs.get("question") else 0.0,
|
||
"cc2_matches": bool(m and m.get("cc2") and sid == m["cc2"]),
|
||
"last_booking": (b or {}).get("last"),
|
||
})
|
||
return {
|
||
"id": self.id, "name": self.name, "enabled": self.cfg.enabled, "host": self.cfg.host,
|
||
"errors": dict(self.errors),
|
||
"cc2_enabled": None if cc2 is None else bool(cc2.get("enabled")),
|
||
"job_active": None if cc2 is None else bool((cc2.get("job") or {}).get("active")),
|
||
"host_key": self.bridge.keys.host_key(self.id) is not None,
|
||
"job": self._job_summary(),
|
||
"slots": slots,
|
||
}
|
||
|
||
def _job_summary(self) -> dict | None:
|
||
job = (self.cc2_data or {}).get("job") or {}
|
||
if not job.get("active"):
|
||
return None
|
||
used = [s for s in job.get("slots") or [] if isinstance(s, dict)]
|
||
info = self.print_info or {}
|
||
pr, machine = info.get("print") or {}, info.get("machine") or {}
|
||
progress = machine.get("progress")
|
||
return {
|
||
"file": str(job.get("file") or pr.get("filename") or "").rsplit("/", 1)[-1],
|
||
"started": job.get("started"),
|
||
"grams": round(sum(_num(s.get("grams")) for s in used), 2),
|
||
"slots": [{"slot": i, "grams": round(_num(s.get("grams")), 2)} for i, s in enumerate(used)
|
||
if _num(s.get("grams")) > 0],
|
||
"progress": progress if isinstance(progress, (int, float)) else None,
|
||
"layer": pr.get("current_layer"),
|
||
"layers": pr.get("total_layers"),
|
||
"remaining": pr.get("remaining"),
|
||
"state": pr.get("state"),
|
||
}
|
||
|
||
|
||
class Bridge:
|
||
def __init__(self, data_dir: Path, settings: SettingsStore, keys: KeyStore, *,
|
||
reader_factory=None, cc2_factory=None, spoolman_factory=None, clock=time.time):
|
||
self.data_dir = data_dir
|
||
self.settings_store = settings
|
||
self.keys = keys
|
||
self.clock = clock
|
||
self.settings: Settings = settings.get()
|
||
first = self.settings.printers[0].id if self.settings.printers else "p1"
|
||
keys.migrate_host_key(first)
|
||
self.state = StateStore(data_dir, first)
|
||
self.log = ActionLog(data_dir, clock)
|
||
self.reader_factory = reader_factory or (
|
||
lambda p: CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user))
|
||
self.cc2_factory = cc2_factory or (lambda p: CC2Control(p.cc2_url))
|
||
self.spoolman_factory = spoolman_factory or (lambda s: Spoolman(s.spoolman_url))
|
||
self.workers: dict[str, PrinterWorker] = {}
|
||
self._worker_cfg: dict[str, dict] = {}
|
||
self.spoolman = None
|
||
self._spoolman_url: str | None = None
|
||
self.spoolman_err: str | None = None
|
||
self.fields_ok = False
|
||
self.last_sync = 0.0
|
||
self.lock = threading.RLock()
|
||
self.commands: queue.Queue = queue.Queue()
|
||
self.wake = threading.Event()
|
||
self.dry_logged: dict[str, str] = {}
|
||
self.spool_cache: dict[int, tuple[float, dict]] = {}
|
||
self.snapshot: dict = {"last_poll": None, "printers": []}
|
||
|
||
# ---------- infrastructure ----------
|
||
|
||
def _sync_workers(self) -> None:
|
||
s = self.settings
|
||
if s.spoolman_url != self._spoolman_url:
|
||
if self.spoolman is not None:
|
||
self.spoolman.close()
|
||
self.spoolman = self.spoolman_factory(s)
|
||
self._spoolman_url = s.spoolman_url
|
||
self.fields_ok = False
|
||
wanted = {p.id: p for p in s.printers}
|
||
for pid in list(self.workers):
|
||
if pid not in wanted or self._worker_cfg[pid] != wanted[pid].to_dict():
|
||
self.workers.pop(pid).close()
|
||
self._worker_cfg.pop(pid)
|
||
for pid, cfg in wanted.items():
|
||
if pid not in self.workers:
|
||
self.workers[pid] = PrinterWorker(self, cfg)
|
||
self._worker_cfg[pid] = cfg.to_dict()
|
||
else:
|
||
self.workers[pid].cfg = cfg
|
||
for pid in list(self.state.data["printers"]):
|
||
if pid not in wanted:
|
||
st = self.state.data["printers"].pop(pid)
|
||
if st.get("slots"):
|
||
self.log.add("info", f"Drucker {pid} entfernt; seine {len(st['slots'])} Spule(n) gehen ins Lager")
|
||
|
||
def _spoolman_error(self, msg: str | None) -> None:
|
||
if msg != self.spoolman_err:
|
||
if msg:
|
||
self.log.add("error", msg)
|
||
elif self.spoolman_err:
|
||
self.log.add("info", "Spoolman wieder erreichbar")
|
||
self.spoolman_err = msg
|
||
|
||
def _once_error(self, key: str, msg: str) -> None:
|
||
if self.dry_logged.get(key) != msg:
|
||
self.dry_logged[key] = msg
|
||
self.log.add("error", msg)
|
||
|
||
def _write(self, key: str, msg: str, fn):
|
||
"""Run a write, or in dry-run only log it once per key/message."""
|
||
if self.settings.dry_run:
|
||
if self.dry_logged.get(key) != msg:
|
||
self.dry_logged[key] = msg
|
||
self.log.add("dry", f"würde {msg}")
|
||
return None, False
|
||
self.dry_logged.pop(key, None)
|
||
result = fn()
|
||
self.log.add("info", f"erledigt: {msg}")
|
||
return result, True
|
||
|
||
def run(self, stop: threading.Event) -> None:
|
||
while not stop.is_set():
|
||
started = self.clock()
|
||
try:
|
||
self.poll_once()
|
||
except Exception: # never let the loop die
|
||
log.exception("poll failed")
|
||
self.log.add("error", "Interner Fehler im Abfragezyklus (Details im Container-Log)")
|
||
interval = self.settings.poll_interval
|
||
self.wake.wait(max(0.5, interval - (self.clock() - started)))
|
||
self.wake.clear()
|
||
|
||
# ---------- one cycle ----------
|
||
|
||
def poll_once(self) -> None:
|
||
with self.lock:
|
||
self.settings = self.settings_store.get()
|
||
self._sync_workers()
|
||
self._run_commands()
|
||
for w in self.workers.values():
|
||
if w.cfg.enabled:
|
||
try:
|
||
w.poll()
|
||
except Exception:
|
||
log.exception("printer %s failed", w.id)
|
||
w._log("error", "Interner Fehler (Details im Container-Log)")
|
||
self._reconcile_locations()
|
||
self._refresh_spool_cache()
|
||
self.state.save()
|
||
self.snapshot = {
|
||
"last_poll": self.clock(),
|
||
"dry_run": self.settings.dry_run,
|
||
"spoolman_error": self.spoolman_err,
|
||
"ssh_key": self.keys.has_key(),
|
||
"printers": [w.snapshot() for w in self.workers.values()],
|
||
}
|
||
|
||
def _run_commands(self) -> None:
|
||
while True:
|
||
try:
|
||
cmd, pid, arg = self.commands.get_nowait()
|
||
except queue.Empty:
|
||
return
|
||
w = self.workers.get(pid)
|
||
if w is None:
|
||
continue
|
||
try:
|
||
if cmd == "enable":
|
||
w._write("enable", "Spulenverwaltung in CC2 Control einschalten", lambda: w.cc2.enable(True))
|
||
elif cmd == "reassign":
|
||
m = w.st["slots"].get(str(arg))
|
||
if m:
|
||
m["assigned"] = False
|
||
self.dry_logged.pop(f"{pid}:assign:{arg}", None)
|
||
w._log("info", f"Slot {arg + 1}: Zuordnung wird erneut an CC2 Control gesendet")
|
||
elif cmd == "release":
|
||
if str(arg) in w.st["slots"]:
|
||
self.release(w, arg, "manuell aufgehoben")
|
||
except (CC2Error, SpoolmanError) as e:
|
||
w._log("error", str(e))
|
||
|
||
# ---------- mappings across printers ----------
|
||
|
||
def mappings(self):
|
||
for pid, st in self.state.data["printers"].items():
|
||
for k, m in st.get("slots", {}).items():
|
||
yield pid, int(k), m
|
||
|
||
def mapped_elsewhere(self, spool_id: int, pid: str, n: int) -> bool:
|
||
return any(m["spool"] == spool_id and (p, k) != (pid, n) for p, k, m in self.mappings())
|
||
|
||
def map(self, w: PrinterWorker, n: int, tid: int, name: str, reason: str, cc2_id: str | None = None) -> None:
|
||
mappings = w.st["slots"]
|
||
cur = mappings.get(str(n))
|
||
if cur and cur["spool"] == tid:
|
||
if cc2_id is None:
|
||
cur["assigned"] = False # rescan: make sure CC2 Control has it in the slot
|
||
cur.pop("reinserted", None)
|
||
self.dry_logged.pop(f"{w.id}:assign:{n}", None)
|
||
return
|
||
for pid, k, m in list(self.mappings()):
|
||
if m["spool"] == tid and (pid, k) != (w.id, n):
|
||
del self.state.data["printers"][pid]["slots"][str(k)]
|
||
other = self.workers.get(pid)
|
||
where = f"Slot {k + 1}" if pid == w.id else f"{other.name if other else pid} Slot {k + 1}"
|
||
w._log("info", f"Spule {name} von {where} nach Slot {n + 1} umgezogen")
|
||
if cur:
|
||
self.release(w, n, f"andere Spule ({name})")
|
||
mappings[str(n)] = {
|
||
"spool": tid, "name": name, "since": self.clock(),
|
||
"cc2": cc2_id, "assigned": cc2_id is not None, "cc2_seen": cc2_id is not None,
|
||
}
|
||
w.empty_since.pop(n, None)
|
||
for other in self.workers.values():
|
||
last = other.st["last"]
|
||
for k in [k for k, v in last.items() if v["spool"] == tid or (other is w and k == str(n))]:
|
||
del last[k]
|
||
other._close_takeout(n if other is w else None, tid)
|
||
w._log("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})")
|
||
|
||
def release(self, w: PrinterWorker, n: int, reason: str) -> None:
|
||
m = w.st["slots"].pop(str(n), None)
|
||
if not m:
|
||
return
|
||
w.st["last"][str(n)] = {"spool": m["spool"], "name": m.get("name") or str(m["spool"])}
|
||
self.dry_logged.pop(f"{w.id}:assign:{n}", None)
|
||
w._log("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})")
|
||
|
||
# ---------- Spoolman locations and extra fields ----------
|
||
|
||
def _desired(self) -> dict[int, dict]:
|
||
out = {}
|
||
for pid, n, m in self.mappings():
|
||
w = self.workers.get(pid)
|
||
if w is None:
|
||
continue
|
||
out[m["spool"]] = {"location": self.settings.slot_location(w.cfg, n), "printer": w.name, "slot": n + 1}
|
||
return out
|
||
|
||
def _storage(self) -> dict:
|
||
return {"location": self.settings.location_storage, "printer": None, "slot": None}
|
||
|
||
def _actual(self, sp: dict) -> dict:
|
||
return {"location": sp.get("location") or "", "printer": extra_value(sp, FIELD_PRINTER),
|
||
"slot": extra_value(sp, FIELD_SLOT)}
|
||
|
||
def _same(self, a: dict | None, b: dict) -> bool:
|
||
if a is None or a.get("location") != b["location"]:
|
||
return False
|
||
if not self.settings.spoolman_fields:
|
||
return True
|
||
return a.get("printer") == b["printer"] and a.get("slot") == b["slot"]
|
||
|
||
def _our_locations(self) -> set[str]:
|
||
ours = set(self.state.data.get("written_locations", []))
|
||
for w in self.workers.values():
|
||
ours.update(self.settings.slot_location(w.cfg, n) for n in range(4))
|
||
ours.discard(self.settings.location_storage)
|
||
return ours
|
||
|
||
def _our_printers(self) -> set[str]:
|
||
"""Printer names this bridge runs or has written. Spoolman's "drucker" field naming anyone else is not ours."""
|
||
return set(self.state.data.get("written_printers", [])) | {w.name for w in self.workers.values()}
|
||
|
||
def _ensure_fields(self) -> bool:
|
||
if self.fields_ok or not self.settings.spoolman_fields:
|
||
return True
|
||
existing = {f.get("key") for f in self.spoolman.fields()}
|
||
missing = {k: v for k, v in FIELD_DEFS.items() if k not in existing}
|
||
for key, params in missing.items():
|
||
_, done = self._write(f"field:{key}", f"in Spoolman das Zusatzfeld „{params['name']}“ ({key}) anlegen",
|
||
lambda: self.spoolman.add_field(key, params))
|
||
if not done:
|
||
return False
|
||
self.fields_ok = True
|
||
return True
|
||
|
||
def _reconcile_locations(self) -> None:
|
||
if self.spoolman is None:
|
||
return
|
||
desired = self._desired()
|
||
storage = self._storage()
|
||
applied = self.state.data["applied"]
|
||
targets: dict[int, tuple[dict, str]] = {}
|
||
for sid, want in desired.items():
|
||
if not self._same(applied.get(str(sid)), want):
|
||
targets[sid] = (want, "")
|
||
for k, a in applied.items():
|
||
if int(k) not in desired and not self._same(a, storage):
|
||
targets[int(k)] = (storage, "")
|
||
names: dict[int, str] = {}
|
||
try:
|
||
if self.clock() - self.last_sync >= SYNC_SECONDS:
|
||
# Full comparison: the printer wins over manual edits in Spoolman.
|
||
ours = self._our_locations()
|
||
our_printers = self._our_printers()
|
||
for sp in self.spoolman.spools():
|
||
sid = int(sp["id"])
|
||
names[sid] = label(sp)
|
||
actual = self._actual(sp)
|
||
want = desired.get(sid)
|
||
if want is None:
|
||
if actual["location"] not in ours and actual["printer"] not in our_printers:
|
||
# a place of yours (e.g. a shelf), or a printer this bridge never ran: not ours to touch
|
||
targets.pop(sid, None)
|
||
applied.pop(str(sid), None)
|
||
continue
|
||
want = storage
|
||
if self._same(actual, want):
|
||
targets.pop(sid, None)
|
||
applied[str(sid)] = want
|
||
elif sid not in targets:
|
||
targets[sid] = (want, " (korrigiert)")
|
||
self.last_sync = self.clock()
|
||
self._spoolman_error(None)
|
||
if targets and not self._ensure_fields():
|
||
for sid, (t, why) in targets.items():
|
||
self._write(f"location:{sid}", self._location_msg(names.get(sid) or self._name(sid), t, why), lambda: None)
|
||
return
|
||
for sid, (t, why) in targets.items():
|
||
name = names.get(sid) or self._name(sid)
|
||
try:
|
||
_, done = self._write(f"location:{sid}", self._location_msg(name, t, why),
|
||
lambda: self.spoolman.update_spool(sid, self._patch_body(t)))
|
||
except SpoolmanNotFound:
|
||
self.log.add("warn", f"Spoolman-Spule {sid} existiert nicht mehr; Ort nicht gesetzt")
|
||
applied.pop(str(sid), None)
|
||
continue
|
||
if done:
|
||
applied[str(sid)] = t
|
||
if t["location"] != storage["location"]:
|
||
written = set(self.state.data["written_locations"]) | {t["location"]}
|
||
self.state.data["written_locations"] = sorted(written)
|
||
if t.get("printer"):
|
||
printers = set(self.state.data.get("written_printers", [])) | {t["printer"]}
|
||
self.state.data["written_printers"] = sorted(printers)
|
||
except SpoolmanError as e:
|
||
self.fields_ok = False
|
||
self._spoolman_error(str(e))
|
||
|
||
def _location_msg(self, name: str, t: dict, why: str) -> str:
|
||
extra = ""
|
||
if self.settings.spoolman_fields:
|
||
extra = f", Drucker {t['printer']}, Slot {t['slot']}" if t["printer"] else ", Drucker/Slot geleert"
|
||
return f"in Spoolman {name}: Ort „{t['location']}“{extra}{why}"
|
||
|
||
def _patch_body(self, t: dict) -> dict:
|
||
body: dict = {"location": t["location"]}
|
||
if self.settings.spoolman_fields:
|
||
body["extra"] = {
|
||
FIELD_PRINTER: json.dumps(t["printer"], ensure_ascii=False) if t["printer"] else None,
|
||
FIELD_SLOT: json.dumps(t["slot"]) if t["slot"] else None,
|
||
}
|
||
return body
|
||
|
||
# ---------- Spoolman cache ----------
|
||
|
||
def spool(self, smid: int) -> dict | None:
|
||
cached = self.spool_cache.get(smid)
|
||
if cached and self.clock() - cached[0] < SPOOL_CACHE_SECONDS:
|
||
return cached[1]
|
||
try:
|
||
sm = self.spoolman.spool(smid)
|
||
except SpoolmanNotFound:
|
||
self._once_error(f"missing:{smid}", f"Spoolman-Spule {smid} existiert nicht")
|
||
return None
|
||
except SpoolmanError as e:
|
||
self._spoolman_error(str(e))
|
||
return cached[1] if cached else None
|
||
self.spool_cache[smid] = (self.clock(), sm)
|
||
return sm
|
||
|
||
def _name(self, smid: int) -> str:
|
||
cached = self.spool_cache.get(smid)
|
||
return label(cached[1]) if cached else f"Spoolman-Spule {smid}"
|
||
|
||
def _refresh_spool_cache(self) -> None:
|
||
if self.spoolman is None:
|
||
return
|
||
for _, _, m in self.mappings():
|
||
self.spool(m["spool"])
|