"""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 public_url: str = "" # the bridge as phones reach it (links on NFC tags); empty: the address in the browser 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("/") self.public_url = self.public_url.strip().rstrip("/") if self.public_url and not re.fullmatch(r"https?://[^\s/?#]+(/[^\s?#]*)?", self.public_url): raise ValueError("Adresse der Bridge muss mit http:// oder https:// beginnen, ohne ? oder #") 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