"""Settings and state persistence in the data volume (atomic JSON writes).""" from __future__ import annotations import json import os import tempfile import threading from dataclasses import asdict, dataclass, fields from pathlib import Path 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 @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: 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 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)} self.settings = Settings.from_dict(defaults) else: self.settings = Settings.from_dict(stored) 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: slot mappings, booked consumption, last record.""" def __init__(self, data_dir: Path): 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) def save(self) -> None: atomic_write_json(self.path, self.data)