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) ## Quick start — Pi with internet (one command)
If the Pi itself has internet, skip the offline bundle entirely. Copy this repo onto If the Pi itself has internet, skip the offline bundle entirely. Copy the built delivery
the Pi (git clone or scp), then: 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 ```bash
sudo deploy/setup_online.sh sudo deploy/setup_online.sh
``` ```
It installs the system packages (`python3-picamera2`, and `hailo-all` if present), It installs the system packages (`python3-picamera2`, and `hailo-all` if present), builds
builds the venv, pip-installs the runtime deps, fetches the MoveNet model, writes the venv, pip-installs the runtime deps, **places the pose model from the delivery's
`/etc/move-detect/edge.env` (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `models/`** (it does NOT download models on the Pi — the HEF/ONNX are fetched on the build
`CAMERAS`, or take them from env vars), installs the systemd unit, and starts the service. machine per the air-gap rule, `build pulls, target doesn't`), writes `/etc/move-detect/edge.env`
It begins on CPU MoveNet; drop a `yolov8s_pose.hef` in and pass `HAILO_HEF=...` (or edit (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `CAMERAS`, or take them from env
`edge.env`) to switch to Hailo. The rest of this doc is the **air-gapped** path. 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 ""
echo "Installed. Next:" echo "Installed. Next:"
echo " 1. edit $ETC/edge.env (device id/secret, server URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)" 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 " 2. camera check BEFORE enabling — a wrong /dev/video index or an unseated CSI ribbon"
echo " 3. journalctl -u move-detect-edge -f" 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. # 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 \ # 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 # deploy/setup_online.sh
# The Hailo HEF is normally auto-fetched for the detected board; you do not need HAILO_HEF. # MODELS ARE NOT DOWNLOADED HERE. The pose HEF (and optional MoveNet ONNX) ship IN the delivery,
# If the board can't be auto-detected (a Hailo-8 that reports no serial/identity), name it: # staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh) — air-gap rule "build pulls,
# sudo HAILO_ARCH=hailo8 deploy/setup_online.sh # 26 TOPS AI HAT+ (hailo8l = 13 TOPS AI Kit) # 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. # Interactive (a real terminal): it prompts for anything not passed.
set -euo pipefail set -euo pipefail
@ -111,13 +112,18 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install onnxruntime \ "$PREFIX/venv/bin/pip" install onnxruntime \
|| echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)." || echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)."
echo ">> [5/9] MoveNet CPU model" echo ">> [5/9] MoveNet CPU model (from the delivery, not downloaded)"
# Best-effort for the same reason: this model is inert without onnxruntime, and a download # Models ship IN the delivery, staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh
# hiccup must not kill an install whose backend is Hailo. fetch_movenet is pure stdlib, so it # fetches them there) and carried across with the repo copy — air-gap rule "build pulls, target
# runs fine with onnxruntime absent. # doesn't". This installer NEVER downloads a model on the Pi. On-device fetch was removed because
( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet ) \ # it was fragile in two ways at once: a partial repo copy made `-m edge.tools.fetch_*` fail with a
|| echo " WARN: MoveNet ONNX download failed — CPU MoveNet unavailable (Hailo unaffected)." # ModuleNotFoundError that the `|| echo WARN` mislabeled as "download failed", and the MoveNet URL
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true # 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" echo ">> [6/9] pose backend"
HEF_DEST="$PREFIX/models/yolov8s_pose.hef" 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; } [ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; }
cp "$HAILO_HEF" "$HEF_DEST" cp "$HAILO_HEF" "$HEF_DEST"
echo " -> HEF installed from HAILO_HEF=$HAILO_HEF" 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 else
# No HEF supplied: fetch the one matching THIS board. Nothing in this repo could previously echo " WARN: no Hailo HEF in the delivery (models/yolov8s_pose.hef) and HAILO_HEF unset —"
# produce a .hef — MoveNet had fetch_movenet.py, Hailo had nothing and no doc named a echo " no Hailo backend. The probe below decides if CPU MoveNet can carry the install."
# 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)."
fi fi
# Probe what can ACTUALLY run, in the venv the service will use. A HEF on disk is not enough # 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 "" >&2
echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2 echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2
echo "" >&2 echo "" >&2
echo " A) Hailo-8 HAT (the intended path). The installer downloads the matching HEF" >&2 echo " A) Hailo-8 HAT (the intended path). The HEF ships IN the delivery" >&2
echo " ITSELF — you do not need to supply one. It only needs the board to answer:" >&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 " 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 " (/dev/hailo0 appears only after that reboot). Confirm:" >&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 " hailortcli fw-control identify # -> Device Architecture: HAILO8" >&2
echo " If identify prints NO 'Device Architecture' line (a Hailo-8 that reports no" >&2 echo " B) CPU MoveNet: put models/movenet_lightning.onnx in the delivery, and" >&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 " $PREFIX/venv/bin/pip install onnxruntime" >&2
echo "" >&2 echo "" >&2
echo " If onnxruntime AND hailo-all both refuse to install, check the userland arch:" >&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}" PROVISION="${PROVISION:-$REPO_ROOT/deploy/device.env}"
if [ -f "$PROVISION" ]; then if [ -f "$PROVISION" ]; then
echo ">> reading pre-provisioned values from $PROVISION" 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 while IFS='=' read -r k v; do
case "$k" in DEVICE_ID|DEVICE_SECRET|CAFE24_URL|CAMERAS) ;; *) continue ;; esac case "$k" in DEVICE_ID|DEVICE_SECRET|CAFE24_URL|CAMERAS) ;; *) continue ;; esac
[ -n "${!k:-}" ] || printf -v "$k" '%s' "$v" [ -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 Documentation=file:/opt/move-detect/docs/pilot-runbook.md
Wants=network-online.target Wants=network-online.target
After=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 # Never stop retrying. This MUST live in [Unit]: systemd's parser table knows
# Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in # Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in
# [Service] it is an unknown key — silently ignored, leaving the default 5-starts/10s # [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 알림. - 서버: 기존 `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 ```bash
pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들) pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들)
@ -83,10 +111,13 @@ python -m edge # 또는 move-detect-edge
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것. ≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 → 6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송. 복구 후 `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로 > cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()`
> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다. > 기본값이며 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 가속은 후속. > **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2. > 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다.
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save` - **노트북 파일럿은 단일 카메라 · 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). - **드릴 게이트 · 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 on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
attack surface for no benefit. attack surface for no benefit.
If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL **Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression
(on `role`, `event_type`, `severity`, `level`) will be parsed but silently not enforced — default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a
harmless, since the application already whitelists these values before every insert 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`). (`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 ## 5. Storage directory

View file

@ -12,6 +12,7 @@ INSTALL_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundl
"install_offline.sh") "install_offline.sh")
BUILD_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle", BUILD_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle",
"build_offline_bundle.sh") "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). # Keys the daemon consumes (edge/config.py:load_config + edge/daemon.py:main).
REQUIRED_ENV = { REQUIRED_ENV = {
@ -161,26 +162,24 @@ def test_onnxruntime_is_optional_but_a_pose_backend_is_mandatory():
assert "import onnxruntime" in sh 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: with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read() sh = f.read()
# Nothing in this repo could produce a .hef: MoveNet had edge/tools/fetch_movenet.py, # On-device model fetch was removed: it failed two ways at once. A partial repo copy onto the
# Hailo had no equivalent and no doc named a source — so [6/8] demanded a file the # Pi made `python -m edge.tools.fetch_*` raise ModuleNotFoundError, which the `|| echo WARN`
# operator had no way to obtain, and the install dead-ended there. # fallbacks mislabeled as "download failed" (sending the operator after a phantom network/board
assert "edge.tools.fetch_hailo_hef" in sh # problem); and the MoveNet URL is dead upstream. Models now ship IN the delivery, staged on the
# An explicit HAILO_HEF that does not exist is a typo, not a request to fall back. The old # BUILD machine — air-gap rule "build pulls, target doesn't". The installer must NOT fetch.
# single guarded `cp` skipped it silently and printed the same HEF=MISSING table as the assert "edge.tools.fetch_hailo_hef" not in sh
# unset case, never echoing the bad value. 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 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. # from "driver installed, reboot pending" from "no Hailo at all" — three different fixes.
assert "hailortcli fw-control identify" in sh assert "hailortcli fw-control identify" in sh
assert "board=" 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(): 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 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(): def test_offline_bundle_fetches_and_stages_the_hailo_hef():
with open(BUILD_OFFLINE, encoding="utf-8") as f: with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read() sh = f.read()