move_detect/deploy/setup_online.sh
유창욱 9ed759befc docs(deploy): fix MySQL/runbook/secret guidance; offline camera probe
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
  defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
  only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
2026-07-25 19:21:01 +09:00

343 lines
20 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 \
# deploy/setup_online.sh
# MODELS ARE NOT DOWNLOADED HERE. The pose HEF (and optional MoveNet ONNX) ship IN the delivery,
# staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh) — air-gap rule "build pulls,
# target doesn't". Copy the delivery (with its models/) to the Pi and this installer places them.
# Have a HEF elsewhere on the Pi? Point at it: HAILO_HEF=/home/pi/yolov8s_pose.hef.
# 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/9] 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` (set -e above) 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/9]-[5/9] 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/9] 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/9] 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/9] 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/9]; [6/9] 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/9] MoveNet CPU model (from the delivery, not downloaded)"
# Models ship IN the delivery, staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh
# fetches them there) and carried across with the repo copy — air-gap rule "build pulls, target
# doesn't". This installer NEVER downloads a model on the Pi. On-device fetch was removed because
# it was fragile in two ways at once: a partial repo copy made `-m edge.tools.fetch_*` fail with a
# ModuleNotFoundError that the `|| echo WARN` mislabeled as "download failed", and the MoveNet URL
# is dead upstream anyway. MoveNet is the OPTIONAL CPU fallback; stage it only if present.
if cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null; then
echo " -> MoveNet ONNX staged from the delivery"
else
echo " note: no MoveNet ONNX in the delivery (expected for a Hailo device)."
fi
echo ">> [6/9] 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"
elif [ -f "$REPO_ROOT/models/yolov8s_pose.hef" ]; then
# The delivery carries the board-matching HEF, staged on the BUILD machine
# (build_offline_bundle.sh runs fetch_hailo_hef there, where hailortcli or --arch picks
# hailo8 vs hailo8l). This installer only PLACES it — it never downloads or guesses an arch
# on the Pi. Air-gap rule: build pulls, target doesn't.
cp "$REPO_ROOT/models/yolov8s_pose.hef" "$HEF_DEST"
echo " -> HEF staged from the delivery (models/yolov8s_pose.hef)"
else
echo " WARN: no Hailo HEF in the delivery (models/yolov8s_pose.hef) and HAILO_HEF unset —"
echo " no Hailo backend. The probe below decides if CPU MoveNet can carry the install."
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/9]), 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 HEF ships IN the delivery" >&2
echo " (models/yolov8s_pose.hef, staged on the build machine) — this installer does NOT" >&2
echo " download it. Read the table above:" >&2
echo " HEF=MISSING -> this Pi's copy of the delivery has no" >&2
echo " models/yolov8s_pose.hef. Re-copy it, or point at one:" >&2
echo " sudo HAILO_HEF=/path/to/yolov8s_pose.hef deploy/setup_online.sh" >&2
echo " hailo_platform=MISSING -> the HailoRT driver/runtime is not up yet:" >&2
echo " sudo apt-get install -y hailo-all && sudo reboot" >&2
echo " (/dev/hailo0 appears only after that reboot). Confirm:" >&2
echo " hailortcli fw-control identify # -> Device Architecture: HAILO8" >&2
echo " B) CPU MoveNet: put models/movenet_lightning.onnx in the delivery, and" >&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/9] 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/9] can legitimately reach a different verdict than last time. Leaving the
# file wholly unchanged meant run #1's POSE_BACKEND=movenet survived while [6/9] 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"
# device.env holds the plaintext HMAC secret. It arrived via "copy the working tree" and may
# be world-readable (umask 022). Lock it to root-only now so it is not left readable by every
# local account for the life of the device. The secret still lands in /etc/move-detect/edge.env
# (0640 root:movedetect); once the install succeeds you should DELETE this file and rotate the
# secret if it was ever exposed — see deploy/README.md "Secret handling".
chmod 600 "$PROVISION" 2>/dev/null || true
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/9] camera check (open every camera in CAMERAS and grab a frame)"
# Do this BEFORE enabling the unit. build_runtimes() logs-and-skips a camera it cannot open,
# so a wrong /dev/video index or an unseated ribbon would otherwise produce a green unit that
# silently monitors one camera less. Fail here, while someone is still at the Pi.
# SKIP_CAMERA_CHECK=1 for a headless/bench install with no cameras attached yet.
if [ "${SKIP_CAMERA_CHECK:-0}" = "1" ]; then
echo " -> skipped (SKIP_CAMERA_CHECK=1)"
else
# Probe exactly what the unit will run with: the CAMERAS line as written to edge.env, and
# the installed copy of the package ($PREFIX/src, not the repo checkout).
probe_cams="$(sed -n 's/^CAMERAS=//p' "$ETC/edge.env" | head -1)"
if ( cd "$PREFIX/src" && CAMERAS="$probe_cams" "$PREFIX/venv/bin/python" -m edge.probe \
--save "$STATE/probe" ); then
echo " -> every camera delivered a frame (previews in $STATE/probe)"
chown -R "$SVC_USER":"$SVC_USER" "$STATE/probe" 2>/dev/null || true
else
echo "" >&2
echo "ERROR: camera check failed — refusing to enable the service." >&2
echo " Fix CAMERAS in $ETC/edge.env and re-run, or install with SKIP_CAMERA_CHECK=1" >&2
echo " to enable the service anyway (it will monitor only the cameras that work)." >&2
exit 1
fi
fi
echo ">> [9/9] 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)"