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.
120 lines
5.3 KiB
Python
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,
|
|
)
|