Files
cc2-spoolman-bridge/app/storage.py
T
Jonas MenzelandClaude Opus 5.5 b37cd64909
CI / test (push) Successful in 2m35s
CI / image (push) Successful in 42s
Spoolman leads CC2 Control's copies; new spools from the bridge
- Master data of CC2 Control's copies follows Spoolman (name, brand,
  colour, diameter, density, net, tare, price; not the material, which was
  fitted to the tray).
- A loaded spool's rest in CC2 Control is set to Spoolman's via
  /api/spools/adjust once everything is booked (old copy after the roll
  printed in the other printer, weigh-in in Spoolman); the resulting
  correction is not booked back.
- Copies of spools archived or deleted in Spoolman are deleted from CC2
  Control when no tray holds them (library limit 128); an outage never
  counts as deleted.
- A roll that ran out is archived in Spoolman (setting, default on); a
  scan of an archived spool maps nothing.
- A roll scanned again with unchanged tag data and put back into its old
  slot (after printing in the other printer) is recognised.
- Tag generator: "Neue Spule" creates spools in Spoolman from an existing
  or a new filament (and vendor), labelled vendor + id, then opens the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
2026-10-25 21:26:49 +01:00

274 lines
10 KiB
Python

"""Settings and state persistence in the data volume (atomic JSON writes)."""
from __future__ import annotations
import json
import os
import re
import secrets
import tempfile
import threading
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)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
try:
os.fchmod(fd, mode)
with os.fdopen(fd, "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
raise
try:
dfd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
except OSError:
pass
def atomic_write_json(path: Path, obj, mode: int = 0o644) -> None:
atomic_write(path, json.dumps(obj, indent=2, sort_keys=True, ensure_ascii=False).encode(), mode)
def read_json(path: Path, default):
try:
return json.loads(path.read_text())
except FileNotFoundError:
return default
def _coerce(typ, value, name):
typ = typ if isinstance(typ, str) else typ.__name__
try:
if typ == "bool":
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on", "ja")
return bool(value)
if typ == "int":
return int(value)
if typ == "float":
return float(value)
return str(value).strip()
except (TypeError, ValueError):
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
archive_empty: bool = True # archive a Spoolman spool whose roll ran out
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"
self._lock = threading.Lock()
stored = read_json(self.path, None)
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:
return Settings.from_dict(self.settings.to_dict())
def save(self, changes: dict) -> Settings:
with self._lock:
new = Settings.from_dict({**self.settings.to_dict(), **changes})
atomic_write_json(self.path, new.to_dict())
self.settings = new
return Settings.from_dict(new.to_dict())
class StateStore:
"""Bridge state: per printer slot mappings, booked consumption and last record; Spoolman locations."""
def __init__(self, data_dir: Path, first_printer: str = "p1"):
self.path = data_dir / "state.json"
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", [])
p.setdefault("last", {}) # slot -> roll that last left it (for putting it back)
p.setdefault("takeout", []) # windows in which a tray prints for a roll taken out in CC2 Control
p.setdefault("log_seen", None) # CC2 Control log entries already looked at
p.setdefault("extra", []) # bookings from CC2 Control's log still to make
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", [])
if "written_printers" not in data:
data["written_printers"] = sorted({a["printer"] for a in data["applied"].values() if a.get("printer")})
return data