move_detect/deploy/setup_online.sh
유창욱 f062667a65 feat(deploy): read pre-provisioned values from deploy/device.env
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.
2026-07-20 23:06:39 +09:00

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)"