import math 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" # Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real # provisioned value. A unit left at any of these uploads nothing while looking healthy. _PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx") # Per-device secrets issued by the cloud are HMAC-derived (long hex); anything shorter is a # stub left in the template, not a real key. _MIN_SECRET_LEN = 16 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, left at a ship-time placeholder, or on plaintext http runs perfectly green while every upload fails (a bad/empty URL makes requests raise, which PostClient catches and returns False; http is refused by allow_redirects=False), and the offline queue grows until the SD card is full. The operator sees a healthy unit and an empty dashboard, or worker snapshots leave the Pi in plaintext. Refuse to start instead. """ problems: list[str] = [] url = cfg.cafe24_url.strip() secret = cfg.secret.strip() if not url: problems.append("CAFE24_URL not set") elif not url.lower().startswith("https://"): problems.append(f"CAFE24_URL must be https:// (got {url!r}) — plaintext would leak " "worker snapshots and the .htaccess redirect is not followed") elif any(m in url.lower() for m in _PLACEHOLDER_MARKERS): problems.append(f"CAFE24_URL is still a template placeholder ({url!r})") if not secret: problems.append("DEVICE_SECRET not set") elif any(m in secret.lower() for m in _PLACEHOLDER_MARKERS): problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)") elif len(secret) < _MIN_SECRET_LEN: problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the " "per-device secret issued by the cloud, not a stub") if problems: raise ValueError( "FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run " "but upload nothing. CAFE24_URL must include the app path, e.g. " "https:///move_detect ; DEVICE_SECRET is the per-device secret from the cloud." ) def load_config(env: dict) -> Config: camera_id = env.get("CAMERA_ID", "cam-01") # Per-second stillness threshold (units/second), shared by fall no-motion and immobility. # track_displacement is now frame-rate-normalized, so this must be tuned per device — expose # it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive # value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion). motion_thr = _f(env, "MOTION_THR", 0.25) if not math.isfinite(motion_thr) or motion_thr <= 0: raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}") fall = FallConfig( prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0), no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0), motion_thr=motion_thr, ) immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0), motion_thr=motion_thr) 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, )