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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 03:53:01 +01:00
1 parent bf32667f77
commit a9041eb538
15 files changed
+1400 -684

No files matched your search

+172 -67
View File
@@ -4,11 +4,15 @@ from __future__ import annotations
import json
import os
import re
import secrets
import tempfile
import threading
from dataclasses import asdict, dataclass, fields
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
V1_LOCATION_SLOT = "CC2 Slot {n}"
def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -47,64 +51,6 @@ def read_json(path: Path, default):
return default
@dataclass
class Settings:
printer_host: str = "10.10.2.202"
ssh_port: int = 22
ssh_user: str = "root"
cc2_url: str = "http://10.10.2.202:8081"
spoolman_url: str = "http://10.10.6.10:7912"
id_offset: int = 10000
poll_interval: float = 5.0
location_slot: str = "CC2 Slot {n}"
location_storage: str = "Lager"
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
settle_seconds: float = 60.0 # `used` must be unchanged this long before booking
dry_run: bool = True
# environment variable -> field (only used as defaults before settings.json exists)
ENV = {
"PRINTER_HOST": "printer_host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
"CC2_URL": "cc2_url", "SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset",
"POLL_INTERVAL": "poll_interval", "DRY_RUN": "dry_run",
}
@classmethod
def from_dict(cls, d: dict) -> "Settings":
s = cls()
s.update(d)
return s
def update(self, d: dict) -> None:
for f in fields(self):
if f.name in d and d[f.name] is not None:
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
self.validate()
def validate(self) -> None:
if not 1 <= self.ssh_port <= 65535:
raise ValueError("SSH-Port ungültig")
if not 0 <= self.id_offset <= 65000:
raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen")
if not 1 <= self.poll_interval <= 300:
raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen")
if "{n}" not in self.location_slot:
raise ValueError("Ortsname für Slots muss {n} enthalten")
for name in ("cc2_url", "spoolman_url"):
v = getattr(self, name)
if not v.startswith(("http://", "https://")):
raise ValueError(f"{name} muss mit http:// oder https:// beginnen")
setattr(self, name, v.rstrip("/"))
if not self.location_storage.strip():
raise ValueError("Ortsname für das Lager fehlt")
def to_dict(self) -> dict:
return asdict(self)
def slot_location(self, slot: int) -> str:
return self.location_slot.replace("{n}", str(slot + 1))
def _coerce(typ, value, name):
typ = typ if isinstance(typ, str) else typ.__name__
try:
@@ -121,6 +67,138 @@ def _coerce(typ, value, name):
raise ValueError(f"Ungültiger Wert für {name}") from None
def new_printer_id() -> str:
return "p" + secrets.token_hex(3)
@dataclass
class PrinterConfig:
id: str = ""
name: str = "CC2"
host: str = "10.10.2.202"
ssh_port: int = 22
ssh_user: str = "root"
cc2_url: str = "http://10.10.2.202:8081"
enabled: bool = True
@classmethod
def from_dict(cls, d: dict) -> "PrinterConfig":
p = cls()
for f in fields(p):
if f.name in d and d[f.name] is not None:
setattr(p, f.name, _coerce(f.type, d[f.name], f.name))
if not p.id:
p.id = new_printer_id()
p.validate()
return p
def validate(self) -> None:
if not re.fullmatch(r"[a-z0-9_-]{1,32}", self.id):
raise ValueError("Ungültige Drucker-ID")
if not self.name.strip():
raise ValueError("Druckername fehlt")
if len(self.name) > 60:
raise ValueError("Druckername ist zu lang (max. 60 Zeichen)")
if not self.host.strip():
raise ValueError(f"{self.name}: Drucker-IP fehlt")
if not 1 <= self.ssh_port <= 65535:
raise ValueError(f"{self.name}: SSH-Port ungültig")
if not self.cc2_url.startswith(("http://", "https://")):
raise ValueError(f"{self.name}: CC2-Control-URL muss mit http:// beginnen")
self.cc2_url = self.cc2_url.rstrip("/")
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class Settings:
spoolman_url: str = "http://10.10.6.10:7912"
id_offset: int = 10000
poll_interval: float = 5.0
location_slot: str = "{printer} Slot {n}"
location_storage: str = "Lager"
spoolman_fields: bool = True # also write the extra fields "drucker" and "slot"
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking
dry_run: bool = True
printers: list[PrinterConfig] = field(default_factory=list)
# environment variable -> field (only used as defaults before settings.json exists)
ENV = {
"SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset", "POLL_INTERVAL": "poll_interval",
"DRY_RUN": "dry_run",
}
PRINTER_ENV = {
"PRINTER_NAME": "name", "PRINTER_HOST": "host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
"CC2_URL": "cc2_url",
}
@classmethod
def from_dict(cls, d: dict) -> "Settings":
s = cls()
s.update(migrate_settings(d))
return s
def update(self, d: dict) -> None:
for f in fields(self):
if f.name == "printers" or f.name not in d or d[f.name] is None:
continue
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
if d.get("printers") is not None:
self.printers = [p if isinstance(p, PrinterConfig) else PrinterConfig.from_dict(p) for p in d["printers"]]
self.validate()
def validate(self) -> None:
if not 0 <= self.id_offset <= 65000:
raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen")
if not 1 <= self.poll_interval <= 300:
raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen")
if "{n}" not in self.location_slot:
raise ValueError("Ortsname für Slots muss {n} enthalten")
if len(self.printers) > 1 and "{printer}" not in self.location_slot:
raise ValueError("Bei mehreren Druckern muss der Ortsname {printer} enthalten")
if not self.spoolman_url.startswith(("http://", "https://")):
raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen")
self.spoolman_url = self.spoolman_url.rstrip("/")
if not self.location_storage.strip():
raise ValueError("Ortsname für das Lager fehlt")
ids = [p.id for p in self.printers]
names = [p.name.strip().lower() for p in self.printers]
if len(set(ids)) != len(ids) or len(set(names)) != len(names):
raise ValueError("Druckernamen müssen eindeutig sein")
if len(self.printers) > 8:
raise ValueError("Höchstens 8 Drucker")
def to_dict(self) -> dict:
d = asdict(self)
d["printers"] = [p.to_dict() for p in self.printers]
return d
def printer(self, pid: str) -> PrinterConfig | None:
return next((p for p in self.printers if p.id == pid), None)
def slot_location(self, printer: PrinterConfig | str, slot: int) -> str:
name = printer.name if isinstance(printer, PrinterConfig) else printer
return self.location_slot.replace("{printer}", name).replace("{n}", str(slot + 1))
def migrate_settings(d: dict) -> dict:
"""v1 had one printer at the top level; v2 keeps a list of printers."""
d = dict(d)
if "printers" not in d and "printer_host" in d:
d["printers"] = [{
"id": "p1", "name": d.get("printer_name") or "CC2", "host": d.get("printer_host"),
"ssh_port": d.get("ssh_port", 22), "ssh_user": d.get("ssh_user", "root"),
"cc2_url": d.get("cc2_url"), "enabled": True,
}]
if d.get("location_slot") == V1_LOCATION_SLOT:
d["location_slot"] = Settings.location_slot
for key in ("printer_host", "printer_name", "ssh_port", "ssh_user", "cc2_url"):
d.pop(key, None)
return d
class SettingsStore:
def __init__(self, data_dir: Path, env: dict | None = None):
self.path = data_dir / "settings.json"
@@ -129,9 +207,13 @@ class SettingsStore:
if stored is None:
env = os.environ if env is None else env
defaults = {f: env[k] for k, f in Settings.ENV.items() if env.get(k)}
printer = {f: env[k] for k, f in Settings.PRINTER_ENV.items() if env.get(k)}
defaults["printers"] = [{"id": "p1", **printer}]
self.settings = Settings.from_dict(defaults)
else:
self.settings = Settings.from_dict(stored)
if "printer_host" in stored: # write the migrated form once
atomic_write_json(self.path, self.settings.to_dict())
def get(self) -> Settings:
with self._lock:
@@ -146,16 +228,39 @@ class SettingsStore:
class StateStore:
"""Bridge state: slot mappings, booked consumption, last record."""
"""Bridge state: per printer slot mappings, booked consumption and last record; Spoolman locations."""
def __init__(self, data_dir: Path):
def __init__(self, data_dir: Path, first_printer: str = "p1"):
self.path = data_dir / "state.json"
self.data = read_json(self.path, {})
self.data.setdefault("version", 1)
self.data.setdefault("slots", {})
self.data.setdefault("booked", {})
self.data.setdefault("locations", {})
self.data.setdefault("last_record", None)
self.data = migrate_state(read_json(self.path, {}), first_printer)
def printer(self, pid: str) -> dict:
p = self.data["printers"].setdefault(pid, {})
p.setdefault("slots", {})
p.setdefault("booked", {})
p.setdefault("last_record", None)
p.setdefault("scans", [])
return p
def save(self) -> None:
atomic_write_json(self.path, self.data)
def migrate_state(data: dict, first_printer: str) -> dict:
data.setdefault("printers", {})
if data.get("version", 1) < 2:
if any(k in data for k in ("slots", "booked", "last_record", "scans")):
data["printers"][first_printer] = {
"slots": data.pop("slots", {}), "booked": data.pop("booked", {}),
"last_record": data.pop("last_record", None), "scans": data.pop("scans", []),
}
applied = {}
for sid, loc in (data.pop("locations", None) or {}).items():
if loc.get("done"):
applied[sid] = {"location": loc["done"], "printer": None, "slot": None}
data["applied"] = applied
data["written_locations"] = sorted({a["location"] for a in applied.values()})
data["version"] = 2
data.setdefault("applied", {})
data.setdefault("written_locations", [])
return data