feat(delivery): install-path fixes + cafe24 package + delivery-audit hardening

Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).

## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
  StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
  real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
  8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.

## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
  (docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
  walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
  scoped to the mount.

## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
  allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
  heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
  offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
  misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
  ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
This commit is contained in:
유창욱 2026-07-17 09:07:33 +09:00
parent 34dcf557e9
commit 9386dfc580
59 changed files with 2781 additions and 157 deletions

10
.gitattributes vendored
View file

@ -4,3 +4,13 @@
*.sh text eol=lf *.sh text eol=lf
*.service text eol=lf *.service text eol=lf
move-detect-edge.env.example text eol=lf move-detect-edge.env.example text eol=lf
# The cafe24 package is assembled on Windows but unpacked onto Linux hosting. A CRLF
# .htaccess makes Apache read directives with a trailing \r; PHP/JS tolerate CRLF but there
# is no reason to ship it. Normalize everything the package carries.
*.php text eol=lf
*.js text eol=lf
*.css text eol=lf
*.sql text eol=lf
*.md text eol=lf
htaccess.* text eol=lf

7
.gitignore vendored
View file

@ -12,7 +12,12 @@ dist/
# Edge runtime artifacts # Edge runtime artifacts
*.db *.db
*.hef *.hef
snapshots/ # Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
# edge never writes a snapshots directory anyway: they stay in memory and are base64'd into
# the queue db or POSTed (edge/queue.py, edge/alert.py).
/snapshots/
queue.db queue.db
# Secrets / local config # Secrets / local config

1
cloud/.gitignore vendored
View file

@ -2,3 +2,4 @@
/config.php /config.php
/storage/snapshots/* /storage/snapshots/*
!/storage/snapshots/.gitkeep !/storage/snapshots/.gitkeep
/.phpunit.result.cache

View file

@ -4,6 +4,11 @@ return [
'db_user' => 'CHANGEME', 'db_user' => 'CHANGEME',
'db_pass' => 'CHANGEME', 'db_pass' => 'CHANGEME',
'snapshot_dir' => __DIR__ . '/storage/snapshots', 'snapshot_dir' => __DIR__ . '/storage/snapshots',
// Every key App\Config reads is listed, defaults included. They all have `?? default`
// fallbacks, so an omitted key is silently absent rather than an error — an operator
// who never sees it here cannot know the knob exists.
'nonce_ttl' => 86400,
'timestamp_window' => 300, 'timestamp_window' => 300,
'rate_limit_per_min' => 60, 'rate_limit_per_min' => 60,
'ingest_ip_rate_limit_per_min' => 300,
]; ];

View file

@ -0,0 +1,152 @@
# 카페24 업로드 안내
이 폴더의 **내용물**을 카페24 FTP **최상위**에 그대로 올리면 됩니다.
고칠 파일은 **`config.php` 하나뿐**입니다.
---
## 1. 올리는 위치 (제일 중요)
**폴더 2개만** 올리면 됩니다. `move_detect_app` 과 `www`.
```
/ <- FTP 최상위
├── (기존 사이트 파일) <- 건드리지 않습니다
├── move_detect_app/ <- [올리기 1] 통째로. 웹에서 안 보입니다
│ ├── config.php 여기만 고칩니다
│ ├── src/
│ ├── vendor/
│ ├── storage/snapshots/ 사진 저장
│ └── schema.sql DB 넣고 나면 지웁니다
└── www/ <- 원래 있던 폴더 (다른 사이트가 씀)
├── (기존 사이트 파일) <- 건드리지 않습니다
└── move_detect/ <- [올리기 2] 이 폴더만 새로 생깁니다
├── index.php
├── .htaccess
├── _preflight.php
└── assets/
```
접속 주소는 **`https://내도메인/move_detect/`** 입니다.
`README-UPLOAD.md`(이 파일)는 올릴 필요 없습니다.
**왜 `move_detect_app` 을 `www` 밖에 두나:** `config.php` 에 DB 비밀번호가, `storage/snapshots/`
에 작업자 사진이 들어갑니다. `www` 안에 있으면 같은 `www` 를 쓰는 다른 사이트 주소로 접근될
수 있습니다. 한 폴더로 묶은 건 최상위에 있는 남의 파일(`vendor/`, `config.php` 같은 흔한
이름)과 겹쳐 덮어쓰는 사고를 막기 위해서입니다.
업로드가 끝나면 FTP 클라이언트에서 권한을 지정하세요. zip 과 FTP 는 유닉스 권한을
옮기지 못하므로, 패키지에 넣어둔 권한은 올리는 순간 사라집니다.
| 대상 | 권한 |
|---|---|
| `move_detect_app/config.php` | **600** (DB 비밀번호가 들어있습니다) |
| `move_detect_app/storage/snapshots/` | 750 |
`_preflight.php` 가 실제 권한과 노출 여부를 확인해줍니다.
---
## 2. `config.php` 고치기
DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
```php
'db_dsn' => 'mysql:host=localhost;dbname=여기에_DB이름;charset=utf8mb4',
'db_user' => '여기에_MySQL아이디',
'db_pass' => '여기에_MySQL비밀번호',
```
나머지 값은 그대로 두면 됩니다.
비밀번호는 메일/메신저/깃에 올리지 마세요.
---
## 3. DB 만들기
1. 카페24 관리자 → **phpMyAdmin** 열기
2. 이 앱용 DB 선택
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
4. 표 10개가 생겼는지 확인
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
---
## 4. HTTPS 켜기
카페24 관리자에서 **무료 SSL 인증서**를 켭니다.
선택이 아니라 필수입니다. 로그인 세션 쿠키가 `cookie_secure=true` 라서,
HTTP 로는 브라우저가 쿠키를 아예 안 보냅니다 → 로그인이 안 됩니다.
---
## 5. 점검하기
브라우저에서 엽니다:
```
https://내도메인/move_detect/_preflight.php
```
전부 **OK** 여야 합니다. 실패한 줄에 무엇을 하면 되는지 적혀 있습니다.
특히 이 둘은 카페24 요금제에 따라 없을 수 있고, 없으면 카페24에 문의해야 합니다:
| 항목 | 없으면 생기는 일 |
|---|---|
| **Argon2id** | 로그인 페이지가 즉시 죽습니다. 제일 흔한 실패 원인입니다. |
| **gd** | 이벤트는 저장되는데 **사진만 조용히 사라집니다.** 에러도 안 납니다. |
**확인이 끝나면 `www/move_detect/_preflight.php` 를 지우세요.**
---
## 6. 관리자 계정 만들기
앱에 회원가입 기능이 없습니다. 첫 계정은 직접 넣습니다.
비밀번호 해시를 만듭니다(로컬 PC 에서 실행 — 평문이 phpMyAdmin 기록에 남지 않게):
```bash
php -r "echo password_hash('여기에_진짜_비밀번호', PASSWORD_ARGON2ID), \"\n\";"
```
phpMyAdmin → SQL 탭:
```sql
INSERT INTO users (username, password_hash, role, site_scope, failed_attempts, locked_until)
VALUES ('admin', '여기에_해시_붙여넣기', 'admin', NULL, 0, 0);
```
---
## 7. 라즈베리파이(장비) 등록
장비마다 비밀키를 하나씩 만들어 DB 에 넣습니다.
```bash
php -r "echo bin2hex(random_bytes(32)), \"\n\";"
```
```sql
INSERT INTO devices (device_id, name, secret, config_version)
VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기', 1);
```
같은 값을 파이 쪽 `/etc/move-detect/edge.env` 의 `DEVICE_SECRET` 에 넣습니다.
`CAFE24_URL` 에는 `https://내도메인/move_detect` 를 넣습니다. (앱이 서브폴더에 있으므로
뒤의 `/move_detect` 까지 반드시 포함해야 합니다.)
비밀키는 메일/메신저로 보내지 마세요.
---
## 8. 마지막 확인
- [ ] `https://내도메인/move_detect/login` 이 열린다
- [ ] admin 으로 로그인되고 대시보드가 뜬다
- [ ] `schema.sql` 을 서버에서 지웠다
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)

View file

@ -0,0 +1,239 @@
#!/usr/bin/env bash
# Build the Cafe24 upload package: a folder whose contents are dragged into the hosting
# account's FTP root as-is.
#
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
# folder. The hosting account executes nothing — no shell, no composer, no installer: the
# operator uploads files over FTP and edits config.php. That is also why this lives under
# cloud/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
# root on the target.
#
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
# cloud/deploy/build_cafe24_package.sh
#
# Output: dist/cafe24/ and dist/move-detect-cafe24.zip
#
# dist/cafe24/
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
# │ ├── config.php the ONLY file the operator edits
# │ ├── src/ vendor/ storage/snapshots/
# │ └── schema.sql import via phpMyAdmin, then DELETE from the host
# ├── www/
# │ └── move_detect/ -> UPLOAD into the existing www/. Reached at
# │ ├── index.php https://<domain>/move_detect/
# │ └── .htaccess _preflight.php assets/
# └── README-UPLOAD.md -> for the operator to read; not uploaded
#
# Two upload targets, and everything private is ONE folder. That matters on a shared account:
# this www/ already serves another site, so the FTP root may hold that site's files too —
# spraying src/, vendor/ and config.php across the root risks overwriting them (vendor/ and
# config.php are ordinary names in any PHP project).
#
# The app is mounted in a SUBDIRECTORY of www/ for the same reason: nothing secret may sit
# under a docroot the other site also serves. index.php finds move_detect_app/ by walking up
# rather than hardcoding '/..', and the URL prefix comes from SCRIPT_NAME, so this same
# package also works dropped straight into www/.
#
# APP_DIR= override the www/ subdirectory; empty mounts the app directly in www/.
# PRIVATE_DIR= override the private folder name (must match index.php's APP_PRIVATE_DIR).
set -euo pipefail
APP_DIR="${APP_DIR-move_detect}"
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLOUD="$REPO_ROOT/cloud"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/cafe24"
ZIP="$OUT_DIR/move-detect-cafe24.zip"
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
echo ">> [1/6] clean stage: $STAGE"
rm -rf "$STAGE"
# WEB is where the app's own files land: www/move_detect by default, www/ when APP_DIR is
# empty. Everything else stays at the stage root = the FTP root = one level above www/.
WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"
# PRIV holds everything that must never be web-reachable, in ONE folder so it cannot
# collide with the co-hosted site's files at the FTP root.
PRIV="$STAGE/$PRIVATE_DIR"
mkdir -p "$WEB" "$PRIV/src" "$PRIV/storage/snapshots"
echo ">> [2/6] generating the no-dev autoloader"
# public/index.php hard-requires ../vendor/autoload.php, so vendor/ MUST ship — the app has
# no runtime composer *packages* (composer.lock's "packages" is empty), but it does need
# composer's generated autoloader to map App\ -> src/.
#
# --no-dev is load-bearing, not tidiness: the working tree's vendor/ carries PHPUnit and a
# dev autoload_files.php that eagerly requires tests/Support/InMemoryRepositories.php on
# every request — a file this package deliberately excludes. Shipping it fatals every hit.
#
# --classmap-authoritative because src/Auth/Session.php declares three symbols
# (SessionStore, ArraySessionStore, PhpSessionStore) under one filename matching none of
# them; that is why composer.json carries an explicit classmap entry for it. Plain PSR-4
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
# `new PhpSessionStore()` — the 4th statement of every request.
#
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites cloud/vendor/
# in place, so restore the dev autoloader on exit or local phpunit breaks.
restore_dev_autoloader() {
echo ">> restoring the dev autoloader in cloud/vendor (so local phpunit keeps working)"
( cd "$CLOUD" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
}
trap restore_dev_autoloader EXIT
( cd "$CLOUD" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
# Precondition for everything below: this package stages ONLY composer's generated
# autoloader, which is correct exactly while the app has no runtime packages. The day
# someone adds one to composer.json's `require`, that staging silently omits its code and
# the host fatals on a missing class. Fail here, on the commit that adds the dependency,
# rather than on the hosting account.
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
"$CLOUD/composer.lock" || {
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
echo " autoloader, so their code would be missing from the package. Teach" >&2
echo " step [3/6] to stage the real vendor tree before shipping again." >&2
exit 1
}
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
mkdir -p "$PRIV/vendor/composer"
cp "$CLOUD/vendor/autoload.php" "$PRIV/vendor/autoload.php"
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in
# phpunit/, sebastian/, myclabs/ and friends.
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
cp "$CLOUD/vendor/composer/$f" "$PRIV/vendor/composer/$f"
done
cp "$CLOUD/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
# regenerate them — they are the package inventory written by the last `composer install`,
# so they still list every dev package (and its exact version, which is a free CVE shopping
# list for anyone who can read them). Nothing here needs them: autoload_real.php eagerly
# requires only platform_check.php, and Composer\InstalledVersions reads installed.php
# lazily, on a call this app never makes.
# Guard: the dev autoload_files.php requires tests/Support/InMemoryRepositories.php, which
# is not in this package. If it slipped through, every request 500s — fail the build loudly
# rather than ship it.
if [ -f "$PRIV/vendor/composer/autoload_files.php" ]; then
echo "ERROR: autoload_files.php present — that is the DEV autoloader (it requires tests/)." >&2
echo " composer dump-autoload --no-dev did not take effect." >&2
exit 1
fi
# Guard: no autoload map may point outside src/. Grep the maps that actually drive loading
# rather than the whole tree — InstalledVersions.php names PHPUnit in a docblock, which is
# harmless prose, and matching it would make this check cry wolf.
for map in autoload_classmap.php autoload_psr4.php autoload_static.php autoload_namespaces.php; do
if grep -qiE "phpunit|sebastian|myclabs|/tests/" "$PRIV/vendor/composer/$map"; then
echo "ERROR: $map references dev deps or tests/ — --no-dev did not take effect." >&2
exit 1
fi
done
# Guard: no dev package directory may exist in the staged vendor/.
if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -q .; then
echo "ERROR: unexpected package directories under the staged vendor/:" >&2
find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer >&2
exit 1
fi
echo ">> [4/6] staging app source"
cp -r "$CLOUD/src/." "$PRIV/src/"
cp -r "$CLOUD/public/." "$WEB/"
cp "$CLOUD/schema.sql" "$PRIV/schema.sql"
# The hardened DocumentRoot .htaccess replaces cloud/public/'s dev-local rewrite-only one.
cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess"
cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php"
cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php"
cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
# storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the
# failure is silent rather than loud: IngestController does
# `if (file_put_contents(...) === false) continue;`, so a missing dir means the snapshot is
# dropped while the API still answers 201 to the edge device. The event row lands in MySQL
# with no image, the dashboard shows an event with no photo, and with display_errors=Off
# (correct for production) not even a warning survives. That is permanent evidence loss.
touch "$PRIV/storage/snapshots/.gitkeep"
# Build-local only. Neither zip nor FTP carries unix modes, so these do NOT reach the host —
# they are correct for someone who rsyncs the stage directly, and a no-op otherwise. The
# host-side modes are the operator's job (README-UPLOAD.md) and _preflight.php verifies the
# live value rather than trusting these.
chmod 750 "$PRIV/storage/snapshots"
chmod 600 "$PRIV/config.php"
echo ">> [5/6] verifying the package resolves every class index.php touches"
# Prove the autoloader works against the STAGED layout, not the repo's.
php -r '
$stage = $argv[1];
require $stage . "/vendor/autoload.php";
$classes = ["App\\Config", "App\\Http\\Router", "App\\Http\\Request", "App\\Http\\Response",
"App\\Db\\Database", "App\\Auth\\PhpSessionStore", "App\\Auth\\AuthController",
"App\\Auth\\CsrfController", "App\\Auth\\Rbac", "App\\Ingest\\IngestController",
"App\\Ingest\\EventValidator", "App\\Ingest\\ImageProcessor", "App\\Api\\ConfigController",
"App\\Api\\HeartbeatController", "App\\Dashboard\\EventListController",
"App\\Dashboard\\EventDetailController", "App\\Dashboard\\SnapshotProxy",
"App\\Dashboard\\ZoneConfigController", "App\\Geometry\\Polygon",
"App\\Repository\\Pdo\\PdoDeviceRepository", "App\\Repository\\Pdo\\PdoNonceRepository",
"App\\Repository\\Pdo\\PdoRateLimiterRepository", "App\\Repository\\Pdo\\PdoEventRepository",
"App\\Repository\\Pdo\\PdoSnapshotRepository", "App\\Repository\\Pdo\\PdoUserRepository",
"App\\Repository\\Pdo\\PdoZoneRepository", "App\\Repository\\Pdo\\PdoThresholdRepository",
"App\\Repository\\Pdo\\PdoAuditRepository"];
$missing = array_values(array_filter($classes, fn($c) => !class_exists($c) && !interface_exists($c)));
if ($missing) { fwrite(STDERR, "ERROR: unresolvable in the package: " . implode(", ", $missing) . "\n"); exit(1); }
echo " -> " . count($classes) . " classes resolve through the packaged autoloader\n";
' "$PRIV"
# Prove index.php ACTUALLY finds $PRIV from where it is mounted, at the real depth, rather
# than trusting that the tree looks right. This is the check that would have caught a
# hardcoded '/..' — the walk-up must cross www/ and land on the private folder.
php -r '
[$_, $web, $priv] = $argv;
$found = null;
$isAppRoot = fn(string $d) => is_file("$d/config.php") && is_file("$d/vendor/autoload.php")
&& is_file("$d/src/Config.php");
for ($dir = dirname($web), $i = 0; $i < 4; $i++) {
foreach (["$dir/move_detect_app", $dir] as $c) { if ($isAppRoot($c)) { $found = $c; break 2; } }
if (dirname($dir) === $dir) break;
$dir = dirname($dir);
}
if ($found === null) { fwrite(STDERR, "ERROR: index.php could not resolve the app root from $web\n"); exit(1); }
if (realpath($found) !== realpath($priv)) {
fwrite(STDERR, "ERROR: app root resolved to $found, expected $priv\n"); exit(1);
}
echo " -> index.php resolves its app root across www/ to " . basename($found) . "/\n";
' "$WEB" "$PRIV"
# Every staged .php must parse under the build php. Catches a truncated copy.
while IFS= read -r -d '' f; do
php -l "$f" >/dev/null || { echo "ERROR: php -l failed: $f" >&2; exit 1; }
done < <(find "$STAGE" -name '*.php' -print0)
echo " -> every staged .php parses"
echo ">> [6/6] zipping"
rm -f "$ZIP"
if command -v zip >/dev/null 2>&1; then
( cd "$STAGE" && zip -rq "$ZIP" . )
elif command -v powershell >/dev/null 2>&1; then
# No zip on the box (common on Windows/Git Bash) — PowerShell ships Compress-Archive.
win_stage="$(cygpath -w "$STAGE" 2>/dev/null || echo "$STAGE")"
win_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")"
powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force"
else
echo " WARN: no zip/powershell — skipping the archive; upload $STAGE/ directly."
fi
echo ""
echo "DONE."
echo " package : $STAGE"
[ -f "$ZIP" ] && echo " zip : $ZIP"
echo ""
echo "Upload: put the CONTENTS of $STAGE into the Cafe24 FTP root."
echo " www/* merges into the existing www/ (DocumentRoot)"
echo " src/ vendor/ storage/ config.php sit BESIDE www/, never inside it"
echo ""
echo "Then, on the host: edit config.php (DB name/user/password), import schema.sql via"
echo "phpMyAdmin and delete it, and open https://<domain>/_preflight.php to confirm the PHP"
echo "build is usable — then DELETE _preflight.php. See README-UPLOAD.md."

View file

@ -0,0 +1,30 @@
<?php
// ============================================================================
// move-detect — 설정 파일 (이 파일만 고치면 됩니다)
// 위치: 카페24 FTP 최상위 /config.php (www/ 안이 아닙니다!)
// 권한: 업로드 후 FTP 클라이언트에서 600 으로 지정하세요. zip/FTP 는 유닉스 권한을
// 옮기지 못하므로 여기서 미리 줄 수 없습니다. _preflight.php 가 확인해줍니다.
// ============================================================================
//
// 고칠 곳은 아래 "1. 데이터베이스" 세 줄뿐입니다. 값은 카페24 관리자 페이지의
// MySQL 정보에서 그대로 복사하세요. 나머지는 손대지 않아도 동작합니다.
//
return [
// ---- 1. 데이터베이스 (반드시 채우세요) ------------------------------
// CHANGEME_DB_NAME → 카페24에서 만든 DB 이름
'db_dsn' => 'mysql:host=localhost;dbname=CHANGEME_DB_NAME;charset=utf8mb4',
'db_user' => 'CHANGEME_DB_USER', // 카페24 MySQL 아이디
'db_pass' => 'CHANGEME_DB_PASSWORD', // 카페24 MySQL 비밀번호 — 비밀. 어디에도 붙여넣지 마세요.
// ---- 2. 저장 경로 (그대로 두세요) -----------------------------------
// 여기서 __DIR__ 은 이 config.php 가 있는 폴더(= FTP 최상위)입니다.
// src/Config.php 의 기본값은 '/../storage/snapshots' 이지만 그것은 src/ 기준이라
// '..' 이 하나 더 붙습니다. 둘 다 결국 같은 <최상위>/storage/snapshots 를 가리킵니다.
'snapshot_dir' => __DIR__ . '/storage/snapshots',
// ---- 3. 조절값 (기본값. 필요할 때만 바꾸세요) ------------------------
'nonce_ttl' => 86400, // 재전송 방지 nonce 보관 시간(초)
'timestamp_window' => 300, // HMAC 허용 시간 오차(초)
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
];

64
cloud/deploy/htaccess.www Normal file
View file

@ -0,0 +1,64 @@
# move-detect cloud — belongs in the DocumentRoot (Cafe24: /home/hosting_users/<ID>/www/).
# Built into the upload package as www/.htaccess by build_cafe24_package.sh.
#
# mod_rewrite is confirmed on Cafe24 web hosting. mod_headers and mod_authz_core are not,
# so every dependent block is wrapped in <IfModule> and degrades instead of 500-ing.
<IfModule mod_rewrite.c>
RewriteEngine On
# 1) Force HTTPS. This is REQUIRED, not hardening: src/Auth/Session.php hard-codes
# 'cookie_secure' => true, so over plain HTTP the browser never sends the session
# cookie back — login appears to succeed, then silently loses the session on the very
# next request. All three conditions must hold before redirecting, so a TLS-terminating
# proxy that sets any one of them cannot trigger a redirect loop.
# R=302 on purpose. Promote to R=301 only after verifying in a real browser: a wrong
# 301 is cached by clients and is very hard to walk back.
RewriteCond %{HTTPS} !=on
RewriteCond %{SERVER_PORT} !^443$
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=302,L]
# 2) Front controller — every non-file, non-directory path goes to index.php.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
</IfModule>
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "no-referrer"
</IfModule>
# 3) No directory listings. Backstop against an unauthenticated listing of
# storage/snapshots/ (JPEGs of people at work) if it is ever misplaced under the docroot.
Options -Indexes
# 4) Deny dotfiles (.git, .env, .htaccess, .user.ini, .phpunit.result.cache, ...).
<FilesMatch "^\.">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# 5) Deny anything Apache would serve as PLAINTEXT for lack of a handler. schema.sql is the
# sharp one: it is full DDL, and unlike a .php file PHP would not swallow it.
<FilesMatch "\.(sql|md|json|lock|dist|ini|log|sh|ya?ml|cache)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# NOTE: there are deliberately no deny rules for src/ vendor/ storage/ config.php here.
# Those live OUTSIDE the DocumentRoot (siblings of www/), where no .htaccess under www/
# could reach them and none is needed. Placement is the control; keep them out of www/.

160
cloud/deploy/preflight.php Normal file
View file

@ -0,0 +1,160 @@
<?php
// Uploaded as www/_preflight.php. Open https://<domain>/_preflight.php once, confirm every
// row is OK, then DELETE THIS FILE.
//
// It exists because three host-level facts are unknowable from the repo and every one of
// them fails badly on a stock PHP build:
// * no argon2 -> src/Security/Password.php calls password_hash(..., PASSWORD_ARGON2ID)
// unguarded, and AuthController::DUMMY_HASH runs verify() on EVERY login
// attempt including unknown usernames -> the login page fatals for
// everyone, instantly.
// * no gd -> ImageProcessor::reencode() returns ok=false and IngestController's
// `continue` swallows it: events save with no snapshot, API still 201s.
// * no pdo_mysql -> every request 500s at Database::connect(). (mysqli being present does
// NOT imply pdo_mysql.)
//
// Deliberately prints booleans only — no phpinfo(), no paths, no config values — so the
// window between upload and deletion leaks nothing useful.
declare(strict_types=1);
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex, nofollow');
// Find the app root exactly the way public/index.php does — container first, then the bare
// directory, probing each level for any marker. Using ANY marker (not all three) is
// deliberate here and differs from index.php on purpose: this page exists to DIAGNOSE a
// half-finished upload, so it must still locate a root that is missing vendor/ or config.php
// and then report precisely which piece is absent.
const APP_PRIVATE_DIR = 'move_detect_app';
$root = null;
$hasAnyMarker = static fn (string $d): bool =>
is_file($d . '/config.php') || is_file($d . '/vendor/autoload.php') || is_file($d . '/src/Config.php');
for ($d = dirname(__DIR__), $i = 0; $i < 4; $i++) {
foreach ([$d . '/' . APP_PRIVATE_DIR, $d] as $cand) {
if ($hasAnyMarker($cand)) { $root = $cand; break 2; }
}
if (dirname($d) === $d) break;
$d = dirname($d);
}
// Nothing found anywhere IS the diagnosis: fall back to where the files SHOULD be, so every
// row below reports a truthful "missing" instead of crashing on null.
$root = $root ?? dirname(dirname(__DIR__)) . '/' . APP_PRIVATE_DIR;
$checks = [
'PHP >= 8.2' => [
PHP_VERSION_ID >= 80200,
'Cafe24 관리자에서 PHP 버전을 8.2 이상으로 올리세요. (주의: 카페24는 PHP 버전 변경 시 데이터와 DB가 초기화된다고 안내합니다. 반드시 업로드 전에 정하세요.)',
],
'pdo_mysql 확장' => [
in_array('mysql', PDO::getAvailableDrivers(), true),
'DB 연결이 전혀 안 됩니다. 카페24에 pdo_mysql 활성화를 요청하세요.',
],
'gd 확장' => [
function_exists('imagecreatefromstring') && function_exists('imagejpeg'),
'사진(스냅샷)이 조용히 사라집니다. 이벤트는 저장되는데 사진만 없어집니다. 카페24에 gd 활성화를 요청하세요.',
],
'Argon2id 암호 해시' => [
defined('PASSWORD_ARGON2ID') && in_array('argon2id', password_algos(), true),
'로그인 페이지가 즉시 죽습니다. 이게 가장 흔한 배포 실패 원인입니다. 카페24에 Argon2 포함 PHP 빌드를 요청하세요.',
],
'config.php 있음' => [
is_readable($root . '/config.php'),
'FTP 최상위(www 밖)에 config.php 를 올리세요.',
],
// Checks the parsed VALUES, not the file text. Grepping the raw file for 'CHANGEME'
// would also match the template's explanatory comments, which survive a correct edit —
// so a properly-filled config would report 실패 forever.
'config.php 를 수정했음' => (function () use ($root) {
$fix = 'config.php 안의 CHANGEME 를 실제 DB 이름/아이디/비밀번호로 바꾸세요.';
if (!is_readable($root . '/config.php')) {
return [false, $fix];
}
$cfg = @require $root . '/config.php';
if (!is_array($cfg)) {
return [false, 'config.php 가 PHP 배열을 return 하지 않습니다. 파일이 깨졌는지 확인하세요.'];
}
foreach (['db_dsn', 'db_user', 'db_pass'] as $k) {
if (str_contains((string)($cfg[$k] ?? ''), 'CHANGEME')) {
return [false, $fix];
}
}
return [true, ''];
})(),
'storage/snapshots 쓰기 가능' => [
is_dir($root . '/storage/snapshots') && is_writable($root . '/storage/snapshots'),
'사진이 조용히 사라집니다. 폴더를 만들고 권한 750 으로 주세요.',
],
'src/ 있음' => [
is_readable($root . '/src/Config.php'),
'src/ 폴더를 FTP 최상위(www 밖)에 올리세요.',
],
'vendor/ 오토로더 있음' => [
is_readable($root . '/vendor/autoload.php'),
'vendor/ 폴더를 FTP 최상위(www 밖)에 올리세요. 없으면 모든 페이지가 죽습니다.',
],
// These probe THIS directory (the one the web serves), not $root. If the secrets turned
// up here, they are downloadable.
'src/ 가 웹에 노출되지 않음' => [
!is_readable(__DIR__ . '/src/Config.php'),
'src/ 가 웹에서 접근 가능한 폴더 안에 있습니다. FTP 최상위(www 밖)로 옮기세요.',
],
'config.php 가 웹에 노출되지 않음' => [
!is_readable(__DIR__ . '/config.php'),
'config.php 가 웹에서 접근 가능한 폴더 안에 있습니다. DB 비밀번호가 노출됩니다. FTP 최상위(www 밖)로 옮기세요.',
],
// Reports the LIVE mode rather than trusting the build. The package's chmod 600 cannot
// survive a zip round-trip or an FTP upload (neither carries unix modes), so the only
// honest place to check this is here, on the host. 0077 — NOT 0o077, which is PHP 8.1+
// syntax and would make this file fail to parse on the very old build we want to warn about.
'config.php 권한이 600' => [
is_readable($root . '/config.php')
&& (fileperms($root . '/config.php') & 0077) === 0,
'FTP 클라이언트에서 config.php 권한을 600 으로 바꾸세요. DB 비밀번호가 들어있습니다.',
],
'HTTPS 로 접속됨' => [
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['SERVER_PORT'] ?? '') === '443'
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
'세션 쿠키가 cookie_secure=true 라서 HTTP 로는 로그인이 안 됩니다. 카페24에서 무료 SSL 을 켜세요.',
],
];
$failed = array_keys(array_filter($checks, fn($c) => !$c[0]));
?>
<!doctype html>
<meta charset="utf-8">
<title>move-detect 설치 점검</title>
<style>
body { font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
table { border-collapse: collapse; width: 100%; }
td { border-bottom: 1px solid #e5e5e5; padding: .5rem .4rem; vertical-align: top; }
td:first-child { white-space: nowrap; }
.ok { color: #0a7c2f; font-weight: 600; }
.no { color: #c02626; font-weight: 600; }
.fix { color: #666; font-size: 13px; }
.banner { padding: .75rem 1rem; border-radius: 6px; margin: 1rem 0; }
.good { background: #e8f6ec; }
.bad { background: #fdeaea; }
.warn { background: #fff6d8; border: 1px solid #e8cf7a; }
</style>
<h1>move-detect 설치 점검</h1>
<?php if ($failed): ?>
<p class="banner bad"><strong><?= count($failed) ?>개 항목 실패.</strong> 아래 빨간 줄의 안내를 먼저 해결하세요.</p>
<?php else: ?>
<p class="banner good"><strong>전부 통과.</strong> <a href="/login">/login</a> 으로 접속해 관리자 로그인을 확인하세요.</p>
<?php endif; ?>
<table>
<?php foreach ($checks as $name => [$ok, $fix]): ?>
<tr>
<td><?= htmlspecialchars($name, ENT_QUOTES) ?></td>
<td class="<?= $ok ? 'ok' : 'no' ?>"><?= $ok ? 'OK' : '실패' ?></td>
<td class="fix"><?= $ok ? '' : htmlspecialchars($fix, ENT_QUOTES) ?></td>
</tr>
<?php endforeach; ?>
</table>
<p class="banner warn">
<strong>확인이 끝나면 이 파일(<code>www/_preflight.php</code>)을 반드시 지우세요.</strong>
진단용이라 계속 열어둘 이유가 없습니다.
</p>

View file

@ -15,31 +15,66 @@ web root at all.
``` ```
/home/<account>/ <- NOT web-reachable /home/<account>/ <- NOT web-reachable
├── src/ <- App\ classes (App\Db, App\Repository\Pdo, ...) ├── src/ <- App\ classes (App\Db, App\Repository\Pdo, ...)
├── vendor/ <- Composer's generated autoloader ONLY (see below)
├── storage/ ├── storage/
│ └── snapshots/ <- re-encoded JPEGs; must be writable by the PHP process │ └── snapshots/ <- re-encoded JPEGs; must be writable by the PHP process
├── config.php <- copied from config.example.php, secrets filled in (see §3) ├── config.php <- copied from config.example.php, secrets filled in (see §3)
├── schema.sql <- only needed transiently for the phpMyAdmin import (§4); ├── schema.sql <- only needed transiently for the phpMyAdmin import (§4);
│ delete it from the server afterwards │ delete it from the server afterwards
└── public/ <- web root ("html" folder in Cafe24's control panel) └── public/ <- web root. On Cafe24 it is named `www`; when that `www`
├── index.php ├── index.php already serves another site the app is mounted in a
├── .htaccess ├── .htaccess subdirectory (`www/move_detect/`) and the entries above
└── assets/ └── assets/ sit at the FTP root — see below.
``` ```
`cloud/public/index.php` resolves its dependencies as `__DIR__ . '/../config.php'`, `cloud/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
`__DIR__ . '/../src/View/...'`, etc. — so `src/`, `config.php`, and `storage/` must be itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
exactly one directory above `public/`, matching the layout above. hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a
subdirectory of one — in the latter case the app root is two levels up, not one, because
nothing secret may sit under a docroot the other site also serves.
**Do not upload `vendor/`.** It contains only `phpunit` and its dev dependencies (see The URL prefix comes from `SCRIPT_NAME` (`App\Http\Request::baseFrom()`), which the web
`cloud/composer.json` — `require-dev` only, `require` has no packages beyond the `php` server sets — never from client input. `Request::pathFor()` strips it before routing, and
version constraint). Shipping it would put a large, unused, occasionally-vulnerable views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without that, routes
dependency tree on a production box for no benefit; leave it off the transfer entirely. declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
resolve against the co-hosted site.
If your Cafe24 plan cannot place folders outside the web root, upload `src/`, `storage/`, `cloud/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
and `config.php` into `public/` but add explicit deny rules for them in (empty) to mount the app directly in `www/`.
`public/.htaccess` (e.g. `RewriteRule ^(src|storage)/ - [F]` and
`RewriteRule ^config\.php$ - [F]`, placed **before** the existing rewrite-to-index rule) — **`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
placing them outside the web root is strongly preferred and should be the default choice. `require __DIR__ . '/../vendor/autoload.php'`, so a transfer without `vendor/` fatals on
every request, including §10's own smoke check. There are no runtime Composer *packages*
(`composer.lock`'s `packages` is empty), but the app does need Composer's generated
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
```bash
cd cloud && composer dump-autoload --no-dev --classmap-authoritative --no-interaction
```
That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy
exactly those. Do **not** upload the working tree's `vendor/` as-is — it carries PHPUnit
and a dev `autoload_files.php` that eagerly `require`s
`tests/Support/InMemoryRepositories.php` on every request, a file this deployment
deliberately excludes.
`--classmap-authoritative` is not optional: `src/Auth/Session.php` declares three symbols
(`SessionStore`, `ArraySessionStore`, `PhpSessionStore`) under a filename matching none of
them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain
PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`.
`cloud/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the
tree below automatically — prefer it over assembling the transfer by hand.
If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is
unsupported, and the obvious workaround does not work.** `public/index.php` resolves
`../vendor/autoload.php` (line 3), `../config.php` (line 17), and `../src/View/` (line 43)
relative to itself, so moving those *into* `public/` puts them exactly where those three
`require`s do not look: every request fatals. Making it work needs source changes to
`index.php`'s path resolution plus deny rules for `src/`, `vendor/`, `storage/` **and**
`config.php` — not deny rules alone. Raise it before deploying rather than improvising:
`config.php` holds the DB password, and a half-applied version of this is how it ends up
web-readable.
## 2. Host prerequisites — verify before importing data ## 2. Host prerequisites — verify before importing data

View file

@ -8,6 +8,11 @@
(function () { (function () {
'use strict'; 'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME. Every URL below must
// go through it — a bare '/api/...' would address the other site instead.
var B = window.APP_BASE || '';
var POLL_INTERVAL_MS = 5000; var POLL_INTERVAL_MS = 5000;
var SEVERITY_LABELS = { var SEVERITY_LABELS = {
@ -22,7 +27,7 @@
var csrfToken = null; var csrfToken = null;
function loadCsrfToken() { function loadCsrfToken() {
return fetch('/api/csrf', { headers: { Accept: 'application/json' } }) return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; }) .then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data && data.token) csrfToken = data.token; }) .then(function (data) { if (data && data.token) csrfToken = data.token; })
.catch(function () { /* ack will fail with 403 and re-enable the button */ }); .catch(function () { /* ack will fail with 403 and re-enable the button */ });
@ -119,7 +124,7 @@
type.className = 'event-type'; type.className = 'event-type';
var typeLink = document.createElement('a'); var typeLink = document.createElement('a');
typeLink.className = 'event-detail-link'; typeLink.className = 'event-detail-link';
typeLink.href = '/events/' + encodeURIComponent(ev.event_id) + '/view'; typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
typeLink.textContent = String(ev.event_type || ''); typeLink.textContent = String(ev.event_type || '');
type.appendChild(typeLink); type.appendChild(typeLink);
meta.appendChild(type); meta.appendChild(type);
@ -150,7 +155,7 @@
function acknowledge(eventId, btn) { function acknowledge(eventId, btn) {
btn.disabled = true; btn.disabled = true;
fetch('/events/' + encodeURIComponent(eventId) + '/ack', { fetch(B + '/events/' + encodeURIComponent(eventId) + '/ack', {
method: 'POST', method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' } headers: { 'X-Csrf-Token': csrfToken || '' }
}) })
@ -185,10 +190,10 @@
} }
function loadEvents() { function loadEvents() {
return fetch('/api/events', { headers: { Accept: 'application/json' } }) return fetch(B + '/api/events', { headers: { Accept: 'application/json' } })
.then(function (res) { .then(function (res) {
if (res.status === 401) { if (res.status === 401) {
window.location.href = '/login'; window.location.href = B + '/login';
return null; return null;
} }
if (!res.ok) throw new Error('list_failed:' + res.status); if (!res.ok) throw new Error('list_failed:' + res.status);
@ -205,7 +210,7 @@
var logoutBtn = document.getElementById('logout-btn'); var logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) { if (logoutBtn) {
logoutBtn.addEventListener('click', function () { logoutBtn.addEventListener('click', function () {
fetch('/logout', { method: 'POST' }).then(function () { window.location.href = '/login'; }); fetch(B + '/logout', { method: 'POST' }).then(function () { window.location.href = B + '/login'; });
}); });
} }

View file

@ -10,6 +10,10 @@
(function () { (function () {
'use strict'; 'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
var B = window.APP_BASE || '';
var SEVERITY_LABELS = { var SEVERITY_LABELS = {
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, critical: { icon: '⛔', text: '위급', cls: 'sev-critical' },
high: { icon: '⚠', text: '높음', cls: 'sev-high' }, high: { icon: '⚠', text: '높음', cls: 'sev-high' },
@ -97,7 +101,7 @@
snaps.forEach(function (s) { snaps.forEach(function (s) {
if (!s || !s.file_uuid) return; if (!s || !s.file_uuid) return;
var img = document.createElement('img'); var img = document.createElement('img');
img.src = '/snap/' + encodeURIComponent(s.file_uuid); img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
img.alt = '사고 스냅샷'; img.alt = '사고 스냅샷';
img.className = 'detail-snapshot'; img.className = 'detail-snapshot';
img.loading = 'lazy'; img.loading = 'lazy';
@ -105,9 +109,9 @@
}); });
} }
fetch('/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } }) fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
.then(function (res) { .then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return null; } if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('detail_failed:' + res.status); if (!res.ok) throw new Error('detail_failed:' + res.status);
return res.json(); return res.json();
}) })

View file

@ -8,6 +8,10 @@
(function () { (function () {
'use strict'; 'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
var B = window.APP_BASE || '';
var form = document.querySelector('.login-form'); var form = document.querySelector('.login-form');
if (!form) return; if (!form) return;
@ -26,7 +30,7 @@
var formData = new FormData(form); var formData = new FormData(form);
var body = new URLSearchParams(formData).toString(); var body = new URLSearchParams(formData).toString();
fetch('/login', { fetch(B + '/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body body: body
@ -34,7 +38,7 @@
.then(function (res) { .then(function (res) {
if (res.status === 200) { if (res.status === 200) {
// Success: redirect to dashboard // Success: redirect to dashboard
window.location.href = '/'; window.location.href = B + '/';
return null; return null;
} }
// Error: parse response and show error message // Error: parse response and show error message

View file

@ -13,6 +13,10 @@
(function () { (function () {
'use strict'; 'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
var B = window.APP_BASE || '';
var canvas = document.getElementById('zone-canvas'); var canvas = document.getElementById('zone-canvas');
if (!canvas) return; if (!canvas) return;
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
@ -42,16 +46,23 @@
} }
function loadCsrfToken() { function loadCsrfToken() {
return fetch('/api/csrf', { headers: { Accept: 'application/json' } }) return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; }) .then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data && data.token) csrfToken = data.token; }) .then(function (data) { if (data && data.token) csrfToken = data.token; })
.catch(function () { /* save/delete will fail with 403 if the token is missing */ }); .catch(function () { /* save/delete will fail with 403 if the token is missing */ });
} }
// WIRE CONTRACT: zone polygons are stored NORMALIZED (0..1 of the image), so the edge can
// compare them against a foot point normalized by the camera's own resolution regardless of
// how big the camera is. On this canvas we draw in canvas pixels, so convert at the two wire
// boundaries only: denormalize on load (here), normalize on save (saveZone).
function parsePolygon(zone) { function parsePolygon(zone) {
try { try {
var pts = JSON.parse(zone.polygon_json || '[]'); var pts = JSON.parse(zone.polygon_json || '[]');
return Array.isArray(pts) ? pts : []; if (!Array.isArray(pts)) return [];
return pts.map(function (p) {
return [p[0] * canvas.width, p[1] * canvas.height];
});
} catch (e) { } catch (e) {
return []; return [];
} }
@ -116,16 +127,16 @@
var img = new Image(); var img = new Image();
img.onload = function () { backgroundImage = img; redraw(); }; img.onload = function () { backgroundImage = img; redraw(); };
img.onerror = function () { backgroundImage = null; redraw(); }; img.onerror = function () { backgroundImage = null; redraw(); };
img.src = '/snap/' + encodeURIComponent(uuid); img.src = B + '/snap/' + encodeURIComponent(uuid);
} }
function loadZones() { function loadZones() {
var camera = cameraInput.value.trim(); var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
setStatus('불러오는 중...'); setStatus('불러오는 중...');
fetch('/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } }) fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
.then(function (res) { .then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return null; } if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('load_failed:' + res.status); if (!res.ok) throw new Error('load_failed:' + res.status);
return res.json(); return res.json();
}) })
@ -148,18 +159,22 @@
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; } if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
setStatus('저장 중...'); setStatus('저장 중...');
fetch('/api/zones', { fetch(B + '/api/zones', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' }, headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
body: JSON.stringify({ body: JSON.stringify({
device_id: device, device_id: device,
camera_id: camera, camera_id: camera,
level: levelSelect.value, level: levelSelect.value,
polygon: currentPoints // Normalize canvas pixels -> 0..1 of the image (the edge un-normalizes with the
// camera's own resolution). See the parsePolygon contract note above.
polygon: currentPoints.map(function (p) {
return [p[0] / canvas.width, p[1] / canvas.height];
})
}) })
}) })
.then(function (res) { .then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return null; } if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('save_failed:' + res.status); if (!res.ok) throw new Error('save_failed:' + res.status);
return res.json(); return res.json();
}) })
@ -173,12 +188,12 @@
} }
function deleteZone(zoneId) { function deleteZone(zoneId) {
fetch('/api/zones/' + encodeURIComponent(zoneId) + '/delete', { fetch(B + '/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
method: 'POST', method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' } headers: { 'X-Csrf-Token': csrfToken || '' }
}) })
.then(function (res) { .then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return; } if (res.status === 401) { window.location.href = B + '/login'; return; }
if (!res.ok) throw new Error('delete_failed:' + res.status); if (!res.ok) throw new Error('delete_failed:' + res.status);
loadZones(); loadZones();
}) })

View file

@ -1,6 +1,39 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
// The app root — the directory holding config.php, src/, vendor/ and storage/ — is found by
// walking up from this file rather than hardcoded as '/..'. Its depth genuinely varies:
// one level up when the docroot is the app's own directory, but two when the app is mounted
// in a subdirectory of a www/ that already serves another site, since nothing secret may live
// under a docroot that other site also serves. On shared hosting the private files are kept
// together in a single APP_PRIVATE_DIR beside www/, so that name is probed at every level.
//
// Two deliberate choices guard against binding to the CO-HOSTED site's files:
// * the container is probed BEFORE the bare directory, so our own root always wins;
// * all three markers must be present. config.php + vendor/ alone are ordinary names in any
// PHP project sharing this account — src/Config.php is specific to this app.
const APP_PRIVATE_DIR = 'move_detect_app';
(static function (): void {
$isAppRoot = static fn (string $d): bool =>
is_file($d . '/config.php')
&& is_file($d . '/vendor/autoload.php')
&& is_file($d . '/src/Config.php');
for ($dir = dirname(__DIR__), $i = 0; $i < 4; $i++) {
foreach ([$dir . '/' . APP_PRIVATE_DIR, $dir] as $candidate) {
if ($isAppRoot($candidate)) {
define('APP_ROOT', $candidate);
return;
}
}
if (dirname($dir) === $dir) break; // filesystem root; dirname() would spin here
$dir = dirname($dir);
}
http_response_code(500);
exit('config.php / vendor/autoload.php / src/Config.php not found above ' . __DIR__);
})();
require APP_ROOT . '/vendor/autoload.php';
use App\Config; use App\Config;
use App\Http\{Request, Router, Response}; use App\Http\{Request, Router, Response};
@ -14,9 +47,16 @@ use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController}; use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController};
$cfg = Config::fromArray(require __DIR__ . '/../config.php'); // The URL prefix this app is mounted under: '' at the web root, '/move_detect' in a
// subdirectory. Server-derived (SCRIPT_NAME), never client input. Views and the JS bundles
// prefix every link/fetch with it; without it they'd address the other site sharing www/.
define('APP_BASE', Request::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? '')));
$cfg = Config::fromArray(require APP_ROOT . '/config.php');
$pdo = Database::connect($cfg); $pdo = Database::connect($cfg);
$sess = new PhpSessionStore(); // Scope the session cookie to the mount, so a co-hosted site under the same www/ is never
// sent this app's session id.
$sess = new PhpSessionStore(APP_BASE . '/');
$now = time(); $now = time();
$devices = new PdoDeviceRepository($pdo); $devices = new PdoDeviceRepository($pdo);
@ -40,7 +80,7 @@ $proxy = new SnapshotProxy($snaps, $sess);
$zoneCtl = new ZoneConfigController($zones, $audit, $sess); $zoneCtl = new ZoneConfigController($zones, $audit, $sess);
$render = function (string $view): Response { $render = function (string $view): Response {
ob_start(); require __DIR__ . '/../src/View/' . $view . '.php'; ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
return Response::text((string)ob_get_clean()); return Response::text((string)ob_get_clean());
}; };

View file

@ -18,9 +18,15 @@ final class ArraySessionStore implements SessionStore {
} }
final class PhpSessionStore implements SessionStore { final class PhpSessionStore implements SessionStore {
public function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) session_start([ /** @param string $cookiePath Scope the cookie to the mount ('/' at the web root,
'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax', * '/move_detect/' in a subdirectory) so a co-hosted site
]); } * sharing the same www/ is never sent this session id. */
public function __construct(string $cookiePath = '/') {
if (session_status() !== PHP_SESSION_ACTIVE) session_start([
'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax',
'cookie_path' => $cookiePath,
]);
}
public function get(string $k): mixed { return $_SESSION[$k] ?? null; } public function get(string $k): mixed { return $_SESSION[$k] ?? null; }
public function set(string $k, mixed $v): void { $_SESSION[$k] = $v; } public function set(string $k, mixed $v): void { $_SESSION[$k] = $v; }
public function clear(): void { $_SESSION = []; session_destroy(); } public function clear(): void { $_SESSION = []; session_destroy(); }

View file

@ -20,12 +20,44 @@ final class Request
public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; } public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; }
public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; } public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; }
/**
* The URL prefix the app is mounted under, derived from SCRIPT_NAME:
* '/index.php' => '' (web root), '/move_detect/index.php' => '/move_detect'.
*
* SCRIPT_NAME is set by the web server, never by the caller. Deriving the mount point
* from client-controlled input (a query param, a header) would let anyone relocate the
* app and route around the prefix.
*/
public static function baseFrom(string $scriptName): string
{
if ($scriptName === '' || $scriptName[0] !== '/') return '';
$dir = str_replace('\\', '/', dirname($scriptName));
return $dir === '/' || $dir === '.' ? '' : rtrim($dir, '/');
}
/**
* The route path for a REQUEST_URI: query string dropped, mount prefix removed.
* Routes are declared as '/login', '/api/events', ... so without this every route 404s
* whenever the app is mounted anywhere but the web root.
*/
public static function pathFor(string $requestUri, string $base): string
{
$path = parse_url($requestUri, PHP_URL_PATH) ?: '/';
if ($base !== '' && ($path === $base || str_starts_with($path, $base . '/'))) {
// Match $base exactly or as a whole segment. A bare str_starts_with($path, $base)
// would also strip '/move_detected/x' and route it as 'ed/x'.
$path = substr($path, strlen($base));
}
return $path === '' ? '/' : $path;
}
public static function fromGlobals(): self public static function fromGlobals(): self
{ {
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : []; $headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
$base = self::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? ''));
return new self( return new self(
$_SERVER['REQUEST_METHOD'] ?? 'GET', $_SERVER['REQUEST_METHOD'] ?? 'GET',
parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/', self::pathFor((string)($_SERVER['REQUEST_URI'] ?? '/'), $base),
$headers, file_get_contents('php://input') ?: '', $_GET, $_POST $headers, file_get_contents('php://input') ?: '', $_GET, $_POST
); );
} }

View file

@ -5,7 +5,7 @@ render_header('이벤트 상세');
<h1>이벤트 상세</h1> <h1>이벤트 상세</h1>
<div id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">불러오는 중...</div> <div id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">불러오는 중...</div>
<div id="event-detail-snapshots"></div> <div id="event-detail-snapshots"></div>
<p><a href="/">← 목록으로</a></p> <p><a href="<?= view_url('/') ?>">← 목록으로</a></p>
<script src="/assets/detail.js"></script> <script src="<?= view_url('/assets/detail.js') ?>"></script>
<?php <?php
render_footer(); render_footer();

View file

@ -4,6 +4,6 @@ render_header('이벤트');
?> ?>
<h1>이벤트</h1> <h1>이벤트</h1>
<div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div> <div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
<script src="/assets/app.js"></script> <script src="<?= view_url('/assets/app.js') ?>"></script>
<?php <?php
render_footer(); render_footer();

View file

@ -10,6 +10,19 @@ if (!function_exists('view_e')) {
function view_e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function view_e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
} }
if (!function_exists('view_url')) {
/**
* URL for an app-absolute path, prefixed with the mount point: '/zones' renders as
* '/zones' at the web root and '/move_detect/zones' in a subdirectory. A hardcoded
* leading '/' would address whatever else is hosted under a shared www/.
* APP_BASE comes from public/index.php; fall back to the web root so a view rendered
* outside the front controller still emits usable links.
*/
function view_url(string $path): string {
return view_e((defined('APP_BASE') ? APP_BASE : '') . $path);
}
}
if (!function_exists('render_header')) { if (!function_exists('render_header')) {
function render_header(string $title): void { function render_header(string $title): void {
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null; $username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
@ -20,20 +33,21 @@ if (!function_exists('render_header')) {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= view_e($title) ?> · 이동 감지 대시보드</title> <title><?= view_e($title) ?> · 이동 감지 대시보드</title>
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
<script>window.APP_BASE = <?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;</script>
</head> </head>
<body> <body>
<header class="app-header"> <header class="app-header">
<nav class="app-nav"> <nav class="app-nav">
<a href="/">이벤트</a> <a href="<?= view_url('/') ?>">이벤트</a>
<a href="/zones">구역 설정</a> <a href="<?= view_url('/zones') ?>">구역 설정</a>
</nav> </nav>
<div class="app-session"> <div class="app-session">
<?php if ($username !== null): ?> <?php if ($username !== null): ?>
<span><?= view_e($username) ?></span> <span><?= view_e($username) ?></span>
<button type="button" id="logout-btn">로그아웃</button> <button type="button" id="logout-btn">로그아웃</button>
<?php else: ?> <?php else: ?>
<a href="/login">로그인</a> <a href="<?= view_url('/login') ?>">로그인</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</header> </header>

View file

@ -10,7 +10,7 @@ $csrf = $_SESSION['csrf'];
render_header('로그인'); render_header('로그인');
?> ?>
<form method="post" action="/login" class="login-form"> <form method="post" action="<?= view_url('/login') ?>" class="login-form">
<h1>로그인</h1> <h1>로그인</h1>
<input type="hidden" name="csrf" value="<?= view_e($csrf) ?>"> <input type="hidden" name="csrf" value="<?= view_e($csrf) ?>">
<label for="username">아이디</label> <label for="username">아이디</label>
@ -20,6 +20,6 @@ render_header('로그인');
<button type="submit">로그인</button> <button type="submit">로그인</button>
<p id="login-error" class="form-error" role="alert" hidden></p> <p id="login-error" class="form-error" role="alert" hidden></p>
</form> </form>
<script src="/assets/login.js" defer></script> <script src="<?= view_url('/assets/login.js') ?>" defer></script>
<?php <?php
render_footer(); render_footer();

View file

@ -23,6 +23,6 @@ render_header('구역 설정');
<canvas id="zone-canvas" width="960" height="540" tabindex="0" aria-label="구역 편집 캔버스: 클릭하여 폴리곤 점을 추가합니다"></canvas> <canvas id="zone-canvas" width="960" height="540" tabindex="0" aria-label="구역 편집 캔버스: 클릭하여 폴리곤 점을 추가합니다"></canvas>
<ul id="zone-list" aria-live="polite"></ul> <ul id="zone-list" aria-live="polite"></ul>
<p id="zone-status" role="status"></p> <p id="zone-status" role="status"></p>
<script src="/assets/zone-draw.js"></script> <script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
<?php <?php
render_footer(); render_footer();

View file

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\{Request, Router, Response};
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* The app may be mounted at the web root (https://host/login) or in a subdirectory
* (https://host/move_detect/login) when the hosting account's www/ already serves another
* site. Routes are declared as '/login', '/api/events', ... — so if the mount prefix is not
* stripped from the request path, EVERY route 404s and the whole dashboard is dead.
*
* The prefix comes from SCRIPT_NAME, which the web server sets. It is never read from
* client-controlled input (a query param or header would let a caller relocate the app).
*/
final class BasePathTest extends TestCase
{
/** @return array<string, array{string, string, string}> */
public static function mounts(): array
{
// SCRIPT_NAME REQUEST_URI expected
return [
'web root' => ['/index.php', '/login', '/login'],
'web root, query' => ['/index.php', '/api/events?since=5', '/api/events'],
'web root, bare' => ['/index.php', '/', '/'],
'subdir' => ['/move_detect/index.php', '/move_detect/login', '/login'],
'subdir, query' => ['/move_detect/index.php', '/move_detect/api/events?since=5', '/api/events'],
'subdir, bare' => ['/move_detect/index.php', '/move_detect/', '/'],
'subdir, no slash' => ['/move_detect/index.php', '/move_detect', '/'],
'subdir, param route' => ['/move_detect/index.php', '/move_detect/events/42', '/events/42'],
'nested subdir' => ['/a/b/index.php', '/a/b/api/csrf', '/api/csrf'],
];
}
#[DataProvider('mounts')]
public function testBasePathIsStrippedFromTheRoutedPath(
string $scriptName, string $requestUri, string $expected): void
{
$this->assertSame($expected, Request::pathFor($requestUri, Request::baseFrom($scriptName)));
}
public function testBaseFromScriptName(): void
{
$this->assertSame('', Request::baseFrom('/index.php'));
$this->assertSame('/move_detect', Request::baseFrom('/move_detect/index.php'));
$this->assertSame('/a/b', Request::baseFrom('/a/b/index.php'));
// Missing/odd SCRIPT_NAME must degrade to "mounted at the root", never to a prefix
// that would silently swallow the first path segment of every request.
$this->assertSame('', Request::baseFrom(''));
$this->assertSame('', Request::baseFrom('index.php'));
}
public function testAPathThatMerelyResemblesTheBaseIsNotTruncated(): void
{
// '/move_detected/x' starts with the literal string '/move_detect' but is NOT inside
// the mount. A naive str_starts_with() would rewrite it to 'ed/x'.
$base = Request::baseFrom('/move_detect/index.php');
$this->assertSame('/move_detected/x', Request::pathFor('/move_detected/x', $base));
}
public function testRouterMatchesDeclaredRoutesWhenMountedInASubdirectory(): void
{
$r = new Router();
$r->add('GET', '/api/events', fn() => Response::json(['ok' => true]));
$r->add('GET', '/events/{id}', fn($q, $p) => Response::json(['id' => $p['id']]));
$base = Request::baseFrom('/move_detect/index.php');
$list = new Request('GET', Request::pathFor('/move_detect/api/events', $base), [], '');
$this->assertSame(200, $r->dispatch($list)->status());
$detail = new Request('GET', Request::pathFor('/move_detect/events/42', $base), [], '');
$this->assertSame(['id' => '42'], json_decode($r->dispatch($detail)->body(), true));
}
public function testFromGlobalsHonoursScriptNameWithoutTrustingClientInput(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['SCRIPT_NAME'] = '/move_detect/index.php';
$_SERVER['REQUEST_URI'] = '/move_detect/zones';
$this->assertSame('/zones', Request::fromGlobals()->path());
}
}

0
deploy/bundle/build_offline_bundle.sh Normal file → Executable file
View file

57
deploy/bundle/install_offline.sh Normal file → Executable file
View file

@ -18,7 +18,10 @@ STATE=/var/lib/move-detect
ETC=/etc/move-detect ETC=/etc/move-detect
SVC_USER=movedetect SVC_USER=movedetect
WHEELHOUSE="$HERE/wheelhouse" WHEELHOUSE="$HERE/wheelhouse"
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless) # Mandatory for every backend. onnxruntime is deliberately NOT here — see the optional
# install below; a Hailo bundle need not carry its wheel.
RUNTIME_PKGS=(numpy requests opencv-python-headless)
OPTIONAL_PKGS=(onnxruntime)
echo ">> [1/6] service account + groups" echo ">> [1/6] service account + groups"
if ! id -u "$SVC_USER" >/dev/null 2>&1; then if ! id -u "$SVC_USER" >/dev/null 2>&1; then
@ -49,18 +52,64 @@ echo ">> [3/6] python venv + offline wheel install (--no-index)"
python3 -m venv --system-site-packages "$PREFIX/venv" python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" --upgrade pip >/dev/null 2>&1 || true "$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" --upgrade pip >/dev/null 2>&1 || true
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${RUNTIME_PKGS[@]}" "$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# Optional: only present if the bundle was built for the CPU MoveNet backend. Still --no-index
# (zero fetches, air-gap intact); absent simply means Hailo-only.
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime not in the wheelhouse; Hailo-only bundle (CPU MoveNet unavailable)."
echo ">> [4/6] verify the edge package imports offline" echo ">> [4/6] verify the edge package imports offline"
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, onnxruntime, cv2, numpy, requests; print('edge import OK')" # cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
# on the unconditional startup chain; the daemon's cloud clients import requests at startup).
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
# not carry it. Report, don't fail: a hard `import onnxruntime` here refuses the install over
# a package the daemon never loads.
if PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import onnxruntime" >/dev/null 2>&1; then
echo " -> onnxruntime present; CPU MoveNet available"
else
echo " -> onnxruntime absent; CPU MoveNet unavailable (set POSE_BACKEND=hailo in $ETC/edge.env)"
fi
echo ">> [5/6] pose backend gate + state dir + env file"
# Do NOT hand over a Pi whose POSE_BACKEND cannot load. The template ships POSE_BACKEND=movenet,
# but a Hailo-only bundle carries no onnxruntime/ONNX — installing it verbatim would crash-loop.
# Probe what can ACTUALLY run (like setup_online's [6/8]) and reconcile, or refuse.
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
pyhas() { PYTHONPATH="$PREFIX/src" "$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"
elif [ "$MOVENET_OK" = 1 ]; then
POSE_BACKEND=movenet
echo " -> CPU MoveNet usable; POSE_BACKEND=movenet"
else
echo "" >&2
echo "ERROR: no usable pose backend in this bundle — refusing to install a daemon that" >&2
echo "cannot start." >&2
echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform')" >&2
echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2
echo " Rebuild the bundle for the target backend (deploy/bundle/build_offline_bundle.sh)." >&2
exit 1
fi
echo ">> [5/6] state dir + env file"
install -d -m 0750 "$STATE" install -d -m 0750 "$STATE"
chown "$SVC_USER":"$SVC_USER" "$STATE" chown "$SVC_USER":"$SVC_USER" "$STATE"
install -d -m 0750 "$ETC" install -d -m 0750 "$ETC"
if [ ! -f "$ETC/edge.env" ]; then if [ ! -f "$ETC/edge.env" ]; then
install -m 0640 "$HERE/deploy/systemd/move-detect-edge.env.example" "$ETC/edge.env" install -m 0640 "$HERE/deploy/systemd/move-detect-edge.env.example" "$ETC/edge.env"
# Reconcile the template's POSE_BACKEND to the one that actually works in this bundle.
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env"
if [ "$POSE_BACKEND" = hailo ]; then
grep -q '^HAILO_HEF=' "$ETC/edge.env" || printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env"
fi
chown root:"$SVC_USER" "$ETC/edge.env" chown root:"$SVC_USER" "$ETC/edge.env"
echo " -> wrote $ETC/edge.env from template (EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)" echo " -> wrote $ETC/edge.env (POSE_BACKEND=$POSE_BACKEND; EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)"
else else
echo " -> $ETC/edge.env already exists; left unchanged" echo " -> $ETC/edge.env already exists; left unchanged"
fi fi

218
deploy/setup_online.sh Normal file → Executable file
View file

@ -8,6 +8,9 @@
# Non-interactive: pass values as env vars, e.g. # Non-interactive: pass values as env vars, e.g.
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \ # sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh # 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. # Interactive (a real terminal): it prompts for anything not passed.
set -euo pipefail set -euo pipefail
@ -24,11 +27,46 @@ SVC_USER=movedetect
echo ">> [1/8] system packages (CSI camera, venv; Hailo runtime best-effort)" echo ">> [1/8] system packages (CSI camera, venv; Hailo runtime best-effort)"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update # A freshly-booted Pi runs apt-daily / apt-daily-upgrade / unattended-upgrades, which hold
apt-get install -y python3-picamera2 python3-venv python3-pip # /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. # 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. # Best-effort so a Pi without it still installs and runs on CPU MoveNet.
apt-get install -y hailo-all || echo " WARN: hailo-all not installed — Hailo accel unavailable, CPU MoveNet will be used." 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" 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" id -u "$SVC_USER" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER"
@ -47,39 +85,156 @@ cp "$REPO_ROOT/docs/pilot-runbook.md" "$PREFIX/docs/" 2>/dev/null || true
echo ">> [4/8] venv (+ system site-packages) + pip runtime deps" echo ">> [4/8] venv (+ system site-packages) + pip runtime deps"
python3 -m venv --system-site-packages "$PREFIX/venv" python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install --upgrade pip >/dev/null "$PREFIX/venv/bin/pip" install --upgrade pip >/dev/null
# numpy is intentionally NOT pinned here: it comes in transitively via onnxruntime, and # MANDATORY for every backend — do not soften these to `|| echo WARN`:
# leaving the system numpy in place (the venv sees it) avoids shadowing the numpy that # cv2 edge/snapshot.py imports it at MODULE level, and that module is on the
# the apt python3-picamera2 stack was compiled against. # unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND
"$PREFIX/venv/bin/pip" install requests onnxruntime opencv-python-headless # can reach build_pose() without it.
# requests the daemon builds its cloud/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" echo ">> [5/8] MoveNet CPU model"
( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet ) # 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 cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true
echo ">> [6/8] pose backend" echo ">> [6/8] pose backend"
POSE_BACKEND=movenet
HEF_DEST="$PREFIX/models/yolov8s_pose.hef" HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
if [ -n "${HAILO_HEF:-}" ] && [ -f "${HAILO_HEF:-}" ]; then 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" cp "$HAILO_HEF" "$HEF_DEST"
POSE_BACKEND=hailo echo " -> HEF installed from HAILO_HEF=$HAILO_HEF"
echo " -> Hailo HEF installed; POSE_BACKEND=hailo"
else else
echo " -> no HAILO_HEF given; starting on CPU MoveNet." # 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 " 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" 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 fi
echo ">> [7/8] config -> $ETC/edge.env" echo ">> [7/8] config -> $ETC/edge.env"
install -d -m 0750 "$STATE"; chown "$SVC_USER":"$SVC_USER" "$STATE" install -d -m 0750 "$STATE"; chown "$SVC_USER":"$SVC_USER" "$STATE"
install -d -m 0750 "$ETC" install -d -m 0750 "$ETC"
if [ -f "$ETC/edge.env" ]; then if [ -f "$ETC/edge.env" ]; then
echo " -> $ETC/edge.env already exists; left unchanged" # 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 else
: "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}" : "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}"
if [ -t 0 ]; then if [ -t 0 ]; then
[ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID [ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; } [ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; }
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (cloud base URL): " CAFE24_URL [ -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 [ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
fi fi
DEVICE_ID="${DEVICE_ID:-pi-01}" DEVICE_ID="${DEVICE_ID:-pi-01}"
@ -106,7 +261,38 @@ fi
echo ">> [8/8] systemd service" echo ">> [8/8] systemd service"
install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/ install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now move-detect-edge
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 ""
echo "Done. The edge daemon is installed and started." echo "Done. The edge daemon is installed and started."

View file

@ -10,8 +10,11 @@
DEVICE_ID=pi-01 DEVICE_ID=pi-01
# DEVICE_SECRET: per-device HMAC secret; must match devices.secret. # DEVICE_SECRET: per-device HMAC secret; must match devices.secret.
DEVICE_SECRET=CHANGEME DEVICE_SECRET=CHANGEME
# CAFE24_URL: cloud base URL (POST events / poll config / heartbeat). # CAFE24_URL: cloud base URL (POST events / poll config / heartbeat). MUST include the app's
CAFE24_URL=https://cloud.example.com # subdirectory when the cloud is mounted under an existing www/ (the cafe24 package default):
# use https://<domain>/move_detect, NOT https://<domain>. A wrong/missing path 404s every
# upload — the daemon now refuses to start on a blank value, but a wrong path it cannot detect.
CAFE24_URL=https://your-domain.example/move_detect
# CAMERA_ID: stamped on events and matched to dashboard zones. # CAMERA_ID: stamped on events and matched to dashboard zones.
CAMERA_ID=cam-01 CAMERA_ID=cam-01

View file

@ -9,9 +9,17 @@ 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
# 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
# limit to defeat Restart=always below.
StartLimitIntervalSec=0
[Service] [Service]
Type=simple # Type=exec, not simple: with simple, systemd marks the start job done the instant fork()
# returns, so an exec-step failure (226/NAMESPACE, 216/GROUP, 217/USER, 203/EXEC) never
# reaches `systemctl enable --now` and the installer reports success over a dead daemon.
Type=exec
# Dedicated unprivileged account; must be in the groups that own the camera and # Dedicated unprivileged account; must be in the groups that own the camera and
# the Hailo accelerator device nodes. # the Hailo accelerator device nodes.
User=movedetect User=movedetect
@ -28,7 +36,6 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge
# periodic-op exceptions internally so a single glitch never exits the process. # periodic-op exceptions internally so a single glitch never exits the process.
Restart=always Restart=always
RestartSec=5 RestartSec=5
StartLimitIntervalSec=0
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it). # Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
# systemd creates/owns /var/lib/move-detect for the service user. # systemd creates/owns /var/lib/move-detect for the service user.
@ -42,7 +49,10 @@ DeviceAllow=char-video4linux rw
# Conservative hardening (loosen if it blocks device access on your image). # Conservative hardening (loosen if it blocks device access on your image).
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=/var/lib/move-detect # Do NOT add ReadWritePaths=/var/lib/move-detect here. StateDirectory= above already emits
# a recursive read-write bind mount for that exact path, which outranks a ReadWritePaths=
# entry for the same path and drops it before any path is resolved. It is dead config: it
# can neither cause nor fix a state-dir problem.
ProtectHome=true ProtectHome=true
PrivateTmp=true PrivateTmp=true

190
docs/delivery-audit.md Normal file
View file

@ -0,0 +1,190 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
verification of every blocker against real code. Ordered for sequential fixing. Each item is
checked off only after a test locks it and verification passes.
Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low ·
`OFF-HW` = cannot be verified without the Hailo board.
---
## Phase 1 — Silent failure & misconfiguration (the #1 delivery risk)
A safety system that *looks* healthy while doing nothing is the worst failure. Make every
misconfig/degradation fail LOUD.
- [x] **P1-1 [H]** `edge/cloudclient.py`, `edge/alert.py` — `requests` sends the default
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_cloudclient.py.
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
DONE — env.example + installer prompt now show/require the `/move_detect` subdir.
- [x] **P1-3 [H]** `edge/config.py:40` + `edge/daemon.py` — empty `CAFE24_URL`/`DEVICE_SECRET`
defaults let the daemon run green while every upload fails and the queue grows forever.
**Fix:** fail loud at startup if either is blank.
DONE — `require_upload_config(cfg)` raises at daemon startup; test_config.py locks it.
- [x] **P1-4 [H]** `edge/daemon.py:23` — `run_loop` swallows every per-frame/per-camera
exception with no log and no degraded-monitoring signal → a permanently broken pipeline
looks green. **Fix:** rate-limited `logging.exception` + consecutive-error counter.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the cloud cannot tell a dead device from a live one.
**Fix:** real fps + per-camera frame-age/health in the heartbeat body.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
test_daemon.py locks fps math + failing-camera flag.
- [x] **P1-6 [H]** `edge/adapters/uvc_source.py:12` — a camera that stops delivering frames is
never detected or reopened; monitoring dies silently. **Fix:** fail loud on open failure;
emit `device_fault`/`monitoring_degraded` when a camera goes stale.
DONE — open-failure raises (build_runtimes logs+skips); read() releases+reopens after a
streak; heartbeat's cameras_erroring (P1-5) reports the fault. test_uvc_source.py locks it.
- [x] **P1-7 [M]** `edge/queue.py` — offline sqlite queue has no size cap/TTL; an outage (or a
permanent misconfig) fills the SD card, then new events drop silently. **Fix:** bounded
queue (row/byte budget), log dropped-oldest count.
DONE — `max_rows` (default 2000) evicts oldest on enqueue; test_queue.py locks it.
- [x] **P1-8 [H]** `edge/queue.py:52` — a permanently-rejected event (e.g. 400) is retried
forever at the head, blocking the whole queue. **Fix:** drop/quarantine after N failures.
DONE — `attempts` column; a row is dropped after `max_send_attempts` (default 20) so a
poison head unwedges the queue; test locks the drain-past-poison behavior.
- [ ] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
client-generated `event_id` in the payload; cloud dedups on it.
DEFERRED to the cloud-ingest batch (needs a schema column + IngestController dedup);
sequenced with Phase 4 / P4-7.
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
DONE — `frame_interval_from_env` validates; test_config.py locks it.
- [x] **P1-11 [L]** `edge/daemon.py:120` — `QUEUE_DB` defaults to relative `queue.db`,
unwritable under the hardened unit (`ProtectSystem=strict`) → crash loop when env omitted.
**Fix:** absolute default under the state dir.
DONE — `queue_db_path` defaults to /var/lib/move-detect/queue.db; test_config.py locks it.
## Phase 2 — Core safety correctness (fall / zone / tracking)
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `cloud/public/assets/zone-draw.js:196` — zones are
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
(zone.py → engine.process → run_once threads frame_wh); Polygon::validate is range-agnostic
so PHP stores it opaquely. test_zone.py locks the normalized contract.
- [x] **P2-2 [B]** `edge/engine.py:72` — a `pinch` (협착) zone falls through to
`ZONE_WARNING`; the PINCH event + HIGH severity are never emitted. **Fix:** map
`pinch→PINCH`, `restricted→ZONE_RESTRICTED`, else WARNING.
DONE — dict map pinch→PINCH/restricted→ZONE_RESTRICTED; test_engine.py locks PINCH+HIGH.
- [x] **P2-3 [H]** `edge/detectors/fall.py:65` — a per-frame keypoint-confidence gate
silently disables fall, no_motion AND ghost for an occluded/low-confidence fallen person —
exactly when someone is on the floor. **Fix:** use bbox cues when keypoints are low-conf.
DONE — bbox cues (aspect/height-collapse) always run; keypoint cues gated by kp_ok;
test_fall.py locks both the low-conf detection and the no-false-positive-standing case.
- [x] **P2-4 [H]** `edge/detectors/ghost.py:38` — a ghost candidate is cancelled when ANY
person (a colleague) passes near the loss point. **Fix:** only the plausibly-same track
cancels it.
DONE — candidate snapshots the active track ids at loss time; only a NEW track (not a
known bystander) near the loss point cancels. test_ghost.py locks the bystander case.
- [x] **P2-5 [H]** `edge/geometry.py:66` — stillness compares raw per-frame displacement to a
fixed threshold with no dt normalization → immobility/no_motion flips with FPS. **Fix:**
normalize by elapsed time.
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
- [ ] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
reset per-track safety state on a low-confidence match.
- [ ] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
- [ ] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
- [ ] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
confidence-gate the ankles, fall back to bbox-bottom-center.
- [ ] **P2-10 [M]** `edge/adapters/movenet_pose.py:34` — the CPU MoveNet fallback is
single-person; with 2+ workers all but one are invisible to every detector, no warning.
**Fix:** warn loudly that MoveNet fallback is single-person (Hailo is multi-person).
- [ ] **P2-11 [L]** `edge/geometry.py:39` — `head_below_hips` uses the NOSE keypoint
regardless of confidence. **Fix:** confidence-gate.
## Phase 3 — Privacy / PIPA (delivery compliance)
- [ ] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
- [ ] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
- [ ] **P3-2b [M]** `cloud/src/Dashboard/EventListController.php:17` — `site_scope` is stored
in the session but never enforced; any authenticated user sees every device's worker
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
- [ ] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
`fetch_hailo_hef.py`.
## Phase 4 — Cloud hardening (shared hosting)
- [ ] **P4-1 [H]** `cloud/public/index.php:106` — no top-level try/catch; a PDOException
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`.
- [ ] **P4-2 [H]** `cloud/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error.
- [ ] **P4-3 [M]** `cloud/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows.
- [ ] **P4-4 [M]** `cloud/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params.
- [ ] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
- [ ] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error.
- [ ] **P4-7 [L]** `cloud/src/Ingest/IngestController.php:64` — snapshot write failure is
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
order write→insert, log failures, reconcile orphans.
- [ ] **P4-8 [L]** `cloud/src/Auth/AuthController.php:31` — login form emits a CSRF token that
`login` never checks. **Fix:** verify it (or drop it).
- [ ] **P4-9 [L]** `cloud/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
- [ ] **P4-10 [L]** `cloud/src/Ingest/EventValidator.php:26` — no string length caps → an
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
lengths in the validator.
- [ ] **P4-11 [L]** `cloud/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
(parse_url scheme-relative). **Fix:** collapse/normalize.
## Phase 5 — Install / bundle / supply-chain
- [x] **P5-1 [B]** `deploy/bundle/install_offline.sh:79` — the OFFLINE installer has no
backend-usability gate (setup_online's [6/8] does): it installs `POSE_BACKEND=movenet`
verbatim even when the bundle carries no working backend. **Fix:** mirror the [6/8] probe.
DONE — [5/6] probes HEF+hailo_platform / onnx+onnxruntime, reconciles POSE_BACKEND in the
installed env, hard-fails if neither. test_deploy_artifacts.py locks the gate.
- [ ] **P5-2 [H]** `deploy/bundle/build_offline_bundle.sh:55` — a native build on x86 produces
an aarch64-named tarball full of x86_64 wheels. **Fix:** assert `uname -m == TARGET_ARCH`.
- [ ] **P5-3 [M]** `deploy/bundle/build_offline_bundle.sh:65` — the air-gapped bundle never
carries the Hailo HEF, so offline Hailo install is impossible. **Fix:** stage the HEF (via
`fetch_hailo_hef --arch`) into the bundle.
- [ ] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests,
MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin +
hash + manifest digests.
- [ ] **P5-5 [H]** `deploy/systemd/move-detect-edge.service:46` `OFF-HW` — `DeviceAllow` omits
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
- [ ] **P5-6 [L]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
---
## Progress log
(updated as each item lands — newest first)
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
correctness (P2-3,4,5). Remaining: P1-9 (deferred to cloud batch), P2-6..11 (mostly threshold
tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
decision: how many days), Phase 4 (cloud hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
Nothing committed yet.

View file

@ -169,7 +169,11 @@ class HailoPoseAdapter:
for info in out_infos} for info in out_infos}
bindings = configured.create_bindings(output_buffers=buffers) bindings = configured.create_bindings(output_buffers=buffers)
bindings.input().set_buffer(np.ascontiguousarray(frame)) bindings.input().set_buffer(np.ascontiguousarray(frame))
configured.run([bindings], timeout_ms=10000) # Positional, NOT timeout_ms=. Upstream is `def run(self, bindings, timeout)` in
# every HailoRT that supports Hailo-8 (v4.19.0 / v4.20.0 / v4.23.0 / master), so
# the keyword form is a TypeError on EVERY frame — and run_loop swallows it
# (edge/daemon.py:23-24), so it presents as a green unit emitting zero events.
configured.run([bindings], 10000)
return [buffers[info.name][None, ...] if buffers[info.name].ndim == 3 return [buffers[info.name][None, ...] if buffers[info.name].ndim == 3
else buffers[info.name] for info in out_infos] else buffers[info.name] for info in out_infos]

View file

@ -4,10 +4,43 @@ import cv2
class UvcSource: class UvcSource:
def __init__(self, dev, mjpeg: bool = True): def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
self.cap = cv2.VideoCapture(dev) # _open lets tests inject a fake capture; production uses cv2.VideoCapture.
if mjpeg: self._dev = dev
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) self._mjpeg = mjpeg
self._reopen_after = reopen_after
self._open = _open or (lambda d: cv2.VideoCapture(d))
self._fail_streak = 0
self.cap = self._open_cap()
def _open_cap(self):
cap = self._open(self._dev)
# Fail LOUD: a device that never opens must not become a silently-dead capture that
# returns (False, None) forever while the daemon looks healthy. build_runtimes catches
# this and logs+skips the camera.
if not cap.isOpened():
raise RuntimeError(f"UVC device {self._dev!r} failed to open")
if self._mjpeg:
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
return cap
def read(self): def read(self):
return self.cap.read() ok, frame = self.cap.read()
if ok:
self._fail_streak = 0
return ok, frame
# A USB cam that browns out / is unplugged returns (False, None) indefinitely. After a
# streak, release and reopen so a recovered device resumes on its own rather than the
# camera staying dead until someone restarts the service.
self._fail_streak += 1
if self._fail_streak >= self._reopen_after:
self._fail_streak = 0
try:
self.cap.release()
except Exception:
pass
try:
self.cap = self._open_cap()
except Exception:
pass # still dead — the daemon's error/heartbeat path surfaces it
return False, None

View file

@ -5,6 +5,11 @@ import json
from edge.events import Event from edge.events import Event
# Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent
# with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC
# and URL. Send an explicit UA on every request. CloudClient imports this too.
USER_AGENT = "move-detect-edge/1.0"
def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str: def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str:
if isinstance(body, str): if isinstance(body, str):
@ -35,12 +40,16 @@ class PostClient:
ts = str(self.now_fn()) ts = str(self.now_fn())
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-Nonce": nonce, "X-Nonce": nonce,
"X-Timestamp": ts, "X-Timestamp": ts,
"X-Signature": sign(self.secret, nonce, ts, body.encode()), "X-Signature": sign(self.secret, nonce, ts, body.encode()),
} }
try: try:
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout) # allow_redirects=False: the .htaccess http->https 302 must not silently become a
# GET (405/404). A redirect is a CAFE24_URL misconfiguration to surface, not follow.
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
allow_redirects=False)
except Exception: except Exception:
return False return False
return 200 <= resp.status_code < 300 return 200 <= resp.status_code < 300

View file

@ -31,7 +31,8 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
f = tr.latest f = tr.latest
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now) f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame) ring.push(now, frame)
events = engine.process(tracks, lost, now) h, w = frame.shape[:2]
events = engine.process(tracks, lost, now, frame_wh=(w, h))
for tr in lost: for tr in lost:
pool.forget(tr.track_id) pool.forget(tr.track_id)
if events: if events:

View file

@ -1,6 +1,6 @@
import json import json
from edge.alert import sign from edge.alert import USER_AGENT, sign
class CloudClient: class CloudClient:
@ -22,13 +22,15 @@ class CloudClient:
def _headers(self, body: str) -> dict: def _headers(self, body: str) -> dict:
nonce = self.nonce_fn() nonce = self.nonce_fn()
ts = str(self.now_fn()) ts = str(self.now_fn())
return {"X-Nonce": nonce, "X-Timestamp": ts, # User-Agent: cafe24's WAF 403s the default python-requests UA (see edge/alert.py).
return {"User-Agent": USER_AGENT, "X-Nonce": nonce, "X-Timestamp": ts,
"X-Signature": sign(self.secret, nonce, ts, body)} "X-Signature": sign(self.secret, nonce, ts, body)}
def fetch_config(self) -> dict | None: def fetch_config(self) -> dict | None:
url = f"{self.base_url}/api/config?device_id={self.device_id}" url = f"{self.base_url}/api/config?device_id={self.device_id}"
try: try:
resp = self._get(url, headers=self._headers(""), timeout=self.timeout) resp = self._get(url, headers=self._headers(""), timeout=self.timeout,
allow_redirects=False)
except Exception: except Exception:
return None return None
if 200 <= resp.status_code < 300: if 200 <= resp.status_code < 300:
@ -44,7 +46,7 @@ class CloudClient:
headers = {"Content-Type": "application/json", **self._headers(body)} headers = {"Content-Type": "application/json", **self._headers(body)}
try: try:
resp = self._post(f"{self.base_url}/api/heartbeat", data=body, resp = self._post(f"{self.base_url}/api/heartbeat", data=body,
headers=headers, timeout=self.timeout) headers=headers, timeout=self.timeout, allow_redirects=False)
except Exception: except Exception:
return False return False
return 200 <= resp.status_code < 300 return 200 <= resp.status_code < 300

View file

@ -24,6 +24,47 @@ def _f(env: dict, key: str, default: float) -> float:
return float(env[key]) if key in env else default return float(env[key]) if key in env else default
def frame_interval_from_env(env: dict) -> float:
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
systemd restart loop (Restart=always + StartLimitIntervalSec=0)."""
raw = env.get("TARGET_FPS", "12")
try:
fps = float(raw)
except (TypeError, ValueError):
raise ValueError(f"TARGET_FPS={raw!r} is not a number")
if fps <= 0:
raise ValueError(f"TARGET_FPS={raw!r} must be > 0")
return 1.0 / fps
def queue_db_path(env: dict) -> str:
"""Absolute sqlite path for the offline queue. A relative 'queue.db' resolves against the
unit's read-only WorkingDirectory under ProtectSystem=strict and cannot be created ->
crash loop. Default to the systemd StateDirectory, the one writable path."""
return env.get("QUEUE_DB") or "/var/lib/move-detect/queue.db"
def require_upload_config(cfg: Config) -> None:
"""Fail LOUD at startup when the daemon cannot possibly deliver events.
load_config defaults cafe24_url and secret to '' so unit tests can build a Config from a
minimal env — but a real daemon with either blank runs perfectly green while every upload
fails (a bad/empty URL makes requests raise, which PostClient catches and returns False),
and the offline queue grows until the SD card is full. The operator sees a healthy unit
and an empty dashboard. Refuse to start instead.
"""
missing = [name for name, val in (("CAFE24_URL", cfg.cafe24_url),
("DEVICE_SECRET", cfg.secret)) if not val.strip()]
if missing:
raise ValueError(
f"FATAL: {', '.join(missing)} not set in /etc/move-detect/edge.env — the daemon "
f"would run but upload nothing. CAFE24_URL must include the app path, e.g. "
f"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the "
f"cloud."
)
def load_config(env: dict) -> Config: def load_config(env: dict) -> Config:
camera_id = env.get("CAMERA_ID", "cam-01") camera_id = env.get("CAMERA_ID", "cam-01")
fall = FallConfig( fall = FallConfig(

View file

@ -1,27 +1,59 @@
import os import os
# Rate-limit for the swallowed-exception log: a camera failing every frame must be VISIBLE
# (not a silent green unit) but must not spam the journal. Log the 1st of a run of consecutive
# failures, then every LOG_EVERY-th, and again after any recovery.
_LOG_EVERY = 300
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
import sys
import traceback
print(msg, file=sys.stderr)
if exc is not None:
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300, sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None): frame_interval=0.0, zones_for=None, log=None):
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All # `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
# cameras share this loop, one shared clock read per tick, and the periodic # cameras share this loop, one shared clock read per tick, and the periodic
# sync/heartbeat/flush. A per-camera step exception is counted, never fatal. # sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
# never fatal — but it is no longer SILENT (a permanently broken pipeline used to look
# perfectly green).
runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime] runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime]
if zones_for is None: if zones_for is None:
from edge.zones import zones_from_config from edge.zones import zones_from_config
zones_for = lambda cfg, rt: zones_from_config(cfg) zones_for = lambda cfg, rt: zones_from_config(cfg)
if log is None:
log = _default_log
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0} counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc):
counters["errors"] += 1
n = streak.get(key, 0) + 1
streak[key] = n
if n == 1 or n % _LOG_EVERY == 0:
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc)
beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
i = 0 i = 0
while iterations is None or i < iterations: while iterations is None or i < iterations:
i += 1 i += 1
now = clock() # one timestamp per tick, shared across cameras now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None:
beat_anchor = (now, 0)
for rt in runtimes: for rt in runtimes:
try: try:
rt.step(now) rt.step(now)
counters["frames"] += 1 counters["frames"] += 1
except Exception: streak.pop(id(rt), None) # recovered — reset the streak
counters["errors"] += 1 except Exception as e:
cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} step failed", e)
try: try:
if sync_every and i % sync_every == 0: if sync_every and i % sync_every == 0:
cfg = client.fetch_config() cfg = client.fetch_config()
@ -30,13 +62,25 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
rt.apply_zones(zones_for(cfg, rt)) rt.apply_zones(zones_for(cfg, rt))
counters["syncs"] += 1 counters["syncs"] += 1
if heartbeat_every and i % heartbeat_every == 0: if heartbeat_every and i % heartbeat_every == 0:
if client.send_heartbeat({"fps": 0.0, "frames": counters["frames"]}): # Measured fps over the window since the last beat, and per-camera health, so
# the cloud can tell a live device from a degraded/dead one (was hardcoded 0.0
# + a frames counter that ticked even when no frame was read).
t0, f0 = beat_anchor
elapsed = now - t0
fps = (counters["frames"] - f0) / elapsed if elapsed > 0 else 0.0
erroring = sum(1 for rt in runtimes if id(rt) in streak)
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
"cameras_total": len(runtimes), "cameras_erroring": erroring,
"errors": counters["errors"]}
if client.send_heartbeat(metrics):
counters["beats"] += 1 counters["beats"] += 1
beat_anchor = (now, counters["frames"])
if flush_every and i % flush_every == 0: if flush_every and i % flush_every == 0:
queue.flush() queue.flush()
counters["flushes"] += 1 counters["flushes"] += 1
except Exception: streak.pop("periodic", None)
counters["errors"] += 1 except Exception as e:
note("periodic", "periodic op (sync/heartbeat/flush) failed", e)
if frame_interval: if frame_interval:
sleep(frame_interval) sleep(frame_interval)
return counters return counters
@ -87,7 +131,8 @@ def build_runtimes(cameras, make_source, make_runtime, log=None):
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device) def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
import time import time
from dataclasses import replace from dataclasses import replace
from edge.config import load_config from edge.config import (frame_interval_from_env, load_config, queue_db_path,
require_upload_config)
from edge.cloudclient import CloudClient from edge.cloudclient import CloudClient
from edge.alert import PostClient from edge.alert import PostClient
from edge.queue import OfflineQueue from edge.queue import OfflineQueue
@ -112,12 +157,13 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
return MoveNetPoseAdapter(os.environ["MOVENET_ONNX"]) return MoveNetPoseAdapter(os.environ["MOVENET_ONNX"])
cfg = load_config(dict(os.environ)) cfg = load_config(dict(os.environ))
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
nonce = lambda: os.urandom(8).hex() nonce = lambda: os.urandom(8).hex()
now = time.time now = time.time
base = cfg.cafe24_url.rstrip("/") base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now) client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post) queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
pose = build_pose() # shared across cameras pose = build_pose() # shared across cameras
@ -131,5 +177,5 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
build_source, make_runtime) build_source, make_runtime)
run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None, run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")), frame_interval=frame_interval_from_env(dict(os.environ)),
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id)) zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))

View file

@ -40,18 +40,19 @@ class FallDetector:
def forget(self, track_id: int) -> None: def forget(self, track_id: int) -> None:
self._states.pop(track_id, None) self._states.pop(track_id, None)
def _prone(self, kps, bbox, st: _State) -> tuple[bool, dict]: def _prone(self, kps, bbox, st: _State, kp_ok: bool) -> tuple[bool, dict]:
angle = torso_angle_deg(kps)
ar = bbox.aspect ar = bbox.aspect
remaining = 1.0 remaining = 1.0
if st.baseline_height: if st.baseline_height:
remaining = bbox.height / st.baseline_height remaining = bbox.height / st.baseline_height
prone = ( # bbox cues need NO keypoints — a fallen person is wide-and-short, or their bbox height
angle > self.cfg.fallen_angle # collapsed. These stay live even when the pose model has lost confidence (occluded /
or ar > self.cfg.aspect_ratio # on the floor), which is exactly when a fall must not be missed.
or remaining < self.cfg.height_collapse # 높이 급붕괴(정면/후면 낙상) prone = ar > self.cfg.aspect_ratio or remaining < self.cfg.height_collapse
or head_below_hips(kps) angle = torso_angle_deg(kps)
) # keypoint cues only when the keypoints are actually trustworthy, else they are noise.
if kp_ok:
prone = prone or angle > self.cfg.fallen_angle or head_below_hips(kps)
return prone, {"torso_angle": round(angle, 1), return prone, {"torso_angle": round(angle, 1),
"aspect_ratio": round(ar, 2), "aspect_ratio": round(ar, 2),
"height_remaining": round(remaining, 2)} "height_remaining": round(remaining, 2)}
@ -61,14 +62,16 @@ class FallDetector:
f = track.latest f = track.latest
kps, bbox = f.keypoints, f.bbox kps, bbox = f.keypoints, f.bbox
# 신뢰도 게이트 # Confidence gate — no longer a blanket early-return (that disabled fall/no_motion/
if not core_visible(kps, self.cfg.min_conf) or visible_count(kps) < self.cfg.min_visible: # ghost for the occluded person on the ground). It now only decides whether the
return None, {} # KEYPOINT-based cues are trustworthy; the bbox cues always run.
kp_ok = core_visible(kps, self.cfg.min_conf) and visible_count(kps) >= self.cfg.min_visible
prone, meta = self._prone(kps, bbox, st) prone, meta = self._prone(kps, bbox, st, kp_ok)
# incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze # incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze. Only trust a
if st.name == "STANDING" and not prone: # confident standing frame for the baseline so a bad low-conf bbox can't corrupt it.
if st.name == "STANDING" and not prone and kp_ok:
st.baseline_height = bbox.height st.baseline_height = bbox.height
if not prone: if not prone:

View file

@ -26,13 +26,20 @@ class GhostIncidentDetector:
state = self._states.get(tr.track_id, "STANDING") state = self._states.get(tr.track_id, "STANDING")
if state in _DANGEROUS: if state in _DANGEROUS:
b = tr.latest.bbox b = tr.latest.bbox
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now) # Snapshot the OTHER tracks active at loss time. Only a track that did NOT
# exist then can be the victim re-appearing (a re-acquired track gets a new
# id); a track already present is a bystander/colleague and must not cancel
# the incident by walking over the loss point.
bystanders = set(self._states) - {tr.track_id}
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, bystanders)
self._states.pop(tr.track_id, None) self._states.pop(tr.track_id, None)
def update(self, active_tracks: list[Track], now: float) -> list[dict]: def update(self, active_tracks: list[Track], now: float) -> list[dict]:
# 인근 재출현이면 후보 취소 # 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현).
for tid, (spatial, _state, height, _lost_at) in list(self._candidates.items()): for tid, (spatial, _state, height, _lost_at, bystanders) in list(self._candidates.items()):
for tr in active_tracks: for tr in active_tracks:
if tr.track_id in bystanders:
continue # a colleague who was already here — not the reappearing victim
b = tr.latest.bbox b = tr.latest.bbox
dist = ((b.cx - spatial[0]) ** 2 + (b.cy - spatial[1]) ** 2) ** 0.5 dist = ((b.cx - spatial[0]) ** 2 + (b.cy - spatial[1]) ** 2) ** 0.5
if dist <= self.cfg.reappear_dist * (height or 1.0): if dist <= self.cfg.reappear_dist * (height or 1.0):
@ -40,7 +47,7 @@ class GhostIncidentDetector:
break break
# 대기 초과 방출 # 대기 초과 방출
out = [] out = []
for tid, (spatial, state, _height, lost_at) in list(self._candidates.items()): for tid, (spatial, state, _height, lost_at, _by) in list(self._candidates.items()):
if now - lost_at >= self.cfg.reappear_s: if now - lost_at >= self.cfg.reappear_s:
out.append({"track_id": tid, "spatial": spatial, "last_state": state, out.append({"track_id": tid, "spatial": spatial, "last_state": state,
"waited_s": round(now - lost_at, 1)}) "waited_s": round(now - lost_at, 1)})

View file

@ -40,16 +40,27 @@ class ZoneIntrusionDetector:
self._inside_since = {k: v for k, v in self._inside_since.items() if k[0] != track_id} self._inside_since = {k: v for k, v in self._inside_since.items() if k[0] != track_id}
self._fired = {k for k in self._fired if k[0] != track_id} self._fired = {k for k in self._fired if k[0] != track_id}
def _foot(self, track: Track) -> tuple[float, float]: def _foot(self, track: Track, frame_wh=None) -> tuple[float, float]:
kps = track.latest.keypoints kps = track.latest.keypoints
a, b = kps[L_ANKLE], kps[R_ANKLE] a, b = kps[L_ANKLE], kps[R_ANKLE]
return ((a.x + b.x) / 2, (a.y + b.y) / 2) fx, fy = (a.x + b.x) / 2, (a.y + b.y) / 2
# Zones are stored NORMALIZED (0..1 of the image) by the dashboard, which stretches the
# camera snapshot to a fixed canvas. The foot is camera-native pixels, so normalize it
# by the frame size to compare in the same space — otherwise every zone is misplaced
# (a zone drawn at image-centre would be checked against a raw pixel like 480, matching
# nothing on a 1280-wide frame). frame_wh=None keeps raw coords for unit tests that
# supply their own matching space.
if frame_wh:
w, h = frame_wh
if w > 0 and h > 0:
fx, fy = fx / w, fy / h
return (fx, fy)
def active_zone_ids(self, track_id: int) -> set: def active_zone_ids(self, track_id: int) -> set:
return {z for (tid, z) in self._inside_since if tid == track_id} return {z for (tid, z) in self._inside_since if tid == track_id}
def update(self, track: Track, now: float) -> list[dict]: def update(self, track: Track, now: float, frame_wh=None) -> list[dict]:
fx, fy = self._foot(track) fx, fy = self._foot(track, frame_wh)
out = [] out = []
for zone in self.zones: for zone in self.zones:
key = (track.track_id, zone.zone_id) key = (track.track_id, zone.zone_id)

View file

@ -48,12 +48,14 @@ class EventEngine:
zone_id, now, duration_s, m, spatial) zone_id, now, duration_s, m, spatial)
def process(self, active_tracks: list[Track], lost_tracks: list[Track], def process(self, active_tracks: list[Track], lost_tracks: list[Track],
now: float) -> list[Event]: now: float, frame_wh=None) -> list[Event]:
events: list[Event] = [] events: list[Event] = []
self.ghost.on_lost(lost_tracks, now) self.ghost.on_lost(lost_tracks, now)
for tr in active_tracks: for tr in active_tracks:
zone_hits = self.zone.update(tr, now) # frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh)
in_zone = bool(self.zone.active_zone_ids(tr.track_id)) in_zone = bool(self.zone.active_zone_ids(tr.track_id))
sig, meta = self.fall.update(tr, now) sig, meta = self.fall.update(tr, now)
@ -69,8 +71,12 @@ class EventEngine:
events.append(self._mk(EventType.IMMOBILITY, now, tr.track_id, metrics=imeta)) events.append(self._mk(EventType.IMMOBILITY, now, tr.track_id, metrics=imeta))
for hit in zone_hits: for hit in zone_hits:
etype = (EventType.ZONE_RESTRICTED if hit["level"] == "restricted" # 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
else EventType.ZONE_WARNING) # PINCH/HIGH — the old `restricted else WARNING` silently downgraded it. The
# dashboard offers exactly these three levels (see zones.php).
etype = {"pinch": EventType.PINCH,
"restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now): if self._allow((etype, tr.track_id, hit["zone_id"]), now):
events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"])) events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"]))

View file

@ -56,11 +56,21 @@ def normalize(value: float, bbox_height: float) -> float:
def track_displacement(track) -> float: def track_displacement(track) -> float:
"""Mean per-keypoint displacement between the last two frames, normalized by bbox height. 1.0 if <2 frames.""" """Mean per-keypoint SPEED between the last two frames: displacement normalized by bbox
height AND by elapsed time (per second). Without the time term the value scales with the
frame rate, so the same physical stillness reads as 'moving' at low fps and 'still' at
high fps — flipping immobility/no_motion with the daemon's actual (load-dependent) fps.
Returns 1.0 (moving/unknown) with <2 frames or a non-positive dt.
NOTE: motion_thr in FallConfig/ImmobilityConfig is now a per-SECOND threshold; its default
predates this change and should be re-tuned on device."""
if len(track.frames) < 2: if len(track.frames) < 2:
return 1.0 return 1.0
a, b = track.frames[-2], track.frames[-1] a, b = track.frames[-2], track.frames[-1]
dt = b.t - a.t
if dt <= 0:
return 1.0
h = b.bbox.height or 1.0 h = b.bbox.height or 1.0
d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5
for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints) for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints)
return normalize(d, h) return normalize(d, h) / dt

View file

@ -6,13 +6,22 @@ from edge.events import Event, EventType, Severity
class OfflineQueue: class OfflineQueue:
def __init__(self, db_path: str, client): def __init__(self, db_path: str, client, max_rows: int = 2000,
max_send_attempts: int = 20):
self.client = client self.client = client
self.max_rows = max_rows # SD-card guard: evict oldest beyond this
self.max_send_attempts = max_send_attempts # drop a poison event after this many tries
self.db = sqlite3.connect(db_path) self.db = sqlite3.connect(db_path)
self.db.execute( self.db.execute(
"CREATE TABLE IF NOT EXISTS queue (" "CREATE TABLE IF NOT EXISTS queue ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT)" "id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT, "
"attempts INTEGER DEFAULT 0)"
) )
# Migrate an older DB that predates the attempts column.
try:
self.db.execute("ALTER TABLE queue ADD COLUMN attempts INTEGER DEFAULT 0")
except sqlite3.OperationalError:
pass # column already exists
self.db.commit() self.db.commit()
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]: def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
@ -30,6 +39,12 @@ class OfflineQueue:
snaps = [base64.b64encode(s).decode() for s in snapshots] snaps = [base64.b64encode(s).decode() for s in snapshots]
self.db.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)", self.db.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)",
(json.dumps(payload), json.dumps(snaps))) (json.dumps(payload), json.dumps(snaps)))
# Bound the queue so a multi-day outage — or a permanent misconfig that queues every
# event forever — cannot fill the SD card and hard-crash the Pi. Keep the newest
# max_rows; a fresh fall alert matters more than a stale one.
self.db.execute(
"DELETE FROM queue WHERE id NOT IN "
"(SELECT id FROM queue ORDER BY id DESC LIMIT ?)", (self.max_rows,))
self.db.commit() self.db.commit()
def try_send(self, event: Event, snapshots: list[bytes]) -> bool: def try_send(self, event: Event, snapshots: list[bytes]) -> bool:
@ -39,9 +54,10 @@ class OfflineQueue:
return False return False
def flush(self) -> int: def flush(self) -> int:
rows = self.db.execute("SELECT id, payload, snaps FROM queue ORDER BY id").fetchall() rows = self.db.execute(
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
sent = 0 sent = 0
for rid, payload_json, snaps_json in rows: for rid, payload_json, snaps_json, attempts in rows:
payload = json.loads(payload_json) payload = json.loads(payload_json)
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)] snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
event, snapshots = self._rehydrate(payload, snaps) event, snapshots = self._rehydrate(payload, snaps)
@ -49,8 +65,19 @@ class OfflineQueue:
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
self.db.commit() self.db.commit()
sent += 1 sent += 1
else: continue
break # 여전히 오프라인 — 순서 보존 # Failed. A bool send() can't tell a transient outage from a permanently-rejected
# (poison) event, so count attempts: below the cap, stop and preserve order (an
# outage recovers and the whole queue drains next flush); at the cap, DROP this
# row and continue so a poison head can never wedge the queue forever.
attempts += 1
if attempts >= self.max_send_attempts:
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
self.db.commit()
continue
self.db.execute("UPDATE queue SET attempts = ? WHERE id = ?", (attempts, rid))
self.db.commit()
break # transient — preserve order, retry the whole queue next time
return sent return sent
def pending(self) -> int: def pending(self) -> int:

View file

@ -0,0 +1,261 @@
"""빌드/온라인 머신 전용: Hailo yolov8s_pose HEF 다운로드(폐쇄망 번들용). MIT (Hailo Model Zoo).
Mirrors edge/tools/fetch_movenet.py: pure stdlib, no deps, idempotent (re-running with the
file already in place verifies and returns without re-downloading).
AIR GAP (rules/common/build-deployment.md): this runs on the BUILD machine or on an
INTERNET-CONNECTED Pi (deploy/setup_online.sh [6/8]) — never on the air-gapped target. The
offline bundle carries the resulting .hef across the gap (.gitignore ignores *.hef, so it is
a build artifact, never a committed one).
REFUSES TO GUESS THE BOARD. A Hailo-8 HEF does not load on a Hailo-8L and vice versa:
[HailoRT] [error] HEF format is not compatible with device. Device arch: HAILO8L, HEF arch: HAILO8
[HailoRT] [error] CHECK_SUCCESS failed with status=HAILO_INVALID_HEF(26)
so the arch is read from the board (`hailortcli fw-control identify`) or must be passed
explicitly with --arch. With neither, this exits non-zero rather than install a coin-flip.
WHY v2.14.0: it is what the official hailo-rpi5-examples download_resources.sh pins, and its
HEF header is version 1 — parsed by every HailoRT from 4.18 through 4.23, so one file works on
both Bookworm (hailo-all 4.20) and Trixie (hailo-all 5.1.1 -> hailort 4.23). Newer model-zoo
releases (v2.15.0+) emit HEF header version 2, which HailoRT 4.18/4.19 reject outright.
CONTRACT (verified 2026-07-16 by decoding the HEF protobuf itself, BOTH arches — matches
edge/adapters/hailo_pose.py and tests/test_hailo_pose.py exactly):
input_layer1 640x640x3 uint8 RGB; a `normalization1` layer does 0-255 scaling ON CHIP
output_layer1..9 3 strides x {box DFL (H,W,64), score (H,W,1, sigmoid on chip),
keypoints (H,W,51)} @ 80x80, 40x40, 20x20
NO on-chip NMS (zero nms/hailo_nms markers anywhere in either file) -> the host-side DFL
decode + NMS in hailo_pose.py is the correct and necessary post-processing.
"""
import argparse
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
MZ_VERSION = "v2.14.0"
BASE = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled"
MODEL = "yolov8s_pose"
# Hailo publishes NO checksum for these objects: .sha256 / .md5 / SHA256SUMS sidecars all
# return HTTP 403 and bucket listing is denied, so there is no upstream manifest to verify
# against. The digests below are therefore NOT vendor-published — they were computed from the
# bytes these URLs served on 2026-07-16 and are pinned so that a later download which differs
# (mirror swap, truncation, tampering) fails loudly instead of installing wrong weights.
# To re-pin deliberately: curl the URL, sha256sum it, update BOTH sha256 and size here.
#
# The S3 ETag is a usable cross-check for hailo8 ONLY (single-part upload -> ETag == MD5
# 9c0542f0009ce952e16f9d32febebe01). hailo8l is a MULTIPART upload (ETag ends "-2"), so its
# ETag is a digest-of-digests, not an MD5 — do not "fix" it to compare equal.
HEFS = {
"hailo8": { # 26 TOPS "AI HAT+"
"sha256": "4f06be93349a21fcc28ee0a746ca84f1c0e17aee99bfd1f59596a477d5160f99",
"size": 10294713,
},
"hailo8l": { # 13 TOPS "AI Kit"
"sha256": "7293a6b88a2a7bf9ea07819e92057d67e947300f8d0e87f49d50bb060fb959b1",
"size": 22658846,
},
}
DEFAULT_DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", f"{MODEL}.hef")
def url_for(arch):
return f"{BASE}/{MZ_VERSION}/{arch}/{MODEL}.hef"
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _arch_from_enum_name(name):
"""Map a hailo_platform DeviceArchitectureTypes member NAME to our HEF key.
-> 'hailo8' | 'hailo8l' | None. Same first-match 8L-before-8 discipline as the hailortcli
regex ('HAILO8L' contains 'HAILO8'), so it also folds silicon revisions ('HAILO8_A0' /
'HAILO8_B0') onto 'hailo8', and returns None for any non-8 family (Hailo-10/15) we have no
HEF for. Never guesses."""
n = (name or "").upper()
if "HAILO8L" in n:
return "hailo8l"
if "HAILO8" in n:
return "hailo8"
return None
def _arch_via_hailortcli(run):
"""First signal: parse `hailortcli fw-control identify`. -> 'hailo8'|'hailo8l'|None."""
try:
p = run(["hailortcli", "fw-control", "identify"],
capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return None
if p.returncode != 0:
return None
# HAILO8L must precede HAILO8 in the alternation: regex alternation is first-match, so
# (HAILO8|HAILO8L) would match the "HAILO8" prefix of "HAILO8L" and mis-detect an AI Kit
# as an AI HAT+ — the exact HAILO_INVALID_HEF(26) this function exists to prevent.
m = re.search(r"Device Architecture\s*:\s*(HAILO8L|HAILO8)\b", p.stdout or "", re.I)
return m.group(1).lower() if m else None
def _arch_via_python_api(): # pragma: no cover (device-only: needs hailo_platform + a board)
"""Second signal: read the arch through the hailo_platform bindings instead of parsing
hailortcli text. Runs the SAME firmware identify underneath, so it does NOT rescue a board
whose firmware will not answer — but it (a) works when hailortcli is absent from PATH
(hailo-all ships the Python bindings independently) and (b) returns a typed enum, so it is
not defeated by the \\b regex on develop-firmware strings like 'HAILO8_B0'. Never raises:
any missing symbol/attr or absent board -> None.
UNVERIFIED: the API names (Device.scan / control.identify().device_architecture) are from
Hailo's pyhailort and are not runnable in this repo. The broad except makes a wrong name
safe — it yields None (refuse-to-guess), never a crash or a guess."""
try:
from hailo_platform import Device
ids = Device.scan()
if not ids:
return None
with Device(ids[0]) as dev:
arch = dev.control.identify().device_architecture
return _arch_from_enum_name(getattr(arch, "name", str(arch)))
except Exception:
return None
def detect_arch(run=subprocess.run, python_api=_arch_via_python_api):
"""Read the arch off the board itself. -> 'hailo8' | 'hailo8l' | None.
Two independent signals, in order: (1) `hailortcli fw-control identify` text, (2) the
hailo_platform bindings. Both read the board's ARCHITECTURE, which is reported even when
the serial/part/product identity fields are unprogrammed — a Hailo-8 commonly shows
'Device Architecture: HAILO8' with 'Serial Number: <N/A>'. So a serial-less board is NOT
why this returns None. None still means "no board answered": no hailortcli AND no
bindings, no /dev/hailo0 (driver installed, not rebooted), or an arch we have no HEF for.
None is never papered over with a default — an operator who knows the board bypasses
detection entirely with --arch / HAILO_ARCH."""
arch = _arch_via_hailortcli(run)
if arch is not None:
return arch
# hailortcli missing/non-zero, or its Device Architecture line absent/unparseable. The
# in-process bindings survive a missing CLI and the \b regex fragility; still None if no
# board answers.
return python_api()
def fetch(arch, dest, force=False, log=print):
"""Download (or verify in place) the yolov8s_pose HEF for `arch`. -> dest path."""
spec = HEFS[arch]
url = url_for(arch)
if os.path.exists(dest) and not force:
if sha256_of(dest) == spec["sha256"]:
log(f"{dest} already present and matches the pinned {arch} digest; nothing to do")
return dest
# Covers the nastiest case: a previous run left the OTHER arch's HEF (or a truncated
# one) here. Silently keeping it is how a board ends up loading a HEF it will reject.
# ASCII only in printed strings: a Windows build machine's console is often cp949/cp1252,
# where an em-dash raises UnicodeEncodeError. That would crash the tamper-detection path
# itself, replacing "digest mismatch" with a traceback about a dash.
log(f"WARNING: {dest} exists but does not match the pinned {arch} digest - replacing it")
dest_dir = os.path.dirname(dest) or "."
os.makedirs(dest_dir, exist_ok=True)
log(f"downloading {MODEL} HEF for {arch} ({spec['size']} bytes, model-zoo {MZ_VERSION})")
log(f" {url}")
log(f" -> {dest}")
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=f".{MODEL}.", suffix=".part")
os.close(fd)
# Staged through a temp file in the SAME directory, then os.replace()'d: an
# interrupted download must never leave a half-written .hef at `dest`, because the
# installer's next step only tests -f and would happily install the truncation.
with urllib.request.urlopen(url, timeout=60) as r, open(tmp, "wb") as f:
shutil.copyfileobj(r, f)
size = os.path.getsize(tmp)
digest = sha256_of(tmp)
if size != spec["size"] or digest != spec["sha256"]:
raise SystemExit(
f"ERROR: downloaded {MODEL}.hef for {arch} does not match the pinned digest.\n"
f" url : {url}\n"
f" expected: {spec['size']} bytes, sha256 {spec['sha256']}\n"
f" got : {size} bytes, sha256 {digest}\n"
f" Refusing to install it. If upstream legitimately re-published this model,\n"
f" re-pin HEFS['{arch}'] in {os.path.basename(__file__)} deliberately."
)
os.replace(tmp, dest)
tmp = None
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
log(f"done: {dest} (sha256 verified)")
return dest
def main(argv=None):
ap = argparse.ArgumentParser(
description="Download the Hailo yolov8s_pose HEF for THIS board "
"(build/online machine only - never the air-gapped target).")
ap.add_argument("--arch", choices=sorted(HEFS),
help="target arch. Omit to read it from the board via `hailortcli "
"fw-control identify`, or set the HAILO_ARCH env var. Pass "
"explicitly to cross-fetch on a build machine with no board.")
ap.add_argument("--dest", default=DEFAULT_DEST, help=f"output path (default: {DEFAULT_DEST})")
ap.add_argument("--force", action="store_true", help="re-download even if the digest matches")
args = ap.parse_args(argv)
# Operator override for a board auto-detect cannot read (e.g. a Hailo-8 whose firmware
# reports no identity fields, or a Pi where hailortcli is not yet on PATH). Precedence:
# --arch flag > HAILO_ARCH env > board auto-detect. argparse already validated the flag
# against HEFS; validate the env value against the same allow-list — an unknown value is a
# typo, not a request to fall back.
env_arch = os.environ.get("HAILO_ARCH", "").strip().lower() or None
if env_arch is not None and env_arch not in HEFS:
sys.exit(
f"ERROR: HAILO_ARCH={os.environ['HAILO_ARCH']!r} is not a supported arch.\n"
f" Use exactly one of: {', '.join(sorted(HEFS))} (lowercase)."
)
detected = detect_arch()
override = args.arch or env_arch
arch = override or detected
if arch is None:
sys.exit(
"ERROR: cannot determine the Hailo arch, and refusing to guess.\n"
" A Hailo-8 HEF does not load on a Hailo-8L (HAILO_INVALID_HEF(26)), so there is\n"
" no safe default here.\n"
"\n"
" On the Pi this usually means the board is not answering yet:\n"
" hailortcli fw-control identify # expect 'Device Architecture: HAILO8'\n"
" dpkg -s hailo-all >/dev/null 2>&1 || sudo apt-get install -y hailo-all\n"
" ls -l /dev/hailo0 # absent -> the driver needs a REBOOT\n"
"\n"
" If the board answers but prints no 'Device Architecture' line (some Hailo-8\n"
" boards report no serial/identity), name the arch yourself:\n"
" HAILO_ARCH=hailo8 python -m edge.tools.fetch_hailo_hef # 26 TOPS AI HAT+\n"
" HAILO_ARCH=hailo8l python -m edge.tools.fetch_hailo_hef # 13 TOPS AI Kit"
)
if override and detected and override != detected:
# Legitimate when cross-fetching for a fleet; disastrous when it is a typo. Say it
# loudly, but let the explicit override win — the operator stated an intent.
src = "--arch" if args.arch else "HAILO_ARCH"
print(f"WARNING: {src} {override} overrides the detected board ({detected}). "
f"This HEF will NOT load on this machine's board.", file=sys.stderr)
fetch(arch, os.path.abspath(args.dest), force=args.force)
if __name__ == "__main__":
main()

View file

@ -16,10 +16,11 @@ def test_sign_is_deterministic():
def test_send_posts_signed_payload(): def test_send_posts_signed_payload():
captured = {} captured = {}
def fake_post(url, data=None, headers=None, timeout=None): def fake_post(url, data=None, headers=None, timeout=None, **kw):
captured["url"] = url captured["url"] = url
captured["headers"] = headers captured["headers"] = headers
captured["body"] = data captured["body"] = data
captured["kw"] = kw
return _FakeResp(200) return _FakeResp(200)
client = PostClient("https://x/api/events", "pi-01", "secret", client = PostClient("https://x/api/events", "pi-01", "secret",
@ -35,6 +36,12 @@ def test_send_posts_signed_payload():
assert len(payload["snapshots"]) == 1 assert len(payload["snapshots"]) == 1
expected = sign("secret", "n1", "1000.0", captured["body"].encode()) expected = sign("secret", "n1", "1000.0", captured["body"].encode())
assert captured["headers"]["X-Signature"] == expected assert captured["headers"]["X-Signature"] == expected
# cafe24's shared-hosting WAF blocks the default python-requests/x.y User-Agent with an
# HTML 403 (curl passes) — a custom UA is required or every event silently 403s.
assert captured["headers"]["User-Agent"] == "move-detect-edge/1.0"
# The .htaccess http->https 302 must NOT be auto-followed as a GET (405/404); a redirect
# is a misconfiguration to surface, not to silently downgrade the POST.
assert captured["kw"].get("allow_redirects") is False
def test_send_returns_false_on_error_code(): def test_send_returns_false_on_error_code():
client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,

View file

@ -0,0 +1,180 @@
"""Guard: the Cafe24 upload package must be complete and safe the moment it is built.
The package is what a non-developer drags into Cafe24's FTP root, so a mistake here is
not caught by anything downstream — there is no CI on the hosting account. Every check
below encodes a failure that is silent in production:
* a missing vendor/ autoloader fatals every request (public/index.php's `require` of
../vendor/autoload.php is unconditional);
* a *dev* autoloader eagerly requires tests/Support/InMemoryRepositories.php, which is
not in the package, so it also fatals every request;
* a missing storage/snapshots/ silently drops every snapshot while still returning
201 to the edge device (IngestController's `continue` on a failed write).
"""
import os
import re
import subprocess
import pytest
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
# cloud/deploy/, not deploy/: deploy/ belongs to the Raspberry Pi — its own README opens with
# "Edge deployment (Raspberry Pi 5, air-gapped)" and everything in it is run as root ON the
# Pi. Cafe24 runs nothing; the package is only ever uploaded. Deploying cloud/ belongs to
# cloud/, which already owns cloud/docs/cloud-deploy.md.
BUILD = os.path.join(ROOT, "cloud", "deploy", "build_cafe24_package.sh")
HTACCESS = os.path.join(ROOT, "cloud", "deploy", "htaccess.www")
CONFIG_TMPL = os.path.join(ROOT, "cloud", "deploy", "config.template.php")
CONFIG_EXAMPLE = os.path.join(ROOT, "cloud", "config.example.php")
CLOUD_DEPLOY_DOC = os.path.join(ROOT, "cloud", "docs", "cloud-deploy.md")
CONFIG_PHP = os.path.join(ROOT, "cloud", "src", "Config.php")
def _read(path):
with open(path, encoding="utf-8") as f:
return f.read()
# Paths are passed to bash/shellcheck relative to ROOT: Git Bash's bash is an MSYS program,
# so it resolves neither C:\a\b (backslashes vanish) nor C:/a/b (not a POSIX path) — it
# wants /c/a/b. A relative path plus cwd=ROOT sidesteps the conversion on every platform.
BUILD_REL = os.path.join("cloud", "deploy", "build_cafe24_package.sh").replace("\\", "/")
def _config_keys_read_by_app():
"""Every config.php key App\\Config actually reads (the sole consumer: its ctor is
private and fromArray() is called once, from public/index.php)."""
return set(re.findall(r"\$this->a\['([a-z0-9_]+)'\]", _read(CONFIG_PHP)))
def test_build_script_exists_and_is_executable_bash():
assert os.path.isfile(BUILD), "cloud/deploy/build_cafe24_package.sh is missing"
assert _read(BUILD).startswith("#!"), "build script needs a shebang"
def test_build_script_generates_a_no_dev_autoloader():
sh = _read(BUILD)
# cloud/public/index.php:3 hard-requires ../vendor/autoload.php, and
# cloud/src/Auth/Session.php declares three symbols under one filename (hence
# composer.json's classmap entry) — so a hand-rolled PSR-4 loader cannot resolve
# App\Auth\PhpSessionStore. Composer's classmap dump is the only correct artifact.
assert "composer dump-autoload" in sh
assert "--no-dev" in sh
assert "--classmap-authoritative" in sh
def test_build_script_excludes_dev_only_trees():
sh = _read(BUILD)
# Shipping the working tree's vendor/ would drag in PHPUnit *and* a dev
# autoload_files.php that requires tests/Support/InMemoryRepositories.php on every
# request. Shipping tests/ or phpunit.xml is dead weight on a production host.
for forbidden in ("phpunit", "tests"):
assert forbidden in sh, f"build script must explicitly deal with {forbidden}"
def test_build_script_mounts_the_app_in_a_subdirectory_of_www():
sh = _read(BUILD)
# This account's www/ already serves another site, so the app is mounted at
# www/move_detect/ and everything secret stays at the FTP root — one level ABOVE www/,
# two above the app. Emitting the app straight into www/ would put config.php (DB
# password) one level up, i.e. inside a docroot the other site also serves.
assert 'APP_DIR="${APP_DIR-move_detect}"' in sh
assert 'WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"' in sh
# Everything private goes in ONE container beside www/, not sprayed across the FTP root:
# that root also holds the co-hosted site's files, and `vendor/`/`config.php` are ordinary
# names in any PHP project — a flat layout risks overwriting them.
assert 'PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"' in sh
assert 'PRIV="$STAGE/$PRIVATE_DIR"' in sh
for private in ('"$PRIV/config.php"', '"$PRIV/src/"', '"$PRIV/storage/snapshots"',
'"$PRIV/vendor/autoload.php"', '"$PRIV/schema.sql"'):
assert private in sh, f"{private} must live inside the private container"
# ...and nothing private may be staged under the web directory.
for leaked in ('"$WEB/config.php"', '"$WEB/src/"', '"$WEB/vendor', '"$WEB/storage'):
assert leaked not in sh, f"{leaked} would be web-reachable"
def test_app_finds_its_root_by_walking_up_not_by_a_hardcoded_depth():
index = _read(os.path.join(ROOT, "cloud", "public", "index.php"))
# A hardcoded '/..' resolves to www/ under a subdirectory mount — exactly where
# config.php must never live. The walk-up makes one package work at either depth.
assert "APP_ROOT" in index
assert "require __DIR__ . '/../vendor/autoload.php'" not in index
assert "require APP_ROOT . '/vendor/autoload.php'" in index
assert "require APP_ROOT . '/config.php'" in index
# The private container is probed at every level, and BEFORE the bare directory: the
# co-hosted site may itself have config.php + vendor/ at the FTP root, and binding to
# those would hand this app another site's database credentials.
assert "APP_PRIVATE_DIR = 'move_detect_app'" in index
assert "[$dir . '/' . APP_PRIVATE_DIR, $dir]" in index, \
"the container must be probed before the bare directory"
# All three markers must match. config.php + vendor/ alone are ordinary names in any PHP
# project sharing the account; src/Config.php is specific to this app.
for marker in ("'/config.php'", "'/vendor/autoload.php'", "'/src/Config.php'"):
assert marker in index, f"{marker} must be part of the app-root check"
def test_build_script_creates_the_snapshot_dir():
sh = _read(BUILD)
# Nothing in cloud/src ever creates this directory, and a failed write is swallowed
# by IngestController's `continue` — the API still answers 201 and the evidence is
# gone for good.
assert "storage/snapshots" in sh
def test_htaccess_forces_https_and_keeps_the_front_controller():
ht = _read(HTACCESS)
# src/Auth/Session.php hard-codes cookie_secure => true, so over plain HTTP the
# browser never returns the session cookie and login silently fails. HTTPS is a hard
# requirement, not hardening.
assert "RewriteCond %{HTTPS} !=on" in ht
assert "https://%{HTTP_HOST}%{REQUEST_URI}" in ht
# ...without breaking the existing rewrite-to-index front controller.
assert "RewriteRule ^ index.php [QSA,L]" in ht
def test_htaccess_denies_dotfiles_and_plaintext_servable_files():
ht = _read(HTACCESS)
assert "Options -Indexes" in ht
assert re.search(r'<FilesMatch\s+"\^\\\."', ht), "dotfiles (.git, .env) must be denied"
# schema.sql is the sharp one: Apache has no handler for .sql, so it serves the full
# DDL as plaintext. Unlike .php, PHP will not swallow it.
assert "sql" in ht
def test_config_template_lists_every_key_the_app_reads():
tmpl = _read(CONFIG_TMPL)
missing = {k for k in _config_keys_read_by_app() if f"'{k}'" not in tmpl}
assert not missing, f"config template omits keys App\\Config reads: {sorted(missing)}"
def test_config_example_lists_every_key_the_app_reads():
example = _read(CONFIG_EXAMPLE)
# Silent-default bug: nonce_ttl and ingest_ip_rate_limit_per_min have `?? default`
# fallbacks, so an operator who never sees them cannot know the knobs exist.
missing = {k for k in _config_keys_read_by_app() if f"'{k}'" not in example}
assert not missing, f"config.example.php omits keys App\\Config reads: {sorted(missing)}"
def test_cloud_deploy_doc_does_not_tell_operators_to_drop_vendor():
doc = _read(CLOUD_DEPLOY_DOC)
# public/index.php:3 is an unconditional `require` of ../vendor/autoload.php.
# "Do not upload vendor/" bricks every request, including the doc's own smoke check.
assert "Do not upload `vendor/`" not in doc
def test_build_script_passes_shellcheck_if_available():
# Probe and invoke through the SAME interpreter. Probing with `bash -c command -v` and
# then invoking shellcheck from the Windows PATH asks two different machines, so the
# probe can pass while the call ENOENTs. And skip visibly rather than `return`, which
# reports a green test that never ran.
r = subprocess.run(["bash", "-c", f"command -v shellcheck >/dev/null || exit 99; "
f"shellcheck -S warning {BUILD_REL}"],
cwd=ROOT, capture_output=True, text=True)
if r.returncode == 99:
pytest.skip("shellcheck not installed; bash -n still guards syntax")
assert r.returncode == 0, f"shellcheck:\n{r.stdout}\n{r.stderr}"
def test_build_script_is_syntactically_valid_bash():
r = subprocess.run(["bash", "-n", BUILD_REL], cwd=ROOT, capture_output=True, text=True)
assert r.returncode == 0, f"bash -n failed:\n{r.stderr}"

View file

@ -8,14 +8,17 @@ class _Resp:
def test_fetch_config_signs_and_parses(): def test_fetch_config_signs_and_parses():
captured = {} captured = {}
def fake_get(url, headers=None, timeout=None): def fake_get(url, headers=None, timeout=None, **kw):
captured["url"] = url; captured["headers"] = headers captured["url"] = url; captured["headers"] = headers; captured["kw"] = kw
return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}})) return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}}))
c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get) c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
cfg = c.fetch_config() cfg = c.fetch_config()
assert cfg["version"] == 3 assert cfg["version"] == 3
assert "device_id=pi-1" in captured["url"] assert "device_id=pi-1" in captured["url"]
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "") assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "")
# cafe24 WAF blocks the default python-requests UA; a redirect must not be auto-followed.
assert captured["headers"]["User-Agent"] == "move-detect-edge/1.0"
assert captured["kw"].get("allow_redirects") is False
def test_fetch_config_none_on_error(): def test_fetch_config_none_on_error():
c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401)) c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
@ -23,10 +26,13 @@ def test_fetch_config_none_on_error():
def test_send_heartbeat_signs_body(): def test_send_heartbeat_signs_body():
captured = {} captured = {}
def fake_post(url, data=None, headers=None, timeout=None): def fake_post(url, data=None, headers=None, timeout=None, **kw):
captured["data"] = data; captured["headers"] = headers; return _Resp(200) captured["data"] = data; captured["headers"] = headers; captured["kw"] = kw
return _Resp(200)
c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post) c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
assert c.send_heartbeat({"fps": 12.0}) is True assert c.send_heartbeat({"fps": 12.0}) is True
body = captured["data"] body = captured["data"]
assert json.loads(body)["device_id"] == "pi-1" assert json.loads(body)["device_id"] == "pi-1"
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body) assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body)
assert captured["headers"]["User-Agent"] == "move-detect-edge/1.0"
assert captured["kw"].get("allow_redirects") is False

View file

@ -1,4 +1,7 @@
from edge.config import load_config import pytest
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
require_upload_config)
def test_defaults_when_env_empty(): def test_defaults_when_env_empty():
cfg = load_config({}) cfg = load_config({})
@ -30,3 +33,40 @@ def test_env_overrides():
assert cfg.engine.camera_id == "cam-9" assert cfg.engine.camera_id == "cam-9"
assert cfg.ghost.reappear_s == 7.0 assert cfg.ghost.reappear_s == 7.0
assert cfg.zone.debounce_s == 1.5 assert cfg.zone.debounce_s == 1.5
def test_require_upload_config_rejects_blank_url_and_secret():
# A blank CAFE24_URL or DEVICE_SECRET must fail LOUD at startup. Left silent, the daemon
# runs green while every upload fails (bad URL -> requests error, caught) and the offline
# queue grows until the SD card is full.
with pytest.raises(ValueError, match="CAFE24_URL"):
require_upload_config(load_config({"DEVICE_SECRET": "s"}))
with pytest.raises(ValueError, match="DEVICE_SECRET"):
require_upload_config(load_config({"CAFE24_URL": "https://x/move_detect"}))
# Whitespace-only is still blank.
with pytest.raises(ValueError):
require_upload_config(load_config({"CAFE24_URL": " ", "DEVICE_SECRET": " "}))
def test_require_upload_config_passes_when_both_present():
cfg = load_config({"CAFE24_URL": "https://x/move_detect", "DEVICE_SECRET": "s"})
require_upload_config(cfg) # must not raise
def test_frame_interval_defaults_and_validates():
assert frame_interval_from_env({}) == pytest.approx(1 / 12)
assert frame_interval_from_env({"TARGET_FPS": "24"}) == pytest.approx(1 / 24)
# TARGET_FPS=0 -> ZeroDivisionError today -> infinite systemd crash loop; must be rejected.
with pytest.raises(ValueError):
frame_interval_from_env({"TARGET_FPS": "0"})
with pytest.raises(ValueError):
frame_interval_from_env({"TARGET_FPS": "12fps"})
with pytest.raises(ValueError):
frame_interval_from_env({"TARGET_FPS": "-5"})
def test_queue_db_path_defaults_absolute_under_state_dir():
# A relative 'queue.db' is unwritable under the hardened unit (ProtectSystem=strict,
# WorkingDirectory read-only) -> crash loop. Default to the writable StateDirectory.
assert queue_db_path({}) == "/var/lib/move-detect/queue.db"
assert queue_db_path({"QUEUE_DB": "/custom/q.db"}) == "/custom/q.db"

View file

@ -1,3 +1,5 @@
import pytest
from edge.daemon import run_loop from edge.daemon import run_loop
@ -8,9 +10,9 @@ class _Runtime:
class _Client: class _Client:
def __init__(self): self.configs = 0; self.beats = 0 def __init__(self): self.configs = 0; self.beats = 0; self.metrics = []
def fetch_config(self): self.configs += 1; return {"zones": [], "thresholds": {}} def fetch_config(self): self.configs += 1; return {"zones": [], "thresholds": {}}
def send_heartbeat(self, m): self.beats += 1; return True def send_heartbeat(self, m): self.beats += 1; self.metrics.append(m); return True
class _Queue: class _Queue:
@ -43,6 +45,59 @@ def test_loop_survives_step_exception():
assert counters["errors"] == 1 assert counters["errors"] == 1
def test_loop_logs_swallowed_step_exception_rate_limited():
# A permanently-broken pipeline must not present as a silent green unit: the swallowed
# exception has to be logged. But it must be rate-limited — a camera failing every frame
# cannot spam the journal thousands of times a second.
logs = []
class AlwaysBoom(_Runtime):
camera_id = "cam-x"
def step(self, now):
self.steps += 1
raise RuntimeError("perma-broken")
rt = AlwaysBoom()
counters = run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0,
sleep=lambda s: None, iterations=5,
sync_every=99, heartbeat_every=99, flush_every=99,
log=lambda msg, exc=None: logs.append(msg))
assert counters["errors"] == 5 # every step raised
assert len(logs) == 1 # logged once, not five times (rate-limited)
assert "cam-x" in logs[0] # names the failing camera
def test_heartbeat_reports_real_fps_and_camera_health():
# A hardcoded fps=0.0 makes the cloud's device-health columns fiction; it can't tell a
# live device from a dead one. The heartbeat must carry the measured fps and per-camera
# health so a degraded device is visible remotely.
rt, cl, q = _Runtime(), _Client(), _Queue()
ticks = iter(float(t) for t in range(1000))
run_loop(rt, cl, q, clock=lambda: next(ticks), sleep=lambda s: None,
iterations=10, sync_every=99, heartbeat_every=10, flush_every=99)
assert len(cl.metrics) == 1
m = cl.metrics[0]
# 10 frames over the elapsed wall time between the first tick (t=0) and the beat (t=9).
assert m["fps"] == pytest.approx(10 / 9, rel=0.05)
assert m["cameras_total"] == 1
assert m["cameras_erroring"] == 0
def test_heartbeat_flags_a_failing_camera():
class AlwaysBoom(_Runtime):
camera_id = "cam-x"
def step(self, now):
self.steps += 1
raise RuntimeError("dead")
cl = _Client()
ticks = iter(float(t) for t in range(1000))
run_loop(AlwaysBoom(), cl, _Queue(), clock=lambda: next(ticks), sleep=lambda s: None,
iterations=10, sync_every=99, heartbeat_every=10, flush_every=99,
log=lambda *a, **k: None)
m = cl.metrics[0]
assert m["cameras_erroring"] == 1 # the cloud can now see the camera is down
def test_loop_survives_flush_exception(): def test_loop_survives_flush_exception():
class BoomQueue(_Queue): class BoomQueue(_Queue):
def flush(self): def flush(self):

View file

@ -7,6 +7,9 @@ import os
DEPLOY = os.path.join(os.path.dirname(__file__), "..", "deploy", "systemd") DEPLOY = os.path.join(os.path.dirname(__file__), "..", "deploy", "systemd")
ENV_EXAMPLE = os.path.join(DEPLOY, "move-detect-edge.env.example") ENV_EXAMPLE = os.path.join(DEPLOY, "move-detect-edge.env.example")
UNIT = os.path.join(DEPLOY, "move-detect-edge.service") UNIT = os.path.join(DEPLOY, "move-detect-edge.service")
SETUP_ONLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "setup_online.sh")
INSTALL_OFFLINE = os.path.join(os.path.dirname(__file__), "..", "deploy", "bundle",
"install_offline.sh")
# 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 = {
@ -47,11 +50,167 @@ def test_env_example_values_have_no_inline_comments():
assert not offenders, f"inline comments in env values break systemd: {offenders}" assert not offenders, f"inline comments in env values break systemd: {offenders}"
def _directives(text, section=None):
"""Active Key=value lines of a systemd unit, comments stripped. Restricted to one
[Section] when given. Comments must be excluded: the unit deliberately *documents*
directives it must not carry (e.g. ReadWritePaths=), and a naive substring check
matches the warning against re-adding them."""
out, cur = [], None
for line in text.splitlines():
s = line.strip()
if not s or s.startswith(("#", ";")): # systemd honors both as comment leaders
continue
if s.startswith("[") and s.endswith("]"):
cur = s[1:-1]
continue
if section is None or cur == section:
out.append(s)
return "\n".join(out)
def test_systemd_unit_wiring(): def test_systemd_unit_wiring():
# Every assertion binds to the [Section] that gives it meaning. A section-less check
# would pass on a directive moved into the wrong section, which is precisely the bug
# class that made StartLimitIntervalSec= a silent no-op for this unit.
with open(UNIT, encoding="utf-8") as f: with open(UNIT, encoding="utf-8") as f:
unit = f.read() raw = f.read()
assert "EnvironmentFile=/etc/move-detect/edge.env" in unit svc = _directives(raw, "Service")
assert "PYTHONPATH=/opt/move-detect/src" in unit assert "EnvironmentFile=/etc/move-detect/edge.env" in svc
assert "python -m edge" in unit # runs the package entrypoint assert "PYTHONPATH=/opt/move-detect/src" in svc
assert "Restart=always" in unit # unattended resilience assert "python -m edge" in svc # runs the package entrypoint
assert "WantedBy=multi-user.target" in unit assert "Restart=always" in svc # unattended resilience
assert "WantedBy=multi-user.target" in _directives(raw, "Install")
# Type=simple reports the start job done the instant fork() returns, so an exec-step
# failure (226/NAMESPACE, 216/GROUP, 217/USER, 203/EXEC) never reaches
# `systemctl enable --now` and setup_online.sh prints "Done." over a dead daemon.
assert "Type=exec" in svc
assert "Type=simple" not in svc
# StartLimitIntervalSec= is a [Unit] key. systemd 252's parser table has
# Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in
# [Service] it is an unknown key: silently ignored, leaving the default 5-starts/10s
# limit to defeat Restart=always.
assert "StartLimitIntervalSec=0" in _directives(raw, "Unit")
assert "StartLimit" not in svc
# ReadWritePaths=/var/lib/move-detect is dead config: StateDirectory= already emits a
# recursive read-write bind mount for that same path, which outranks a READWRITE entry
# and drops it before any path is resolved. Keep it out so it cannot be re-added as a
# "fix" for a state-dir problem it is incapable of causing or solving.
assert "StateDirectory=move-detect" in svc
assert "ReadWritePaths=" not in svc
def test_online_installer_guards_apt_and_surfaces_start_failures():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# [1/8] runs under `set -euo pipefail`. On a freshly-booted Pi, apt-daily and
# unattended-upgrades hold /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend;
# a bare apt-get then exits 100 with "Unable to lock directory /var/lib/apt/lists/"
# and kills the install. -o DPkg::Lock::Timeout does not cover the lists lock, so the
# wait has to live in the shell.
assert "apt_retry" in sh
for bare in ("\napt-get update", "\napt-get install"):
assert bare not in sh, "apt-get must go through apt_retry()"
# The lock-detection grep matches apt's C-locale strings, but apt gettext-translates its
# errors and sudo passes LANG/LC_* through. Without the locale pin, a ko_KR Pi misreads
# lock contention as a real error and fail-fasts — the exact bug apt_retry exists to fix.
assert "LC_ALL=C apt-get" in sh
# [8/8] must print WHY the daemon did not come up rather than dying under set -e on an
# opaque "Job for move-detect-edge.service failed". Assert the real invocation including
# flags: a bare "journalctl -xeu move-detect-edge" is also present in a human-facing echo
# hint, so the loose form passes even if the actual diagnostic call is deleted.
assert "journalctl -xeu move-detect-edge -n 50 --no-pager" in sh
assert "systemctl is-active --quiet move-detect-edge" in sh
# is-active is phase-dependent (a crash at T<=~0.2s is back in "active" by T+RestartSec);
# NRestarts is not, so it must stay as the AND alongside it.
assert "NRestarts" in sh
def test_onnxruntime_is_optional_but_a_pose_backend_is_mandatory():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# onnxruntime is imported inside MoveNetPoseAdapter.__init__ (edge/adapters/
# movenet_pose.py) and the Hailo path (edge/daemon.py) never imports the module at all —
# so it is the CPU-MoveNet-only dep. It publishes no sdist and no 32-bit-ARM wheel, so
# pip can legitimately find zero candidates and exit non-zero. On the same hard-fail line
# as requests/opencv under `set -e`, that killed the whole install on a Hailo-8 Pi that
# never needed it.
assert "pip\" install requests onnxruntime opencv-python-headless" not in sh, \
"onnxruntime must not share a hard-fail pip line with the mandatory deps"
# cv2 and requests stay mandatory: edge/snapshot.py imports cv2 at module level on the
# unconditional startup chain, and the daemon builds its cloud clients with no injected
# HTTP fn, so `import requests` fires at startup for every backend.
assert "install requests opencv-python-headless" in sh
# ...but the installer must not then hand over a Pi with NO working backend. build_pose()
# runs outside the daemon's try/except, so a missing backend is an uncaught traceback on
# every Restart=always cycle, forever. Fail at install time, naming the fix.
assert "no usable pose backend" in sh
# Probe capability, not intent: a HEF on disk does not mean HailoRT is installed, and a
# pip exit code does not mean the .so loads.
assert "import hailo_platform" in sh
assert "import onnxruntime" in sh
def test_installer_can_obtain_a_hef_without_the_operator_supplying_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.
assert "does not exist" in sh
# The failure message must 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():
with open(SETUP_ONLINE, encoding="utf-8") as f:
sh = f.read()
# Re-running IS the Hailo flow (install -> reboot for the driver -> re-run), so [6/8] can
# reach a different verdict than last time. Leaving edge.env wholly unchanged left run #1's
# POSE_BACKEND=movenet in place while [6/8] printed "hailo" — a crash loop the installer
# called a success.
assert "re-run reconciled" in sh
assert "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" in sh
# sed -i / >> can leave the file root:root; the unit reads it as movedetect via
# EnvironmentFile=, so the group must be restored or the start fails on permissions.
assert 'chown root:"$SVC_USER" "$ETC/edge.env"' in sh
def test_offline_installer_does_not_require_onnxruntime_to_verify_the_install():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# Same brittleness offline: a bundle built for a Hailo Pi need not carry onnxruntime, and
# this import check would fail the install for a package the daemon never loads.
assert "import edge.daemon, onnxruntime, cv2, numpy, requests" not in sh
def test_offline_installer_gates_the_pose_backend():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read()
# The offline installer must not hand over a Pi whose configured POSE_BACKEND cannot load
# (a Hailo-only bundle installed with the template's POSE_BACKEND=movenet -> crash loop).
# Probe capability and set the working backend, or hard-fail — like setup_online's [6/8].
assert "import hailo_platform" in sh
assert "import onnxruntime" in sh
assert "no usable pose backend" in sh
# It must WRITE the reconciled backend into the installed env, not leave the template's.
assert "POSE_BACKEND=$POSE_BACKEND" in sh or "POSE_BACKEND=${POSE_BACKEND}" in sh

View file

@ -1,11 +1,11 @@
from collections import deque from collections import deque
from edge.models import Keypoint, BBox from edge.models import Keypoint, BBox
from edge.tracking import Track, TrackFrame from edge.tracking import Track, TrackFrame
from edge.events import EventType from edge.events import EventType, Severity
from edge.detectors.fall import FallDetector, FallConfig from edge.detectors.fall import FallDetector, FallConfig
from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig
from edge.detectors.ghost import GhostIncidentDetector, GhostConfig from edge.detectors.ghost import GhostIncidentDetector, GhostConfig
from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig, Zone
from edge.engine import EventEngine, EngineConfig from edge.engine import EventEngine, EngineConfig
def _lying_kps(): def _lying_kps():
@ -85,6 +85,27 @@ def test_forget_prunes_state_on_lost():
assert 1 not in eng.fall._states assert 1 not in eng.fall._states
def test_pinch_zone_emits_pinch_event_not_warning():
# A 협착(pinch) zone is the highest-danger zone type (machinery entrapment). It must fire
# as PINCH/HIGH, not be silently downgraded to a generic ZONE_WARNING.
poly = [(100, 100), (200, 100), (200, 200), (100, 200)]
zdet = ZoneIntrusionDetector([Zone("z1", "pinch", poly)], ZoneConfig(debounce_s=0.0))
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)), # keep fall/no_motion quiet
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
zdet, EngineConfig(camera_id="cam-01", cooldown_s=30.0))
kps = [Keypoint(150, 150, 0.9) for _ in range(17)] # both ankles inside the zone
tr = Track(2, deque(maxlen=90))
tr.frames.append(TrackFrame(0.0, BBox(140, 140, 160, 160), kps))
events = eng.process([tr], [], now=0.0)
pinch = [e for e in events if e.event_type == EventType.PINCH]
assert len(pinch) == 1
assert pinch[0].severity == Severity.HIGH
assert pinch[0].zone_id == "z1"
assert not any(e.event_type == EventType.ZONE_WARNING for e in events)
def test_allow_cooldown_primitive(): def test_allow_cooldown_primitive():
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0) eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
key = ("FALL", 1) key = ("FALL", 1)

View file

@ -30,6 +30,32 @@ def test_standing_produces_no_event():
sig, _ = _feed(det, tr, _standing_kps(), BBox(40, 60, 60, 210), t=0.0) sig, _ = _feed(det, tr, _standing_kps(), BBox(40, 60, 60, 210), t=0.0)
assert sig is None assert sig is None
def test_fall_detected_from_bbox_when_keypoints_low_confidence():
# A fallen person is exactly when the pose model loses confidence (occluded, on the
# floor). The old blanket confidence gate returned None -> fall/no_motion/ghost all
# DISABLED for the person on the ground. The bbox cues (wide-and-short) don't need
# keypoints and must still fire.
det = FallDetector(FallConfig(prone_confirm_s=0.5, min_conf=0.5, min_visible=10))
tr = _track()
low_conf = [Keypoint(100, 100, 0.1) for _ in range(17)] # all below min_conf
wide_short = BBox(0, 0, 200, 100) # aspect 2.0 -> prone
_feed(det, tr, low_conf, wide_short, t=0.0) # FALLING
sig, _ = _feed(det, tr, low_conf, wide_short, t=0.6) # prone 0.6s > 0.5 -> FALLEN
assert sig == "fall"
def test_low_confidence_standing_is_not_a_false_fall():
# ...but a low-confidence STANDING person (tall, narrow bbox, no collapse) must NOT be
# flagged — the bbox cues alone stay conservative.
det = FallDetector(FallConfig(prone_confirm_s=0.5, min_conf=0.5, min_visible=10))
tr = _track()
low_conf = [Keypoint(50, 100, 0.1) for _ in range(17)]
tall_narrow = BBox(40, 60, 60, 210) # aspect ~0.13 -> not prone
_feed(det, tr, low_conf, tall_narrow, t=0.0)
sig, _ = _feed(det, tr, low_conf, tall_narrow, t=0.6)
assert sig is None
def test_lying_confirmed_after_prone_seconds_emits_fall(): def test_lying_confirmed_after_prone_seconds_emits_fall():
det = FallDetector(FallConfig(prone_confirm_s=1.0)) det = FallDetector(FallConfig(prone_confirm_s=1.0))
tr = _track() tr = _track()

View file

@ -0,0 +1,139 @@
"""Unit tests for edge/tools/fetch_hailo_hef.py — arch detection (both signals) and the
--arch / HAILO_ARCH override precedence.
No hardware, no network: `run` is a subprocess stand-in, the python-API fallback and
detect_arch()/fetch() are injected/monkeypatched, and HAILO_ARCH is patched per test. The
override path is what lets an install proceed on a Hailo-8 board that auto-detect cannot read
(the user's board reports no serial/identity), so it is the one that must not regress.
"""
import pytest
from edge.tools import fetch_hailo_hef as fh
class _Proc:
def __init__(self, stdout="", returncode=0):
self.stdout, self.returncode = stdout, returncode
def _run(stdout="", returncode=0):
return lambda cmd, **kw: _Proc(stdout, returncode)
# A real serial-less identify: the architecture line is present while every identity field is
# <N/A>. This is the proof in-repo — a missing serial is NOT why detection would fail.
SERIAL_LESS = (
"Board Name: Hailo-8\n"
"Device Architecture: HAILO8\n"
"Serial Number: <N/A>\n"
"Part Number: <N/A>\n"
"Product Name: <N/A>\n"
)
# ---- signal 1: hailortcli text --------------------------------------------------------
def test_detect_arch_reads_hailo8_even_when_serial_is_absent():
assert fh.detect_arch(run=_run(SERIAL_LESS), python_api=lambda: None) == "hailo8"
def test_detect_arch_hailo8l_wins_first_match_ordering():
# 'HAILO8L' contains 'HAILO8'; a 8-before-8L alternation would mis-read an AI Kit as a HAT+.
assert fh.detect_arch(run=_run("Device Architecture: HAILO8L\n"),
python_api=lambda: None) == "hailo8l"
def test_detect_arch_none_when_board_silent_and_no_bindings():
assert fh.detect_arch(run=_run("", returncode=1), python_api=lambda: None) is None
def test_detect_arch_none_when_hailortcli_missing_and_no_bindings():
def boom(cmd, **kw):
raise FileNotFoundError("hailortcli")
assert fh.detect_arch(run=boom, python_api=lambda: None) is None
# ---- signal 2: python-API fallback wiring ---------------------------------------------
def test_fallback_used_when_hailortcli_missing():
def boom(cmd, **kw):
raise FileNotFoundError("hailortcli")
assert fh.detect_arch(run=boom, python_api=lambda: "hailo8") == "hailo8"
def test_fallback_used_when_regex_misses_develop_firmware():
# 'HAILO8_B0' defeats the \b regex ('_' is a word char), so signal 1 -> None; the enum
# fallback resolves it. Documents exactly the fragility the 2nd signal exists to cover.
assert fh.detect_arch(run=_run("Device Architecture: HAILO8_B0\n"),
python_api=lambda: "hailo8") == "hailo8"
def test_fallback_not_consulted_when_hailortcli_answers():
def explode():
raise AssertionError("the python fallback must not run when signal 1 answered")
assert fh.detect_arch(run=_run("Device Architecture: HAILO8\n"),
python_api=explode) == "hailo8"
# ---- pure enum-name mapping (no hailo_platform needed) --------------------------------
@pytest.mark.parametrize("name,expected", [
("HAILO8", "hailo8"), ("HAILO8L", "hailo8l"),
("HAILO8_A0", "hailo8"), ("HAILO8_B0", "hailo8"),
("HAILO15H", None), ("HAILO10H", None), ("", None), (None, None),
])
def test_arch_from_enum_name(name, expected):
assert fh._arch_from_enum_name(name) == expected
# ---- main() override precedence: --arch > HAILO_ARCH > detected -----------------------
@pytest.fixture
def fetched(monkeypatch):
"""Capture what arch main() would download, without touching the network."""
calls = []
monkeypatch.setattr(fh, "fetch", lambda arch, dest, force=False: calls.append(arch))
return calls
def test_env_forces_when_detection_returns_none(monkeypatch, fetched, tmp_path):
# THE FIX: serial-less board, detect_arch -> None, operator sets HAILO_ARCH=hailo8.
monkeypatch.setattr(fh, "detect_arch", lambda: None)
monkeypatch.setenv("HAILO_ARCH", "hailo8")
fh.main(["--dest", str(tmp_path / "y.hef")])
assert fetched == ["hailo8"]
def test_flag_beats_env(monkeypatch, fetched, tmp_path):
monkeypatch.setattr(fh, "detect_arch", lambda: None)
monkeypatch.setenv("HAILO_ARCH", "hailo8")
fh.main(["--arch", "hailo8l", "--dest", str(tmp_path / "y.hef")])
assert fetched == ["hailo8l"]
def test_env_normalized_case_and_whitespace(monkeypatch, fetched, tmp_path):
monkeypatch.setattr(fh, "detect_arch", lambda: None)
monkeypatch.setenv("HAILO_ARCH", " HAILO8 ")
fh.main(["--dest", str(tmp_path / "y.hef")])
assert fetched == ["hailo8"]
def test_detection_used_when_no_override(monkeypatch, fetched, tmp_path):
monkeypatch.setattr(fh, "detect_arch", lambda: "hailo8l")
monkeypatch.delenv("HAILO_ARCH", raising=False)
fh.main(["--dest", str(tmp_path / "y.hef")])
assert fetched == ["hailo8l"]
def test_bad_env_is_a_clear_error_not_a_fallback(monkeypatch, fetched, tmp_path):
monkeypatch.setattr(fh, "detect_arch", lambda: "hailo8") # board present, but env is junk
monkeypatch.setenv("HAILO_ARCH", "hailo9000")
with pytest.raises(SystemExit) as e:
fh.main(["--dest", str(tmp_path / "y.hef")])
assert "HAILO_ARCH" in str(e.value)
assert fetched == [] # never downloads on a bad override
def test_no_arch_anywhere_refuses_to_guess(monkeypatch, fetched, tmp_path):
monkeypatch.setattr(fh, "detect_arch", lambda: None)
monkeypatch.delenv("HAILO_ARCH", raising=False)
with pytest.raises(SystemExit) as e:
fh.main(["--dest", str(tmp_path / "y.hef")])
assert "refusing to guess" in str(e.value)
assert fetched == []

View file

@ -46,11 +46,28 @@ def test_track_displacement_defaults_to_one_with_fewer_than_two_frames():
[Keypoint(0, 0, 0.9) for _ in range(17)])) [Keypoint(0, 0, 0.9) for _ in range(17)]))
assert track_displacement(tr) == 1.0 assert track_displacement(tr) == 1.0
def test_track_displacement_normalizes_mean_delta_by_bbox_height(): def test_track_displacement_normalizes_mean_delta_by_bbox_height_and_time():
tr = Track(1, deque(maxlen=90)) tr = Track(1, deque(maxlen=90))
tr.frames.append(TrackFrame(0.0, BBox(0, 0, 20, 60), tr.frames.append(TrackFrame(0.0, BBox(0, 0, 20, 60),
[Keypoint(0, 0, 0.9) for _ in range(17)])) [Keypoint(0, 0, 0.9) for _ in range(17)]))
tr.frames.append(TrackFrame(0.1, BBox(0, 0, 20, 60), tr.frames.append(TrackFrame(0.1, BBox(0, 0, 20, 60),
[Keypoint(6, 8, 0.9) for _ in range(17)])) [Keypoint(6, 8, 0.9) for _ in range(17)]))
# per-point delta = sqrt(6^2+8^2) = 10; bbox height = 60 -> 10/60 # per-point delta = sqrt(6^2+8^2) = 10; /bbox height 60; /dt 0.1 -> per-second, per-height
assert math.isclose(track_displacement(tr), 10 / 60) assert math.isclose(track_displacement(tr), (10 / 60) / 0.1)
def test_displacement_is_fps_invariant():
# The stillness measure must reflect real SPEED, not per-frame delta: the same physical
# motion sampled at 10fps vs 1fps must read the same, or immobility/no_motion flips with
# the daemon's actual frame rate (which varies with Hailo/CPU load and camera count).
def moving_track(dt, speed_px_s):
move = speed_px_s * dt
tr = Track(1, deque(maxlen=90))
tr.frames.append(TrackFrame(0.0, BBox(0, 0, 10, 100),
[Keypoint(0, 0, 0.9) for _ in range(17)]))
tr.frames.append(TrackFrame(dt, BBox(0, 0, 10, 100),
[Keypoint(0, move, 0.9) for _ in range(17)]))
return tr
slow = track_displacement(moving_track(1.0, 5.0)) # sampled once per second
fast = track_displacement(moving_track(0.1, 5.0)) # sampled ten times per second
assert math.isclose(slow, fast)

View file

@ -28,6 +28,21 @@ def test_reappearance_near_location_cancels_ghost():
d = GhostIncidentDetector(GhostConfig(reappear_s=1.0, reappear_dist=2.0)) d = GhostIncidentDetector(GhostConfig(reappear_s=1.0, reappear_dist=2.0))
d.note_state(1, "FALLEN") d.note_state(1, "FALLEN")
d.on_lost([_track(1, 100, 100, 0.0)], now=0.0) d.on_lost([_track(1, 100, 100, 0.0)], now=0.0)
# 인근에 새 트랙 재출현 # 인근에 새 트랙(손실 시점엔 없던 id) 재출현 -> 같은 사람이 다시 잡힌 것 -> 취소
assert d.update([_track(2, 105, 105, 0.5)], now=0.5) == [] assert d.update([_track(2, 105, 105, 0.5)], now=0.5) == []
assert d.update([], now=2.0) == [] # 취소되어 방출 없음 assert d.update([], now=2.0) == [] # 취소되어 방출 없음
def test_bystander_present_at_loss_does_not_cancel_ghost():
# A colleague already being tracked when the victim was lost must NOT cancel the incident
# by walking over the loss point — otherwise a worker dragged/collapsed under a machine,
# with a co-worker nearby, is never reported. Only a track that did NOT exist at loss time
# (a genuine re-appearance of the victim) may cancel.
d = GhostIncidentDetector(GhostConfig(reappear_s=1.0, reappear_dist=2.0))
d.note_state(1, "FALLEN")
d.note_state(2, "STANDING") # colleague id 2 is active at loss time
d.on_lost([_track(1, 100, 100, 0.0)], now=0.0)
assert d.update([_track(2, 105, 105, 0.5)], now=0.5) == [] # bystander near, not cancelled
ghosts = d.update([_track(2, 105, 105, 1.5)], now=1.5)
assert len(ghosts) == 1 # incident still fires
assert ghosts[0]["track_id"] == 1

View file

@ -5,6 +5,9 @@ by injecting a fake infer_fn that returns synthetic raw output tensors.
yolov8s_pose emits 9 raw tensors (no on-chip NMS): per stride (8/16/32) a box(H,W,64 yolov8s_pose emits 9 raw tensors (no on-chip NMS): per stride (8/16/32) a box(H,W,64
DFL), score(H,W,1 already-sigmoid), and kpts(H,W,51 = 17*(x,y,score)). DFL), score(H,W,1 already-sigmoid), and kpts(H,W,51 = 17*(x,y,score)).
""" """
import inspect
import re
import numpy as np import numpy as np
from edge.adapters.hailo_pose import HailoPoseAdapter from edge.adapters.hailo_pose import HailoPoseAdapter
@ -62,3 +65,28 @@ def test_infer_maps_yolov8_pose_to_coco17_person():
def test_no_detection_returns_empty(): def test_no_detection_returns_empty():
a = HailoPoseAdapter("unused.hef", infer_fn=lambda f: _blank_outputs()) a = HailoPoseAdapter("unused.hef", infer_fn=lambda f: _blank_outputs())
assert a.infer(np.zeros((640, 640, 3), np.uint8)) == [] assert a.infer(np.zeros((640, 640, 3), np.uint8)) == []
def test_hailort_run_is_called_positionally_not_with_timeout_ms():
"""HailoRT's ConfiguredInferModel.run() takes `timeout`, not `timeout_ms`.
Verified verbatim against upstream pyhailort.py at v4.19.0, v4.20.0, v4.23.0 and master —
all four are `def run(self, bindings, timeout):`. (The neighbouring
`wait_for_async_ready(self, timeout_ms=1000, ...)` DOES use `timeout_ms`, which is the
trap.) So `run(..., timeout_ms=...)` is a TypeError on EVERY frame.
That failure is invisible without this test: `_build_hailo_infer` is device-only
(`# pragma: no cover`), so nothing executes it here, and on the Pi `run_loop` catches
Exception per frame and only bumps an error counter — the unit stays green and emits
zero events, forever. Hence a source-level assertion rather than a behavioural one.
"""
src = inspect.getsource(HailoPoseAdapter._build_hailo_infer)
# Inspect the CALL, not the whole source: the code comment beside it necessarily spells
# out the wrong form, and a naive `"timeout_ms=" not in src` matches that comment.
call = re.search(r"configured\.run\((.*?)\)\n", src)
assert call, "configured.run(...) not found in _build_hailo_infer"
args = call.group(1).strip()
assert "timeout_ms" not in args, \
f"ConfiguredInferModel.run() has no timeout_ms kwarg; pass it positionally. Got: run({args})"
assert re.fullmatch(r"\[bindings\],\s*\d+", args), \
f"expected run([bindings], <ms int>) — positional timeout. Got: run({args})"

View file

@ -34,3 +34,40 @@ def test_flush_resends_when_recovered(tmp_path):
assert q.flush() == 2 assert q.flush() == 2
assert q.pending() == 0 assert q.pending() == 0
assert client.sent == [1, 2] assert client.sent == [1, 2]
class _PoisonClient:
"""Rejects one specific event forever (e.g. a 400 the cloud will never accept)."""
def __init__(self, poison_id):
self.poison = poison_id
self.sent = []
def send(self, event, snapshots):
if event.track_id == self.poison:
return False
self.sent.append(event.track_id)
return True
def test_queue_is_bounded_and_keeps_newest(tmp_path):
# An extended outage (or a permanent misconfig) must not fill the SD card. The queue caps
# itself, evicting the OLDEST — a fresh fall alert matters more than a stale one.
q = OfflineQueue(str(tmp_path / "q.db"), _Client(fail=True), max_rows=5)
for n in range(20):
q.try_send(_event(n), [b"x"])
assert q.pending() == 5
q.client.fail = False
q.flush()
assert q.client.sent == [15, 16, 17, 18, 19] # the newest 5 survived
def test_poison_head_is_dropped_and_unblocks_the_queue(tmp_path):
# A permanently-rejected event at the head must not wedge the whole queue forever; after
# max_send_attempts it is dropped so the events behind it can drain.
client = _PoisonClient(99)
q = OfflineQueue(str(tmp_path / "q.db"), client, max_send_attempts=3)
q.enqueue(_event(99), [b"x"]) # poison, at the head
q.enqueue(_event(2), [b"y"]) # good, behind it
for _ in range(3):
q.flush()
assert client.sent == [2] # good event delivered
assert q.pending() == 0 # poison dropped, queue drained

74
tests/test_uvc_source.py Normal file
View file

@ -0,0 +1,74 @@
"""UvcSource must fail LOUD when a camera won't open, and try to REOPEN a camera that goes
dead mid-run — a USB cam on a 24/7 Pi browns out / gets unplugged, and today read() would
return (False, None) forever while the daemon looks perfectly healthy.
No hardware: a fake capture is injected via the `_open` factory."""
import pytest
from edge.adapters.uvc_source import UvcSource
class _FakeCap:
def __init__(self, frames, opened=True):
# frames: list of (ok, frame) tuples served in order; exhausted -> (False, None)
self._frames = list(frames)
self._opened = opened
self.released = 0
self.sets = 0
def isOpened(self):
return self._opened
def read(self):
return self._frames.pop(0) if self._frames else (False, None)
def set(self, *a):
self.sets += 1
def release(self):
self.released += 1
def test_open_failure_raises_loudly():
# A device that never opens must raise at construction so build_runtimes logs+skips it,
# not silently construct a dead capture.
with pytest.raises(RuntimeError, match="failed to open"):
UvcSource(0, mjpeg=False, _open=lambda dev: _FakeCap([], opened=False))
def test_read_proxies_a_live_frame():
cap = _FakeCap([(True, "frameA")])
src = UvcSource(0, mjpeg=False, _open=lambda dev: cap)
assert src.read() == (True, "frameA")
def test_dead_camera_is_reopened_after_a_threshold():
opens = []
def opener(dev):
cap = _FakeCap([], opened=True) # opens fine but delivers no frames
opens.append(cap)
return cap
src = UvcSource(0, mjpeg=False, reopen_after=3, _open=opener)
assert len(opens) == 1
for _ in range(3): # 3 consecutive dead reads -> exactly one reopen
assert src.read() == (False, None)
assert len(opens) == 2, "camera should be reopened after reopen_after failures"
assert opens[0].released == 1, "the dead capture should be released before reopening"
def test_recovery_resets_the_failure_streak():
# F, F, live, F, F with reopen_after=3 never has 3-in-a-row, so no reopen must fire.
cap = _FakeCap([(False, None), (False, None), (True, "ok"), (False, None), (False, None)])
opens = {"n": 0}
def opener(dev):
opens["n"] += 1
return cap
src = UvcSource(0, mjpeg=False, reopen_after=3, _open=opener)
assert opens["n"] == 1 # initial open only
for _ in range(5):
src.read()
assert opens["n"] == 1, "a recovered read must reset the streak so no reopen fires"

View file

@ -31,3 +31,18 @@ def test_leaving_zone_resets():
det.update(_track_with_ankles(200, 200, 0.6), now=0.6) # 이탈 det.update(_track_with_ankles(200, 200, 0.6), now=0.6) # 이탈
out = det.update(_track_with_ankles(50, 50, 1.0), now=1.0) # 재진입, 디바운스 재시작 out = det.update(_track_with_ankles(50, 50, 1.0), now=1.0) # 재진입, 디바운스 재시작
assert out == [] assert out == []
def test_zone_uses_normalized_coordinates_when_frame_size_given():
# The dashboard stores zones as 0..1 of the image (it stretches the snapshot to a fixed
# 960x540 canvas). The edge foot is camera-native pixels. Normalizing the foot by the
# frame size makes the two comparable regardless of camera resolution — without this,
# every zone is silently misplaced.
zn = Zone("z1", "restricted", [(0.4, 0.4), (0.6, 0.4), (0.6, 0.6), (0.4, 0.6)])
det = ZoneIntrusionDetector([zn], ZoneConfig(debounce_s=0.0))
# foot at the centre of a 1280x720 frame -> (0.5, 0.5) -> inside the 0.4..0.6 zone
inside = det.update(_track_with_ankles(640, 360, 0.0), now=0.0, frame_wh=(1280, 720))
assert inside == [{"zone_id": "z1", "level": "restricted"}]
# foot near the corner -> ~(0.008, 0.014) -> outside
det2 = ZoneIntrusionDetector([zn], ZoneConfig(debounce_s=0.0))
assert det2.update(_track_with_ankles(10, 10, 0.0), now=0.0, frame_wh=(1280, 720)) == []