Merge feat/p1f-online-setup: one-command online Pi install + resilient camera startup

setup_online.sh sets up an internet-connected Pi in a single command; the daemon now
skips a camera that fails to construct so an unseated CSI camera never crash-loops the
service. Adversarial review clean after the fix.
This commit is contained in:
유창욱 2026-07-13 23:47:20 +09:00
commit 34dcf557e9
4 changed files with 182 additions and 5 deletions

View file

@ -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.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` | | `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) ## 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 **Most reliable: build on an aarch64 host** (a Pi 5, or an arm64 CI runner) so the

114
deploy/setup_online.sh Normal file
View file

@ -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" <<EOF
DEVICE_ID=$DEVICE_ID
DEVICE_SECRET=$DEVICE_SECRET
CAFE24_URL=$CAFE24_URL
CAMERAS=$CAMERAS
POSE_BACKEND=$POSE_BACKEND
HAILO_HEF=$HEF_DEST
MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx
QUEUE_DB=$STATE/queue.db
TARGET_FPS=12
EOF
chown root:"$SVC_USER" "$ETC/edge.env"
chmod 0640 "$ETC/edge.env"
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the cloud will accept events."
echo " -> 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)"

View file

@ -65,6 +65,25 @@ def parse_cameras(env, default_camera_id):
return [(backend, device, 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) def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
import time import time
from dataclasses import replace 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) queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
pose = build_pose() # shared across cameras 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. # 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)) 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, run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")), frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")),

View file

@ -1,7 +1,9 @@
"""TDD for multi-camera support: per-camera zone routing, a run_loop that steps """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).""" N cameras per tick, and CAMERAS env parsing. All with fakes (no hardware)."""
import pytest
from edge.zones import zones_from_config 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(): 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(): 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 non-empty-but-all-blank CAMERAS must fail loudly, never yield zero cameras
# (a safety daemon monitoring nothing while looking healthy). # (a safety daemon monitoring nothing while looking healthy).
with pytest.raises(ValueError): with pytest.raises(ValueError):
parse_cameras({"CAMERAS": " , "}, "cam-01") parse_cameras({"CAMERAS": " , "}, "cam-01")
with pytest.raises(ValueError): with pytest.raises(ValueError):
parse_cameras({"CAMERAS": "csi:0"}, "cam-01") # wrong arity 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)