Multiple printers, printer/slot in Spoolman, kept in sync
CI / test (push) Successful in 1m6s
CI / image (push) Successful in 43s

- One worker per printer (name, IP, CC2 Control URL, own pinned host key);
  a spool sits in at most one slot of one printer, moves between printers
- Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot"
  (created by the bridge), cleared and "Lager" on removal
- Full comparison every minute: the printer wins over manual edits of loaded
  spools; stale printer locations go to storage; own locations untouched
- Automatic migration of v1 settings, state and host key
- Status hint when a tag is still in a tray but unmapped (e.g. after
  "Take out" in CC2 Control)
- SSH test: connection drops during exec give a clean error, not a 500
- Mock environment with several printers; tests for all of the above

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:53:01 +01:00
1 parent bf32667f77
commit a9041eb538
15 files changed
+1400 -684

No files matched your search

+568 -373
View File
File diff suppressed because it is too large. Load diff
+30 -2
View File
@@ -2,11 +2,21 @@
from __future__ import annotations
import json
import re
import httpx
# Extra fields the bridge keeps on loaded spools (Spoolman stores values JSON-encoded).
FIELD_PRINTER = "drucker"
FIELD_SLOT = "slot"
FIELD_DEFS = {
FIELD_PRINTER: {"name": "Drucker", "field_type": "text", "order": 100},
FIELD_SLOT: {"name": "Slot", "field_type": "integer", "order": 101},
}
class SpoolmanError(RuntimeError):
pass
@@ -42,8 +52,14 @@ class Spoolman:
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 update_spool(self, spool_id: int, body: dict) -> dict:
return self._request("PATCH", f"/spool/{int(spool_id)}", json=body)
def fields(self) -> list[dict]:
return self._request("GET", "/field/spool")
def add_field(self, key: str, params: dict) -> list[dict]:
return self._request("POST", f"/field/spool/{key}", json=params)
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)})
@@ -73,6 +89,16 @@ def color_hex(spool: dict) -> str | None:
return color if re.fullmatch(r"[0-9A-F]{6}", color) else None
def extra_value(spool: dict, key: str):
raw = (spool.get("extra") or {}).get(key)
if raw is None:
return None
try:
return json.loads(raw)
except (TypeError, ValueError):
return None
def summary(spool: dict) -> dict:
fil = spool.get("filament") or {}
return {
@@ -91,4 +117,6 @@ def summary(spool: dict) -> dict:
"location": spool.get("location") or "",
"archived": bool(spool.get("archived")),
"used_weight": spool.get("used_weight"),
"printer": extra_value(spool, FIELD_PRINTER),
"slot": extra_value(spool, FIELD_SLOT),
}
+22 -11
View File
@@ -108,25 +108,36 @@ class KeyStore:
"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 _host_path(self, pid: str) -> Path:
if not pid.replace("-", "").replace("_", "").isalnum():
raise ValueError("invalid printer id")
return self.dir / f"host_key_{pid}.json"
def pin_host_key(self, host: str, key: paramiko.PKey) -> None:
def migrate_host_key(self, pid: str) -> None:
"""v1 kept one host key in host_key.json; it belongs to the first printer."""
if self.host_path.exists() and not self._host_path(pid).exists():
os.replace(self.host_path, self._host_path(pid))
def host_key(self, pid: str) -> dict | None:
return read_json(self._host_path(pid), None)
def pin_host_key(self, pid: str, host: str, key: paramiko.PKey) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
atomic_write_json(self.host_path, {
atomic_write_json(self._host_path(pid), {
"host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
}, mode=0o600)
def forget_host_key(self) -> None:
def forget_host_key(self, pid: str) -> None:
try:
self.host_path.unlink()
self._host_path(pid).unlink()
except FileNotFoundError:
pass
class CanvasRecordReader:
def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
def __init__(self, keys: KeyStore, pid: str, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
self.keys = keys
self.pid = pid
self.host, self.port, self.user, self.timeout = host, port, user, timeout
self._transport: paramiko.Transport | None = None
self._lock = threading.Lock()
@@ -155,7 +166,7 @@ class CanvasRecordReader:
try:
t.start_client(timeout=self.timeout)
server_key = t.get_remote_server_key()
pinned = self.keys.host_key()
pinned = self.keys.host_key(self.pid)
if pinned is None:
if not accept_unknown:
raise HostKeyUnknown(server_key)
@@ -222,11 +233,11 @@ class CanvasRecordReader:
try:
server_key = self._connect(accept_unknown=True)
raw = self._exec()
except HostKeyMismatch:
raise
except (paramiko.SSHException, OSError, EOFError) as e:
raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
finally:
self._close()
pinned = self.keys.host_key()
pinned = self.keys.host_key(self.pid)
return {
"host_key": {"type": server_key.get_name(), "key": server_key.get_base64(),
"fingerprint": fingerprint(server_key)},
+172 -67
View File
@@ -4,11 +4,15 @@ from __future__ import annotations
import json
import os
import re
import secrets
import tempfile
import threading
from dataclasses import asdict, dataclass, fields
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
V1_LOCATION_SLOT = "CC2 Slot {n}"
def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -47,64 +51,6 @@ def read_json(path: Path, default):
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:
@@ -121,6 +67,138 @@ def _coerce(typ, value, name):
raise ValueError(f"Ungültiger Wert für {name}") from None
def new_printer_id() -> str:
return "p" + secrets.token_hex(3)
@dataclass
class PrinterConfig:
id: str = ""
name: str = "CC2"
host: str = "10.10.2.202"
ssh_port: int = 22
ssh_user: str = "root"
cc2_url: str = "http://10.10.2.202:8081"
enabled: bool = True
@classmethod
def from_dict(cls, d: dict) -> "PrinterConfig":
p = cls()
for f in fields(p):
if f.name in d and d[f.name] is not None:
setattr(p, f.name, _coerce(f.type, d[f.name], f.name))
if not p.id:
p.id = new_printer_id()
p.validate()
return p
def validate(self) -> None:
if not re.fullmatch(r"[a-z0-9_-]{1,32}", self.id):
raise ValueError("Ungültige Drucker-ID")
if not self.name.strip():
raise ValueError("Druckername fehlt")
if len(self.name) > 60:
raise ValueError("Druckername ist zu lang (max. 60 Zeichen)")
if not self.host.strip():
raise ValueError(f"{self.name}: Drucker-IP fehlt")
if not 1 <= self.ssh_port <= 65535:
raise ValueError(f"{self.name}: SSH-Port ungültig")
if not self.cc2_url.startswith(("http://", "https://")):
raise ValueError(f"{self.name}: CC2-Control-URL muss mit http:// beginnen")
self.cc2_url = self.cc2_url.rstrip("/")
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class Settings:
spoolman_url: str = "http://10.10.6.10:7912"
id_offset: int = 10000
poll_interval: float = 5.0
location_slot: str = "{printer} Slot {n}"
location_storage: str = "Lager"
spoolman_fields: bool = True # also write the extra fields "drucker" and "slot"
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking
dry_run: bool = True
printers: list[PrinterConfig] = field(default_factory=list)
# environment variable -> field (only used as defaults before settings.json exists)
ENV = {
"SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset", "POLL_INTERVAL": "poll_interval",
"DRY_RUN": "dry_run",
}
PRINTER_ENV = {
"PRINTER_NAME": "name", "PRINTER_HOST": "host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
"CC2_URL": "cc2_url",
}
@classmethod
def from_dict(cls, d: dict) -> "Settings":
s = cls()
s.update(migrate_settings(d))
return s
def update(self, d: dict) -> None:
for f in fields(self):
if f.name == "printers" or f.name not in d or d[f.name] is None:
continue
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
if d.get("printers") is not None:
self.printers = [p if isinstance(p, PrinterConfig) else PrinterConfig.from_dict(p) for p in d["printers"]]
self.validate()
def validate(self) -> None:
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")
if len(self.printers) > 1 and "{printer}" not in self.location_slot:
raise ValueError("Bei mehreren Druckern muss der Ortsname {printer} enthalten")
if not self.spoolman_url.startswith(("http://", "https://")):
raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen")
self.spoolman_url = self.spoolman_url.rstrip("/")
if not self.location_storage.strip():
raise ValueError("Ortsname für das Lager fehlt")
ids = [p.id for p in self.printers]
names = [p.name.strip().lower() for p in self.printers]
if len(set(ids)) != len(ids) or len(set(names)) != len(names):
raise ValueError("Druckernamen müssen eindeutig sein")
if len(self.printers) > 8:
raise ValueError("Höchstens 8 Drucker")
def to_dict(self) -> dict:
d = asdict(self)
d["printers"] = [p.to_dict() for p in self.printers]
return d
def printer(self, pid: str) -> PrinterConfig | None:
return next((p for p in self.printers if p.id == pid), None)
def slot_location(self, printer: PrinterConfig | str, slot: int) -> str:
name = printer.name if isinstance(printer, PrinterConfig) else printer
return self.location_slot.replace("{printer}", name).replace("{n}", str(slot + 1))
def migrate_settings(d: dict) -> dict:
"""v1 had one printer at the top level; v2 keeps a list of printers."""
d = dict(d)
if "printers" not in d and "printer_host" in d:
d["printers"] = [{
"id": "p1", "name": d.get("printer_name") or "CC2", "host": d.get("printer_host"),
"ssh_port": d.get("ssh_port", 22), "ssh_user": d.get("ssh_user", "root"),
"cc2_url": d.get("cc2_url"), "enabled": True,
}]
if d.get("location_slot") == V1_LOCATION_SLOT:
d["location_slot"] = Settings.location_slot
for key in ("printer_host", "printer_name", "ssh_port", "ssh_user", "cc2_url"):
d.pop(key, None)
return d
class SettingsStore:
def __init__(self, data_dir: Path, env: dict | None = None):
self.path = data_dir / "settings.json"
@@ -129,9 +207,13 @@ class SettingsStore:
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)}
printer = {f: env[k] for k, f in Settings.PRINTER_ENV.items() if env.get(k)}
defaults["printers"] = [{"id": "p1", **printer}]
self.settings = Settings.from_dict(defaults)
else:
self.settings = Settings.from_dict(stored)
if "printer_host" in stored: # write the migrated form once
atomic_write_json(self.path, self.settings.to_dict())
def get(self) -> Settings:
with self._lock:
@@ -146,16 +228,39 @@ class SettingsStore:
class StateStore:
"""Bridge state: slot mappings, booked consumption, last record."""
"""Bridge state: per printer slot mappings, booked consumption and last record; Spoolman locations."""
def __init__(self, data_dir: Path):
def __init__(self, data_dir: Path, first_printer: str = "p1"):
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)
self.data = migrate_state(read_json(self.path, {}), first_printer)
def printer(self, pid: str) -> dict:
p = self.data["printers"].setdefault(pid, {})
p.setdefault("slots", {})
p.setdefault("booked", {})
p.setdefault("last_record", None)
p.setdefault("scans", [])
return p
def save(self) -> None:
atomic_write_json(self.path, self.data)
def migrate_state(data: dict, first_printer: str) -> dict:
data.setdefault("printers", {})
if data.get("version", 1) < 2:
if any(k in data for k in ("slots", "booked", "last_record", "scans")):
data["printers"][first_printer] = {
"slots": data.pop("slots", {}), "booked": data.pop("booked", {}),
"last_record": data.pop("last_record", None), "scans": data.pop("scans", []),
}
applied = {}
for sid, loc in (data.pop("locations", None) or {}).items():
if loc.get("done"):
applied[sid] = {"location": loc["done"], "printer": None, "slot": None}
data["applied"] = applied
data["written_locations"] = sorted({a["location"] for a in applied.values()})
data["version"] = 2
data.setdefault("applied", {})
data.setdefault("written_locations", [])
return data
+94 -55
View File
@@ -1,38 +1,44 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Einstellungen{% endblock %}
{% block content %}
<h2>Einstellungen</h2>
<form id="settings" class="panel" onsubmit="saveSettings(event)">
<h2>Drucker</h2>
<div id="printers"></div>
<div class="row" style="margin-bottom:8px">
<button onclick="addPrinter()">Drucker hinzufügen</button>
<button class="primary" onclick="saveSettings()">Speichern</button>
<span id="saved1" class="small muted"></span>
</div>
<p class="small muted">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p>
<h2>Allgemein</h2>
<form id="settings" class="panel" onsubmit="event.preventDefault(); saveSettings()">
<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 class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></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_slot">Ort im Slot (<code>{printer}</code> = Druckername, <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>
<label style="color:var(--text)"><input type="checkbox" id="spoolman_fields" style="width:auto"> Zusatzfelder „Drucker“ und „Slot“ in Spoolman pflegen</label>
</div>
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved" class="small muted"></span></div>
<div class="field">
<label style="color:var(--text)"><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="saved2" class="small muted"></span></div>
</form>
<h2>SSH-Schlüssel für den Drucker</h2>
<h2>SSH-Schlüssel für die 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>
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen – gilt für alle Drucker</label>
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
</div>
<div class="row">
@@ -42,39 +48,75 @@
</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 = {};
const FIELDS = ['spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
const PFIELDS = [['name', 'Name'], ['host', 'Drucker-IP'], ['ssh_port', 'SSH-Port'], ['ssh_user', 'SSH-Benutzer'], ['cc2_url', 'CC2-Control-URL']];
let current = {}, printers = [], hostKeys = {}, saved = new Set();
function printerCard(p, i) {
const hk = hostKeys[p.id];
const isSaved = saved.has(p.id);
return `<div class="panel" style="margin-bottom:12px" id="pc${i}">
<div class="row">${PFIELDS.map(([f, l]) => `<div class="field"><label>${l}</label>
<input data-i="${i}" data-f="${f}" value="${esc(p[f])}" ${f === 'ssh_port' ? 'type="number" min="1" max="65535"' : ''}></div>`).join('')}</div>
<div class="row" style="align-items:center">
<label style="margin:0;color:var(--text);flex:0 0 auto"><input type="checkbox" data-i="${i}" data-f="enabled" ${p.enabled ? 'checked' : ''} style="width:auto"> Abfrage aktiv</label>
<span class="small" style="flex:1 1 200px">${isSaved ? (hk ? `Host-Key <code>${esc(hk.fingerprint)}</code>` : '<span class="tag warn">Host-Key nicht bestätigt</span>') : '<span class="tag">noch nicht gespeichert</span>'}</span>
${isSaved ? `<button class="primary" onclick="testSSH(${i})">Verbindung testen</button>
<button onclick="forgetHost(${i})">Host-Key vergessen</button>` : ''}
<button class="danger" onclick="removePrinter(${i})">Entfernen</button>
</div>
<div id="test${i}" style="margin-top:10px"></div>
</div>`;
}
function renderPrinters() {
$('printers').innerHTML = printers.map(printerCard).join('') || '<div class="panel muted">Kein Drucker eingerichtet.</div>';
document.querySelectorAll('#printers input[data-f]').forEach((el) => el.addEventListener('input', () => {
const p = printers[el.dataset.i];
p[el.dataset.f] = el.type === 'checkbox' ? el.checked : el.value;
}));
}
function addPrinter() {
printers.push({ id: '', name: '', host: '', ssh_port: 22, ssh_user: 'root', cc2_url: 'http://', enabled: true });
renderPrinters();
}
function removePrinter(i) {
const p = printers[i];
if (!confirm(`Drucker „${p.name || 'neu'}“ entfernen?\n\nSeine Spulen werden nach dem Speichern in Spoolman ins Lager gebucht.`)) return;
printers.splice(i, 1);
renderPrinters();
}
async function loadSettings() {
current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f];
$('dry_run').checked = current.dry_run;
$('spoolman_fields').checked = current.spoolman_fields;
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
setMode(current.dry_run);
}
async function saveSettings(ev) {
ev.preventDefault();
const body = {};
async function saveSettings() {
const body = { printers };
for (const f of FIELDS) body[f] = $(f).value;
body.dry_run = $('dry_run').checked;
body.spoolman_fields = $('spoolman_fields').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;
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, pflegt Ort und Zusatzfelder in Spoolman und bucht Verbrauch.')) return;
try {
current = await api('POST', '/api/settings', body);
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
setMode(current.dry_run);
$('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
const t = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
$('saved1').textContent = t; $('saved2').textContent = t;
await loadSSH();
} catch (e) { toast(e.message); }
}
@@ -82,21 +124,19 @@ 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>
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf jedem 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');
hostKeys = info.host_keys || {};
renderKey(info);
renderPrinters();
}
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();
@@ -117,19 +157,18 @@ async function deleteKey() {
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 forgetHost(i) {
const p = printers[i];
if (!confirm(`Host-Key von ${p.name} vergessen? Danach muss er beim nächsten Test neu bestätigt werden.`)) return;
await api('DELETE', `/api/printers/${p.id}/ssh/hostkey`); loadSSH();
}
async function testSSH() {
$('testresult').innerHTML = '<span class="muted">Verbinde …</span>';
async function testSSH(i) {
const p = printers[i], out = $('test' + i);
out.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;
}
try { r = await api('POST', `/api/printers/${p.id}/ssh/test`); } catch (e) { out.innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
if (!r.ok) { out.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, '');
@@ -138,16 +177,16 @@ async function testSSH() {
}).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>`;
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}). Vergleichen und bestätigen.
<button class="primary" onclick="acceptHost(${i}, '${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>`;
out.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(); }
async function acceptHost(i, fp) {
const p = printers[i];
if (!confirm(`Host-Key ${fp} für ${p.name} als vertrauenswürdig speichern?`)) return;
try { await api('POST', `/api/printers/${p.id}/ssh/hostkey`, { fingerprint: fp }); await loadSSH(); testSSH(i); }
catch (e) { toast(e.message); }
}
+30 -17
View File
@@ -2,8 +2,7 @@
{% 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>
<div id="printers"><div class="panel muted">Lade …</div></div>
<p class="small muted" id="lastpoll"></p>
<h2>Protokoll</h2>
<div class="panel table-wrap">
@@ -21,7 +20,7 @@ function colorOf(ch) {
return /^[0-9a-f]{6}$/i.test(c) ? c : null;
}
function slotCard(s) {
function slotCard(p, s) {
const ch = s.record.channel || {};
const tray = TRAY[s.tray_status];
const col = colorOf(ch);
@@ -37,9 +36,11 @@ function slotCard(s) {
: (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 orphan = !m && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2);
const lb = s.last_booking;
return `<div class="panel">
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
<p class="small muted" style="margin:-4px 0 8px">Ort in Spoolman: ${esc(s.location)}</p>
<dl class="kv">
<dt>Tag</dt><dd>${tag}</dd>
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd>
@@ -48,25 +49,30 @@ function slotCard(s) {
<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>
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steckt noch, ist aber keinem Slot zugeordnet
(z. B. nach „Take out“ in CC2 Control). Verbrauch wird so nicht gezählt – zum Zuordnen die Spule neu einlegen und scannen.</p>` : ''}
${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>` : ''}
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Zuordnung erneut senden</button>
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}
</div>`;
}
async function slotAction(slot, action) {
let NAMES = {};
async function slotAction(pid, slot, action) {
const name = NAMES[pid] || pid;
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?`;
? `${name}: Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
: `${name}: 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); }
try { await api('POST', `/api/printers/${pid}/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;
async function enableCC2(pid) {
const name = NAMES[pid] || pid;
if (!confirm(`Spulenverwaltung in CC2 Control von ${name} einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.`)) return;
try {
const r = await api('POST', '/api/cc2/enable', { confirm: true });
const r = await api('POST', `/api/printers/${pid}/cc2/enable`, { confirm: true });
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
setTimeout(load, 1500);
} catch (e) { toast(e.message); }
@@ -79,12 +85,19 @@ async function load() {
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>`);
if (st.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</div>`);
$('banners').innerHTML = b.join('');
if (st.slots) $('slots').innerHTML = st.slots.map(slotCard).join('');
NAMES = Object.fromEntries((st.printers || []).map((p) => [p.id, p.name]));
if (st.printers) $('printers').innerHTML = st.printers.map((p) => {
const pb = [];
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>');
else if (st.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`);
if (p.job_active) pb.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`);
return `<h2>${esc(p.name)} <span class="small muted" style="font-weight:400">${esc(p.host)}</span></h2>${pb.join('')}
<div class="grid">${p.slots.map((s) => slotCard(p, s)).join('')}</div>`;
}).join('') || '<div class="panel muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>';
$('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, ''];
+48 -33
View File
@@ -36,7 +36,7 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
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
tested_host_keys: dict[str, dict] = {} # printer id -> host key from its last connection test
@asynccontextmanager
async def lifespan(app):
@@ -105,20 +105,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
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):
def printer_or_404(pid: str):
p = settings.get().printer(pid)
if p is None:
raise HTTPException(404, "Unbekannter Drucker")
return p
def close_readers():
for w in list(bridge.workers.values()):
w.reader.close()
@app.post("/api/printers/{pid}/cc2/enable")
async def cc2_enable(pid: str, request: Request):
printer_or_404(pid)
body = await _json(request)
if body.get("confirm") is not True:
raise HTTPException(400, "Bestätigung fehlt")
bridge.commands.put(("enable", None))
bridge.commands.put(("enable", pid, 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):
@app.post("/api/printers/{pid}/slots/{slot}/{action}")
def slot_action(pid: str, slot: int, action: str):
printer_or_404(pid)
if slot not in range(4) or action not in ("reassign", "release"):
raise HTTPException(404)
bridge.commands.put((action, slot))
bridge.commands.put((action, pid, slot))
bridge.wake.set()
return {"ok": True}
@@ -131,11 +143,14 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
@app.post("/api/settings")
async def post_settings(request: Request):
body = await _json(request)
before = {p.id for p in settings.get().printers}
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
for pid in before - {p.id for p in s.printers}:
keys.forget_host_key(pid)
bridge.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
bridge.wake.set()
return s.to_dict()
@@ -144,7 +159,8 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
@app.get("/api/ssh")
def ssh_info():
return {"key": keys.key_info(), "host_key": keys.host_key()}
return {"key": keys.key_info(),
"host_keys": {p.id: keys.host_key(p.id) for p in settings.get().printers}}
@app.post("/api/ssh/key")
async def ssh_key(request: Request):
@@ -157,22 +173,20 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
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()
close_readers()
return {"key": keys.key_info()}
@app.delete("/api/ssh/key")
def ssh_key_delete():
keys.delete_key()
if bridge.reader:
bridge.reader.close()
close_readers()
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)
@app.post("/api/printers/{pid}/ssh/test")
def ssh_test(pid: str):
p = printer_or_404(pid)
reader = CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user)
try:
result = reader.test()
except HostKeyMismatch as e:
@@ -182,31 +196,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
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)
tested_host_keys.pop(pid, None)
tested_host_keys[pid] = hk
slots = tagcodec.parse_record(result["record"], settings.get().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):
@app.post("/api/printers/{pid}/ssh/hostkey")
async def ssh_hostkey(pid: str, request: Request):
p = printer_or_404(pid)
body = await _json(request)
hk = tested_host_keys.get(body.get("fingerprint"))
if not hk:
hk = tested_host_keys.get(pid)
if not hk or hk["fingerprint"] != body.get("fingerprint"):
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']})")
keys.pin_host_key(pid, p.host, key)
tested_host_keys.pop(pid, None)
bridge.log.add("info", f"{p.name}: Host-Key bestätigt ({hk['fingerprint']})")
bridge.wake.set()
return {"host_key": keys.host_key()}
return {"host_key": keys.host_key(pid)}
@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")
@app.delete("/api/printers/{pid}/ssh/hostkey")
def ssh_hostkey_delete(pid: str):
p = printer_or_404(pid)
keys.forget_host_key(pid)
close_readers()
bridge.log.add("info", f"{p.name}: gespeicherter Host-Key gelöscht")
return {"host_key": None}
# ---------- tag generator ----------