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:
parent
34dcf557e9
commit
9386dfc580
59 changed files with 2781 additions and 157 deletions
10
.gitattributes
vendored
10
.gitattributes
vendored
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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
1
cloud/.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
||||||
/config.php
|
/config.php
|
||||||
/storage/snapshots/*
|
/storage/snapshots/*
|
||||||
!/storage/snapshots/.gitkeep
|
!/storage/snapshots/.gitkeep
|
||||||
|
/.phpunit.result.cache
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
152
cloud/deploy/README-UPLOAD.md
Normal file
152
cloud/deploy/README-UPLOAD.md
Normal 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` 가 안 열린다 (열리면 위치가 잘못된 것)
|
||||||
239
cloud/deploy/build_cafe24_package.sh
Executable file
239
cloud/deploy/build_cafe24_package.sh
Executable 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."
|
||||||
30
cloud/deploy/config.template.php
Normal file
30
cloud/deploy/config.template.php
Normal 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
64
cloud/deploy/htaccess.www
Normal 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
160
cloud/deploy/preflight.php
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'; });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
* '/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_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(); }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
0
cloud/storage/snapshots/.gitkeep
Normal file
0
cloud/storage/snapshots/.gitkeep
Normal file
85
cloud/tests/BasePathTest.php
Normal file
85
cloud/tests/BasePathTest.php
Normal 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
0
deploy/bundle/build_offline_bundle.sh
Normal file → Executable file
57
deploy/bundle/install_offline.sh
Normal file → Executable file
57
deploy/bundle/install_offline.sh
Normal file → Executable 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
|
||||||
|
|
|
||||||
216
deploy/setup_online.sh
Normal file → Executable file
216
deploy/setup_online.sh
Normal file → Executable 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
|
||||||
|
# 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"
|
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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
190
docs/delivery-audit.md
Normal 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.
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"]))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
261
edge/tools/fetch_hailo_hef.py
Normal file
261
edge/tools/fetch_hailo_hef.py
Normal 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()
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
180
tests/test_cafe24_package.py
Normal file
180
tests/test_cafe24_package.py
Normal 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}"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
139
tests/test_fetch_hailo_hef.py
Normal file
139
tests/test_fetch_hailo_hef.py
Normal 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 == []
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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
74
tests/test_uvc_source.py
Normal 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"
|
||||||
|
|
@ -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)) == []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue