CC2 ↔ Spoolman bridge with NFC tag generator
- 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
This commit is contained in:
commit
bf32667f77
32 files changed
+4216
No files matched your search
Whitespace-only changes.
+614
@@ -0,0 +1,614 @@
|
||||
"""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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""CC2 Control HTTP client (spool management only).
|
||||
|
||||
Request rules from CC2 Control's HTTP guard: the Host header must be exactly the
|
||||
printer's host:port, no Origin header, and writes need `X-CC2-Request: 1` with a
|
||||
plain-text body of `key=value` lines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
TEXT_LIMITS = {"name": 96, "brand": 96, "note": 96, "material": 32}
|
||||
_FORBIDDEN = re.compile(r'["\\\x00-\x1f\x7f]')
|
||||
|
||||
NOTE_PREFIX = "spoolman:"
|
||||
|
||||
|
||||
class CC2Error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def clean_text(value, limit: int | None = None) -> str:
|
||||
"""UTF-8 without quotes, backslashes and control characters, cut to `limit` bytes."""
|
||||
text = _FORBIDDEN.sub(" ", str(value)).strip()
|
||||
text = re.sub(r" {2,}", " ", text)
|
||||
if limit is not None:
|
||||
raw = text.encode("utf-8")[:limit]
|
||||
text = raw.decode("utf-8", "ignore").rstrip()
|
||||
return text
|
||||
|
||||
|
||||
def fmt_number(value) -> str:
|
||||
v = float(value)
|
||||
if v == int(v):
|
||||
return str(int(v))
|
||||
return f"{v:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def encode_body(fields: dict) -> str:
|
||||
lines = []
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
if not re.fullmatch(r"[a-z_]+", key):
|
||||
raise ValueError(f"invalid key {key!r}")
|
||||
if isinstance(value, bool):
|
||||
value = "1" if value else "0"
|
||||
elif isinstance(value, (int, float)):
|
||||
value = fmt_number(value)
|
||||
else:
|
||||
value = clean_text(value, TEXT_LIMITS.get(key))
|
||||
lines.append(f"{key}={value}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def spoolman_id_of(cc2_spool: dict) -> int | None:
|
||||
note = str(cc2_spool.get("note") or "")
|
||||
m = re.search(r"\bspoolman:(\d+)\b", note)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def slot_spool_id(slot: dict) -> str | None:
|
||||
"""CC2 Control spool ids are strings; an empty string means the slot holds no spool."""
|
||||
s = slot.get("spool")
|
||||
if isinstance(s, dict):
|
||||
s = s.get("id")
|
||||
s = "" if s is None else str(s)
|
||||
return s or None
|
||||
|
||||
|
||||
class CC2Control:
|
||||
def __init__(self, base_url: str, timeout: float = 8.0, transport: httpx.BaseTransport | None = None):
|
||||
parts = urlsplit(base_url)
|
||||
self.host_header = parts.netloc
|
||||
self.client = httpx.Client(
|
||||
base_url=base_url.rstrip("/"), timeout=timeout, transport=transport,
|
||||
headers={"Host": self.host_header, "Accept": "application/json"},
|
||||
)
|
||||
# httpx never adds Origin by itself; make sure nothing else does either.
|
||||
self.client.headers.pop("Origin", None)
|
||||
|
||||
def close(self):
|
||||
self.client.close()
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
try:
|
||||
r = self.client.get(path)
|
||||
except httpx.HTTPError as e:
|
||||
raise CC2Error(f"CC2 Control nicht erreichbar ({type(e).__name__})") from None
|
||||
if r.status_code != 200:
|
||||
raise CC2Error(f"CC2 Control {path}: HTTP {r.status_code}")
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
raise CC2Error(f"CC2 Control {path}: keine JSON-Antwort") from None
|
||||
|
||||
def _post(self, path: str, fields: dict | str) -> dict:
|
||||
# /api/spools/enable takes the bare word "on"/"off" as its body.
|
||||
body = fields if isinstance(fields, str) else encode_body(fields)
|
||||
try:
|
||||
r = self.client.post(
|
||||
path, content=body.encode("utf-8"),
|
||||
headers={"X-CC2-Request": "1", "Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise CC2Error(f"CC2 Control nicht erreichbar ({type(e).__name__})") from None
|
||||
if r.status_code not in (200, 201):
|
||||
detail = ""
|
||||
try:
|
||||
detail = str(r.json().get("error") or "")[:200]
|
||||
except ValueError:
|
||||
pass
|
||||
raise CC2Error(f"CC2 Control {path}: HTTP {r.status_code} {detail}".strip())
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def canvas(self) -> dict:
|
||||
return self._get("/api/canvas")
|
||||
|
||||
def printer(self) -> dict:
|
||||
return self._get("/api/printer")
|
||||
|
||||
def spools(self) -> dict:
|
||||
return self._get("/api/spools")
|
||||
|
||||
def save_spool(self, fields: dict) -> dict:
|
||||
return self._post("/api/spools/save", fields)
|
||||
|
||||
def assign(self, slot: int, spool_id: int | None) -> dict:
|
||||
return self._post("/api/spools/assign", {"slot": slot, "spool": "" if spool_id is None else spool_id})
|
||||
|
||||
def enable(self, on: bool) -> dict:
|
||||
return self._post("/api/spools/enable", "on" if on else "off")
|
||||
|
||||
|
||||
def tray_statuses(canvas: dict) -> dict[int, int]:
|
||||
"""Slot -> tray status (0 = no filament tip at the feeder, 1 = loaded, 2 = feeding)."""
|
||||
try:
|
||||
trays = canvas["telemetry"]["result"]["canvas_info"]["canvas_list"][0]["tray_list"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return {}
|
||||
out = {}
|
||||
for i, tray in enumerate(trays or []):
|
||||
if not isinstance(tray, dict):
|
||||
continue
|
||||
slot = tray.get("tray_id", i)
|
||||
try:
|
||||
out[int(slot)] = int(tray.get("status"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Elegoo Canvas material and subtype codes.
|
||||
|
||||
Taken verbatim from cjtallman/canvas-rfid-tool (index.html, MIT License,
|
||||
Copyright (c) 2026 cjtallman, see THIRD_PARTY_NOTICES.md), which in turn credits
|
||||
DnG-Crafts/ELG-RFID and Savion/elegoo-rfid-editor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# name -> (4-byte material code, subtype family byte, suggested nozzle min/max)
|
||||
MATERIALS: dict[str, dict] = {
|
||||
"PLA": {"code": 0x00807665, "family": 0x00, "temps": (190, 230)},
|
||||
"PETG": {"code": 0x80698471, "family": 0x01, "temps": (230, 260)},
|
||||
"ABS": {"code": 0x00656683, "family": 0x02, "temps": (240, 270)},
|
||||
"TPU": {"code": 0x00848085, "family": 0x03, "temps": (210, 240)},
|
||||
"PA": {"code": 0x00008065, "family": 0x04, "temps": (260, 290)},
|
||||
"CPE": {"code": 0x00678069, "family": 0x05, "temps": (240, 270)},
|
||||
"PC": {"code": 0x00008067, "family": 0x06, "temps": (260, 290)},
|
||||
"PVA": {"code": 0x00808665, "family": 0x07, "temps": (190, 220)},
|
||||
"ASA": {"code": 0x00658365, "family": 0x08, "temps": (240, 270)},
|
||||
"BVOH": {"code": 0x42564F48, "family": 0x09, "temps": (190, 220)},
|
||||
"EVA": {"code": 0x00455641, "family": 0x0A, "temps": (190, 220)},
|
||||
"HIPS": {"code": 0x48495053, "family": 0x0B, "temps": (230, 250)},
|
||||
"PP": {"code": 0x00005050, "family": 0x0C, "temps": (220, 250)},
|
||||
"PPA": {"code": 0x00505041, "family": 0x0D, "temps": (280, 310)},
|
||||
"PPS": {"code": 0x00505053, "family": 0x0E, "temps": (300, 330)},
|
||||
}
|
||||
|
||||
# (code, name, hidden on the CC2 display)
|
||||
SUBTYPES: list[tuple[int, str, bool]] = [
|
||||
(0x0000, "PLA", False), (0x0001, "PLA+", False), (0x0002, "PLA Pro", False),
|
||||
(0x0003, "PLA Silk", False), (0x0004, "PLA-CF", False), (0x0005, "PLA Carbon", True),
|
||||
(0x0006, "PLA Matte", False), (0x0007, "PLA Fluo", False), (0x0008, "PLA Wood", False),
|
||||
(0x0009, "PLA Basic", False), (0x000A, "RAPID PLA+", False), (0x000B, "PLA Marble", False),
|
||||
(0x000C, "PLA Galaxy", False), (0x000D, "PLA Red Copper", False), (0x000E, "PLA Sparkle", True),
|
||||
(0x0100, "PETG", False), (0x0101, "PETG-CF", False), (0x0102, "PETG-GF", False),
|
||||
(0x0103, "PETG Pro", True), (0x0104, "PETG Translucent", False), (0x0105, "RAPID PETG", False),
|
||||
(0x0200, "ABS", False), (0x0201, "ABS-GF", True),
|
||||
(0x0300, "TPU", False), (0x0301, "TPU 95A", False), (0x0302, "RAPID TPU 95A", False),
|
||||
(0x0400, "PA", False), (0x0401, "PA-CF", True), (0x0403, "PAHT-CF", False),
|
||||
(0x0404, "PA6", True), (0x0405, "PA6-CF", True), (0x0406, "PA12", True), (0x0407, "PA12-CF", True),
|
||||
(0x0500, "CPE", False),
|
||||
(0x0600, "PC", False), (0x0601, "PCTG", True), (0x0602, "PC-FR", False),
|
||||
(0x0700, "PVA", False), (0x0800, "ASA", False), (0x0900, "BVOH", False), (0x0A00, "EVA", False),
|
||||
(0x0B00, "HIPS", False),
|
||||
(0x0C00, "PP", False), (0x0C01, "PP-CF", True), (0x0C02, "PP-GF", True),
|
||||
(0x0D00, "PPA", False), (0x0D01, "PPA-CF", True), (0x0D02, "PPA-GF", True),
|
||||
(0x0E00, "PPS", False), (0x0E02, "PPS-CF", True),
|
||||
]
|
||||
|
||||
SUBTYPE_BY_CODE = {code: name for code, name, _ in SUBTYPES}
|
||||
MATERIAL_BY_CODE = {m["code"]: name for name, m in MATERIALS.items()}
|
||||
|
||||
|
||||
def material_of_subtype(code: int) -> str | None:
|
||||
family = code >> 8
|
||||
for name, m in MATERIALS.items():
|
||||
if m["family"] == family:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def subtypes_for(material: str) -> list[tuple[int, str, bool]]:
|
||||
family = MATERIALS[material]["family"]
|
||||
return [s for s in SUBTYPES if s[0] >> 8 == family]
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
text = text.upper().replace("+", " PLUS ")
|
||||
return set(re.findall(r"[A-Z0-9]+", text))
|
||||
|
||||
|
||||
# Spoolman material spellings that do not name the Elegoo base material directly.
|
||||
_ALIASES = {"PCTG": "PC", "PA6": "PA", "PA12": "PA", "PAHT": "PA", "NYLON": "PA", "PET": "PETG"}
|
||||
|
||||
_MODIFIERS = [
|
||||
# (tokens that must all be present, subtype name suffix)
|
||||
({"RAPID", "PLUS"}, "RAPID {m}+"),
|
||||
({"RAPID"}, "RAPID {m}"),
|
||||
({"95A"}, "{m} 95A"),
|
||||
({"SILK"}, "{m} Silk"),
|
||||
({"MATTE"}, "{m} Matte"),
|
||||
({"MATT"}, "{m} Matte"),
|
||||
({"CF"}, "{m}-CF"),
|
||||
({"CARBON"}, "{m}-CF"),
|
||||
({"GF"}, "{m}-GF"),
|
||||
({"WOOD"}, "{m} Wood"),
|
||||
({"MARBLE"}, "{m} Marble"),
|
||||
({"GALAXY"}, "{m} Galaxy"),
|
||||
({"SPARKLE"}, "{m} Sparkle"),
|
||||
({"FLUO"}, "{m} Fluo"),
|
||||
({"TRANSLUCENT"}, "{m} Translucent"),
|
||||
({"TRANSPARENT"}, "{m} Translucent"),
|
||||
({"PRO"}, "{m} Pro"),
|
||||
({"BASIC"}, "{m} Basic"),
|
||||
({"PLUS"}, "{m}+"),
|
||||
]
|
||||
|
||||
|
||||
def suggest(material: str | None, name: str | None = "") -> dict:
|
||||
"""Suggest Elegoo material, subtype and nozzle temps from Spoolman text."""
|
||||
material = material or ""
|
||||
tokens = _tokens(f"{material} {name or ''}")
|
||||
mat_tokens = _tokens(material)
|
||||
base = None
|
||||
# Prefer the Spoolman material field; fall back to the filament name.
|
||||
for pool in (mat_tokens, tokens):
|
||||
for tok in sorted(pool, key=len, reverse=True):
|
||||
cand = _ALIASES.get(tok, tok)
|
||||
if cand in MATERIALS:
|
||||
base = cand
|
||||
break
|
||||
if tok.startswith("PLA"): # e.g. "PLAPLUS"
|
||||
base = "PLA"
|
||||
break
|
||||
if base:
|
||||
break
|
||||
if base is None:
|
||||
base = "PLA"
|
||||
subtype = MATERIALS[base]["family"] << 8
|
||||
names = {n.upper(): c for c, n, _ in subtypes_for(base)}
|
||||
# Exact subtype names first (e.g. "PCTG", "PAHT-CF").
|
||||
for tok in tokens:
|
||||
if tok in names and tok != base:
|
||||
subtype = names[tok]
|
||||
break
|
||||
else:
|
||||
for needed, pattern in _MODIFIERS:
|
||||
if needed <= tokens:
|
||||
cand = pattern.format(m=base).upper()
|
||||
if cand in names:
|
||||
subtype = names[cand]
|
||||
break
|
||||
return {"material": base, "subtype": subtype, "temps": MATERIALS[base]["temps"]}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Minimal Spoolman REST client (/api/v1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class SpoolmanError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SpoolmanNotFound(SpoolmanError):
|
||||
pass
|
||||
|
||||
|
||||
class Spoolman:
|
||||
def __init__(self, base_url: str, timeout: float = 8.0, transport: httpx.BaseTransport | None = None):
|
||||
self.client = httpx.Client(base_url=base_url.rstrip("/") + "/api/v1", timeout=timeout, transport=transport)
|
||||
|
||||
def close(self):
|
||||
self.client.close()
|
||||
|
||||
def _request(self, method: str, path: str, **kw):
|
||||
try:
|
||||
r = self.client.request(method, path, **kw)
|
||||
except httpx.HTTPError as e:
|
||||
raise SpoolmanError(f"Spoolman nicht erreichbar ({type(e).__name__})") from None
|
||||
if r.status_code == 404:
|
||||
raise SpoolmanNotFound(f"Spoolman {path}: nicht gefunden")
|
||||
if r.status_code >= 400:
|
||||
raise SpoolmanError(f"Spoolman {method} {path}: HTTP {r.status_code}")
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
raise SpoolmanError(f"Spoolman {path}: keine JSON-Antwort") from None
|
||||
|
||||
def spool(self, spool_id: int) -> dict:
|
||||
return self._request("GET", f"/spool/{int(spool_id)}")
|
||||
|
||||
def spools(self, archived: bool = False) -> list[dict]:
|
||||
return self._request("GET", "/spool", params={"allow_archived": str(archived).lower()})
|
||||
|
||||
def set_location(self, spool_id: int, location: str) -> dict:
|
||||
return self._request("PATCH", f"/spool/{int(spool_id)}", json={"location": location})
|
||||
|
||||
def use(self, spool_id: int, grams: float) -> dict:
|
||||
return self._request("PUT", f"/spool/{int(spool_id)}/use", json={"use_weight": round(float(grams), 3)})
|
||||
|
||||
|
||||
def vendor_name(spool: dict) -> str:
|
||||
return ((spool.get("filament") or {}).get("vendor") or {}).get("name") or ""
|
||||
|
||||
|
||||
def label(spool: dict) -> str:
|
||||
"""lot_nr like SUNLU001; otherwise built from vendor + id the same way."""
|
||||
lot = (spool.get("lot_nr") or "").strip()
|
||||
if lot:
|
||||
return lot
|
||||
vendor = re.sub(r"[^A-Z0-9]", "", vendor_name(spool).upper()) or "SPOOL"
|
||||
return f"{vendor}{int(spool['id']):03d}"
|
||||
|
||||
|
||||
def color_hex(spool: dict) -> str | None:
|
||||
fil = spool.get("filament") or {}
|
||||
color = fil.get("color_hex")
|
||||
if not color and fil.get("multi_color_hexes"):
|
||||
color = str(fil["multi_color_hexes"]).split(",")[0]
|
||||
if not color:
|
||||
return None
|
||||
color = color.strip().lstrip("#").upper()[:6]
|
||||
return color if re.fullmatch(r"[0-9A-F]{6}", color) else None
|
||||
|
||||
|
||||
def summary(spool: dict) -> dict:
|
||||
fil = spool.get("filament") or {}
|
||||
return {
|
||||
"id": spool.get("id"),
|
||||
"label": label(spool),
|
||||
"vendor": vendor_name(spool),
|
||||
"name": fil.get("name") or "",
|
||||
"material": fil.get("material") or "",
|
||||
"color": color_hex(spool),
|
||||
"diameter": fil.get("diameter"),
|
||||
"density": fil.get("density"),
|
||||
"remaining": spool.get("remaining_weight"),
|
||||
"initial": spool.get("initial_weight") or fil.get("weight"),
|
||||
"spool_weight": spool.get("spool_weight") or fil.get("spool_weight"),
|
||||
"extruder_temp": fil.get("settings_extruder_temp"),
|
||||
"location": spool.get("location") or "",
|
||||
"archived": bool(spool.get("archived")),
|
||||
"used_weight": spool.get("used_weight"),
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Read /opt/usr/canvas_record_info.json from the printer over SSH.
|
||||
|
||||
The printer's authorized_keys entry forces `cat /opt/usr/canvas_record_info.json`
|
||||
for this key, so every exec returns that file whatever command is sent. One
|
||||
transport is kept open and a new channel is opened per read; nothing keeps
|
||||
running on the printer between reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
from .storage import atomic_write, atomic_write_json, read_json
|
||||
|
||||
REMOTE_COMMAND = "cat /opt/usr/canvas_record_info.json"
|
||||
MAX_RECORD_BYTES = 256 * 1024
|
||||
|
||||
|
||||
class SSHError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HostKeyUnknown(SSHError):
|
||||
def __init__(self, key: paramiko.PKey):
|
||||
self.key = key
|
||||
super().__init__(f"Host-Key des Druckers noch nicht bestätigt ({fingerprint(key)})")
|
||||
|
||||
|
||||
class HostKeyMismatch(SSHError):
|
||||
def __init__(self, key: paramiko.PKey, expected: str):
|
||||
self.key = key
|
||||
super().__init__(
|
||||
f"Host-Key des Druckers hat sich geändert: {fingerprint(key)}, erwartet {expected}. "
|
||||
"Verbindung abgelehnt."
|
||||
)
|
||||
|
||||
|
||||
def fingerprint(key: paramiko.PKey) -> str:
|
||||
digest = hashlib.sha256(key.asbytes()).digest()
|
||||
return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")
|
||||
|
||||
|
||||
def load_private_key(text: str) -> paramiko.PKey:
|
||||
"""Parse an OpenSSH/PEM private key (no passphrase)."""
|
||||
text = text.replace("\r\n", "\n").strip() + "\n"
|
||||
errors = []
|
||||
for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
|
||||
try:
|
||||
return cls.from_private_key(io.StringIO(text))
|
||||
except paramiko.PasswordRequiredException:
|
||||
raise SSHError("Der Schlüssel ist passwortgeschützt; bitte einen Schlüssel ohne Passphrase verwenden") from None
|
||||
except (paramiko.SSHException, ValueError, IndexError, TypeError) as e:
|
||||
errors.append(type(e).__name__)
|
||||
raise SSHError("Kein gültiger privater SSH-Schlüssel (ed25519, ecdsa oder rsa)")
|
||||
|
||||
|
||||
class KeyStore:
|
||||
"""Private key and pinned printer host key in <data>/ssh."""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
self.dir = data_dir / "ssh"
|
||||
self.key_path = self.dir / "id_key"
|
||||
self.host_path = self.dir / "host_key.json"
|
||||
|
||||
def has_key(self) -> bool:
|
||||
return self.key_path.exists()
|
||||
|
||||
def save_key(self, text: str) -> paramiko.PKey:
|
||||
text = text.replace("\r\n", "\n").strip() + "\n"
|
||||
key = load_private_key(text)
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.dir, 0o700)
|
||||
atomic_write(self.key_path, text.encode(), mode=0o600)
|
||||
return key
|
||||
|
||||
def delete_key(self) -> None:
|
||||
try:
|
||||
self.key_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def load_key(self) -> paramiko.PKey:
|
||||
try:
|
||||
os.chmod(self.key_path, 0o600)
|
||||
return load_private_key(self.key_path.read_text())
|
||||
except FileNotFoundError:
|
||||
raise SSHError("Kein SSH-Schlüssel hinterlegt") from None
|
||||
|
||||
def key_info(self) -> dict | None:
|
||||
if not self.has_key():
|
||||
return None
|
||||
try:
|
||||
key = self.load_key()
|
||||
except SSHError as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"type": key.get_name(),
|
||||
"fingerprint": fingerprint(key),
|
||||
"public": f"{key.get_name()} {key.get_base64()} cc2-spoolman-bridge",
|
||||
}
|
||||
|
||||
def host_key(self) -> dict | None:
|
||||
return read_json(self.host_path, None)
|
||||
|
||||
def pin_host_key(self, host: str, key: paramiko.PKey) -> None:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(self.host_path, {
|
||||
"host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
|
||||
}, mode=0o600)
|
||||
|
||||
def forget_host_key(self) -> None:
|
||||
try:
|
||||
self.host_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class CanvasRecordReader:
|
||||
def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
|
||||
self.keys = keys
|
||||
self.host, self.port, self.user, self.timeout = host, port, user, timeout
|
||||
self._transport: paramiko.Transport | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._close()
|
||||
|
||||
def _close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._transport = None
|
||||
|
||||
def _connect(self, accept_unknown: bool = False) -> paramiko.PKey:
|
||||
"""Open and authenticate a transport; returns the server's host key."""
|
||||
key = self.keys.load_key()
|
||||
try:
|
||||
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||
except OSError as e:
|
||||
raise SSHError(f"Drucker {self.host}:{self.port} nicht erreichbar ({e.strerror or type(e).__name__})") from None
|
||||
t = paramiko.Transport(sock)
|
||||
t.banner_timeout = t.auth_timeout = self.timeout
|
||||
try:
|
||||
t.start_client(timeout=self.timeout)
|
||||
server_key = t.get_remote_server_key()
|
||||
pinned = self.keys.host_key()
|
||||
if pinned is None:
|
||||
if not accept_unknown:
|
||||
raise HostKeyUnknown(server_key)
|
||||
elif pinned.get("type") != server_key.get_name() or pinned.get("key") != server_key.get_base64():
|
||||
raise HostKeyMismatch(server_key, pinned.get("fingerprint", "?"))
|
||||
t.auth_publickey(self.user, key)
|
||||
if not t.is_authenticated():
|
||||
raise SSHError("SSH-Anmeldung fehlgeschlagen")
|
||||
t.set_keepalive(30)
|
||||
except SSHError:
|
||||
t.close()
|
||||
raise
|
||||
except paramiko.AuthenticationException:
|
||||
t.close()
|
||||
raise SSHError("SSH-Anmeldung mit dem hinterlegten Schlüssel abgelehnt") from None
|
||||
except (paramiko.SSHException, OSError, EOFError) as e:
|
||||
t.close()
|
||||
raise SSHError(f"SSH-Fehler ({type(e).__name__})") from None
|
||||
self._transport = t
|
||||
return server_key
|
||||
|
||||
def _exec(self) -> bytes:
|
||||
assert self._transport is not None
|
||||
chan = self._transport.open_session(timeout=self.timeout)
|
||||
try:
|
||||
chan.settimeout(self.timeout)
|
||||
chan.exec_command(REMOTE_COMMAND)
|
||||
chunks, size = [], 0
|
||||
while True:
|
||||
data = chan.recv(32768)
|
||||
if not data:
|
||||
break
|
||||
size += len(data)
|
||||
if size > MAX_RECORD_BYTES:
|
||||
raise SSHError("Antwort des Druckers ist zu groß")
|
||||
chunks.append(data)
|
||||
status = chan.recv_exit_status()
|
||||
if status not in (0, -1):
|
||||
raise SSHError(f"Befehl auf dem Drucker endete mit Status {status}")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
chan.close()
|
||||
|
||||
def read(self) -> dict:
|
||||
"""Read and parse the canvas record; reconnects once on a dead transport."""
|
||||
with self._lock:
|
||||
for attempt in (0, 1):
|
||||
if self._transport is None or not self._transport.is_active():
|
||||
self._close()
|
||||
self._connect()
|
||||
try:
|
||||
raw = self._exec()
|
||||
break
|
||||
except (paramiko.SSHException, OSError, EOFError, socket.timeout) as e:
|
||||
self._close()
|
||||
if attempt:
|
||||
raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
|
||||
return parse_record_json(raw)
|
||||
|
||||
def test(self) -> dict:
|
||||
"""Fresh connection; an unknown host key is reported, not trusted."""
|
||||
with self._lock:
|
||||
self._close()
|
||||
try:
|
||||
server_key = self._connect(accept_unknown=True)
|
||||
raw = self._exec()
|
||||
except HostKeyMismatch:
|
||||
raise
|
||||
finally:
|
||||
self._close()
|
||||
pinned = self.keys.host_key()
|
||||
return {
|
||||
"host_key": {"type": server_key.get_name(), "key": server_key.get_base64(),
|
||||
"fingerprint": fingerprint(server_key)},
|
||||
"host_key_pinned": pinned is not None,
|
||||
"record": parse_record_json(raw),
|
||||
}
|
||||
|
||||
|
||||
def parse_record_json(raw: bytes) -> dict:
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise SSHError("canvas_record_info.json ist kein gültiges JSON") from None
|
||||
if not isinstance(data, dict):
|
||||
raise SSHError("canvas_record_info.json hat ein unerwartetes Format")
|
||||
return data
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
"""Settings and state persistence in the data volume (atomic JSON writes)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
dfd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, obj, mode: int = 0o644) -> None:
|
||||
atomic_write(path, json.dumps(obj, indent=2, sort_keys=True, ensure_ascii=False).encode(), mode)
|
||||
|
||||
|
||||
def read_json(path: Path, default):
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
printer_host: str = "10.10.2.202"
|
||||
ssh_port: int = 22
|
||||
ssh_user: str = "root"
|
||||
cc2_url: str = "http://10.10.2.202:8081"
|
||||
spoolman_url: str = "http://10.10.6.10:7912"
|
||||
id_offset: int = 10000
|
||||
poll_interval: float = 5.0
|
||||
location_slot: str = "CC2 Slot {n}"
|
||||
location_storage: str = "Lager"
|
||||
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
|
||||
settle_seconds: float = 60.0 # `used` must be unchanged this long before booking
|
||||
dry_run: bool = True
|
||||
|
||||
# environment variable -> field (only used as defaults before settings.json exists)
|
||||
ENV = {
|
||||
"PRINTER_HOST": "printer_host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
|
||||
"CC2_URL": "cc2_url", "SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset",
|
||||
"POLL_INTERVAL": "poll_interval", "DRY_RUN": "dry_run",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Settings":
|
||||
s = cls()
|
||||
s.update(d)
|
||||
return s
|
||||
|
||||
def update(self, d: dict) -> None:
|
||||
for f in fields(self):
|
||||
if f.name in d and d[f.name] is not None:
|
||||
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
|
||||
self.validate()
|
||||
|
||||
def validate(self) -> None:
|
||||
if not 1 <= self.ssh_port <= 65535:
|
||||
raise ValueError("SSH-Port ungültig")
|
||||
if not 0 <= self.id_offset <= 65000:
|
||||
raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen")
|
||||
if not 1 <= self.poll_interval <= 300:
|
||||
raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen")
|
||||
if "{n}" not in self.location_slot:
|
||||
raise ValueError("Ortsname für Slots muss {n} enthalten")
|
||||
for name in ("cc2_url", "spoolman_url"):
|
||||
v = getattr(self, name)
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError(f"{name} muss mit http:// oder https:// beginnen")
|
||||
setattr(self, name, v.rstrip("/"))
|
||||
if not self.location_storage.strip():
|
||||
raise ValueError("Ortsname für das Lager fehlt")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def slot_location(self, slot: int) -> str:
|
||||
return self.location_slot.replace("{n}", str(slot + 1))
|
||||
|
||||
|
||||
def _coerce(typ, value, name):
|
||||
typ = typ if isinstance(typ, str) else typ.__name__
|
||||
try:
|
||||
if typ == "bool":
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||
return bool(value)
|
||||
if typ == "int":
|
||||
return int(value)
|
||||
if typ == "float":
|
||||
return float(value)
|
||||
return str(value).strip()
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"Ungültiger Wert für {name}") from None
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
def __init__(self, data_dir: Path, env: dict | None = None):
|
||||
self.path = data_dir / "settings.json"
|
||||
self._lock = threading.Lock()
|
||||
stored = read_json(self.path, None)
|
||||
if stored is None:
|
||||
env = os.environ if env is None else env
|
||||
defaults = {f: env[k] for k, f in Settings.ENV.items() if env.get(k)}
|
||||
self.settings = Settings.from_dict(defaults)
|
||||
else:
|
||||
self.settings = Settings.from_dict(stored)
|
||||
|
||||
def get(self) -> Settings:
|
||||
with self._lock:
|
||||
return Settings.from_dict(self.settings.to_dict())
|
||||
|
||||
def save(self, changes: dict) -> Settings:
|
||||
with self._lock:
|
||||
new = Settings.from_dict({**self.settings.to_dict(), **changes})
|
||||
atomic_write_json(self.path, new.to_dict())
|
||||
self.settings = new
|
||||
return Settings.from_dict(new.to_dict())
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Bridge state: slot mappings, booked consumption, last record."""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
self.path = data_dir / "state.json"
|
||||
self.data = read_json(self.path, {})
|
||||
self.data.setdefault("version", 1)
|
||||
self.data.setdefault("slots", {})
|
||||
self.data.setdefault("booked", {})
|
||||
self.data.setdefault("locations", {})
|
||||
self.data.setdefault("last_record", None)
|
||||
|
||||
def save(self) -> None:
|
||||
atomic_write_json(self.path, self.data)
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
"""Encode/decode Elegoo Canvas NFC tags (NTAG213/215/216) and canvas records.
|
||||
|
||||
Page numbers are hex, as used by NFC Tools ("A2:PP:XXXXXXXX"). All values are
|
||||
big-endian. Pages 0-3 (UID, lock bits, capability container) are never written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .materials import MATERIAL_BY_CODE, MATERIALS, SUBTYPE_BY_CODE, material_of_subtype
|
||||
|
||||
DEFAULT_URL = "https://www.elegoo.com"
|
||||
DEFAULT_ID_OFFSET = 10000
|
||||
MAX_WEIGHT = 0xFFFF
|
||||
|
||||
NDEF_FIRST_PAGE = 0x04
|
||||
NDEF_LAST_PAGE = 0x0F # canvas data starts at page 0x10
|
||||
CANVAS_PAGES = range(0x10, 0x19)
|
||||
|
||||
PAGE_18_DEFAULT = 0x0036C800 # copied from original Elegoo spools; meaning unknown
|
||||
|
||||
# Display palette from /opt/inst/filament_color_set.json
|
||||
PALETTE = [
|
||||
"FFFFFF", "FFF242", "DBF47A", "09CC3A", "077747", "0B6283", "0BE2A0", "74D9F3",
|
||||
"48A7FA", "2850DF", "433089", "A03BF7", "F32FF8", "D4B1DD", "F95D77", "F72221",
|
||||
"7C4C00", "F88D36", "FCEBD7", "D2C5A3", "AF7832", "898989", "BCBCBC", "000000",
|
||||
]
|
||||
BLUE = "2850DF"
|
||||
|
||||
_URI_PREFIXES = [
|
||||
("https://www.", 0x02), ("http://www.", 0x01), ("https://", 0x04), ("http://", 0x03),
|
||||
]
|
||||
|
||||
|
||||
class TagError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagData:
|
||||
spool_id: int
|
||||
color: str # RRGGBB
|
||||
material: str = "PLA"
|
||||
subtype: int = 0x0001
|
||||
temp_min: int = 190
|
||||
temp_max: int = 230
|
||||
diameter: float = 1.75
|
||||
finish: int = 0xFF
|
||||
filament_code: int = 0x0000
|
||||
id_offset: int = DEFAULT_ID_OFFSET
|
||||
url: str = DEFAULT_URL
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def normalize_hex_color(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
v = value.strip().lstrip("#").upper()
|
||||
if re.fullmatch(r"[0-9A-F]{8}", v): # RRGGBBAA
|
||||
v = v[:6]
|
||||
if re.fullmatch(r"[0-9A-F]{3}", v):
|
||||
v = "".join(c * 2 for c in v)
|
||||
return v if re.fullmatch(r"[0-9A-F]{6}", v) else None
|
||||
|
||||
|
||||
# ---------- NDEF ----------
|
||||
|
||||
def encode_ndef_url(url: str) -> bytes:
|
||||
"""Lock-control TLV + NDEF URI record + terminator, as on the original tags."""
|
||||
prefix_code, rest = 0x00, url
|
||||
for prefix, code in _URI_PREFIXES:
|
||||
if url.startswith(prefix):
|
||||
prefix_code, rest = code, url[len(prefix):]
|
||||
break
|
||||
body = rest.encode("utf-8")
|
||||
payload = bytes([prefix_code]) + body
|
||||
if len(payload) > 255:
|
||||
raise TagError("URL zu lang")
|
||||
record = bytes([0xD1, 0x01, len(payload), 0x55]) + payload
|
||||
data = bytes([0x01, 0x03, 0xA0, 0x0C, 0x34, 0x03, len(record)]) + record + b"\xFE"
|
||||
max_len = (NDEF_LAST_PAGE - NDEF_FIRST_PAGE + 1) * 4
|
||||
if len(data) > max_len:
|
||||
raise TagError(f"URL zu lang: {len(data)} Byte, maximal {max_len} (Seiten 04-0F)")
|
||||
pad = (-len(data)) % 4
|
||||
return data + b"\x00" * pad
|
||||
|
||||
|
||||
def decode_ndef_url(data: bytes) -> str | None:
|
||||
i = 0
|
||||
while i < len(data):
|
||||
t = data[i]
|
||||
if t == 0x00:
|
||||
i += 1
|
||||
continue
|
||||
if t == 0xFE or i + 1 >= len(data):
|
||||
return None
|
||||
length = data[i + 1]
|
||||
value = data[i + 2:i + 2 + length]
|
||||
if t == 0x03 and len(value) >= 5 and value[3] == 0x55:
|
||||
plen = value[2]
|
||||
payload = value[4:4 + plen]
|
||||
prefix = {code: p for p, code in _URI_PREFIXES}.get(payload[0], "")
|
||||
return prefix + payload[1:].decode("utf-8", "replace")
|
||||
i += 2 + length
|
||||
return None
|
||||
|
||||
|
||||
# ---------- canvas pages ----------
|
||||
|
||||
def encode_pages(tag: TagData) -> dict[int, bytes]:
|
||||
if tag.material not in MATERIALS:
|
||||
raise TagError(f"Unbekanntes Material {tag.material}")
|
||||
if tag.subtype not in SUBTYPE_BY_CODE:
|
||||
raise TagError(f"Unbekannter Untertyp 0x{tag.subtype:04X}")
|
||||
if material_of_subtype(tag.subtype) != tag.material:
|
||||
raise TagError(f"Untertyp {SUBTYPE_BY_CODE[tag.subtype]} passt nicht zu {tag.material}")
|
||||
color = normalize_hex_color(tag.color)
|
||||
if color is None:
|
||||
raise TagError("Farbe muss RRGGBB sein")
|
||||
weight = tag.spool_id + tag.id_offset
|
||||
if tag.spool_id < 0 or weight > MAX_WEIGHT:
|
||||
raise TagError(f"Spulen-ID {tag.spool_id} passt nicht in 2 Byte (max. {MAX_WEIGHT - tag.id_offset})")
|
||||
for t in (tag.temp_min, tag.temp_max):
|
||||
if not 0 <= t <= 500:
|
||||
raise TagError("Düsentemperatur außerhalb 0-500 °C")
|
||||
if tag.temp_min > tag.temp_max:
|
||||
raise TagError("Düse min ist größer als max")
|
||||
diameter = round(tag.diameter * 100)
|
||||
if not 0 < diameter <= 0xFFFF:
|
||||
raise TagError("Ungültiger Durchmesser")
|
||||
|
||||
pages: dict[int, bytes] = {}
|
||||
ndef = encode_ndef_url(tag.url)
|
||||
for i in range(0, len(ndef), 4):
|
||||
pages[NDEF_FIRST_PAGE + i // 4] = ndef[i:i + 4]
|
||||
pages[0x10] = bytes([0x36, 0xEE, 0xEE, 0xEE])
|
||||
pages[0x11] = bytes([0xEE]) + tag.filament_code.to_bytes(2, "big") + b"\x00"
|
||||
pages[0x12] = MATERIALS[tag.material]["code"].to_bytes(4, "big")
|
||||
pages[0x13] = tag.subtype.to_bytes(2, "big") + b"\x00\x00"
|
||||
pages[0x14] = bytes.fromhex(color) + bytes([tag.finish & 0xFF])
|
||||
pages[0x15] = tag.temp_min.to_bytes(2, "big") + tag.temp_max.to_bytes(2, "big")
|
||||
pages[0x16] = b"\x00\x00\x00\x00"
|
||||
pages[0x17] = diameter.to_bytes(2, "big") + weight.to_bytes(2, "big")
|
||||
pages[0x18] = PAGE_18_DEFAULT.to_bytes(4, "big")
|
||||
return pages
|
||||
|
||||
|
||||
def pages_to_commands(pages: dict[int, bytes]) -> str:
|
||||
for p in pages:
|
||||
if p < 4:
|
||||
raise TagError("Seiten 0-3 dürfen nicht geschrieben werden")
|
||||
return ",".join(f"A2:{p:02X}:{pages[p].hex().upper()}" for p in sorted(pages))
|
||||
|
||||
|
||||
def encode_commands(tag: TagData) -> str:
|
||||
return pages_to_commands(encode_pages(tag))
|
||||
|
||||
|
||||
def parse_commands(text: str) -> dict[int, bytes]:
|
||||
pages: dict[int, bytes] = {}
|
||||
for part in re.split(r"[,\s]+", text.strip()):
|
||||
if not part:
|
||||
continue
|
||||
m = re.fullmatch(r"A2:([0-9A-Fa-f]{2}):([0-9A-Fa-f]{8})", part)
|
||||
if not m:
|
||||
raise TagError(f"Ungültiger Befehl: {part}")
|
||||
pages[int(m.group(1), 16)] = bytes.fromhex(m.group(2))
|
||||
return pages
|
||||
|
||||
|
||||
def decode_pages(pages: dict[int, bytes], id_offset: int = DEFAULT_ID_OFFSET) -> dict:
|
||||
def page(n: int) -> bytes:
|
||||
return pages.get(n, b"\x00\x00\x00\x00")
|
||||
|
||||
ndef = b"".join(page(p) for p in range(NDEF_FIRST_PAGE, NDEF_LAST_PAGE + 1))
|
||||
mat_code = int.from_bytes(page(0x12), "big")
|
||||
subtype = int.from_bytes(page(0x13)[:2], "big")
|
||||
weight = int.from_bytes(page(0x17)[2:], "big")
|
||||
return {
|
||||
"url": decode_ndef_url(ndef),
|
||||
"header": page(0x10)[0],
|
||||
"manufacturer": page(0x10)[1:].hex().upper() + page(0x11)[:1].hex().upper(),
|
||||
"filament_code": int.from_bytes(page(0x11)[1:3], "big"),
|
||||
"material": MATERIAL_BY_CODE.get(mat_code, f"0x{mat_code:08X}"),
|
||||
"subtype": subtype,
|
||||
"subtype_name": SUBTYPE_BY_CODE.get(subtype, "?"),
|
||||
"color": page(0x14)[:3].hex().upper(),
|
||||
"finish": page(0x14)[3],
|
||||
"temp_min": int.from_bytes(page(0x15)[:2], "big"),
|
||||
"temp_max": int.from_bytes(page(0x15)[2:], "big"),
|
||||
"diameter": int.from_bytes(page(0x17)[:2], "big") / 100,
|
||||
"weight": weight,
|
||||
"spool_id": spool_id_from_weight(weight, id_offset),
|
||||
}
|
||||
|
||||
|
||||
# ---------- canvas record (/opt/usr/canvas_record_info.json) ----------
|
||||
|
||||
def spool_id_from_weight(weight, id_offset: int = DEFAULT_ID_OFFSET) -> int | None:
|
||||
"""weight >= offset encodes a Spoolman spool id; smaller values are real weights."""
|
||||
try:
|
||||
w = int(float(str(weight).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if w < id_offset or w > MAX_WEIGHT:
|
||||
return None
|
||||
return w - id_offset
|
||||
|
||||
|
||||
def parse_record(record: dict, id_offset: int = DEFAULT_ID_OFFSET) -> list[dict]:
|
||||
"""Return 4 slot dicts (slot 0-3) with the raw channel and the decoded spool id."""
|
||||
slots = []
|
||||
for n in range(4):
|
||||
ch = record.get(f"channels{n}") or {}
|
||||
if not isinstance(ch, dict):
|
||||
ch = {}
|
||||
occupied = any(str(v).strip() for v in ch.values())
|
||||
slots.append({
|
||||
"slot": n,
|
||||
"channel": ch,
|
||||
"occupied": occupied,
|
||||
"spool_id": spool_id_from_weight(ch.get("weight"), id_offset) if occupied else None,
|
||||
})
|
||||
return slots
|
||||
|
||||
|
||||
# ---------- colour suggestion ----------
|
||||
|
||||
def _srgb_to_lab(hexcolor: str) -> tuple[float, float, float]:
|
||||
rgb = [int(hexcolor[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||
lin = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb]
|
||||
r, g, b = lin
|
||||
x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||
y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||
|
||||
def f(t):
|
||||
return t ** (1 / 3) if t > 216 / 24389 else (24389 / 27 * t + 16) / 116
|
||||
|
||||
fx, fy, fz = f(x), f(y), f(z)
|
||||
return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)
|
||||
|
||||
|
||||
def _lab_distance(a: str, b: str) -> float:
|
||||
return math.dist(_srgb_to_lab(a), _srgb_to_lab(b))
|
||||
|
||||
|
||||
def _is_saturated_blue(hexcolor: str) -> bool:
|
||||
r, g, b = (int(hexcolor[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||||
return 215 <= h * 360 <= 245 and s >= 0.6 and v >= 0.35
|
||||
|
||||
|
||||
def nearest_palette_color(hexcolor: str | None) -> str:
|
||||
color = normalize_hex_color(hexcolor)
|
||||
if color is None:
|
||||
return "000000"
|
||||
if color in PALETTE:
|
||||
return color
|
||||
if _is_saturated_blue(color):
|
||||
# Lab distance lands on the violet entries for saturated blues.
|
||||
return BLUE
|
||||
return min(PALETTE, key=lambda p: _lab_distance(color, p))
|
||||
@@ -0,0 +1,131 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}CC2 Bridge{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9; --panel: #ffffff; --text: #1d2330; --muted: #5d6677; --line: #dde1e8;
|
||||
--accent: #2856d9; --accent-text: #fff; --ok: #1b7f3b; --warn: #a15c00; --err: #b3261e;
|
||||
--ok-bg: #e7f5ec; --warn-bg: #fff4e0; --err-bg: #fde8e7; --dry-bg: #eef1ff; --code: #f0f2f5;
|
||||
--radius: 8px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14171c; --panel: #1c2027; --text: #e6e9ef; --muted: #9aa3b2; --line: #2e343e;
|
||||
--accent: #6d8dff; --accent-text: #0d1020; --ok: #5cc982; --warn: #f0b55c; --err: #ff8a80;
|
||||
--ok-bg: #16301f; --warn-bg: #33270f; --err-bg: #3a1a18; --dry-bg: #1e2340; --code: #232832;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
header { background: var(--panel); border-bottom: 1px solid var(--line); }
|
||||
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px; }
|
||||
header .wrap { display: flex; align-items: center; gap: 24px; min-height: 52px; flex-wrap: wrap; }
|
||||
header h1 { font-size: 16px; margin: 0; font-weight: 650; }
|
||||
nav { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
nav a { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; }
|
||||
nav a.on, nav a:hover { color: var(--text); background: var(--code); }
|
||||
.mode { margin-left: auto; font-size: 13px; padding: 3px 10px; border-radius: 99px; font-weight: 600; }
|
||||
.mode.dry { background: var(--dry-bg); color: var(--accent); }
|
||||
.mode.live { background: var(--err-bg); color: var(--err); }
|
||||
main { padding: 20px 0 48px; }
|
||||
h2 { font-size: 18px; margin: 28px 0 12px; }
|
||||
h2:first-child { margin-top: 0; }
|
||||
h3 { font-size: 15px; margin: 0 0 8px; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
|
||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 13px; }
|
||||
.banner { border-radius: var(--radius); padding: 10px 14px; margin-bottom: 12px; display: flex; gap: 12px;
|
||||
align-items: center; flex-wrap: wrap; }
|
||||
.banner.err { background: var(--err-bg); color: var(--err); }
|
||||
.banner.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.banner.ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.banner.dry { background: var(--dry-bg); color: var(--accent); }
|
||||
button, .btn { font: inherit; border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; }
|
||||
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
button.danger { color: var(--err); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 6px 8px; width: 100%; }
|
||||
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
||||
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.row > .field { flex: 1 1 160px; }
|
||||
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
||||
code { background: var(--code); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
|
||||
pre { background: var(--code); padding: 10px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap;
|
||||
word-break: break-all; margin: 0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th { font-size: 13px; color: var(--muted); font-weight: 600; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line);
|
||||
vertical-align: -2px; }
|
||||
.tag { display: inline-block; font-size: 12px; padding: 1px 7px; border-radius: 99px; background: var(--code); }
|
||||
.tag.ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.tag.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.tag.err { background: var(--err-bg); color: var(--err); }
|
||||
.tag.dry { background: var(--dry-bg); color: var(--accent); }
|
||||
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 3px 12px; margin: 0; font-size: 14px; }
|
||||
dl.kv dt { color: var(--muted); }
|
||||
dl.kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--text);
|
||||
color: var(--bg); padding: 8px 14px; border-radius: 6px; opacity: 0; transition: opacity .2s; pointer-events: none; }
|
||||
.toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="wrap">
|
||||
<h1>CC2 ↔ Spoolman</h1>
|
||||
<nav>
|
||||
<a href="/" class="{{ 'on' if active == 'status.html' }}">Status</a>
|
||||
<a href="/tags" class="{{ 'on' if active == 'tags.html' }}">Tag-Generator</a>
|
||||
<a href="/settings" class="{{ 'on' if active == 'settings.html' }}">Einstellungen</a>
|
||||
</nav>
|
||||
<span id="mode" class="mode"></span>
|
||||
</div></header>
|
||||
<main><div class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</div></main>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(method, url, body) {
|
||||
const opt = { method, headers: { 'X-Bridge-Request': '1' } };
|
||||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opt);
|
||||
let data = null;
|
||||
try { data = await r.json(); } catch (e) {}
|
||||
if (!r.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + r.status));
|
||||
return data;
|
||||
}
|
||||
function toast(msg) {
|
||||
const t = $('toast'); t.textContent = msg; t.classList.add('show');
|
||||
clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('show'), 2200);
|
||||
}
|
||||
async function copyText(text) {
|
||||
try { await navigator.clipboard.writeText(text); }
|
||||
catch (e) {
|
||||
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta);
|
||||
ta.select(); document.execCommand('copy'); ta.remove();
|
||||
}
|
||||
toast('Kopiert');
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString('de-DE') : '–'; }
|
||||
function setMode(dry) {
|
||||
const m = $('mode');
|
||||
m.textContent = dry ? 'Dry-Run' : 'Schreiben aktiv';
|
||||
m.className = 'mode ' + (dry ? 'dry' : 'live');
|
||||
}
|
||||
api('GET', '/api/settings').then((s) => setMode(s.dry_run)).catch(() => {});
|
||||
</script>
|
||||
{% block script %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Einstellungen{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Einstellungen</h2>
|
||||
<form id="settings" class="panel" onsubmit="saveSettings(event)">
|
||||
<div class="row">
|
||||
<div class="field"><label for="printer_host">Drucker-IP</label><input id="printer_host" required></div>
|
||||
<div class="field"><label for="ssh_port">SSH-Port</label><input id="ssh_port" type="number" min="1" max="65535"></div>
|
||||
<div class="field"><label for="ssh_user">SSH-Benutzer</label><input id="ssh_user"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="cc2_url">CC2-Control-URL</label><input id="cc2_url" required></div>
|
||||
<div class="field"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
|
||||
<div class="field"><label for="poll_interval">Abfrage-Intervall (s)</label><input id="poll_interval" type="number" step="0.5" min="1" max="300"></div>
|
||||
<div class="field"><label for="empty_grace">Slot leer nach (s)</label><input id="empty_grace" type="number" min="0"></div>
|
||||
<div class="field"><label for="settle_seconds">Buchen nach Ruhe (s)</label><input id="settle_seconds" type="number" min="0"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="location_slot">Ort im Slot (<code>{n}</code> = 1–4)</label><input id="location_slot" required></div>
|
||||
<div class="field"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" id="dry_run" style="width:auto"> Dry-Run (Schreibaktionen nur protokollieren)</label>
|
||||
</div>
|
||||
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved" class="small muted"></span></div>
|
||||
</form>
|
||||
|
||||
<h2>SSH-Schlüssel für den Drucker</h2>
|
||||
<div class="panel">
|
||||
<div id="keyinfo" class="field muted">Lade …</div>
|
||||
<div class="field">
|
||||
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen</label>
|
||||
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="file" id="keyfile" style="flex:1 1 220px">
|
||||
<button class="primary" onclick="saveKey()">Schlüssel speichern</button>
|
||||
<button class="danger" onclick="deleteKey()">Schlüssel löschen</button>
|
||||
</div>
|
||||
<p class="small muted">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p>
|
||||
</div>
|
||||
|
||||
<h2>Verbindung testen</h2>
|
||||
<div class="panel">
|
||||
<div id="hostkey" class="field muted"></div>
|
||||
<div class="row"><button class="primary" onclick="testSSH()">Verbindung testen</button>
|
||||
<button class="danger" onclick="forgetHost()">Host-Key vergessen</button></div>
|
||||
<div id="testresult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const FIELDS = ['printer_host', 'ssh_port', 'ssh_user', 'cc2_url', 'spoolman_url', 'id_offset', 'poll_interval',
|
||||
'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
|
||||
let current = {};
|
||||
|
||||
async function loadSettings() {
|
||||
current = await api('GET', '/api/settings');
|
||||
for (const f of FIELDS) $(f).value = current[f];
|
||||
$('dry_run').checked = current.dry_run;
|
||||
setMode(current.dry_run);
|
||||
}
|
||||
|
||||
async function saveSettings(ev) {
|
||||
ev.preventDefault();
|
||||
const body = {};
|
||||
for (const f of FIELDS) body[f] = $(f).value;
|
||||
body.dry_run = $('dry_run').checked;
|
||||
if (current.dry_run && !body.dry_run && !confirm(
|
||||
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, setzt Orte in Spoolman und bucht Verbrauch.')) return;
|
||||
try {
|
||||
current = await api('POST', '/api/settings', body);
|
||||
setMode(current.dry_run);
|
||||
$('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
function renderKey(info) {
|
||||
const k = info.key;
|
||||
if (!k) { $('keyinfo').innerHTML = '<span class="tag warn">Kein Schlüssel hinterlegt</span>'; return; }
|
||||
if (k.error) { $('keyinfo').innerHTML = `<span class="tag err">${esc(k.error)}</span>`; return; }
|
||||
const host = $('printer_host').value || '10.10.2.202';
|
||||
$('keyinfo').innerHTML = `<dl class="kv"><dt>Typ</dt><dd>${esc(k.type)}</dd><dt>Fingerprint</dt><dd><code>${esc(k.fingerprint)}</code></dd></dl>
|
||||
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf dem Drucker (öffentlicher Schlüssel, eingeschränkt):</p>
|
||||
<pre id="authline">command="cat /opt/usr/canvas_record_info.json",from="10.10.6.10",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ${esc(k.public)}</pre>
|
||||
<button style="margin-top:6px" onclick="copyText($('authline').textContent)">Kopieren</button>`;
|
||||
}
|
||||
|
||||
function renderHost(hk) {
|
||||
$('hostkey').innerHTML = hk
|
||||
? `<dl class="kv"><dt>Bestätigter Host-Key</dt><dd><code>${esc(hk.fingerprint)}</code> <span class="muted small">(${esc(hk.type)}, ${esc(hk.host)})</span></dd></dl>`
|
||||
: '<span class="tag warn">Host-Key des Druckers noch nicht bestätigt</span>';
|
||||
}
|
||||
|
||||
async function loadSSH() { const info = await api('GET', '/api/ssh'); renderKey(info); renderHost(info.host_key); }
|
||||
|
||||
$('keyfile').addEventListener('change', async (ev) => {
|
||||
const f = ev.target.files[0];
|
||||
if (f) $('keytext').value = await f.text();
|
||||
});
|
||||
|
||||
async function saveKey() {
|
||||
const key = $('keytext').value.trim();
|
||||
if (!key) { toast('Kein Schlüssel eingegeben'); return; }
|
||||
try {
|
||||
const r = await api('POST', '/api/ssh/key', { key });
|
||||
$('keytext').value = ''; $('keyfile').value = '';
|
||||
renderKey(r); toast('Schlüssel gespeichert');
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function deleteKey() {
|
||||
if (!confirm('SSH-Schlüssel löschen?')) return;
|
||||
await api('DELETE', '/api/ssh/key'); loadSSH();
|
||||
}
|
||||
|
||||
async function forgetHost() {
|
||||
if (!confirm('Gespeicherten Host-Key vergessen? Danach muss er beim nächsten Test neu bestätigt werden.')) return;
|
||||
await api('DELETE', '/api/ssh/hostkey'); loadSSH();
|
||||
}
|
||||
|
||||
async function testSSH() {
|
||||
$('testresult').innerHTML = '<span class="muted">Verbinde …</span>';
|
||||
let r;
|
||||
try { r = await api('POST', '/api/ssh/test'); } catch (e) { $('testresult').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
if (!r.ok) {
|
||||
$('testresult').innerHTML = `<div class="banner err">${esc(r.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
const rows = r.slots.map((s) => {
|
||||
const ch = s.channel || {};
|
||||
const c = (ch.color || '').replace(/^0x/i, '');
|
||||
return `<tr><td>Slot ${s.slot + 1}</td><td>${s.occupied ? `${/^[0-9a-f]{6}$/i.test(c) ? `<span class="swatch" style="background:#${c}"></span> ` : ''}${esc(ch.detailed_type || ch.type)} <span class="muted small">${esc(ch.manufacturer)}</span>` : '<span class="muted">leer</span>'}</td>
|
||||
<td class="mono">${esc(ch.weight)}</td><td>${s.spool_id ?? '<span class="muted">–</span>'}</td></tr>`;
|
||||
}).join('');
|
||||
let host = `<div class="banner ok">Verbunden. Host-Key <code>${esc(r.host_key.fingerprint)}</code> stimmt mit dem gespeicherten überein.</div>`;
|
||||
if (!r.host_key_pinned) {
|
||||
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}).
|
||||
Mit <code>ssh-keygen -lf /etc/dropbear/…</code> bzw. <code>ssh-keyscan</code> vergleichen und bestätigen.
|
||||
<button class="primary" onclick="acceptHost('${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
|
||||
}
|
||||
$('testresult').innerHTML = host + `<div class="table-wrap"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
async function acceptHost(fp) {
|
||||
if (!confirm('Host-Key ' + fp + ' als vertrauenswürdig speichern?')) return;
|
||||
try { const r = await api('POST', '/api/ssh/hostkey', { fingerprint: fp }); renderHost(r.host_key); testSSH(); }
|
||||
catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
loadSettings().then(loadSSH);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Status{% endblock %}
|
||||
{% block content %}
|
||||
<div id="banners"></div>
|
||||
<h2>Canvas-Slots</h2>
|
||||
<div class="grid" id="slots"><div class="panel muted">Lade …</div></div>
|
||||
<p class="small muted" id="lastpoll"></p>
|
||||
<h2>Protokoll</h2>
|
||||
<div class="panel table-wrap">
|
||||
<table><thead><tr><th style="width:170px">Zeit</th><th style="width:80px"></th><th>Aktion</th></tr></thead>
|
||||
<tbody id="log"></tbody></table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const TRAY = { 0: ['leer', 'warn'], 1: ['eingelegt', 'ok'], 2: ['fördert', 'ok'] };
|
||||
const LEVEL = { info: ['Info', ''], warn: ['Warnung', 'warn'], error: ['Fehler', 'err'], dry: ['Dry-Run', 'dry'] };
|
||||
|
||||
function colorOf(ch) {
|
||||
const c = (ch.color || '').replace(/^0x/i, '');
|
||||
return /^[0-9a-f]{6}$/i.test(c) ? c : null;
|
||||
}
|
||||
|
||||
function slotCard(s) {
|
||||
const ch = s.record.channel || {};
|
||||
const tray = TRAY[s.tray_status];
|
||||
const col = colorOf(ch);
|
||||
const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool;
|
||||
let tag = '<span class="muted">kein Tag</span>';
|
||||
if (s.record.occupied) {
|
||||
tag = `${col ? `<span class="swatch" style="background:#${col}"></span> ` : ''}${esc(ch.detailed_type || ch.type)}` +
|
||||
` <span class="muted small">${esc(ch.manufacturer)}, weight ${esc(ch.weight)}</span>`;
|
||||
}
|
||||
let assign = '<span class="muted">–</span>';
|
||||
if (m) {
|
||||
assign = s.cc2_matches ? '<span class="tag ok">zugewiesen</span>'
|
||||
: (m.cc2 ? '<span class="tag warn">nicht zugewiesen</span>' : '<span class="tag warn">noch nicht angelegt</span>');
|
||||
}
|
||||
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: ${esc(s.cc2_question)}</span>`;
|
||||
const lb = s.last_booking;
|
||||
return `<div class="panel">
|
||||
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
|
||||
<dl class="kv">
|
||||
<dt>Tag</dt><dd>${tag}</dd>
|
||||
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd>
|
||||
<dt>Spoolman</dt><dd>${m ? `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
|
||||
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '') : '<span class="muted">nicht zugeordnet</span>'}</dd>
|
||||
<dt>CC2 Control</dt><dd>${cc ? esc(cc.name) + ` <span class="small muted">Rest ${Number(cc.remaining).toFixed(1)} g</span><br>` : ''}${assign}</dd>
|
||||
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` : '<span class="muted">–</span>'}</dd>
|
||||
</dl>
|
||||
${m ? `<div class="row" style="margin-top:10px">
|
||||
<button onclick="slotAction(${s.slot}, 'reassign')">Zuordnung erneut senden</button>
|
||||
<button class="danger" onclick="slotAction(${s.slot}, 'release')">Aufheben</button></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function slotAction(slot, action) {
|
||||
const text = action === 'release'
|
||||
? `Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
|
||||
: `Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
|
||||
if (!confirm(text)) return;
|
||||
try { await api('POST', `/api/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
|
||||
catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function enableCC2() {
|
||||
if (!confirm('Spulenverwaltung in CC2 Control einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.')) return;
|
||||
try {
|
||||
const r = await api('POST', '/api/cc2/enable', { confirm: true });
|
||||
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
|
||||
setTimeout(load, 1500);
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let st;
|
||||
try { st = await api('GET', '/api/status'); } catch (e) { $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
setMode(st.dry_run);
|
||||
const b = [];
|
||||
if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>');
|
||||
if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>');
|
||||
else if (!st.host_key) b.push('<div class="banner warn">Host-Key des Druckers noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
|
||||
if (st.cc2_enabled === false) b.push('<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2()">Einschalten …</button></div>');
|
||||
if (st.job_active) b.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
|
||||
for (const [k, v] of Object.entries(st.errors || {})) if (v) b.push(`<div class="banner err">${esc(v)}</div>`);
|
||||
$('banners').innerHTML = b.join('');
|
||||
if (st.slots) $('slots').innerHTML = st.slots.map(slotCard).join('');
|
||||
$('lastpoll').textContent = st.last_poll ? 'Letzte Abfrage: ' + fmtTime(st.last_poll) : 'Noch keine Abfrage';
|
||||
$('log').innerHTML = (st.log || []).map((e) => {
|
||||
const l = LEVEL[e.level] || [e.level, ''];
|
||||
return `<tr><td class="small muted">${fmtTime(e.time)}</td><td><span class="tag ${l[1]}">${l[0]}</span></td><td>${esc(e.msg)}</td></tr>`;
|
||||
}).join('') || '<tr><td colspan="3" class="muted">Noch keine Einträge</td></tr>';
|
||||
}
|
||||
load();
|
||||
setInterval(load, 3000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,171 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Tag-Generator{% endblock %}
|
||||
{% block content %}
|
||||
<div class="banner err"><b>Wichtig:</b> In NFC Tools nie „Lock tag“ oder „Set password“ verwenden – der Tag wäre danach nicht mehr beschreibbar.</div>
|
||||
<h2>Spoolman-Spulen</h2>
|
||||
<div class="panel">
|
||||
<div class="row" style="margin-bottom:10px">
|
||||
<div class="field" style="margin:0"><label for="filter">Filter</label><input id="filter" placeholder="Label, Hersteller, Material …"></div>
|
||||
<label style="flex:0 0 auto;margin:0 0 8px"><input type="checkbox" id="archived" style="width:auto"> archivierte zeigen</label>
|
||||
</div>
|
||||
<div class="table-wrap" style="max-height:340px;overflow-y:auto">
|
||||
<table><thead><tr><th>ID</th><th>Label</th><th>Hersteller</th><th>Material</th><th>Farbe</th><th>Rest</th><th>Ort</th><th></th></tr></thead>
|
||||
<tbody id="spools"><tr><td colspan="8" class="muted">Lade …</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editor" style="display:none">
|
||||
<h2 id="edtitle">Tag</h2>
|
||||
<div class="panel">
|
||||
<div class="row">
|
||||
<div class="field"><label for="material">Elegoo-Material</label><select id="material"></select></div>
|
||||
<div class="field"><label for="subtype">Untertyp (wählt das Slicer-Profil)</label><select id="subtype"></select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="color">Tag-Farbe (RRGGBB)</label>
|
||||
<div class="row" style="gap:6px;flex-wrap:nowrap"><input id="color" maxlength="7" class="mono"><input type="color" id="picker" style="width:48px;padding:2px"></div></div>
|
||||
<div class="field"><label for="temp_min">Düse min (°C)</label><input id="temp_min" type="number" min="0" max="500"></div>
|
||||
<div class="field"><label for="temp_max">Düse max (°C)</label><input id="temp_max" type="number" min="0" max="500"></div>
|
||||
<div class="field"><label for="finish">Finish-Byte (hex)</label><input id="finish" maxlength="2" class="mono" value="FF"></div>
|
||||
</div>
|
||||
<div class="field"><label>Display-Palette</label><div id="palette" style="display:flex;flex-wrap:wrap;gap:4px"></div>
|
||||
<div class="small muted" id="colorhint" style="margin-top:4px"></div></div>
|
||||
<div class="field"><label>URL auf dem Tag (nur fürs Handy, der Drucker ignoriert sie)</label>
|
||||
<label style="color:var(--text)"><input type="radio" name="url" value="elegoo" checked style="width:auto"> <code>https://www.elegoo.com</code> (verifizierte Vorlage)</label>
|
||||
<label style="color:var(--text)"><input type="radio" name="url" value="spoolman" style="width:auto"> Link auf die Spule in Spoolman <span class="muted">(nicht am Drucker getestet)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Befehlskette für NFC Tools</h2>
|
||||
<div class="panel">
|
||||
<div id="tagerror"></div>
|
||||
<textarea id="commands" rows="5" readonly></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="primary" onclick="copyText($('commands').value)">Kopieren</button>
|
||||
<label style="margin:0;color:var(--text)"><input type="checkbox" id="showqr" style="width:auto"> QR-Code zeigen</label>
|
||||
</div>
|
||||
<div id="qr" style="display:none;margin-top:10px;background:#fff;padding:8px;border-radius:6px;width:max-content;max-width:100%;overflow:auto"></div>
|
||||
<ol class="small muted" style="margin:14px 0 0;padding-left:20px">
|
||||
<li>NFC Tools → <i>Other</i> → <i>Advanced NFC commands</i>.</li>
|
||||
<li>Befehlskette einfügen, leeren NTAG213/215/216 ans Handy halten, ausführen.</li>
|
||||
<li>Spule in die Canvas legen und den Scan am Display bestätigen.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>Dekodier-Vorschau</h2>
|
||||
<div class="panel"><dl class="kv" id="decoded"></dl></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
let META = null, SPOOLS = [], current = null, timer = null, spoolmanBase = '';
|
||||
|
||||
async function init() {
|
||||
META = await api('GET', '/api/materials');
|
||||
$('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
|
||||
$('palette').innerHTML = META.palette.map((c) =>
|
||||
`<button type="button" title="#${c}" onclick="setColor('${c}')" style="width:26px;height:26px;padding:0;background:#${c}"></button>`).join('');
|
||||
await loadSpools();
|
||||
}
|
||||
|
||||
async function loadSpools() {
|
||||
try {
|
||||
const r = await api('GET', '/api/spoolman/spools' + ($('archived').checked ? '?archived=true' : ''));
|
||||
SPOOLS = r.spools;
|
||||
} catch (e) {
|
||||
$('spools').innerHTML = `<tr><td colspan="8"><div class="banner err" style="margin:0">${esc(e.message)}</div></td></tr>`;
|
||||
return;
|
||||
}
|
||||
renderSpools();
|
||||
}
|
||||
|
||||
function renderSpools() {
|
||||
const q = $('filter').value.toLowerCase();
|
||||
const rows = SPOOLS.filter((s) => !q || [s.label, s.vendor, s.material, s.name, s.location].join(' ').toLowerCase().includes(q));
|
||||
$('spools').innerHTML = rows.map((s) => `<tr>
|
||||
<td>${s.id}</td><td><b>${esc(s.label)}</b></td><td>${esc(s.vendor)}</td><td>${esc(s.material)} <span class="muted small">${esc(s.name)}</span></td>
|
||||
<td>${s.color ? `<span class="swatch" style="background:#${s.color}"></span> <span class="mono small">${s.color}</span>` : '–'}</td>
|
||||
<td>${s.remaining != null ? Math.round(s.remaining) + ' g' : '–'}</td><td class="small">${esc(s.location)}</td>
|
||||
<td><button onclick="pick(${s.id})">Tag erstellen</button></td></tr>`).join('')
|
||||
|| '<tr><td colspan="8" class="muted">Keine Spulen</td></tr>';
|
||||
}
|
||||
|
||||
function fillSubtypes(material, selected) {
|
||||
const list = META.subtypes.filter((s) => s.material === material);
|
||||
$('subtype').innerHTML = list.map((s) =>
|
||||
`<option value="${s.code}" ${s.code === selected ? 'selected' : ''}>${esc(s.name)} (0x${s.code.toString(16).padStart(4, '0').toUpperCase()})${s.hidden ? ' – am CC2 ausgeblendet' : ''}</option>`).join('');
|
||||
}
|
||||
|
||||
function setColor(c) {
|
||||
c = c.replace('#', '').toUpperCase();
|
||||
$('color').value = c;
|
||||
if (/^[0-9A-F]{6}$/.test(c)) $('picker').value = '#' + c;
|
||||
schedule();
|
||||
}
|
||||
|
||||
function pick(id) {
|
||||
current = SPOOLS.find((s) => s.id === id);
|
||||
const sg = current.suggest;
|
||||
$('edtitle').textContent = `Tag für ${current.label} (Spoolman #${current.id})`;
|
||||
$('material').value = sg.material;
|
||||
fillSubtypes(sg.material, sg.subtype);
|
||||
$('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max;
|
||||
$('finish').value = 'FF';
|
||||
$('colorhint').innerHTML = current.color
|
||||
? `Spoolman-Farbe <span class="swatch" style="background:#${current.color}"></span> <span class="mono">${current.color}</span> → Vorschlag <span class="mono">${sg.color}</span>`
|
||||
: 'Spoolman-Spule hat keine Farbe.';
|
||||
setColor(sg.color);
|
||||
$('editor').style.display = '';
|
||||
$('editor').scrollIntoView({ behavior: 'smooth' });
|
||||
generate();
|
||||
}
|
||||
|
||||
function schedule() { clearTimeout(timer); timer = setTimeout(generate, 250); }
|
||||
|
||||
async function generate() {
|
||||
if (!current) return;
|
||||
const useSpoolman = document.querySelector('input[name=url]:checked').value === 'spoolman';
|
||||
const body = {
|
||||
spool_id: current.id, material: $('material').value, subtype: Number($('subtype').value),
|
||||
color: $('color').value, temp_min: Number($('temp_min').value), temp_max: Number($('temp_max').value),
|
||||
finish: parseInt($('finish').value || 'FF', 16), diameter: current.diameter || 1.75,
|
||||
url: useSpoolman ? current.spoolman_url : null,
|
||||
};
|
||||
try {
|
||||
const r = await api('POST', '/api/tag', body);
|
||||
$('tagerror').innerHTML = '';
|
||||
$('commands').value = r.commands;
|
||||
$('qr').innerHTML = r.qr_svg;
|
||||
const d = r.decoded;
|
||||
$('decoded').innerHTML = [
|
||||
['Spoolman-ID', `${d.spool_id} <span class="muted small">(weight ${d.weight})</span>`],
|
||||
['Hersteller', `${esc(d.manufacturer)} <span class="muted small">(Drucker zeigt ELEGOO)</span>`],
|
||||
['Material', esc(d.material)],
|
||||
['Untertyp', `${esc(d.subtype_name)} <span class="mono small">0x${d.subtype.toString(16).padStart(4, '0').toUpperCase()}</span>`],
|
||||
['Farbe', `<span class="swatch" style="background:#${d.color}"></span> <span class="mono">${d.color}</span>, Finish ${d.finish.toString(16).toUpperCase()}`],
|
||||
['Düse', `${d.temp_min}–${d.temp_max} °C`],
|
||||
['Durchmesser', `${d.diameter.toFixed(2)} mm`],
|
||||
['URL', esc(d.url || '–')],
|
||||
].map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
|
||||
} catch (e) {
|
||||
$('tagerror').innerHTML = `<div class="banner err">${esc(e.message)}</div>`;
|
||||
$('commands').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
$('material').addEventListener('change', () => {
|
||||
const m = $('material').value;
|
||||
fillSubtypes(m, META.subtypes.find((s) => s.material === m).code);
|
||||
const t = META.materials[m].temps; $('temp_min').value = t[0]; $('temp_max').value = t[1];
|
||||
schedule();
|
||||
});
|
||||
for (const id of ['subtype', 'temp_min', 'temp_max', 'finish']) $(id).addEventListener('input', schedule);
|
||||
$('color').addEventListener('input', () => { const c = $('color').value.replace('#', ''); if (/^[0-9a-f]{6}$/i.test(c)) $('picker').value = '#' + c; schedule(); });
|
||||
$('picker').addEventListener('input', () => setColor($('picker').value));
|
||||
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
|
||||
$('showqr').addEventListener('change', () => { $('qr').style.display = $('showqr').checked ? '' : 'none'; });
|
||||
$('filter').addEventListener('input', renderSpools);
|
||||
$('archived').addEventListener('change', loadSpools);
|
||||
init();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
"""Web UI and JSON API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
import segno
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from . import materials, tagcodec
|
||||
from .bridge import Bridge
|
||||
from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint
|
||||
from .spoolman import SpoolmanError, summary
|
||||
from .storage import SettingsStore
|
||||
|
||||
log = logging.getLogger("web")
|
||||
HERE = Path(__file__).parent
|
||||
VERSION = os.environ.get("APP_VERSION", "dev")
|
||||
|
||||
|
||||
def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, start_loop: bool = True,
|
||||
env: dict | None = None) -> FastAPI:
|
||||
env = os.environ if env is None else env
|
||||
data_dir = Path(data_dir or env.get("DATA_DIR", "/data"))
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings = bridge.settings_store if bridge else SettingsStore(data_dir, env)
|
||||
keys = bridge.keys if bridge else KeyStore(data_dir)
|
||||
bridge = bridge or Bridge(data_dir, settings, keys)
|
||||
stop = threading.Event()
|
||||
tested_host_keys: dict[str, object] = {} # fingerprint -> key from the last connection test
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
thread = None
|
||||
if start_loop:
|
||||
thread = threading.Thread(target=bridge.run, args=(stop,), name="bridge", daemon=True)
|
||||
thread.start()
|
||||
yield
|
||||
stop.set()
|
||||
bridge.wake.set()
|
||||
if thread:
|
||||
thread.join(timeout=15)
|
||||
|
||||
app = FastAPI(title="CC2 Spoolman Bridge", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.state.bridge = bridge
|
||||
templates = Jinja2Templates(directory=str(HERE / "templates"))
|
||||
|
||||
user, password = env.get("BRIDGE_USER", ""), env.get("BRIDGE_PASSWORD", "")
|
||||
|
||||
@app.middleware("http")
|
||||
async def guard(request: Request, call_next):
|
||||
if user and password and request.url.path != "/healthz":
|
||||
ok = False
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.lower().startswith("basic "):
|
||||
try:
|
||||
u, _, p = base64.b64decode(auth[6:]).decode("utf-8").partition(":")
|
||||
ok = hmac.compare_digest(u, user) & hmac.compare_digest(p, password)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
ok = False
|
||||
if not ok:
|
||||
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="CC2 Bridge"'})
|
||||
# Writes only from our own pages: a custom header cannot be sent cross-site without CORS.
|
||||
if request.method not in ("GET", "HEAD") and request.headers.get("x-bridge-request") != "1":
|
||||
return JSONResponse({"error": "Fehlender X-Bridge-Request-Header"}, status_code=403)
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
return response
|
||||
|
||||
def page(request: Request, name: str, **ctx):
|
||||
return templates.TemplateResponse(request, name, {"version": VERSION, "active": name, **ctx})
|
||||
|
||||
# ---------- pages ----------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
return page(request, "status.html")
|
||||
|
||||
@app.get("/tags", response_class=HTMLResponse)
|
||||
def tags(request: Request):
|
||||
return page(request, "tags.html")
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request):
|
||||
return page(request, "settings.html")
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"ok": True, "version": VERSION, "last_poll": bridge.snapshot.get("last_poll")}
|
||||
|
||||
# ---------- status ----------
|
||||
|
||||
@app.get("/api/status")
|
||||
def status():
|
||||
return {**bridge.snapshot, "dry_run": settings.get().dry_run, "log": bridge.log.recent(200)}
|
||||
|
||||
@app.post("/api/cc2/enable")
|
||||
async def cc2_enable(request: Request):
|
||||
body = await _json(request)
|
||||
if body.get("confirm") is not True:
|
||||
raise HTTPException(400, "Bestätigung fehlt")
|
||||
bridge.commands.put(("enable", None))
|
||||
bridge.wake.set()
|
||||
return {"ok": True, "dry_run": settings.get().dry_run}
|
||||
|
||||
@app.post("/api/slots/{slot}/{action}")
|
||||
def slot_action(slot: int, action: str):
|
||||
if slot not in range(4) or action not in ("reassign", "release"):
|
||||
raise HTTPException(404)
|
||||
bridge.commands.put((action, slot))
|
||||
bridge.wake.set()
|
||||
return {"ok": True}
|
||||
|
||||
# ---------- settings ----------
|
||||
|
||||
@app.get("/api/settings")
|
||||
def get_settings():
|
||||
return settings.get().to_dict()
|
||||
|
||||
@app.post("/api/settings")
|
||||
async def post_settings(request: Request):
|
||||
body = await _json(request)
|
||||
allowed = set(settings.get().to_dict())
|
||||
try:
|
||||
s = settings.save({k: v for k, v in body.items() if k in allowed})
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
bridge.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
|
||||
bridge.wake.set()
|
||||
return s.to_dict()
|
||||
|
||||
# ---------- SSH ----------
|
||||
|
||||
@app.get("/api/ssh")
|
||||
def ssh_info():
|
||||
return {"key": keys.key_info(), "host_key": keys.host_key()}
|
||||
|
||||
@app.post("/api/ssh/key")
|
||||
async def ssh_key(request: Request):
|
||||
body = await _json(request)
|
||||
text = body.get("key")
|
||||
if not isinstance(text, str) or len(text) > 20000:
|
||||
raise HTTPException(400, "Schlüssel fehlt")
|
||||
try:
|
||||
keys.save_key(text)
|
||||
except SSHError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
bridge.log.add("info", "SSH-Schlüssel gespeichert")
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
return {"key": keys.key_info()}
|
||||
|
||||
@app.delete("/api/ssh/key")
|
||||
def ssh_key_delete():
|
||||
keys.delete_key()
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
bridge.log.add("info", "SSH-Schlüssel gelöscht")
|
||||
return {"key": None}
|
||||
|
||||
@app.post("/api/ssh/test")
|
||||
def ssh_test():
|
||||
s = settings.get()
|
||||
reader = CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user)
|
||||
try:
|
||||
result = reader.test()
|
||||
except HostKeyMismatch as e:
|
||||
return JSONResponse({"ok": False, "error": str(e), "host_key": {"fingerprint": fingerprint(e.key)}},
|
||||
status_code=200)
|
||||
except SSHError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
hk = result["host_key"]
|
||||
if not result["host_key_pinned"]:
|
||||
tested_host_keys.clear()
|
||||
tested_host_keys[hk["fingerprint"]] = hk
|
||||
slots = tagcodec.parse_record(result["record"], s.id_offset)
|
||||
return {"ok": True, "host_key": {"fingerprint": hk["fingerprint"], "type": hk["type"]},
|
||||
"host_key_pinned": result["host_key_pinned"], "slots": slots}
|
||||
|
||||
@app.post("/api/ssh/hostkey")
|
||||
async def ssh_hostkey(request: Request):
|
||||
body = await _json(request)
|
||||
hk = tested_host_keys.get(body.get("fingerprint"))
|
||||
if not hk:
|
||||
raise HTTPException(400, "Bitte zuerst „Verbindung testen“ und dann diesen Fingerprint bestätigen")
|
||||
key = paramiko.PKey.from_type_string(hk["type"], base64.b64decode(hk["key"]))
|
||||
keys.pin_host_key(settings.get().printer_host, key)
|
||||
tested_host_keys.clear()
|
||||
bridge.log.add("info", f"Host-Key des Druckers bestätigt ({hk['fingerprint']})")
|
||||
bridge.wake.set()
|
||||
return {"host_key": keys.host_key()}
|
||||
|
||||
@app.delete("/api/ssh/hostkey")
|
||||
def ssh_hostkey_delete():
|
||||
keys.forget_host_key()
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
bridge.log.add("info", "Gespeicherter Host-Key des Druckers gelöscht")
|
||||
return {"host_key": None}
|
||||
|
||||
# ---------- tag generator ----------
|
||||
|
||||
@app.get("/api/materials")
|
||||
def get_materials():
|
||||
return {
|
||||
"materials": {k: {"temps": v["temps"], "code": f"{v['code']:08X}"} for k, v in materials.MATERIALS.items()},
|
||||
"subtypes": [{"code": c, "name": n, "hidden": h, "material": materials.material_of_subtype(c)}
|
||||
for c, n, h in materials.SUBTYPES],
|
||||
"palette": tagcodec.PALETTE,
|
||||
}
|
||||
|
||||
@app.get("/api/spoolman/spools")
|
||||
def spoolman_spools(archived: bool = False):
|
||||
s = settings.get()
|
||||
client = bridge.spoolman_factory(s)
|
||||
try:
|
||||
spools = client.spools(archived=archived)
|
||||
except SpoolmanError as e:
|
||||
raise HTTPException(502, str(e)) from None
|
||||
finally:
|
||||
client.close()
|
||||
out = []
|
||||
for sp in spools:
|
||||
info = summary(sp)
|
||||
sug = materials.suggest(info["material"], info["name"])
|
||||
info["suggest"] = {
|
||||
"material": sug["material"], "subtype": sug["subtype"],
|
||||
"temp_min": sug["temps"][0], "temp_max": sug["temps"][1],
|
||||
"color": tagcodec.nearest_palette_color(info["color"]),
|
||||
}
|
||||
info["spoolman_url"] = f"{s.spoolman_url}/spool/show/{info['id']}"
|
||||
out.append(info)
|
||||
out.sort(key=lambda x: x["id"])
|
||||
return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset}
|
||||
|
||||
@app.post("/api/tag")
|
||||
async def tag(request: Request):
|
||||
body = await _json(request)
|
||||
s = settings.get()
|
||||
try:
|
||||
data = tagcodec.TagData(
|
||||
spool_id=int(body["spool_id"]),
|
||||
color=str(body["color"]),
|
||||
material=str(body["material"]),
|
||||
subtype=int(body["subtype"]),
|
||||
temp_min=int(body["temp_min"]),
|
||||
temp_max=int(body["temp_max"]),
|
||||
diameter=float(body.get("diameter") or 1.75),
|
||||
finish=int(body.get("finish", 0xFF)),
|
||||
id_offset=s.id_offset,
|
||||
url=str(body.get("url") or tagcodec.DEFAULT_URL),
|
||||
)
|
||||
commands = tagcodec.encode_commands(data)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
raise HTTPException(400, str(e) if isinstance(e, tagcodec.TagError) else f"Ungültige Eingabe ({e})") from None
|
||||
decoded = tagcodec.decode_pages(tagcodec.parse_commands(commands), s.id_offset)
|
||||
qr = segno.make(commands, error="l").svg_inline(scale=3, border=2, dark="#000", light="#fff")
|
||||
return {"commands": commands, "decoded": decoded, "qr_svg": qr}
|
||||
|
||||
@app.post("/api/tag/decode")
|
||||
async def tag_decode(request: Request):
|
||||
body = await _json(request)
|
||||
try:
|
||||
pages = tagcodec.parse_commands(str(body.get("commands", "")))
|
||||
except tagcodec.TagError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
return tagcodec.decode_pages(pages, settings.get().id_offset)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _json(request: Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Ungültiges JSON") from None
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Ungültiges JSON")
|
||||
return body
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logging.getLogger("paramiko").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
uvicorn.run(create_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "5066")),
|
||||
log_level="warning", proxy_headers=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user