move_detect/edge/config.py
유창욱 2ea0420de7 fix(delivery): resolve codex + workflow review findings across edge/cloud/deploy
Two independent adversarial reviews (codex CLI + a 6-dimension verification
workflow) of the delivery-hardening branch surfaced ~40 findings; this closes
the confirmed correctness/security/deploy blockers plus a codex re-review pass.

edge:
- geometry: track_displacement per-second threshold recalibrated (0.02->0.25,
  env-tunable via MOTION_THR, validated finite/positive); confidence-filter
  keypoints with a bbox-center fallback so hallucinated points don't fake motion
- fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident
  (freeze + reset recovery timer so recovery needs a fresh confident streak);
  baseline height only seeded from trusted frames
- queue: PostClient.send() returns a tri-state SendResult; only definite 4xx
  count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx)
  never deletes queued safety events; legacy rows persist their minted uuid
- config: reject placeholder / http / short DEVICE_SECRET at startup
- zones: warn+skip legacy pixel-space polygons that would silently match nothing

cloud:
- zone endpoints wired into P3-2b site-scope (cross-site read/write/delete
  closed) + role gate on list; catch narrowed so DB faults surface as 500
- snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan)
- ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE
  (event_id, content_hash) makes recovery idempotent under retry/race
- event list cursor carries full-precision occurred_at (no float rounding at the
  page boundary); string keyset end-to-end
- SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap
- runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit
  reap fails open

deploy:
- offline bundle hashes EVERY file (fatal on missing/failed/injected; models in
  SBOM; arch in tarball name; REQUIRE_SIGNATURE option)
- installer verifies before any mutation, Hailo arch preflight before install,
  atomic src swap, backend reconcile preserves a working operator choice
- systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert)

Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4
skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings +
fixes) with the second-pass findings folded in.
2026-07-17 19:24:42 +09:00

120 lines
5.3 KiB
Python

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://<domain>/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,
)