- Reads /opt/usr/canvas_record_info.json over SSH (pinned host key, restricted key), decodes Spoolman spool ids from the tag weight - Mirrors spools into CC2 Control's spool library and assigns slots, keeps its own slot mapping, sets Spoolman locations - Books the drop of CC2 Control's measured remaining weight to Spoolman after each print, idempotent across restarts and interrupted bookings - Dry-run by default; web UI for settings, SSH key, status and log - Tag generator for NFC Tools command chains (NTAG213/215/216) - Tests, mock environment, Dockerfile, Gitea Actions CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
615 lines
27 KiB
Python
615 lines
27 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.
|
|
"""
|
|
|
|
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 Spoolman, SpoolmanError, SpoolmanNotFound, color_hex, label, summary, vendor_name
|
|
from .sshsource import CanvasRecordReader, KeyStore, SSHError
|
|
from .storage import Settings, SettingsStore, StateStore
|
|
|
|
log = logging.getLogger("bridge")
|
|
|
|
MIN_BOOKING = 0.05 # grams; smaller changes accumulate until they reach this
|
|
SPOOL_CACHE_SECONDS = 300
|
|
LOG_KEEP = 1000
|
|
SOURCE_NAMES = {"ssh": "Drucker (SSH)", "cc2": "CC2 Control", "spoolman": "Spoolman"}
|
|
|
|
|
|
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 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.state = StateStore(data_dir)
|
|
self.log = ActionLog(data_dir, clock)
|
|
self.reader_factory = reader_factory or (
|
|
lambda s: CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user))
|
|
self.cc2_factory = cc2_factory or (lambda s: CC2Control(s.cc2_url))
|
|
self.spoolman_factory = spoolman_factory or (lambda s: Spoolman(s.spoolman_url))
|
|
self._clients_for: dict | None = None
|
|
self.reader = self.cc2 = self.spoolman = None
|
|
self.lock = threading.RLock()
|
|
self.commands: queue.Queue = queue.Queue()
|
|
self.wake = threading.Event()
|
|
self.errors: dict[str, str | None] = {"ssh": None, "cc2": None, "spoolman": None}
|
|
self.empty_since: dict[int, float] = {}
|
|
self.remaining_seen: dict[str, tuple[float, float]] = {} # cc2 id -> (value, since)
|
|
self.dry_logged: dict[str, str] = {}
|
|
self.spool_cache: dict[int, tuple[float, dict]] = {}
|
|
self.record_slots: list[dict] = []
|
|
self.tray_status: dict[int, int] = {}
|
|
self.snapshot: dict = {"last_poll": None}
|
|
self.settings: Settings = settings.get()
|
|
|
|
# ---------- infrastructure ----------
|
|
|
|
def _clients(self) -> None:
|
|
s = self.settings
|
|
key = {k: getattr(s, k) for k in ("printer_host", "ssh_port", "ssh_user", "cc2_url", "spoolman_url")}
|
|
if key == self._clients_for:
|
|
return
|
|
for c in (self.reader, self.cc2, self.spoolman):
|
|
if c is not None:
|
|
try:
|
|
c.close()
|
|
except Exception:
|
|
pass
|
|
self.reader = self.reader_factory(s)
|
|
self.cc2 = self.cc2_factory(s)
|
|
self.spoolman = self.spoolman_factory(s)
|
|
self._clients_for = key
|
|
|
|
def _error(self, source: str, msg: str | None) -> None:
|
|
if msg != self.errors.get(source):
|
|
if msg:
|
|
self.log.add("error", msg)
|
|
elif self.errors.get(source):
|
|
self.log.add("info", f"{SOURCE_NAMES[source]} wieder erreichbar")
|
|
self.errors[source] = 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._clients()
|
|
self._run_commands()
|
|
record = cc2 = None
|
|
try:
|
|
record = self.reader.read()
|
|
self._error("ssh", None)
|
|
except SSHError as e:
|
|
self._error("ssh", str(e))
|
|
try:
|
|
cc2 = self.cc2.spools()
|
|
self._error("cc2", None)
|
|
except CC2Error as e:
|
|
self._error("cc2", str(e))
|
|
|
|
if record is not None:
|
|
self._process_record(record)
|
|
self._process_scans()
|
|
if cc2 is not None:
|
|
self._process_trays(cc2)
|
|
self._process_cc2_slots(cc2)
|
|
self._reconcile_cc2(cc2)
|
|
self._consumption(cc2)
|
|
self._reconcile_locations()
|
|
self._refresh_spool_cache()
|
|
self.state.save()
|
|
self._build_snapshot(record, cc2)
|
|
|
|
def _run_commands(self) -> None:
|
|
while True:
|
|
try:
|
|
cmd, arg = self.commands.get_nowait()
|
|
except queue.Empty:
|
|
return
|
|
try:
|
|
if cmd == "enable":
|
|
self._write("enable", "Spulenverwaltung in CC2 Control einschalten", lambda: self.cc2.enable(True))
|
|
elif cmd == "reassign":
|
|
m = self.state.data["slots"].get(str(arg))
|
|
if m:
|
|
m["assigned"] = False
|
|
self.dry_logged.pop(f"assign:{arg}", None)
|
|
self.log.add("info", f"Slot {arg + 1}: Zuordnung wird erneut an CC2 Control gesendet")
|
|
elif cmd == "release":
|
|
if str(arg) in self.state.data["slots"]:
|
|
self._release(arg, "manuell aufgehoben")
|
|
except (CC2Error, SpoolmanError) as e:
|
|
self.log.add("error", str(e))
|
|
|
|
# ---------- record / scans ----------
|
|
|
|
def _process_record(self, record: dict) -> None:
|
|
offset = self.settings.id_offset
|
|
slots = tagcodec.parse_record(record, offset)
|
|
prev_record = self.state.data.get("last_record")
|
|
prev = tagcodec.parse_record(prev_record, offset) if prev_record else None
|
|
mappings = self.state.data["slots"]
|
|
for s in slots:
|
|
n, tid = s["slot"], s["spool_id"]
|
|
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.state.data.setdefault("scans", []).append({"slot": n, "spool": tid, "time": self.clock()})
|
|
self.log.add("info", f"Slot {n + 1}: Tag mit Spoolman-ID {tid} erkannt")
|
|
self.state.data["last_record"] = record
|
|
self.record_slots = slots
|
|
|
|
def _process_scans(self) -> None:
|
|
pending = []
|
|
for scan in self.state.data.get("scans", []):
|
|
n, tid = scan["slot"], scan["spool"]
|
|
try:
|
|
sm = self.spoolman.spool(tid)
|
|
self._error("spoolman", None)
|
|
except SpoolmanNotFound:
|
|
self.log.add("warn", f"Slot {n + 1}: Spoolman-Spule {tid} existiert nicht; nichts geändert")
|
|
continue
|
|
except SpoolmanError as e:
|
|
self._error("spoolman", str(e))
|
|
pending.append(scan) # retry next cycle
|
|
continue
|
|
self.spool_cache[tid] = (self.clock(), sm)
|
|
self._map(n, tid, label(sm), reason="Tag gescannt")
|
|
self.state.data["scans"] = pending
|
|
|
|
def _map(self, n: int, tid: int, name: str, reason: str, cc2_id: str | None = None) -> None:
|
|
mappings = self.state.data["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
|
|
self.dry_logged.pop(f"assign:{n}", None)
|
|
self._want_location(tid, self.settings.slot_location(n))
|
|
return
|
|
for k, m in list(mappings.items()):
|
|
if k != str(n) and m["spool"] == tid:
|
|
del mappings[k]
|
|
self.log.add("info", f"Spule {name} von Slot {int(k) + 1} nach Slot {n + 1} umgezogen")
|
|
if cur:
|
|
self._release(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,
|
|
}
|
|
self.empty_since.pop(n, None)
|
|
self.log.add("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})")
|
|
self._want_location(tid, self.settings.slot_location(n))
|
|
|
|
def _release(self, n: int, reason: str) -> None:
|
|
m = self.state.data["slots"].pop(str(n), None)
|
|
if not m:
|
|
return
|
|
self.dry_logged.pop(f"assign:{n}", None)
|
|
self.log.add("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})")
|
|
if not any(o["spool"] == m["spool"] for o in self.state.data["slots"].values()):
|
|
self._want_location(m["spool"], self.settings.location_storage)
|
|
|
|
# ---------- 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.clock()
|
|
for n in range(4):
|
|
st = statuses.get(n)
|
|
if st is None:
|
|
continue
|
|
if st == 0:
|
|
since = self.empty_since.setdefault(n, now)
|
|
if now - since >= self.settings.empty_grace and str(n) in self.state.data["slots"]:
|
|
self._release(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.state.data["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
|
|
# A spool we already map to another slot (fresh scan) wins over CC2 Control's older binding.
|
|
elsewhere = mirrored is not None and any(
|
|
o["spool"] == mirrored for k, o in mappings.items() if k != str(n))
|
|
if m is None:
|
|
if mirrored is not None and not elsewhere and self.tray_status.get(n) in (1, 2):
|
|
# assigned in CC2 Control by hand
|
|
self._map(n, mirrored, by_id[sid].get("name") or str(mirrored), "in CC2 Control zugewiesen", sid)
|
|
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._map(n, mirrored, by_id[sid].get("name") or str(mirrored), "in CC2 Control zugewiesen", sid)
|
|
elif mirrored is None:
|
|
self._release(n, "in CC2 Control mit anderer Spule belegt")
|
|
elif not slot.get("question"):
|
|
self._release(n, "CC2 Control hat die Spule ins Lager gelegt")
|
|
|
|
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.state.data["slots"].items()):
|
|
n = int(k)
|
|
if m.get("assigned"):
|
|
continue
|
|
try:
|
|
mirror = mirrors.get(m["spool"])
|
|
if mirror is None:
|
|
sm = self._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.add("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.add("error", "CC2 Control hat keine Spulen-ID zurückgegeben")
|
|
continue
|
|
self.state.data["booked"][cc2_id] = {
|
|
"spoolman": m["spool"], "remaining": float(fields.get("remaining", fields.get("net", 1000))),
|
|
"pending": None, "last": None,
|
|
}
|
|
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.add("error", f"Slot {n + 1}: {e}")
|
|
|
|
# ---------- consumption ----------
|
|
|
|
def _consumption(self, cc2: dict) -> None:
|
|
job_active = bool((cc2.get("job") or {}).get("active"))
|
|
booked = self.state.data["booked"]
|
|
now = self.clock()
|
|
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
|
|
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}
|
|
self.log.add("info", f"{sp.get('name')}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})")
|
|
continue
|
|
try:
|
|
if b.get("pending") and not self._resolve_pending(cid, b):
|
|
continue
|
|
delta = b["remaining"] - rem
|
|
if delta < -MIN_BOOKING:
|
|
self.log.add("warn", f"{sp.get('name')}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
|
|
"nicht gebucht, neuer Ausgangsstand")
|
|
b["remaining"] = rem
|
|
continue
|
|
if delta < MIN_BOOKING or job_active:
|
|
continue
|
|
if now - self.remaining_seen[cid][1] < self.settings.settle_seconds:
|
|
continue
|
|
self._book(cid, b, sp.get("name") or cid, smid, delta, rem)
|
|
except SpoolmanNotFound:
|
|
self._write_error(f"book:{cid}", f"{sp.get('name')}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
|
|
except SpoolmanError as e:
|
|
self._error("spoolman", str(e))
|
|
|
|
def _write_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 _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float) -> None:
|
|
if self.settings.dry_run:
|
|
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen", lambda: None)
|
|
return
|
|
before = float(self.spoolman.spool(smid).get("used_weight") or 0)
|
|
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.clock()}
|
|
self.state.save() # intent first: a crash after the PUT must not book twice
|
|
self.spoolman.use(smid, delta)
|
|
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht")
|
|
|
|
def _booked(self, b: dict, grams: float, target: float, msg: str) -> None:
|
|
b["remaining"] = target
|
|
b["pending"] = None
|
|
b["last"] = {"time": self.clock(), "grams": round(grams, 3)}
|
|
self.state.save()
|
|
self.log.add("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"]
|
|
used = float(self.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")
|
|
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.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")
|
|
return True
|
|
|
|
# ---------- Spoolman locations ----------
|
|
|
|
def _want_location(self, smid: int, location: str) -> None:
|
|
locs = self.state.data["locations"]
|
|
entry = locs.setdefault(str(smid), {"want": None, "done": None})
|
|
entry["want"] = location
|
|
|
|
def _reconcile_locations(self) -> None:
|
|
for k, entry in self.state.data["locations"].items():
|
|
if entry["want"] == entry["done"]:
|
|
continue
|
|
smid = int(k)
|
|
name = self._name(smid)
|
|
try:
|
|
_, done = self._write(f"location:{k}", f"in Spoolman den Ort von {name} auf „{entry['want']}“ setzen",
|
|
lambda: self.spoolman.set_location(smid, entry["want"]))
|
|
if done:
|
|
entry["done"] = entry["want"]
|
|
self._error("spoolman", None)
|
|
except SpoolmanNotFound:
|
|
self.log.add("warn", f"Spoolman-Spule {smid} existiert nicht mehr; Ort nicht gesetzt")
|
|
entry["done"] = entry["want"]
|
|
except SpoolmanError as e:
|
|
self._error("spoolman", str(e))
|
|
|
|
# ---------- 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._write_error(f"missing:{smid}", f"Spoolman-Spule {smid} existiert nicht")
|
|
return None
|
|
except SpoolmanError as e:
|
|
self._error("spoolman", 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:
|
|
for m in self.state.data["slots"].values():
|
|
self._spool(m["spool"])
|
|
|
|
# ---------- UI snapshot ----------
|
|
|
|
def _build_snapshot(self, record: dict | None, cc2: dict | None) -> None:
|
|
record_slots = tagcodec.parse_record(record or self.state.data.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 []}
|
|
booked = self.state.data["booked"]
|
|
slots = []
|
|
for n in range(4):
|
|
m = self.state.data["slots"].get(str(n))
|
|
cs = cc2_slots.get(n) or {}
|
|
sid = slot_spool_id(cs)
|
|
cached = self.spool_cache.get(m["spool"]) if m else None
|
|
b = booked.get(m["cc2"]) if m and m.get("cc2") else None
|
|
slots.append({
|
|
"slot": n,
|
|
"record": record_slots[n],
|
|
"tray_status": self.tray_status.get(n),
|
|
"mapping": m,
|
|
"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"),
|
|
})
|
|
self.snapshot = {
|
|
"last_poll": self.clock(),
|
|
"dry_run": self.settings.dry_run,
|
|
"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")),
|
|
"slots": slots,
|
|
"ssh_key": self.keys.has_key(),
|
|
"host_key": self.keys.host_key() is not None,
|
|
}
|
|
|