From c385be017c8585c996e36fb2ef8a8d16f2bc7e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Mon, 13 Jul 2026 23:47:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(edge):=20resilient=20multi-camera=20sta?= =?UTF-8?q?rtup=20=E2=80=94=20skip=20a=20camera=20that=20fails=20to=20cons?= =?UTF-8?q?truct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_runtimes() builds each camera's source+runtime in a try/except: a camera that raises at construction (e.g. an unseated/absent CSI camera whose Picamera2(...) throws) is logged and skipped so the working cameras still run, instead of an unguarded exception crash-looping the whole daemon (Restart=always). Raises only if no camera comes up. Closes the eager-construction gap the review flagged. --- edge/daemon.py | 29 ++++++++++++++++++++++++++--- tests/test_multicam.py | 26 ++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/edge/daemon.py b/edge/daemon.py index 036f2bd..c3f8788 100644 --- a/edge/daemon.py +++ b/edge/daemon.py @@ -65,6 +65,25 @@ def parse_cameras(env, default_camera_id): return [(backend, device, default_camera_id)] +def build_runtimes(cameras, make_source, make_runtime, log=None): + """Build one (source -> runtime) per camera. A camera that fails to CONSTRUCT + (e.g. an unseated/absent CSI camera whose Picamera2(...) raises) is logged and + SKIPPED so it never crash-loops the whole daemon and the other cameras still run. + Raises if no camera comes up (never run monitoring nothing).""" + if log is None: + import sys + log = lambda m: print(m, file=sys.stderr) + runtimes = [] + for backend, device, cam in cameras: + try: + runtimes.append(make_runtime(cam, make_source(backend, device))) + except Exception as e: + log(f"camera {cam} ({backend}:{device}) failed to start, skipping: {e}") + if not runtimes: + raise RuntimeError("no cameras could be started") + return runtimes + + def main() -> None: # pragma: no cover (integration entrypoint, exercised on device) import time from dataclasses import replace @@ -101,11 +120,15 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post) pose = build_pose() # shared across cameras - runtimes = [] - for backend, device, cam in parse_cameras(dict(os.environ), cfg.camera_id): + + def make_runtime(cam, source): # Per-camera config so events are stamped with that camera's id. cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam)) - runtimes.append(PipelineRuntime(cam_cfg, build_source(backend, device), pose, queue)) + return PipelineRuntime(cam_cfg, source, pose, queue) + + # A missing/unseated camera is skipped (logged), not fatal — the others still run. + runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id), + build_source, make_runtime) run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None, frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")), diff --git a/tests/test_multicam.py b/tests/test_multicam.py index 4fc6fb9..613cd4a 100644 --- a/tests/test_multicam.py +++ b/tests/test_multicam.py @@ -1,7 +1,9 @@ """TDD for multi-camera support: per-camera zone routing, a run_loop that steps N cameras per tick, and CAMERAS env parsing. All with fakes (no hardware).""" +import pytest + from edge.zones import zones_from_config -from edge.daemon import run_loop, parse_cameras +from edge.daemon import run_loop, parse_cameras, build_runtimes def _cfg_two_cams(): @@ -73,10 +75,30 @@ def test_parse_cameras_multi_and_single(): def test_parse_cameras_rejects_blank_entries_never_zero_cameras(): - import pytest # A non-empty-but-all-blank CAMERAS must fail loudly, never yield zero cameras # (a safety daemon monitoring nothing while looking healthy). with pytest.raises(ValueError): parse_cameras({"CAMERAS": " , "}, "cam-01") with pytest.raises(ValueError): parse_cameras({"CAMERAS": "csi:0"}, "cam-01") # wrong arity + + +def test_build_runtimes_skips_a_camera_that_fails_to_start(): + # An unseated/absent CSI camera raises at construction; it must be skipped so the + # working USB camera still runs (construction resilience, not just step isolation). + def make_source(backend, device): + if backend == "csi": + raise RuntimeError("no CSI camera attached") + return ("src", backend, device) + logs = [] + rts = build_runtimes([("csi", "0", "cam-01"), ("uvc", "0", "cam-02")], + make_source, lambda cam, src: (cam, src), log=logs.append) + assert rts == [("cam-02", ("src", "uvc", "0"))] + assert any("cam-01" in m for m in logs) + + +def test_build_runtimes_raises_when_all_cameras_fail(): + def boom(*_a): + raise RuntimeError("gone") + with pytest.raises(RuntimeError): + build_runtimes([("csi", "0", "c")], boom, lambda c, s: None, log=lambda m: None) From e8b75d5366112738b0ce6e11c5d570d90e2f9c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Mon, 13 Jul 2026 23:47:20 +0900 Subject: [PATCH 2/2] feat(deploy): one-command online installer for an internet-connected Pi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy/setup_online.sh installs system packages (picamera2, hailo-all best-effort), builds the venv + pip deps, fetches the MoveNet model, writes /etc/move-detect/edge.env (prompting or from env vars), installs the systemd unit and starts the service — one command, no offline bundle. Starts on CPU MoveNet; drop in a HEF for Hailo. numpy left to the system to avoid shadowing picamera2's build. Air-gapped path (deploy/bundle/*) unchanged. --- deploy/README.md | 18 +++++++ deploy/setup_online.sh | 114 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 deploy/setup_online.sh diff --git a/deploy/README.md b/deploy/README.md index e164b6a..04fe207 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -21,6 +21,24 @@ being offline (events buffer in the local sqlite queue and flush on reconnect). | `systemd/move-detect-edge.service` | the Pi | the daemon unit (auto-restart, device access, hardening) | | `systemd/move-detect-edge.env.example` | the Pi | env template → `/etc/move-detect/edge.env` | +## Quick start — Pi with internet (one command) + +If the Pi itself has internet, skip the offline bundle entirely. Copy this repo onto +the Pi (git clone or scp), then: + +```bash +sudo deploy/setup_online.sh +``` + +It installs the system packages (`python3-picamera2`, and `hailo-all` if present), +builds the venv, pip-installs the runtime deps, fetches the MoveNet model, writes +`/etc/move-detect/edge.env` (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / +`CAMERAS`, or take them from env vars), installs the systemd unit, and starts the service. +It begins on CPU MoveNet; drop a `yolov8s_pose.hef` in and pass `HAILO_HEF=...` (or edit +`edge.env`) to switch to Hailo. The rest of this doc is the **air-gapped** path. + +--- + ## 1. Build the bundle (on a machine WITH internet) **Most reliable: build on an aarch64 host** (a Pi 5, or an arm64 CI runner) so the diff --git a/deploy/setup_online.sh b/deploy/setup_online.sh new file mode 100644 index 0000000..be95fa4 --- /dev/null +++ b/deploy/setup_online.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# ONE-COMMAND online installer for an INTERNET-CONNECTED Raspberry Pi 5. +# Installs everything and starts the service. For AIR-GAPPED Pis use deploy/bundle/*. +# +# Run from a clone/copy of this repo, as root: +# sudo deploy/setup_online.sh +# +# Non-interactive: pass values as env vars, e.g. +# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \ +# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh +# Interactive (a real terminal): it prompts for anything not passed. +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "run as root: sudo $0" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PREFIX=/opt/move-detect +STATE=/var/lib/move-detect +ETC=/etc/move-detect +SVC_USER=movedetect + +echo ">> [1/8] system packages (CSI camera, venv; Hailo runtime best-effort)" +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y python3-picamera2 python3-venv python3-pip +# hailo-all is the Hailo driver+runtime; only present in Pi repos with a Hailo HAT. +# Best-effort so a Pi without it still installs and runs on CPU MoveNet. +apt-get install -y hailo-all || echo " WARN: hailo-all not installed — Hailo accel unavailable, CPU MoveNet will be used." + +echo ">> [2/8] service account + device groups" +id -u "$SVC_USER" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER" +for g in video render plugdev hailo; do + getent group "$g" >/dev/null 2>&1 && usermod -aG "$g" "$SVC_USER" || true +done + +echo ">> [3/8] source + dirs under $PREFIX" +install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs" +rm -rf "$PREFIX/src/edge" # full replace on re-run (no nested edge/edge) +cp -r "$REPO_ROOT/edge" "$PREFIX/src/edge" +cp "$REPO_ROOT/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true +find "$PREFIX/src" -name __pycache__ -type d -prune -exec rm -rf {} + +cp "$REPO_ROOT/docs/pilot-runbook.md" "$PREFIX/docs/" 2>/dev/null || true + +echo ">> [4/8] venv (+ system site-packages) + pip runtime deps" +python3 -m venv --system-site-packages "$PREFIX/venv" +"$PREFIX/venv/bin/pip" install --upgrade pip >/dev/null +# numpy is intentionally NOT pinned here: it comes in transitively via onnxruntime, and +# leaving the system numpy in place (the venv sees it) avoids shadowing the numpy that +# the apt python3-picamera2 stack was compiled against. +"$PREFIX/venv/bin/pip" install requests onnxruntime opencv-python-headless + +echo ">> [5/8] MoveNet CPU model" +( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet ) +cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true + +echo ">> [6/8] pose backend" +POSE_BACKEND=movenet +HEF_DEST="$PREFIX/models/yolov8s_pose.hef" +if [ -n "${HAILO_HEF:-}" ] && [ -f "${HAILO_HEF:-}" ]; then + cp "$HAILO_HEF" "$HEF_DEST" + POSE_BACKEND=hailo + echo " -> Hailo HEF installed; POSE_BACKEND=hailo" +else + echo " -> no HAILO_HEF given; starting on CPU MoveNet." + echo " For Hailo: copy yolov8s_pose.hef to $HEF_DEST, set POSE_BACKEND=hailo in" + echo " $ETC/edge.env, then: sudo systemctl restart move-detect-edge" +fi + +echo ">> [7/8] config -> $ETC/edge.env" +install -d -m 0750 "$STATE"; chown "$SVC_USER":"$SVC_USER" "$STATE" +install -d -m 0750 "$ETC" +if [ -f "$ETC/edge.env" ]; then + echo " -> $ETC/edge.env already exists; left unchanged" +else + : "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}" + if [ -t 0 ]; then + [ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID + [ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; } + [ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (cloud base URL): " CAFE24_URL + [ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS + fi + DEVICE_ID="${DEVICE_ID:-pi-01}" + CAMERAS="${CAMERAS:-csi:0:cam-01,uvc:0:cam-02}" + # Every line is a bare KEY=value (systemd EnvironmentFile= keeps inline text literally). + umask 077 + cat > "$ETC/edge.env" < wrote $ETC/edge.env" +fi + +echo ">> [8/8] systemd service" +install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now move-detect-edge + +echo "" +echo "Done. The edge daemon is installed and started." +echo " logs: journalctl -u move-detect-edge -f" +echo " config: sudo nano $ETC/edge.env (then: sudo systemctl restart move-detect-edge)"