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
+1400
-684
No files matched your search
+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
|
||||
Reference in new issue
Block a user