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

+2
View File
@@ -1,4 +1,6 @@
# Startwerte. Nach dem ersten Start gelten die Einstellungen aus der Oberfläche (/data/settings.json). # Startwerte. Nach dem ersten Start gelten die Einstellungen aus der Oberfläche (/data/settings.json).
# Erster Drucker; weitere in der Oberfläche unter Einstellungen → Drucker.
PRINTER_NAME=Centauri Carbon 2 Rechts
PRINTER_HOST=10.10.2.202 PRINTER_HOST=10.10.2.202
SSH_PORT=22 SSH_PORT=22
SSH_USER=root SSH_USER=root
+38 -17
View File
@@ -1,11 +1,12 @@
# cc2-spoolman-bridge # cc2-spoolman-bridge
Bridge zwischen der Canvas des **Elegoo Centauri Carbon 2** (Community-Firmware, CC2 Control) und **Spoolman**, Bridge zwischen der Canvas eines oder mehrerer **Elegoo Centauri Carbon 2** (Community-Firmware, CC2 Control) und
mit Weboberfläche und **NFC-Tag-Generator**. **Spoolman**, mit Weboberfläche und **NFC-Tag-Generator**.
- Liegt eine Spule mit eigenem Tag in der Canvas, erkennt die Bridge die Spoolman-Spule am Tag, legt sie in - Liegt eine Spule mit eigenem Tag in einer Canvas, erkennt die Bridge die Spoolman-Spule am Tag, legt sie in der
CC2 Control an (bzw. findet sie wieder), weist sie dem Slot zu und setzt in Spoolman den Ort („CC2 Slot 1–4“, CC2 Control dieses Druckers an (bzw. findet sie wieder) und weist sie dem Slot zu.
beim Entnehmen „Lager“). - In Spoolman steht dann, wo die Rolle liegt: Ort „<Drucker> Slot N“ (z. B. „Centauri Carbon 2 Links Slot 3“) und
die Zusatzfelder **Drucker** und **Slot**. Entnommene Rollen kommen ins „Lager“, die Felder werden geleert.
- Den von CC2 Control gemessenen Verbrauch bucht sie nach jedem Druck auf genau diese Spoolman-Spule. - Den von CC2 Control gemessenen Verbrauch bucht sie nach jedem Druck auf genau diese Spoolman-Spule.
- Der Tag-Generator gibt für jede Spoolman-Spule die vollständige Befehlskette zum Beschreiben eines leeren NTAG213 aus. - Der Tag-Generator gibt für jede Spoolman-Spule die vollständige Befehlskette zum Beschreiben eines leeren NTAG213 aus.
@@ -56,17 +57,23 @@ Oberfläche: `http://10.10.6.10:5066`
### 3. In der Oberfläche ### 3. In der Oberfläche
1. *Einstellungen* prüfen (Drucker-IP, CC2-Control- und Spoolman-URL, ID-Offset 10000). 1. *Einstellungen* → **Drucker**: Name, IP, SSH-Port/-Benutzer und CC2-Control-URL je Drucker; „Drucker
2. Privaten Schlüssel einfügen oder hochladen und speichern. Er liegt dann mit Rechten 600 in hinzufügen“ für weitere. Speichern. Der Name erscheint so in Spoolman (Ort und Feld „Drucker“).
`/data/ssh/id_key`; die Oberfläche zeigt nur Fingerprint und öffentlichen Schlüssel. 2. Privaten Schlüssel einfügen oder hochladen und speichern. Er gilt für alle Drucker (dieselbe eingeschränkte
3. **Verbindung testen**: zeigt die 4 Slots und den Host-Key-Fingerprint des Druckers. Fingerprint vergleichen und `authorized_keys`-Zeile auf jedem Drucker) und liegt mit Rechten 600 in `/data/ssh/id_key`; die Oberfläche zeigt
bestätigen. Danach wird er bei jeder Verbindung geprüft; ein geänderter Host-Key wird abgelehnt. nur Fingerprint und öffentlichen Schlüssel.
3. Je Drucker **Verbindung testen**: zeigt die 4 Slots und den Host-Key-Fingerprint. Vergleichen und bestätigen.
Danach wird er bei jeder Verbindung geprüft; ein geänderter Host-Key wird abgelehnt.
4. *Status* im Dry-Run beobachten. Wenn die „würde …“-Einträge stimmen, Dry-Run ausschalten. 4. *Status* im Dry-Run beobachten. Wenn die „würde …“-Einträge stimmen, Dry-Run ausschalten.
Ein Update von v1 übernimmt Einstellungen, Zuordnungen, Buchungsstände und den Host-Key automatisch; der bisherige
Drucker heißt danach „CC2“ (Ort bleibt „CC2 Slot N“) und kann in den Einstellungen umbenannt werden – Ort und
Felder in Spoolman ziehen dann mit.
### Umgebungsvariablen ### Umgebungsvariablen
Siehe [`.env.example`](.env.example). `PRINTER_HOST`, `SSH_PORT`, `SSH_USER`, `CC2_URL`, `SPOOLMAN_URL`, Siehe [`.env.example`](.env.example). `PRINTER_HOST`, `SSH_PORT`, `SSH_USER`, `CC2_URL`, `SPOOLMAN_URL`,
`ID_OFFSET`, `POLL_INTERVAL` und `DRY_RUN` sind nur Startwerte, bis die Einstellungen einmal in der Oberfläche `ID_OFFSET`, `POLL_INTERVAL`, `DRY_RUN` und `PRINTER_NAME` (erster Drucker) sind nur Startwerte, bis die Einstellungen einmal in der Oberfläche
gespeichert wurden (`/data/settings.json`). `BRIDGE_USER` und `BRIDGE_PASSWORD` schalten Basic-Auth ein gespeichert wurden (`/data/settings.json`). `BRIDGE_USER` und `BRIDGE_PASSWORD` schalten Basic-Auth ein
(`/healthz` bleibt offen). `PORT` (Standard 5066), `LOG_LEVEL`. (`/healthz` bleibt offen). `PORT` (Standard 5066), `LOG_LEVEL`.
@@ -77,15 +84,27 @@ gespeichert wurden (`/data/settings.json`). `BRIDGE_USER` und `BRIDGE_PASSWORD`
kleinere Werte sind echte Gewichte von Original- oder Fremdtags und werden ignoriert. Neu ist ein Tag, wenn sich die ID kleinere Werte sind echte Gewichte von Original- oder Fremdtags und werden ignoriert. Neu ist ein Tag, wenn sich die ID
im Slot gegenüber dem letzten Datensatz ändert. im Slot gegenüber dem letzten Datensatz ändert.
**Zuordnung.** Die Bridge speichert Slot ↔ Spule selbst (`/data/state.json`). Fällt `weight` nach einer **Zuordnung.** Die Bridge speichert Drucker/Slot ↔ Spule selbst (`/data/state.json`). Eine Spule liegt höchstens
Bearbeitung am Display auf 1000 zurück, bleibt die Zuordnung. Aufgehoben wird sie nur, wenn in einem Slot eines Druckers. Fällt `weight` nach einer Bearbeitung am Display auf 1000 zurück, bleibt die
Zuordnung. Aufgehoben wird sie nur, wenn
- der Slot länger als 20 s leer ist (Tray-Status 0), - der Slot länger als 20 s leer ist (Tray-Status 0),
- CC2 Control die Spule ins Lager legt (Slot ohne Spule und ohne offene Rückfrage), - CC2 Control die Spule herausnimmt („Take out“) oder ins Lager legt (Slot ohne Spule und ohne offene Rückfrage),
- im Slot ein Tag mit einer anderen ID gescannt wird oder - im Slot ein Tag mit einer anderen ID gescannt wird oder
- die Spule in einem anderen Slot gescannt wird (Umzug). - die Spule in einem anderen Slot oder im anderen Drucker gescannt wird (Umzug).
Weist du eine gespiegelte Spule in CC2 Control von Hand einem belegten Slot zu, übernimmt die Bridge das. Weist du eine gespiegelte Spule in CC2 Control von Hand einem belegten Slot zu, übernimmt die Bridge das.
**„Take out“ in CC2 Control** zählt als Entnahme: Ort „Lager“, Felder leer. Bleibt die Rolle physisch im Tray,
rechnet CC2 Control ihren Verbrauch keiner Spule mehr zu; der Status zeigt dann „Tag steckt noch, ist aber keinem
Slot zugeordnet“. Zum erneuten Zuordnen die Rolle neu einlegen und den Scan bestätigen.
**Spoolman synchron halten.** Nach jeder Änderung schreibt die Bridge Ort und Zusatzfelder (die Felder legt sie beim
ersten Mal selbst an). Zusätzlich vergleicht sie jede Minute alle Spoolman-Spulen: Geladene Rollen bekommen ihren
Ort/ihre Felder zurück, falls sie dort von Hand geändert wurden (der Drucker gewinnt). Rollen mit einem Druckerort
oder gesetztem Feld „Drucker“, die in keinem Slot liegen, kommen ins Lager. Eigene Orte wie „Regal 2“ bei nicht
geladenen Rollen bleiben unangetastet. Wird ein Drucker umbenannt oder entfernt, ziehen Ort und Felder mit bzw. seine
Rollen kommen ins Lager.
**CC2 Control.** Gespiegelte Spulen erkennt die Bridge an `note` = `spoolman:<id>`. Fehlt die Spule, legt sie sie **CC2 Control.** Gespiegelte Spulen erkennt die Bridge an `note` = `spoolman:<id>`. Fehlt die Spule, legt sie sie
mit `name` = Label (`lot_nr`, sonst Hersteller + ID dreistellig), `brand`, `material`, `color`, `diameter`, mit `name` = Label (`lot_nr`, sonst Hersteller + ID dreistellig), `brand`, `material`, `color`, `diameter`,
`density`, `net`, `remaining`, `tare` und `price` aus Spoolman an und weist sie dem Slot zu. Damit ist auch die `density`, `net`, `remaining`, `tare` und `price` aus Spoolman an und weist sie dem Slot zu. Damit ist auch die
@@ -131,11 +150,13 @@ Optional kann statt der Elegoo-URL ein Link auf die Spule in Spoolman auf den Ta
```bash ```bash
docker build --target test . # Tests (pytest) im Container docker build --target test . # Tests (pytest) im Container
docker build -t cc2-spoolman-bridge:dev . # Image docker build -t cc2-spoolman-bridge:dev . # Image
python tools/mock_env.py --host-header mock:8081 # Mock für Drucker (SSH :2222), CC2 Control, Spoolman python tools/mock_env.py --host mock --printers 2 # Mock: 2 Drucker (SSH :2222/2223, CC2 :8081/8082), Spoolman
``` ```
Die Tests decken ab: Tag-Kodierung gegen die verifizierte Vorlage, ID-Dekodierung, Palette und Materialvorschlag, Die Tests decken ab: Tag-Kodierung gegen die verifizierte Vorlage, ID-Dekodierung, Palette und Materialvorschlag,
Zuordnung bei verschwindender ID, Verbrauchs-Delta und Idempotenz (auch nach Abbruch), Dry-Run, Request-Format für Zuordnung bei verschwindender ID, Verbrauchs-Delta und Idempotenz (auch nach Abbruch), Dry-Run, mehrere Drucker
(Umzug, Ausfall eines Druckers, Entfernen, Umbenennen), Spoolman-Ort und Zusatzfelder inkl. Korrektur von Hand-
Änderungen, Migration von v1, Request-Format für
CC2 Control (Host-Header, kein Origin, `X-CC2-Request`, `key=value`-Body, `enable` als nacktes `on`/`off`) und SSH CC2 Control (Host-Header, kein Origin, `X-CC2-Request`, `key=value`-Body, `enable` als nacktes `on`/`off`) und SSH
mit Host-Key-Prüfung gegen einen lokalen SSH-Server. mit Host-Key-Prüfung gegen einen lokalen SSH-Server.
+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 from __future__ import annotations
import json
import re import re
import httpx 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): class SpoolmanError(RuntimeError):
pass pass
@@ -42,8 +52,14 @@ class Spoolman:
def spools(self, archived: bool = False) -> list[dict]: def spools(self, archived: bool = False) -> list[dict]:
return self._request("GET", "/spool", params={"allow_archived": str(archived).lower()}) return self._request("GET", "/spool", params={"allow_archived": str(archived).lower()})
def set_location(self, spool_id: int, location: str) -> dict: def update_spool(self, spool_id: int, body: dict) -> dict:
return self._request("PATCH", f"/spool/{int(spool_id)}", json={"location": location}) 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: 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)}) 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 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: def summary(spool: dict) -> dict:
fil = spool.get("filament") or {} fil = spool.get("filament") or {}
return { return {
@@ -91,4 +117,6 @@ def summary(spool: dict) -> dict:
"location": spool.get("location") or "", "location": spool.get("location") or "",
"archived": bool(spool.get("archived")), "archived": bool(spool.get("archived")),
"used_weight": spool.get("used_weight"), "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", "public": f"{key.get_name()} {key.get_base64()} cc2-spoolman-bridge",
} }
def host_key(self) -> dict | None: def _host_path(self, pid: str) -> Path:
return read_json(self.host_path, None) 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) 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), "host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
}, mode=0o600) }, mode=0o600)
def forget_host_key(self) -> None: def forget_host_key(self, pid: str) -> None:
try: try:
self.host_path.unlink() self._host_path(pid).unlink()
except FileNotFoundError: except FileNotFoundError:
pass pass
class CanvasRecordReader: 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.keys = keys
self.pid = pid
self.host, self.port, self.user, self.timeout = host, port, user, timeout self.host, self.port, self.user, self.timeout = host, port, user, timeout
self._transport: paramiko.Transport | None = None self._transport: paramiko.Transport | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -155,7 +166,7 @@ class CanvasRecordReader:
try: try:
t.start_client(timeout=self.timeout) t.start_client(timeout=self.timeout)
server_key = t.get_remote_server_key() server_key = t.get_remote_server_key()
pinned = self.keys.host_key() pinned = self.keys.host_key(self.pid)
if pinned is None: if pinned is None:
if not accept_unknown: if not accept_unknown:
raise HostKeyUnknown(server_key) raise HostKeyUnknown(server_key)
@@ -222,11 +233,11 @@ class CanvasRecordReader:
try: try:
server_key = self._connect(accept_unknown=True) server_key = self._connect(accept_unknown=True)
raw = self._exec() raw = self._exec()
except HostKeyMismatch: except (paramiko.SSHException, OSError, EOFError) as e:
raise raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
finally: finally:
self._close() self._close()
pinned = self.keys.host_key() pinned = self.keys.host_key(self.pid)
return { return {
"host_key": {"type": server_key.get_name(), "key": server_key.get_base64(), "host_key": {"type": server_key.get_name(), "key": server_key.get_base64(),
"fingerprint": fingerprint(server_key)}, "fingerprint": fingerprint(server_key)},
+172 -67
View File
@@ -4,11 +4,15 @@ from __future__ import annotations
import json import json
import os import os
import re
import secrets
import tempfile import tempfile
import threading import threading
from dataclasses import asdict, dataclass, fields from dataclasses import asdict, dataclass, field, fields
from pathlib import Path from pathlib import Path
V1_LOCATION_SLOT = "CC2 Slot {n}"
def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None: def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -47,64 +51,6 @@ def read_json(path: Path, default):
return 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): def _coerce(typ, value, name):
typ = typ if isinstance(typ, str) else typ.__name__ typ = typ if isinstance(typ, str) else typ.__name__
try: try:
@@ -121,6 +67,138 @@ def _coerce(typ, value, name):
raise ValueError(f"Ungültiger Wert für {name}") from None 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: class SettingsStore:
def __init__(self, data_dir: Path, env: dict | None = None): def __init__(self, data_dir: Path, env: dict | None = None):
self.path = data_dir / "settings.json" self.path = data_dir / "settings.json"
@@ -129,9 +207,13 @@ class SettingsStore:
if stored is None: if stored is None:
env = os.environ if env is None else env env = os.environ if env is None else env
defaults = {f: env[k] for k, f in Settings.ENV.items() if env.get(k)} 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) self.settings = Settings.from_dict(defaults)
else: else:
self.settings = Settings.from_dict(stored) 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: def get(self) -> Settings:
with self._lock: with self._lock:
@@ -146,16 +228,39 @@ class SettingsStore:
class StateStore: 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.path = data_dir / "state.json"
self.data = read_json(self.path, {}) self.data = migrate_state(read_json(self.path, {}), first_printer)
self.data.setdefault("version", 1)
self.data.setdefault("slots", {}) def printer(self, pid: str) -> dict:
self.data.setdefault("booked", {}) p = self.data["printers"].setdefault(pid, {})
self.data.setdefault("locations", {}) p.setdefault("slots", {})
self.data.setdefault("last_record", None) p.setdefault("booked", {})
p.setdefault("last_record", None)
p.setdefault("scans", [])
return p
def save(self) -> None: def save(self) -> None:
atomic_write_json(self.path, self.data) 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" %} {% extends "base.html" %}
{% block title %}CC2 Bridge – Einstellungen{% endblock %} {% block title %}CC2 Bridge – Einstellungen{% endblock %}
{% block content %} {% block content %}
<h2>Einstellungen</h2> <h2>Drucker</h2>
<form id="settings" class="panel" onsubmit="saveSettings(event)"> <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="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="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>
<div class="row"> <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="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="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 class="field"><label for="settle_seconds">Buchen nach Ruhe (s)</label><input id="settle_seconds" type="number" min="0"></div>
</div> </div>
<div class="row"> <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 class="field"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
</div> </div>
<div class="field"> <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>
<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> </form>
<h2>SSH-Schlüssel für den Drucker</h2> <h2>SSH-Schlüssel für die Drucker</h2>
<div class="panel"> <div class="panel">
<div id="keyinfo" class="field muted">Lade …</div> <div id="keyinfo" class="field muted">Lade …</div>
<div class="field"> <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> <textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
</div> </div>
<div class="row"> <div class="row">
@@ -42,39 +48,75 @@
</div> </div>
<p class="small muted">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p> <p class="small muted">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p>
</div> </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 %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
const FIELDS = ['printer_host', 'ssh_port', 'ssh_user', 'cc2_url', 'spoolman_url', 'id_offset', 'poll_interval', const FIELDS = ['spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
'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 = {}; 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() { async function loadSettings() {
current = await api('GET', '/api/settings'); current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f]; for (const f of FIELDS) $(f).value = current[f];
$('dry_run').checked = current.dry_run; $('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); setMode(current.dry_run);
} }
async function saveSettings(ev) { async function saveSettings() {
ev.preventDefault(); const body = { printers };
const body = {};
for (const f of FIELDS) body[f] = $(f).value; for (const f of FIELDS) body[f] = $(f).value;
body.dry_run = $('dry_run').checked; body.dry_run = $('dry_run').checked;
body.spoolman_fields = $('spoolman_fields').checked;
if (current.dry_run && !body.dry_run && !confirm( 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 { try {
current = await api('POST', '/api/settings', body); 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); 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); } } catch (e) { toast(e.message); }
} }
@@ -82,21 +124,19 @@ function renderKey(info) {
const k = info.key; const k = info.key;
if (!k) { $('keyinfo').innerHTML = '<span class="tag warn">Kein Schlüssel hinterlegt</span>'; return; } 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; } 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> $('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> <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>`; <button style="margin-top:6px" onclick="copyText($('authline').textContent)">Kopieren</button>`;
} }
function renderHost(hk) { async function loadSSH() {
$('hostkey').innerHTML = hk const info = await api('GET', '/api/ssh');
? `<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>` hostKeys = info.host_keys || {};
: '<span class="tag warn">Host-Key des Druckers noch nicht bestätigt</span>'; renderKey(info);
renderPrinters();
} }
async function loadSSH() { const info = await api('GET', '/api/ssh'); renderKey(info); renderHost(info.host_key); }
$('keyfile').addEventListener('change', async (ev) => { $('keyfile').addEventListener('change', async (ev) => {
const f = ev.target.files[0]; const f = ev.target.files[0];
if (f) $('keytext').value = await f.text(); if (f) $('keytext').value = await f.text();
@@ -117,19 +157,18 @@ async function deleteKey() {
await api('DELETE', '/api/ssh/key'); loadSSH(); await api('DELETE', '/api/ssh/key'); loadSSH();
} }
async function forgetHost() { async function forgetHost(i) {
if (!confirm('Gespeicherten Host-Key vergessen? Danach muss er beim nächsten Test neu bestätigt werden.')) return; const p = printers[i];
await api('DELETE', '/api/ssh/hostkey'); loadSSH(); 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() { async function testSSH(i) {
$('testresult').innerHTML = '<span class="muted">Verbinde …</span>'; const p = printers[i], out = $('test' + i);
out.innerHTML = '<span class="muted">Verbinde …</span>';
let r; let r;
try { r = await api('POST', '/api/ssh/test'); } catch (e) { $('testresult').innerHTML = `<div class="banner err">${esc(e.message)}</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) { if (!r.ok) { out.innerHTML = `<div class="banner err">${esc(r.error)}</div>`; return; }
$('testresult').innerHTML = `<div class="banner err">${esc(r.error)}</div>`;
return;
}
const rows = r.slots.map((s) => { const rows = r.slots.map((s) => {
const ch = s.channel || {}; const ch = s.channel || {};
const c = (ch.color || '').replace(/^0x/i, ''); const c = (ch.color || '').replace(/^0x/i, '');
@@ -138,16 +177,16 @@ async function testSSH() {
}).join(''); }).join('');
let host = `<div class="banner ok">Verbunden. Host-Key <code>${esc(r.host_key.fingerprint)}</code> stimmt mit dem gespeicherten überein.</div>`; 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) { 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)}). host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}). Vergleichen und bestätigen.
Mit <code>ssh-keygen -lf /etc/dropbear/…</code> bzw. <code>ssh-keyscan</code> vergleichen und bestätigen. <button class="primary" onclick="acceptHost(${i}, '${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
<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>`; 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) { async function acceptHost(i, fp) {
if (!confirm('Host-Key ' + fp + ' als vertrauenswürdig speichern?')) return; const p = printers[i];
try { const r = await api('POST', '/api/ssh/hostkey', { fingerprint: fp }); renderHost(r.host_key); testSSH(); } 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); } catch (e) { toast(e.message); }
} }
+30 -17
View File
@@ -2,8 +2,7 @@
{% block title %}CC2 Bridge – Status{% endblock %} {% block title %}CC2 Bridge – Status{% endblock %}
{% block content %} {% block content %}
<div id="banners"></div> <div id="banners"></div>
<h2>Canvas-Slots</h2> <div id="printers"><div class="panel muted">Lade …</div></div>
<div class="grid" id="slots"><div class="panel muted">Lade …</div></div>
<p class="small muted" id="lastpoll"></p> <p class="small muted" id="lastpoll"></p>
<h2>Protokoll</h2> <h2>Protokoll</h2>
<div class="panel table-wrap"> <div class="panel table-wrap">
@@ -21,7 +20,7 @@ function colorOf(ch) {
return /^[0-9a-f]{6}$/i.test(c) ? c : null; return /^[0-9a-f]{6}$/i.test(c) ? c : null;
} }
function slotCard(s) { function slotCard(p, s) {
const ch = s.record.channel || {}; const ch = s.record.channel || {};
const tray = TRAY[s.tray_status]; const tray = TRAY[s.tray_status];
const col = colorOf(ch); 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>'); : (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>`; 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; const lb = s.last_booking;
return `<div class="panel"> return `<div class="panel">
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3> <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"> <dl class="kv">
<dt>Tag</dt><dd>${tag}</dd> <dt>Tag</dt><dd>${tag}</dd>
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</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>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> <dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` : '<span class="muted">–</span>'}</dd>
</dl> </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"> ${m ? `<div class="row" style="margin-top:10px">
<button onclick="slotAction(${s.slot}, 'reassign')">Zuordnung erneut senden</button> <button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Zuordnung erneut senden</button>
<button class="danger" onclick="slotAction(${s.slot}, 'release')">Aufheben</button></div>` : ''} <button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}
</div>`; </div>`;
} }
async function slotAction(slot, action) { let NAMES = {};
async function slotAction(pid, slot, action) {
const name = NAMES[pid] || pid;
const text = action === 'release' const text = action === 'release'
? `Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.` ? `${name}: 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} erneut an CC2 Control senden?`;
if (!confirm(text)) return; 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); } catch (e) { toast(e.message); }
} }
async function enableCC2() { async function enableCC2(pid) {
if (!confirm('Spulenverwaltung in CC2 Control einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.')) return; 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 { 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'); toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
setTimeout(load, 1500); setTimeout(load, 1500);
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
@@ -79,12 +85,19 @@ async function load() {
const b = []; 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.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>'); 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.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</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(''); $('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'; $('lastpoll').textContent = st.last_poll ? 'Letzte Abfrage: ' + fmtTime(st.last_poll) : 'Noch keine Abfrage';
$('log').innerHTML = (st.log || []).map((e) => { $('log').innerHTML = (st.log || []).map((e) => {
const l = LEVEL[e.level] || [e.level, '']; 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) keys = bridge.keys if bridge else KeyStore(data_dir)
bridge = bridge or Bridge(data_dir, settings, keys) bridge = bridge or Bridge(data_dir, settings, keys)
stop = threading.Event() 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 @asynccontextmanager
async def lifespan(app): async def lifespan(app):
@@ -105,20 +105,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
def status(): def status():
return {**bridge.snapshot, "dry_run": settings.get().dry_run, "log": bridge.log.recent(200)} return {**bridge.snapshot, "dry_run": settings.get().dry_run, "log": bridge.log.recent(200)}
@app.post("/api/cc2/enable") def printer_or_404(pid: str):
async def cc2_enable(request: Request): 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) body = await _json(request)
if body.get("confirm") is not True: if body.get("confirm") is not True:
raise HTTPException(400, "Bestätigung fehlt") raise HTTPException(400, "Bestätigung fehlt")
bridge.commands.put(("enable", None)) bridge.commands.put(("enable", pid, None))
bridge.wake.set() bridge.wake.set()
return {"ok": True, "dry_run": settings.get().dry_run} return {"ok": True, "dry_run": settings.get().dry_run}
@app.post("/api/slots/{slot}/{action}") @app.post("/api/printers/{pid}/slots/{slot}/{action}")
def slot_action(slot: int, action: str): 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"): if slot not in range(4) or action not in ("reassign", "release"):
raise HTTPException(404) raise HTTPException(404)
bridge.commands.put((action, slot)) bridge.commands.put((action, pid, slot))
bridge.wake.set() bridge.wake.set()
return {"ok": True} return {"ok": True}
@@ -131,11 +143,14 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
@app.post("/api/settings") @app.post("/api/settings")
async def post_settings(request: Request): async def post_settings(request: Request):
body = await _json(request) body = await _json(request)
before = {p.id for p in settings.get().printers}
allowed = set(settings.get().to_dict()) allowed = set(settings.get().to_dict())
try: try:
s = settings.save({k: v for k, v in body.items() if k in allowed}) s = settings.save({k: v for k, v in body.items() if k in allowed})
except ValueError as e: except ValueError as e:
raise HTTPException(400, str(e)) from None 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.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
bridge.wake.set() bridge.wake.set()
return s.to_dict() 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") @app.get("/api/ssh")
def ssh_info(): 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") @app.post("/api/ssh/key")
async def ssh_key(request: Request): 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: except SSHError as e:
raise HTTPException(400, str(e)) from None raise HTTPException(400, str(e)) from None
bridge.log.add("info", "SSH-Schlüssel gespeichert") bridge.log.add("info", "SSH-Schlüssel gespeichert")
if bridge.reader: close_readers()
bridge.reader.close()
return {"key": keys.key_info()} return {"key": keys.key_info()}
@app.delete("/api/ssh/key") @app.delete("/api/ssh/key")
def ssh_key_delete(): def ssh_key_delete():
keys.delete_key() keys.delete_key()
if bridge.reader: close_readers()
bridge.reader.close()
bridge.log.add("info", "SSH-Schlüssel gelöscht") bridge.log.add("info", "SSH-Schlüssel gelöscht")
return {"key": None} return {"key": None}
@app.post("/api/ssh/test") @app.post("/api/printers/{pid}/ssh/test")
def ssh_test(): def ssh_test(pid: str):
s = settings.get() p = printer_or_404(pid)
reader = CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user) reader = CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user)
try: try:
result = reader.test() result = reader.test()
except HostKeyMismatch as e: 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)} return {"ok": False, "error": str(e)}
hk = result["host_key"] hk = result["host_key"]
if not result["host_key_pinned"]: if not result["host_key_pinned"]:
tested_host_keys.clear() tested_host_keys.pop(pid, None)
tested_host_keys[hk["fingerprint"]] = hk tested_host_keys[pid] = hk
slots = tagcodec.parse_record(result["record"], s.id_offset) slots = tagcodec.parse_record(result["record"], settings.get().id_offset)
return {"ok": True, "host_key": {"fingerprint": hk["fingerprint"], "type": hk["type"]}, return {"ok": True, "host_key": {"fingerprint": hk["fingerprint"], "type": hk["type"]},
"host_key_pinned": result["host_key_pinned"], "slots": slots} "host_key_pinned": result["host_key_pinned"], "slots": slots}
@app.post("/api/ssh/hostkey") @app.post("/api/printers/{pid}/ssh/hostkey")
async def ssh_hostkey(request: Request): async def ssh_hostkey(pid: str, request: Request):
p = printer_or_404(pid)
body = await _json(request) body = await _json(request)
hk = tested_host_keys.get(body.get("fingerprint")) hk = tested_host_keys.get(pid)
if not hk: if not hk or hk["fingerprint"] != body.get("fingerprint"):
raise HTTPException(400, "Bitte zuerst „Verbindung testen“ und dann diesen Fingerprint bestätigen") 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"])) key = paramiko.PKey.from_type_string(hk["type"], base64.b64decode(hk["key"]))
keys.pin_host_key(settings.get().printer_host, key) keys.pin_host_key(pid, p.host, key)
tested_host_keys.clear() tested_host_keys.pop(pid, None)
bridge.log.add("info", f"Host-Key des Druckers bestätigt ({hk['fingerprint']})") bridge.log.add("info", f"{p.name}: Host-Key bestätigt ({hk['fingerprint']})")
bridge.wake.set() bridge.wake.set()
return {"host_key": keys.host_key()} return {"host_key": keys.host_key(pid)}
@app.delete("/api/ssh/hostkey") @app.delete("/api/printers/{pid}/ssh/hostkey")
def ssh_hostkey_delete(): def ssh_hostkey_delete(pid: str):
keys.forget_host_key() p = printer_or_404(pid)
if bridge.reader: keys.forget_host_key(pid)
bridge.reader.close() close_readers()
bridge.log.add("info", "Gespeicherter Host-Key des Druckers gelöscht") bridge.log.add("info", f"{p.name}: gespeicherter Host-Key gelöscht")
return {"host_key": None} return {"host_key": None}
# ---------- tag generator ---------- # ---------- tag generator ----------
+34 -5
View File
@@ -113,12 +113,13 @@ class FakeSpoolman:
self.calls: list[tuple] = [] self.calls: list[tuple] = []
self.fail = False self.fail = False
self.fail_use_after_apply = False self.fail_use_after_apply = False
self.field_defs: dict[str, dict] = {}
def add(self, sid, vendor="SUNLU", material="PLA+", color="000000", remaining=800.0, lot=None, **extra): def add(self, sid, vendor="SUNLU", material="PLA+", color="000000", remaining=800.0, lot=None, **extra):
self.db[sid] = { self.db[sid] = {
"id": sid, "lot_nr": lot if lot is not None else f"{vendor.upper()}{sid:03d}", "id": sid, "lot_nr": lot if lot is not None else f"{vendor.upper()}{sid:03d}",
"remaining_weight": remaining, "initial_weight": 1000.0, "spool_weight": 150.0, "used_weight": 0.0, "remaining_weight": remaining, "initial_weight": 1000.0, "spool_weight": 150.0, "used_weight": 0.0,
"location": "", "archived": False, "location": "", "archived": False, "extra": {},
"filament": {"id": sid, "name": f"{material} {color}", "material": material, "color_hex": color, "filament": {"id": sid, "name": f"{material} {color}", "material": material, "color_hex": color,
"diameter": 1.75, "density": 1.24, "weight": 1000.0, "vendor": {"name": vendor}}, "diameter": 1.75, "density": 1.24, "weight": 1000.0, "vendor": {"name": vendor}},
**extra, **extra,
@@ -139,11 +140,39 @@ class FakeSpoolman:
self._check() self._check()
return [copy.deepcopy(s) for s in self.db.values() if archived or not s["archived"]] return [copy.deepcopy(s) for s in self.db.values() if archived or not s["archived"]]
def set_location(self, sid, location): def update_spool(self, sid, body):
self._check() self._check()
self.calls.append(("location", sid, location)) if sid not in self.db:
self.db[sid]["location"] = location raise SpoolmanNotFound("nicht gefunden")
return self.db[sid] sp = self.db[sid]
for key, value in (body.get("extra") or {}).items():
if value is not None and key not in self.field_defs:
raise SpoolmanError("Spoolman PATCH: HTTP 400")
self.calls.append(("location", sid, body.get("location")))
if "extra" in body:
self.calls.append(("extra", sid, dict(body["extra"])))
if "location" in body:
sp["location"] = body["location"]
for key, value in (body.get("extra") or {}).items():
if value is None:
sp["extra"].pop(key, None)
else:
sp["extra"][key] = value
return sp
def fields(self):
self._check()
return [{"key": k, **v} for k, v in self.field_defs.items()]
def add_field(self, key, params):
self._check()
self.calls.append(("field", key))
self.field_defs[key] = dict(params)
return self.fields()
def extra(self, sid):
import json
return {k: json.loads(v) for k, v in self.db[sid]["extra"].items()}
def use(self, sid, grams): def use(self, sid, grams):
self._check() self._check()
+226 -38
View File
@@ -25,24 +25,40 @@ def env(tmp_path):
return make_env(tmp_path) return make_env(tmp_path)
def make_env(tmp_path, dry_run=False, reader=None, cc2=None, spoolman=None, clock=None): PRINTERS = [
{"id": "p1", "name": "Rechts", "host": "10.0.0.1", "cc2_url": "http://10.0.0.1:8081"},
{"id": "p2", "name": "Links", "host": "10.0.0.2", "cc2_url": "http://10.0.0.2:8081"},
]
def make_env(tmp_path, dry_run=False, reader=None, cc2=None, spoolman=None, clock=None, printers=1,
readers=None, cc2s=None):
settings = SettingsStore(tmp_path, env={}) settings = SettingsStore(tmp_path, env={})
settings.save({"dry_run": dry_run, "settle_seconds": 30, "empty_grace": 20}) settings.save({"printers": PRINTERS[:printers], "dry_run": dry_run, "settle_seconds": 30, "empty_grace": 20})
reader = reader or FakeReader() readers = readers or {"p1": reader or FakeReader(), "p2": FakeReader()}
cc2 = cc2 or FakeCC2() cc2s = cc2s or {"p1": cc2 or FakeCC2(), "p2": FakeCC2()}
sm = spoolman or FakeSpoolman() sm = spoolman or FakeSpoolman()
clock = clock or Clock() clock = clock or Clock()
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda s: reader, bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda p: readers[p.id],
cc2_factory=lambda s: cc2, spoolman_factory=lambda s: sm, clock=clock) cc2_factory=lambda p: cc2s[p.id], spoolman_factory=lambda s: sm, clock=clock)
return {"bridge": bridge, "reader": reader, "cc2": cc2, "sm": sm, "clock": clock, "settings": settings, return {"bridge": bridge, "reader": readers["p1"], "cc2": cc2s["p1"], "reader2": readers["p2"],
"path": tmp_path} "cc2_2": cc2s["p2"], "readers": readers, "cc2s": cc2s, "sm": sm, "clock": clock,
"settings": settings, "path": tmp_path}
def insert(e, slot, spool_id, **kw): def slots(e, pid="p1"):
return e["bridge"].state.printer(pid)["slots"]
def booked(e, pid="p1"):
return e["bridge"].state.printer(pid)["booked"]
def insert(e, slot, spool_id, pid="p1", **kw):
"""Put a tagged spool into a slot: tray loaded and the record carries the id.""" """Put a tagged spool into a slot: tray loaded and the record carries the id."""
e["cc2"].slots[slot]["printer"] = {"status": 1} e["cc2s"][pid].slots[slot]["printer"] = {"status": 1}
e["cc2"].slots[slot]["question"] = "inserted" e["cc2s"][pid].slots[slot]["question"] = "inserted"
e["reader"].record[f"channels{slot}"] = channel(10000 + spool_id, **kw) e["readers"][pid].record[f"channels{slot}"] = channel(10000 + spool_id, **kw)
def poll(e, n=1, step=5): def poll(e, n=1, step=5):
@@ -64,7 +80,7 @@ def test_scan_creates_assigns_and_sets_location(env):
assert f["net"] == 1000 and f["tare"] == 150 and f["diameter"] == 1.75 and f["density"] == 1.24 assert f["net"] == 1000 and f["tare"] == 150 and f["diameter"] == 1.75 and f["density"] == 1.24
assert ("assign", 1, "s0001") in cc2.calls assert ("assign", 1, "s0001") in cc2.calls
assert cc2.slots[1]["spool"] == "s0001" and cc2.slots[1]["question"] == "" assert cc2.slots[1]["spool"] == "s0001" and cc2.slots[1]["question"] == ""
assert ("location", 5, "CC2 Slot 2") in env["sm"].calls assert ("location", 5, "Rechts Slot 2") in env["sm"].calls
poll(env, 3) poll(env, 3)
# nothing repeated # nothing repeated
assert len([c for c in cc2.calls if c[0] in ("save", "assign")]) == 2 assert len([c for c in cc2.calls if c[0] in ("save", "assign")]) == 2
@@ -85,7 +101,7 @@ def test_unknown_spool_id_is_ignored(env):
insert(env, 0, 42) insert(env, 0, 42)
poll(env) poll(env)
assert env["cc2"].calls == [] assert env["cc2"].calls == []
assert env["bridge"].state.data["slots"] == {} assert slots(env) == {}
assert any("42 existiert nicht" in e["msg"] for e in env["bridge"].log.recent()) assert any("42 existiert nicht" in e["msg"] for e in env["bridge"].log.recent())
@@ -93,7 +109,7 @@ def test_real_weight_tags_are_ignored(env):
env["reader"].record["channels0"] = channel(1000) env["reader"].record["channels0"] = channel(1000)
env["cc2"].slots[0]["printer"] = {"status": 1} env["cc2"].slots[0]["printer"] = {"status": 1}
poll(env, 2) poll(env, 2)
assert env["cc2"].calls == [] and env["bridge"].state.data["slots"] == {} assert env["cc2"].calls == [] and slots(env) == {}
def test_mapping_survives_weight_falling_back_to_1000(env): def test_mapping_survives_weight_falling_back_to_1000(env):
@@ -105,9 +121,9 @@ def test_mapping_survives_weight_falling_back_to_1000(env):
env["cc2"].slots[2]["spool"] = "" env["cc2"].slots[2]["spool"] = ""
env["cc2"].slots[2]["question"] = "changed" env["cc2"].slots[2]["question"] = "changed"
poll(env, 10) poll(env, 10)
m = env["bridge"].state.data["slots"]["2"] m = slots(env)["2"]
assert m["spool"] == 5 assert m["spool"] == 5
assert env["sm"].db[5]["location"] == "CC2 Slot 3" assert env["sm"].db[5]["location"] == "Rechts Slot 3"
def test_mapping_survives_restart(env): def test_mapping_survives_restart(env):
@@ -118,7 +134,7 @@ def test_mapping_survives_restart(env):
poll(env) poll(env)
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"]) e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
poll(e2, 3) poll(e2, 3)
assert e2["bridge"].state.data["slots"]["0"]["spool"] == 5 assert slots(e2)["0"]["spool"] == 5
assert len([c for c in env["cc2"].calls if c[0] == "save"]) == 1 assert len([c for c in env["cc2"].calls if c[0] == "save"]) == 1
@@ -128,9 +144,9 @@ def test_empty_slot_releases_to_storage_after_grace(env):
poll(env) poll(env)
env["cc2"].slots[0]["printer"] = {"status": 0} env["cc2"].slots[0]["printer"] = {"status": 0}
poll(env, 3, step=5) # 0, 5, 10 s poll(env, 3, step=5) # 0, 5, 10 s
assert "0" in env["bridge"].state.data["slots"] assert "0" in slots(env)
poll(env, 3, step=5) # >= 20 s poll(env, 3, step=5) # >= 20 s
assert "0" not in env["bridge"].state.data["slots"] assert "0" not in slots(env)
assert env["sm"].db[5]["location"] == "Lager" assert env["sm"].db[5]["location"] == "Lager"
@@ -140,7 +156,7 @@ def test_cc2_returning_spool_to_storage_releases(env):
poll(env, 2) poll(env, 2)
env["cc2"].slots[0]["spool"] = "" # CC2 gave it back to storage (no question) env["cc2"].slots[0]["spool"] = "" # CC2 gave it back to storage (no question)
poll(env) poll(env)
assert "0" not in env["bridge"].state.data["slots"] assert "0" not in slots(env)
assert env["sm"].db[5]["location"] == "Lager" assert env["sm"].db[5]["location"] == "Lager"
@@ -151,9 +167,9 @@ def test_new_scan_with_other_id_replaces_mapping(env):
poll(env, 2) poll(env, 2)
insert(env, 0, 8, color="0xF88D36") insert(env, 0, 8, color="0xF88D36")
poll(env, 2) poll(env, 2)
assert env["bridge"].state.data["slots"]["0"]["spool"] == 8 assert slots(env)["0"]["spool"] == 8
assert env["sm"].db[5]["location"] == "Lager" assert env["sm"].db[5]["location"] == "Lager"
assert env["sm"].db[8]["location"] == "CC2 Slot 1" assert env["sm"].db[8]["location"] == "Rechts Slot 1"
assert env["cc2"].slots[0]["spool"] == "s0002" assert env["cc2"].slots[0]["spool"] == "s0002"
@@ -164,9 +180,9 @@ def test_spool_moved_to_other_slot(env):
env["reader"].record["channels0"] = channel("") env["reader"].record["channels0"] = channel("")
insert(env, 3, 5) insert(env, 3, 5)
poll(env, 2) poll(env, 2)
slots = env["bridge"].state.data["slots"] sl = slots(env)
assert "0" not in slots and slots["3"]["spool"] == 5 assert "0" not in sl and sl["3"]["spool"] == 5
assert env["sm"].db[5]["location"] == "CC2 Slot 4" assert env["sm"].db[5]["location"] == "Rechts Slot 4"
def test_consumption_booked_after_print_once(env): def test_consumption_booked_after_print_once(env):
@@ -216,7 +232,7 @@ def test_interrupted_booking_is_not_repeated(env):
poll(e2, 10) poll(e2, 10)
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 4.0)] assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 4.0)]
assert env["sm"].db[5]["used_weight"] == 4.0 assert env["sm"].db[5]["used_weight"] == 4.0
b = e2["bridge"].state.data["booked"]["s0001"] b = booked(e2)["s0001"]
assert b["pending"] is None and b["remaining"] == 796.0 assert b["pending"] is None and b["remaining"] == 796.0
@@ -260,7 +276,8 @@ def test_dry_run_writes_nothing(tmp_path):
assert e["sm"].calls == [] assert e["sm"].calls == []
msgs = [x["msg"] for x in e["bridge"].log.recent(500) if x["level"] == "dry"] msgs = [x["msg"] for x in e["bridge"].log.recent(500) if x["level"] == "dry"]
assert any("Spule SUNLU005 anlegen" in m for m in msgs) assert any("Spule SUNLU005 anlegen" in m for m in msgs)
assert any("Ort von" in m for m in msgs) assert any("Ort „Rechts Slot 1“, Drucker Rechts, Slot 1" in m for m in msgs)
assert any("Zusatzfeld" in m for m in msgs)
assert any("10.00 g" in m for m in msgs) assert any("10.00 g" in m for m in msgs)
# logged once, not every cycle # logged once, not every cycle
assert len([m for m in msgs if "10.00 g" in m]) == 1 assert len([m for m in msgs if "10.00 g" in m]) == 1
@@ -268,7 +285,7 @@ def test_dry_run_writes_nothing(tmp_path):
e["settings"].save({"dry_run": False}) e["settings"].save({"dry_run": False})
poll(e, 10) poll(e, 10)
assert ("use", 7, 10.0) in e["sm"].calls assert ("use", 7, 10.0) in e["sm"].calls
assert e["sm"].db[5]["location"] == "CC2 Slot 1" assert e["sm"].db[5]["location"] == "Rechts Slot 1"
assert e["cc2"].slots[0]["spool"] == "s0002" assert e["cc2"].slots[0]["spool"] == "s0002"
@@ -277,11 +294,11 @@ def test_outages_do_not_lose_scans(env):
env["sm"].fail = True env["sm"].fail = True
insert(env, 0, 5) insert(env, 0, 5)
poll(env, 2) poll(env, 2)
assert env["bridge"].state.data["slots"] == {} assert slots(env) == {}
assert env["bridge"].state.data["scans"] assert env["bridge"].state.printer("p1")["scans"]
env["sm"].fail = False env["sm"].fail = False
poll(env) poll(env)
assert env["bridge"].state.data["slots"]["0"]["spool"] == 5 assert slots(env)["0"]["spool"] == 5
def test_cc2_outage_retries_assignment(env): def test_cc2_outage_retries_assignment(env):
@@ -300,17 +317,17 @@ def test_manual_assignment_in_cc2_is_adopted(env):
env["cc2"].assign(3, "s0001") env["cc2"].assign(3, "s0001")
env["cc2"].slots[3]["printer"] = {"status": 1} env["cc2"].slots[3]["printer"] = {"status": 1}
poll(env) poll(env)
assert env["bridge"].state.data["slots"]["3"]["spool"] == 9 assert slots(env)["3"]["spool"] == 9
assert env["sm"].db[9]["location"] == "CC2 Slot 4" assert env["sm"].db[9]["location"] == "Rechts Slot 4"
def test_enable_command_respects_dry_run(tmp_path): def test_enable_command_respects_dry_run(tmp_path):
e = make_env(tmp_path, dry_run=True, cc2=FakeCC2(enabled=False)) e = make_env(tmp_path, dry_run=True, cc2=FakeCC2(enabled=False))
e["bridge"].commands.put(("enable", None)) e["bridge"].commands.put(("enable", "p1", None))
poll(e) poll(e)
assert e["cc2"].calls == [] and e["bridge"].snapshot["cc2_enabled"] is False assert e["cc2"].calls == [] and e["bridge"].snapshot["printers"][0]["cc2_enabled"] is False
e["settings"].save({"dry_run": False}) e["settings"].save({"dry_run": False})
e["bridge"].commands.put(("enable", None)) e["bridge"].commands.put(("enable", "p1", None))
poll(e) poll(e)
assert e["cc2"].calls == [("enable", True)] assert e["cc2"].calls == [("enable", True)]
@@ -320,7 +337,7 @@ def test_state_file_is_valid_json(env):
insert(env, 0, 5) insert(env, 0, 5)
poll(env) poll(env)
data = json.loads((env["path"] / "state.json").read_text()) data = json.loads((env["path"] / "state.json").read_text())
assert data["slots"]["0"]["spool"] == 5 assert data["printers"]["p1"]["slots"]["0"]["spool"] == 5
assert not list(env["path"].glob(".state.json.*")) assert not list(env["path"].glob(".state.json.*"))
@@ -338,3 +355,174 @@ def test_fields_use_tag_material_when_spoolman_material_does_not_fit():
fields, notes = cc2_spool_fields(sm, channel(10003, type_="PLA", detailed="PLA Silk")) fields, notes = cc2_spool_fields(sm, channel(10003, type_="PLA", detailed="PLA Silk"))
assert fields["material"] == "PLA Silk" and notes assert fields["material"] == "PLA Silk" and notes
assert fields["color"] == "#FFD700" assert fields["color"] == "#FFD700"
# ---------- v2: several printers, Spoolman location and extra fields ----------
def test_extra_fields_created_and_kept_in_sync(env):
env["sm"].add(5)
insert(env, 1, 5)
poll(env)
assert set(env["sm"].field_defs) == {"drucker", "slot"}
assert env["sm"].field_defs["slot"]["field_type"] == "integer"
assert env["sm"].db[5]["location"] == "Rechts Slot 2"
assert env["sm"].extra(5) == {"drucker": "Rechts", "slot": 2}
env["cc2"].slots[1]["spool"] = "" # "Take out" in CC2 Control
poll(env)
assert env["sm"].db[5]["location"] == "Lager" and env["sm"].extra(5) == {}
def test_take_out_in_cc2_control_goes_to_storage(env):
env["sm"].add(5)
insert(env, 0, 5)
poll(env, 2)
env["cc2"].assign(0, None) # what the "Take out" button sends
poll(env)
assert slots(env) == {}
assert env["sm"].db[5]["location"] == "Lager"
poll(env, 20) # the unchanged tag in the record does not bring it back
assert slots(env) == {}
def test_renaming_printer_moves_location(env):
env["sm"].add(5)
insert(env, 0, 5)
poll(env)
printers = env["settings"].get().to_dict()["printers"]
printers[0]["name"] = "Centauri Carbon 2 Rechts"
env["settings"].save({"printers": printers})
poll(env)
assert env["sm"].db[5]["location"] == "Centauri Carbon 2 Rechts Slot 1"
assert env["sm"].extra(5)["drucker"] == "Centauri Carbon 2 Rechts"
assert slots(env)["0"]["spool"] == 5
def test_manual_spoolman_edits_are_corrected(env):
env["sm"].add(5)
env["sm"].add(6)
insert(env, 0, 5)
poll(env)
env["sm"].db[5]["location"] = "Regal 3" # someone moves a loaded spool by hand
env["sm"].db[5]["extra"]["slot"] = "4"
env["sm"].db[6]["location"] = "Regal 3" # a spool that is not loaded: left alone
poll(env, 13) # >= 60 s: full comparison
assert env["sm"].db[5]["location"] == "Rechts Slot 1" and env["sm"].extra(5)["slot"] == 1
assert env["sm"].db[6]["location"] == "Regal 3"
assert any("(korrigiert)" in x["msg"] for x in env["bridge"].log.recent())
def test_stale_printer_locations_go_to_storage(env):
env["sm"].add(1, location="CC2 Slot 2") # from v1 or before a state loss
env["sm"].add(2, location="Rechts Slot 3")
env["sm"].add(3, location="Lager", extra={"drucker": '"Links"', "slot": "1"})
env["sm"].add(4, location="Regal 1")
env["bridge"].state.data["written_locations"] = ["CC2 Slot 2"]
poll(env)
assert env["sm"].db[1]["location"] == "Lager"
assert env["sm"].db[2]["location"] == "Lager"
assert env["sm"].db[3]["location"] == "Lager" and env["sm"].extra(3) == {}
assert env["sm"].db[4]["location"] == "Regal 1"
assert not [c for c in env["sm"].calls if c[1] == 4]
def test_spool_moves_between_printers(tmp_path):
e = make_env(tmp_path, printers=2)
e["sm"].add(5)
insert(e, 0, 5)
poll(e, 2)
assert e["sm"].db[5]["location"] == "Rechts Slot 1"
e["readers"]["p1"].record["channels0"] = channel("") # taken out of Rechts ...
insert(e, 2, 5, pid="p2") # ... and put into Links
poll(e, 2)
assert slots(e, "p1") == {} and slots(e, "p2")["2"]["spool"] == 5
assert e["sm"].db[5]["location"] == "Links Slot 3" and e["sm"].extra(5) == {"drucker": "Links", "slot": 3}
assert e["cc2s"]["p2"].slots[2]["spool"] == "s0001"
# Rechts' CC2 Control still binds its own mirror until the tray empties: no flip-flop
e["cc2s"]["p1"].slots[0]["printer"] = {"status": 0}
poll(e, 6)
assert slots(e, "p2")["2"]["spool"] == 5 and slots(e, "p1") == {}
assert e["sm"].db[5]["location"] == "Links Slot 3"
def test_consumption_is_booked_per_printer(tmp_path):
e = make_env(tmp_path, printers=2)
e["sm"].add(5, remaining=800)
e["sm"].add(8, remaining=500)
insert(e, 0, 5)
insert(e, 1, 8, pid="p2")
poll(e)
e["cc2s"]["p1"].consume("s0001", 3.0)
e["cc2s"]["p2"].consume("s0001", 7.5) # same CC2 id in the other printer's library
poll(e, 10)
uses = sorted(c for c in e["sm"].calls if c[0] == "use")
assert uses == [("use", 5, 3.0), ("use", 8, 7.5)]
def test_one_printer_down_does_not_block_the_other(tmp_path):
e = make_env(tmp_path, printers=2)
e["sm"].add(5)
e["readers"]["p2"].fail = True
e["cc2s"]["p2"].fail = True
insert(e, 0, 5)
poll(e)
assert e["sm"].db[5]["location"] == "Rechts Slot 1"
snap = {p["id"]: p for p in e["bridge"].snapshot["printers"]}
assert snap["p2"]["errors"]["ssh"] and snap["p1"]["errors"]["ssh"] is None
def test_removed_printer_releases_its_spools(tmp_path):
e = make_env(tmp_path, printers=2)
e["sm"].add(5)
insert(e, 0, 5, pid="p2")
poll(e)
assert e["sm"].db[5]["location"] == "Links Slot 1"
e["settings"].save({"printers": PRINTERS[:1]})
poll(e)
assert "p2" not in e["bridge"].state.data["printers"]
assert e["sm"].db[5]["location"] == "Lager" and e["sm"].extra(5) == {}
def test_without_extra_fields_only_location(tmp_path):
e = make_env(tmp_path)
e["settings"].save({"spoolman_fields": False})
e["sm"].add(5)
insert(e, 0, 5)
poll(e)
assert e["sm"].db[5]["location"] == "Rechts Slot 1" and e["sm"].extra(5) == {}
assert e["sm"].field_defs == {}
def test_migration_from_v1(tmp_path):
(tmp_path / "settings.json").write_text(json.dumps({
"printer_host": "10.10.2.202", "ssh_port": 22, "ssh_user": "root", "cc2_url": "http://10.10.2.202:8081",
"spoolman_url": "http://10.10.6.10:7912", "id_offset": 10000, "poll_interval": 5.0,
"location_slot": "CC2 Slot {n}", "location_storage": "Lager", "empty_grace": 20.0,
"settle_seconds": 60.0, "dry_run": False}))
(tmp_path / "state.json").write_text(json.dumps({
"version": 1,
"slots": {"1": {"spool": 1, "name": "SUNLU001", "since": 1, "cc2": "abc", "assigned": True, "cc2_seen": True}},
"booked": {"abc": {"spoolman": 1, "remaining": 250.0, "pending": None, "last": None}},
"locations": {"1": {"want": "CC2 Slot 2", "done": "CC2 Slot 2"}},
"last_record": record("", 10001), "scans": []}))
(tmp_path / "ssh").mkdir()
(tmp_path / "ssh" / "host_key.json").write_text('{"fingerprint": "SHA256:x"}')
settings = SettingsStore(tmp_path, env={})
s = settings.get()
assert [(p.id, p.name, p.host) for p in s.printers] == [("p1", "CC2", "10.10.2.202")]
assert s.location_slot == "{printer} Slot {n}" and s.dry_run is False
assert "printer_host" not in json.loads((tmp_path / "settings.json").read_text())
reader = FakeReader(record("", 10001))
cc2 = FakeCC2()
cc2.library.append({"id": "abc", "name": "SUNLU001", "material": "PLA+", "color": "#000000",
"remaining": 250.0, "note": "spoolman:1", "archived": False})
cc2.slots[1]["spool"] = "abc"
cc2.slots[1]["printer"] = {"status": 1}
sm = FakeSpoolman()
sm.add(1, remaining=250.0, location="CC2 Slot 2")
e = make_env(tmp_path, dry_run=False, reader=reader, cc2=cc2, spoolman=sm)
poll(e)
assert slots(e)["1"]["spool"] == 1 and booked(e)["abc"]["remaining"] == 250.0
assert (tmp_path / "ssh" / "host_key_p1.json").exists()
assert sm.db[1]["location"] == "Rechts Slot 2" and sm.extra(1) == {"drucker": "Rechts", "slot": 2}
assert not [c for c in cc2.calls if c[0] in ("save", "assign")] # nothing re-created
+1 -1
View File
@@ -107,7 +107,7 @@ def test_spoolman_requests():
return httpx.Response(200, json={"id": 5}) return httpx.Response(200, json={"id": 5})
sm = Spoolman("http://10.10.6.10:7912", transport=httpx.MockTransport(handler)) sm = Spoolman("http://10.10.6.10:7912", transport=httpx.MockTransport(handler))
sm.spool(5) sm.spool(5)
sm.set_location(5, "CC2 Slot 1") sm.update_spool(5, {"location": "CC2 Slot 1"})
sm.use(5, 12.3456) sm.use(5, 12.3456)
assert [(r.method, r.url.path) for r in seen] == [ assert [(r.method, r.url.path) for r in seen] == [
("GET", "/api/v1/spool/5"), ("PATCH", "/api/v1/spool/5"), ("PUT", "/api/v1/spool/5/use")] ("GET", "/api/v1/spool/5"), ("PATCH", "/api/v1/spool/5"), ("PUT", "/api/v1/spool/5/use")]
+25 -9
View File
@@ -18,6 +18,8 @@ RECORD = record(10001, "", 1000, 10008)
class Server(paramiko.ServerInterface): class Server(paramiko.ServerInterface):
reject_exec = False
def __init__(self, allowed: paramiko.PKey): def __init__(self, allowed: paramiko.PKey):
self.allowed = allowed self.allowed = allowed
self.commands = [] self.commands = []
@@ -34,6 +36,8 @@ class Server(paramiko.ServerInterface):
def check_channel_exec_request(self, channel, command): def check_channel_exec_request(self, channel, command):
self.commands.append(command) self.commands.append(command)
if Server.reject_exec:
return False
def reply(): # forced command: always the canvas record def reply(): # forced command: always the canvas record
channel.sendall(json.dumps(RECORD).encode()) channel.sendall(json.dumps(RECORD).encode())
@@ -102,13 +106,13 @@ def test_rejects_garbage_key(tmp_path):
def test_first_contact_requires_confirmation(tmp_path, printer): def test_first_contact_requires_confirmation(tmp_path, printer):
keys = KeyStore(tmp_path) keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"]) keys.save_key(printer["client_key_text"])
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"]) reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(HostKeyUnknown): with pytest.raises(HostKeyUnknown):
reader.read() reader.read()
result = reader.test() result = reader.test()
assert result["host_key_pinned"] is False assert result["host_key_pinned"] is False
assert result["record"]["channels0"]["weight"] == "10001" assert result["record"]["channels0"]["weight"] == "10001"
keys.pin_host_key("127.0.0.1", printer["host_key"]) keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
rec = reader.read() rec = reader.read()
assert rec["channels3"]["weight"] == "10008" assert rec["channels3"]["weight"] == "10008"
# transport is reused between reads # transport is reused between reads
@@ -121,8 +125,8 @@ def test_changed_host_key_is_rejected(tmp_path, printer):
keys = KeyStore(tmp_path) keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"]) keys.save_key(printer["client_key_text"])
other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text())) other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
keys.pin_host_key("127.0.0.1", other) keys.pin_host_key("p1", "127.0.0.1", other)
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"]) reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(HostKeyMismatch): with pytest.raises(HostKeyMismatch):
reader.read() reader.read()
with pytest.raises(HostKeyMismatch): with pytest.raises(HostKeyMismatch):
@@ -132,16 +136,16 @@ def test_changed_host_key_is_rejected(tmp_path, printer):
def test_wrong_client_key_fails_cleanly(tmp_path, printer): def test_wrong_client_key_fails_cleanly(tmp_path, printer):
keys = KeyStore(tmp_path) keys = KeyStore(tmp_path)
keys.save_key(_new_key_text()) keys.save_key(_new_key_text())
keys.pin_host_key("127.0.0.1", printer["host_key"]) keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"): with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
CanvasRecordReader(keys, "127.0.0.1", printer["port"]).read() CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]).read()
def test_reconnects_after_drop(tmp_path, printer): def test_reconnects_after_drop(tmp_path, printer):
keys = KeyStore(tmp_path) keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"]) keys.save_key(printer["client_key_text"])
keys.pin_host_key("127.0.0.1", printer["host_key"]) keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"]) reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
reader.read() reader.read()
for t, _ in printer["servers"]: for t, _ in printer["servers"]:
t.close() t.close()
@@ -156,4 +160,16 @@ def test_unreachable_printer(tmp_path, printer):
port = s.getsockname()[1] port = s.getsockname()[1]
s.close() s.close()
with pytest.raises(SSHError, match="nicht erreichbar"): with pytest.raises(SSHError, match="nicht erreichbar"):
CanvasRecordReader(keys, "127.0.0.1", port, timeout=2).read() CanvasRecordReader(keys, "p1", "127.0.0.1", port, timeout=2).read()
def test_exec_failure_is_a_clean_error(tmp_path, printer, monkeypatch):
monkeypatch.setattr(Server, "reject_exec", True)
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(SSHError, match="Lesefehler"):
reader.test()
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
with pytest.raises(SSHError, match="Lesefehler"):
reader.read()
+21 -5
View File
@@ -67,8 +67,9 @@ def test_ssh_key_upload_never_returned(tmp_path):
def test_hostkey_only_from_last_test(tmp_path): def test_hostkey_only_from_last_test(tmp_path):
c, _ = make_client(tmp_path) c, _ = make_client(tmp_path)
r = c.post("/api/ssh/hostkey", json={"fingerprint": "SHA256:forged"}, headers=H) r = c.post("/api/printers/p1/ssh/hostkey", json={"fingerprint": "SHA256:forged"}, headers=H)
assert r.status_code == 400 assert r.status_code == 400
assert c.post("/api/printers/nope/ssh/hostkey", json={"fingerprint": "x"}, headers=H).status_code == 404
def test_tag_endpoint(tmp_path): def test_tag_endpoint(tmp_path):
@@ -104,7 +105,22 @@ def test_status_and_commands(tmp_path):
c, bridge = make_client(tmp_path) c, bridge = make_client(tmp_path)
bridge.poll_once() bridge.poll_once()
st = c.get("/api/status").json() st = c.get("/api/status").json()
assert len(st["slots"]) == 4 and st["dry_run"] is True assert len(st["printers"]) == 1 and len(st["printers"][0]["slots"]) == 4 and st["dry_run"] is True
assert c.post("/api/cc2/enable", json={}, headers=H).status_code == 400 assert c.post("/api/printers/p1/cc2/enable", json={}, headers=H).status_code == 400
assert c.post("/api/cc2/enable", json={"confirm": True}, headers=H).json()["dry_run"] is True assert c.post("/api/printers/p1/cc2/enable", json={"confirm": True}, headers=H).json()["dry_run"] is True
assert c.post("/api/slots/7/release", headers=H).status_code == 404 assert c.post("/api/printers/p1/slots/7/release", headers=H).status_code == 404
assert c.post("/api/printers/zz/slots/1/release", headers=H).status_code == 404
def test_printers_editable(tmp_path):
c, _ = make_client(tmp_path)
s = c.get("/api/settings").json()
printers = s["printers"] + [{"name": "Links", "host": "10.10.2.235", "cc2_url": "http://10.10.2.235:8081"}]
r = c.post("/api/settings", json={"printers": printers}, headers=H)
assert r.status_code == 200
got = r.json()["printers"]
assert [p["name"] for p in got] == ["CC2", "Links"] and got[1]["id"].startswith("p") and got[1]["ssh_port"] == 22
dup = c.post("/api/settings", json={"printers": got + [{"name": "links", "host": "x", "cc2_url": "http://x"}]}, headers=H)
assert dup.status_code == 400
bad = c.post("/api/settings", json={"location_slot": "Slot {n}"}, headers=H)
assert bad.status_code == 400 # two printers need {printer}
+89 -51
View File
@@ -1,10 +1,10 @@
"""Local stand-in for the printer (SSH), CC2 Control and Spoolman, for manual end-to-end tests. """Local stand-in for the printer (SSH), CC2 Control and Spoolman, for manual end-to-end tests.
python tools/mock_env.py --host-header mock:8081 python tools/mock_env.py --host mock --printers 2
Ports: 2222 SSH (any key listed in /mock/authorized), 8081 CC2 Control, 7912 Spoolman, 8099 control. Per printer i: SSH 2222+i (keys added via POST /authorize), CC2 Control 8081+i. Spoolman 7912, control 8099.
Control endpoints (port 8099): POST /insert?slot=&spool=, /remove?slot=, /consume?slot=&grams=, Control endpoints (port 8099, optional &printer=i): POST /insert?slot=&spool=, /remove?slot=,
/job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000). /consume?slot=&grams=, /job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,17 +25,21 @@ from fastapi.responses import JSONResponse
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tests.fakes import channel, record # noqa: E402 from tests.fakes import channel, record # noqa: E402
STATE = { def printer_state():
"record": record(), st = {
"enabled": True, "record": record(),
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)], "enabled": True,
"spools": [], "slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
"job": {"active": False, "file": "", "slots": []}, "spools": [],
"spoolman": {}, "job": {"active": False, "file": "", "slots": []},
"requests": [], "ids": (f"m{i:04d}" for i in itertools.count(1)),
} }
STATE["slots"][4]["printer"] = None st["slots"][4]["printer"] = None
IDS = (f"m{i:04d}" for i in itertools.count(1)) return st
PRINTERS: list[dict] = []
STATE = {"spoolman": {}, "fields": {}, "requests": []}
LOCK = threading.Lock() LOCK = threading.Lock()
@@ -46,6 +50,7 @@ def seed_spoolman():
STATE["spoolman"][sid] = { STATE["spoolman"][sid] = {
"id": sid, "lot_nr": f"{vendor.upper()}{sid:03d}", "remaining_weight": rem, "used_weight": 1000 - rem, "id": sid, "lot_nr": f"{vendor.upper()}{sid:03d}", "remaining_weight": rem, "used_weight": 1000 - rem,
"initial_weight": 1000.0, "spool_weight": 160.0, "location": "Lager", "archived": False, "price": 19.9, "initial_weight": 1000.0, "spool_weight": 160.0, "location": "Lager", "archived": False, "price": 19.9,
"extra": {},
"filament": {"id": sid, "name": f"{mat} {'Silk Gold' if sid == 14 else color}", "material": mat, "filament": {"id": sid, "name": f"{mat} {'Silk Gold' if sid == 14 else color}", "material": mat,
"color_hex": color, "diameter": 1.75, "density": 1.24, "weight": 1000.0, "color_hex": color, "diameter": 1.75, "density": 1.24, "weight": 1000.0,
"settings_extruder_temp": 215, "vendor": {"id": 1, "name": vendor}}, "settings_extruder_temp": 215, "vendor": {"id": 1, "name": vendor}},
@@ -54,7 +59,7 @@ def seed_spoolman():
# ---------- CC2 Control ---------- # ---------- CC2 Control ----------
def make_cc2(host_header: str) -> FastAPI: def make_cc2(host_header: str, P: dict) -> FastAPI:
app = FastAPI() app = FastAPI()
@app.middleware("http") @app.middleware("http")
@@ -85,13 +90,13 @@ def make_cc2(host_header: str) -> FastAPI:
def spools(): def spools():
with LOCK: with LOCK:
STATE["requests"].append("GET /api/spools") STATE["requests"].append("GET /api/spools")
return {"available": True, "enabled": STATE["enabled"], "slots": STATE["slots"], "spools": STATE["spools"], return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"],
"job": STATE["job"], "log": []} "job": P["job"], "log": []}
@app.get("/api/canvas") @app.get("/api/canvas")
def canvas(): def canvas():
return {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [ return {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [
{"tray_id": s["slot"], "status": s["printer"]["status"]} for s in STATE["slots"][:4]]}]}}}} {"tray_id": s["slot"], "status": s["printer"]["status"]} for s in P["slots"][:4]]}]}}}}
@app.post("/api/spools/enable") @app.post("/api/spools/enable")
async def enable(request: Request): async def enable(request: Request):
@@ -99,9 +104,9 @@ def make_cc2(host_header: str) -> FastAPI:
if body not in (b"on", b"off"): if body not in (b"on", b"off"):
raise HTTPException(400, "Invalid switch") raise HTTPException(400, "Invalid switch")
with LOCK: with LOCK:
STATE["enabled"] = body == b"on" P["enabled"] = body == b"on"
STATE["requests"].append(f"POST enable {body.decode()}") STATE["requests"].append(f"POST enable {body.decode()}")
return {"ok": True, "enabled": STATE["enabled"]} return {"ok": True, "enabled": P["enabled"]}
@app.post("/api/spools/save") @app.post("/api/spools/save")
async def save(request: Request): async def save(request: Request):
@@ -109,7 +114,7 @@ def make_cc2(host_header: str) -> FastAPI:
with LOCK: with LOCK:
STATE["requests"].append("POST save " + json.dumps(f, ensure_ascii=False)) STATE["requests"].append("POST save " + json.dumps(f, ensure_ascii=False))
if f.get("id"): if f.get("id"):
sp = next((s for s in STATE["spools"] if s["id"] == f["id"]), None) sp = next((s for s in P["spools"] if s["id"] == f["id"]), None)
if not sp: if not sp:
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404) return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
if "remaining" in f: if "remaining" in f:
@@ -122,12 +127,12 @@ def make_cc2(host_header: str) -> FastAPI:
return {"ok": True, "id": sp["id"]} return {"ok": True, "id": sp["id"]}
if not f.get("material") or not f.get("color"): if not f.get("material") or not f.get("color"):
return JSONResponse({"ok": False, "error": "A spool needs a material and a colour"}, 400) return JSONResponse({"ok": False, "error": "A spool needs a material and a colour"}, 400)
sp = {"id": next(IDS), "name": f.get("name", ""), "brand": f.get("brand", ""), "material": f["material"], sp = {"id": next(P["ids"]), "name": f.get("name", ""), "brand": f.get("brand", ""), "material": f["material"],
"color": f["color"], "diameter": float(f.get("diameter", 1.75)), "density": float(f.get("density", 1.24)), "color": f["color"], "diameter": float(f.get("diameter", 1.75)), "density": float(f.get("density", 1.24)),
"net": float(f.get("net", 1000)), "remaining": float(f.get("remaining", f.get("net", 1000))), "net": float(f.get("net", 1000)), "remaining": float(f.get("remaining", f.get("net", 1000))),
"tare": float(f.get("tare", 0)), "low": 0.0, "price": float(f.get("price", 0)), "tare": float(f.get("tare", 0)), "low": 0.0, "price": float(f.get("price", 0)),
"note": f.get("note", ""), "created": 0, "used": 0, "archived": False} "note": f.get("note", ""), "created": 0, "used": 0, "archived": False}
STATE["spools"].append(sp) P["spools"].append(sp)
return JSONResponse({"ok": True, "id": sp["id"]}, 201) return JSONResponse({"ok": True, "id": sp["id"]}, 201)
@app.post("/api/spools/assign") @app.post("/api/spools/assign")
@@ -136,11 +141,11 @@ def make_cc2(host_header: str) -> FastAPI:
with LOCK: with LOCK:
STATE["requests"].append("POST assign " + json.dumps(f)) STATE["requests"].append("POST assign " + json.dumps(f))
slot = int(f["slot"]) slot = int(f["slot"])
for s in STATE["slots"]: for s in P["slots"]:
if f["spool"] and s["spool"] == f["spool"]: if f["spool"] and s["spool"] == f["spool"]:
s["spool"] = "" s["spool"] = ""
STATE["slots"][slot]["spool"] = f["spool"] P["slots"][slot]["spool"] = f["spool"]
STATE["slots"][slot]["question"] = "" P["slots"][slot]["question"] = ""
return {"ok": True} return {"ok": True}
return app return app
@@ -169,10 +174,30 @@ def make_spoolman() -> FastAPI:
body = await request.json() body = await request.json()
with LOCK: with LOCK:
sp = get(sid) sp = get(sid)
for key, value in (body.get("extra") or {}).items():
if value is not None and key not in STATE["fields"]:
raise HTTPException(400, f"Unknown extra field {key}")
sp.update({k: v for k, v in body.items() if k in ("location",)}) sp.update({k: v for k, v in body.items() if k in ("location",)})
for key, value in (body.get("extra") or {}).items():
if value is None:
sp["extra"].pop(key, None)
else:
sp["extra"][key] = value
STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}") STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}")
return sp return sp
@app.get("/api/v1/field/spool")
def fields():
return [{"key": k, "entity_type": "spool", **v} for k, v in STATE["fields"].items()]
@app.post("/api/v1/field/spool/{key}")
async def add_field(key: str, request: Request):
body = await request.json()
with LOCK:
STATE["fields"][key] = body
STATE["requests"].append(f"POST field {key} {json.dumps(body, ensure_ascii=False)}")
return fields()
@app.put("/api/v1/spool/{sid}/use") @app.put("/api/v1/spool/{sid}/use")
async def use(sid: int, request: Request): async def use(sid: int, request: Request):
body = await request.json() body = await request.json()
@@ -193,43 +218,48 @@ def make_control() -> FastAPI:
@app.get("/state") @app.get("/state")
def state(): def state():
return STATE return {**STATE, "printers": [{k: v for k, v in p.items() if k != "ids"} for p in PRINTERS]}
@app.post("/insert") @app.post("/insert")
def insert(slot: int, spool: int, color: str = "0x000000"): def insert(slot: int, spool: int, color: str = "0x000000", printer: int = 0):
P = PRINTERS[printer]
with LOCK: with LOCK:
STATE["slots"][slot]["printer"] = {"status": 1} P["slots"][slot]["printer"] = {"status": 1}
STATE["slots"][slot]["question"] = "inserted" P["slots"][slot]["question"] = "inserted"
STATE["slots"][slot]["spool"] = "" P["slots"][slot]["spool"] = ""
STATE["record"][f"channels{slot}"] = channel(10000 + spool, color=color) P["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
return {"ok": True} return {"ok": True}
@app.post("/remove") @app.post("/remove")
def remove(slot: int): def remove(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK: with LOCK:
STATE["slots"][slot]["printer"] = {"status": 0} P["slots"][slot]["printer"] = {"status": 0}
STATE["record"][f"channels{slot}"] = channel("") P["record"][f"channels{slot}"] = channel("")
return {"ok": True} return {"ok": True}
@app.post("/touch") @app.post("/touch")
def touch(slot: int): def touch(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK: with LOCK:
STATE["record"][f"channels{slot}"]["weight"] = "1000" P["record"][f"channels{slot}"]["weight"] = "1000"
STATE["slots"][slot]["spool"] = "" P["slots"][slot]["spool"] = ""
STATE["slots"][slot]["question"] = "changed" P["slots"][slot]["question"] = "changed"
return {"ok": True} return {"ok": True}
@app.post("/job") @app.post("/job")
def job(active: int): def job(active: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK: with LOCK:
STATE["job"]["active"] = bool(active) P["job"]["active"] = bool(active)
return {"ok": True} return {"ok": True}
@app.post("/consume") @app.post("/consume")
def consume(slot: int, grams: float): def consume(slot: int, grams: float, printer: int = 0):
P = PRINTERS[printer]
with LOCK: with LOCK:
sid = STATE["slots"][slot]["spool"] sid = P["slots"][slot]["spool"]
sp = next(s for s in STATE["spools"] if s["id"] == sid) sp = next(s for s in P["spools"] if s["id"] == sid)
sp["remaining"] -= grams sp["remaining"] -= grams
return {"ok": True, "remaining": sp["remaining"]} return {"ok": True, "remaining": sp["remaining"]}
@@ -247,6 +277,9 @@ AUTHORIZED: set[str] = set()
class SSHServer(paramiko.ServerInterface): class SSHServer(paramiko.ServerInterface):
def __init__(self, P: dict):
self.P = P
def check_channel_request(self, kind, chanid): def check_channel_request(self, kind, chanid):
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
@@ -259,7 +292,7 @@ class SSHServer(paramiko.ServerInterface):
def check_channel_exec_request(self, channel_, command): def check_channel_exec_request(self, channel_, command):
def reply(): # forced command, whatever was sent def reply(): # forced command, whatever was sent
with LOCK: with LOCK:
data = json.dumps(STATE["record"]).encode() data = json.dumps(self.P["record"]).encode()
channel_.sendall(data) channel_.sendall(data)
channel_.send_exit_status(0) channel_.send_exit_status(0)
channel_.close() channel_.close()
@@ -267,7 +300,7 @@ class SSHServer(paramiko.ServerInterface):
return True return True
def ssh_server(port: int): def ssh_server(port: int, P: dict):
host_key = paramiko.Ed25519Key.generate() if hasattr(paramiko.Ed25519Key, "generate") else None host_key = paramiko.Ed25519Key.generate() if hasattr(paramiko.Ed25519Key, "generate") else None
if host_key is None: if host_key is None:
from io import StringIO from io import StringIO
@@ -287,22 +320,27 @@ def ssh_server(port: int):
t = paramiko.Transport(conn) t = paramiko.Transport(conn)
t.add_server_key(host_key) t.add_server_key(host_key)
try: try:
t.start_server(server=SSHServer()) t.start_server(server=SSHServer(P))
except Exception: except Exception:
pass pass
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--host-header", default="127.0.0.1:8081") ap.add_argument("--host", default="127.0.0.1", help="host part the CC2 Control guard expects")
ap.add_argument("--printers", type=int, default=1)
args = ap.parse_args() args = ap.parse_args()
seed_spoolman() seed_spoolman()
threading.Thread(target=ssh_server, args=(2222,), daemon=True).start()
servers = [ servers = [
uvicorn.Server(uvicorn.Config(make_cc2(args.host_header), host="0.0.0.0", port=8081, log_level="warning")),
uvicorn.Server(uvicorn.Config(make_spoolman(), host="0.0.0.0", port=7912, log_level="warning")), uvicorn.Server(uvicorn.Config(make_spoolman(), host="0.0.0.0", port=7912, log_level="warning")),
uvicorn.Server(uvicorn.Config(make_control(), host="0.0.0.0", port=8099, log_level="warning")), uvicorn.Server(uvicorn.Config(make_control(), host="0.0.0.0", port=8099, log_level="warning")),
] ]
for i in range(args.printers):
P = printer_state()
PRINTERS.append(P)
threading.Thread(target=ssh_server, args=(2222 + i, P), daemon=True).start()
servers.append(uvicorn.Server(uvicorn.Config(make_cc2(f"{args.host}:{8081 + i}", P), host="0.0.0.0",
port=8081 + i, log_level="warning")))
threads = [threading.Thread(target=s.run, daemon=True) for s in servers[1:]] threads = [threading.Thread(target=s.run, daemon=True) for s in servers[1:]]
for t in threads: for t in threads:
t.start() t.start()