The online Pi install required a four-variable command line typed on the device. setup_online.sh now reads DEVICE_ID / DEVICE_SECRET / CAFE24_URL / CAMERAS from deploy/device.env (written on the build machine) so the whole install is 'sudo deploy/setup_online.sh'. Real env vars still take precedence, so the documented long-form command overrides a stale device.env. device.env is gitignored -- it carries the device HMAC secret in plaintext.
316 lines
18 KiB
Bash
Executable file
316 lines
18 KiB
Bash
Executable file
#!/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:-<unset>} -> $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://<domain>/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" <<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 server 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
|
|
|
|
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)"
|