Compare commits

..

No commits in common. "9ed759befcf61566238c94694c571121a9ee9e48" and "2ea0420de7e8e1a0a9148a40b52fee2ca6981a2e" have entirely different histories.

128 changed files with 952 additions and 2689 deletions

3
.gitignore vendored
View file

@ -30,6 +30,3 @@ Thumbs.db
# Tool caches # Tool caches
.impeccable/ .impeccable/
# Pi provisioning (real device secret) — never commit
deploy/device.env

View file

@ -1,5 +1,5 @@
{ {
"name": "move-detect/server", "name": "move-detect/cloud",
"type": "project", "type": "project",
"require": { "php": ">=8.2" }, "require": { "php": ">=8.2" },
"require-dev": { "phpunit/phpunit": "^11" }, "require-dev": { "phpunit/phpunit": "^11" },

View file

@ -129,7 +129,7 @@ php -r "echo password_hash('여기에_진짜_비밀번호', PASSWORD_ARGON2ID),
phpMyAdmin → SQL 탭: phpMyAdmin → SQL 탭:
```sql ```sql
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until) INSERT INTO users (username, password_hash, role, site_scope, failed_attempts, locked_until)
VALUES ('admin', '여기에_해시_붙여넣기', 'admin', NULL, 0, 0); VALUES ('admin', '여기에_해시_붙여넣기', 'admin', NULL, 0, 0);
``` ```
@ -144,7 +144,7 @@ php -r "echo bin2hex(random_bytes(32)), \"\n\";"
``` ```
```sql ```sql
INSERT INTO move_devices (device_id, name, secret, config_version) INSERT INTO devices (device_id, name, secret, config_version)
VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기', 1); VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기', 1);
``` ```

View file

@ -5,15 +5,15 @@
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a # 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 # 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 # operator uploads files over FTP and edits config.php. That is also why this lives under
# server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as # cloud/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
# root on the target. # root on the target.
# #
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host): # Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
# server/deploy/build_cafe24_package.sh # cloud/deploy/build_cafe24_package.sh
# #
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip # Output: dist/cafe24/ and dist/move-detect-cafe24.zip
# #
# dist/1_cafe24_upload/ # dist/cafe24/
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable. # ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
# │ ├── config.php the ONLY file the operator edits # │ ├── config.php the ONLY file the operator edits
# │ ├── src/ vendor/ storage/snapshots/ # │ ├── src/ vendor/ storage/snapshots/
@ -42,9 +42,9 @@ APP_DIR="${APP_DIR-move_detect}"
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}" PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SERVER="$REPO_ROOT/server" CLOUD="$REPO_ROOT/cloud"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}" OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/1_cafe24_upload" STAGE="$OUT_DIR/cafe24"
ZIP="$OUT_DIR/move-detect-cafe24.zip" 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 composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
@ -75,15 +75,15 @@ echo ">> [2/6] generating the no-dev autoloader"
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's # would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
# `new PhpSessionStore()` — the 4th statement of every request. # `new PhpSessionStore()` — the 4th statement of every request.
# #
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/ # --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. # in place, so restore the dev autoloader on exit or local phpunit breaks.
restore_dev_autoloader() { restore_dev_autoloader() {
echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)" echo ">> restoring the dev autoloader in cloud/vendor (so local phpunit keeps working)"
( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true ( cd "$CLOUD" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
} }
trap restore_dev_autoloader EXIT trap restore_dev_autoloader EXIT
( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction ) ( cd "$CLOUD" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
# Precondition for everything below: this package stages ONLY composer's generated # 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 # autoloader, which is correct exactly while the app has no runtime packages. The day
@ -91,7 +91,7 @@ trap restore_dev_autoloader EXIT
# the host fatals on a missing class. Fail here, on the commit that adds the dependency, # the host fatals on a missing class. Fail here, on the commit that adds the dependency,
# rather than on the hosting account. # rather than on the hosting account.
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \ php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
"$SERVER/composer.lock" || { "$CLOUD/composer.lock" || {
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2 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 " 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 echo " step [3/6] to stage the real vendor tree before shipping again." >&2
@ -100,14 +100,14 @@ php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ?
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)" echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
mkdir -p "$PRIV/vendor/composer" mkdir -p "$PRIV/vendor/composer"
cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php" 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 # Copy only composer's own generated files, by name. A glob over vendor/* would drag in
# phpunit/, sebastian/, myclabs/ and friends. # phpunit/, sebastian/, myclabs/ and friends.
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \ 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 autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f" cp "$CLOUD/vendor/composer/$f" "$PRIV/vendor/composer/$f"
done done
cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true cp "$CLOUD/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not # 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`, # regenerate them — they are the package inventory written by the last `composer install`,
@ -141,18 +141,18 @@ if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -
fi fi
echo ">> [4/6] staging app source" echo ">> [4/6] staging app source"
cp -r "$SERVER/src/." "$PRIV/src/" cp -r "$CLOUD/src/." "$PRIV/src/"
cp -r "$SERVER/public/." "$WEB/" cp -r "$CLOUD/public/." "$WEB/"
cp "$SERVER/schema.sql" "$PRIV/schema.sql" cp "$CLOUD/schema.sql" "$PRIV/schema.sql"
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one. # The hardened DocumentRoot .htaccess replaces cloud/public/'s dev-local rewrite-only one.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess" cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess"
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php" cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php"
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php" cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On). # .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini" cp "$CLOUD/deploy/user.ini" "$WEB/.user.ini"
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md" cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches # `cp -r` above copies whatever is in cloud/src|public — defensively strip dev/tooling caches
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served # so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed. # package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \ find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \
@ -223,36 +223,15 @@ echo " -> every staged .php parses"
echo ">> [6/6] zipping" echo ">> [6/6] zipping"
rm -f "$ZIP" rm -f "$ZIP"
if [ "${MAKE_ZIP:-0}" != "1" ]; then if command -v zip >/dev/null 2>&1; then
# Default: leave the folder uncompressed. The operator uploads $STAGE/ over FTP as-is,
# which is also the only path that cannot mangle directory separators (see below).
echo " -> skipped (MAKE_ZIP=1 to also produce $ZIP)"
elif command -v zip >/dev/null 2>&1; then
( cd "$STAGE" && zip -rq "$ZIP" . ) ( cd "$STAGE" && zip -rq "$ZIP" . )
elif command -v python3 >/dev/null 2>&1; then
# No `zip` (common on Windows/Git Bash). Use Python's zipfile with POSIX (forward-slash)
# arcnames. PowerShell's Compress-Archive writes BACKSLASH path separators, which Cafe24's
# extractor and some unzip tools do NOT normalise — the app tree then unpacks as flat files
# (move_detect_app\src\... as one filename) and the deploy silently breaks. Prefer python.
( cd "$STAGE" && python3 - "$ZIP" <<'PY'
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
for root, _dirs, files in os.walk("."):
for name in files:
full = os.path.join(root, name)
arc = os.path.relpath(full, ".").replace(os.sep, "/") # force forward slashes
z.write(full, arc)
PY
)
elif command -v powershell >/dev/null 2>&1; then elif command -v powershell >/dev/null 2>&1; then
# Last resort. Compress-Archive uses backslash separators (see above) — the .htaccess/README # No zip on the box (common on Windows/Git Bash) — PowerShell ships Compress-Archive.
# note the operator to extract with a slash-aware tool, or prefer the python path.
win_stage="$(cygpath -w "$STAGE" 2>/dev/null || echo "$STAGE")" win_stage="$(cygpath -w "$STAGE" 2>/dev/null || echo "$STAGE")"
win_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")" win_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")"
powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force" powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force"
echo " WARN: archived via Compress-Archive (backslash paths). Verify the host unpacks the tree correctly."
else else
echo " WARN: no zip/python3/powershell — skipping the archive; upload $STAGE/ directly." echo " WARN: no zip/powershell — skipping the archive; upload $STAGE/ directly."
fi fi
echo "" echo ""

View file

@ -1,13 +1,13 @@
# Cafe24 Server Deploy Guide (P1-B) # Cafe24 Cloud Deploy Guide (P1-B)
This document covers deploying the `server/` PHP application (ingest API + dashboard) to a This document covers deploying the `cloud/` PHP application (ingest API + dashboard) to a
Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer
dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with
prepared statements — there is nothing to `composer install` on the production host. prepared statements — there is nothing to `composer install` on the production host.
## 1. Directory layout on the server ## 1. Directory layout on the server
Only `server/public/` may be reachable from the web. Everything else must sit **outside** Only `cloud/public/` may be reachable from the web. Everything else must sit **outside**
the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is), the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is),
or be blocked by hosting-level access rules if your plan cannot place files outside the or be blocked by hosting-level access rules if your plan cannot place files outside the
web root at all. web root at all.
@ -27,7 +27,7 @@ web root at all.
└── assets/ sit at the FTP root — see below. └── assets/ sit at the FTP root — see below.
``` ```
`server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from `cloud/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a 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 subdirectory of one — in the latter case the app root is two levels up, not one, because
@ -39,7 +39,7 @@ views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without t
declared as `/login` would 404 under `https://host/move_detect/login` and every asset would declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
resolve against the co-hosted site. resolve against the co-hosted site.
`server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=` `cloud/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
(empty) to mount the app directly in `www/`. (empty) to mount the app directly in `www/`.
**`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional **`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
@ -49,7 +49,7 @@ every request, including §10's own smoke check. There are no runtime Composer *
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine: autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
```bash ```bash
cd server && composer dump-autoload --no-dev --classmap-authoritative --no-interaction 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 That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy
@ -63,7 +63,7 @@ deliberately excludes.
them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain 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`. PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`.
`server/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the `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. 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 If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is
@ -81,7 +81,7 @@ web-readable.
Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over
SSH if available) — do not assume, since PHP builds vary by hosting plan: SSH if available) — do not assume, since PHP builds vary by hosting plan:
- [ ] **PHP version** matches `server/composer.json`'s `"php": ">=8.2"` (the app is written - [ ] **PHP version** matches `cloud/composer.json`'s `"php": ">=8.2"` (the app is written
and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact
version Cafe24 provisions for your plan). version Cafe24 provisions for your plan).
- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include - [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include
@ -112,7 +112,7 @@ SSH if available) — do not assume, since PHP builds vary by hosting plan:
## 3. `config.php` ## 3. `config.php`
Copy the example and fill in real values — **never commit `config.php`** (already covered Copy the example and fill in real values — **never commit `config.php`** (already covered
by `server/.gitignore`): by `cloud/.gitignore`):
```bash ```bash
cp config.example.php config.php cp config.example.php config.php
@ -139,7 +139,7 @@ or version control.
## 4. Import the schema ## 4. Import the schema
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`, `cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`). `events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this 1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
@ -151,19 +151,12 @@ or version control.
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
attack surface for no benefit. attack surface for no benefit.
**Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a (on `role`, `event_type`, `severity`, `level`) will be parsed but silently not enforced —
hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an harmless, since the application already whitelists these values before every insert
older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1
blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel
(or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either
request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied
timestamp (the code already passes one on every insert).
Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) are parsed but silently not enforced — harmless,
since the application already whitelists these values before every insert
(`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`). (`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`).
Everything else in the schema (tables, keys, indexes, `JSON` columns) requires only
MySQL 5.7+.
## 5. Storage directory ## 5. Storage directory
@ -195,14 +188,14 @@ php -r "echo bin2hex(random_bytes(32)), \"\n\";"
``` ```
```sql ```sql
INSERT INTO move_devices (device_id, name, secret, config_version) INSERT INTO devices (device_id, name, secret, config_version)
VALUES ('pi-01', 'Line 3 — Fall Cam', '<paste the generated secret here>', 1); VALUES ('pi-01', 'Line 3 — Fall Cam', '<paste the generated secret here>', 1);
``` ```
Deliver the secret to the device provisioning process out-of-band (not email/chat) and Deliver the secret to the device provisioning process out-of-band (not email/chat) and
configure it as the device's `HMAC` signing key for `X-Signature` on configure it as the device's `HMAC` signing key for `X-Signature` on
`/api/events`, `/api/heartbeat`, and `/api/config`. Rotate a compromised secret by `/api/events`, `/api/heartbeat`, and `/api/config`. Rotate a compromised secret by
`UPDATE move_devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the `UPDATE devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the
old value stops working immediately since `secretFor()` always reads the current row. old value stops working immediately since `secretFor()` always reads the current row.
## 7. First admin account ## 7. First admin account
@ -222,7 +215,7 @@ php -r "echo password_hash('CHANGE-ME-to-a-real-passphrase', PASSWORD_ARGON2ID),
`PASSWORD_ARGON2ID`.) `PASSWORD_ARGON2ID`.)
```sql ```sql
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until) INSERT INTO users (username, password_hash, role, site_scope, failed_attempts, locked_until)
VALUES ('admin', '<paste the argon2id hash here>', 'admin', NULL, 0, 0); VALUES ('admin', '<paste the argon2id hash here>', 'admin', NULL, 0, 0);
``` ```

View file

@ -21,27 +21,27 @@
-- =========================================================================== -- ===========================================================================
-- A1) devices.site — the access-control site each device belongs to. -- A1) devices.site — the access-control site each device belongs to.
ALTER TABLE move_devices ALTER TABLE devices
ADD COLUMN site VARCHAR(64) NULL AFTER name, ADD COLUMN site VARCHAR(64) NULL AFTER name,
ADD KEY ix_devices_site (site); ADD KEY ix_devices_site (site);
-- A2) events.site — denormalized from move_devices.site at ingest, for the dashboard's -- A2) events.site — denormalized from devices.site at ingest, for the dashboard's
-- site-scoped event list. -- site-scoped event list.
ALTER TABLE move_events ALTER TABLE events
ADD COLUMN site VARCHAR(64) NULL AFTER device_id, ADD COLUMN site VARCHAR(64) NULL AFTER device_id,
ADD KEY ix_events_site_occurred_at (site, occurred_at); ADD KEY ix_events_site_occurred_at (site, occurred_at);
-- A3) events.event_uuid — edge-generated idempotency key (P1-9). The UNIQUE key -- A3) events.event_uuid — edge-generated idempotency key (P1-9). The UNIQUE key
-- makes a re-sent event collide (dedup) instead of duplicating. MySQL allows -- makes a re-sent event collide (dedup) instead of duplicating. MySQL allows
-- multiple NULLs in a UNIQUE index, so legacy rows without a uuid are fine. -- multiple NULLs in a UNIQUE index, so legacy rows without a uuid are fine.
ALTER TABLE move_events ALTER TABLE events
ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id, ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id,
ADD UNIQUE KEY uq_events_uuid (event_uuid); ADD UNIQUE KEY uq_events_uuid (event_uuid);
-- A4) event_snapshots.created_at index — PdoSnapshotRepository::purgeOlderThan() -- A4) event_snapshots.created_at index — PdoSnapshotRepository::purgeOlderThan()
-- runs "SELECT ... WHERE created_at < ?" inside live ingest requests. Without -- runs "SELECT ... WHERE created_at < ?" inside live ingest requests. Without
-- this index it full-scans the snapshot table on every triggered purge. -- this index it full-scans the snapshot table on every triggered purge.
ALTER TABLE move_event_snapshots ALTER TABLE event_snapshots
ADD KEY ix_event_snapshots_created (created_at); ADD KEY ix_event_snapshots_created (created_at);
-- A5) event_snapshots (event_id, content_hash) UNIQUE — lets the ingest dedup path -- A5) event_snapshots (event_id, content_hash) UNIQUE — lets the ingest dedup path
@ -50,21 +50,21 @@ ALTER TABLE move_event_snapshots
-- collides instead of writing a second copy). If this fails on "Duplicate -- collides instead of writing a second copy). If this fails on "Duplicate
-- entry", the table already has duplicate (event_id, content_hash) rows — -- entry", the table already has duplicate (event_id, content_hash) rows —
-- remove the dups, then re-run this one statement. -- remove the dups, then re-run this one statement.
ALTER TABLE move_event_snapshots ALTER TABLE event_snapshots
ADD UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash); ADD UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash);
-- A6) rate_limits.window_start index — the reap "DELETE ... WHERE window_start < ?" -- A6) rate_limits.window_start index — the reap "DELETE ... WHERE window_start < ?"
-- cannot use the (rate_key, window_start) PRIMARY KEY (window_start is the -- cannot use the (rate_key, window_start) PRIMARY KEY (window_start is the
-- trailing column), so it full-scans and locks rows on the pre-auth hot path. -- trailing column), so it full-scans and locks rows on the pre-auth hot path.
ALTER TABLE move_rate_limits ALTER TABLE rate_limits
ADD KEY ix_rate_limits_window (window_start); ADD KEY ix_rate_limits_window (window_start);
-- =========================================================================== -- ===========================================================================
-- PART B — provisioning (EDIT the values for your deployment, then run). -- PART B — provisioning (EDIT the values for your deployment, then run).
-- Assign every device to its site here; the backfill in Part C reads it. -- Assign every device to its site here; the backfill in Part C reads it.
-- =========================================================================== -- ===========================================================================
-- UPDATE move_devices SET site = 'factory-a' WHERE device_id = 'pi-1'; -- UPDATE devices SET site = 'factory-a' WHERE device_id = 'pi-1';
-- UPDATE move_devices SET site = 'factory-b' WHERE device_id = 'pi-2'; -- UPDATE devices SET site = 'factory-b' WHERE device_id = 'pi-2';
-- -- ... one row per device ... -- -- ... one row per device ...
-- =========================================================================== -- ===========================================================================
@ -72,8 +72,8 @@ ALTER TABLE move_rate_limits
-- RUN THIS ONLY AFTER Part B. Re-run it whenever you add/relabel a -- RUN THIS ONLY AFTER Part B. Re-run it whenever you add/relabel a
-- device's site so its past events reappear for scoped operators. -- device's site so its past events reappear for scoped operators.
-- =========================================================================== -- ===========================================================================
UPDATE move_events e UPDATE events e
JOIN move_devices d ON d.device_id = e.device_id JOIN devices d ON d.device_id = e.device_id
SET e.site = d.site SET e.site = d.site
WHERE e.site IS NULL AND d.site IS NOT NULL; WHERE e.site IS NULL AND d.site IS NOT NULL;
@ -82,17 +82,17 @@ UPDATE move_events e
-- see their backfilled history). NULL = global operator; '' is NOT -- see their backfilled history). NULL = global operator; '' is NOT
-- global (matches nothing — an accidental blank fails closed). -- global (matches nothing — an accidental blank fails closed).
-- =========================================================================== -- ===========================================================================
-- UPDATE move_users SET site_scope = 'factory-a' WHERE username = 'operator-a'; -- UPDATE users SET site_scope = 'factory-a' WHERE username = 'operator-a';
-- ---- Verification (run these; each must confirm the object/state) ----------- -- ---- Verification (run these; each must confirm the object/state) -----------
-- SHOW COLUMNS FROM move_devices LIKE 'site'; -- SHOW COLUMNS FROM devices LIKE 'site';
-- SHOW COLUMNS FROM move_events LIKE 'site'; -- SHOW COLUMNS FROM events LIKE 'site';
-- SHOW COLUMNS FROM move_events LIKE 'event_uuid'; -- SHOW COLUMNS FROM events LIKE 'event_uuid';
-- SHOW INDEX FROM move_events WHERE Key_name = 'uq_events_uuid'; -- SHOW INDEX FROM events WHERE Key_name = 'uq_events_uuid';
-- SHOW INDEX FROM move_event_snapshots WHERE Key_name = 'ix_event_snapshots_created'; -- SHOW INDEX FROM event_snapshots WHERE Key_name = 'ix_event_snapshots_created';
-- SHOW INDEX FROM move_event_snapshots WHERE Key_name = 'uq_event_snapshots_event_hash'; -- SHOW INDEX FROM event_snapshots WHERE Key_name = 'uq_event_snapshots_event_hash';
-- SHOW INDEX FROM move_rate_limits WHERE Key_name = 'ix_rate_limits_window'; -- SHOW INDEX FROM rate_limits WHERE Key_name = 'ix_rate_limits_window';
-- Events still unscoped after Part C (expect 0, or only rows whose device has no -- Events still unscoped after Part C (expect 0, or only rows whose device has no
-- site assigned yet — go back and finish Part B for those devices): -- site assigned yet — go back and finish Part B for those devices):
-- SELECT COUNT(*) FROM move_events e JOIN move_devices d ON d.device_id = e.device_id -- SELECT COUNT(*) FROM events e JOIN devices d ON d.device_id = e.device_id
-- WHERE e.site IS NULL AND d.site IS NOT NULL; -- WHERE e.site IS NULL AND d.site IS NOT NULL;

View file

@ -0,0 +1,97 @@
/* Base layout ------------------------------------------------------- */
:root {
color-scheme: light;
--border: #d1d5db;
--text: #1f2937;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--text);
background: #f9fafb;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #111827;
color: #fff;
}
.app-nav a { color: #fff; margin-right: 1rem; text-decoration: none; }
.app-nav a:hover, .app-nav a:focus { text-decoration: underline; }
.app-session { display: flex; align-items: center; gap: 0.5rem; }
.app-session a { color: #fff; }
.app-main { padding: 1rem; max-width: 960px; margin: 0 auto; }
.login-form, .zone-controls { display: flex; flex-direction: column; gap: 0.5rem; max-width: 320px; }
.zone-controls { flex-direction: row; align-items: end; flex-wrap: wrap; max-width: none; }
.zone-controls label { display: flex; flex-direction: column; font-size: 0.85rem; }
.form-error { color: #991b1b; }
#zone-canvas {
border: 1px solid var(--border);
background: #111827 center / contain no-repeat;
cursor: crosshair;
max-width: 100%;
}
button {
cursor: pointer;
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 4px;
background: #fff;
}
button:hover { background: #f3f4f6; }
/* Event list ---------------------------------------------------------- */
.event-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.5rem;
background: #fff;
}
.event-row.is-unacknowledged { border-left: 4px solid #4b5563; }
.event-meta { flex: 1; }
.event-meta .event-type { font-weight: 600; }
.event-meta .event-time { color: #4b5563; font-size: 0.85rem; }
/* Severity badges (NF9): color-blind-safe — icon + text label + color.
Never rely on color alone. Pastel background + dark text pairs are
chosen for WCAG-AA contrast (>= 4.5:1 for normal-size text). */
.severity-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-weight: 700;
font-size: 0.85rem;
white-space: nowrap;
}
.severity-badge .icon { font-size: 1rem; line-height: 1; }
.sev-critical { background: #fee2e2; color: #991b1b; border: 1px solid #991b1b; }
.sev-high { background: #ffedd5; color: #9a3412; border: 1px solid #9a3412; }
.sev-warning { background: #fef9c3; color: #854d0e; border: 1px solid #854d0e; }
.sev-info { background: #e5e7eb; color: #374151; border: 1px solid #374151; }
.ack-btn { margin-left: auto; }
.ack-btn:disabled { opacity: 0.6; cursor: default; }
/* Dashboard row -> incident detail link (keyboard-focusable affordance). */
.event-detail-link { color: inherit; text-decoration: none; }
.event-detail-link:hover,
.event-detail-link:focus-visible { text-decoration: underline; }
/* Incident detail view (event_detail.php + detail.js). */
.detail-row { margin: 0.25rem 0; }
.detail-row .detail-label { color: #4b5563; font-weight: 600; }
#event-detail-snapshots { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; }
.detail-snapshot { max-width: 100%; height: auto; border: 1px solid #d1d5db; border-radius: 4px; }

226
cloud/public/assets/app.js Normal file
View file

@ -0,0 +1,226 @@
/**
* Events dashboard: polls GET /api/events every 5s, renders the list with
* severity text+icon+color (never color alone, per NF9), pins unacknowledged
* events to the top, and wires the acknowledge button to POST /events/{id}/ack.
*
* XSS-safe: all dynamic values are set via textContent, never innerHTML.
*/
(function () {
'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 SEVERITY_LABELS = {
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔
high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠
warning: { icon: '△', text: '경고', cls: 'sev-warning' }, // △
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } // ℹ
};
var root = document.getElementById('events-root');
if (!root) return;
var csrfToken = null;
function loadCsrfToken() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data && data.token) csrfToken = data.token; })
.catch(function () { /* ack will fail with 403 and re-enable the button */ });
}
function severityInfo(sev) {
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
}
function formatTime(occurredAt) {
if (!occurredAt && occurredAt !== 0) return '';
var d = new Date(Number(occurredAt) * 1000);
if (isNaN(d.getTime())) return '';
return d.toLocaleString('ko-KR');
}
function buildBadge(sev) {
var info = severityInfo(sev);
var badge = document.createElement('span');
badge.className = 'severity-badge ' + info.cls;
var icon = document.createElement('span');
icon.className = 'icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = info.icon;
var label = document.createElement('span');
label.className = 'label';
label.textContent = info.text;
badge.appendChild(icon);
badge.appendChild(label);
return badge;
}
// --- desk audio alert: repeating beep while any unacknowledged critical exists ---
// No binary asset (air-gapped friendly): a WebAudio oscillator tone. The browser
// blocks audio until a user gesture, so the context is created on first click.
var audioCtx = null;
var alarmTimer = null;
var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately
function unlockAudio() {
if (!audioCtx) {
var AC = window.AudioContext || window.webkitAudioContext;
if (AC) audioCtx = new AC();
}
document.removeEventListener('click', unlockAudio);
}
function beep() {
if (!audioCtx) return;
try {
var osc = audioCtx.createOscillator();
var gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.value = 0.2;
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.4);
} catch (e) {
// audio unavailable; the visual list still surfaces the critical event
}
}
function updateAlarm(events) {
var hasUnackedCritical = false;
events.forEach(function (ev) {
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
if (ev.severity === 'critical' && !acked) {
hasUnackedCritical = true;
if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); }
}
});
if (hasUnackedCritical && !alarmTimer) {
alarmTimer = setInterval(beep, 3000);
} else if (!hasUnackedCritical && alarmTimer) {
clearInterval(alarmTimer);
alarmTimer = null;
}
}
function buildRow(ev) {
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
var row = document.createElement('div');
row.className = 'event-row' + (isAcked ? '' : ' is-unacknowledged');
row.appendChild(buildBadge(ev.severity));
var meta = document.createElement('div');
meta.className = 'event-meta';
var type = document.createElement('div');
type.className = 'event-type';
var typeLink = document.createElement('a');
typeLink.className = 'event-detail-link';
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
typeLink.textContent = String(ev.event_type || '');
type.appendChild(typeLink);
meta.appendChild(type);
var sub = document.createElement('div');
sub.className = 'event-time';
var cam = ev.camera_id ? String(ev.camera_id) : '';
var time = formatTime(ev.occurred_at);
sub.textContent = [cam, time].filter(Boolean).join(' · ');
meta.appendChild(sub);
row.appendChild(meta);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ack-btn';
if (isAcked) {
btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
btn.disabled = true;
} else {
btn.textContent = '확인';
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
}
row.appendChild(btn);
return row;
}
function acknowledge(eventId, btn) {
btn.disabled = true;
fetch(B + '/events/' + encodeURIComponent(eventId) + '/ack', {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
})
.then(function (res) {
if (!res.ok) throw new Error('ack_failed:' + res.status);
return loadEvents();
})
.catch(function () {
btn.disabled = false;
});
}
function render(events) {
var sorted = events.slice().sort(function (a, b) {
var aAcked = a.acknowledged_by !== null && a.acknowledged_by !== undefined && a.acknowledged_by !== '';
var bAcked = b.acknowledged_by !== null && b.acknowledged_by !== undefined && b.acknowledged_by !== '';
if (aAcked === bAcked) return 0;
return aAcked ? 1 : -1; // unacknowledged first
});
updateAlarm(sorted); // before any early return, so an emptied list also silences the alarm
root.textContent = '';
root.removeAttribute('aria-busy');
if (sorted.length === 0) {
root.textContent = '표시할 이벤트가 없습니다.';
return;
}
var frag = document.createDocumentFragment();
sorted.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
root.appendChild(frag);
}
function loadEvents() {
// Request the full server page cap (MAX_LIMIT=200), not the default 50: the audible alarm
// and the "unacknowledged first" pinning only consider the rows fetched here. During an event
// burst, a default page of 50 could push an unacknowledged critical off the fetched set, and
// updateAlarm() would then clear the alarm while a critical fall is still unacknowledged.
return fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) {
window.location.href = B + '/login';
return null;
}
if (!res.ok) throw new Error('list_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (data && Array.isArray(data.events)) render(data.events);
})
.catch(function () {
// Leave the last rendered state on transient poll failure.
});
}
var logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function () {
fetch(B + '/logout', { method: 'POST' }).then(function () { window.location.href = B + '/login'; });
});
}
document.addEventListener('click', unlockAudio);
loadCsrfToken();
loadEvents();
setInterval(loadEvents, POLL_INTERVAL_MS);
})();

View file

@ -4,12 +4,9 @@
* - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones. * - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones.
* - "Load" fetches existing zones for the camera via GET /api/zones/{camera} * - "Load" fetches existing zones for the camera via GET /api/zones/{camera}
* and redraws them; each has a Delete button -> POST /api/zones/{id}/delete. * and redraws them; each has a Delete button -> POST /api/zones/{id}/delete.
* - "현재 화면 불러오기" fetches the camera's newest snapshot uuid via * - If a `?snapshot={uuid}` query param is present, that image (served via
* GET /api/cameras/{camera}/latest-snapshot and draws it as the canvas * the authenticated /snap/{uuid} proxy) is drawn as the canvas background;
* background, so zones are drawn over what the camera actually sees. * otherwise the canvas keeps its plain dark backdrop.
* - A `?snapshot={uuid}` query param does the same for one specific image
* (the event-detail page links here that way); otherwise the canvas keeps
* its plain dark backdrop.
* *
* XSS-safe: all dynamic text is set via textContent, never innerHTML. * XSS-safe: all dynamic text is set via textContent, never innerHTML.
*/ */
@ -28,7 +25,6 @@
var cameraInput = document.getElementById('camera-id'); var cameraInput = document.getElementById('camera-id');
var levelSelect = document.getElementById('zone-level'); var levelSelect = document.getElementById('zone-level');
var loadBtn = document.getElementById('zone-load-btn'); var loadBtn = document.getElementById('zone-load-btn');
var frameBtn = document.getElementById('zone-frame-btn');
var clearBtn = document.getElementById('zone-clear-btn'); var clearBtn = document.getElementById('zone-clear-btn');
var saveBtn = document.getElementById('zone-save-btn'); var saveBtn = document.getElementById('zone-save-btn');
var listEl = document.getElementById('zone-list'); var listEl = document.getElementById('zone-list');
@ -134,22 +130,6 @@
img.src = B + '/snap/' + encodeURIComponent(uuid); img.src = B + '/snap/' + encodeURIComponent(uuid);
} }
// Load the camera's most recent frame as the drawing background. The uuid comes from the
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
function loadLatestFrame() {
var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
setStatus('현재 화면을 불러오는 중...');
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
.then(function (r) {
if (r.status === 404) throw new Error('이 카메라의 저장된 화면이 아직 없습니다. 이벤트가 한 번 발생해야 배경이 생깁니다.');
if (!r.ok) throw new Error('불러오기 실패 (' + r.status + ')');
return r.json();
})
.then(function (data) { loadBackground(data.uuid); setStatus('현재 화면을 배경으로 불러왔습니다.'); })
.catch(function (e) { setStatus(e.message); });
}
function loadZones() { function loadZones() {
var camera = cameraInput.value.trim(); var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
@ -232,7 +212,6 @@
levelSelect.addEventListener('change', redraw); levelSelect.addEventListener('change', redraw);
loadBtn.addEventListener('click', loadZones); loadBtn.addEventListener('click', loadZones);
if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); }); clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
saveBtn.addEventListener('click', saveZone); saveBtn.addEventListener('click', saveZone);

View file

@ -45,8 +45,7 @@ use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterR
use App\Ingest\IngestController; use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController}; 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};
DeviceStatusController};
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so // Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off // an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
@ -90,11 +89,10 @@ $config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate);
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate); $heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
$auth = new AuthController($users, $audit, $rate); $auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController(); $csrfCtl = new CsrfController();
$list = new EventListController($events, $snaps, $sess); $list = new EventListController($events, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess);
$deviceStatus = new DeviceStatusController($devices, $sess);
$render = function (string $view): Response { $render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
@ -109,12 +107,9 @@ $r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess));
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); $r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); $r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
// Background frame for the zone editor: the newest snapshot this camera produced.
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p)); $r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p)); $r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));

View file

@ -36,7 +36,7 @@ SET NAMES utf8mb4;
-- No PdoUserRepository::create() exists in P1-B -- the first admin account is -- No PdoUserRepository::create() exists in P1-B -- the first admin account is
-- provisioned out-of-band via the CLI snippet in cloud/docs/cloud-deploy.md. -- provisioned out-of-band via the CLI snippet in cloud/docs/cloud-deploy.md.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_users ( CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(190) NOT NULL, username VARCHAR(190) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- Argon2id, see App\Security\Password::hash() password_hash VARCHAR(255) NOT NULL, -- Argon2id, see App\Security\Password::hash()
@ -55,7 +55,7 @@ CREATE TABLE move_users (
-- No PdoDeviceRepository::create() exists -- rows are provisioned by an admin -- No PdoDeviceRepository::create() exists -- rows are provisioned by an admin
-- (per-device secret issuance) before the device is allowed to sync/ingest. -- (per-device secret issuance) before the device is allowed to sync/ingest.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_devices ( CREATE TABLE devices (
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
name VARCHAR(190) NULL, name VARCHAR(190) NULL,
site VARCHAR(64) NULL, -- access-control scope; matches users.site_scope (NULL = unassigned) site VARCHAR(64) NULL, -- access-control scope; matches users.site_scope (NULL = unassigned)
@ -77,7 +77,7 @@ CREATE TABLE move_devices (
-- directly as a free-form string. Kept for documentation/future per-camera -- directly as a free-form string. Kept for documentation/future per-camera
-- config and to avoid a breaking migration when that repository is added. -- config and to avoid a breaking migration when that repository is added.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_cameras ( CREATE TABLE cameras (
camera_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NOT NULL,
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
name VARCHAR(190) NULL, name VARCHAR(190) NULL,
@ -94,7 +94,7 @@ CREATE TABLE move_cameras (
-- `cameras` row exists for a given camera_id (no camera-registration flow in -- `cameras` row exists for a given camera_id (no camera-registration flow in
-- P1-B), so a FK here would spuriously reject valid app writes. -- P1-B), so a FK here would spuriously reject valid app writes.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_zones ( CREATE TABLE zones (
zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NOT NULL,
@ -112,11 +112,11 @@ CREATE TABLE move_zones (
-- App\Ingest\EventValidator before insert(); the CHECK constraints below are -- App\Ingest\EventValidator before insert(); the CHECK constraints below are
-- defense-in-depth only, not the primary validation layer. -- defense-in-depth only, not the primary validation layer.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_events ( CREATE TABLE events (
event_id INT UNSIGNED NOT NULL AUTO_INCREMENT, event_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_uuid CHAR(32) NULL, -- edge-generated idempotency key (dedup a re-sent event); NULL for legacy rows event_uuid CHAR(32) NULL, -- edge-generated idempotency key (dedup a re-sent event); NULL for legacy rows
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
site VARCHAR(64) NULL, -- denormalized from move_devices.site at ingest; access-control scope site VARCHAR(64) NULL, -- denormalized from devices.site at ingest; access-control scope
camera_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL, event_type VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL, severity VARCHAR(16) NOT NULL,
@ -137,7 +137,6 @@ CREATE TABLE move_events (
KEY ix_events_severity_occurred_at (severity, occurred_at), KEY ix_events_severity_occurred_at (severity, occurred_at),
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b) KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
KEY ix_events_device (device_id), KEY ix_events_device (device_id),
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
CONSTRAINT chk_events_type CHECK (event_type IN ( CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning', 'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault' 'zone_restricted','pinch','monitoring_degraded','device_fault'
@ -151,7 +150,7 @@ CREATE TABLE move_events (
-- events is safe because IngestController always inserts the parent event row -- events is safe because IngestController always inserts the parent event row
-- before calling SnapshotRepository::add() for its snapshots. -- before calling SnapshotRepository::add() for its snapshots.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_event_snapshots ( CREATE TABLE event_snapshots (
snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT, snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id INT UNSIGNED NOT NULL, event_id INT UNSIGNED NOT NULL,
file_uuid CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)); on-disk filename = <uuid>.jpg file_uuid CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)); on-disk filename = <uuid>.jpg
@ -165,7 +164,7 @@ CREATE TABLE move_event_snapshots (
UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash), UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash),
KEY ix_event_snapshots_event (event_id), KEY ix_event_snapshots_event (event_id),
KEY ix_event_snapshots_created (created_at), -- retention purge scans WHERE created_at < ? KEY ix_event_snapshots_created (created_at), -- retention purge scans WHERE created_at < ?
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -174,7 +173,7 @@ CREATE TABLE move_event_snapshots (
-- GET /api/config. No PdoThresholdRepository write method exists in P1-B -- -- GET /api/config. No PdoThresholdRepository write method exists in P1-B --
-- rows are seeded/edited directly (phpMyAdmin) until a management UI lands. -- rows are seeded/edited directly (phpMyAdmin) until a management UI lands.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_thresholds ( CREATE TABLE thresholds (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NULL, camera_id VARCHAR(64) NULL,
@ -195,11 +194,11 @@ CREATE TABLE move_thresholds (
-- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard, -- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard,
-- not the earlier seen() check). -- not the earlier seen() check).
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_nonces ( CREATE TABLE nonces (
nonce VARCHAR(190) NOT NULL, nonce VARCHAR(190) NOT NULL,
expires_at BIGINT UNSIGNED NOT NULL, expires_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (nonce), PRIMARY KEY (nonce),
KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM move_nonces WHERE expires_at < ?` cleanup job KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM nonces WHERE expires_at < ?` cleanup job
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -208,7 +207,7 @@ CREATE TABLE move_nonces (
-- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in -- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in
-- PdoRateLimiterRepository::hit() -- no read-modify-write race window. -- PdoRateLimiterRepository::hit() -- no read-modify-write race window.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_rate_limits ( CREATE TABLE rate_limits (
rate_key VARCHAR(190) NOT NULL, rate_key VARCHAR(190) NOT NULL,
window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket
count INT UNSIGNED NOT NULL DEFAULT 0, count INT UNSIGNED NOT NULL DEFAULT 0,
@ -223,7 +222,7 @@ CREATE TABLE move_rate_limits (
-- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the -- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the
-- Task 16 brief self-review -- this table is the plain append-only record only. -- Task 16 brief self-review -- this table is the plain append-only record only.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_audit_log ( CREATE TABLE audit_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
actor VARCHAR(190) NOT NULL, actor VARCHAR(190) NOT NULL,
action VARCHAR(64) NOT NULL, action VARCHAR(64) NOT NULL,
@ -242,18 +241,13 @@ CREATE TABLE move_audit_log (
-- run. Apply the RUNNABLE migration instead: -- run. Apply the RUNNABLE migration instead:
-- --
-- cloud/migrations/2026-07-17-p1-9-p3-2b.sql -- cloud/migrations/2026-07-17-p1-9-p3-2b.sql
-- cloud/migrations/2026-07-20-move-table-prefix.sql (run this one LAST)
--
-- The second migration renames every table to the `move_` prefix used above and
-- by App\Repository\Pdo\* -- an existing DB must be renamed before deploy or all
-- queries fail with "table doesn't exist".
-- --
-- It adds devices.site / events.site / events.event_uuid, the retention and -- It adds devices.site / events.site / events.event_uuid, the retention and
-- rate-limit reap indexes, AND backfills events.site from move_devices.site (without -- rate-limit reap indexes, AND backfills events.site from devices.site (without
-- that backfill, historical events vanish for a scoped operator). Run it once in -- that backfill, historical events vanish for a scoped operator). Run it once in
-- phpMyAdmin BEFORE deploying code that reads these columns, then provision: -- phpMyAdmin BEFORE deploying code that reads these columns, then provision:
-- UPDATE move_devices SET site = 'factory-a' WHERE device_id = 'pi-1'; -- UPDATE devices SET site = 'factory-a' WHERE device_id = 'pi-1';
-- UPDATE move_users SET site_scope = 'factory-a' WHERE username = 'operator-a'; -- UPDATE users SET site_scope = 'factory-a' WHERE username = 'operator-a';
-- A user with site_scope = NULL is a global operator; '' is NOT global (matches -- A user with site_scope = NULL is a global operator; '' is NOT global (matches
-- nothing — a blank scope fails closed). -- nothing — a blank scope fails closed).
-- =========================================================================== -- ===========================================================================

View file

@ -35,15 +35,9 @@ final class HeartbeatController
} }
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl()); $this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
// config_version is null when the device did not report one (older edge builds). Storing a
// fake 0 made the dashboard's config-drift check (reported !== published) fire on EVERY
// device forever, since published versions start at 1. Keep it null until a real version
// arrives so drift only ever means genuine drift.
$reportedVersion = isset($b['config_version']) ? (int)$b['config_version'] : null;
$this->devices->touchHeartbeat($deviceId, [ $this->devices->touchHeartbeat($deviceId, [
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0), 'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
'throttle' => (bool)($b['throttle'] ?? false), 'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0),
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
'at' => $now, 'at' => $now,
]); ]);
return Response::json(['ok' => true], 200); return Response::json(['ok' => true], 200);

View file

@ -4,14 +4,14 @@ namespace App\Dashboard;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Auth\SessionStore; use App\Auth\SessionStore;
use App\Repository\{EventRepository, SnapshotRepository}; use App\Repository\EventRepository;
final class EventListController final class EventListController
{ {
private const DEFAULT_LIMIT = 50; private const DEFAULT_LIMIT = 50;
private const MAX_LIMIT = 200; private const MAX_LIMIT = 200;
public function __construct(private EventRepository $events, private SnapshotRepository $snaps, private SessionStore $sess) {} public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function json(Request $req): Response public function json(Request $req): Response
{ {
@ -28,16 +28,6 @@ final class EventListController
$site = is_string($scope) ? $scope : null; $site = is_string($scope) ? $scope : null;
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site); $rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site);
// Attach a thumbnail uuid (newest snapshot) per event, batched in one query — the list
// shows evidence images inline instead of only on the detail page. The image itself still
// goes through /snap/{uuid}, which re-checks session + site scope, so exposing the uuid
// here grants nothing the row's owner could not already fetch.
if ($rows !== []) {
$thumbs = $this->snaps->thumbUuidsFor(array_map(static fn ($r) => (int)$r['event_id'], $rows));
foreach ($rows as &$row) { $row['thumb_uuid'] = $thumbs[(int)$row['event_id']] ?? null; }
unset($row);
}
// Advertise a next cursor only on a full page — a short page means we hit the end. // Advertise a next cursor only on a full page — a short page means we hit the end.
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page. // The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
$next = null; $next = null;

View file

@ -30,12 +30,6 @@ final class SnapshotProxy
return Response::json(['error' => 'not_found'], 404); return Response::json(['error' => 'not_found'], 404);
} }
} }
// A snapshot is immutable per uuid, so let the browser cache it. Without this the event return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200);
// list — which rebuilds every <img src=/snap/{uuid}> on each 5s poll — re-downloads every
// visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open
// dashboard, tens of GB/day that can trip Cafe24 shared-hosting traffic caps. `private` so
// a shared proxy never caches one operator's site's worker image for another (PIPA).
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200,
['Cache-Control' => 'private, max-age=86400, immutable']);
} }
} }

View file

@ -6,7 +6,7 @@ use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac}; use App\Auth\{SessionStore, Rbac};
use App\Security\{Csrf, SiteScope}; use App\Security\{Csrf, SiteScope};
use App\Geometry\Polygon; use App\Geometry\Polygon;
use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotRepository, ZoneOwnershipException}; use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, ZoneOwnershipException};
final class ZoneConfigController final class ZoneConfigController
{ {
@ -14,8 +14,7 @@ final class ZoneConfigController
public function __construct( public function __construct(
private ZoneRepository $zones, private DeviceRepository $devices, private ZoneRepository $zones, private DeviceRepository $devices,
private AuditRepository $audit, private SessionStore $sess, private AuditRepository $audit, private SessionStore $sess
private SnapshotRepository $snapshots
) {} ) {}
private function guard(string $perm): ?Response private function guard(string $perm): ?Response
@ -46,22 +45,6 @@ final class ZoneConfigController
return Response::json(['zones' => $zones], 200); return Response::json(['zones' => $zones], 200);
} }
/** GET /api/cameras/{camera}/latest-snapshot -> {"uuid": "..."} or 404.
* The zone editor loads /snap/{uuid} as its canvas background so zones are drawn over the
* camera's actual view. Returns only the uuid: the image itself still goes through
* SnapshotProxy, which re-checks the session and the site scope before serving bytes. */
public function latestSnapshot(Request $req, array $params): Response
{
if ($g = $this->guard('view_events')) return $g;
$shot = $this->snapshots->latestForCamera((string)($params['camera'] ?? ''));
// 404 for out-of-scope as well as missing -- a scoped operator must not be able to
// tell "another site's camera" apart from "no such camera" (mirrors list()).
if ($shot === null || !$this->inScope((string)($shot['device_id'] ?? ''))) {
return Response::json(['error' => 'not_found'], 404);
}
return Response::json(['uuid' => $shot['file_uuid']], 200);
}
public function save(Request $req): Response public function save(Request $req): Response
{ {
if ($g = $this->guard('edit_zones')) return $g; if ($g = $this->guard('edit_zones')) return $g;
@ -75,15 +58,6 @@ final class ZoneConfigController
return Response::json(['error' => 'invalid_zone'], 400); return Response::json(['error' => 'invalid_zone'], 400);
} }
$deviceId = (string)($b['device_id'] ?? ''); $deviceId = (string)($b['device_id'] ?? '');
// The zone MUST name a real device: it is delivered to the edge only via
// GET /api/config -> zones->forDevice($deviceId), so a zone saved with a blank or mistyped
// device_id is enforced by nobody while the dashboard still shows it as active (a silent
// safety hole). A global (null-scope) operator's inScope() check below passes for an unknown
// device (siteFor()->null, allows(null,null) true), so it cannot catch this — check existence
// explicitly. 400: it is a client input error, not a hidden/cross-site resource.
if ($deviceId === '' || !$this->devices->exists($deviceId)) {
return Response::json(['error' => 'unknown_device'], 400);
}
// Scope BEFORE the write: a scoped operator must not create or overwrite a zone on another // Scope BEFORE the write: a scoped operator must not create or overwrite a zone on another
// site's device. For an update, the EXISTING zone's device must also be in scope, so a // site's device. For an update, the EXISTING zone's device must also be in scope, so a
// caller cannot re-home a cross-site zone under one of their own device ids. // caller cannot re-home a cross-site zone under one of their own device ids.

View file

@ -12,9 +12,8 @@ final class Response
public static function text(string $s, int $status = 200): self { public static function text(string $s, int $status = 200): self {
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s); return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
} }
public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self { public static function bytes(string $b, string $contentType, int $status = 200): self {
// Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden. return new self($status, ['Content-Type' => $contentType], $b);
return new self($status, ['Content-Type' => $contentType] + $headers, $b);
} }
public static function redirect(string $to): self { public static function redirect(string $to): self {
return new self(302, ['Location' => $to], ''); return new self(302, ['Location' => $to], '');

View file

@ -32,7 +32,7 @@ final class EventValidator
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id'; if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s'; if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends // Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
// it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the // it on retry so the cloud can dedup. Enforce the exact shape (matches CHAR(32) + the
// snapshot-uuid convention); a malformed key is a bug, not a silent pass. // snapshot-uuid convention); a malformed key is a bug, not a silent pass.
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) { if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) {
$errors[] = 'bad:event_uuid'; $errors[] = 'bad:event_uuid';

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface DeviceRepository
{
public function secretFor(string $deviceId): ?string;
/** The device's access-control site (devices.site), or null if unassigned/unknown. */
public function siteFor(string $deviceId): ?string;
public function touchHeartbeat(string $deviceId, array $meta): void;
public function configVersion(string $deviceId): int;
}

View file

@ -12,7 +12,7 @@ final class PdoAuditRepository implements AuditRepository
public function append(string $actor, string $action, string $target, array $before, array $after): void public function append(string $actor, string $action, string $target, array $before, array $after): void
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts) 'INSERT INTO audit_log (actor, action, target, before_json, after_json, ts)
VALUES (?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?)'
); );
$st->execute([ $st->execute([

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\DeviceRepository;
use PDO;
final class PdoDeviceRepository implements DeviceRepository
{
public function __construct(private PDO $pdo) {}
public function secretFor(string $deviceId): ?string
{
$st = $this->pdo->prepare('SELECT secret FROM devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$secret = $st->fetchColumn();
return $secret === false ? null : (string)$secret;
}
public function siteFor(string $deviceId): ?string
{
$st = $this->pdo->prepare('SELECT site FROM devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$site = $st->fetchColumn();
// false = no such device; null = device exists but unassigned. Both -> null (unscoped).
return ($site === false || $site === null) ? null : (string)$site;
}
public function touchHeartbeat(string $deviceId, array $meta): void
{
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
// only reaches this after secretFor() already succeeded for the same
// device_id, so the row is guaranteed to exist. Device rows are provisioned
// out-of-band by an admin (see cloud/docs/cloud-deploy.md), never created
// implicitly from an inbound heartbeat.
$st = $this->pdo->prepare(
'UPDATE devices
SET last_fps = ?, last_temp = ?, last_throttle = ?,
reported_config_version = ?, last_heartbeat_at = ?
WHERE device_id = ?'
);
$st->execute([
(float)($meta['fps'] ?? 0.0),
(float)($meta['temp'] ?? 0.0),
!empty($meta['throttle']) ? 1 : 0,
(int)($meta['config_version'] ?? 0),
(int)($meta['at'] ?? time()),
$deviceId,
]);
}
public function configVersion(string $deviceId): int
{
$st = $this->pdo->prepare('SELECT config_version FROM devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$version = $st->fetchColumn();
return $version === false ? 1 : (int)$version;
}
}

View file

@ -16,7 +16,7 @@ final class PdoEventRepository implements EventRepository
public function insert(array $event): int public function insert(array $event): int
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_events 'INSERT INTO events
(event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, (event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, received_at) occurred_at, duration_s, metrics_json, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
@ -24,7 +24,7 @@ final class PdoEventRepository implements EventRepository
$st->execute([ $st->execute([
$event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy) $event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy)
(string)$event['device_id'], (string)$event['device_id'],
$event['site'] ?? null, // denormalized from move_devices.site by IngestController $event['site'] ?? null, // denormalized from devices.site by IngestController
(string)$event['camera_id'], (string)$event['camera_id'],
(string)$event['event_type'], (string)$event['event_type'],
(string)$event['severity'], (string)$event['severity'],
@ -40,7 +40,7 @@ final class PdoEventRepository implements EventRepository
public function idByUuid(string $uuid): ?int public function idByUuid(string $uuid): ?int
{ {
$st = $this->pdo->prepare('SELECT event_id FROM move_events WHERE event_uuid = ? LIMIT 1'); $st = $this->pdo->prepare('SELECT event_id FROM events WHERE event_uuid = ? LIMIT 1');
$st->execute([$uuid]); $st->execute([$uuid]);
$id = $st->fetchColumn(); $id = $st->fetchColumn();
return $id === false ? null : (int)$id; return $id === false ? null : (int)$id;
@ -51,7 +51,7 @@ final class PdoEventRepository implements EventRepository
if ($severity !== null) { if ($severity !== null) {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . ' 'SELECT ' . self::SELECT_COLUMNS . '
FROM move_events WHERE severity = ? FROM events WHERE severity = ?
ORDER BY occurred_at DESC, event_id DESC LIMIT ?' ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
); );
$st->bindValue(1, $severity, PDO::PARAM_STR); $st->bindValue(1, $severity, PDO::PARAM_STR);
@ -59,7 +59,7 @@ final class PdoEventRepository implements EventRepository
} else { } else {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . ' 'SELECT ' . self::SELECT_COLUMNS . '
FROM move_events FROM events
ORDER BY occurred_at DESC, event_id DESC LIMIT ?' ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
); );
$st->bindValue(1, $limit, PDO::PARAM_INT); $st->bindValue(1, $limit, PDO::PARAM_INT);
@ -92,7 +92,7 @@ final class PdoEventRepository implements EventRepository
$binds[] = [$beforeTs, PDO::PARAM_STR]; $binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeId, PDO::PARAM_INT]; $binds[] = [$beforeId, PDO::PARAM_INT];
} }
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events'; $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM events';
if ($where) $sql .= ' WHERE ' . implode(' AND ', $where); if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
$sql .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?'; $sql .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT]; $binds[] = [$limit, PDO::PARAM_INT];
@ -106,7 +106,7 @@ final class PdoEventRepository implements EventRepository
public function find(int $id): ?array public function find(int $id): ?array
{ {
$st = $this->pdo->prepare('SELECT ' . self::SELECT_COLUMNS . ' FROM move_events WHERE event_id = ?'); $st = $this->pdo->prepare('SELECT ' . self::SELECT_COLUMNS . ' FROM events WHERE event_id = ?');
$st->execute([$id]); $st->execute([$id]);
$row = $st->fetch(); $row = $st->fetch();
return $row === false ? null : self::mapRow($row); return $row === false ? null : self::mapRow($row);
@ -119,13 +119,13 @@ final class PdoEventRepository implements EventRepository
// "changed rows" rather than "matched rows": re-acknowledging an // "changed rows" rather than "matched rows": re-acknowledging an
// already-acknowledged event by the same user would otherwise report // already-acknowledged event by the same user would otherwise report
// 0 affected rows and this method would wrongly return false. // 0 affected rows and this method would wrongly return false.
$exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1'); $exists = $this->pdo->prepare('SELECT 1 FROM events WHERE event_id = ? LIMIT 1');
$exists->execute([$id]); $exists->execute([$id]);
if ($exists->fetchColumn() === false) { if ($exists->fetchColumn() === false) {
return false; return false;
} }
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'UPDATE move_events SET acknowledged_by = ?, acknowledged_at = ? WHERE event_id = ?' 'UPDATE events SET acknowledged_by = ?, acknowledged_at = ? WHERE event_id = ?'
); );
$st->execute([$user, time(), $id]); $st->execute([$user, time(), $id]);
return true; return true;

View file

@ -20,20 +20,20 @@ final class PdoNonceRepository implements NonceRepository
public function seen(string $nonce): bool public function seen(string $nonce): bool
{ {
$st = $this->pdo->prepare('SELECT 1 FROM move_nonces WHERE nonce = ? LIMIT 1'); $st = $this->pdo->prepare('SELECT 1 FROM nonces WHERE nonce = ? LIMIT 1');
$st->execute([$nonce]); $st->execute([$nonce]);
return (bool)$st->fetchColumn(); return (bool)$st->fetchColumn();
} }
public function remember(string $nonce, int $expiresAt): void public function remember(string $nonce, int $expiresAt): void
{ {
$st = $this->pdo->prepare('INSERT INTO move_nonces (nonce, expires_at) VALUES (?, ?)'); $st = $this->pdo->prepare('INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)');
$st->execute([$nonce, $expiresAt]); $st->execute([$nonce, $expiresAt]);
// Cron-less reap: Cafe24 has no scheduler, so expired nonces accumulate forever until // Cron-less reap: Cafe24 has no scheduler, so expired nonces accumulate forever until
// the table bloats and inserts slow/fail. Opportunistically prune a bounded batch of // the table bloats and inserts slow/fail. Opportunistically prune a bounded batch of
// already-expired rows (safe: seen() only cares about unexpired ones). // already-expired rows (safe: seen() only cares about unexpired ones).
if (mt_rand(1, 100) === 1) { if (mt_rand(1, 100) === 1) {
$del = $this->pdo->prepare('DELETE FROM move_nonces WHERE expires_at < ? LIMIT 5000'); $del = $this->pdo->prepare('DELETE FROM nonces WHERE expires_at < ? LIMIT 5000');
$del->execute([time()]); $del->execute([time()]);
} }
} }

View file

@ -18,13 +18,13 @@ final class PdoRateLimiterRepository implements RateLimiterRepository
public function hit(string $key, int $windowStart): int public function hit(string $key, int $windowStart): int
{ {
$upsert = $this->pdo->prepare( $upsert = $this->pdo->prepare(
'INSERT INTO move_rate_limits (rate_key, window_start, count) VALUES (?, ?, 1) 'INSERT INTO rate_limits (rate_key, window_start, count) VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE count = count + 1' ON DUPLICATE KEY UPDATE count = count + 1'
); );
$upsert->execute([$key, $windowStart]); $upsert->execute([$key, $windowStart]);
$select = $this->pdo->prepare( $select = $this->pdo->prepare(
'SELECT count FROM move_rate_limits WHERE rate_key = ? AND window_start = ?' 'SELECT count FROM rate_limits WHERE rate_key = ? AND window_start = ?'
); );
$select->execute([$key, $windowStart]); $select->execute([$key, $windowStart]);
$count = (int)$select->fetchColumn(); $count = (int)$select->fetchColumn();
@ -37,7 +37,7 @@ final class PdoRateLimiterRepository implements RateLimiterRepository
// count is already computed above; swallow reap failures and let the next hit retry. // count is already computed above; swallow reap failures and let the next hit retry.
if (mt_rand(1, 100) === 1) { if (mt_rand(1, 100) === 1) {
try { try {
$del = $this->pdo->prepare('DELETE FROM move_rate_limits WHERE window_start < ? LIMIT 5000'); $del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000');
$del->execute([$windowStart - 2]); $del->execute([$windowStart - 2]);
} catch (\PDOException $e) { } catch (\PDOException $e) {
error_log('rate_limits reap skipped: ' . $e->getMessage()); error_log('rate_limits reap skipped: ' . $e->getMessage());

View file

@ -12,7 +12,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
public function add(int $eventId, string $fileUuid, string $contentHash): void public function add(int $eventId, string $fileUuid, string $contentHash): void
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at) 'INSERT INTO event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)' VALUES (?, ?, ?, ?)'
); );
$st->execute([$eventId, $fileUuid, $contentHash, time()]); $st->execute([$eventId, $fileUuid, $contentHash, time()]);
@ -21,59 +21,13 @@ final class PdoSnapshotRepository implements SnapshotRepository
public function forEvent(int $eventId): array public function forEvent(int $eventId): array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT file_uuid, content_hash FROM move_event_snapshots 'SELECT file_uuid, content_hash FROM event_snapshots
WHERE event_id = ? ORDER BY snapshot_id ASC' WHERE event_id = ? ORDER BY snapshot_id ASC'
); );
$st->execute([$eventId]); $st->execute([$eventId]);
return $st->fetchAll(); return $st->fetchAll();
} }
public function thumbUuidsFor(array $eventIds): array
{
// Sanitize to positive ints and build the IN list from their COUNT — never interpolate
// caller values into SQL. Empty -> no query.
$ids = array_values(array_filter(array_map('intval', $eventIds), static fn (int $i): bool => $i > 0));
if ($ids === []) return [];
$in = implode(',', array_fill(0, count($ids), '?'));
// Newest snapshot per event: the grouped subquery picks MAX(snapshot_id) per event (the
// server's own insert sequence, not the device clock), then joins back to read that row's
// uuid. One round-trip, rides ix_event_snapshots_event — no N+1 across the page.
$sql = "SELECT s.event_id, s.file_uuid
FROM move_event_snapshots s
JOIN (SELECT event_id, MAX(snapshot_id) AS mx
FROM move_event_snapshots
WHERE event_id IN ($in)
GROUP BY event_id) m
ON m.event_id = s.event_id AND m.mx = s.snapshot_id";
$st = $this->pdo->prepare($sql);
foreach ($ids as $i => $v) $st->bindValue($i + 1, $v, PDO::PARAM_INT);
$st->execute();
$out = [];
foreach ($st->fetchAll() as $r) $out[(int)$r['event_id']] = (string)$r['file_uuid'];
return $out;
}
public function latestForCamera(string $cameraId): ?array
{
// Ordered by snapshot_id (insert order), NOT events.occurred_at: occurred_at is the
// DEVICE's clock, so a Pi whose RTC is behind would keep serving a stale frame as
// "latest" forever. snapshot_id is the server's own monotonic sequence.
// Rides ix_events_camera (camera_id, event_id) -- without it this is a full scan of a
// table that grows unbounded, on a page an operator opens interactively.
$st = $this->pdo->prepare(
'SELECT s.file_uuid, e.device_id
FROM move_event_snapshots s
JOIN move_events e ON e.event_id = s.event_id
WHERE e.camera_id = ?
ORDER BY s.snapshot_id DESC
LIMIT 1'
);
$st->execute([$cameraId]);
$row = $st->fetch();
return $row === false ? null : ['file_uuid' => (string)$row['file_uuid'],
'device_id' => (string)$row['device_id']];
}
public function pathByUuid(string $uuid): ?string public function pathByUuid(string $uuid): ?string
{ {
// Validate BEFORE building any filesystem path or touching the DB -- // Validate BEFORE building any filesystem path or touching the DB --
@ -85,7 +39,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
return null; return null;
} }
$st = $this->pdo->prepare('SELECT 1 FROM move_event_snapshots WHERE file_uuid = ? LIMIT 1'); $st = $this->pdo->prepare('SELECT 1 FROM event_snapshots WHERE file_uuid = ? LIMIT 1');
$st->execute([$uuid]); $st->execute([$uuid]);
if ($st->fetchColumn() === false) { if ($st->fetchColumn() === false) {
return null; return null;
@ -99,7 +53,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) { if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) {
return null; return null;
} }
$st = $this->pdo->prepare('SELECT event_id FROM move_event_snapshots WHERE file_uuid = ? LIMIT 1'); $st = $this->pdo->prepare('SELECT event_id FROM event_snapshots WHERE file_uuid = ? LIMIT 1');
$st->execute([$uuid]); $st->execute([$uuid]);
$id = $st->fetchColumn(); $id = $st->fetchColumn();
return $id === false ? null : (int)$id; return $id === false ? null : (int)$id;
@ -112,7 +66,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
// row (file gone, row left) is harmless; an orphan file (row gone, file left) is the // row (file gone, row left) is harmless; an orphan file (row gone, file left) is the
// PIPA leak we are closing, so never delete the row before its file. // PIPA leak we are closing, so never delete the row before its file.
$sel = $this->pdo->prepare( $sel = $this->pdo->prepare(
'SELECT snapshot_id, file_uuid FROM move_event_snapshots WHERE created_at < ? LIMIT ?'); 'SELECT snapshot_id, file_uuid FROM event_snapshots WHERE created_at < ? LIMIT ?');
$sel->bindValue(1, $cutoff, PDO::PARAM_INT); $sel->bindValue(1, $cutoff, PDO::PARAM_INT);
$sel->bindValue(2, $limit, PDO::PARAM_INT); $sel->bindValue(2, $limit, PDO::PARAM_INT);
$sel->execute(); $sel->execute();
@ -138,7 +92,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
return 0; return 0;
} }
$in = implode(',', array_fill(0, count($ids), '?')); $in = implode(',', array_fill(0, count($ids), '?'));
$del = $this->pdo->prepare("DELETE FROM move_event_snapshots WHERE snapshot_id IN ($in)"); $del = $this->pdo->prepare("DELETE FROM event_snapshots WHERE snapshot_id IN ($in)");
$del->execute($ids); $del->execute($ids);
return count($ids); return count($ids);
} }

View file

@ -11,7 +11,7 @@ final class PdoThresholdRepository implements ThresholdRepository
public function forDevice(string $deviceId): array public function forDevice(string $deviceId): array
{ {
$st = $this->pdo->prepare('SELECT `key`, `value` FROM move_thresholds WHERE device_id = ?'); $st = $this->pdo->prepare('SELECT `key`, `value` FROM thresholds WHERE device_id = ?');
$st->execute([$deviceId]); $st->execute([$deviceId]);
$out = []; $out = [];

View file

@ -13,7 +13,7 @@ final class PdoUserRepository implements UserRepository
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT id, username, password_hash, role, site_scope, failed_attempts, locked_until 'SELECT id, username, password_hash, role, site_scope, failed_attempts, locked_until
FROM move_users WHERE username = ? LIMIT 1' FROM users WHERE username = ? LIMIT 1'
); );
$st->execute([$username]); $st->execute([$username]);
$row = $st->fetch(); $row = $st->fetch();
@ -36,14 +36,14 @@ final class PdoUserRepository implements UserRepository
// Increment in SQL (not read-then-write in PHP) so concurrent failed // Increment in SQL (not read-then-write in PHP) so concurrent failed
// logins for the same account can't clobber each other's count. // logins for the same account can't clobber each other's count.
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'UPDATE move_users SET failed_attempts = failed_attempts + 1, locked_until = ? WHERE id = ?' 'UPDATE users SET failed_attempts = failed_attempts + 1, locked_until = ? WHERE id = ?'
); );
$st->execute([$lockUntil, $userId]); $st->execute([$lockUntil, $userId]);
} }
public function clearFailures(int $userId): void public function clearFailures(int $userId): void
{ {
$st = $this->pdo->prepare('UPDATE move_users SET failed_attempts = 0, locked_until = 0 WHERE id = ?'); $st = $this->pdo->prepare('UPDATE users SET failed_attempts = 0, locked_until = 0 WHERE id = ?');
$st->execute([$userId]); $st->execute([$userId]);
} }
} }

View file

@ -13,7 +13,7 @@ final class PdoZoneRepository implements ZoneRepository
public function find(string $zoneId): ?array public function find(string $zoneId): ?array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE zone_id = ? LIMIT 1' 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE zone_id = ? LIMIT 1'
); );
$st->execute([$zoneId]); $st->execute([$zoneId]);
$row = $st->fetch(); $row = $st->fetch();
@ -23,7 +23,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forDevice(string $deviceId): array public function forDevice(string $deviceId): array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE device_id = ?' 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE device_id = ?'
); );
$st->execute([$deviceId]); $st->execute([$deviceId]);
return $st->fetchAll(); return $st->fetchAll();
@ -32,7 +32,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forCamera(string $cameraId): array public function forCamera(string $cameraId): array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE camera_id = ?' 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE camera_id = ?'
); );
$st->execute([$cameraId]); $st->execute([$cameraId]);
return $st->fetchAll(); return $st->fetchAll();
@ -52,7 +52,7 @@ final class PdoZoneRepository implements ZoneRepository
$newId = bin2hex(random_bytes(16)); $newId = bin2hex(random_bytes(16));
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, updated_at) 'INSERT INTO zones (zone_id, device_id, camera_id, level, polygon_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?)'
); );
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]); $st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]);
@ -61,7 +61,7 @@ final class PdoZoneRepository implements ZoneRepository
public function delete(string $zoneId): bool public function delete(string $zoneId): bool
{ {
$st = $this->pdo->prepare('DELETE FROM move_zones WHERE zone_id = ?'); $st = $this->pdo->prepare('DELETE FROM zones WHERE zone_id = ?');
$st->execute([$zoneId]); $st->execute([$zoneId]);
return $st->rowCount() > 0; return $st->rowCount() > 0;
} }
@ -89,7 +89,7 @@ final class PdoZoneRepository implements ZoneRepository
string $polygonJson, string $polygonJson,
): string { ): string {
$owns = $this->pdo->prepare( $owns = $this->pdo->prepare(
'SELECT 1 FROM move_zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1' 'SELECT 1 FROM zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1'
); );
$owns->execute([$zoneId, $deviceId, $cameraId]); $owns->execute([$zoneId, $deviceId, $cameraId]);
if ($owns->fetchColumn() === false) { if ($owns->fetchColumn() === false) {
@ -97,7 +97,7 @@ final class PdoZoneRepository implements ZoneRepository
} }
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'UPDATE move_zones SET level = ?, polygon_json = ?, updated_at = ? 'UPDATE zones SET level = ?, polygon_json = ?, updated_at = ?
WHERE zone_id = ? AND device_id = ? AND camera_id = ?' WHERE zone_id = ? AND device_id = ? AND camera_id = ?'
); );
$st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]); $st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]);
@ -108,7 +108,7 @@ final class PdoZoneRepository implements ZoneRepository
// error) or (b) the zone was deleted between the ownership check // error) or (b) the zone was deleted between the ownership check
// above and this UPDATE. Disambiguate with a targeted existence // above and this UPDATE. Disambiguate with a targeted existence
// check instead of assuming either case. // check instead of assuming either case.
$stillExists = $this->pdo->prepare('SELECT 1 FROM move_zones WHERE zone_id = ? LIMIT 1'); $stillExists = $this->pdo->prepare('SELECT 1 FROM zones WHERE zone_id = ? LIMIT 1');
$stillExists->execute([$zoneId]); $stillExists->execute([$zoneId]);
if ($stillExists->fetchColumn() === false) { if ($stillExists->fetchColumn() === false) {
throw new ZoneOwnershipException('zone was deleted concurrently'); throw new ZoneOwnershipException('zone was deleted concurrently');

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface SnapshotRepository
{
public function add(int $eventId, string $fileUuid, string $contentHash): void;
public function forEvent(int $eventId): array;
public function pathByUuid(string $uuid): ?string;
/** The event_id that owns this snapshot uuid, or null. Lets the proxy resolve a
* snapshot's site (via the owning event) for access control. */
public function eventIdByUuid(string $uuid): ?int;
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
* worker images must not be kept forever. Returns the number purged. */
public function purgeOlderThan(int $cutoff): int;
}

View file

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

View file

@ -26,10 +26,6 @@ if (!function_exists('view_url')) {
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;
// Which primary nav tab is active is derived from the page title so no view
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
$navEvents = ($title === '이벤트') ? ' is-active' : '';
$navZones = ($title === '구역 설정') ? ' is-active' : '';
?> ?>
<!doctype html> <!doctype html>
<html lang="ko"> <html lang="ko">
@ -38,24 +34,17 @@ if (!function_exists('render_header')) {
<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="<?= view_url('/assets/app.css') ?>"> <link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
<script> <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>
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) ?>;
window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | 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">
<div class="app-brand">
<span class="live-dot" aria-hidden="true"></span>
<span class="brand-name">이동 감지 관제</span>
</div>
<nav class="app-nav"> <nav class="app-nav">
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a> <a href="<?= view_url('/') ?>">이벤트</a>
<a href="<?= view_url('/zones') ?>" class="app-nav-link<?= $navZones ?>">구역 설정</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 class="who"><?= 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="<?= view_url('/login') ?>">로그인</a> <a href="<?= view_url('/login') ?>">로그인</a>
@ -71,19 +60,6 @@ if (!function_exists('render_footer')) {
function render_footer(): void { function render_footer(): void {
?> ?>
</main> </main>
<script>
/* Logout is wired here in the shared layout, not in a page script: app.js loads only on the
* events page and bails early on any page without #events-root, so previously the 로그아웃 button
* was inert on /zones and event-detail pages (a stale open session on a shared control-room PC). */
(function () {
var b = window.APP_BASE || '';
var btn = document.getElementById('logout-btn');
if (!btn) return;
btn.addEventListener('click', function () {
fetch(b + '/logout', { method: 'POST' }).then(function () { window.location.href = b + '/login'; });
});
})();
</script>
</body> </body>
</html> </html>
<?php <?php

View file

@ -9,7 +9,6 @@ render_header('구역 설정');
<label for="camera-id">카메라 ID</label> <label for="camera-id">카메라 ID</label>
<input type="text" id="camera-id" placeholder="cam-01"> <input type="text" id="camera-id" placeholder="cam-01">
<button type="button" id="zone-load-btn">불러오기</button> <button type="button" id="zone-load-btn">불러오기</button>
<button type="button" id="zone-frame-btn">현재 화면 불러오기</button>
</div> </div>
<div class="zone-controls"> <div class="zone-controls">
<label for="zone-level">등급</label> <label for="zone-level">등급</label>

View file

@ -65,19 +65,6 @@ final class ApiSyncTest extends TestCase
$res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000); $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000);
$this->assertSame(200, $res->status()); $this->assertSame(200, $res->status());
$this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']); $this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']);
$this->assertSame(7, $dev->heartbeats['pi-1']['config_version']);
}
public function testHeartbeatStoresNullConfigVersionWhenNotReported(): void {
// An older edge build sends no config_version. The server must store NULL, not a fake 0 —
// storing 0 made the dashboard's config-drift check fire on every device forever (0 != 1),
// training operators to ignore the device-warning line.
$cfg = Config::fromArray([]);
$dev = new InMemoryDeviceRepository(['pi-1' => 'sek']);
$c = new HeartbeatController($cfg, $dev, new InMemoryNonceRepository());
$body = ['device_id' => 'pi-1', 'fps' => 12.0]; // no config_version reported
$res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000);
$this->assertSame(200, $res->status());
$this->assertNull($dev->heartbeats['pi-1']['config_version']);
} }
public function testHeartbeatRejectsReplayedNonce(): void { public function testHeartbeatRejectsReplayedNonce(): void {
$cfg = Config::fromArray([]); $cfg = Config::fromArray([]);

View file

@ -3,9 +3,8 @@ declare(strict_types=1);
namespace App\Tests; namespace App\Tests;
use App\Http\Request; use App\Http\Request;
use App\Auth\ArraySessionStore; use App\Auth\ArraySessionStore;
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, DeviceStatusController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy};
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository, use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository};
InMemoryDeviceRepository};
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class DashboardTest extends TestCase final class DashboardTest extends TestCase
@ -15,13 +14,13 @@ final class DashboardTest extends TestCase
} }
public function testListRequiresAuth(): void { public function testListRequiresAuth(): void {
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$c = new EventListController($repo, new InMemorySnapshotRepository(), new ArraySessionStore()); $c = new EventListController($repo, new ArraySessionStore());
$this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status()); $this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status());
} }
public function testListReturnsRecent(): void { public function testListReturnsRecent(): void {
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$repo->insert(['severity' => 'high', 'event_type' => 'fall']); $repo->insert(['severity' => 'high', 'event_type' => 'fall']);
$c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed()); $c = new EventListController($repo, $this->authed());
$res = $c->json(new Request('GET', '/api/events', [], '', [], [])); $res = $c->json(new Request('GET', '/api/events', [], '', [], []));
$this->assertSame(200, $res->status()); $this->assertSame(200, $res->status());
$this->assertCount(1, json_decode($res->body(), true)['events']); $this->assertCount(1, json_decode($res->body(), true)['events']);
@ -31,7 +30,7 @@ final class DashboardTest extends TestCase
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 100.0]); // id 1 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 100.0]); // id 1
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 200.0]); // id 2 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 200.0]); // id 2
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3
$c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed()); $c = new EventListController($repo, $this->authed());
// Page 1: the newest two (300, 200); a full page hands back a cursor at the oldest row. // Page 1: the newest two (300, 200); a full page hands back a cursor at the oldest row.
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true); $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true);
$this->assertCount(2, $p1['events']); $this->assertCount(2, $p1['events']);
@ -50,7 +49,7 @@ final class DashboardTest extends TestCase
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123456]); // id 1 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123456]); // id 1
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2 $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2
$c = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed()); $c = new EventListController($repo, $this->authed());
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true); $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true);
$this->assertSame(2, $p1['events'][0]['event_id']); // newest first $this->assertSame(2, $p1['events'][0]['event_id']); // newest first
$p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1', 'cursor' => $p1['next_cursor']], []))->body(), true); $p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1', 'cursor' => $p1['next_cursor']], []))->body(), true);
@ -99,12 +98,12 @@ final class DashboardTest extends TestCase
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1.0, 'site' => 'factory-a']); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1.0, 'site' => 'factory-a']);
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']); $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']);
// A factory-a operator sees only the factory-a event. // A factory-a operator sees only the factory-a event.
$scoped = new EventListController($repo, new InMemorySnapshotRepository(), $this->authedSite('factory-a')); $scoped = new EventListController($repo, $this->authedSite('factory-a'));
$rows = json_decode($scoped->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events']; $rows = json_decode($scoped->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
$this->assertCount(1, $rows); $this->assertCount(1, $rows);
$this->assertSame('factory-a', $rows[0]['site']); $this->assertSame('factory-a', $rows[0]['site']);
// A global operator (no site_scope) sees both. // A global operator (no site_scope) sees both.
$global = new EventListController($repo, new InMemorySnapshotRepository(), $this->authed('operator')); $global = new EventListController($repo, $this->authed('operator'));
$all = json_decode($global->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events']; $all = json_decode($global->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
$this->assertCount(2, $all); $this->assertCount(2, $all);
} }
@ -129,82 +128,14 @@ final class DashboardTest extends TestCase
public function __construct(private string $file, private int $eid, private string $uuid) {} public function __construct(private string $file, private int $eid, private string $uuid) {}
public function add(int $e, string $u, string $h): void {} public function add(int $e, string $u, string $h): void {}
public function forEvent(int $e): array { return []; } public function forEvent(int $e): array { return []; }
public function thumbUuidsFor(array $ids): array { return []; }
public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; } public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; }
public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; } public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; }
public function latestForCamera(string $cameraId): ?array { return null; }
public function purgeOlderThan(int $c): int { return 0; } public function purgeOlderThan(int $c): int { return 0; }
}; };
$cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a')); $cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a'));
$this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status()); $this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status());
$same = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-b')); $same = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-b'));
$served = $same->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid]); $this->assertSame(200, $same->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status());
$this->assertSame(200, $served->status());
// A snapshot is immutable per uuid, so it is browser-cacheable — without this the event
// list re-downloads every visible JPEG on every 5s poll (a Cafe24 traffic-cap hazard).
// `private` keeps a shared proxy from serving one site's worker image to another (PIPA).
$cache = $served->headers()['Cache-Control'] ?? '';
$this->assertStringContainsString('private', $cache);
$this->assertStringContainsString('max-age=', $cache);
@unlink($file); @unlink($file);
} }
public function testEventListAttachesNewestSnapshotThumbnail(): void {
$repo = new InMemoryEventRepository();
$withSnap = $repo->insert(['severity' => 'critical', 'event_type' => 'fall', 'occurred_at' => 2.0]);
$noSnap = $repo->insert(['severity' => 'info', 'event_type' => 'ghost', 'occurred_at' => 1.0]);
$snaps = new InMemorySnapshotRepository();
$snaps->add($withSnap, 'uuidA', 'hashA');
$snaps->add($withSnap, 'uuidB', 'hashB'); // newer -> this one wins
$c = new EventListController($repo, $snaps, $this->authed());
$rows = json_decode($c->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
$byId = [];
foreach ($rows as $r) $byId[$r['event_id']] = $r;
$this->assertSame('uuidB', $byId[$withSnap]['thumb_uuid']); // newest snapshot per event
$this->assertNull($byId[$noSnap]['thumb_uuid']); // event with no snapshot
}
public function testDeviceStatusRequiresAuth(): void {
$c = new DeviceStatusController(new InMemoryDeviceRepository(), new ArraySessionStore());
$this->assertSame(401, $c->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->status());
}
public function testDeviceStatusComputesLivenessAndDrift(): void {
$now = 10000;
$dev = new InMemoryDeviceRepository([], [], [], [
['device_id' => 'pi-online', 'name' => 'A동', 'site' => null, 'last_heartbeat_at' => $now - 30,
'last_fps' => 12.0, 'last_temp' => 55.0, 'last_throttle' => false, 'config_version' => 3, 'reported_config_version' => 3],
['device_id' => 'pi-offline', 'name' => 'B동', 'site' => null, 'last_heartbeat_at' => $now - 600,
'last_fps' => 0.0, 'last_temp' => 0.0, 'last_throttle' => true, 'config_version' => 1, 'reported_config_version' => 1],
['device_id' => 'pi-new', 'name' => null, 'site' => null, 'last_heartbeat_at' => null,
'last_fps' => null, 'last_temp' => null, 'last_throttle' => null, 'config_version' => 1, 'reported_config_version' => null],
['device_id' => 'pi-drift', 'name' => 'C동', 'site' => null, 'last_heartbeat_at' => $now - 10,
'last_fps' => 10.0, 'last_temp' => 50.0, 'last_throttle' => false, 'config_version' => 5, 'reported_config_version' => 4],
]);
$out = json_decode((new DeviceStatusController($dev, $this->authed()))
->json(new Request('GET', '/api/devices', [], '', [], []), $now)->body(), true)['devices'];
$by = [];
foreach ($out as $d) $by[$d['device_id']] = $d;
$this->assertTrue($by['pi-online']['online']);
$this->assertSame(30, $by['pi-online']['seconds_since']);
$this->assertFalse($by['pi-offline']['online']); // 600s past the 120s threshold
$this->assertFalse($by['pi-new']['online']); // never reported
$this->assertNull($by['pi-new']['seconds_since']);
$this->assertFalse($by['pi-online']['config_drift']);
$this->assertTrue($by['pi-drift']['config_drift']); // device on v4, server published v5
}
public function testDeviceStatusIsScopedToSite(): void {
$row = fn (string $id, string $site) => ['device_id' => $id, 'name' => null, 'site' => $site,
'last_heartbeat_at' => null, 'last_fps' => null, 'last_temp' => null, 'last_throttle' => null,
'config_version' => 1, 'reported_config_version' => null];
$dev = new InMemoryDeviceRepository([], [], [], [$row('a1', 'factory-a'), $row('b1', 'factory-b')]);
$scoped = json_decode((new DeviceStatusController($dev, $this->authedSite('factory-a')))
->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->body(), true)['devices'];
$this->assertCount(1, $scoped);
$this->assertSame('a1', $scoped[0]['device_id']);
$all = json_decode((new DeviceStatusController($dev, $this->authed('operator')))
->json(new Request('GET', '/api/devices', [], '', [], []), 1000)->body(), true)['devices'];
$this->assertCount(2, $all);
}
} }

View file

@ -109,10 +109,8 @@ final class IngestControllerTest extends TestCase
$throwingSnaps = new class implements \App\Repository\SnapshotRepository { $throwingSnaps = new class implements \App\Repository\SnapshotRepository {
public function add(int $e, string $u, string $h): void { throw new \RuntimeException('db down'); } public function add(int $e, string $u, string $h): void { throw new \RuntimeException('db down'); }
public function forEvent(int $e): array { return []; } public function forEvent(int $e): array { return []; }
public function thumbUuidsFor(array $ids): array { return []; }
public function pathByUuid(string $u): ?string { return null; } public function pathByUuid(string $u): ?string { return null; }
public function eventIdByUuid(string $u): ?int { return null; } public function eventIdByUuid(string $u): ?int { return null; }
public function latestForCamera(string $cameraId): ?array { return null; }
public function purgeOlderThan(int $c): int { return 0; } public function purgeOlderThan(int $c): int { return 0; }
}; };
$events = new InMemoryEventRepository(); $events = new InMemoryEventRepository();
@ -127,7 +125,7 @@ final class IngestControllerTest extends TestCase
public function testDeduplicatesRepeatedEventUuid(): void { public function testDeduplicatesRepeatedEventUuid(): void {
// A POST that succeeded server-side but timed out client-side is re-sent with the SAME // A POST that succeeded server-side but timed out client-side is re-sent with the SAME
// event_uuid (fresh nonce). The server must return the already-stored event, not duplicate. // event_uuid (fresh nonce). The cloud must return the already-stored event, not duplicate.
[$c, $events] = $this->make(); [$c, $events] = $this->make();
$uuid = str_repeat('a', 32); $uuid = str_repeat('a', 32);
$p = $this->payload(); $p['event_uuid'] = $uuid; $p = $this->payload(); $p['event_uuid'] = $uuid;

View file

@ -148,12 +148,12 @@ final class PdoIntegrationTest extends TestCase
private static function sqliteSchema(): string private static function sqliteSchema(): string
{ {
return <<<'SQL' return <<<'SQL'
CREATE TABLE move_nonces ( CREATE TABLE nonces (
nonce TEXT PRIMARY KEY, nonce TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL
); );
CREATE TABLE move_events ( CREATE TABLE events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uuid TEXT UNIQUE, event_uuid TEXT UNIQUE,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
@ -171,7 +171,7 @@ final class PdoIntegrationTest extends TestCase
received_at INTEGER NOT NULL received_at INTEGER NOT NULL
); );
CREATE TABLE move_zones ( CREATE TABLE zones (
zone_id TEXT PRIMARY KEY, zone_id TEXT PRIMARY KEY,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
camera_id TEXT NOT NULL, camera_id TEXT NOT NULL,

View file

@ -7,23 +7,12 @@ use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
final class InMemoryDeviceRepository implements DeviceRepository { final class InMemoryDeviceRepository implements DeviceRepository {
public array $heartbeats = []; public array $heartbeats = [];
// $statusRows: full device rows for listStatus() (the status panel tests populate it). Kept a
// trailing optional arg so the many existing 3-arg call sites are unaffected.
public function __construct(private array $secrets = [], private array $versions = [], public function __construct(private array $secrets = [], private array $versions = [],
private array $sites = [], public array $statusRows = []) {} private array $sites = []) {}
public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; } public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; }
public function exists(string $d): bool {
// A device is "known" if it appears in any provisioned map the test set up.
return isset($this->secrets[$d]) || isset($this->sites[$d]) || isset($this->versions[$d]);
}
public function siteFor(string $d): ?string { return $this->sites[$d] ?? null; } public function siteFor(string $d): ?string { return $this->sites[$d] ?? null; }
public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; } public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
public function configVersion(string $d): int { return $this->versions[$d] ?? 1; } public function configVersion(string $d): int { return $this->versions[$d] ?? 1; }
public function listStatus(?string $site): array {
$rows = $this->statusRows;
if ($site !== null) $rows = array_filter($rows, fn($d) => ($d['site'] ?? null) === $site);
return array_values($rows);
}
} }
final class InMemoryNonceRepository implements NonceRepository { final class InMemoryNonceRepository implements NonceRepository {
public array $store = []; public array $store = [];
@ -71,30 +60,11 @@ final class InMemorySnapshotRepository implements SnapshotRepository {
$this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time();
} }
public function forEvent(int $e): array { return $this->rows[$e] ?? []; } public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
public function thumbUuidsFor(array $eventIds): array {
$out = [];
foreach ($eventIds as $e) {
$list = $this->rows[(int)$e] ?? [];
if ($list !== []) $out[(int)$e] = end($list)['file_uuid']; // newest = last appended
}
return $out;
}
public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; } public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
public function eventIdByUuid(string $u): ?int { public function eventIdByUuid(string $u): ?int {
foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e; foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e;
return null; return null;
} }
/** @var array<string,string> file_uuid => camera_id, recorded by the tests that need it */
public array $cameras = [];
/** @var array<string,string> file_uuid => device_id */
public array $devices = [];
public function latestForCamera(string $cameraId): ?array {
// Insert order, newest first -- mirrors the Pdo impl's ORDER BY snapshot_id DESC.
foreach (array_reverse($this->cameras, true) as $uuid => $cam) {
if ($cam === $cameraId) return ['file_uuid' => $uuid, 'device_id' => $this->devices[$uuid] ?? ''];
}
return null;
}
public function purgeOlderThan(int $cutoff): int { public function purgeOlderThan(int $cutoff): int {
$n = 0; $n = 0;
foreach ($this->created as $uuid => $ts) { foreach ($this->created as $uuid => $ts) {

View file

@ -5,7 +5,7 @@ use App\Http\Request;
use App\Auth\ArraySessionStore; use App\Auth\ArraySessionStore;
use App\Geometry\Polygon; use App\Geometry\Polygon;
use App\Dashboard\ZoneConfigController; use App\Dashboard\ZoneConfigController;
use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository, InMemorySnapshotRepository}; use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class ZoneConfigTest extends TestCase final class ZoneConfigTest extends TestCase
@ -15,12 +15,9 @@ final class ZoneConfigTest extends TestCase
if ($site !== null) $s->set('site_scope', $site); if ($site !== null) $s->set('site_scope', $site);
return $s; return $s;
} }
/** Device repo mapping the fixture devices to their sites (empty = every device unscoped). /** Device repo mapping the fixture devices to their sites (empty = every device unscoped). */
* Every fixture device is registered (via secrets) so exists() is true — a zone save now
* requires a real device, so the fixtures must model provisioned devices. */
private function devices(array $sites = []): InMemoryDeviceRepository { private function devices(array $sites = []): InMemoryDeviceRepository {
$ids = array_unique(array_merge(['pi-1', 'pi-2'], array_keys($sites))); return new InMemoryDeviceRepository([], [], $sites);
return new InMemoryDeviceRepository(array_fill_keys($ids, 's'), [], $sites);
} }
public function testPolygonValidation(): void { public function testPolygonValidation(): void {
$this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]])); $this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
@ -31,13 +28,13 @@ final class ZoneConfigTest extends TestCase
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []); return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
} }
public function testSaveRequiresPermission(): void { public function testSaveRequiresPermission(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer'), new InMemorySnapshotRepository()); $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer'));
$res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
$this->assertSame(403, $res->status()); $this->assertSame(403, $res->status());
} }
public function testSaveValidZone(): void { public function testSaveValidZone(): void {
$repo = new InMemoryZoneRepository(); $repo = new InMemoryZoneRepository();
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
$this->assertSame(201, $res->status()); $this->assertSame(201, $res->status());
$this->assertCount(1, $repo->forCamera('cam-1')); $this->assertCount(1, $repo->forCamera('cam-1'));
@ -53,7 +50,7 @@ final class ZoneConfigTest extends TestCase
public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); } public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); }
public function delete(string $id): bool { return true; } public function delete(string $id): bool { return true; }
}; };
$c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1', $res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1',
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
$this->assertSame(400, $res->status()); $this->assertSame(400, $res->status());
@ -68,34 +65,19 @@ final class ZoneConfigTest extends TestCase
public function save(array $z): string { throw new \PDOException('server has gone away'); } public function save(array $z): string { throw new \PDOException('server has gone away'); }
public function delete(string $id): bool { return true; } public function delete(string $id): bool { return true; }
}; };
$c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$this->expectException(\PDOException::class); $this->expectException(\PDOException::class);
$c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1',
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
} }
public function testSaveRejectsBlankOrUnknownDevice(): void {
// A zone is delivered to the edge only via /api/config -> forDevice($deviceId); a zone saved
// with a blank or mistyped device_id is enforced by nobody, so the save must be refused
// (400) rather than confirmed to the operator and rendered as if it were active.
$repo = new InMemoryZoneRepository();
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository());
$poly = [[0,0],[9,0],[9,9]];
// 'ghost-pi' is not among the provisioned devices (pi-1/pi-2).
$unknown = $c->save($this->saveReq(['device_id' => 'ghost-pi', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => $poly]));
$this->assertSame(400, $unknown->status());
// Blank device_id (the operator left the field empty) is rejected too.
$blank = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => $poly]));
$this->assertSame(400, $blank->status());
$this->assertCount(0, $repo->forCamera('cam-1')); // nothing was persisted
}
public function testSaveRejectsBadLevelOrPolygon(): void { public function testSaveRejectsBadLevelOrPolygon(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status());
} }
public function testSaveRejectsMissingOrWrongCsrf(): void { public function testSaveRejectsMissingOrWrongCsrf(): void {
$repo = new InMemoryZoneRepository(); $repo = new InMemoryZoneRepository();
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]; $body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]];
// missing header // missing header
$this->assertSame(403, $c->save($this->saveReq($body, []))->status()); $this->assertSame(403, $c->save($this->saveReq($body, []))->status());
@ -107,7 +89,7 @@ final class ZoneConfigTest extends TestCase
public function testDeleteRejectsMissingOrWrongCsrf(): void { public function testDeleteRejectsMissingOrWrongCsrf(): void {
$repo = new InMemoryZoneRepository(); $repo = new InMemoryZoneRepository();
$repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); $repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']);
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
// missing header // missing header
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []); $missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status()); $this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
@ -133,7 +115,7 @@ final class ZoneConfigTest extends TestCase
} }
private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController { private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController {
return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']), return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
new InMemoryAuditRepository(), $this->authed('operator', $site), new InMemorySnapshotRepository()); new InMemoryAuditRepository(), $this->authed('operator', $site));
} }
private function zonesOf($res): array { private function zonesOf($res): array {
return json_decode($res->body(), true)['zones']; return json_decode($res->body(), true)['zones'];
@ -170,56 +152,13 @@ final class ZoneConfigTest extends TestCase
public function testGlobalOperatorSeesEverySite(): void { public function testGlobalOperatorSeesEverySite(): void {
$repo = $this->seededRepo(); $repo = $this->seededRepo();
$c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']), $c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
new InMemoryAuditRepository(), $this->authed('operator', null), new InMemorySnapshotRepository()); // no site_scope = global new InMemoryAuditRepository(), $this->authed('operator', null)); // no site_scope = global
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']); $res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
$this->assertCount(1, $this->zonesOf($res)); $this->assertCount(1, $this->zonesOf($res));
} }
public function testListRequiresAuthenticatedRole(): void { public function testListRequiresAuthenticatedRole(): void {
$s = new ArraySessionStore(); // no user_id $s = new ArraySessionStore(); // no user_id
$c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s, new InMemorySnapshotRepository()); $c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s);
$this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status()); $this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status());
} }
// --- latest-snapshot background for the zone editor -------------------------------
private function snapsWith(array $map, array $devices = []): InMemorySnapshotRepository {
$r = new InMemorySnapshotRepository();
$r->cameras = $map; $r->devices = $devices;
return $r;
}
private function snapReq(): Request { return new Request('GET', '/api/cameras/cam-a/latest-snapshot', [], '', [], []); }
public function testLatestSnapshotReturnsTheNewestUuidForTheCamera(): void {
$uuidOld = str_repeat('a', 32); $uuidNew = str_repeat('b', 32);
$snaps = $this->snapsWith([$uuidOld => 'cam-a', $uuidNew => 'cam-a'], [$uuidOld => 'pi-1', $uuidNew => 'pi-1']);
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(),
$this->authed('operator'), $snaps);
$res = $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a']);
$this->assertSame(200, $res->status());
$this->assertSame($uuidNew, json_decode($res->body(), true)['uuid']);
}
public function testLatestSnapshotIs404WhenTheCameraHasNoSnapshotYet(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(),
$this->authed('operator'), new InMemorySnapshotRepository());
$this->assertSame(404, $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a'])->status());
}
public function testLatestSnapshotRequiresAuthentication(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(),
new ArraySessionStore(), new InMemorySnapshotRepository());
$this->assertSame(401, $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a'])->status());
}
public function testScopedOperatorCannotSeeAnotherSitesCameraFrame(): void {
// The frame is a worker image: leaking it across sites would defeat the whole scope guard.
$uuid = str_repeat('c', 32);
$snaps = $this->snapsWith([$uuid => 'cam-b'], [$uuid => 'pi-b']);
$devices = $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']);
$mine = new ZoneConfigController(new InMemoryZoneRepository(), $devices, new InMemoryAuditRepository(),
$this->authed('operator', 'factory-a'), $snaps);
$this->assertSame(404, $mine->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status());
$theirs = new ZoneConfigController(new InMemoryZoneRepository(), $devices, new InMemoryAuditRepository(),
$this->authed('operator', 'factory-b'), $snaps);
$this->assertSame(200, $theirs->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status());
}
} }

View file

@ -3,7 +3,7 @@
Offline-installable bundle + systemd unit for the `move-detect` edge daemon. Offline-installable bundle + systemd unit for the `move-detect` edge daemon.
The bundle is fully self-contained (all wheels + pose model + source): the Pi The bundle is fully self-contained (all wheels + pose model + source): the Pi
performs **zero network fetches** at install or run time. The only runtime performs **zero network fetches** at install or run time. The only runtime
network use is the server POST / config-poll / heartbeat, and even that tolerates network use is the cloud POST / config-poll / heartbeat, and even that tolerates
being offline (events buffer in the local sqlite queue and flush on reconnect). being offline (events buffer in the local sqlite queue and flush on reconnect).
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the > Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
@ -23,41 +23,19 @@ being offline (events buffer in the local sqlite queue and flush on reconnect).
## Quick start — Pi with internet (one command) ## Quick start — Pi with internet (one command)
If the Pi itself has internet, skip the offline bundle entirely. Copy the built delivery If the Pi itself has internet, skip the offline bundle entirely. Copy this repo onto
folder onto the Pi — **copy the working tree, not a fresh `git clone`**: the pose model the Pi (git clone or scp), then:
`models/yolov8s_pose.hef` is git-ignored (a build artifact), so a clone arrives without it.
Use `scp -r`/USB from a build machine that has already staged `models/` (see step 1). Then:
```bash ```bash
sudo deploy/setup_online.sh sudo deploy/setup_online.sh
``` ```
It installs the system packages (`python3-picamera2`, and `hailo-all` if present), builds It installs the system packages (`python3-picamera2`, and `hailo-all` if present),
the venv, pip-installs the runtime deps, **places the pose model from the delivery's builds the venv, pip-installs the runtime deps, fetches the MoveNet model, writes
`models/`** (it does NOT download models on the Pi — the HEF/ONNX are fetched on the build `/etc/move-detect/edge.env` (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` /
machine per the air-gap rule, `build pulls, target doesn't`), writes `/etc/move-detect/edge.env` `CAMERAS`, or take them from env vars), installs the systemd unit, and starts the service.
(prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `CAMERAS`, or take them from env It begins on CPU MoveNet; drop a `yolov8s_pose.hef` in and pass `HAILO_HEF=...` (or edit
vars), probes every camera, installs the systemd unit, and starts the service. It uses Hailo `edge.env`) to switch to Hailo. The rest of this doc is the **air-gapped** path.
when `models/yolov8s_pose.hef` is present (or pass `HAILO_HEF=/path/to.hef`), else CPU MoveNet
when `models/movenet_lightning.onnx` + onnxruntime are present. The rest of this doc is the
**air-gapped** path.
### Secret handling (`deploy/device.env`)
If you pre-provision with `deploy/device.env`, that file holds this device's **HMAC secret in
plaintext**. It is gitignored (never committed), but it rides onto the Pi with the "copy the
working tree" transfer. After a successful install:
1. **Delete it:** `shred -u deploy/device.env` (the live secret now lives only in
`/etc/move-detect/edge.env`, `0640 root:movedetect`). The installer already `chmod 600`s it
while running, but that is defense-in-depth, not a substitute for removing it.
2. **Rotate if it was ever exposed** (shared over chat, a shell history, a shared drive):
update `move_devices.secret` on the server and the `DEVICE_SECRET` in the Pi's
`/etc/move-detect/edge.env`, then restart the service. A leaked secret lets anyone forge
signed events/heartbeats for this device.
Prefer `device.env` over passing `DEVICE_SECRET=...` on the `sudo` command line, which leaves the
secret in the shell history.
--- ---
@ -77,18 +55,16 @@ wheel tags may not resolve; if it fails, build natively):
deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311 deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311
``` ```
Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled Output: `dist/move-detect-edge-bundle-aarch64-cp311.tar.gz` (+ `MANIFEST.txt`
wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step. listing the bundled wheels). This single file crosses the air gap.
Set `MAKE_TARBALL=1` if you would rather transfer one
`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file.
## 2. Transfer + install (on the air-gapped Pi) ## 2. Transfer + install (on the air-gapped Pi)
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then: Copy the tarball across the gap (USB, one-way transfer, etc.), then:
```bash ```bash
# tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz tar xzf move-detect-edge-bundle-aarch64-cp311.tar.gz
cd 2_pi_install cd move-detect-edge-bundle
sudo ./install_offline.sh sudo ./install_offline.sh
``` ```
@ -105,8 +81,8 @@ sudo systemctl enable --now move-detect-edge
journalctl -u move-detect-edge -f journalctl -u move-detect-edge -f
``` ```
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see `DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the cloud (see
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled `cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
model (`/opt/move-detect/models/movenet_lightning.onnx`). model (`/opt/move-detect/models/movenet_lightning.onnx`).
## 4. Verify the air gap (do this before trusting a build) ## 4. Verify the air gap (do this before trusting a build)
@ -117,7 +93,7 @@ model (`/opt/move-detect/models/movenet_lightning.onnx`).
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 + - [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
numpy + requests all import from the venv). numpy + requests all import from the venv).
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows - [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
frames processed and (if the server is reachable) heartbeats. frames processed and (if the cloud is reachable) heartbeats.
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore - [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
→ the queue flushes. (End-to-end behavior verified in the edge e2e harness.) → the queue flushes. (End-to-end behavior verified in the edge e2e harness.)
@ -149,14 +125,14 @@ validated). USB webcam stays available anytime via `CAMERA_BACKEND=uvc`.
**Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several **Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several
cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share
the Hailo device (round-robin) and the offline queue, and server zones are routed to the the Hailo device (round-robin) and the offline queue, and cloud zones are routed to the
matching camera. Example — CSI + USB together: matching camera. Example — CSI + USB together:
```ini ```ini
CAMERAS=csi:0:cam-01,uvc:0:cam-02 CAMERAS=csi:0:cam-01,uvc:0:cam-02
``` ```
Each `camera_id` must match the `camera_id` on that camera's zones in the server. Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
> **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't > **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't
> carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer > carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer
@ -165,7 +141,7 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the server
## Not bundled (by design) ## Not bundled (by design)
- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`. - **MySQL / the cloud app** — a separate host; see `cloud/docs/cloud-deploy.md`.
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled - **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above); HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
the default CPU MoveNet + USB path needs none of them. the default CPU MoveNet + USB path needs none of them.

View file

@ -20,7 +20,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}" OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/2_pi_install" STAGE="$OUT_DIR/move-detect-edge-bundle"
WHEELHOUSE="$STAGE/wheelhouse" WHEELHOUSE="$STAGE/wheelhouse"
PYVER="${PYVER:-3.11}" # Pi OS Bookworm ships CPython 3.11 PYVER="${PYVER:-3.11}" # Pi OS Bookworm ships CPython 3.11
PYABI="cp${PYVER//./}" # cp311 PYABI="cp${PYVER//./}" # cp311
@ -187,17 +187,9 @@ else
fi fi
echo ">> [6/6] packaging tarball" echo ">> [6/6] packaging tarball"
if [ "${MAKE_TARBALL:-0}" != "1" ]; then
# Default: leave $STAGE uncompressed — copy the folder to the Pi (USB/scp) and run
# install_offline.sh inside it. Set MAKE_TARBALL=1 for a single transferable file.
echo ">> skipped (MAKE_TARBALL=1 to also produce a .tar.gz)"
echo ">> DONE: $STAGE"
echo ">> copy this folder to the Pi, then run ./install_offline.sh inside it."
exit 0
fi
# Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were # Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were
# otherwise identically named, so the wrong one could be shipped to a board and crash-loop. # otherwise identically named, so the wrong one could be shipped to a board and crash-loop.
TARBALL="$OUT_DIR/2_pi_install-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz" TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz"
tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install" tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle"
echo ">> DONE: $TARBALL" echo ">> DONE: $TARBALL"
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi." echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."

View file

@ -136,7 +136,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
echo ">> [4/6] verify the edge package imports offline" echo ">> [4/6] verify the edge package imports offline"
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level # cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
# on the unconditional startup chain; the daemon's server clients import requests at startup). # 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')" 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 # 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 # ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
@ -226,11 +226,6 @@ systemctl daemon-reload
echo "" echo ""
echo "Installed. Next:" echo "Installed. Next:"
echo " 1. edit $ETC/edge.env (device id/secret, server URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)" echo " 1. edit $ETC/edge.env (device id/secret, cloud URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
echo " 2. camera check BEFORE enabling — a wrong /dev/video index or an unseated CSI ribbon" echo " 2. sudo systemctl enable --now move-detect-edge"
echo " otherwise yields a green unit that silently monitors one camera less (build_runtimes" echo " 3. journalctl -u move-detect-edge -f"
echo " logs-and-skips a camera it cannot open). This mirrors setup_online.sh's [8/9] probe,"
echo " which the offline path cannot run at install time (CAMERAS is only set in step 1):"
echo " sudo sh -c 'cd $PREFIX/src && CAMERAS=\$(sed -n \"s/^CAMERAS=//p\" $ETC/edge.env) $PREFIX/venv/bin/python -m edge.probe'"
echo " 3. sudo systemctl enable --now move-detect-edge"
echo " 4. journalctl -u move-detect-edge -f"

View file

@ -5,18 +5,12 @@
# Run from a clone/copy of this repo, as root: # Run from a clone/copy of this repo, as root:
# sudo deploy/setup_online.sh # sudo deploy/setup_online.sh
# #
# Pre-provisioned (shortest path): put the values in deploy/device.env on the BUILD machine # Non-interactive: pass values as env vars, e.g.
# (DEVICE_ID / DEVICE_SECRET / CAFE24_URL / CAMERAS, one KEY=value per line, gitignored), copy # sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
# the repo to the Pi, and the whole install is just: # HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh
# sudo 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:
# Non-interactive: pass values as env vars (these override device.env), e.g. # sudo HAILO_ARCH=hailo8 deploy/setup_online.sh # 26 TOPS AI HAT+ (hailo8l = 13 TOPS AI Kit)
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://server CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
# deploy/setup_online.sh
# MODELS ARE NOT DOWNLOADED HERE. The pose HEF (and optional MoveNet ONNX) ship IN the delivery,
# staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh) — air-gap rule "build pulls,
# target doesn't". Copy the delivery (with its models/) to the Pi and this installer places them.
# Have a HEF elsewhere on the Pi? Point at it: HAILO_HEF=/home/pi/yolov8s_pose.hef.
# Interactive (a real terminal): it prompts for anything not passed. # Interactive (a real terminal): it prompts for anything not passed.
set -euo pipefail set -euo pipefail
@ -31,12 +25,12 @@ STATE=/var/lib/move-detect
ETC=/etc/move-detect ETC=/etc/move-detect
SVC_USER=movedetect SVC_USER=movedetect
echo ">> [1/9] 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
# A freshly-booted Pi runs apt-daily / apt-daily-upgrade / unattended-upgrades, which hold # A freshly-booted Pi runs apt-daily / apt-daily-upgrade / unattended-upgrades, which hold
# /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend for the first few minutes — # /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 # exactly when someone runs a one-command installer. A bare apt-get then exits 100 with
# "E: Unable to lock directory /var/lib/apt/lists/" and `set -e` (set -e above) kills us here. # "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 # -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 # `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. That wait has to live here, in the shell.
@ -52,7 +46,7 @@ apt_retry() {
# renders as "%s 디렉터리를 잠글 수 없습니다", the grep below misses it, and lock # renders as "%s 디렉터리를 잠글 수 없습니다", the grep below misses it, and lock
# contention gets misread as a real error — fail-fast, zero retries, exactly the bug # 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 # this function exists to fix. Pinning it per-call (not script-wide) keeps pip/venv and
# fetch_movenet at [4/9]-[5/9] on the system locale. # 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]} 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 [ "$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 if ! grep -qE 'Could not get lock|Unable to lock|Unable to acquire the dpkg' "$log"; then
@ -74,13 +68,13 @@ apt_retry install -y python3-picamera2 python3-venv python3-pip
# 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_retry 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/9] 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"
for g in video render plugdev hailo; do for g in video render plugdev hailo; do
getent group "$g" >/dev/null 2>&1 && usermod -aG "$g" "$SVC_USER" || true getent group "$g" >/dev/null 2>&1 && usermod -aG "$g" "$SVC_USER" || true
done done
echo ">> [3/9] source + dirs under $PREFIX" echo ">> [3/8] source + dirs under $PREFIX"
install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs" install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs"
rm -rf "$PREFIX/src/edge" # full replace on re-run (no nested edge/edge) rm -rf "$PREFIX/src/edge" # full replace on re-run (no nested edge/edge)
cp -r "$REPO_ROOT/edge" "$PREFIX/src/edge" cp -r "$REPO_ROOT/edge" "$PREFIX/src/edge"
@ -88,14 +82,14 @@ cp "$REPO_ROOT/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true
find "$PREFIX/src" -name __pycache__ -type d -prune -exec rm -rf {} + find "$PREFIX/src" -name __pycache__ -type d -prune -exec rm -rf {} +
cp "$REPO_ROOT/docs/pilot-runbook.md" "$PREFIX/docs/" 2>/dev/null || true cp "$REPO_ROOT/docs/pilot-runbook.md" "$PREFIX/docs/" 2>/dev/null || true
echo ">> [4/9] 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
# MANDATORY for every backend — do not soften these to `|| echo WARN`: # MANDATORY for every backend — do not soften these to `|| echo WARN`:
# cv2 edge/snapshot.py imports it at MODULE level, and that module is on the # cv2 edge/snapshot.py imports it at MODULE level, and that module is on the
# unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND # unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND
# can reach build_pose() without it. # can reach build_pose() without it.
# requests the daemon builds its server/alert clients with no injected HTTP fn, so # requests the daemon builds its cloud/alert clients with no injected HTTP fn, so
# `import requests` fires at startup regardless of backend. # `import requests` fires at startup regardless of backend.
# numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives # 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 # transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless
@ -107,25 +101,20 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
# MoveNetPoseAdapter.__init__, and the Hailo path never imports the module at all. It # 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 # 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 # and exit non-zero in seconds — which under `set -euo pipefail` used to kill this installer
# on a Hailo-8 Pi that never needed it. Best-effort, like hailo-all at [1/9]; [6/9] decides # 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. # whether a backend actually exists.
"$PREFIX/venv/bin/pip" install onnxruntime \ "$PREFIX/venv/bin/pip" install onnxruntime \
|| echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)." || echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)."
echo ">> [5/9] MoveNet CPU model (from the delivery, not downloaded)" echo ">> [5/8] MoveNet CPU model"
# Models ship IN the delivery, staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh # Best-effort for the same reason: this model is inert without onnxruntime, and a download
# fetches them there) and carried across with the repo copy — air-gap rule "build pulls, target # hiccup must not kill an install whose backend is Hailo. fetch_movenet is pure stdlib, so it
# doesn't". This installer NEVER downloads a model on the Pi. On-device fetch was removed because # runs fine with onnxruntime absent.
# it was fragile in two ways at once: a partial repo copy made `-m edge.tools.fetch_*` fail with a ( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet ) \
# ModuleNotFoundError that the `|| echo WARN` mislabeled as "download failed", and the MoveNet URL || echo " WARN: MoveNet ONNX download failed — CPU MoveNet unavailable (Hailo unaffected)."
# is dead upstream anyway. MoveNet is the OPTIONAL CPU fallback; stage it only if present. cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true
if cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null; then
echo " -> MoveNet ONNX staged from the delivery"
else
echo " note: no MoveNet ONNX in the delivery (expected for a Hailo device)."
fi
echo ">> [6/9] pose backend" echo ">> [6/8] pose backend"
HEF_DEST="$PREFIX/models/yolov8s_pose.hef" HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx" ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
if [ -n "${HAILO_HEF:-}" ]; then if [ -n "${HAILO_HEF:-}" ]; then
@ -136,20 +125,29 @@ if [ -n "${HAILO_HEF:-}" ]; then
[ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; } [ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; }
cp "$HAILO_HEF" "$HEF_DEST" cp "$HAILO_HEF" "$HEF_DEST"
echo " -> HEF installed from HAILO_HEF=$HAILO_HEF" echo " -> HEF installed from HAILO_HEF=$HAILO_HEF"
elif [ -f "$REPO_ROOT/models/yolov8s_pose.hef" ]; then
# The delivery carries the board-matching HEF, staged on the BUILD machine
# (build_offline_bundle.sh runs fetch_hailo_hef there, where hailortcli or --arch picks
# hailo8 vs hailo8l). This installer only PLACES it — it never downloads or guesses an arch
# on the Pi. Air-gap rule: build pulls, target doesn't.
cp "$REPO_ROOT/models/yolov8s_pose.hef" "$HEF_DEST"
echo " -> HEF staged from the delivery (models/yolov8s_pose.hef)"
else else
echo " WARN: no Hailo HEF in the delivery (models/yolov8s_pose.hef) and HAILO_HEF unset —" # No HEF supplied: fetch the one matching THIS board. Nothing in this repo could previously
echo " no Hailo backend. The probe below decides if CPU MoveNet can carry the install." # 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 fi
# Probe what can ACTUALLY run, in the venv the service will use. A HEF on disk is not enough # Probe what can ACTUALLY run, in the venv the service will use. A HEF on disk is not enough
# (hailo_platform comes from apt hailo-all, best-effort at [1/9]), and a pip exit code is not # (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 # 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. # 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; } pyhas() { "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; }
@ -183,17 +181,17 @@ else
echo "" >&2 echo "" >&2
echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2 echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2
echo "" >&2 echo "" >&2
echo " A) Hailo-8 HAT (the intended path). The HEF ships IN the delivery" >&2 echo " A) Hailo-8 HAT (the intended path). The installer downloads the matching HEF" >&2
echo " (models/yolov8s_pose.hef, staged on the build machine) — this installer does NOT" >&2 echo " ITSELF — you do not need to supply one. It only needs the board to answer:" >&2
echo " download it. Read the table above:" >&2
echo " HEF=MISSING -> this Pi's copy of the delivery has no" >&2
echo " models/yolov8s_pose.hef. Re-copy it, or point at one:" >&2
echo " sudo HAILO_HEF=/path/to/yolov8s_pose.hef deploy/setup_online.sh" >&2
echo " hailo_platform=MISSING -> the HailoRT driver/runtime is not up yet:" >&2
echo " sudo apt-get install -y hailo-all && sudo reboot" >&2 echo " sudo apt-get install -y hailo-all && sudo reboot" >&2
echo " (/dev/hailo0 appears only after that reboot). Confirm:" >&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 " hailortcli fw-control identify # -> Device Architecture: HAILO8" >&2
echo " B) CPU MoveNet: put models/movenet_lightning.onnx in the delivery, and" >&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 " $PREFIX/venv/bin/pip install onnxruntime" >&2
echo "" >&2 echo "" >&2
echo " If onnxruntime AND hailo-all both refuse to install, check the userland arch:" >&2 echo " If onnxruntime AND hailo-all both refuse to install, check the userland arch:" >&2
@ -203,13 +201,13 @@ else
exit 1 exit 1
fi fi
echo ">> [7/9] 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-running IS the normal Hailo flow: install -> reboot so the driver creates /dev/hailo0
# -> re-run. So [6/9] can legitimately reach a different verdict than last time. Leaving the # -> 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/9] printed # 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 # "POSE_BACKEND=hailo" — and with onnxruntime absent that is a ModuleNotFoundError crash
# loop the installer just called a success. # loop the installer just called a success.
# Reconcile ONLY the backend keys, in place: DEVICE_SECRET and the operator's other answers # Reconcile ONLY the backend keys, in place: DEVICE_SECRET and the operator's other answers
@ -232,27 +230,10 @@ if [ -f "$ETC/edge.env" ]; then
echo " -> $ETC/edge.env: POSE_BACKEND ${cur:-<unset>} -> $POSE_BACKEND (re-run reconciled)" echo " -> $ETC/edge.env: POSE_BACKEND ${cur:-<unset>} -> $POSE_BACKEND (re-run reconciled)"
fi fi
else else
# Pre-provisioning file: values written on the BUILD machine so the operator only has to
# type `sudo deploy/setup_online.sh`. A real env var always wins over the file, so the
# long-form command documented at the top still overrides a stale device.env.
PROVISION="${PROVISION:-$REPO_ROOT/deploy/device.env}"
if [ -f "$PROVISION" ]; then
echo ">> reading pre-provisioned values from $PROVISION"
# device.env holds the plaintext HMAC secret. It arrived via "copy the working tree" and may
# be world-readable (umask 022). Lock it to root-only now so it is not left readable by every
# local account for the life of the device. The secret still lands in /etc/move-detect/edge.env
# (0640 root:movedetect); once the install succeeds you should DELETE this file and rotate the
# secret if it was ever exposed — see deploy/README.md "Secret handling".
chmod 600 "$PROVISION" 2>/dev/null || true
while IFS='=' read -r k v; do
case "$k" in DEVICE_ID|DEVICE_SECRET|CAFE24_URL|CAMERAS) ;; *) continue ;; esac
[ -n "${!k:-}" ] || printf -v "$k" '%s' "$v"
done < <(grep -E '^[A-Z_][A-Z0-9_]*=' "$PROVISION") # digits matter: CAFE24_URL
fi
: "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}" : "${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 server): " DEVICE_SECRET; echo; } [ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; }
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https://<domain>/move_detect — include the subdir!): " 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
@ -273,35 +254,11 @@ TARGET_FPS=12
EOF EOF
chown root:"$SVC_USER" "$ETC/edge.env" chown root:"$SVC_USER" "$ETC/edge.env"
chmod 0640 "$ETC/edge.env" chmod 0640 "$ETC/edge.env"
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the server will accept events." [ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the cloud will accept events."
echo " -> wrote $ETC/edge.env" echo " -> wrote $ETC/edge.env"
fi fi
echo ">> [8/9] camera check (open every camera in CAMERAS and grab a frame)" echo ">> [8/8] systemd service"
# Do this BEFORE enabling the unit. build_runtimes() logs-and-skips a camera it cannot open,
# so a wrong /dev/video index or an unseated ribbon would otherwise produce a green unit that
# silently monitors one camera less. Fail here, while someone is still at the Pi.
# SKIP_CAMERA_CHECK=1 for a headless/bench install with no cameras attached yet.
if [ "${SKIP_CAMERA_CHECK:-0}" = "1" ]; then
echo " -> skipped (SKIP_CAMERA_CHECK=1)"
else
# Probe exactly what the unit will run with: the CAMERAS line as written to edge.env, and
# the installed copy of the package ($PREFIX/src, not the repo checkout).
probe_cams="$(sed -n 's/^CAMERAS=//p' "$ETC/edge.env" | head -1)"
if ( cd "$PREFIX/src" && CAMERAS="$probe_cams" "$PREFIX/venv/bin/python" -m edge.probe \
--save "$STATE/probe" ); then
echo " -> every camera delivered a frame (previews in $STATE/probe)"
chown -R "$SVC_USER":"$SVC_USER" "$STATE/probe" 2>/dev/null || true
else
echo "" >&2
echo "ERROR: camera check failed — refusing to enable the service." >&2
echo " Fix CAMERAS in $ETC/edge.env and re-run, or install with SKIP_CAMERA_CHECK=1" >&2
echo " to enable the service anyway (it will monitor only the cameras that work)." >&2
exit 1
fi
fi
echo ">> [9/9] systemd service"
install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/ install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload

View file

@ -1,7 +1,7 @@
# move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망). # move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망).
# Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then # Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then
# sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge # sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge
# The daemon tolerates the server being offline (events buffer in the local queue # The daemon tolerates the cloud being offline (events buffer in the local queue
# and flush on reconnect), so this unit only *wants* the network, not requires it. # and flush on reconnect), so this unit only *wants* the network, not requires it.
[Unit] [Unit]
@ -9,12 +9,6 @@ Description=move-detect edge daemon (workplace-safety AI CCTV)
Documentation=file:/opt/move-detect/docs/pilot-runbook.md Documentation=file:/opt/move-detect/docs/pilot-runbook.md
Wants=network-online.target Wants=network-online.target
After=network-online.target After=network-online.target
# Order after clock sync so Event.occurred_at is real wall time, not the stale fake-hwclock time a
# Pi 5 (no RTC battery) boots with. Detector TIMERS are already immune (they run on a monotonic
# clock — see daemon.main), so this only affects timestamp accuracy; Wants (not Requires) so an
# air-gapped Pi with no reachable NTP still starts rather than hanging forever on time-sync.
Wants=time-sync.target
After=time-sync.target
# Never stop retrying. This MUST live in [Unit]: systemd's parser table knows # Never stop retrying. This MUST live in [Unit]: systemd's parser table knows
# Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in # Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in
# [Service] it is an unknown key — silently ignored, leaving the default 5-starts/10s # [Service] it is an unknown key — silently ignored, leaving the default 5-starts/10s

View file

@ -14,13 +14,13 @@ Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low
A safety system that *looks* healthy while doing nothing is the worst failure. Make every A safety system that *looks* healthy while doing nothing is the worst failure. Make every
misconfig/degradation fail LOUD. misconfig/degradation fail LOUD.
- [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default - [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 `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` 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 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. (`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`; DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_serverclient.py. tests in test_alert.py / test_cloudclient.py.
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt, - [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/ 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. missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
@ -35,7 +35,7 @@ misconfig/degradation fail LOUD.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera; DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit. test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where - [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the server cannot tell a dead device from a live one. `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. **Fix:** real fps + per-camera frame-age/health in the heartbeat body.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors; DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
test_daemon.py locks fps math + failing-camera flag. test_daemon.py locks fps math + failing-camera flag.
@ -54,12 +54,12 @@ misconfig/degradation fail LOUD.
poison head unwedges the queue; test locks the drain-past-poison behavior. poison head unwedges the queue; test locks the drain-past-poison behavior.
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out - [x] **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-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
client-generated `event_id` in the payload; server dedups on it. client-generated `event_id` in the payload; cloud dedups on it.
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
creation, preserved through the offline queue + every retry). Server: `events.event_uuid` creation, preserved through the offline queue + every retry). Cloud: `events.event_uuid`
CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the
already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key. already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key.
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server). Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (cloud).
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError - [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. at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
DONE — `frame_interval_from_env` validates; test_config.py locks it. DONE — `frame_interval_from_env` validates; test_config.py locks it.
@ -70,7 +70,7 @@ misconfig/degradation fail LOUD.
## Phase 2 — Core safety correctness (fall / zone / tracking) ## Phase 2 — Core safety correctness (fall / zone / tracking)
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are - [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 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. 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 DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
@ -125,13 +125,13 @@ misconfig/degradation fail LOUD.
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox. face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both. fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
- [x] **P3-2 [H]** `server/src/Ingest/IngestController.php:68` — worker snapshots are stored - [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days` forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`. config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it. ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
- [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored - [x] **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 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`. snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
@ -149,42 +149,42 @@ misconfig/degradation fail LOUD.
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
is the optional CPU fallback); flag for whoever needs CPU MoveNet. is the optional CPU fallback); flag for whoever needs CPU MoveNet.
## Phase 4 — Server hardening (shared hosting) ## Phase 4 — Cloud hardening (shared hosting)
- [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException - [x] **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). 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')`; **Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`. ship `.user.ini`.
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"} DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse + error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
backstop. Package build verified. backstop. Package build verified.
- [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate - [x] **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 limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error. error.
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood. (shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
- [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits - [x] **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 grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows. rows.
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded, DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
gated 1/100). Full PHP suite green. gated 1/100). Full PHP suite green.
- [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a - [x] **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 `camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params. captured params.
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases. DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
- [x] **P4-5 [M]** `server/src/Dashboard/EventListController.php:17` — event list hard-capped at - [x] **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. newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory), DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full `limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`. history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
- [x] **P4-6 [M]** `server/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a - [x] **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 changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error. clear error.
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException) DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double. → `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
- [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is - [x] **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:** swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
order write→insert, log failures, reconcile orphans. order write→insert, log failures, reconcile orphans.
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode, DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
@ -192,20 +192,20 @@ misconfig/degradation fail LOUD.
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent` is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
(throwing repo double → asserts 201, event stored, no leftover *.jpg). (throwing repo double → asserts 201, event stored, no leftover *.jpg).
- [x] **P4-8 [L]** `server/src/Auth/AuthController.php:31` — login form emits a CSRF token that - [x] **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). `login` never checks. **Fix:** verify it (or drop it).
DONE — verified (completed the half-built defense, not dropped). login() checks DONE — verified (completed the half-built defense, not dropped). login() checks
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the `Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
`testLoginRejectsMissingOrWrongCsrf`. `testLoginRejectsMissingOrWrongCsrf`.
- [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after - [x] **P4-9 [L]** `cloud/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder. `session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
DONE — logout() rotates the id then wipes (was regenerate-after-destroy). DONE — logout() rotates the id then wipes (was regenerate-after-destroy).
- [x] **P4-10 [L]** `server/src/Ingest/EventValidator.php:26` — no string length caps → an - [x] **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 overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
lengths in the validator. lengths in the validator.
DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest. DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest.
- [x] **P4-11 [L]** `server/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/` - [x] **P4-11 [L]** `cloud/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
(parse_url scheme-relative). **Fix:** collapse/normalize. (parse_url scheme-relative). **Fix:** collapse/normalize.
## Phase 5 — Install / bundle / supply-chain ## Phase 5 — Install / bundle / supply-chain
@ -243,7 +243,7 @@ misconfig/degradation fail LOUD.
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera `/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. is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera. DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
- [x] **P5-6 [L]** `server/deploy/build_cafe24_package.sh` — dev cache dirs - [x] **P5-6 [L]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude. (`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
--- ---
@ -253,7 +253,7 @@ misconfig/degradation fail LOUD.
(updated as each item lands — newest first) (updated as each item lands — newest first)
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done. - **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
P1-9: edge `event_uuid` idempotency key (stable through the queue) + server dedup on a UNIQUE P1-9: edge `event_uuid` idempotency key (stable through the queue) + cloud dedup on a UNIQUE
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration + list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3: provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
@ -272,16 +272,16 @@ misconfig/degradation fail LOUD.
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning · (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
knobs — tune with real camera footage on the Pi.) knobs — tune with real camera footage on the Pi.)
- **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch - **2026-07-17 — P4-6 + P4-8 landed (cloud error/auth hardening).** ZoneConfig ownership-mismatch
now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic (P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM). snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared - **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 (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 server batch), P2-6..11 (mostly threshold 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 tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
decision: how many days), Phase 4 (server hardening, 11 items — mostly clear), Phase 5 (P5-2..6). decision: how many days), Phase 4 (cloud hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
Nothing committed yet. Nothing committed yet.
DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package. DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package.

View file

@ -1,45 +1,17 @@
# 파일럿 · 운영 런북 # P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
이 문서는 두 경로를 다룬다. **자신의 상황에 맞는 절에서 시작하라:** 이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다.
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
- **§0 폐쇄망 Pi 현장 설치·운영** — Hailo·Pi5 실장비를 오프라인 번들로 설치한 경우. systemd 유닛의 - 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
`Documentation=`이 이 문서를 가리키므로 현장 운영자는 **§0만** 보면 된다. - 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
- **§1~§6 노트북 웹캠 개발 파일럿** — 개발/데모 머신에서 CPU MoveNet로 엔드투엔드를 돌리는 최소 경로.
**이 절들의 `pip install`·모델 `fetch` 명령은 개발/빌드 머신 전용이다 — 폐쇄망 Pi에서 실행하지 말 것.**
- 엣지: `python -m edge` (또는 `move-detect-edge`) — pose → 감지 → HMAC 서명 POST.
- 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
--- ---
## 0. 폐쇄망 Pi 현장 설치·운영 ## 1. 빌드 머신 준비 (모델 · onnxruntime)
실장비 Hailo·Pi5는 **오프라인 전체 번들**로 설치한다. 런타임 휠·pose 모델(HEF)·소스·systemd 유닛이 > **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서
모두 번들에 동봉되어 있으므로 **Pi에서 인터넷을 받지 않는다** (아래 §1의 `pip install`/`fetch`는 절대 > 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
실행 금지 — 빌드 머신 전용).
1. **설치:** 번들 절차는 [deploy/README.md](../deploy/README.md)를 따른다. 빌드 머신에서
`deploy/bundle/build_offline_bundle.sh`(aarch64)로 번들을 만들어 Pi로 옮기고,
`sudo ./install_offline.sh`가 SHA256SUMS 검증 → 오프라인 휠 설치 → HEF 배치 → 유닛 설치를 수행한다.
(온라인 프로비저닝은 `sudo deploy/setup_online.sh` — 카메라 프로브까지 자동.)
2. **멀티카메라:** `/etc/move-detect/edge.env`의 `CAMERAS=csi:0:cam-01,uvc:0:cam-02`
형식으로 1 Pi에 2~4대를 배선한다(형식 `backend:device:camera_id`).
3. **enable 전 카메라 프로브** (설치 스크립트가 출력하는 명령) — 잘못된 인덱스/미체결 리본이 만드는
"초록불인데 한 대 덜 감시" 상태를 현장에서 잡는다.
4. **기동·확인:** `sudo systemctl enable --now move-detect-edge` → `journalctl -u move-detect-edge -f`.
5. **시크릿 위생:** 프로비저닝에 `deploy/device.env`를 썼다면 설치 후 삭제·필요시 로테이션
(deploy/README.md "Secret handling").
> 시크릿·서버 URL·모델 경로 등 env 키는 §3 표와 동일하다. 감지 임계값은 서버 대시보드의 구역·임계값이
> 다음 config 폴링에 반영된다.
---
## 1. 빌드/개발 머신 준비 (모델 · onnxruntime) — 폐쇄망 Pi에서 실행 금지
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망 Pi)에서는
> 인터넷을 받지 않으며, 아래 두 명령을 Pi에서 실행하면 안 된다(§0 참조 — Pi는 이미 번들로 설치됨).
> 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
```bash ```bash
pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들) pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들)
@ -53,23 +25,23 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
--- ---
## 2. 서버 로컬 기동 ## 2. 클라우드 로컬 기동
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은 로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만. [cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만.
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블). 1. DB 생성 후 스키마 임포트: `mysql <db> < cloud/schema.sql` (10개 테이블).
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로). (`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. 3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` - `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` - `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
4. 기동: `php -S localhost:8000 -t server/public` 4. 기동: `php -S localhost:8000 -t cloud/public`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조. `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조.
--- ---
@ -81,7 +53,7 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|-----|------|------| |-----|------|------|
| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` | | `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` |
| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` | | `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
| `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` | | `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` |
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` | | `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` | | `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` | | `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
@ -111,13 +83,10 @@ python -m edge # 또는 move-detect-edge
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것. ≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 → 6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송. 복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
7. **하트비트:** **30초 시간기반**(`heartbeat_interval_s=30`)으로 `POST /api/heartbeat`(서명). 7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명).
부하와 무관하게 주기가 일정해 서버의 오프라인 판정(120초, 4배 마진)이 정상 장치를 오프라인으로
깜빡이지 않는다. temp/throttle/queue_pending/config_version 텔레메트리를 함께 싣는다.
> cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()` > cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로
> 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 > 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다.
> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계).
--- ---
@ -125,7 +94,7 @@ python -m edge # 또는 move-detect-edge
```bash ```bash
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip) pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # 서버(0 warnings) cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings)
``` ```
모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` — 모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —
@ -133,15 +102,11 @@ cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations #
--- ---
## 6. 한계 (명시) ## 6. 파일럿 한계 (명시)
> **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를 - **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속.
> 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다. - **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2.
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
- **노트북 파일럿은 단일 카메라 · CPU pose(저 fps)** — 폐쇄망 Pi(§0)는 Hailo 가속 + 멀티카메라 - **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save`
(`CAMERAS=` 다중 배선)를 사용한다. 패키징은 별도 후속 태스크.
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2(양끝 인프라는 존재, 배선 대기).
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook·미확인 에스컬레이션 메일은 후속(P3).
- **폐쇄망 전체 번들은 이미 존재한다** — `deploy/bundle/`에 오프라인 휠·HEF·소스·SHA256SUMS 검증
설치를 제공한다(§0). 노트북 파일럿만 `pip install`/`fetch`로 편의 실행할 뿐이다.
- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3). - **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).

View file

@ -1,4 +1,4 @@
# P1-B 서버 수신·대시보드 (Server Ingest + Dashboard) Implementation Plan # P1-B 클라우드 수신·대시보드 (Cloud Ingest + Dashboard) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@ -10,22 +10,22 @@
## Global Constraints ## Global Constraints
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `server/src/`). - **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `cloud/src/`).
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `server/public/`만 웹 노출. - **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `cloud/public/`만 웹 노출.
- **HMAC 계약(P1-A와 정확히 일치):** 서명 = `hmac-sha256(nonce + timestamp + body, per_device_secret)` hex. P1-A `edge/alert.py::sign`과 바이트 동일하게 검증. 헤더 `X-Signature`, `X-Nonce`, `X-Timestamp`. - **HMAC 계약(P1-A와 정확히 일치):** 서명 = `hmac-sha256(nonce + timestamp + body, per_device_secret)` hex. P1-A `edge/alert.py::sign`과 바이트 동일하게 검증. 헤더 `X-Signature`, `X-Nonce`, `X-Timestamp`.
- **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초). - **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초).
- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`server/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지. - **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`cloud/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
- **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증. - **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증.
- **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer. - **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer.
- **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트. - **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트.
- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`server/config.php`는 `.gitignore`), 하드코딩 금지. - **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`cloud/config.php`는 `.gitignore`), 하드코딩 금지.
- **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24). - **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24).
- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로. - 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.
## File Structure ## File Structure
``` ```
server/ cloud/
composer.json # PSR-4 autoload + phpunit(dev) composer.json # PSR-4 autoload + phpunit(dev)
phpunit.xml phpunit.xml
config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore) config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore)
@ -64,7 +64,7 @@ server/
## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit) ## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)
**Files:** **Files:**
- Create: `server/composer.json`, `server/phpunit.xml`, `server/src/Config.php`, `server/config.example.php`, `server/tests/ConfigTest.php`, `server/.gitignore` - Create: `cloud/composer.json`, `cloud/phpunit.xml`, `cloud/src/Config.php`, `cloud/config.example.php`, `cloud/tests/ConfigTest.php`, `cloud/.gitignore`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -74,7 +74,7 @@ server/
```json ```json
{ {
"name": "move-detect/server", "name": "move-detect/cloud",
"type": "project", "type": "project",
"require": { "php": ">=8.2" }, "require": { "php": ">=8.2" },
"require-dev": { "phpunit/phpunit": "^11" }, "require-dev": { "phpunit/phpunit": "^11" },
@ -95,7 +95,7 @@ server/
</phpunit> </phpunit>
``` ```
- [ ] **Step 3: `.gitignore`** (server/) - [ ] **Step 3: `.gitignore`** (cloud/)
``` ```
/vendor/ /vendor/
@ -104,7 +104,7 @@ server/
!/storage/snapshots/.gitkeep !/storage/snapshots/.gitkeep
``` ```
- [ ] **Step 4: 실패 테스트** — `server/tests/ConfigTest.php` - [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php`
```php ```php
<?php <?php
@ -135,10 +135,10 @@ final class ConfigTest extends TestCase
- [ ] **Step 5: 실패 확인** - [ ] **Step 5: 실패 확인**
Run: `cd server && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php` Run: `cd cloud && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
Expected: FAIL — `Class "App\Config" not found` Expected: FAIL — `Class "App\Config" not found`
- [ ] **Step 6: 구현** — `server/src/Config.php` - [ ] **Step 6: 구현** — `cloud/src/Config.php`
```php ```php
<?php <?php
@ -180,8 +180,8 @@ return [
Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS
```bash ```bash
git add server/composer.json server/phpunit.xml server/.gitignore server/config.example.php server/src/Config.php server/tests/ConfigTest.php git add cloud/composer.json cloud/phpunit.xml cloud/.gitignore cloud/config.example.php cloud/src/Config.php cloud/tests/ConfigTest.php
git commit -m "feat(server): P1-B scaffold — composer autoload + Config" git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config"
``` ```
--- ---
@ -189,13 +189,13 @@ git commit -m "feat(server): P1-B scaffold — composer autoload + Config"
## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치 ## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치
**Files:** **Files:**
- Create: `server/src/Security/Hmac.php`, `server/tests/HmacTest.php` - Create: `cloud/src/Security/Hmac.php`, `cloud/tests/HmacTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
- Produces: `App\Security\Hmac::sign(string $secret, string $nonce, string $ts, string $body): string` (hex), `Hmac::verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool` (hash_equals, 상수시간). - Produces: `App\Security\Hmac::sign(string $secret, string $nonce, string $ts, string $body): string` (hex), `Hmac::verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool` (hash_equals, 상수시간).
- [ ] **Step 1: 실패 테스트** — `server/tests/HmacTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/HmacTest.php`
```php ```php
<?php <?php
@ -229,7 +229,7 @@ final class HmacTest extends TestCase
Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found) Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found)
- [ ] **Step 3: 구현** — `server/src/Security/Hmac.php` - [ ] **Step 3: 구현** — `cloud/src/Security/Hmac.php`
```php ```php
<?php <?php
@ -256,8 +256,8 @@ final class Hmac
Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS
```bash ```bash
git add server/src/Security/Hmac.php server/tests/HmacTest.php git add cloud/src/Security/Hmac.php cloud/tests/HmacTest.php
git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract" git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
``` ```
--- ---
@ -265,7 +265,7 @@ git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
## Task 3: Repository 인터페이스 + InMemory 테스트더블 ## Task 3: Repository 인터페이스 + InMemory 테스트더블
**Files:** **Files:**
- Create: `server/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `server/tests/Support/InMemoryRepositories.php`, `server/tests/RepositoryContractTest.php` - Create: `cloud/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `cloud/tests/Support/InMemoryRepositories.php`, `cloud/tests/RepositoryContractTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -282,7 +282,7 @@ git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
- [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의. - [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의.
`server/src/Repository/DeviceRepository.php`: `cloud/src/Repository/DeviceRepository.php`:
```php ```php
<?php <?php
@ -413,7 +413,7 @@ interface AuditRepository
} }
``` ```
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `server/tests/Support/InMemoryRepositories.php` (테스트 전용 구현) - [ ] **Step 2: InMemory 더블 + 실패 테스트** — `cloud/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
```php ```php
<?php <?php
@ -479,7 +479,7 @@ final class InMemoryAuditRepository implements AuditRepository {
} }
``` ```
그리고 `server/tests/RepositoryContractTest.php`: 그리고 `cloud/tests/RepositoryContractTest.php`:
```php ```php
<?php <?php
@ -523,8 +523,8 @@ Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS.
- [ ] **Step 4: 커밋** - [ ] **Step 4: 커밋**
```bash ```bash
git add server/src/Repository/*.php server/tests/Support/InMemoryRepositories.php server/tests/RepositoryContractTest.php git add cloud/src/Repository/*.php cloud/tests/Support/InMemoryRepositories.php cloud/tests/RepositoryContractTest.php
git commit -m "feat(server): repository interfaces + in-memory test doubles" git commit -m "feat(cloud): repository interfaces + in-memory test doubles"
``` ```
--- ---
@ -532,13 +532,13 @@ git commit -m "feat(server): repository interfaces + in-memory test doubles"
## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증) ## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증)
**Files:** **Files:**
- Create: `server/src/Ingest/EventValidator.php`, `server/tests/EventValidatorTest.php` - Create: `cloud/src/Ingest/EventValidator.php`, `cloud/tests/EventValidatorTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
- Produces: `App\Ingest\EventValidator::validate(array $payload): array` — `['ok' => bool, 'errors' => string[], 'clean' => array]`. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목. - Produces: `App\Ingest\EventValidator::validate(array $payload): array` — `['ok' => bool, 'errors' => string[], 'clean' => array]`. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목.
- [ ] **Step 1: 실패 테스트** — `server/tests/EventValidatorTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/EventValidatorTest.php`
```php ```php
<?php <?php
@ -579,7 +579,7 @@ final class EventValidatorTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Ingest/EventValidator.php` - [ ] **Step 3: 구현** — `cloud/src/Ingest/EventValidator.php`
```php ```php
<?php <?php
@ -627,8 +627,8 @@ final class EventValidator
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Ingest/EventValidator.php server/tests/EventValidatorTest.php git add cloud/src/Ingest/EventValidator.php cloud/tests/EventValidatorTest.php
git commit -m "feat(server): event payload validator with enum whitelist" git commit -m "feat(cloud): event payload validator with enum whitelist"
``` ```
--- ---
@ -636,13 +636,13 @@ git commit -m "feat(server): event payload validator with enum whitelist"
## Task 5: ImageProcessor (gd 재인코딩 + UUID) ## Task 5: ImageProcessor (gd 재인코딩 + UUID)
**Files:** **Files:**
- Create: `server/src/Ingest/ImageProcessor.php`, `server/tests/ImageProcessorTest.php` - Create: `cloud/src/Ingest/ImageProcessor.php`, `cloud/tests/ImageProcessorTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
- Produces: `App\Ingest\ImageProcessor::reencode(string $base64, int $quality = 80): array` — `['ok'=>bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]`. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 `['ok'=>false]`. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트). - Produces: `App\Ingest\ImageProcessor::reencode(string $base64, int $quality = 80): array` — `['ok'=>bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]`. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 `['ok'=>false]`. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트).
- [ ] **Step 1: 실패 테스트** — `server/tests/ImageProcessorTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/ImageProcessorTest.php`
```php ```php
<?php <?php
@ -677,7 +677,7 @@ final class ImageProcessorTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Ingest/ImageProcessor.php` - [ ] **Step 3: 구현** — `cloud/src/Ingest/ImageProcessor.php`
```php ```php
<?php <?php
@ -710,8 +710,8 @@ final class ImageProcessor
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Ingest/ImageProcessor.php server/tests/ImageProcessorTest.php git add cloud/src/Ingest/ImageProcessor.php cloud/tests/ImageProcessorTest.php
git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)" git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
``` ```
--- ---
@ -719,7 +719,7 @@ git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"
## Task 6: Http Request/Response ## Task 6: Http Request/Response
**Files:** **Files:**
- Create: `server/src/Http/Request.php`, `server/src/Http/Response.php`, `server/tests/HttpTest.php` - Create: `cloud/src/Http/Request.php`, `cloud/src/Http/Response.php`, `cloud/tests/HttpTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -727,7 +727,7 @@ git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"
- `App\Http\Request(string $method, string $path, array $headers, string $body, array $query, array $post)` with getters `method(), path(), header(string $name): ?string, body(), json(): array, query(string $k): ?string, post(string $k): ?string`, and static `fromGlobals(): self`. - `App\Http\Request(string $method, string $path, array $headers, string $body, array $query, array $post)` with getters `method(), path(), header(string $name): ?string, body(), json(): array, query(string $k): ?string, post(string $k): ?string`, and static `fromGlobals(): self`.
- `App\Http\Response`: static `json(array $data, int $status=200): self`, `text(string $s, int $status=200): self`, `bytes(string $b, string $contentType, int $status=200): self`, `redirect(string $to): self`; getters `status(), headers(), body()`; `send(): void`. - `App\Http\Response`: static `json(array $data, int $status=200): self`, `text(string $s, int $status=200): self`, `bytes(string $b, string $contentType, int $status=200): self`, `redirect(string $to): self`; getters `status(), headers(), body()`; `send(): void`.
- [ ] **Step 1: 실패 테스트** — `server/tests/HttpTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/HttpTest.php`
```php ```php
<?php <?php
@ -755,7 +755,7 @@ final class HttpTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Http/Request.php` - [ ] **Step 3: 구현** — `cloud/src/Http/Request.php`
```php ```php
<?php <?php
@ -792,7 +792,7 @@ final class Request
} }
``` ```
`server/src/Http/Response.php`: `cloud/src/Http/Response.php`:
```php ```php
<?php <?php
@ -829,8 +829,8 @@ final class Response
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Http/Request.php server/src/Http/Response.php server/tests/HttpTest.php git add cloud/src/Http/Request.php cloud/src/Http/Response.php cloud/tests/HttpTest.php
git commit -m "feat(server): Http Request/Response primitives" git commit -m "feat(cloud): Http Request/Response primitives"
``` ```
--- ---
@ -838,13 +838,13 @@ git commit -m "feat(server): Http Request/Response primitives"
## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합 ## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합
**Files:** **Files:**
- Create: `server/src/Ingest/IngestController.php`, `server/tests/IngestControllerTest.php` - Create: `cloud/src/Ingest/IngestController.php`, `cloud/tests/IngestControllerTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Hmac` (T2), `EventValidator`/`ImageProcessor` (T4/T5), `Request`/`Response` (T6), `DeviceRepository`/`NonceRepository`/`RateLimiterRepository`/`EventRepository`/`SnapshotRepository` (T3), `Config` (T1) - Consumes: `Hmac` (T2), `EventValidator`/`ImageProcessor` (T4/T5), `Request`/`Response` (T6), `DeviceRepository`/`NonceRepository`/`RateLimiterRepository`/`EventRepository`/`SnapshotRepository` (T3), `Config` (T1)
- Produces: `App\Ingest\IngestController(Config, DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository)` with `handle(Request $req, int $now): Response`. 순서: rate limit → device secret 조회 → HMAC 검증 → timestamp 윈도우 → nonce replay → 검증 → 이미지 재인코딩·저장 → 이벤트+스냅샷 insert. 실패는 401/400/429, 성공 201 `{event_id}`. - Produces: `App\Ingest\IngestController(Config, DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository)` with `handle(Request $req, int $now): Response`. 순서: rate limit → device secret 조회 → HMAC 검증 → timestamp 윈도우 → nonce replay → 검증 → 이미지 재인코딩·저장 → 이벤트+스냅샷 insert. 실패는 401/400/429, 성공 201 `{event_id}`.
- [ ] **Step 1: 실패 테스트** — `server/tests/IngestControllerTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/IngestControllerTest.php`
```php ```php
<?php <?php
@ -919,7 +919,7 @@ final class IngestControllerTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Ingest/IngestController.php` - [ ] **Step 3: 구현** — `cloud/src/Ingest/IngestController.php`
```php ```php
<?php <?php
@ -991,8 +991,8 @@ final class IngestController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Ingest/IngestController.php server/tests/IngestControllerTest.php git add cloud/src/Ingest/IngestController.php cloud/tests/IngestControllerTest.php
git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+persist)" git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
``` ```
--- ---
@ -1000,7 +1000,7 @@ git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+pe
## Task 8: Password (argon2id) + Csrf ## Task 8: Password (argon2id) + Csrf
**Files:** **Files:**
- Create: `server/src/Security/Password.php`, `server/src/Security/Csrf.php`, `server/tests/PasswordCsrfTest.php` - Create: `cloud/src/Security/Password.php`, `cloud/src/Security/Csrf.php`, `cloud/tests/PasswordCsrfTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -1008,7 +1008,7 @@ git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+pe
- `App\Security\Password::hash(string $p): string` (PASSWORD_ARGON2ID), `Password::verify(string $p, string $hash): bool`. - `App\Security\Password::hash(string $p): string` (PASSWORD_ARGON2ID), `Password::verify(string $p, string $hash): bool`.
- `App\Security\Csrf::issue(callable $randHex): string`, `Csrf::check(string $expected, string $given): bool` (hash_equals). - `App\Security\Csrf::issue(callable $randHex): string`, `Csrf::check(string $expected, string $given): bool` (hash_equals).
- [ ] **Step 1: 실패 테스트** — `server/tests/PasswordCsrfTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/PasswordCsrfTest.php`
```php ```php
<?php <?php
@ -1036,7 +1036,7 @@ final class PasswordCsrfTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Security/Password.php` - [ ] **Step 3: 구현** — `cloud/src/Security/Password.php`
```php ```php
<?php <?php
@ -1054,7 +1054,7 @@ final class Password
} }
``` ```
`server/src/Security/Csrf.php`: `cloud/src/Security/Csrf.php`:
```php ```php
<?php <?php
@ -1073,8 +1073,8 @@ final class Csrf
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Security/Password.php server/src/Security/Csrf.php server/tests/PasswordCsrfTest.php git add cloud/src/Security/Password.php cloud/src/Security/Csrf.php cloud/tests/PasswordCsrfTest.php
git commit -m "feat(server): argon2id password + CSRF token helpers" git commit -m "feat(cloud): argon2id password + CSRF token helpers"
``` ```
--- ---
@ -1082,13 +1082,13 @@ git commit -m "feat(server): argon2id password + CSRF token helpers"
## Task 9: Rbac (역할→권한) ## Task 9: Rbac (역할→권한)
**Files:** **Files:**
- Create: `server/src/Auth/Rbac.php`, `server/tests/RbacTest.php` - Create: `cloud/src/Auth/Rbac.php`, `cloud/tests/RbacTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
- Produces: `App\Auth\Rbac::can(string $role, string $permission): bool`. 권한: `view_events`(all), `acknowledge`(admin,operator), `edit_zones`(admin,operator), `manage_thresholds`(admin), `manage_compliance`(admin). - Produces: `App\Auth\Rbac::can(string $role, string $permission): bool`. 권한: `view_events`(all), `acknowledge`(admin,operator), `edit_zones`(admin,operator), `manage_thresholds`(admin), `manage_compliance`(admin).
- [ ] **Step 1: 실패 테스트** — `server/tests/RbacTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/RbacTest.php`
```php ```php
<?php <?php
@ -1123,7 +1123,7 @@ final class RbacTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Auth/Rbac.php` - [ ] **Step 3: 구현** — `cloud/src/Auth/Rbac.php`
```php ```php
<?php <?php
@ -1146,8 +1146,8 @@ final class Rbac
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Auth/Rbac.php server/tests/RbacTest.php git add cloud/src/Auth/Rbac.php cloud/tests/RbacTest.php
git commit -m "feat(server): RBAC permission matrix" git commit -m "feat(cloud): RBAC permission matrix"
``` ```
--- ---
@ -1155,7 +1155,7 @@ git commit -m "feat(server): RBAC permission matrix"
## Task 10: Session + AuthController (로그인/brute-force 잠금) ## Task 10: Session + AuthController (로그인/brute-force 잠금)
**Files:** **Files:**
- Create: `server/src/Auth/Session.php`, `server/src/Auth/AuthController.php`, `server/tests/AuthControllerTest.php` - Create: `cloud/src/Auth/Session.php`, `cloud/src/Auth/AuthController.php`, `cloud/tests/AuthControllerTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3) - Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3)
@ -1163,7 +1163,7 @@ git commit -m "feat(server): RBAC permission matrix"
- `interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }`, `ArraySessionStore` (test), `PhpSessionStore` (prod, secure cookie). - `interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }`, `ArraySessionStore` (test), `PhpSessionStore` (prod, secure cookie).
- `App\Auth\AuthController(UserRepository $users, AuditRepository $audit, int $maxAttempts=5, int $lockSeconds=900)` with `login(Request $req, SessionStore $sess, int $now): Response` and `logout(SessionStore $sess): Response`. Locks after maxAttempts; 200 on success (sets user in session), 401 on bad creds, 423 when locked. - `App\Auth\AuthController(UserRepository $users, AuditRepository $audit, int $maxAttempts=5, int $lockSeconds=900)` with `login(Request $req, SessionStore $sess, int $now): Response` and `logout(SessionStore $sess): Response`. Locks after maxAttempts; 200 on success (sets user in session), 401 on bad creds, 423 when locked.
- [ ] **Step 1: 실패 테스트** — `server/tests/AuthControllerTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/AuthControllerTest.php`
```php ```php
<?php <?php
@ -1210,7 +1210,7 @@ final class AuthControllerTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Auth/Session.php` - [ ] **Step 3: 구현** — `cloud/src/Auth/Session.php`
```php ```php
<?php <?php
@ -1240,7 +1240,7 @@ final class PhpSessionStore implements SessionStore {
} }
``` ```
`server/src/Auth/AuthController.php`: `cloud/src/Auth/AuthController.php`:
```php ```php
<?php <?php
@ -1300,8 +1300,8 @@ final class AuthController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Auth/Session.php server/src/Auth/AuthController.php server/tests/AuthControllerTest.php git add cloud/src/Auth/Session.php cloud/src/Auth/AuthController.php cloud/tests/AuthControllerTest.php
git commit -m "feat(server): session store + auth controller with lockout" git commit -m "feat(cloud): session store + auth controller with lockout"
``` ```
--- ---
@ -1309,13 +1309,13 @@ git commit -m "feat(server): session store + auth controller with lockout"
## Task 11: Router + 프론트컨트롤러 준비 ## Task 11: Router + 프론트컨트롤러 준비
**Files:** **Files:**
- Create: `server/src/Http/Router.php`, `server/tests/RouterTest.php` - Create: `cloud/src/Http/Router.php`, `cloud/tests/RouterTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6) - Consumes: `Request`/`Response` (T6)
- Produces: `App\Http\Router` with `add(string $method, string $path, callable $handler): void` and `dispatch(Request $req): Response` (404 if no match; path params `{id}` → passed to handler as second arg assoc array). - Produces: `App\Http\Router` with `add(string $method, string $path, callable $handler): void` and `dispatch(Request $req): Response` (404 if no match; path params `{id}` → passed to handler as second arg assoc array).
- [ ] **Step 1: 실패 테스트** — `server/tests/RouterTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/RouterTest.php`
```php ```php
<?php <?php
@ -1347,7 +1347,7 @@ final class RouterTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Http/Router.php` - [ ] **Step 3: 구현** — `cloud/src/Http/Router.php`
```php ```php
<?php <?php
@ -1379,8 +1379,8 @@ final class Router
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Http/Router.php server/tests/RouterTest.php git add cloud/src/Http/Router.php cloud/tests/RouterTest.php
git commit -m "feat(server): path-param router + dispatch" git commit -m "feat(cloud): path-param router + dispatch"
``` ```
--- ---
@ -1388,7 +1388,7 @@ git commit -m "feat(server): path-param router + dispatch"
## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시 ## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시
**Files:** **Files:**
- Create: `server/src/Dashboard/EventListController.php`, `server/src/Dashboard/EventDetailController.php`, `server/src/Dashboard/SnapshotProxy.php`, `server/tests/DashboardTest.php` - Create: `cloud/src/Dashboard/EventListController.php`, `cloud/src/Dashboard/EventDetailController.php`, `cloud/src/Dashboard/SnapshotProxy.php`, `cloud/tests/DashboardTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3) - Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3)
@ -1397,7 +1397,7 @@ git commit -m "feat(server): path-param router + dispatch"
- `EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response`; `acknowledge(Request, array $params): Response` (RBAC `acknowledge`). - `EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response`; `acknowledge(Request, array $params): Response` (RBAC `acknowledge`).
- `SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response` — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404. - `SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response` — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404.
- [ ] **Step 1: 실패 테스트** — `server/tests/DashboardTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/DashboardTest.php`
```php ```php
<?php <?php
@ -1451,7 +1451,7 @@ final class DashboardTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Dashboard/EventListController.php` - [ ] **Step 3: 구현** — `cloud/src/Dashboard/EventListController.php`
```php ```php
<?php <?php
@ -1476,7 +1476,7 @@ final class EventListController
} }
``` ```
`server/src/Dashboard/EventDetailController.php`: `cloud/src/Dashboard/EventDetailController.php`:
```php ```php
<?php <?php
@ -1516,7 +1516,7 @@ final class EventDetailController
} }
``` ```
`server/src/Dashboard/SnapshotProxy.php`: `cloud/src/Dashboard/SnapshotProxy.php`:
```php ```php
<?php <?php
@ -1545,8 +1545,8 @@ final class SnapshotProxy
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Dashboard/EventListController.php server/src/Dashboard/EventDetailController.php server/src/Dashboard/SnapshotProxy.php server/tests/DashboardTest.php git add cloud/src/Dashboard/EventListController.php cloud/src/Dashboard/EventDetailController.php cloud/src/Dashboard/SnapshotProxy.php cloud/tests/DashboardTest.php
git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot proxy" git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot proxy"
``` ```
--- ---
@ -1554,7 +1554,7 @@ git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot pro
## Task 13: Polygon 검증 + ZoneConfigController ## Task 13: Polygon 검증 + ZoneConfigController
**Files:** **Files:**
- Create: `server/src/Geometry/Polygon.php`, `server/src/Dashboard/ZoneConfigController.php`, `server/tests/ZoneConfigTest.php` - Create: `cloud/src/Geometry/Polygon.php`, `cloud/src/Dashboard/ZoneConfigController.php`, `cloud/tests/ZoneConfigTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3) - Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3)
@ -1562,7 +1562,7 @@ git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot pro
- `App\Geometry\Polygon::validate(array $points): bool` — ≥3점, 각 점 `[x,y]` 숫자. - `App\Geometry\Polygon::validate(array $points): bool` — ≥3점, 각 점 `[x,y]` 숫자.
- `ZoneConfigController(ZoneRepository, AuditRepository, SessionStore)`: `list(Request, array): Response` (camera별), `save(Request): Response` (RBAC edit_zones; level ∈ {warning,restricted,pinch}; polygon 검증; zone_id 반환), `delete(Request, array): Response`. - `ZoneConfigController(ZoneRepository, AuditRepository, SessionStore)`: `list(Request, array): Response` (camera별), `save(Request): Response` (RBAC edit_zones; level ∈ {warning,restricted,pinch}; polygon 검증; zone_id 반환), `delete(Request, array): Response`.
- [ ] **Step 1: 실패 테스트** — `server/tests/ZoneConfigTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/ZoneConfigTest.php`
```php ```php
<?php <?php
@ -1610,7 +1610,7 @@ final class ZoneConfigTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Geometry/Polygon.php` - [ ] **Step 3: 구현** — `cloud/src/Geometry/Polygon.php`
```php ```php
<?php <?php
@ -1631,7 +1631,7 @@ final class Polygon
} }
``` ```
`server/src/Dashboard/ZoneConfigController.php`: `cloud/src/Dashboard/ZoneConfigController.php`:
```php ```php
<?php <?php
@ -1698,8 +1698,8 @@ final class ZoneConfigController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Geometry/Polygon.php server/src/Dashboard/ZoneConfigController.php server/tests/ZoneConfigTest.php git add cloud/src/Geometry/Polygon.php cloud/src/Dashboard/ZoneConfigController.php cloud/tests/ZoneConfigTest.php
git commit -m "feat(server): polygon validation + zone config controller (RBAC)" git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
``` ```
--- ---
@ -1707,7 +1707,7 @@ git commit -m "feat(server): polygon validation + zone config controller (RBAC)"
## Task 14: ConfigController + HeartbeatController (Pi 동기 API) ## Task 14: ConfigController + HeartbeatController (Pi 동기 API)
**Files:** **Files:**
- Create: `server/src/Api/ConfigController.php`, `server/src/Api/HeartbeatController.php`, `server/tests/ApiSyncTest.php` - Create: `cloud/src/Api/ConfigController.php`, `cloud/src/Api/HeartbeatController.php`, `cloud/tests/ApiSyncTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1) - Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1)
@ -1715,7 +1715,7 @@ git commit -m "feat(server): polygon validation + zone config controller (RBAC)"
- `App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response` — `?device_id=`; device secret HMAC 검증(엣지 폴링). 반환 `{version, zones, thresholds}`. - `App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response` — `?device_id=`; device secret HMAC 검증(엣지 폴링). 반환 `{version, zones, thresholds}`.
- `App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response` — HMAC 검증 후 `touchHeartbeat(device_id, {fps,temp,throttle,config_version,at})`. 200. - `App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response` — HMAC 검증 후 `touchHeartbeat(device_id, {fps,temp,throttle,config_version,at})`. 200.
- [ ] **Step 1: 실패 테스트** — `server/tests/ApiSyncTest.php` - [ ] **Step 1: 실패 테스트** — `cloud/tests/ApiSyncTest.php`
```php ```php
<?php <?php
@ -1768,7 +1768,7 @@ final class ApiSyncTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `server/src/Api/ConfigController.php` - [ ] **Step 3: 구현** — `cloud/src/Api/ConfigController.php`
```php ```php
<?php <?php
@ -1802,7 +1802,7 @@ final class ConfigController
} }
``` ```
`server/src/Api/HeartbeatController.php`: `cloud/src/Api/HeartbeatController.php`:
```php ```php
<?php <?php
@ -1840,8 +1840,8 @@ final class HeartbeatController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add server/src/Api/ConfigController.php server/src/Api/HeartbeatController.php server/tests/ApiSyncTest.php git add cloud/src/Api/ConfigController.php cloud/src/Api/HeartbeatController.php cloud/tests/ApiSyncTest.php
git commit -m "feat(server): device config + heartbeat sync API (HMAC)" git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
``` ```
--- ---
@ -1849,11 +1849,11 @@ git commit -m "feat(server): device config + heartbeat sync API (HMAC)"
## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합) ## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합)
**Files:** **Files:**
- Create: `server/public/index.php`, `server/public/.htaccess`, `server/public/assets/{app.css,app.js,zone-draw.js}`, `server/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `server/tests/WiringSmokeTest.php` - Create: `cloud/public/index.php`, `cloud/public/.htaccess`, `cloud/public/assets/{app.css,app.js,zone-draw.js}`, `cloud/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `cloud/tests/WiringSmokeTest.php`
**Interfaces:** **Interfaces:**
- Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만). - Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만).
- Produces: `server/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁). - Produces: `cloud/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
라우트 표: 라우트 표:
| method | path | handler | | method | path | handler |
@ -1872,7 +1872,7 @@ git commit -m "feat(server): device config + heartbeat sync API (HMAC)"
| GET | /snap/{uuid} | SnapshotProxy::serve | | GET | /snap/{uuid} | SnapshotProxy::serve |
| GET | /, /zones, /login | View render | | GET | /, /zones, /login | View render |
- [ ] **Step 1: 라우터 스모크 테스트** — `server/tests/WiringSmokeTest.php` - [ ] **Step 1: 라우터 스모크 테스트** — `cloud/tests/WiringSmokeTest.php`
```php ```php
<?php <?php
@ -1902,7 +1902,7 @@ final class WiringSmokeTest extends TestCase
- [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS. - [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS.
- [ ] **Step 3: 프론트컨트롤러** — `server/public/index.php` - [ ] **Step 3: 프론트컨트롤러** — `cloud/public/index.php`
```php ```php
<?php <?php
@ -1970,7 +1970,7 @@ $r->add('GET', '/zones', fn() => $render('zones'));
$r->dispatch(Request::fromGlobals())->send(); $r->dispatch(Request::fromGlobals())->send();
``` ```
- [ ] **Step 4: .htaccess** — `server/public/.htaccess` - [ ] **Step 4: .htaccess** — `cloud/public/.htaccess`
```apache ```apache
RewriteEngine On RewriteEngine On
@ -1994,8 +1994,8 @@ RewriteRule ^ index.php [QSA,L]
Run: `./vendor/bin/phpunit` (전체) — green. Run: `./vendor/bin/phpunit` (전체) — green.
```bash ```bash
git add server/public server/src/View server/tests/WiringSmokeTest.php git add cloud/public cloud/src/View cloud/tests/WiringSmokeTest.php
git commit -m "feat(server): front controller wiring + views + zone-draw UI" git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
``` ```
--- ---
@ -2005,13 +2005,13 @@ git commit -m "feat(server): front controller wiring + views + zone-draw UI"
> ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차. > ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차.
**Files:** **Files:**
- Create: `server/src/Db/Database.php`, `server/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `server/schema.sql`, `server/docs/server-deploy.md`, `server/tests/PdoIntegrationTest.php` - Create: `cloud/src/Db/Database.php`, `cloud/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `cloud/schema.sql`, `cloud/docs/cloud-deploy.md`, `cloud/tests/PdoIntegrationTest.php`
**Interfaces:** **Interfaces:**
- Consumes: repository 인터페이스(T3), `Config`(T1) - Consumes: repository 인터페이스(T3), `Config`(T1)
- Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement). - Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement).
- [ ] **Step 1: Database factory** — `server/src/Db/Database.php` - [ ] **Step 1: Database factory** — `cloud/src/Db/Database.php`
```php ```php
<?php <?php
@ -2035,7 +2035,7 @@ final class Database
- [ ] **Step 2: schema.sql** — MySQL DDL (P1-B 서브셋: users, devices, cameras, zones, events, event_snapshots, thresholds, nonces, rate_limits, audit_log). §7.4 스펙 기준. `event_type`/`severity`/`level`은 애플리케이션 화이트리스트(+ 선택 CHECK). `nonces(nonce PK, expires_at INDEX)`, `rate_limits(rate_key, window)` 유니크. sqlite 호환 노트(AUTOINCREMENT 등) 주석. metrics/polygon_json은 TEXT/JSON. - [ ] **Step 2: schema.sql** — MySQL DDL (P1-B 서브셋: users, devices, cameras, zones, events, event_snapshots, thresholds, nonces, rate_limits, audit_log). §7.4 스펙 기준. `event_type`/`severity`/`level`은 애플리케이션 화이트리스트(+ 선택 CHECK). `nonces(nonce PK, expires_at INDEX)`, `rate_limits(rate_key, window)` 유니크. sqlite 호환 노트(AUTOINCREMENT 등) 주석. metrics/polygon_json은 TEXT/JSON.
- [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `server/src/Repository/Pdo/PdoNonceRepository.php`: - [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `cloud/src/Repository/Pdo/PdoNonceRepository.php`:
```php ```php
<?php <?php
@ -2069,7 +2069,7 @@ final class PdoNonceRepository implements NonceRepository
- `PdoThresholdRepository`: forDevice(SELECT key/value → assoc). - `PdoThresholdRepository`: forDevice(SELECT key/value → assoc).
- `PdoAuditRepository`: append(INSERT, before/after=json_encode). - `PdoAuditRepository`: append(INSERT, before/after=json_encode).
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `server/tests/PdoIntegrationTest.php`: - [ ] **Step 4: (게이트) sqlite 통합 테스트** — `cloud/tests/PdoIntegrationTest.php`:
```php ```php
protected function setUp(): void { protected function setUp(): void {
@ -2082,15 +2082,15 @@ protected function setUp(): void {
로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지. 로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지.
- [ ] **Step 5: 배포 문서** — `server/docs/server-deploy.md`: Cafe24 업로드 절차(`public/`만 웹루트; `storage/`·`src/`·`config.php`는 웹루트 밖 또는 상위 디렉토리), `config.php` 작성(config.example.php 복사), `schema.sql` phpMyAdmin import, per-device secret 발급(admin), 최초 admin 계정 생성 CLI 스니펫(`password_hash` argon2id), HTTPS/HSTS 강제, PHP 버전·gd·openssl 확인, `vendor/`는 배포 제외(런타임 의존성 0), storage/snapshots 쓰기권한. - [ ] **Step 5: 배포 문서** — `cloud/docs/cloud-deploy.md`: Cafe24 업로드 절차(`public/`만 웹루트; `storage/`·`src/`·`config.php`는 웹루트 밖 또는 상위 디렉토리), `config.php` 작성(config.example.php 복사), `schema.sql` phpMyAdmin import, per-device secret 발급(admin), 최초 admin 계정 생성 CLI 스니펫(`password_hash` argon2id), HTTPS/HSTS 강제, PHP 버전·gd·openssl 확인, `vendor/`는 배포 제외(런타임 의존성 0), storage/snapshots 쓰기권한.
- [ ] **Step 6: 전체 스위트 + 커밋** - [ ] **Step 6: 전체 스위트 + 커밋**
Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip). Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip).
```bash ```bash
git add server/src/Db server/src/Repository/Pdo server/schema.sql server/docs/server-deploy.md server/tests/PdoIntegrationTest.php git add cloud/src/Db cloud/src/Repository/Pdo cloud/schema.sql cloud/docs/cloud-deploy.md cloud/tests/PdoIntegrationTest.php
git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc" git commit -m "feat(cloud): PDO repositories + schema + Cafe24 deploy doc"
``` ```
--- ---
@ -2118,7 +2118,7 @@ git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc"
## Execution Handoff ## Execution Handoff
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-server-ingest-dashboard.md`.** **Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md`.**
실행 옵션: 실행 옵션:
1. **Subagent-Driven (추천)** — 태스크마다 새 subagent + 리뷰. `superpowers:subagent-driven-development`. 1. **Subagent-Driven (추천)** — 태스크마다 새 subagent + 리뷰. `superpowers:subagent-driven-development`.

View file

@ -2,18 +2,18 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(서버) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 서버 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.) **Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(클라우드) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 클라우드 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.)
**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 서버 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) ServerClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 서버 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트. **Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 클라우드 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) CloudClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 클라우드 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트.
**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 서버는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지. **Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 클라우드는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지.
## Global Constraints ## Global Constraints
- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 서버 스위트 **0 warnings/0 deprecations** 유지. - Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 클라우드 스위트 **0 warnings/0 deprecations** 유지.
- **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`). - **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`).
- **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐. - **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐.
- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(서버 T14가 이미 검증). PostClient 서명 재사용. - **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(클라우드 T14가 이미 검증). PostClient 서명 재사용.
- **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑. - **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑.
- YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음. - YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음.
@ -21,7 +21,7 @@
``` ```
edge/ edge/
serverclient.py # ServerClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST) cloudclient.py # CloudClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
zones.py # zone rows(dict) -> Zone objects; thresholds zones.py # zone rows(dict) -> Zone objects; thresholds
runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones() runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones()
daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main() daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main()
@ -30,33 +30,33 @@ edge/
movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap
tools/ tools/
fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/ fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/
server/ cloud/
src/View/event_detail.php # (replace stub) HTML detail shell + script src/View/event_detail.php # (replace stub) HTML detail shell + script
public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}> public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}>
public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical
public/index.php # (edit) add GET /events/{id}/view HTML route public/index.php # (edit) add GET /events/{id}/view HTML route
tests/ (edge) + server/tests/ tests/ (edge) + cloud/tests/
docs/pilot-runbook.md docs/pilot-runbook.md
``` ```
--- ---
## Task 1: ServerClient — config 폴링 + 하트비트 (HMAC 서명) ## Task 1: CloudClient — config 폴링 + 하트비트 (HMAC 서명)
**Files:** Create `edge/serverclient.py`, `tests/test_serverclient.py` **Files:** Create `edge/cloudclient.py`, `tests/test_cloudclient.py`
**Interfaces:** **Interfaces:**
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`. - Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
- Produces: - Produces:
- `ServerClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)` - `CloudClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
- `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None. - `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None.
- `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx. - `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx.
- [ ] **Step 1: 실패 테스트** — `tests/test_serverclient.py` - [ ] **Step 1: 실패 테스트** — `tests/test_cloudclient.py`
```python ```python
import json import json
from edge.serverclient import ServerClient from edge.cloudclient import CloudClient
from edge.alert import sign from edge.alert import sign
class _Resp: class _Resp:
@ -68,30 +68,30 @@ def test_fetch_config_signs_and_parses():
def fake_get(url, headers=None, timeout=None): def fake_get(url, headers=None, timeout=None):
captured["url"] = url; captured["headers"] = headers captured["url"] = url; captured["headers"] = headers
return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}})) return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}}))
c = ServerClient("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", "")
def test_fetch_config_none_on_error(): def test_fetch_config_none_on_error():
c = ServerClient("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))
assert c.fetch_config() is None assert c.fetch_config() is None
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):
captured["data"] = data; captured["headers"] = headers; return _Resp(200) captured["data"] = data; captured["headers"] = headers; return _Resp(200)
c = ServerClient("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)
``` ```
- [ ] **Step 2: 실패 확인** — `pytest tests/test_serverclient.py -v` → FAIL (ModuleNotFoundError) - [ ] **Step 2: 실패 확인** — `pytest tests/test_cloudclient.py -v` → FAIL (ModuleNotFoundError)
- [ ] **Step 3: 구현** — `edge/serverclient.py` - [ ] **Step 3: 구현** — `edge/cloudclient.py`
```python ```python
import json import json
@ -99,7 +99,7 @@ import json
from edge.alert import sign from edge.alert import sign
class ServerClient: class CloudClient:
def __init__(self, base_url: str, device_id: str, secret: str, def __init__(self, base_url: str, device_id: str, secret: str,
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0): nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
@ -146,16 +146,16 @@ class ServerClient:
return 200 <= resp.status_code < 300 return 200 <= resp.status_code < 300
``` ```
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_serverclient.py -v` → PASS - [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_cloudclient.py -v` → PASS
```bash ```bash
git add edge/serverclient.py tests/test_serverclient.py git add edge/cloudclient.py tests/test_cloudclient.py
git commit -m "feat(edge): ServerClient for config poll + heartbeat (HMAC-signed)" git commit -m "feat(edge): CloudClient for config poll + heartbeat (HMAC-signed)"
``` ```
--- ---
## Task 2: 서버 zone 행 → Zone 객체 로딩 ## Task 2: 클라우드 zone 행 → Zone 객체 로딩
**Files:** Create `edge/zones.py`, `tests/test_zones.py` **Files:** Create `edge/zones.py`, `tests/test_zones.py`
@ -225,7 +225,7 @@ def thresholds_from_config(config: dict) -> dict:
```bash ```bash
git add edge/zones.py tests/test_zones.py git add edge/zones.py tests/test_zones.py
git commit -m "feat(edge): parse server config zones/thresholds into Zone objects" git commit -m "feat(edge): parse cloud config zones/thresholds into Zone objects"
``` ```
--- ---
@ -341,7 +341,7 @@ git commit -m "feat(edge): PipelineRuntime assembles components + hot-swaps zone
**Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml` **Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml`
**Interfaces:** **Interfaces:**
- Consumes: `PipelineRuntime` (T3), `ServerClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py). - Consumes: `PipelineRuntime` (T3), `CloudClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
- Produces: - Produces:
- `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict. - `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict.
- `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리) - `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리)
@ -427,7 +427,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=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 edge.config import load_config from edge.config import load_config
from edge.serverclient import ServerClient 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
from edge.runtime import PipelineRuntime from edge.runtime import PipelineRuntime
@ -438,7 +438,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
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 = ServerClient(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(os.environ.get("QUEUE_DB", "queue.db"), post)
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0"))) source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
@ -591,15 +591,15 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
--- ---
## Task 6: 서버 사고 상세 뷰 — 스냅샷 표시 ## Task 6: 클라우드 사고 상세 뷰 — 스냅샷 표시
**Files:** Modify `server/src/View/event_detail.php`; Create `server/public/assets/detail.js`; Modify `server/public/index.php`; Modify `server/tests/WiringSmokeTest.php` **Files:** Modify `cloud/src/View/event_detail.php`; Create `cloud/public/assets/detail.js`; Modify `cloud/public/index.php`; Modify `cloud/tests/WiringSmokeTest.php`
**Interfaces:** **Interfaces:**
- Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image). - Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image).
- Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe). - Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe).
- [ ] **Step 1: 스모크 테스트 갱신** — `server/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd server && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.) - [ ] **Step 1: 스모크 테스트 갱신** — `cloud/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd cloud && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
- [ ] **Step 2: event_detail.php (스텁 교체)** - [ ] **Step 2: event_detail.php (스텁 교체)**
@ -612,7 +612,7 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
<script src="/assets/detail.js" defer></script> <script src="/assets/detail.js" defer></script>
``` ```
- [ ] **Step 3: detail.js** — `server/public/assets/detail.js` - [ ] **Step 3: detail.js** — `cloud/public/assets/detail.js`
```javascript ```javascript
(function () { (function () {
@ -653,18 +653,18 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
$r->add('GET', '/events/{id}/view', fn() => $render('event_detail')); $r->add('GET', '/events/{id}/view', fn() => $render('event_detail'));
``` ```
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l server/public/index.php`. - [ ] **Step 5: 통과 + 커밋** — 전체 `cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l cloud/public/index.php`.
```bash ```bash
git add server/src/View/event_detail.php server/public/assets/detail.js server/public/index.php server/tests/WiringSmokeTest.php git add cloud/src/View/event_detail.php cloud/public/assets/detail.js cloud/public/index.php cloud/tests/WiringSmokeTest.php
git commit -m "feat(server): incident detail view renders snapshot images" git commit -m "feat(cloud): incident detail view renders snapshot images"
``` ```
--- ---
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio ## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
**Files:** Modify `server/public/assets/app.js` **Files:** Modify `cloud/public/assets/app.js`
**Interfaces:** **Interfaces:**
- Consumes: `GET /api/events` (기존 폴링). - Consumes: `GET /api/events` (기존 폴링).
@ -702,11 +702,11 @@ function updateAlarm(events) {
그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출. 그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출.
- [ ] **Step 2: 통과 + 커밋** — `node --check server/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경). - [ ] **Step 2: 통과 + 커밋** — `node --check cloud/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
```bash ```bash
git add server/public/assets/app.js git add cloud/public/assets/app.js
git commit -m "feat(server): dashboard rows link to detail + desk Web Audio on critical" git commit -m "feat(cloud): dashboard rows link to detail + desk Web Audio on critical"
``` ```
--- ---
@ -735,8 +735,8 @@ def test_real_movenet_runs():
- [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차: - [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차:
1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`). 1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`).
2. **서버:** `php -S localhost:8000 -t server/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록. 2. **클라우드:** `php -S localhost:8000 -t cloud/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록.
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(서버와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`. 3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(클라우드와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
4. **실행:** `move-detect-edge` (또는 `python -m edge`). 4. **실행:** `move-detect-edge` (또는 `python -m edge`).
5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush. 5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush.
6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속. 6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속.
@ -753,7 +753,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
## Self-Review ## Self-Review
**1. 최소경로 커버리지 (기능검토 1~7):** **1. 최소경로 커버리지 (기능검토 1~7):**
- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→서버 흐름(3) → T4 데몬+기존 PostClient ✓ - 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→클라우드 흐름(3) → T4 데몬+기존 PostClient ✓
- 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓ - 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓
- config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓ - config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓
- **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지). - **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지).
@ -761,7 +761,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
**2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시). **2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시).
**3. Type consistency:** `ServerClient.sign` = PostClient/서버 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관. **3. Type consistency:** `CloudClient.sign` = PostClient/클라우드 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관.
--- ---

View file

@ -8,7 +8,7 @@ from edge.events import Event
# Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent # 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 # 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. ServerClient imports this too. # and URL. Send an explicit UA on every request. CloudClient imports this too.
USER_AGENT = "move-detect-edge/1.0" USER_AGENT = "move-detect-edge/1.0"
@ -65,7 +65,7 @@ class PostClient:
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout, resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
allow_redirects=False) allow_redirects=False)
except Exception: except Exception:
# Connection refused / DNS / timeout — the server is unreachable, not rejecting us. # Connection refused / DNS / timeout — the cloud is unreachable, not rejecting us.
return SendResult.TRANSIENT return SendResult.TRANSIENT
code = resp.status_code code = resp.status_code
if 200 <= code < 300: if 200 <= code < 300:
@ -74,13 +74,6 @@ class PostClient:
# 4xx codes that explicitly mean try-again (408 Request Timeout, 425 Too Early, 429 Too # 4xx codes that explicitly mean try-again (408 Request Timeout, 425 Too Early, 429 Too
# Many Requests). Counting these toward the poison drop cap would delete real events during # Many Requests). Counting these toward the poison drop cap would delete real events during
# a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection. # a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection.
# if code in (408, 425, 429) or code >= 500:
# 401 is transient too: the server returns it for a STALE timestamp (abs(now-ts) > window),
# which is exactly what an RTC-less Pi hits after a power cut until NTP steps the clock —
# the event is valid and will be accepted verbatim once time syncs. The only permanent 401
# cause is a wrong/rotated secret, a provisioning error to fix; retrying (events stay queued,
# bounded by max_rows) is far safer than poison-dropping a real fall alert. Poison-drop is
# reserved for a definite CONTENT rejection (400/422), never an auth/clock condition.
if code in (401, 408, 425, 429) or code >= 500:
return SendResult.TRANSIENT return SendResult.TRANSIENT
return SendResult.REJECTED return SendResult.REJECTED

View file

@ -29,14 +29,10 @@ def _head_boxes(persons: list) -> list:
return boxes return boxes
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None): def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn):
ok, frame = source.read() ok, frame = source.read()
if not ok: if not ok:
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no return []
# frame" apart from "frame processed, no events" — a dead/unplugged USB camera returns
# (False, None) forever, and counting it as a healthy frame made a monitoring outage
# look perfectly green (fps~=target, cameras_erroring=0).
return None
now = now_fn() now = now_fn()
persons = pose.infer(frame) persons = pose.infer(frame)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
@ -46,10 +42,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
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)
h, w = frame.shape[:2] h, w = frame.shape[:2]
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the events = engine.process(tracks, lost, now, frame_wh=(w, h))
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
wall_now = wall_fn() if wall_fn is not None else None
events = engine.process(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
for tr in lost: for tr in lost:
pool.forget(tr.track_id) pool.forget(tr.track_id)
if events: if events:

Some files were not shown because too many files have changed in this diff Show more