from dataclasses import dataclass from edge.detectors.fall import FallConfig from edge.detectors.immobility import ImmobilityConfig from edge.detectors.ghost import GhostConfig from edge.detectors.zone import ZoneConfig from edge.engine import EngineConfig @dataclass class Config: device_id: str camera_id: str cafe24_url: str secret: str fall: FallConfig immobility: ImmobilityConfig ghost: GhostConfig zone: ZoneConfig engine: EngineConfig def _f(env: dict, key: str, default: float) -> float: return float(env[key]) if key in env else default def frame_interval_from_env(env: dict) -> float: """Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a non-numeric value (ValueError) would otherwise crash at every startup into an infinite systemd restart loop (Restart=always + StartLimitIntervalSec=0).""" raw = env.get("TARGET_FPS", "12") try: fps = float(raw) except (TypeError, ValueError): raise ValueError(f"TARGET_FPS={raw!r} is not a number") if fps <= 0: raise ValueError(f"TARGET_FPS={raw!r} must be > 0") return 1.0 / fps def queue_db_path(env: dict) -> str: """Absolute sqlite path for the offline queue. A relative 'queue.db' resolves against the unit's read-only WorkingDirectory under ProtectSystem=strict and cannot be created -> crash loop. Default to the systemd StateDirectory, the one writable path.""" return env.get("QUEUE_DB") or "/var/lib/move-detect/queue.db" def require_upload_config(cfg: Config) -> None: """Fail LOUD at startup when the daemon cannot possibly deliver events. load_config defaults cafe24_url and secret to '' so unit tests can build a Config from a minimal env — but a real daemon with either blank runs perfectly green while every upload fails (a bad/empty URL makes requests raise, which PostClient catches and returns False), and the offline queue grows until the SD card is full. The operator sees a healthy unit and an empty dashboard. Refuse to start instead. """ missing = [name for name, val in (("CAFE24_URL", cfg.cafe24_url), ("DEVICE_SECRET", cfg.secret)) if not val.strip()] if missing: raise ValueError( f"FATAL: {', '.join(missing)} not set in /etc/move-detect/edge.env — the daemon " f"would run but upload nothing. CAFE24_URL must include the app path, e.g. " f"https:///move_detect ; DEVICE_SECRET is the per-device secret from the " f"cloud." ) def load_config(env: dict) -> Config: camera_id = env.get("CAMERA_ID", "cam-01") fall = FallConfig( prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0), no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0), ) immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0)) ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0)) zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75)) engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0)) return Config( device_id=env.get("DEVICE_ID", "pi-unknown"), camera_id=camera_id, cafe24_url=env.get("CAFE24_URL", ""), secret=env.get("DEVICE_SECRET", ""), fall=fall, immobility=immobility, ghost=ghost, zone=zone, engine=engine, )