#!/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 # # Pre-provisioned (shortest path): put the values in deploy/device.env on the BUILD machine # (DEVICE_ID / DEVICE_SECRET / CAFE24_URL / CAMERAS, one KEY=value per line, gitignored), copy # the repo to the Pi, and the whole install is just: # sudo deploy/setup_online.sh # # Non-interactive: pass values as env vars (these override device.env), e.g. # sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://server CAMERAS=csi:0:cam-01,uvc:0:cam-02 \ # HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh # The Hailo HEF is normally auto-fetched for the detected board; you do not need HAILO_HEF. # If the board can't be auto-detected (a Hailo-8 that reports no serial/identity), name it: # sudo HAILO_ARCH=hailo8 deploy/setup_online.sh # 26 TOPS AI HAT+ (hailo8l = 13 TOPS AI Kit) # 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 # A freshly-booted Pi runs apt-daily / apt-daily-upgrade / unattended-upgrades, which hold # /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend for the first few minutes — # exactly when someone runs a one-command installer. A bare apt-get then exits 100 with # "E: Unable to lock directory /var/lib/apt/lists/" and `set -e` (line 12) kills us here. # -o DPkg::Lock::Timeout is not enough on its own: apt applies it to the dpkg locks, but # `apt-get update`'s lists lock is taken with a single non-blocking fcntl(F_SETLK) and no # retry. That wait has to live here, in the shell. # Retry ONLY on lock contention; a real error (e.g. hailo-all absent from the repo) must # still fail fast so the `|| echo WARN` fallback below stays instant. apt_retry() { local tries=30 i=1 rc log log="$(mktemp)" while :; do rc=0 # LC_ALL=C is load-bearing, not cosmetic: apt gettext-translates its errors, and sudo # passes the caller's LANG/LC_* straight through. On a ko_KR Pi the lists-lock message # renders as "%s 디렉터리를 잠글 수 없습니다", the grep below misses it, and lock # contention gets misread as a real error — fail-fast, zero retries, exactly the bug # this function exists to fix. Pinning it per-call (not script-wide) keeps pip/venv and # fetch_movenet at [4/8]-[5/8] on the system locale. LC_ALL=C apt-get -o DPkg::Lock::Timeout=60 "$@" 2>&1 | tee "$log" || rc=${PIPESTATUS[0]} if [ "$rc" -eq 0 ]; then rm -f "$log"; return 0; fi if ! grep -qE 'Could not get lock|Unable to lock|Unable to acquire the dpkg' "$log"; then rm -f "$log"; return "$rc" fi if [ "$i" -ge "$tries" ]; then rm -f "$log" echo " ERROR: apt/dpkg still locked after $((tries * 10))s. See who holds it with:" >&2 echo " systemctl status apt-daily.service apt-daily-upgrade.service unattended-upgrades.service" >&2 return "$rc" fi echo " apt/dpkg locked by another process (apt-daily/unattended-upgrades); retry $i/$tries in 10s..." i=$((i + 1)); sleep 10 done } apt_retry update apt_retry 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_retry 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 # MANDATORY for every backend — do not soften these to `|| echo WARN`: # cv2 edge/snapshot.py imports it at MODULE level, and that module is on the # unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND # can reach build_pose() without it. # requests the daemon builds its server/alert clients with no injected HTTP fn, so # `import requests` fires at startup regardless of backend. # numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives # transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless # hard-requires numpy and opencv is mandatory, so numpy still always arrives; leaving it # unnamed lets the resolver keep the apt numpy that python3-picamera2 was compiled against # whenever that copy satisfies opencv's floor. "$PREFIX/venv/bin/pip" install requests opencv-python-headless # onnxruntime is the CPU-MoveNet-ONLY dep: movenet_pose.py imports it inside # MoveNetPoseAdapter.__init__, and the Hailo path never imports the module at all. It # publishes NO sdist and NO 32-bit-ARM wheel, so pip can legitimately find zero candidates # and exit non-zero in seconds — which under `set -euo pipefail` used to kill this installer # on a Hailo-8 Pi that never needed it. Best-effort, like hailo-all at [1/8]; [6/8] decides # whether a backend actually exists. "$PREFIX/venv/bin/pip" install onnxruntime \ || echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)." echo ">> [5/8] MoveNet CPU model" # Best-effort for the same reason: this model is inert without onnxruntime, and a download # hiccup must not kill an install whose backend is Hailo. fetch_movenet is pure stdlib, so it # runs fine with onnxruntime absent. ( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet ) \ || echo " WARN: MoveNet ONNX download failed — CPU MoveNet unavailable (Hailo unaffected)." cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true echo ">> [6/8] pose backend" HEF_DEST="$PREFIX/models/yolov8s_pose.hef" ONNX_DEST="$PREFIX/models/movenet_lightning.onnx" if [ -n "${HAILO_HEF:-}" ]; then # An explicit path that does not exist is a typo, not a request to fall back. The old single # guarded `cp` skipped it with NO warning and produced the byte-identical HEF=MISSING table # as the unset case, never echoing the bad value — and if an earlier run had left a # $HEF_DEST behind, that stale HEF got adopted as though it were the one named. [ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; } cp "$HAILO_HEF" "$HEF_DEST" echo " -> HEF installed from HAILO_HEF=$HAILO_HEF" else # No HEF supplied: fetch the one matching THIS board. Nothing in this repo could previously # produce a .hef — MoveNet had fetch_movenet.py, Hailo had nothing and no doc named a # source, so [6/8] demanded a file the operator had no way to obtain. # fetch_hailo_hef refuses to guess: it reads the arch from `hailortcli fw-control identify` # and exits non-zero when no board answers — exactly the "no Hailo here" case that must fall # through to MoveNet rather than install an h8 HEF that dies at load with # HAILO_INVALID_HEF(26). Pure stdlib, so it runs with hailo_platform/onnxruntime absent. # ONLINE-ONLY by design (rules/common/build-deployment.md); the air-gapped path carries the # .hef in the bundle instead. Best-effort — the probes below decide if a backend exists. # HAILO_ARCH (optional): force the arch when the board can't be auto-detected — e.g. a # Hailo-8 whose firmware reports no serial/identity, or a Pi where hailortcli is not yet on # PATH. ${VAR:+..} expands to nothing when unset (safe under `set -u`), so the auto-detect # path is unchanged for everyone who does not set it; the inner quotes keep a spaced value # one word. fetch_hailo_hef also reads HAILO_ARCH from the inherited env, so this is # belt-and-suspenders with a direct `HAILO_ARCH=hailo8 sudo deploy/setup_online.sh`. ( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_hailo_hef \ --dest "$HEF_DEST" ${HAILO_ARCH:+--arch "$HAILO_ARCH"} ) \ || echo " WARN: no Hailo HEF installed (no board detected, or the download failed)." fi # Probe what can ACTUALLY run, in the venv the service will use. A HEF on disk is not enough # (hailo_platform comes from apt hailo-all, best-effort at [1/8]), and a pip exit code is not # enough either (a wheel can install and still fail to load its .so). Both probes must sit # inside `if` conditions — a bare `a && b` list that fails is fatal under set -e. pyhas() { "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; } state() { if "$@" >/dev/null 2>&1; then echo present; else echo MISSING; fi; } HAILO_OK=0; MOVENET_OK=0 if [ -f "$HEF_DEST" ] && pyhas hailo_platform; then HAILO_OK=1; fi if [ -f "$ONNX_DEST" ] && pyhas onnxruntime; then MOVENET_OK=1; fi if [ "$HAILO_OK" = 1 ]; then POSE_BACKEND=hailo echo " -> Hailo HEF + hailo_platform present; POSE_BACKEND=hailo" [ "$MOVENET_OK" = 1 ] || echo " (CPU MoveNet fallback unavailable — Hailo does not need it)" elif [ "$MOVENET_OK" = 1 ]; then POSE_BACKEND=movenet echo " -> no usable Hailo; 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" else # Fail HERE, loudly, naming the fix — not at 3am. build_pose() runs outside the daemon's # try/except, so a missing backend is an uncaught traceback (ModuleNotFoundError on the # MoveNet path, KeyError 'HAILO_HEF' on the Hailo one) on every Restart=always cycle, # forever — a unit that looks installed and never processes a frame. # The board's own answer is the most diagnostic field here: it separates "no HEF" from # "driver installed, reboot pending" from "no Hailo at all" — three different fixes. HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \ | sed -n 's/.*Device Architecture:[[:space:]]*//p' | head -1)" echo "" >&2 echo "ERROR: no usable pose backend — refusing to install a daemon that cannot start." >&2 echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform') board=${HAILO_ARCH:-NOT DETECTED}" >&2 echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2 echo "" >&2 echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2 echo "" >&2 echo " A) Hailo-8 HAT (the intended path). The installer downloads the matching HEF" >&2 echo " ITSELF — you do not need to supply one. It only needs the board to answer:" >&2 echo " sudo apt-get install -y hailo-all && sudo reboot" >&2 echo " The driver only creates /dev/hailo0 after that reboot; until then the board" >&2 echo " cannot be identified and no HEF can be chosen for it. Then confirm:" >&2 echo " hailortcli fw-control identify # -> Device Architecture: HAILO8" >&2 echo " If identify prints NO 'Device Architecture' line (a Hailo-8 that reports no" >&2 echo " serial/identity), name the arch yourself and re-run:" >&2 echo " sudo HAILO_ARCH=hailo8 deploy/setup_online.sh # hailo8l = 13 TOPS AI Kit" >&2 echo " (Already have a HEF? sudo HAILO_HEF=/path/to/yolov8s_pose.hef deploy/setup_online.sh)" >&2 echo " B) CPU MoveNet:" >&2 echo " $PREFIX/venv/bin/pip install onnxruntime" >&2 echo "" >&2 echo " If onnxruntime AND hailo-all both refuse to install, check the userland arch:" >&2 echo " dpkg --print-architecture" >&2 echo " 'armhf' means 32-bit Pi OS. Neither package ships for it (uname -m still says" >&2 echo " aarch64 there — the kernel is 64-bit, the userland is not). Reflash 64-bit Pi OS." >&2 exit 1 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 # Re-running IS the normal Hailo flow: install -> reboot so the driver creates /dev/hailo0 # -> re-run. So [6/8] can legitimately reach a different verdict than last time. Leaving the # file wholly unchanged meant run #1's POSE_BACKEND=movenet survived while [6/8] printed # "POSE_BACKEND=hailo" — and with onnxruntime absent that is a ModuleNotFoundError crash # loop the installer just called a success. # Reconcile ONLY the backend keys, in place: DEVICE_SECRET and the operator's other answers # must survive untouched. cur="$(sed -n 's/^POSE_BACKEND=//p' "$ETC/edge.env" | head -1)" if [ "$cur" = "$POSE_BACKEND" ]; then echo " -> $ETC/edge.env already exists; left unchanged" else if [ -n "$cur" ]; then sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env" else printf 'POSE_BACKEND=%s\n' "$POSE_BACKEND" >> "$ETC/edge.env" fi grep -q '^HAILO_HEF=' "$ETC/edge.env" || printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env" grep -q '^MOVENET_ONNX=' "$ETC/edge.env" || printf 'MOVENET_ONNX=%s\n' "$ONNX_DEST" >> "$ETC/edge.env" # sed -i and >> can leave the file owned root:root, and the unit reads it as movedetect # via EnvironmentFile= — a wrong group here fails the start with a bare permission error. chown root:"$SVC_USER" "$ETC/edge.env" chmod 0640 "$ETC/edge.env" echo " -> $ETC/edge.env: POSE_BACKEND ${cur:-} -> $POSE_BACKEND (re-run reconciled)" fi else # Pre-provisioning file: values written on the BUILD machine so the operator only has to # type `sudo deploy/setup_online.sh`. A real env var always wins over the file, so the # long-form command documented at the top still overrides a stale device.env. PROVISION="${PROVISION:-$REPO_ROOT/deploy/device.env}" if [ -f "$PROVISION" ]; then echo ">> reading pre-provisioned values from $PROVISION" while IFS='=' read -r k v; do case "$k" in DEVICE_ID|DEVICE_SECRET|CAFE24_URL|CAMERAS) ;; *) continue ;; esac [ -n "${!k:-}" ] || printf -v "$k" '%s' "$v" done < <(grep -E '^[A-Z_][A-Z0-9_]*=' "$PROVISION") # digits matter: CAFE24_URL fi : "${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 server): " DEVICE_SECRET; echo; } [ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https:///move_detect — include the subdir!): " 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 diagnose() { echo "" >&2 echo "ERROR: move-detect-edge did not come up. systemd's reason:" >&2 echo "--------------------------------------------------------------------" >&2 systemctl status --no-pager --full move-detect-edge >&2 || true journalctl -xeu move-detect-edge -n 50 --no-pager >&2 || true echo "--------------------------------------------------------------------" >&2 echo "Full log: sudo journalctl -xeu move-detect-edge" >&2 exit 1 } # enable + restart rather than `enable --now`, so a re-run also picks up a rewritten # edge.env instead of no-op'ing on an already-running unit. systemctl enable move-detect-edge systemctl restart move-detect-edge || diagnose # Type=exec fails the job on exec-step errors, but a Python traceback *after* execve still # leaves `restart` returning 0. RestartSec=5 means a crash-looping unit reads as # "activating" here, which is-active correctly rejects. This is a crash detector, not a # readiness gate: under Type=exec the unit is active the moment execve() succeeds, so a # slow camera/Hailo init cannot false-fail it. sleep 5 systemctl is-active --quiet move-detect-edge || diagnose # is-active alone is phase-dependent: a crash at T<=~0.2s after execve is already back in # "active" by the time we sample (T + RestartSec=5 <= 5.2), so a crash-looping daemon can # read as healthy. NRestarts is phase-independent — for a unit we just started, any restart # at all means it has already died once. if [ "$(systemctl show -p NRestarts --value move-detect-edge 2>/dev/null || echo 0)" != "0" ]; then echo "" >&2 echo "ERROR: move-detect-edge has already restarted at least once — it is crash-looping." >&2 diagnose fi 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)"