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.
This commit is contained in:
유창욱 2026-07-25 19:21:01 +09:00
parent bf5255e9eb
commit 9ed759befc
7 changed files with 208 additions and 85 deletions

View file

@ -23,19 +23,41 @@ being offline (events buffer in the local sqlite queue and flush on reconnect).
## 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:
If the Pi itself has internet, skip the offline bundle entirely. Copy the built delivery
folder onto the Pi — **copy the working tree, not a fresh `git clone`**: the pose model
`models/yolov8s_pose.hef` is git-ignored (a build artifact), so a clone arrives without it.
Use `scp -r`/USB from a build machine that has already staged `models/` (see step 1). 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.
It installs the system packages (`python3-picamera2`, and `hailo-all` if present), builds
the venv, pip-installs the runtime deps, **places the pose model from the delivery's
`models/`** (it does NOT download models on the Pi — the HEF/ONNX are fetched on the build
machine per the air-gap rule, `build pulls, target doesn't`), writes `/etc/move-detect/edge.env`
(prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `CAMERAS`, or take them from env
vars), probes every camera, installs the systemd unit, and starts the service. It uses Hailo
when `models/yolov8s_pose.hef` is present (or pass `HAILO_HEF=/path/to.hef`), else CPU MoveNet
when `models/movenet_lightning.onnx` + onnxruntime are present. The rest of this doc is the
**air-gapped** path.
### Secret handling (`deploy/device.env`)
If you pre-provision with `deploy/device.env`, that file holds this device's **HMAC secret in
plaintext**. It is gitignored (never committed), but it rides onto the Pi with the "copy the
working tree" transfer. After a successful install:
1. **Delete it:** `shred -u deploy/device.env` (the live secret now lives only in
`/etc/move-detect/edge.env`, `0640 root:movedetect`). The installer already `chmod 600`s it
while running, but that is defense-in-depth, not a substitute for removing it.
2. **Rotate if it was ever exposed** (shared over chat, a shell history, a shared drive):
update `move_devices.secret` on the server and the `DEVICE_SECRET` in the Pi's
`/etc/move-detect/edge.env`, then restart the service. A leaked secret lets anyone forge
signed events/heartbeats for this device.
Prefer `device.env` over passing `DEVICE_SECRET=...` on the `sudo` command line, which leaves the
secret in the shell history.
---

View file

@ -227,5 +227,10 @@ systemctl daemon-reload
echo ""
echo "Installed. Next:"
echo " 1. edit $ETC/edge.env (device id/secret, server URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
echo " 2. sudo systemctl enable --now move-detect-edge"
echo " 3. journalctl -u move-detect-edge -f"
echo " 2. camera check BEFORE enabling — a wrong /dev/video index or an unseated CSI ribbon"
echo " otherwise yields a green unit that silently monitors one camera less (build_runtimes"
echo " logs-and-skips a camera it cannot open). This mirrors setup_online.sh's [8/9] probe,"
echo " which the offline path cannot run at install time (CAMERAS is only set in step 1):"
echo " sudo sh -c 'cd $PREFIX/src && CAMERAS=\$(sed -n \"s/^CAMERAS=//p\" $ETC/edge.env) $PREFIX/venv/bin/python -m edge.probe'"
echo " 3. sudo systemctl enable --now move-detect-edge"
echo " 4. journalctl -u move-detect-edge -f"

View file

@ -12,10 +12,11 @@
#
# 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)
# 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
@ -111,13 +112,18 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install onnxruntime \
|| echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)."
echo ">> [5/9] 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 ">> [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"
@ -130,25 +136,16 @@ if [ -n "${HAILO_HEF:-}" ]; then
[ -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
# 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/9] 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)."
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
@ -186,17 +183,17 @@ else
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 " 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 " 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 " (/dev/hailo0 appears only after that reboot). 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 " 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
@ -241,6 +238,12 @@ else
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"

View file

@ -9,6 +9,12 @@ Description=move-detect edge daemon (workplace-safety AI CCTV)
Documentation=file:/opt/move-detect/docs/pilot-runbook.md
Wants=network-online.target
After=network-online.target
# Order after clock sync so Event.occurred_at is real wall time, not the stale fake-hwclock time a
# Pi 5 (no RTC battery) boots with. Detector TIMERS are already immune (they run on a monotonic
# clock — see daemon.main), so this only affects timestamp accuracy; Wants (not Requires) so an
# air-gapped Pi with no reachable NTP still starts rather than hanging forever on time-sync.
Wants=time-sync.target
After=time-sync.target
# Never stop retrying. This MUST live in [Unit]: systemd's parser table knows
# Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in
# [Service] it is an unknown key — silently ignored, leaving the default 5-starts/10s

View file

@ -1,17 +1,45 @@
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
# 파일럿 · 운영 런북
이 문서는 노트북 웹캠 하나로 **엣지 → 서버 → 관리자 응답**의 최소 경로를 돌리는 절차다.
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
이 문서는 두 경로를 다룬다. **자신의 상황에 맞는 절에서 시작하라:**
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
- **§0 폐쇄망 Pi 현장 설치·운영** — Hailo·Pi5 실장비를 오프라인 번들로 설치한 경우. systemd 유닛의
`Documentation=`이 이 문서를 가리키므로 현장 운영자는 **§0만** 보면 된다.
- **§1~§6 노트북 웹캠 개발 파일럿** — 개발/데모 머신에서 CPU MoveNet로 엔드투엔드를 돌리는 최소 경로.
**이 절들의 `pip install`·모델 `fetch` 명령은 개발/빌드 머신 전용이다 — 폐쇄망 Pi에서 실행하지 말 것.**
- 엣지: `python -m edge` (또는 `move-detect-edge`) — pose → 감지 → HMAC 서명 POST.
- 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
---
## 1. 빌드 머신 준비 (모델 · onnxruntime)
## 0. 폐쇄망 Pi 현장 설치·운영
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서
> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
실장비 Hailo·Pi5는 **오프라인 전체 번들**로 설치한다. 런타임 휠·pose 모델(HEF)·소스·systemd 유닛이
모두 번들에 동봉되어 있으므로 **Pi에서 인터넷을 받지 않는다** (아래 §1의 `pip install`/`fetch`는 절대
실행 금지 — 빌드 머신 전용).
1. **설치:** 번들 절차는 [deploy/README.md](../deploy/README.md)를 따른다. 빌드 머신에서
`deploy/bundle/build_offline_bundle.sh`(aarch64)로 번들을 만들어 Pi로 옮기고,
`sudo ./install_offline.sh`가 SHA256SUMS 검증 → 오프라인 휠 설치 → HEF 배치 → 유닛 설치를 수행한다.
(온라인 프로비저닝은 `sudo deploy/setup_online.sh` — 카메라 프로브까지 자동.)
2. **멀티카메라:** `/etc/move-detect/edge.env`의 `CAMERAS=csi:0:cam-01,uvc:0:cam-02`
형식으로 1 Pi에 2~4대를 배선한다(형식 `backend:device:camera_id`).
3. **enable 전 카메라 프로브** (설치 스크립트가 출력하는 명령) — 잘못된 인덱스/미체결 리본이 만드는
"초록불인데 한 대 덜 감시" 상태를 현장에서 잡는다.
4. **기동·확인:** `sudo systemctl enable --now move-detect-edge` → `journalctl -u move-detect-edge -f`.
5. **시크릿 위생:** 프로비저닝에 `deploy/device.env`를 썼다면 설치 후 삭제·필요시 로테이션
(deploy/README.md "Secret handling").
> 시크릿·서버 URL·모델 경로 등 env 키는 §3 표와 동일하다. 감지 임계값은 서버 대시보드의 구역·임계값이
> 다음 config 폴링에 반영된다.
---
## 1. 빌드/개발 머신 준비 (모델 · onnxruntime) — 폐쇄망 Pi에서 실행 금지
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망 Pi)에서는
> 인터넷을 받지 않으며, 아래 두 명령을 Pi에서 실행하면 안 된다(§0 참조 — Pi는 이미 번들로 설치됨).
> 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
```bash
pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들)
@ -83,10 +111,13 @@ python -m edge # 또는 move-detect-edge
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명).
7. **하트비트:** **30초 시간기반**(`heartbeat_interval_s=30`)으로 `POST /api/heartbeat`(서명).
부하와 무관하게 주기가 일정해 서버의 오프라인 판정(120초, 4배 마진)이 정상 장치를 오프라인으로
깜빡이지 않는다. temp/throttle/queue_pending/config_version 텔레메트리를 함께 싣는다.
> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로
> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다.
> cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()`
> 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를
> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계).
---
@ -102,11 +133,15 @@ cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations #
---
## 6. 파일럿 한계 (명시)
## 6. 한계 (명시)
- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속.
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2.
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save`
패키징은 별도 후속 태스크.
> **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를
> 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다.
- **노트북 파일럿은 단일 카메라 · CPU pose(저 fps)** — 폐쇄망 Pi(§0)는 Hailo 가속 + 멀티카메라
(`CAMERAS=` 다중 배선)를 사용한다.
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2(양끝 인프라는 존재, 배선 대기).
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook·미확인 에스컬레이션 메일은 후속(P3).
- **폐쇄망 전체 번들은 이미 존재한다** — `deploy/bundle/`에 오프라인 휠·HEF·소스·SHA256SUMS 검증
설치를 제공한다(§0). 노트북 파일럿만 `pip install`/`fetch`로 편의 실행할 뿐이다.
- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).

View file

@ -151,12 +151,19 @@ or version control.
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
attack surface for no benefit.
If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) will be parsed but silently not enforced —
harmless, since the application already whitelists these values before every insert
**Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression
default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a
hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an
older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1
blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel
(or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either
request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied
timestamp (the code already passes one on every insert).
Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) are parsed but silently not enforced — harmless,
since the application already whitelists these values before every insert
(`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`).
Everything else in the schema (tables, keys, indexes, `JSON` columns) requires only
MySQL 5.7+.
## 5. Storage directory

View file

@ -12,6 +12,7 @@ INSTALL_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundl
"install_offline.sh")
BUILD_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle",
"build_offline_bundle.sh")
PILOT_RUNBOOK = os.path.join(os.path.dirname(__file__), "..", "docs", "pilot-runbook.md")
# Keys the daemon consumes (edge/config.py:load_config + edge/daemon.py:main).
REQUIRED_ENV = {
@ -161,26 +162,24 @@ def test_onnxruntime_is_optional_but_a_pose_backend_is_mandatory():
assert "import onnxruntime" in sh
def test_installer_can_obtain_a_hef_without_the_operator_supplying_one():
def test_online_installer_stages_a_delivered_hef_and_never_downloads_one():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# Nothing in this repo could produce a .hef: MoveNet had edge/tools/fetch_movenet.py,
# Hailo had no equivalent and no doc named a source — so [6/8] demanded a file the
# operator had no way to obtain, and the install dead-ended there.
assert "edge.tools.fetch_hailo_hef" in sh
# An explicit HAILO_HEF that does not exist is a typo, not a request to fall back. The old
# single guarded `cp` skipped it silently and printed the same HEF=MISSING table as the
# unset case, never echoing the bad value.
# On-device model fetch was removed: it failed two ways at once. A partial repo copy onto the
# Pi made `python -m edge.tools.fetch_*` raise ModuleNotFoundError, which the `|| echo WARN`
# fallbacks mislabeled as "download failed" (sending the operator after a phantom network/board
# problem); and the MoveNet URL is dead upstream. Models now ship IN the delivery, staged on the
# BUILD machine — air-gap rule "build pulls, target doesn't". The installer must NOT fetch.
assert "edge.tools.fetch_hailo_hef" not in sh
assert "edge.tools.fetch_movenet" not in sh
# It PLACES the HEF the delivery carries (models/yolov8s_pose.hef), or an explicit HAILO_HEF path.
assert "$REPO_ROOT/models/yolov8s_pose.hef" in sh
# An explicit HAILO_HEF that does not exist is a typo, not a request to fall back.
assert "does not exist" in sh
# The failure message must show whether the BOARD answered: that field separates "no HEF"
# The failure table must still show whether the BOARD answered: that field separates "no HEF"
# from "driver installed, reboot pending" from "no Hailo at all" — three different fixes.
assert "hailortcli fw-control identify" in sh
assert "board=" in sh
# A Hailo-8 that reports no serial can leave auto-detect with nothing to read, so the
# operator must be able to force the arch. The installer forwards HAILO_ARCH as --arch,
# set -u-safe (${VAR:+..} expands to nothing when unset).
assert '${HAILO_ARCH:+--arch "$HAILO_ARCH"}' in sh
assert "HAILO_ARCH=hailo8" in sh
def test_reinstall_reconciles_pose_backend_in_the_env_file():
@ -218,6 +217,52 @@ def test_offline_installer_gates_the_pose_backend():
assert "POSE_BACKEND=$POSE_BACKEND" in sh or "POSE_BACKEND=${POSE_BACKEND}" in sh
def test_bundled_runbook_covers_the_airgap_pi_path_not_just_the_laptop_pilot():
# This runbook is staged into the offline bundle and is the systemd unit's Documentation=
# target, so a Pi field operator opens it. It must direct them to the air-gapped install path,
# and must NOT still claim the air-gap bundle does not exist (which used to send the operator to
# `pip install` on a closed network at commissioning).
with open(PILOT_RUNBOOK, encoding="utf-8") as f:
doc = f.read()
assert ("install_offline" in doc) or ("deploy/README.md" in doc)
assert "폐쇄망 전체 번들 아님" not in doc
def test_bundled_runbook_is_what_the_unit_documents_and_the_bundle_ships():
# Guard the wiring: the unit points at pilot-runbook.md and the bundle stages it. If either the
# filename or the staging changes, this fails instead of shipping a dangling Documentation=.
with open(UNIT, encoding="utf-8") as f:
unit = f.read()
with open(BUILD_OFFLINE, encoding="utf-8") as f:
build = f.read()
assert "pilot-runbook.md" in unit
assert "pilot-runbook.md" in build
def test_offline_installer_tells_the_operator_to_probe_cameras_before_enabling():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# The offline path cannot auto-probe at install time (CAMERAS is only set when the operator
# edits edge.env afterwards), so it must at least INSTRUCT the camera check before enable —
# otherwise a mis-indexed camera produces a green unit monitoring one camera less, the exact
# failure setup_online.sh's [8/9] probe was added to prevent. The probe command must precede
# the enable instruction.
assert "edge.probe" in sh
probe_at = sh.index("edge.probe")
enable_at = sh.index("systemctl enable --now move-detect-edge", probe_at)
assert probe_at < enable_at # probe instruction comes before the enable instruction
def test_offline_bundle_stages_the_probe_module_for_the_camera_check():
with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# The bundle stages the whole edge/ package, so `python -m edge.probe` is available on the
# air-gapped Pi. (A stale hand-built dist/ that predates edge/probe.py must be regenerated.)
assert 'cp -r "$REPO_ROOT/edge" "$STAGE/src/edge"' in sh
probe = os.path.join(os.path.dirname(__file__), "..", "edge", "probe.py")
assert os.path.isfile(probe) # the module the instruction invokes actually exists in source
def test_offline_bundle_fetches_and_stages_the_hailo_hef():
with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read()