CC2 ↔ Spoolman bridge with NFC tag generator
CI / test (push) Successful in 1m4s
CI / image (push) Successful in 1m1s

- 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:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 03:01:17 +01:00
commit bf32667f77
32 files changed
+4216

No files matched your search

View File
Whitespace-only changes.
+614
View File
@@ -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,
}
+156
View File
@@ -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
+136
View File
@@ -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"]}
+94
View File
@@ -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"),
}
+245
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+131
View File
@@ -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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
+156
View File
@@ -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 %}
+97
View File
@@ -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 %}
+171
View File
@@ -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
View File
@@ -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()