- A roll put back into the slot it left, with the tag data unchanged, is mapped again and CC2 Control's question answered; if the touchscreen then replaces the id, it was another roll and goes to storage. - A touchscreen edit of a scanned roll keeps the mapping and re-answers CC2 Control's question so its use is counted. - Bookings name the print and its result from CC2 Control's log. - A weigh-in in CC2 Control sets Spoolman's remaining weight instead of being booked as use. - After "Take out" with the roll still in the tray, the tray's logged use is booked to that roll until it leaves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
1035 lines
47 KiB
Python
1035 lines
47 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
|
||
|
||
@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))
|
||
if self.record is not None:
|
||
self._process_record(self.record)
|
||
self._process_scans()
|
||
cc2 = self.cc2_data
|
||
if cc2 is not None:
|
||
self._process_trays(cc2)
|
||
self._process_cc2_slots(cc2)
|
||
self._reconcile_cc2(cc2)
|
||
self._process_cc2_log(cc2)
|
||
self._consumption(cc2)
|
||
|
||
def _record_id(self, n: int) -> int | None:
|
||
for s in self.record_slots:
|
||
if s["slot"] == n:
|
||
return s["spool_id"]
|
||
return None
|
||
|
||
def _close_takeout(self, n: int | None = None, spool: int | None = None) -> None:
|
||
now = self.bridge.clock()
|
||
for w in self.st["takeout"]:
|
||
if w.get("until") is None and (w["slot"] == n or w["spool"] == spool):
|
||
w["until"] = now
|
||
|
||
# ---------- record / scans ----------
|
||
|
||
def _process_record(self, record: dict) -> None:
|
||
offset = self.settings.id_offset
|
||
slots = tagcodec.parse_record(record, offset)
|
||
prev_record = self.st.get("last_record")
|
||
prev = tagcodec.parse_record(prev_record, offset) if prev_record else None
|
||
mappings = self.st["slots"]
|
||
for s in slots:
|
||
n, tid = s["slot"], s["spool_id"]
|
||
if prev is not None and tid != prev[n]["spool_id"]:
|
||
# the tray's tag data was rewritten: whatever was there before is gone
|
||
self.st["last"].pop(str(n), None)
|
||
self._close_takeout(n)
|
||
m = mappings.get(str(n))
|
||
if tid is None and m and m.get("reinserted"):
|
||
self.bridge.release(self, n, "Tag-Daten am Display ersetzt, also eine andere Rolle ohne Spoolman-Tag")
|
||
if tid is None:
|
||
continue
|
||
if prev is not None:
|
||
is_new = tid != prev[n]["spool_id"]
|
||
else:
|
||
m = mappings.get(str(n))
|
||
is_new = m is None or m["spool"] != tid
|
||
if is_new:
|
||
self.st["scans"].append({"slot": n, "spool": tid, "time": self.bridge.clock()})
|
||
self._log("info", f"Slot {n + 1}: Tag mit Spoolman-ID {tid} erkannt")
|
||
self.st["last_record"] = record
|
||
self.record_slots = slots
|
||
|
||
def _process_scans(self) -> None:
|
||
pending = []
|
||
for scan in self.st["scans"]:
|
||
n, tid = scan["slot"], scan["spool"]
|
||
try:
|
||
sm = self.bridge.spoolman.spool(tid)
|
||
self.bridge._spoolman_error(None)
|
||
except SpoolmanNotFound:
|
||
self._log("warn", f"Slot {n + 1}: Spoolman-Spule {tid} existiert nicht; nichts geändert")
|
||
continue
|
||
except SpoolmanError as e:
|
||
self.bridge._spoolman_error(str(e))
|
||
pending.append(scan) # retry next cycle
|
||
continue
|
||
self.bridge.spool_cache[tid] = (self.bridge.clock(), sm)
|
||
self.bridge.map(self, n, tid, label(sm), reason="Tag gescannt")
|
||
self.st["scans"] = pending
|
||
|
||
# ---------- CC2 Control ----------
|
||
|
||
def _process_trays(self, cc2: dict) -> None:
|
||
statuses = {}
|
||
for slot in cc2.get("slots") or []:
|
||
printer = slot.get("printer")
|
||
if isinstance(printer, dict) and slot.get("slot") in (0, 1, 2, 3):
|
||
try:
|
||
statuses[slot["slot"]] = int(printer.get("status"))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if not statuses:
|
||
try:
|
||
statuses = tray_statuses(self.cc2.canvas())
|
||
except CC2Error:
|
||
statuses = {}
|
||
self.tray_status = statuses
|
||
now = self.bridge.clock()
|
||
for n in range(4):
|
||
st = statuses.get(n)
|
||
if st is None:
|
||
continue
|
||
if st == 0:
|
||
self._close_takeout(n)
|
||
since = self.empty_since.setdefault(n, now)
|
||
if now - since >= self.settings.empty_grace and str(n) in self.st["slots"]:
|
||
self.bridge.release(self, n, f"Slot seit {int(now - since)} s leer")
|
||
else:
|
||
self.empty_since.pop(n, None)
|
||
|
||
def _process_cc2_slots(self, cc2: dict) -> None:
|
||
by_id = {str(s.get("id")): s for s in cc2.get("spools") or []}
|
||
mappings = self.st["slots"]
|
||
for slot in cc2.get("slots") or []:
|
||
n = slot.get("slot")
|
||
if n not in (0, 1, 2, 3):
|
||
continue
|
||
sid = slot_spool_id(slot)
|
||
m = mappings.get(str(n))
|
||
mirrored = spoolman_id_of(by_id[sid]) if sid in by_id else None
|
||
question = str(slot.get("question") or "")
|
||
loaded = self.tray_status.get(n) in (1, 2)
|
||
if question:
|
||
self._close_takeout(n) # filament went in or was changed: maybe another roll
|
||
# A spool we already map elsewhere (fresh scan, maybe in the other printer) wins
|
||
# over this CC2 Control's older binding.
|
||
elsewhere = mirrored is not None and self.bridge.mapped_elsewhere(mirrored, self.id, n)
|
||
if m is None:
|
||
if mirrored is not None and not elsewhere and loaded:
|
||
# assigned in CC2 Control by hand
|
||
self.bridge.map(self, n, mirrored, by_id[sid].get("name") or str(mirrored),
|
||
"in CC2 Control zugewiesen", sid)
|
||
elif not sid and question == "inserted" and loaded:
|
||
self._reinserted(n)
|
||
continue
|
||
if m.get("cc2") and sid == m["cc2"]:
|
||
m["cc2_seen"] = m["assigned"] = True
|
||
continue
|
||
if not m.get("cc2_seen"):
|
||
continue
|
||
if sid:
|
||
if elsewhere:
|
||
continue
|
||
if mirrored is not None and mirrored != m["spool"]:
|
||
self.bridge.map(self, n, mirrored, by_id[sid].get("name") or str(mirrored),
|
||
"in CC2 Control zugewiesen", sid)
|
||
elif mirrored is None:
|
||
self.bridge.release(self, n, "in CC2 Control mit anderer Spule belegt")
|
||
elif not question:
|
||
if loaded and self._record_id(n) == m["spool"]:
|
||
# "Take out" with the roll still in the tray: Spoolman says storage, but what the
|
||
# tray prints now is still that roll's
|
||
self.st["takeout"].append({"slot": n, "spool": m["spool"], "name": m.get("name") or str(m["spool"]),
|
||
"since": self.bridge.clock(), "until": None})
|
||
self.bridge.release(self, n, "in CC2 Control herausgenommen bzw. ins Lager gelegt")
|
||
elif loaded:
|
||
self._answer_again(n, m, question)
|
||
|
||
def _reinserted(self, n: int) -> None:
|
||
"""No mapping, CC2 Control asks about filament put in, and the record still names the roll
|
||
that last left this slot: it is that roll again."""
|
||
last = self.st["last"].get(str(n))
|
||
rid = self._record_id(n)
|
||
if not last or rid != last["spool"] or self.bridge.mapped_elsewhere(rid, self.id, n):
|
||
return
|
||
self.bridge.map(self, n, rid, last["name"], "wieder eingelegt, Tag-Daten unverändert")
|
||
self.st["slots"][str(n)]["reinserted"] = True
|
||
|
||
def _answer_again(self, n: int, m: dict, question: str) -> None:
|
||
"""CC2 Control unbound a mapped slot and asks which spool it is."""
|
||
rid = self._record_id(n)
|
||
if question == "inserted":
|
||
if rid != m["spool"]:
|
||
self.bridge.release(self, n, "andere Rolle eingelegt")
|
||
return
|
||
m["reinserted"] = True # taken out and put back without a new scan
|
||
elif rid != m["spool"] and (rid is not None or m.get("reinserted")):
|
||
return
|
||
# same roll (or a touchscreen edit of it): tell CC2 Control again, at most once a minute
|
||
if not m.get("assigned") or self.bridge.clock() - m.get("answered_at", 0) < REANSWER_SECONDS:
|
||
return
|
||
m["assigned"] = False
|
||
m["answered_at"] = self.bridge.clock()
|
||
self.bridge.dry_logged.pop(f"{self.id}:assign:{n}", None)
|
||
self._log("info", f"Slot {n + 1}: CC2 Control fragt nach der Spule ({question}); "
|
||
f"{m.get('name') or m['spool']} wird erneut zugewiesen")
|
||
|
||
def _reconcile_cc2(self, cc2: dict) -> None:
|
||
mirrors: dict[int, dict] = {}
|
||
for sp in cc2.get("spools") or []:
|
||
smid = spoolman_id_of(sp)
|
||
if smid is not None and (smid not in mirrors or mirrors[smid].get("archived")):
|
||
mirrors[smid] = sp
|
||
slots = {s.get("slot"): s for s in cc2.get("slots") or []}
|
||
record_slots = {s["slot"]: s for s in self.record_slots}
|
||
for k, m in list(self.st["slots"].items()):
|
||
n = int(k)
|
||
if m.get("assigned"):
|
||
continue
|
||
try:
|
||
mirror = mirrors.get(m["spool"])
|
||
if mirror is None:
|
||
sm = self.bridge.spool(m["spool"])
|
||
if sm is None:
|
||
continue
|
||
channel = (record_slots.get(n) or {}).get("channel")
|
||
fields, notes = cc2_spool_fields(sm, channel)
|
||
for note in notes:
|
||
self._log("warn", f"{fields['name']}: {note}")
|
||
resp, done = self._write(
|
||
f"create:{m['spool']}", f"in CC2 Control Spule {fields['name']} anlegen "
|
||
f"({fields['material']}, {fields['color']}, Rest {fields.get('remaining', '?')} g)",
|
||
lambda: self.cc2.save_spool(fields))
|
||
if not done:
|
||
self._write(f"assign:{n}", f"in CC2 Control {fields['name']} in Slot {n + 1} legen", lambda: None)
|
||
continue
|
||
cc2_id = str((resp or {}).get("id") or "")
|
||
if not cc2_id:
|
||
self._log("error", "CC2 Control hat keine Spulen-ID zurückgegeben")
|
||
continue
|
||
self.st["booked"][cc2_id] = {
|
||
"spoolman": m["spool"], "remaining": float(fields.get("remaining", fields.get("net", 1000))),
|
||
"pending": None, "last": None, "log_time": 0.0,
|
||
}
|
||
m["cc2"] = cc2_id
|
||
else:
|
||
m["cc2"] = str(mirror["id"])
|
||
if mirror.get("archived"):
|
||
_, done = self._write(f"unarchive:{m['cc2']}", f"in CC2 Control {mirror.get('name')} aus dem Archiv holen",
|
||
lambda: self.cc2.save_spool({"id": m["cc2"], "archived": "0"}))
|
||
if not done:
|
||
continue
|
||
if slot_spool_id(slots.get(n) or {}) == m["cc2"]:
|
||
m["assigned"] = m["cc2_seen"] = True
|
||
continue
|
||
_, done = self._write(f"assign:{n}", f"in CC2 Control {m.get('name')} in Slot {n + 1} legen",
|
||
lambda: self.cc2.assign(n, m["cc2"]))
|
||
if done:
|
||
m["assigned"] = m["cc2_seen"] = True
|
||
except CC2Error as e:
|
||
self._log("error", f"Slot {n + 1}: {e}")
|
||
|
||
# ---------- consumption ----------
|
||
|
||
def _consumption(self, cc2: dict) -> None:
|
||
job_active = bool((cc2.get("job") or {}).get("active"))
|
||
if job_active:
|
||
# a print ran with it: a later touchscreen edit is an edit of this roll
|
||
for m in self.st["slots"].values():
|
||
m.pop("reinserted", None)
|
||
booked = self.st["booked"]
|
||
now = self.bridge.clock()
|
||
entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)]
|
||
for sp in cc2.get("spools") or []:
|
||
smid = spoolman_id_of(sp)
|
||
if smid is None:
|
||
continue
|
||
cid = str(sp.get("id"))
|
||
try:
|
||
rem = float(sp.get("remaining"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
name = sp.get("name") or cid
|
||
mine = [e for e in entries if str(e.get("spool")) == cid]
|
||
newest = max((_num(e.get("time")) for e in mine), default=0.0)
|
||
seen = self.remaining_seen.get(cid)
|
||
if seen is None or abs(seen[0] - rem) > 1e-6:
|
||
self.remaining_seen[cid] = (rem, now)
|
||
b = booked.get(cid)
|
||
if b is None or b.get("spoolman") != smid:
|
||
booked[cid] = {"spoolman": smid, "remaining": rem, "pending": None, "last": None, "log_time": newest}
|
||
self._log("info", f"{name}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})")
|
||
continue
|
||
b.setdefault("log_time", newest) # state from before the log was read
|
||
fresh = [e for e in mine if _num(e.get("time")) > b["log_time"]]
|
||
try:
|
||
if b.get("pending") and not self._resolve_pending(cid, b):
|
||
continue
|
||
settled = not job_active and now - self.remaining_seen[cid][1] >= self.settings.settle_seconds
|
||
if any(e.get("kind") == "correction" for e in fresh):
|
||
if settled:
|
||
self._weighin(cid, b, name, smid, rem, newest)
|
||
continue
|
||
delta = b["remaining"] - rem
|
||
if delta < -MIN_BOOKING:
|
||
self._log("warn", f"{name}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
|
||
"nicht gebucht, neuer Ausgangsstand")
|
||
b["remaining"] = rem
|
||
b["log_time"] = newest
|
||
continue
|
||
if delta < MIN_BOOKING or not settled:
|
||
continue
|
||
self._book(cid, b, name, smid, delta, rem, describe(fresh), newest)
|
||
except SpoolmanNotFound:
|
||
self.bridge._once_error(f"{self.id}:book:{cid}",
|
||
f"{self.name}: {name}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
|
||
except SpoolmanError as e:
|
||
self.bridge._spoolman_error(str(e))
|
||
|
||
def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float,
|
||
details: str = "", log_time: float | None = None) -> None:
|
||
if self.settings.dry_run:
|
||
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen{details}", lambda: None)
|
||
return
|
||
before = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
|
||
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.bridge.clock(),
|
||
"details": details, "log_time": log_time}
|
||
self.bridge.state.save() # intent first: a crash after the PUT must not book twice
|
||
self.bridge.spoolman.use(smid, delta)
|
||
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht{details}",
|
||
details, log_time)
|
||
|
||
def _booked(self, b: dict, grams: float, target: float, msg: str, details: str = "",
|
||
log_time: float | None = None) -> None:
|
||
b["remaining"] = target
|
||
b["pending"] = None
|
||
b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3), "details": details.removeprefix(" – ")}
|
||
if log_time is not None:
|
||
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
|
||
self.bridge.state.save()
|
||
self._log("info", msg)
|
||
|
||
def _resolve_pending(self, cid: str, b: dict) -> bool:
|
||
"""A booking was interrupted: check whether Spoolman already has it."""
|
||
p = b["pending"]
|
||
smid = b["spoolman"]
|
||
details, log_time = p.get("details") or "", p.get("log_time")
|
||
used = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
|
||
if used >= p["before"] + p["grams"] - 0.01:
|
||
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman",
|
||
details, log_time)
|
||
return True
|
||
if self.settings.dry_run:
|
||
self._write(f"book:{cid}", f"unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} wiederholen",
|
||
lambda: None)
|
||
return False
|
||
self.bridge.spoolman.use(smid, p["grams"])
|
||
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt",
|
||
details, log_time)
|
||
return True
|
||
|
||
def _weighin(self, cid: str, b: dict, name: str, smid: int, rem: float, log_time: float) -> None:
|
||
"""Weighed in CC2 Control: Spoolman takes the weighed value (and any use since) as it is."""
|
||
sm = self.bridge.spoolman.spool(smid)
|
||
base = sm.get("initial_weight") or (sm.get("filament") or {}).get("weight")
|
||
if not base:
|
||
self.bridge._once_error(f"{self.id}:weigh:{cid}", f"{self.name}: {name}: Spoolman-Spule {smid} hat kein "
|
||
"Startgewicht; Nachwiegen nicht übernommen")
|
||
return
|
||
target = max(0.0, rem)
|
||
used = round(max(0.0, float(base) - target), 3)
|
||
_, done = self._write(f"weigh:{cid}", f"in Spoolman den Rest von {name} auf {target:.1f} g setzen "
|
||
"(in CC2 Control nachgewogen)",
|
||
lambda: self.bridge.spoolman.update_spool(smid, {"used_weight": used}))
|
||
if done:
|
||
b["remaining"] = rem
|
||
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
|
||
self.bridge.spool_cache.pop(smid, None)
|
||
self.bridge.state.save()
|
||
|
||
# ---------- CC2 Control's log: use of a tray left without spool after "Take out" ----------
|
||
|
||
def _process_cc2_log(self, cc2: dict) -> None:
|
||
st = self.st
|
||
entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)]
|
||
keys = [log_key(e) for e in entries]
|
||
now = self.bridge.clock()
|
||
if st.get("log_seen") is None:
|
||
st["log_seen"] = keys[:LOG_SEEN_KEEP] # older entries are not ours to book
|
||
else:
|
||
seen = set(st["log_seen"])
|
||
for e, key in zip(entries, keys):
|
||
if key in seen:
|
||
continue
|
||
st["log_seen"].append(key)
|
||
seen.add(key)
|
||
n, t, mm = e.get("slot"), _num(e.get("time")), _num(e.get("mm"))
|
||
if e.get("kind") != "print" or e.get("spool") or n not in (0, 1, 2, 3) or mm <= 0:
|
||
continue
|
||
win = next((w for w in st["takeout"] if w["slot"] == n and
|
||
w["since"] - TAKEOUT_MARGIN <= t <= (w["until"] or now) + TAKEOUT_MARGIN), None)
|
||
if win:
|
||
st["extra"].append({"key": key, "slot": n, "spool": win["spool"], "name": win["name"], "mm": mm,
|
||
"job": job_text(e), "before": None, "grams": None})
|
||
st["log_seen"] = st["log_seen"][-LOG_SEEN_KEEP:]
|
||
st["takeout"] = [w for w in st["takeout"] if w["until"] is None or now - w["until"] < TAKEOUT_KEEP]
|
||
self._process_extra()
|
||
|
||
def _process_extra(self) -> None:
|
||
rest = []
|
||
for item in self.st["extra"]:
|
||
smid = item["spool"]
|
||
try:
|
||
sm = self.bridge.spoolman.spool(smid)
|
||
fil = sm.get("filament") or {}
|
||
grams = round(grams_from_mm(item["mm"], _num(fil.get("diameter"), 1.75) or 1.75,
|
||
_num(fil.get("density"), 1.24) or 1.24), 3)
|
||
msg = (f"{grams:.2f} g von {item['name']} auf Spoolman-Spule {smid} nachgebucht (Slot {item['slot'] + 1} "
|
||
f"druckte nach „Take out“ ohne Spule) – {item['job']}")
|
||
if self.settings.dry_run:
|
||
self._write(f"extra:{item['key']}", msg, lambda: None)
|
||
rest.append(item)
|
||
continue
|
||
used = float(sm.get("used_weight") or 0)
|
||
if item["before"] is not None and used >= item["before"] + item["grams"] - 0.01:
|
||
self._log("info", msg) # booked before a crash
|
||
continue
|
||
item["before"], item["grams"] = used, grams
|
||
self.bridge.state.save()
|
||
self.bridge.spoolman.use(smid, grams)
|
||
self._log("info", msg)
|
||
except SpoolmanNotFound:
|
||
self._log("warn", f"Spoolman-Spule {smid} existiert nicht; {item['mm']:.0f} mm aus Slot {item['slot'] + 1} nicht gebucht")
|
||
except SpoolmanError as e:
|
||
self.bridge._spoolman_error(str(e))
|
||
rest.append(item)
|
||
self.st["extra"] = rest
|
||
|
||
# ---------- UI ----------
|
||
|
||
def snapshot(self) -> dict:
|
||
cc2 = self.cc2_data
|
||
record_slots = tagcodec.parse_record(self.record or self.st.get("last_record") or {}, self.settings.id_offset)
|
||
cc2_slots = {s.get("slot"): s for s in (cc2 or {}).get("slots") or []}
|
||
cc2_spools = {str(s.get("id")): s for s in (cc2 or {}).get("spools") or []}
|
||
slots = []
|
||
for n in range(4):
|
||
m = self.st["slots"].get(str(n))
|
||
cs = cc2_slots.get(n) or {}
|
||
sid = slot_spool_id(cs)
|
||
cached = self.bridge.spool_cache.get(m["spool"]) if m else None
|
||
b = self.st["booked"].get(m["cc2"]) if m and m.get("cc2") else None
|
||
takeout = next((t for t in self.st["takeout"] if t["slot"] == n and t["until"] is None), None)
|
||
slots.append({
|
||
"takeout": {"spool": takeout["spool"], "name": takeout["name"]} if takeout else None,
|
||
"slot": n,
|
||
"record": record_slots[n],
|
||
"tray_status": self.tray_status.get(n),
|
||
"mapping": m,
|
||
"location": self.settings.slot_location(self.cfg, n),
|
||
"spoolman": summary(cached[1]) if cached else None,
|
||
"cc2_spool": cc2_spools.get(sid) if sid else None,
|
||
"cc2_question": cs.get("question") or "",
|
||
"cc2_matches": bool(m and m.get("cc2") and sid == m["cc2"]),
|
||
"last_booking": (b or {}).get("last"),
|
||
})
|
||
return {
|
||
"id": self.id, "name": self.name, "enabled": self.cfg.enabled, "host": self.cfg.host,
|
||
"errors": dict(self.errors),
|
||
"cc2_enabled": None if cc2 is None else bool(cc2.get("enabled")),
|
||
"job_active": None if cc2 is None else bool((cc2.get("job") or {}).get("active")),
|
||
"host_key": self.bridge.keys.host_key(self.id) is not None,
|
||
"slots": slots,
|
||
}
|
||
|
||
|
||
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 _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()
|
||
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"] is None:
|
||
# a place of yours (e.g. a shelf): 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)
|
||
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"])
|