Multiple printers, printer/slot in Spoolman, kept in sync
- 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:
1 parent
bf32667f77
commit
a9041eb538
15 files changed
+1389
-673
No files matched your search
@@ -1,4 +1,6 @@
|
||||
# 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
|
||||
SSH_PORT=22
|
||||
SSH_USER=root
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# cc2-spoolman-bridge
|
||||
|
||||
Bridge zwischen der Canvas des **Elegoo Centauri Carbon 2** (Community-Firmware, CC2 Control) und **Spoolman**,
|
||||
mit Weboberfläche und **NFC-Tag-Generator**.
|
||||
Bridge zwischen der Canvas eines oder mehrerer **Elegoo Centauri Carbon 2** (Community-Firmware, CC2 Control) und
|
||||
**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
|
||||
CC2 Control an (bzw. findet sie wieder), weist sie dem Slot zu und setzt in Spoolman den Ort („CC2 Slot 1–4“,
|
||||
beim Entnehmen „Lager“).
|
||||
- Liegt eine Spule mit eigenem Tag in einer Canvas, erkennt die Bridge die Spoolman-Spule am Tag, legt sie in der
|
||||
CC2 Control dieses Druckers an (bzw. findet sie wieder) und weist sie dem Slot zu.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
1. *Einstellungen* prüfen (Drucker-IP, CC2-Control- und Spoolman-URL, ID-Offset 10000).
|
||||
2. Privaten Schlüssel einfügen oder hochladen und speichern. Er liegt dann mit Rechten 600 in
|
||||
`/data/ssh/id_key`; die Oberfläche zeigt nur Fingerprint und öffentlichen Schlüssel.
|
||||
3. **Verbindung testen**: zeigt die 4 Slots und den Host-Key-Fingerprint des Druckers. Fingerprint vergleichen und
|
||||
bestätigen. Danach wird er bei jeder Verbindung geprüft; ein geänderter Host-Key wird abgelehnt.
|
||||
1. *Einstellungen* → **Drucker**: Name, IP, SSH-Port/-Benutzer und CC2-Control-URL je Drucker; „Drucker
|
||||
hinzufügen“ für weitere. Speichern. Der Name erscheint so in Spoolman (Ort und Feld „Drucker“).
|
||||
2. Privaten Schlüssel einfügen oder hochladen und speichern. Er gilt für alle Drucker (dieselbe eingeschränkte
|
||||
`authorized_keys`-Zeile auf jedem Drucker) und liegt mit Rechten 600 in `/data/ssh/id_key`; die Oberfläche zeigt
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
(`/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
|
||||
im Slot gegenüber dem letzten Datensatz ändert.
|
||||
|
||||
**Zuordnung.** Die Bridge speichert Slot ↔ Spule selbst (`/data/state.json`). Fällt `weight` nach einer
|
||||
Bearbeitung am Display auf 1000 zurück, bleibt die Zuordnung. Aufgehoben wird sie nur, wenn
|
||||
**Zuordnung.** Die Bridge speichert Drucker/Slot ↔ Spule selbst (`/data/state.json`). Eine Spule liegt höchstens
|
||||
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),
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
**„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
|
||||
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
|
||||
@@ -131,11 +150,13 @@ Optional kann statt der Elegoo-URL ein Link auf die Spule in Spoolman auf den Ta
|
||||
```bash
|
||||
docker build --target test . # Tests (pytest) im Container
|
||||
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,
|
||||
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
|
||||
mit Host-Key-Prüfung gegen einen lokalen SSH-Server.
|
||||
|
||||
|
||||
+562
-367
File diff suppressed because it is too large.
Load diff
+30
-2
@@ -2,11 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# Extra fields the bridge keeps on loaded spools (Spoolman stores values JSON-encoded).
|
||||
FIELD_PRINTER = "drucker"
|
||||
FIELD_SLOT = "slot"
|
||||
FIELD_DEFS = {
|
||||
FIELD_PRINTER: {"name": "Drucker", "field_type": "text", "order": 100},
|
||||
FIELD_SLOT: {"name": "Slot", "field_type": "integer", "order": 101},
|
||||
}
|
||||
|
||||
|
||||
class SpoolmanError(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -42,8 +52,14 @@ class Spoolman:
|
||||
def spools(self, archived: bool = False) -> list[dict]:
|
||||
return self._request("GET", "/spool", params={"allow_archived": str(archived).lower()})
|
||||
|
||||
def set_location(self, spool_id: int, location: str) -> dict:
|
||||
return self._request("PATCH", f"/spool/{int(spool_id)}", json={"location": location})
|
||||
def update_spool(self, spool_id: int, body: dict) -> dict:
|
||||
return self._request("PATCH", f"/spool/{int(spool_id)}", json=body)
|
||||
|
||||
def fields(self) -> list[dict]:
|
||||
return self._request("GET", "/field/spool")
|
||||
|
||||
def add_field(self, key: str, params: dict) -> list[dict]:
|
||||
return self._request("POST", f"/field/spool/{key}", json=params)
|
||||
|
||||
def use(self, spool_id: int, grams: float) -> dict:
|
||||
return self._request("PUT", f"/spool/{int(spool_id)}/use", json={"use_weight": round(float(grams), 3)})
|
||||
@@ -73,6 +89,16 @@ def color_hex(spool: dict) -> str | None:
|
||||
return color if re.fullmatch(r"[0-9A-F]{6}", color) else None
|
||||
|
||||
|
||||
def extra_value(spool: dict, key: str):
|
||||
raw = (spool.get("extra") or {}).get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def summary(spool: dict) -> dict:
|
||||
fil = spool.get("filament") or {}
|
||||
return {
|
||||
@@ -91,4 +117,6 @@ def summary(spool: dict) -> dict:
|
||||
"location": spool.get("location") or "",
|
||||
"archived": bool(spool.get("archived")),
|
||||
"used_weight": spool.get("used_weight"),
|
||||
"printer": extra_value(spool, FIELD_PRINTER),
|
||||
"slot": extra_value(spool, FIELD_SLOT),
|
||||
}
|
||||
+22
-11
@@ -108,25 +108,36 @@ class KeyStore:
|
||||
"public": f"{key.get_name()} {key.get_base64()} cc2-spoolman-bridge",
|
||||
}
|
||||
|
||||
def host_key(self) -> dict | None:
|
||||
return read_json(self.host_path, None)
|
||||
def _host_path(self, pid: str) -> Path:
|
||||
if not pid.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError("invalid printer id")
|
||||
return self.dir / f"host_key_{pid}.json"
|
||||
|
||||
def pin_host_key(self, host: str, key: paramiko.PKey) -> None:
|
||||
def migrate_host_key(self, pid: str) -> None:
|
||||
"""v1 kept one host key in host_key.json; it belongs to the first printer."""
|
||||
if self.host_path.exists() and not self._host_path(pid).exists():
|
||||
os.replace(self.host_path, self._host_path(pid))
|
||||
|
||||
def host_key(self, pid: str) -> dict | None:
|
||||
return read_json(self._host_path(pid), None)
|
||||
|
||||
def pin_host_key(self, pid: str, host: str, key: paramiko.PKey) -> None:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(self.host_path, {
|
||||
atomic_write_json(self._host_path(pid), {
|
||||
"host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
|
||||
}, mode=0o600)
|
||||
|
||||
def forget_host_key(self) -> None:
|
||||
def forget_host_key(self, pid: str) -> None:
|
||||
try:
|
||||
self.host_path.unlink()
|
||||
self._host_path(pid).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class CanvasRecordReader:
|
||||
def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
|
||||
def __init__(self, keys: KeyStore, pid: str, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
|
||||
self.keys = keys
|
||||
self.pid = pid
|
||||
self.host, self.port, self.user, self.timeout = host, port, user, timeout
|
||||
self._transport: paramiko.Transport | None = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -155,7 +166,7 @@ class CanvasRecordReader:
|
||||
try:
|
||||
t.start_client(timeout=self.timeout)
|
||||
server_key = t.get_remote_server_key()
|
||||
pinned = self.keys.host_key()
|
||||
pinned = self.keys.host_key(self.pid)
|
||||
if pinned is None:
|
||||
if not accept_unknown:
|
||||
raise HostKeyUnknown(server_key)
|
||||
@@ -222,11 +233,11 @@ class CanvasRecordReader:
|
||||
try:
|
||||
server_key = self._connect(accept_unknown=True)
|
||||
raw = self._exec()
|
||||
except HostKeyMismatch:
|
||||
raise
|
||||
except (paramiko.SSHException, OSError, EOFError) as e:
|
||||
raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
|
||||
finally:
|
||||
self._close()
|
||||
pinned = self.keys.host_key()
|
||||
pinned = self.keys.host_key(self.pid)
|
||||
return {
|
||||
"host_key": {"type": server_key.get_name(), "key": server_key.get_base64(),
|
||||
"fingerprint": fingerprint(server_key)},
|
||||
|
||||
+172
-67
@@ -4,11 +4,15 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from pathlib import Path
|
||||
|
||||
V1_LOCATION_SLOT = "CC2 Slot {n}"
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -47,64 +51,6 @@ def read_json(path: Path, default):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
printer_host: str = "10.10.2.202"
|
||||
ssh_port: int = 22
|
||||
ssh_user: str = "root"
|
||||
cc2_url: str = "http://10.10.2.202:8081"
|
||||
spoolman_url: str = "http://10.10.6.10:7912"
|
||||
id_offset: int = 10000
|
||||
poll_interval: float = 5.0
|
||||
location_slot: str = "CC2 Slot {n}"
|
||||
location_storage: str = "Lager"
|
||||
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
|
||||
settle_seconds: float = 60.0 # `used` must be unchanged this long before booking
|
||||
dry_run: bool = True
|
||||
|
||||
# environment variable -> field (only used as defaults before settings.json exists)
|
||||
ENV = {
|
||||
"PRINTER_HOST": "printer_host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
|
||||
"CC2_URL": "cc2_url", "SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset",
|
||||
"POLL_INTERVAL": "poll_interval", "DRY_RUN": "dry_run",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Settings":
|
||||
s = cls()
|
||||
s.update(d)
|
||||
return s
|
||||
|
||||
def update(self, d: dict) -> None:
|
||||
for f in fields(self):
|
||||
if f.name in d and d[f.name] is not None:
|
||||
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
|
||||
self.validate()
|
||||
|
||||
def validate(self) -> None:
|
||||
if not 1 <= self.ssh_port <= 65535:
|
||||
raise ValueError("SSH-Port ungültig")
|
||||
if not 0 <= self.id_offset <= 65000:
|
||||
raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen")
|
||||
if not 1 <= self.poll_interval <= 300:
|
||||
raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen")
|
||||
if "{n}" not in self.location_slot:
|
||||
raise ValueError("Ortsname für Slots muss {n} enthalten")
|
||||
for name in ("cc2_url", "spoolman_url"):
|
||||
v = getattr(self, name)
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError(f"{name} muss mit http:// oder https:// beginnen")
|
||||
setattr(self, name, v.rstrip("/"))
|
||||
if not self.location_storage.strip():
|
||||
raise ValueError("Ortsname für das Lager fehlt")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def slot_location(self, slot: int) -> str:
|
||||
return self.location_slot.replace("{n}", str(slot + 1))
|
||||
|
||||
|
||||
def _coerce(typ, value, name):
|
||||
typ = typ if isinstance(typ, str) else typ.__name__
|
||||
try:
|
||||
@@ -121,6 +67,138 @@ def _coerce(typ, value, name):
|
||||
raise ValueError(f"Ungültiger Wert für {name}") from None
|
||||
|
||||
|
||||
def new_printer_id() -> str:
|
||||
return "p" + secrets.token_hex(3)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrinterConfig:
|
||||
id: str = ""
|
||||
name: str = "CC2"
|
||||
host: str = "10.10.2.202"
|
||||
ssh_port: int = 22
|
||||
ssh_user: str = "root"
|
||||
cc2_url: str = "http://10.10.2.202:8081"
|
||||
enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "PrinterConfig":
|
||||
p = cls()
|
||||
for f in fields(p):
|
||||
if f.name in d and d[f.name] is not None:
|
||||
setattr(p, f.name, _coerce(f.type, d[f.name], f.name))
|
||||
if not p.id:
|
||||
p.id = new_printer_id()
|
||||
p.validate()
|
||||
return p
|
||||
|
||||
def validate(self) -> None:
|
||||
if not re.fullmatch(r"[a-z0-9_-]{1,32}", self.id):
|
||||
raise ValueError("Ungültige Drucker-ID")
|
||||
if not self.name.strip():
|
||||
raise ValueError("Druckername fehlt")
|
||||
if len(self.name) > 60:
|
||||
raise ValueError("Druckername ist zu lang (max. 60 Zeichen)")
|
||||
if not self.host.strip():
|
||||
raise ValueError(f"{self.name}: Drucker-IP fehlt")
|
||||
if not 1 <= self.ssh_port <= 65535:
|
||||
raise ValueError(f"{self.name}: SSH-Port ungültig")
|
||||
if not self.cc2_url.startswith(("http://", "https://")):
|
||||
raise ValueError(f"{self.name}: CC2-Control-URL muss mit http:// beginnen")
|
||||
self.cc2_url = self.cc2_url.rstrip("/")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
spoolman_url: str = "http://10.10.6.10:7912"
|
||||
id_offset: int = 10000
|
||||
poll_interval: float = 5.0
|
||||
location_slot: str = "{printer} Slot {n}"
|
||||
location_storage: str = "Lager"
|
||||
spoolman_fields: bool = True # also write the extra fields "drucker" and "slot"
|
||||
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
|
||||
settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking
|
||||
dry_run: bool = True
|
||||
printers: list[PrinterConfig] = field(default_factory=list)
|
||||
|
||||
# environment variable -> field (only used as defaults before settings.json exists)
|
||||
ENV = {
|
||||
"SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset", "POLL_INTERVAL": "poll_interval",
|
||||
"DRY_RUN": "dry_run",
|
||||
}
|
||||
PRINTER_ENV = {
|
||||
"PRINTER_NAME": "name", "PRINTER_HOST": "host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
|
||||
"CC2_URL": "cc2_url",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Settings":
|
||||
s = cls()
|
||||
s.update(migrate_settings(d))
|
||||
return s
|
||||
|
||||
def update(self, d: dict) -> None:
|
||||
for f in fields(self):
|
||||
if f.name == "printers" or f.name not in d or d[f.name] is None:
|
||||
continue
|
||||
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
|
||||
if d.get("printers") is not None:
|
||||
self.printers = [p if isinstance(p, PrinterConfig) else PrinterConfig.from_dict(p) for p in d["printers"]]
|
||||
self.validate()
|
||||
|
||||
def validate(self) -> None:
|
||||
if not 0 <= self.id_offset <= 65000:
|
||||
raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen")
|
||||
if not 1 <= self.poll_interval <= 300:
|
||||
raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen")
|
||||
if "{n}" not in self.location_slot:
|
||||
raise ValueError("Ortsname für Slots muss {n} enthalten")
|
||||
if len(self.printers) > 1 and "{printer}" not in self.location_slot:
|
||||
raise ValueError("Bei mehreren Druckern muss der Ortsname {printer} enthalten")
|
||||
if not self.spoolman_url.startswith(("http://", "https://")):
|
||||
raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen")
|
||||
self.spoolman_url = self.spoolman_url.rstrip("/")
|
||||
if not self.location_storage.strip():
|
||||
raise ValueError("Ortsname für das Lager fehlt")
|
||||
ids = [p.id for p in self.printers]
|
||||
names = [p.name.strip().lower() for p in self.printers]
|
||||
if len(set(ids)) != len(ids) or len(set(names)) != len(names):
|
||||
raise ValueError("Druckernamen müssen eindeutig sein")
|
||||
if len(self.printers) > 8:
|
||||
raise ValueError("Höchstens 8 Drucker")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
d["printers"] = [p.to_dict() for p in self.printers]
|
||||
return d
|
||||
|
||||
def printer(self, pid: str) -> PrinterConfig | None:
|
||||
return next((p for p in self.printers if p.id == pid), None)
|
||||
|
||||
def slot_location(self, printer: PrinterConfig | str, slot: int) -> str:
|
||||
name = printer.name if isinstance(printer, PrinterConfig) else printer
|
||||
return self.location_slot.replace("{printer}", name).replace("{n}", str(slot + 1))
|
||||
|
||||
|
||||
def migrate_settings(d: dict) -> dict:
|
||||
"""v1 had one printer at the top level; v2 keeps a list of printers."""
|
||||
d = dict(d)
|
||||
if "printers" not in d and "printer_host" in d:
|
||||
d["printers"] = [{
|
||||
"id": "p1", "name": d.get("printer_name") or "CC2", "host": d.get("printer_host"),
|
||||
"ssh_port": d.get("ssh_port", 22), "ssh_user": d.get("ssh_user", "root"),
|
||||
"cc2_url": d.get("cc2_url"), "enabled": True,
|
||||
}]
|
||||
if d.get("location_slot") == V1_LOCATION_SLOT:
|
||||
d["location_slot"] = Settings.location_slot
|
||||
for key in ("printer_host", "printer_name", "ssh_port", "ssh_user", "cc2_url"):
|
||||
d.pop(key, None)
|
||||
return d
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
def __init__(self, data_dir: Path, env: dict | None = None):
|
||||
self.path = data_dir / "settings.json"
|
||||
@@ -129,9 +207,13 @@ class SettingsStore:
|
||||
if stored is None:
|
||||
env = os.environ if env is None else env
|
||||
defaults = {f: env[k] for k, f in Settings.ENV.items() if env.get(k)}
|
||||
printer = {f: env[k] for k, f in Settings.PRINTER_ENV.items() if env.get(k)}
|
||||
defaults["printers"] = [{"id": "p1", **printer}]
|
||||
self.settings = Settings.from_dict(defaults)
|
||||
else:
|
||||
self.settings = Settings.from_dict(stored)
|
||||
if "printer_host" in stored: # write the migrated form once
|
||||
atomic_write_json(self.path, self.settings.to_dict())
|
||||
|
||||
def get(self) -> Settings:
|
||||
with self._lock:
|
||||
@@ -146,16 +228,39 @@ class SettingsStore:
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Bridge state: slot mappings, booked consumption, last record."""
|
||||
"""Bridge state: per printer slot mappings, booked consumption and last record; Spoolman locations."""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
def __init__(self, data_dir: Path, first_printer: str = "p1"):
|
||||
self.path = data_dir / "state.json"
|
||||
self.data = read_json(self.path, {})
|
||||
self.data.setdefault("version", 1)
|
||||
self.data.setdefault("slots", {})
|
||||
self.data.setdefault("booked", {})
|
||||
self.data.setdefault("locations", {})
|
||||
self.data.setdefault("last_record", None)
|
||||
self.data = migrate_state(read_json(self.path, {}), first_printer)
|
||||
|
||||
def printer(self, pid: str) -> dict:
|
||||
p = self.data["printers"].setdefault(pid, {})
|
||||
p.setdefault("slots", {})
|
||||
p.setdefault("booked", {})
|
||||
p.setdefault("last_record", None)
|
||||
p.setdefault("scans", [])
|
||||
return p
|
||||
|
||||
def save(self) -> None:
|
||||
atomic_write_json(self.path, self.data)
|
||||
|
||||
|
||||
def migrate_state(data: dict, first_printer: str) -> dict:
|
||||
data.setdefault("printers", {})
|
||||
if data.get("version", 1) < 2:
|
||||
if any(k in data for k in ("slots", "booked", "last_record", "scans")):
|
||||
data["printers"][first_printer] = {
|
||||
"slots": data.pop("slots", {}), "booked": data.pop("booked", {}),
|
||||
"last_record": data.pop("last_record", None), "scans": data.pop("scans", []),
|
||||
}
|
||||
applied = {}
|
||||
for sid, loc in (data.pop("locations", None) or {}).items():
|
||||
if loc.get("done"):
|
||||
applied[sid] = {"location": loc["done"], "printer": None, "slot": None}
|
||||
data["applied"] = applied
|
||||
data["written_locations"] = sorted({a["location"] for a in applied.values()})
|
||||
data["version"] = 2
|
||||
data.setdefault("applied", {})
|
||||
data.setdefault("written_locations", [])
|
||||
return data
|
||||
+94
-55
@@ -1,38 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Einstellungen{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Einstellungen</h2>
|
||||
<form id="settings" class="panel" onsubmit="saveSettings(event)">
|
||||
<h2>Drucker</h2>
|
||||
<div id="printers"></div>
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<button onclick="addPrinter()">Drucker hinzufügen</button>
|
||||
<button class="primary" onclick="saveSettings()">Speichern</button>
|
||||
<span id="saved1" class="small muted"></span>
|
||||
</div>
|
||||
<p class="small muted">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p>
|
||||
|
||||
<h2>Allgemein</h2>
|
||||
<form id="settings" class="panel" onsubmit="event.preventDefault(); saveSettings()">
|
||||
<div class="row">
|
||||
<div class="field"><label for="printer_host">Drucker-IP</label><input id="printer_host" required></div>
|
||||
<div class="field"><label for="ssh_port">SSH-Port</label><input id="ssh_port" type="number" min="1" max="65535"></div>
|
||||
<div class="field"><label for="ssh_user">SSH-Benutzer</label><input id="ssh_user"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="cc2_url">CC2-Control-URL</label><input id="cc2_url" required></div>
|
||||
<div class="field"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required></div>
|
||||
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
|
||||
<div class="field"><label for="poll_interval">Abfrage-Intervall (s)</label><input id="poll_interval" type="number" step="0.5" min="1" max="300"></div>
|
||||
<div class="field"><label for="empty_grace">Slot leer nach (s)</label><input id="empty_grace" type="number" min="0"></div>
|
||||
<div class="field"><label for="settle_seconds">Buchen nach Ruhe (s)</label><input id="settle_seconds" type="number" min="0"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="location_slot">Ort im Slot (<code>{n}</code> = 1–4)</label><input id="location_slot" required></div>
|
||||
<div class="field"><label for="location_slot">Ort im Slot (<code>{printer}</code> = Druckername, <code>{n}</code> = 1–4)</label><input id="location_slot" required></div>
|
||||
<div class="field"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" id="dry_run" style="width:auto"> Dry-Run (Schreibaktionen nur protokollieren)</label>
|
||||
<label style="color:var(--text)"><input type="checkbox" id="spoolman_fields" style="width:auto"> Zusatzfelder „Drucker“ und „Slot“ in Spoolman pflegen</label>
|
||||
</div>
|
||||
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved" class="small muted"></span></div>
|
||||
<div class="field">
|
||||
<label style="color:var(--text)"><input type="checkbox" id="dry_run" style="width:auto"> Dry-Run (Schreibaktionen nur protokollieren)</label>
|
||||
</div>
|
||||
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved2" class="small muted"></span></div>
|
||||
</form>
|
||||
|
||||
<h2>SSH-Schlüssel für den Drucker</h2>
|
||||
<h2>SSH-Schlüssel für die Drucker</h2>
|
||||
<div class="panel">
|
||||
<div id="keyinfo" class="field muted">Lade …</div>
|
||||
<div class="field">
|
||||
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen</label>
|
||||
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen – gilt für alle Drucker</label>
|
||||
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -42,39 +48,75 @@
|
||||
</div>
|
||||
<p class="small muted">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p>
|
||||
</div>
|
||||
|
||||
<h2>Verbindung testen</h2>
|
||||
<div class="panel">
|
||||
<div id="hostkey" class="field muted"></div>
|
||||
<div class="row"><button class="primary" onclick="testSSH()">Verbindung testen</button>
|
||||
<button class="danger" onclick="forgetHost()">Host-Key vergessen</button></div>
|
||||
<div id="testresult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const FIELDS = ['printer_host', 'ssh_port', 'ssh_user', 'cc2_url', 'spoolman_url', 'id_offset', 'poll_interval',
|
||||
'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
|
||||
let current = {};
|
||||
const FIELDS = ['spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
|
||||
const PFIELDS = [['name', 'Name'], ['host', 'Drucker-IP'], ['ssh_port', 'SSH-Port'], ['ssh_user', 'SSH-Benutzer'], ['cc2_url', 'CC2-Control-URL']];
|
||||
let current = {}, printers = [], hostKeys = {}, saved = new Set();
|
||||
|
||||
function printerCard(p, i) {
|
||||
const hk = hostKeys[p.id];
|
||||
const isSaved = saved.has(p.id);
|
||||
return `<div class="panel" style="margin-bottom:12px" id="pc${i}">
|
||||
<div class="row">${PFIELDS.map(([f, l]) => `<div class="field"><label>${l}</label>
|
||||
<input data-i="${i}" data-f="${f}" value="${esc(p[f])}" ${f === 'ssh_port' ? 'type="number" min="1" max="65535"' : ''}></div>`).join('')}</div>
|
||||
<div class="row" style="align-items:center">
|
||||
<label style="margin:0;color:var(--text);flex:0 0 auto"><input type="checkbox" data-i="${i}" data-f="enabled" ${p.enabled ? 'checked' : ''} style="width:auto"> Abfrage aktiv</label>
|
||||
<span class="small" style="flex:1 1 200px">${isSaved ? (hk ? `Host-Key <code>${esc(hk.fingerprint)}</code>` : '<span class="tag warn">Host-Key nicht bestätigt</span>') : '<span class="tag">noch nicht gespeichert</span>'}</span>
|
||||
${isSaved ? `<button class="primary" onclick="testSSH(${i})">Verbindung testen</button>
|
||||
<button onclick="forgetHost(${i})">Host-Key vergessen</button>` : ''}
|
||||
<button class="danger" onclick="removePrinter(${i})">Entfernen</button>
|
||||
</div>
|
||||
<div id="test${i}" style="margin-top:10px"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrinters() {
|
||||
$('printers').innerHTML = printers.map(printerCard).join('') || '<div class="panel muted">Kein Drucker eingerichtet.</div>';
|
||||
document.querySelectorAll('#printers input[data-f]').forEach((el) => el.addEventListener('input', () => {
|
||||
const p = printers[el.dataset.i];
|
||||
p[el.dataset.f] = el.type === 'checkbox' ? el.checked : el.value;
|
||||
}));
|
||||
}
|
||||
|
||||
function addPrinter() {
|
||||
printers.push({ id: '', name: '', host: '', ssh_port: 22, ssh_user: 'root', cc2_url: 'http://', enabled: true });
|
||||
renderPrinters();
|
||||
}
|
||||
|
||||
function removePrinter(i) {
|
||||
const p = printers[i];
|
||||
if (!confirm(`Drucker „${p.name || 'neu'}“ entfernen?\n\nSeine Spulen werden nach dem Speichern in Spoolman ins Lager gebucht.`)) return;
|
||||
printers.splice(i, 1);
|
||||
renderPrinters();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
current = await api('GET', '/api/settings');
|
||||
for (const f of FIELDS) $(f).value = current[f];
|
||||
$('dry_run').checked = current.dry_run;
|
||||
$('spoolman_fields').checked = current.spoolman_fields;
|
||||
printers = current.printers.map((p) => ({ ...p }));
|
||||
saved = new Set(printers.map((p) => p.id));
|
||||
setMode(current.dry_run);
|
||||
}
|
||||
|
||||
async function saveSettings(ev) {
|
||||
ev.preventDefault();
|
||||
const body = {};
|
||||
async function saveSettings() {
|
||||
const body = { printers };
|
||||
for (const f of FIELDS) body[f] = $(f).value;
|
||||
body.dry_run = $('dry_run').checked;
|
||||
body.spoolman_fields = $('spoolman_fields').checked;
|
||||
if (current.dry_run && !body.dry_run && !confirm(
|
||||
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, setzt Orte in Spoolman und bucht Verbrauch.')) return;
|
||||
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, pflegt Ort und Zusatzfelder in Spoolman und bucht Verbrauch.')) return;
|
||||
try {
|
||||
current = await api('POST', '/api/settings', body);
|
||||
printers = current.printers.map((p) => ({ ...p }));
|
||||
saved = new Set(printers.map((p) => p.id));
|
||||
setMode(current.dry_run);
|
||||
$('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
|
||||
const t = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
|
||||
$('saved1').textContent = t; $('saved2').textContent = t;
|
||||
await loadSSH();
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
@@ -82,21 +124,19 @@ function renderKey(info) {
|
||||
const k = info.key;
|
||||
if (!k) { $('keyinfo').innerHTML = '<span class="tag warn">Kein Schlüssel hinterlegt</span>'; return; }
|
||||
if (k.error) { $('keyinfo').innerHTML = `<span class="tag err">${esc(k.error)}</span>`; return; }
|
||||
const host = $('printer_host').value || '10.10.2.202';
|
||||
$('keyinfo').innerHTML = `<dl class="kv"><dt>Typ</dt><dd>${esc(k.type)}</dd><dt>Fingerprint</dt><dd><code>${esc(k.fingerprint)}</code></dd></dl>
|
||||
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf dem Drucker (öffentlicher Schlüssel, eingeschränkt):</p>
|
||||
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf jedem Drucker (öffentlicher Schlüssel, eingeschränkt):</p>
|
||||
<pre id="authline">command="cat /opt/usr/canvas_record_info.json",from="10.10.6.10",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ${esc(k.public)}</pre>
|
||||
<button style="margin-top:6px" onclick="copyText($('authline').textContent)">Kopieren</button>`;
|
||||
}
|
||||
|
||||
function renderHost(hk) {
|
||||
$('hostkey').innerHTML = hk
|
||||
? `<dl class="kv"><dt>Bestätigter Host-Key</dt><dd><code>${esc(hk.fingerprint)}</code> <span class="muted small">(${esc(hk.type)}, ${esc(hk.host)})</span></dd></dl>`
|
||||
: '<span class="tag warn">Host-Key des Druckers noch nicht bestätigt</span>';
|
||||
async function loadSSH() {
|
||||
const info = await api('GET', '/api/ssh');
|
||||
hostKeys = info.host_keys || {};
|
||||
renderKey(info);
|
||||
renderPrinters();
|
||||
}
|
||||
|
||||
async function loadSSH() { const info = await api('GET', '/api/ssh'); renderKey(info); renderHost(info.host_key); }
|
||||
|
||||
$('keyfile').addEventListener('change', async (ev) => {
|
||||
const f = ev.target.files[0];
|
||||
if (f) $('keytext').value = await f.text();
|
||||
@@ -117,19 +157,18 @@ async function deleteKey() {
|
||||
await api('DELETE', '/api/ssh/key'); loadSSH();
|
||||
}
|
||||
|
||||
async function forgetHost() {
|
||||
if (!confirm('Gespeicherten Host-Key vergessen? Danach muss er beim nächsten Test neu bestätigt werden.')) return;
|
||||
await api('DELETE', '/api/ssh/hostkey'); loadSSH();
|
||||
async function forgetHost(i) {
|
||||
const p = printers[i];
|
||||
if (!confirm(`Host-Key von ${p.name} vergessen? Danach muss er beim nächsten Test neu bestätigt werden.`)) return;
|
||||
await api('DELETE', `/api/printers/${p.id}/ssh/hostkey`); loadSSH();
|
||||
}
|
||||
|
||||
async function testSSH() {
|
||||
$('testresult').innerHTML = '<span class="muted">Verbinde …</span>';
|
||||
async function testSSH(i) {
|
||||
const p = printers[i], out = $('test' + i);
|
||||
out.innerHTML = '<span class="muted">Verbinde …</span>';
|
||||
let r;
|
||||
try { r = await api('POST', '/api/ssh/test'); } catch (e) { $('testresult').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
if (!r.ok) {
|
||||
$('testresult').innerHTML = `<div class="banner err">${esc(r.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
try { r = await api('POST', `/api/printers/${p.id}/ssh/test`); } catch (e) { out.innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
if (!r.ok) { out.innerHTML = `<div class="banner err">${esc(r.error)}</div>`; return; }
|
||||
const rows = r.slots.map((s) => {
|
||||
const ch = s.channel || {};
|
||||
const c = (ch.color || '').replace(/^0x/i, '');
|
||||
@@ -138,16 +177,16 @@ async function testSSH() {
|
||||
}).join('');
|
||||
let host = `<div class="banner ok">Verbunden. Host-Key <code>${esc(r.host_key.fingerprint)}</code> stimmt mit dem gespeicherten überein.</div>`;
|
||||
if (!r.host_key_pinned) {
|
||||
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}).
|
||||
Mit <code>ssh-keygen -lf /etc/dropbear/…</code> bzw. <code>ssh-keyscan</code> vergleichen und bestätigen.
|
||||
<button class="primary" onclick="acceptHost('${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
|
||||
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}). Vergleichen und bestätigen.
|
||||
<button class="primary" onclick="acceptHost(${i}, '${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
|
||||
}
|
||||
$('testresult').innerHTML = host + `<div class="table-wrap"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
out.innerHTML = host + `<div class="table-wrap"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
async function acceptHost(fp) {
|
||||
if (!confirm('Host-Key ' + fp + ' als vertrauenswürdig speichern?')) return;
|
||||
try { const r = await api('POST', '/api/ssh/hostkey', { fingerprint: fp }); renderHost(r.host_key); testSSH(); }
|
||||
async function acceptHost(i, fp) {
|
||||
const p = printers[i];
|
||||
if (!confirm(`Host-Key ${fp} für ${p.name} als vertrauenswürdig speichern?`)) return;
|
||||
try { await api('POST', `/api/printers/${p.id}/ssh/hostkey`, { fingerprint: fp }); await loadSSH(); testSSH(i); }
|
||||
catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
|
||||
+30
-17
@@ -2,8 +2,7 @@
|
||||
{% block title %}CC2 Bridge – Status{% endblock %}
|
||||
{% block content %}
|
||||
<div id="banners"></div>
|
||||
<h2>Canvas-Slots</h2>
|
||||
<div class="grid" id="slots"><div class="panel muted">Lade …</div></div>
|
||||
<div id="printers"><div class="panel muted">Lade …</div></div>
|
||||
<p class="small muted" id="lastpoll"></p>
|
||||
<h2>Protokoll</h2>
|
||||
<div class="panel table-wrap">
|
||||
@@ -21,7 +20,7 @@ function colorOf(ch) {
|
||||
return /^[0-9a-f]{6}$/i.test(c) ? c : null;
|
||||
}
|
||||
|
||||
function slotCard(s) {
|
||||
function slotCard(p, s) {
|
||||
const ch = s.record.channel || {};
|
||||
const tray = TRAY[s.tray_status];
|
||||
const col = colorOf(ch);
|
||||
@@ -37,9 +36,11 @@ function slotCard(s) {
|
||||
: (m.cc2 ? '<span class="tag warn">nicht zugewiesen</span>' : '<span class="tag warn">noch nicht angelegt</span>');
|
||||
}
|
||||
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: ${esc(s.cc2_question)}</span>`;
|
||||
const orphan = !m && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2);
|
||||
const lb = s.last_booking;
|
||||
return `<div class="panel">
|
||||
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
|
||||
<p class="small muted" style="margin:-4px 0 8px">Ort in Spoolman: ${esc(s.location)}</p>
|
||||
<dl class="kv">
|
||||
<dt>Tag</dt><dd>${tag}</dd>
|
||||
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd>
|
||||
@@ -48,25 +49,30 @@ function slotCard(s) {
|
||||
<dt>CC2 Control</dt><dd>${cc ? esc(cc.name) + ` <span class="small muted">Rest ${Number(cc.remaining).toFixed(1)} g</span><br>` : ''}${assign}</dd>
|
||||
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` : '<span class="muted">–</span>'}</dd>
|
||||
</dl>
|
||||
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steckt noch, ist aber keinem Slot zugeordnet
|
||||
(z. B. nach „Take out“ in CC2 Control). Verbrauch wird so nicht gezählt – zum Zuordnen die Spule neu einlegen und scannen.</p>` : ''}
|
||||
${m ? `<div class="row" style="margin-top:10px">
|
||||
<button onclick="slotAction(${s.slot}, 'reassign')">Zuordnung erneut senden</button>
|
||||
<button class="danger" onclick="slotAction(${s.slot}, 'release')">Aufheben</button></div>` : ''}
|
||||
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Zuordnung erneut senden</button>
|
||||
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function slotAction(slot, action) {
|
||||
let NAMES = {};
|
||||
async function slotAction(pid, slot, action) {
|
||||
const name = NAMES[pid] || pid;
|
||||
const text = action === 'release'
|
||||
? `Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
|
||||
: `Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
|
||||
? `${name}: Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
|
||||
: `${name}: Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
|
||||
if (!confirm(text)) return;
|
||||
try { await api('POST', `/api/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
|
||||
try { await api('POST', `/api/printers/${pid}/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
|
||||
catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function enableCC2() {
|
||||
if (!confirm('Spulenverwaltung in CC2 Control einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.')) return;
|
||||
async function enableCC2(pid) {
|
||||
const name = NAMES[pid] || pid;
|
||||
if (!confirm(`Spulenverwaltung in CC2 Control von ${name} einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.`)) return;
|
||||
try {
|
||||
const r = await api('POST', '/api/cc2/enable', { confirm: true });
|
||||
const r = await api('POST', `/api/printers/${pid}/cc2/enable`, { confirm: true });
|
||||
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
|
||||
setTimeout(load, 1500);
|
||||
} catch (e) { toast(e.message); }
|
||||
@@ -79,12 +85,19 @@ async function load() {
|
||||
const b = [];
|
||||
if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>');
|
||||
if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>');
|
||||
else if (!st.host_key) b.push('<div class="banner warn">Host-Key des Druckers noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
|
||||
if (st.cc2_enabled === false) b.push('<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2()">Einschalten …</button></div>');
|
||||
if (st.job_active) b.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
|
||||
for (const [k, v] of Object.entries(st.errors || {})) if (v) b.push(`<div class="banner err">${esc(v)}</div>`);
|
||||
if (st.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</div>`);
|
||||
$('banners').innerHTML = b.join('');
|
||||
if (st.slots) $('slots').innerHTML = st.slots.map(slotCard).join('');
|
||||
NAMES = Object.fromEntries((st.printers || []).map((p) => [p.id, p.name]));
|
||||
if (st.printers) $('printers').innerHTML = st.printers.map((p) => {
|
||||
const pb = [];
|
||||
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>');
|
||||
else if (st.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
|
||||
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`);
|
||||
if (p.job_active) pb.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
|
||||
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`);
|
||||
return `<h2>${esc(p.name)} <span class="small muted" style="font-weight:400">${esc(p.host)}</span></h2>${pb.join('')}
|
||||
<div class="grid">${p.slots.map((s) => slotCard(p, s)).join('')}</div>`;
|
||||
}).join('') || '<div class="panel muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>';
|
||||
$('lastpoll').textContent = st.last_poll ? 'Letzte Abfrage: ' + fmtTime(st.last_poll) : 'Noch keine Abfrage';
|
||||
$('log').innerHTML = (st.log || []).map((e) => {
|
||||
const l = LEVEL[e.level] || [e.level, ''];
|
||||
|
||||
+48
-33
@@ -36,7 +36,7 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
keys = bridge.keys if bridge else KeyStore(data_dir)
|
||||
bridge = bridge or Bridge(data_dir, settings, keys)
|
||||
stop = threading.Event()
|
||||
tested_host_keys: dict[str, object] = {} # fingerprint -> key from the last connection test
|
||||
tested_host_keys: dict[str, dict] = {} # printer id -> host key from its last connection test
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
@@ -105,20 +105,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
def status():
|
||||
return {**bridge.snapshot, "dry_run": settings.get().dry_run, "log": bridge.log.recent(200)}
|
||||
|
||||
@app.post("/api/cc2/enable")
|
||||
async def cc2_enable(request: Request):
|
||||
def printer_or_404(pid: str):
|
||||
p = settings.get().printer(pid)
|
||||
if p is None:
|
||||
raise HTTPException(404, "Unbekannter Drucker")
|
||||
return p
|
||||
|
||||
def close_readers():
|
||||
for w in list(bridge.workers.values()):
|
||||
w.reader.close()
|
||||
|
||||
@app.post("/api/printers/{pid}/cc2/enable")
|
||||
async def cc2_enable(pid: str, request: Request):
|
||||
printer_or_404(pid)
|
||||
body = await _json(request)
|
||||
if body.get("confirm") is not True:
|
||||
raise HTTPException(400, "Bestätigung fehlt")
|
||||
bridge.commands.put(("enable", None))
|
||||
bridge.commands.put(("enable", pid, None))
|
||||
bridge.wake.set()
|
||||
return {"ok": True, "dry_run": settings.get().dry_run}
|
||||
|
||||
@app.post("/api/slots/{slot}/{action}")
|
||||
def slot_action(slot: int, action: str):
|
||||
@app.post("/api/printers/{pid}/slots/{slot}/{action}")
|
||||
def slot_action(pid: str, slot: int, action: str):
|
||||
printer_or_404(pid)
|
||||
if slot not in range(4) or action not in ("reassign", "release"):
|
||||
raise HTTPException(404)
|
||||
bridge.commands.put((action, slot))
|
||||
bridge.commands.put((action, pid, slot))
|
||||
bridge.wake.set()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -131,11 +143,14 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
@app.post("/api/settings")
|
||||
async def post_settings(request: Request):
|
||||
body = await _json(request)
|
||||
before = {p.id for p in settings.get().printers}
|
||||
allowed = set(settings.get().to_dict())
|
||||
try:
|
||||
s = settings.save({k: v for k, v in body.items() if k in allowed})
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
for pid in before - {p.id for p in s.printers}:
|
||||
keys.forget_host_key(pid)
|
||||
bridge.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
|
||||
bridge.wake.set()
|
||||
return s.to_dict()
|
||||
@@ -144,7 +159,8 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
|
||||
@app.get("/api/ssh")
|
||||
def ssh_info():
|
||||
return {"key": keys.key_info(), "host_key": keys.host_key()}
|
||||
return {"key": keys.key_info(),
|
||||
"host_keys": {p.id: keys.host_key(p.id) for p in settings.get().printers}}
|
||||
|
||||
@app.post("/api/ssh/key")
|
||||
async def ssh_key(request: Request):
|
||||
@@ -157,22 +173,20 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
except SSHError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
bridge.log.add("info", "SSH-Schlüssel gespeichert")
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
close_readers()
|
||||
return {"key": keys.key_info()}
|
||||
|
||||
@app.delete("/api/ssh/key")
|
||||
def ssh_key_delete():
|
||||
keys.delete_key()
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
close_readers()
|
||||
bridge.log.add("info", "SSH-Schlüssel gelöscht")
|
||||
return {"key": None}
|
||||
|
||||
@app.post("/api/ssh/test")
|
||||
def ssh_test():
|
||||
s = settings.get()
|
||||
reader = CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user)
|
||||
@app.post("/api/printers/{pid}/ssh/test")
|
||||
def ssh_test(pid: str):
|
||||
p = printer_or_404(pid)
|
||||
reader = CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user)
|
||||
try:
|
||||
result = reader.test()
|
||||
except HostKeyMismatch as e:
|
||||
@@ -182,31 +196,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
return {"ok": False, "error": str(e)}
|
||||
hk = result["host_key"]
|
||||
if not result["host_key_pinned"]:
|
||||
tested_host_keys.clear()
|
||||
tested_host_keys[hk["fingerprint"]] = hk
|
||||
slots = tagcodec.parse_record(result["record"], s.id_offset)
|
||||
tested_host_keys.pop(pid, None)
|
||||
tested_host_keys[pid] = hk
|
||||
slots = tagcodec.parse_record(result["record"], settings.get().id_offset)
|
||||
return {"ok": True, "host_key": {"fingerprint": hk["fingerprint"], "type": hk["type"]},
|
||||
"host_key_pinned": result["host_key_pinned"], "slots": slots}
|
||||
|
||||
@app.post("/api/ssh/hostkey")
|
||||
async def ssh_hostkey(request: Request):
|
||||
@app.post("/api/printers/{pid}/ssh/hostkey")
|
||||
async def ssh_hostkey(pid: str, request: Request):
|
||||
p = printer_or_404(pid)
|
||||
body = await _json(request)
|
||||
hk = tested_host_keys.get(body.get("fingerprint"))
|
||||
if not hk:
|
||||
hk = tested_host_keys.get(pid)
|
||||
if not hk or hk["fingerprint"] != body.get("fingerprint"):
|
||||
raise HTTPException(400, "Bitte zuerst „Verbindung testen“ und dann diesen Fingerprint bestätigen")
|
||||
key = paramiko.PKey.from_type_string(hk["type"], base64.b64decode(hk["key"]))
|
||||
keys.pin_host_key(settings.get().printer_host, key)
|
||||
tested_host_keys.clear()
|
||||
bridge.log.add("info", f"Host-Key des Druckers bestätigt ({hk['fingerprint']})")
|
||||
keys.pin_host_key(pid, p.host, key)
|
||||
tested_host_keys.pop(pid, None)
|
||||
bridge.log.add("info", f"{p.name}: Host-Key bestätigt ({hk['fingerprint']})")
|
||||
bridge.wake.set()
|
||||
return {"host_key": keys.host_key()}
|
||||
return {"host_key": keys.host_key(pid)}
|
||||
|
||||
@app.delete("/api/ssh/hostkey")
|
||||
def ssh_hostkey_delete():
|
||||
keys.forget_host_key()
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
bridge.log.add("info", "Gespeicherter Host-Key des Druckers gelöscht")
|
||||
@app.delete("/api/printers/{pid}/ssh/hostkey")
|
||||
def ssh_hostkey_delete(pid: str):
|
||||
p = printer_or_404(pid)
|
||||
keys.forget_host_key(pid)
|
||||
close_readers()
|
||||
bridge.log.add("info", f"{p.name}: gespeicherter Host-Key gelöscht")
|
||||
return {"host_key": None}
|
||||
|
||||
# ---------- tag generator ----------
|
||||
|
||||
+34
-5
@@ -113,12 +113,13 @@ class FakeSpoolman:
|
||||
self.calls: list[tuple] = []
|
||||
self.fail = 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):
|
||||
self.db[sid] = {
|
||||
"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,
|
||||
"location": "", "archived": False,
|
||||
"location": "", "archived": False, "extra": {},
|
||||
"filament": {"id": sid, "name": f"{material} {color}", "material": material, "color_hex": color,
|
||||
"diameter": 1.75, "density": 1.24, "weight": 1000.0, "vendor": {"name": vendor}},
|
||||
**extra,
|
||||
@@ -139,11 +140,39 @@ class FakeSpoolman:
|
||||
self._check()
|
||||
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.calls.append(("location", sid, location))
|
||||
self.db[sid]["location"] = location
|
||||
return self.db[sid]
|
||||
if sid not in self.db:
|
||||
raise SpoolmanNotFound("nicht gefunden")
|
||||
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):
|
||||
self._check()
|
||||
|
||||
+226
-38
@@ -25,24 +25,40 @@ def 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.save({"dry_run": dry_run, "settle_seconds": 30, "empty_grace": 20})
|
||||
reader = reader or FakeReader()
|
||||
cc2 = cc2 or FakeCC2()
|
||||
settings.save({"printers": PRINTERS[:printers], "dry_run": dry_run, "settle_seconds": 30, "empty_grace": 20})
|
||||
readers = readers or {"p1": reader or FakeReader(), "p2": FakeReader()}
|
||||
cc2s = cc2s or {"p1": cc2 or FakeCC2(), "p2": FakeCC2()}
|
||||
sm = spoolman or FakeSpoolman()
|
||||
clock = clock or Clock()
|
||||
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda s: reader,
|
||||
cc2_factory=lambda s: cc2, spoolman_factory=lambda s: sm, clock=clock)
|
||||
return {"bridge": bridge, "reader": reader, "cc2": cc2, "sm": sm, "clock": clock, "settings": settings,
|
||||
"path": tmp_path}
|
||||
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda p: readers[p.id],
|
||||
cc2_factory=lambda p: cc2s[p.id], spoolman_factory=lambda s: sm, clock=clock)
|
||||
return {"bridge": bridge, "reader": readers["p1"], "cc2": cc2s["p1"], "reader2": readers["p2"],
|
||||
"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."""
|
||||
e["cc2"].slots[slot]["printer"] = {"status": 1}
|
||||
e["cc2"].slots[slot]["question"] = "inserted"
|
||||
e["reader"].record[f"channels{slot}"] = channel(10000 + spool_id, **kw)
|
||||
e["cc2s"][pid].slots[slot]["printer"] = {"status": 1}
|
||||
e["cc2s"][pid].slots[slot]["question"] = "inserted"
|
||||
e["readers"][pid].record[f"channels{slot}"] = channel(10000 + spool_id, **kw)
|
||||
|
||||
|
||||
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 ("assign", 1, "s0001") in cc2.calls
|
||||
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)
|
||||
# nothing repeated
|
||||
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)
|
||||
poll(env)
|
||||
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())
|
||||
|
||||
|
||||
@@ -93,7 +109,7 @@ def test_real_weight_tags_are_ignored(env):
|
||||
env["reader"].record["channels0"] = channel(1000)
|
||||
env["cc2"].slots[0]["printer"] = {"status": 1}
|
||||
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):
|
||||
@@ -105,9 +121,9 @@ def test_mapping_survives_weight_falling_back_to_1000(env):
|
||||
env["cc2"].slots[2]["spool"] = ""
|
||||
env["cc2"].slots[2]["question"] = "changed"
|
||||
poll(env, 10)
|
||||
m = env["bridge"].state.data["slots"]["2"]
|
||||
m = slots(env)["2"]
|
||||
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):
|
||||
@@ -118,7 +134,7 @@ def test_mapping_survives_restart(env):
|
||||
poll(env)
|
||||
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
|
||||
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
|
||||
|
||||
|
||||
@@ -128,9 +144,9 @@ def test_empty_slot_releases_to_storage_after_grace(env):
|
||||
poll(env)
|
||||
env["cc2"].slots[0]["printer"] = {"status": 0}
|
||||
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
|
||||
assert "0" not in env["bridge"].state.data["slots"]
|
||||
assert "0" not in slots(env)
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
|
||||
|
||||
@@ -140,7 +156,7 @@ def test_cc2_returning_spool_to_storage_releases(env):
|
||||
poll(env, 2)
|
||||
env["cc2"].slots[0]["spool"] = "" # CC2 gave it back to storage (no question)
|
||||
poll(env)
|
||||
assert "0" not in env["bridge"].state.data["slots"]
|
||||
assert "0" not in slots(env)
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
|
||||
|
||||
@@ -151,9 +167,9 @@ def test_new_scan_with_other_id_replaces_mapping(env):
|
||||
poll(env, 2)
|
||||
insert(env, 0, 8, color="0xF88D36")
|
||||
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[8]["location"] == "CC2 Slot 1"
|
||||
assert env["sm"].db[8]["location"] == "Rechts Slot 1"
|
||||
assert env["cc2"].slots[0]["spool"] == "s0002"
|
||||
|
||||
|
||||
@@ -164,9 +180,9 @@ def test_spool_moved_to_other_slot(env):
|
||||
env["reader"].record["channels0"] = channel("")
|
||||
insert(env, 3, 5)
|
||||
poll(env, 2)
|
||||
slots = env["bridge"].state.data["slots"]
|
||||
assert "0" not in slots and slots["3"]["spool"] == 5
|
||||
assert env["sm"].db[5]["location"] == "CC2 Slot 4"
|
||||
sl = slots(env)
|
||||
assert "0" not in sl and sl["3"]["spool"] == 5
|
||||
assert env["sm"].db[5]["location"] == "Rechts Slot 4"
|
||||
|
||||
|
||||
def test_consumption_booked_after_print_once(env):
|
||||
@@ -216,7 +232,7 @@ def test_interrupted_booking_is_not_repeated(env):
|
||||
poll(e2, 10)
|
||||
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
|
||||
b = e2["bridge"].state.data["booked"]["s0001"]
|
||||
b = booked(e2)["s0001"]
|
||||
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 == []
|
||||
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("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)
|
||||
# logged once, not every cycle
|
||||
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})
|
||||
poll(e, 10)
|
||||
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"
|
||||
|
||||
|
||||
@@ -277,11 +294,11 @@ def test_outages_do_not_lose_scans(env):
|
||||
env["sm"].fail = True
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
assert env["bridge"].state.data["slots"] == {}
|
||||
assert env["bridge"].state.data["scans"]
|
||||
assert slots(env) == {}
|
||||
assert env["bridge"].state.printer("p1")["scans"]
|
||||
env["sm"].fail = False
|
||||
poll(env)
|
||||
assert env["bridge"].state.data["slots"]["0"]["spool"] == 5
|
||||
assert slots(env)["0"]["spool"] == 5
|
||||
|
||||
|
||||
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"].slots[3]["printer"] = {"status": 1}
|
||||
poll(env)
|
||||
assert env["bridge"].state.data["slots"]["3"]["spool"] == 9
|
||||
assert env["sm"].db[9]["location"] == "CC2 Slot 4"
|
||||
assert slots(env)["3"]["spool"] == 9
|
||||
assert env["sm"].db[9]["location"] == "Rechts Slot 4"
|
||||
|
||||
|
||||
def test_enable_command_respects_dry_run(tmp_path):
|
||||
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)
|
||||
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["bridge"].commands.put(("enable", None))
|
||||
e["bridge"].commands.put(("enable", "p1", None))
|
||||
poll(e)
|
||||
assert e["cc2"].calls == [("enable", True)]
|
||||
|
||||
@@ -320,7 +337,7 @@ def test_state_file_is_valid_json(env):
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
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.*"))
|
||||
|
||||
|
||||
@@ -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"))
|
||||
assert fields["material"] == "PLA Silk" and notes
|
||||
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
|
||||
@@ -107,7 +107,7 @@ def test_spoolman_requests():
|
||||
return httpx.Response(200, json={"id": 5})
|
||||
sm = Spoolman("http://10.10.6.10:7912", transport=httpx.MockTransport(handler))
|
||||
sm.spool(5)
|
||||
sm.set_location(5, "CC2 Slot 1")
|
||||
sm.update_spool(5, {"location": "CC2 Slot 1"})
|
||||
sm.use(5, 12.3456)
|
||||
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")]
|
||||
|
||||
+25
-9
@@ -18,6 +18,8 @@ RECORD = record(10001, "", 1000, 10008)
|
||||
|
||||
|
||||
class Server(paramiko.ServerInterface):
|
||||
reject_exec = False
|
||||
|
||||
def __init__(self, allowed: paramiko.PKey):
|
||||
self.allowed = allowed
|
||||
self.commands = []
|
||||
@@ -34,6 +36,8 @@ class Server(paramiko.ServerInterface):
|
||||
|
||||
def check_channel_exec_request(self, channel, command):
|
||||
self.commands.append(command)
|
||||
if Server.reject_exec:
|
||||
return False
|
||||
|
||||
def reply(): # forced command: always the canvas record
|
||||
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):
|
||||
keys = KeyStore(tmp_path)
|
||||
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):
|
||||
reader.read()
|
||||
result = reader.test()
|
||||
assert result["host_key_pinned"] is False
|
||||
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()
|
||||
assert rec["channels3"]["weight"] == "10008"
|
||||
# transport is reused between reads
|
||||
@@ -121,8 +125,8 @@ def test_changed_host_key_is_rejected(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
|
||||
keys.pin_host_key("127.0.0.1", other)
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
keys.pin_host_key("p1", "127.0.0.1", other)
|
||||
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
|
||||
with pytest.raises(HostKeyMismatch):
|
||||
reader.read()
|
||||
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):
|
||||
keys = KeyStore(tmp_path)
|
||||
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"):
|
||||
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):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
keys.pin_host_key("127.0.0.1", printer["host_key"])
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
|
||||
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
|
||||
reader.read()
|
||||
for t, _ in printer["servers"]:
|
||||
t.close()
|
||||
@@ -156,4 +160,16 @@ def test_unreachable_printer(tmp_path, printer):
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
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
@@ -67,8 +67,9 @@ def test_ssh_key_upload_never_returned(tmp_path):
|
||||
|
||||
def test_hostkey_only_from_last_test(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 c.post("/api/printers/nope/ssh/hostkey", json={"fingerprint": "x"}, headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_tag_endpoint(tmp_path):
|
||||
@@ -104,7 +105,22 @@ def test_status_and_commands(tmp_path):
|
||||
c, bridge = make_client(tmp_path)
|
||||
bridge.poll_once()
|
||||
st = c.get("/api/status").json()
|
||||
assert len(st["slots"]) == 4 and st["dry_run"] is True
|
||||
assert c.post("/api/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/slots/7/release", headers=H).status_code == 404
|
||||
assert len(st["printers"]) == 1 and len(st["printers"][0]["slots"]) == 4 and st["dry_run"] is True
|
||||
assert c.post("/api/printers/p1/cc2/enable", json={}, headers=H).status_code == 400
|
||||
assert c.post("/api/printers/p1/cc2/enable", json={"confirm": True}, headers=H).json()["dry_run"] is True
|
||||
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}
|
||||
+84
-46
@@ -1,10 +1,10 @@
|
||||
"""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.
|
||||
Control endpoints (port 8099): POST /insert?slot=&spool=, /remove?slot=, /consume?slot=&grams=,
|
||||
/job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
|
||||
Per printer i: SSH 2222+i (keys added via POST /authorize), CC2 Control 8081+i. Spoolman 7912, control 8099.
|
||||
Control endpoints (port 8099, optional &printer=i): POST /insert?slot=&spool=, /remove?slot=,
|
||||
/consume?slot=&grams=, /job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,17 +25,21 @@ from fastapi.responses import JSONResponse
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from tests.fakes import channel, record # noqa: E402
|
||||
|
||||
STATE = {
|
||||
def printer_state():
|
||||
st = {
|
||||
"record": record(),
|
||||
"enabled": True,
|
||||
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
|
||||
"spools": [],
|
||||
"job": {"active": False, "file": "", "slots": []},
|
||||
"spoolman": {},
|
||||
"requests": [],
|
||||
}
|
||||
STATE["slots"][4]["printer"] = None
|
||||
IDS = (f"m{i:04d}" for i in itertools.count(1))
|
||||
"ids": (f"m{i:04d}" for i in itertools.count(1)),
|
||||
}
|
||||
st["slots"][4]["printer"] = None
|
||||
return st
|
||||
|
||||
|
||||
PRINTERS: list[dict] = []
|
||||
STATE = {"spoolman": {}, "fields": {}, "requests": []}
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -46,6 +50,7 @@ def seed_spoolman():
|
||||
STATE["spoolman"][sid] = {
|
||||
"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,
|
||||
"extra": {},
|
||||
"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,
|
||||
"settings_extruder_temp": 215, "vendor": {"id": 1, "name": vendor}},
|
||||
@@ -54,7 +59,7 @@ def seed_spoolman():
|
||||
|
||||
# ---------- CC2 Control ----------
|
||||
|
||||
def make_cc2(host_header: str) -> FastAPI:
|
||||
def make_cc2(host_header: str, P: dict) -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -85,13 +90,13 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
def spools():
|
||||
with LOCK:
|
||||
STATE["requests"].append("GET /api/spools")
|
||||
return {"available": True, "enabled": STATE["enabled"], "slots": STATE["slots"], "spools": STATE["spools"],
|
||||
"job": STATE["job"], "log": []}
|
||||
return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"],
|
||||
"job": P["job"], "log": []}
|
||||
|
||||
@app.get("/api/canvas")
|
||||
def canvas():
|
||||
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")
|
||||
async def enable(request: Request):
|
||||
@@ -99,9 +104,9 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
if body not in (b"on", b"off"):
|
||||
raise HTTPException(400, "Invalid switch")
|
||||
with LOCK:
|
||||
STATE["enabled"] = body == b"on"
|
||||
P["enabled"] = body == b"on"
|
||||
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")
|
||||
async def save(request: Request):
|
||||
@@ -109,7 +114,7 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
with LOCK:
|
||||
STATE["requests"].append("POST save " + json.dumps(f, ensure_ascii=False))
|
||||
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:
|
||||
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
|
||||
if "remaining" in f:
|
||||
@@ -122,12 +127,12 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
return {"ok": True, "id": sp["id"]}
|
||||
if not f.get("material") or not f.get("color"):
|
||||
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)),
|
||||
"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)),
|
||||
"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)
|
||||
|
||||
@app.post("/api/spools/assign")
|
||||
@@ -136,11 +141,11 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
with LOCK:
|
||||
STATE["requests"].append("POST assign " + json.dumps(f))
|
||||
slot = int(f["slot"])
|
||||
for s in STATE["slots"]:
|
||||
for s in P["slots"]:
|
||||
if f["spool"] and s["spool"] == f["spool"]:
|
||||
s["spool"] = ""
|
||||
STATE["slots"][slot]["spool"] = f["spool"]
|
||||
STATE["slots"][slot]["question"] = ""
|
||||
P["slots"][slot]["spool"] = f["spool"]
|
||||
P["slots"][slot]["question"] = ""
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
@@ -169,10 +174,30 @@ def make_spoolman() -> FastAPI:
|
||||
body = await request.json()
|
||||
with LOCK:
|
||||
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",)})
|
||||
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)}")
|
||||
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")
|
||||
async def use(sid: int, request: Request):
|
||||
body = await request.json()
|
||||
@@ -193,43 +218,48 @@ def make_control() -> FastAPI:
|
||||
|
||||
@app.get("/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")
|
||||
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:
|
||||
STATE["slots"][slot]["printer"] = {"status": 1}
|
||||
STATE["slots"][slot]["question"] = "inserted"
|
||||
STATE["slots"][slot]["spool"] = ""
|
||||
STATE["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
|
||||
P["slots"][slot]["printer"] = {"status": 1}
|
||||
P["slots"][slot]["question"] = "inserted"
|
||||
P["slots"][slot]["spool"] = ""
|
||||
P["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/remove")
|
||||
def remove(slot: int):
|
||||
def remove(slot: int, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
STATE["slots"][slot]["printer"] = {"status": 0}
|
||||
STATE["record"][f"channels{slot}"] = channel("")
|
||||
P["slots"][slot]["printer"] = {"status": 0}
|
||||
P["record"][f"channels{slot}"] = channel("")
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/touch")
|
||||
def touch(slot: int):
|
||||
def touch(slot: int, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
STATE["record"][f"channels{slot}"]["weight"] = "1000"
|
||||
STATE["slots"][slot]["spool"] = ""
|
||||
STATE["slots"][slot]["question"] = "changed"
|
||||
P["record"][f"channels{slot}"]["weight"] = "1000"
|
||||
P["slots"][slot]["spool"] = ""
|
||||
P["slots"][slot]["question"] = "changed"
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/job")
|
||||
def job(active: int):
|
||||
def job(active: int, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
STATE["job"]["active"] = bool(active)
|
||||
P["job"]["active"] = bool(active)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/consume")
|
||||
def consume(slot: int, grams: float):
|
||||
def consume(slot: int, grams: float, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
sid = STATE["slots"][slot]["spool"]
|
||||
sp = next(s for s in STATE["spools"] if s["id"] == sid)
|
||||
sid = P["slots"][slot]["spool"]
|
||||
sp = next(s for s in P["spools"] if s["id"] == sid)
|
||||
sp["remaining"] -= grams
|
||||
return {"ok": True, "remaining": sp["remaining"]}
|
||||
|
||||
@@ -247,6 +277,9 @@ AUTHORIZED: set[str] = set()
|
||||
|
||||
|
||||
class SSHServer(paramiko.ServerInterface):
|
||||
def __init__(self, P: dict):
|
||||
self.P = P
|
||||
|
||||
def check_channel_request(self, kind, chanid):
|
||||
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 reply(): # forced command, whatever was sent
|
||||
with LOCK:
|
||||
data = json.dumps(STATE["record"]).encode()
|
||||
data = json.dumps(self.P["record"]).encode()
|
||||
channel_.sendall(data)
|
||||
channel_.send_exit_status(0)
|
||||
channel_.close()
|
||||
@@ -267,7 +300,7 @@ class SSHServer(paramiko.ServerInterface):
|
||||
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
|
||||
if host_key is None:
|
||||
from io import StringIO
|
||||
@@ -287,22 +320,27 @@ def ssh_server(port: int):
|
||||
t = paramiko.Transport(conn)
|
||||
t.add_server_key(host_key)
|
||||
try:
|
||||
t.start_server(server=SSHServer())
|
||||
t.start_server(server=SSHServer(P))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
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()
|
||||
seed_spoolman()
|
||||
threading.Thread(target=ssh_server, args=(2222,), daemon=True).start()
|
||||
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_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:]]
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
Reference in new issue
Block a user