Three intertwined bodies of work developed and tested together this session. Full test suite green: python (edge) + phpunit (cloud). ## Edge/Pi install path - setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit + StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection. - onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the real backend and hard-fails if none works. - Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess 8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix. - install_offline.sh: mirrors the backend gate; onnxruntime optional. ## Cafe24 cloud package (cloud/deploy/) - build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/ (docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess. - Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie scoped to the mount. ## Delivery audit — silent-failure & correctness (docs/delivery-audit.md) All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness: - Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and allow_redirects=False. - Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions; heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera; offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated. - Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING). - Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person); ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
84 lines
3.3 KiB
Python
84 lines
3.3 KiB
Python
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://<domain>/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,
|
|
)
|