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:
commit
34dcf557e9
4 changed files with 182 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
114
deploy/setup_online.sh
Normal file
114
deploy/setup_online.sh
Normal 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)"
|
||||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue