Compare commits

..

No commits in common. "main" and "feat/p1f-online-setup" have entirely different histories.

169 changed files with 1689 additions and 8088 deletions

10
.gitattributes vendored
View file

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

10
.gitignore vendored
View file

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

View file

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

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" },

9
cloud/config.example.php Normal file
View file

@ -0,0 +1,9 @@
<?php
return [
'db_dsn' => 'mysql:host=localhost;dbname=move_detect;charset=utf8mb4',
'db_user' => 'CHANGEME',
'db_pass' => 'CHANGEME',
'snapshot_dir' => __DIR__ . '/storage/snapshots',
'timestamp_window' => 300,
'rate_limit_per_min' => 60,
];

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

@ -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; }

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

@ -0,0 +1,217 @@
/**
* 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';
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('/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 = '/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('/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() {
return fetch('/api/events', { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) {
window.location.href = '/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('/logout', { method: 'POST' }).then(function () { window.location.href = '/login'; });
});
}
document.addEventListener('click', unlockAudio);
loadCsrfToken();
loadEvents();
setInterval(loadEvents, POLL_INTERVAL_MS);
})();

View file

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

View file

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

View file

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

66
cloud/public/index.php Normal file
View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Config;
use App\Http\{Request, Router, Response};
use App\Db\Database;
use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
PdoThresholdRepository, PdoAuditRepository};
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController};
$cfg = Config::fromArray(require __DIR__ . '/../config.php');
$pdo = Database::connect($cfg);
$sess = new PhpSessionStore();
$now = time();
$devices = new PdoDeviceRepository($pdo);
$nonces = new PdoNonceRepository($pdo);
$rate = new PdoRateLimiterRepository($pdo);
$events = new PdoEventRepository($pdo);
$snaps = new PdoSnapshotRepository($pdo, $cfg->snapshotDir());
$users = new PdoUserRepository($pdo);
$zones = new PdoZoneRepository($pdo);
$thr = new PdoThresholdRepository($pdo);
$audit = new PdoAuditRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg);
$heartbeat = new HeartbeatController($cfg, $devices, $nonces);
$auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController();
$list = new EventListController($events, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $sess);
$zoneCtl = new ZoneConfigController($zones, $audit, $sess);
$render = function (string $view): Response {
ob_start(); require __DIR__ . '/../src/View/' . $view . '.php';
return Response::text((string)ob_get_clean());
};
$r = new Router();
$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now));
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now));
$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', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$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('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$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('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
$r->add('GET', '/', fn() => $render('events'));
$r->add('GET', '/login', fn() => $render('login'));
$r->add('GET', '/zones', fn() => $render('zones'));
$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail'));
$r->dispatch(Request::fromGlobals())->send();

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,10 +55,9 @@ 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)
secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log
config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config
last_fps DOUBLE NULL, last_fps DOUBLE NULL,
@ -67,8 +66,7 @@ CREATE TABLE move_devices (
reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat
last_heartbeat_at BIGINT UNSIGNED NULL, last_heartbeat_at BIGINT UNSIGNED NULL,
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()), created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
PRIMARY KEY (device_id), PRIMARY KEY (device_id)
KEY ix_devices_site (site)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -77,7 +75,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 +92,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 +110,9 @@ 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
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
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,
@ -129,15 +125,9 @@ CREATE TABLE move_events (
acknowledged_at BIGINT UNSIGNED NULL, acknowledged_at BIGINT UNSIGNED NULL,
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
PRIMARY KEY (event_id), PRIMARY KEY (event_id),
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
-- but the ACK timed out client-side) collide instead of duplicating. MySQL allows multiple
-- NULLs in a UNIQUE index, so legacy rows without a uuid do not conflict.
UNIQUE KEY uq_events_uuid (event_uuid),
KEY ix_events_occurred_at (occurred_at), KEY ix_events_occurred_at (occurred_at),
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_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 +141,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
@ -159,13 +149,8 @@ CREATE TABLE move_event_snapshots (
created_at BIGINT UNSIGNED NOT NULL, created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (snapshot_id), PRIMARY KEY (snapshot_id),
UNIQUE KEY uq_event_snapshots_uuid (file_uuid), UNIQUE KEY uq_event_snapshots_uuid (file_uuid),
-- Idempotent snapshot recovery: a re-sent event carries the SAME re-encoded bytes (same
-- content_hash), so this makes a duplicate/racing store collide instead of writing a second
-- copy. Lets the dedup path safely re-store missing evidence without duplication.
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 < ? CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id)
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES move_events (event_id)
ON DELETE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -174,7 +159,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 +180,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,14 +193,11 @@ 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,
PRIMARY KEY (rate_key, window_start), PRIMARY KEY (rate_key, window_start)
-- The reap `DELETE ... WHERE window_start < ?` cannot use the PK (window_start is its trailing
-- column), so give it its own index to avoid a locking full-scan on the pre-auth hot path.
KEY ix_rate_limits_window (window_start)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -223,7 +205,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,
@ -235,25 +217,3 @@ CREATE TABLE move_audit_log (
KEY ix_audit_log_ts (ts), KEY ix_audit_log_ts (ts),
KEY ix_audit_log_actor (actor) KEY ix_audit_log_actor (actor)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===========================================================================
-- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above):
-- do NOT copy the statements below by hand — they are SQL comments and will not
-- run. Apply the RUNNABLE migration instead:
--
-- 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
-- rate-limit reap indexes, AND backfills events.site from move_devices.site (without
-- that backfill, historical events vanish for a scoped operator). Run it once in
-- phpMyAdmin BEFORE deploying code that reads these columns, then provision:
-- UPDATE move_devices SET site = 'factory-a' WHERE device_id = 'pi-1';
-- UPDATE move_users SET site_scope = 'factory-a' WHERE username = 'operator-a';
-- A user with site_scope = NULL is a global operator; '' is NOT global (matches
-- nothing — a blank scope fails closed).
-- ===========================================================================

View file

@ -5,23 +5,17 @@ namespace App\Api;
use App\Config; use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Hmac; use App\Security\Hmac;
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository}; use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository};
final class ConfigController final class ConfigController
{ {
public function __construct( public function __construct(
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds, private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
private NonceRepository $nonces, private Config $cfg, private NonceRepository $nonces, private Config $cfg,
private ?RateLimiterRepository $rateLimiter = null,
) {} ) {}
public function get(Request $req, int $now, string $clientIp = ''): Response public function get(Request $req, int $now): Response
{ {
// Pre-auth per-IP anti-flood, like /api/events — HMAC + DB reads on every hit.
if ($this->rateLimiter !== null && $clientIp !== ''
&& $this->rateLimiter->hit('ip:' . $clientIp, intdiv($now, 60)) > $this->cfg->ingestIpRateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$deviceId = (string)$req->query('device_id'); $deviceId = (string)$req->query('device_id');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Api;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository};
final class HeartbeatController
{
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces) {}
public function post(Request $req, int $now): Response
{
$b = $req->json();
$deviceId = (string)($b['device_id'] ?? '');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';
if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401);
}
if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
return Response::json(['error' => 'stale'], 401);
}
if ($nonce === '' || $this->nonces->seen($nonce)) {
return Response::json(['error' => 'replay'], 401);
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
$this->devices->touchHeartbeat($deviceId, [
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0),
'at' => $now,
]);
return Response::json(['ok' => true], 200);
}
}

View file

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Auth; namespace App\Auth;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\{Password, Csrf}; use App\Security\Password;
use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository}; use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository};
final class AuthController final class AuthController
@ -28,14 +28,6 @@ final class AuthController
return Response::json(['error' => 'rate_limited'], 429); return Response::json(['error' => 'rate_limited'], 429);
} }
// Login CSRF: the GET /login render seeds $_SESSION['csrf'] and embeds it as a hidden
// field; the POST must echo it back. Without this an attacker can silently log a victim
// into the attacker's account (session fixation via forged login). Checked before the
// user lookup so a forged request never triggers the (expensive) Argon2id verify.
if (!Csrf::check((string)$sess->get('csrf'), (string)$req->post('csrf'))) {
return Response::json(['error' => 'csrf'], 403);
}
$username = (string)$req->post('username'); $username = (string)$req->post('username');
$password = (string)$req->post('password'); $password = (string)$req->post('password');
$u = $this->users->byUsername($username); $u = $this->users->byUsername($username);
@ -75,11 +67,8 @@ final class AuthController
public function logout(SessionStore $sess): Response public function logout(SessionStore $sess): Response
{ {
// Rotate the id FIRST (invalidates the old cookie) while the session is still active,
// then wipe. The old order called session_regenerate_id() AFTER session_destroy(),
// which warns on an inactive session and leaves the old cookie unreplaced.
$sess->regenerate();
$sess->clear(); $sess->clear();
$sess->regenerate();
return Response::json(['ok' => true], 200); return Response::json(['ok' => true], 200);
} }
} }

View file

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

View file

@ -16,5 +16,4 @@ final class Config
public function timestampWindow(): int { return (int)($this->a['timestamp_window'] ?? 300); } public function timestampWindow(): int { return (int)($this->a['timestamp_window'] ?? 300); }
public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); } public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); }
public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); } public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); }
public function snapshotRetentionDays(): int { return (int)($this->a['snapshot_retention_days'] ?? 7); }
} }

View file

@ -4,7 +4,7 @@ namespace App\Dashboard;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac}; use App\Auth\{SessionStore, Rbac};
use App\Security\{Csrf, SiteScope}; use App\Security\Csrf;
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository}; use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
final class EventDetailController final class EventDetailController
@ -14,20 +14,12 @@ final class EventDetailController
private AuditRepository $audit, private SessionStore $sess private AuditRepository $audit, private SessionStore $sess
) {} ) {}
// A scoped operator must not read — or acknowledge — an event outside their site. Return
// 404 (not 403) so a cross-site id is indistinguishable from a non-existent one (no leak).
private function outOfScope(array $event): bool
{
$scope = $this->sess->get('site_scope');
return !SiteScope::allows(is_string($scope) ? $scope : null, $event['site'] ?? null);
}
public function show(Request $req, array $params): Response public function show(Request $req, array $params): Response
{ {
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$event = $this->events->find($id); $event = $this->events->find($id);
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404); if ($event === null) return Response::json(['error' => 'not_found'], 404);
return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200); return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200);
} }
@ -39,10 +31,6 @@ final class EventDetailController
return Response::json(['error' => 'csrf'], 403); return Response::json(['error' => 'csrf'], 403);
} }
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
// Scope BEFORE the write: without the find()+guard a scoped user could acknowledge another
// site's event by guessing its id.
$event = $this->events->find($id);
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
$user = (string)$this->sess->get('username'); $user = (string)$this->sess->get('username');
if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404); if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append($user, 'acknowledge', "event:$id", [], []); $this->audit->append($user, 'acknowledge', "event:$id", [], []);

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\EventRepository;
final class EventListController
{
public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$severity = $req->query('severity');
$rows = $this->events->recent(100, $severity);
return Response::json(['events' => $rows], 200);
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\SnapshotRepository;
final class SnapshotProxy
{
public function __construct(private SnapshotRepository $snaps, private SessionStore $sess) {}
public function serve(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$uuid = (string)($params['uuid'] ?? '');
$path = $this->snaps->pathByUuid($uuid);
if ($path === null || !is_file($path)) return Response::json(['error' => 'not_found'], 404);
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200);
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\Csrf;
use App\Geometry\Polygon;
use App\Repository\{ZoneRepository, AuditRepository};
final class ZoneConfigController
{
private const LEVELS = ['warning','restricted','pinch'];
public function __construct(
private ZoneRepository $zones, private AuditRepository $audit, private SessionStore $sess
) {}
private function guard(string $perm): ?Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), $perm)) return Response::json(['error' => 'forbidden'], 403);
return null;
}
public function list(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
return Response::json(['zones' => $this->zones->forCamera((string)($params['camera'] ?? ''))], 200);
}
public function save(Request $req): Response
{
if ($g = $this->guard('edit_zones')) return $g;
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$b = $req->json();
$level = (string)($b['level'] ?? '');
$polygon = $b['polygon'] ?? [];
if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) {
return Response::json(['error' => 'invalid_zone'], 400);
}
$zoneId = $this->zones->save([
'zone_id' => $b['zone_id'] ?? null,
'device_id' => (string)($b['device_id'] ?? ''),
'camera_id' => (string)($b['camera_id'] ?? ''),
'level' => $level,
'polygon_json' => json_encode($polygon),
]);
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
return Response::json(['zone_id' => $zoneId], 201);
}
public function delete(Request $req, array $params): Response
{
if ($g = $this->guard('edit_zones')) return $g;
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$id = (string)($params['id'] ?? '');
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
return Response::json(['ok' => true], 200);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http;
final class Request
{
private array $headers;
public function __construct(
private string $method, private string $path, array $headers,
private string $body, private array $query = [], private array $post = []
) {
$this->headers = [];
foreach ($headers as $k => $v) $this->headers[strtolower($k)] = $v;
}
public function method(): string { return $this->method; }
public function path(): string { return $this->path; }
public function header(string $name): ?string { return $this->headers[strtolower($name)] ?? null; }
public function body(): string { return $this->body; }
public function json(): array { $d = json_decode($this->body, true); return is_array($d) ? $d : []; }
public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; }
public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; }
public static function fromGlobals(): self
{
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
return new self(
$_SERVER['REQUEST_METHOD'] ?? 'GET',
parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/',
$headers, file_get_contents('php://input') ?: '', $_GET, $_POST
);
}
}

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

@ -15,11 +15,7 @@ final class Router
foreach ($this->routes as [$method, $regex, $handler]) { foreach ($this->routes as [$method, $regex, $handler]) {
if ($req->method() !== $method) continue; if ($req->method() !== $method) continue;
if (preg_match($regex, $req->path(), $m)) { if (preg_match($regex, $req->path(), $m)) {
// Decode captured params: a camera_id/zone_id with a space or Korean text is $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
// %-encoded in the URL, so without this GET /api/zones/{camera} never matches
// what POST /api/zones stored.
$params = array_map('rawurldecode',
array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY));
return $handler($req, $params); return $handler($req, $params);
} }
} }

View file

@ -31,24 +31,10 @@ final class EventValidator
if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id'; if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id';
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
// it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the
// 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']))) {
$errors[] = 'bad:event_uuid';
}
// Length caps matching the DB columns (device_id/camera_id VARCHAR(64), zone_id
// CHAR(32)). Without these an overlong id is a MySQL 'Data too long' 500 that the edge
// re-queues and retries forever; a 400 lets it drop the poison event instead.
foreach (['device_id' => 64, 'camera_id' => 64, 'zone_id' => 32] as $k => $max) {
if (isset($p[$k]) && is_string($p[$k]) && mb_strlen($p[$k]) > $max) $errors[] = "too_long:$k";
}
if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []]; if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []];
$clean = [ $clean = [
'event_uuid' => isset($p['event_uuid']) ? (string)$p['event_uuid'] : null,
'device_id' => (string)$p['device_id'], 'device_id' => (string)$p['device_id'],
'camera_id' => (string)$p['camera_id'], 'camera_id' => (string)$p['camera_id'],
'event_type' => (string)$p['event_type'], 'event_type' => (string)$p['event_type'],

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Ingest;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
EventRepository, SnapshotRepository};
final class IngestController
{
public function __construct(
private Config $cfg,
private DeviceRepository $devices,
private NonceRepository $nonces,
private RateLimiterRepository $rateLimiter,
private EventRepository $events,
private SnapshotRepository $snapshots,
) {}
public function handle(Request $req, int $now, string $clientIp): Response
{
$payload = $req->json();
$deviceId = (string)($payload['device_id'] ?? '');
$window = intdiv($now, 60);
// Pre-auth IP limit (cheap, anti-flood): guards the endpoint before any
// HMAC/device-secret work, so an unauthenticated attacker can't burn
// a real device's own quota (see per-device limit below, moved post-auth).
if ($clientIp !== '' && $this->rateLimiter->hit('ip:' . $clientIp, $window) > $this->cfg->ingestIpRateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$nonce = $req->header('X-Nonce') ?? '';
$ts = $req->header('X-Timestamp') ?? '';
$sig = $req->header('X-Signature') ?? '';
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
if ($secret === null || $nonce === '' || $ts === '' || $sig === '') {
return Response::json(['error' => 'unauthorized'], 401);
}
if (!Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401);
}
if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
return Response::json(['error' => 'unauthorized'], 401);
}
if ($this->nonces->seen($nonce)) {
return Response::json(['error' => 'unauthorized'], 401);
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
// Post-auth per-device limit: only authenticated (HMAC-verified) requests
// count toward a device's quota, so an attacker guessing device_id can no
// longer 429-lock a real camera.
if ($this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$v = EventValidator::validate($payload);
if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);
$eventId = $this->events->insert($v['clean']);
foreach (($payload['snapshots'] ?? []) as $b64) {
$img = ImageProcessor::reencode((string)$b64);
if (!$img['ok']) continue;
$path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg';
if (file_put_contents($path, $img['bytes']) === false) continue;
$this->snapshots->add($eventId, $img['uuid'], $img['hash']);
}
return Response::json(['event_id' => $eventId], 201);
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface DeviceRepository
{
public function secretFor(string $deviceId): ?string;
public function touchHeartbeat(string $deviceId, array $meta): void;
public function configVersion(string $deviceId): int;
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface EventRepository
{
public function insert(array $event): int;
public function recent(int $limit, ?string $severity): array;
public function find(int $id): ?array;
public function acknowledge(int $id, string $user): bool;
}

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,50 @@
<?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 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

@ -8,7 +8,7 @@ use PDO;
final class PdoEventRepository implements EventRepository final class PdoEventRepository implements EventRepository
{ {
private const SELECT_COLUMNS = private const SELECT_COLUMNS =
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, 'event_id, device_id, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at'; occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at';
public function __construct(private PDO $pdo) {} public function __construct(private PDO $pdo) {}
@ -16,15 +16,13 @@ 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, (device_id, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
); );
$st->execute([ $st->execute([
$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
(string)$event['camera_id'], (string)$event['camera_id'],
(string)$event['event_type'], (string)$event['event_type'],
(string)$event['severity'], (string)$event['severity'],
@ -38,20 +36,12 @@ final class PdoEventRepository implements EventRepository
return (int)$this->pdo->lastInsertId(); return (int)$this->pdo->lastInsertId();
} }
public function idByUuid(string $uuid): ?int
{
$st = $this->pdo->prepare('SELECT event_id FROM move_events WHERE event_uuid = ? LIMIT 1');
$st->execute([$uuid]);
$id = $st->fetchColumn();
return $id === false ? null : (int)$id;
}
public function recent(int $limit, ?string $severity): array public function recent(int $limit, ?string $severity): array
{ {
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 +49,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);
@ -68,45 +58,9 @@ final class PdoEventRepository implements EventRepository
return array_map(self::mapRow(...), $st->fetchAll()); return array_map(self::mapRow(...), $st->fetchAll());
} }
public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array
{
// Bind list as [value, PDO type] pairs, positional in SQL order.
$where = [];
$binds = [];
if ($severity !== null) {
$where[] = 'severity = ?';
$binds[] = [$severity, PDO::PARAM_STR];
}
if ($site !== null) {
// Site-scope access control: a scoped operator sees only their site's events.
$where[] = 'site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
// Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id)
// on the (occurred_at DESC, event_id DESC) order. occurred_at arrives as its exact numeric
// STRING (never round-tripped through a PHP float) and is bound as a string, so MySQL
// parses it to the same DOUBLE the column holds — the page-edge boundary stays exact.
if ($beforeTs !== null && $beforeId !== null) {
$where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))';
$binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeId, PDO::PARAM_INT];
}
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events';
if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
$sql .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
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 +73,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;
@ -135,9 +89,7 @@ final class PdoEventRepository implements EventRepository
{ {
return [ return [
'event_id' => (int)$row['event_id'], 'event_id' => (int)$row['event_id'],
'event_uuid' => $row['event_uuid'] ?? null,
'device_id' => $row['device_id'], 'device_id' => $row['device_id'],
'site' => $row['site'] ?? null,
'camera_id' => $row['camera_id'], 'camera_id' => $row['camera_id'],
'event_type' => $row['event_type'], 'event_type' => $row['event_type'],
'severity' => $row['severity'], 'severity' => $row['severity'],

View file

@ -20,21 +20,14 @@ 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
// the table bloats and inserts slow/fail. Opportunistically prune a bounded batch of
// already-expired rows (safe: seen() only cares about unexpired ones).
if (mt_rand(1, 100) === 1) {
$del = $this->pdo->prepare('DELETE FROM move_nonces WHERE expires_at < ? LIMIT 5000');
$del->execute([time()]);
}
} }
} }

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\RateLimiterRepository;
use PDO;
/**
* Atomic upsert via MySQL's INSERT ... ON DUPLICATE KEY UPDATE against the
* unique (rate_key, window_start) primary key (see schema.sql) -- no
* read-modify-write race between reading the current count and writing the
* incremented value back.
*/
final class PdoRateLimiterRepository implements RateLimiterRepository
{
public function __construct(private PDO $pdo) {}
public function hit(string $key, int $windowStart): int
{
$upsert = $this->pdo->prepare(
'INSERT INTO rate_limits (rate_key, window_start, count) VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE count = count + 1'
);
$upsert->execute([$key, $windowStart]);
$select = $this->pdo->prepare(
'SELECT count FROM rate_limits WHERE rate_key = ? AND window_start = ?'
);
$select->execute([$key, $windowStart]);
return (int)$select->fetchColumn();
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\SnapshotRepository;
use PDO;
final class PdoSnapshotRepository implements SnapshotRepository
{
public function __construct(private PDO $pdo, private string $snapshotDir) {}
public function add(int $eventId, string $fileUuid, string $contentHash): void
{
$st = $this->pdo->prepare(
'INSERT INTO event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)'
);
$st->execute([$eventId, $fileUuid, $contentHash, time()]);
}
public function forEvent(int $eventId): array
{
$st = $this->pdo->prepare(
'SELECT file_uuid, content_hash FROM event_snapshots
WHERE event_id = ? ORDER BY snapshot_id ASC'
);
$st->execute([$eventId]);
return $st->fetchAll();
}
public function pathByUuid(string $uuid): ?string
{
// Validate BEFORE building any filesystem path or touching the DB --
// SnapshotProxy trusts whatever this method returns and does
// file_get_contents() on it, so a non-hex/wrong-length uuid must never
// reach path construction (closes the path-traversal risk flagged in
// the Task 12 review).
if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) {
return null;
}
$st = $this->pdo->prepare('SELECT 1 FROM event_snapshots WHERE file_uuid = ? LIMIT 1');
$st->execute([$uuid]);
if ($st->fetchColumn() === false) {
return null;
}
return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg';
}
}

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

@ -3,27 +3,17 @@ declare(strict_types=1);
namespace App\Repository\Pdo; namespace App\Repository\Pdo;
use App\Repository\ZoneRepository; use App\Repository\ZoneRepository;
use App\Repository\ZoneOwnershipException;
use PDO; use PDO;
use RuntimeException;
final class PdoZoneRepository implements ZoneRepository final class PdoZoneRepository implements ZoneRepository
{ {
public function __construct(private PDO $pdo) {} public function __construct(private PDO $pdo) {}
public function find(string $zoneId): ?array
{
$st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE zone_id = ? LIMIT 1'
);
$st->execute([$zoneId]);
$row = $st->fetch();
return $row === false ? null : $row;
}
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 +22,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 +42,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 +51,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,15 +79,15 @@ 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) {
throw new ZoneOwnershipException('zone not found or not owned by the given device/camera'); throw new RuntimeException('zone not found or not owned by the given device/camera');
} }
$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,10 +98,10 @@ 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 RuntimeException('zone was deleted concurrently');
} }
} }
return $zoneId; return $zoneId;

View file

@ -0,0 +1,10 @@
<?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;
}

View file

@ -6,8 +6,6 @@ interface ZoneRepository
{ {
public function forDevice(string $deviceId): array; public function forDevice(string $deviceId): array;
public function forCamera(string $cameraId): array; public function forCamera(string $cameraId): array;
/** The single zone row (incl. device_id) by id, or null — for site-scope resolution. */
public function find(string $zoneId): ?array;
public function save(array $zone): string; public function save(array $zone): string;
public function delete(string $zoneId): bool; public function delete(string $zoneId): bool;
} }

View file

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

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="/assets/app.js"></script>
<?php
render_footer();

53
cloud/src/View/layout.php Normal file
View file

@ -0,0 +1,53 @@
<?php
/**
* Shared header/nav + footer for server-rendered views.
* No business logic: only static markup + minimal session-derived display state
* (username is read from $_SESSION purely for display, not decisioning).
* render_header()/render_footer() bracket the page-specific <main> content.
*/
if (!function_exists('view_e')) {
function view_e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
}
if (!function_exists('render_header')) {
function render_header(string $title): void {
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= view_e($title) ?> · 이동 감지 대시보드</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<header class="app-header">
<nav class="app-nav">
<a href="/">이벤트</a>
<a href="/zones">구역 설정</a>
</nav>
<div class="app-session">
<?php if ($username !== null): ?>
<span><?= view_e($username) ?></span>
<button type="button" id="logout-btn">로그아웃</button>
<?php else: ?>
<a href="/login">로그인</a>
<?php endif; ?>
</div>
</header>
<main class="app-main">
<?php
}
}
if (!function_exists('render_footer')) {
function render_footer(): void {
?>
</main>
</body>
</html>
<?php
}
}

View file

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

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

View file

@ -5,7 +5,7 @@ use App\Config;
use App\Http\Request; use App\Http\Request;
use App\Security\Hmac; use App\Security\Hmac;
use App\Api\{ConfigController, HeartbeatController}; use App\Api\{ConfigController, HeartbeatController};
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository, InMemoryNonceRepository, InMemoryRateLimiter}; use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository, InMemoryNonceRepository};
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class ApiSyncTest extends TestCase final class ApiSyncTest extends TestCase
@ -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([]);
@ -98,15 +85,4 @@ final class ApiSyncTest extends TestCase
$req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-stale', '1000'); $req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-stale', '1000');
$this->assertSame(401, $c->post($req, now: 5000)->status()); // 4000s > 300 default window $this->assertSame(401, $c->post($req, now: 5000)->status()); // 4000s > 300 default window
} }
public function testHeartbeatIpFloodRateLimited(): void {
// An unauthenticated attacker flooding /api/heartbeat from one IP must be throttled
// pre-auth, before the HMAC + DB write on every hit (was: no limit at all).
$cfg = Config::fromArray(['ingest_ip_rate_limit_per_min' => 2]);
$dev = new InMemoryDeviceRepository(['pi-1' => 'sek']);
$c = new HeartbeatController($cfg, $dev, new InMemoryNonceRepository(), new InMemoryRateLimiter());
$garbage = new Request('POST', '/api/heartbeat', [], 'not-json', [], []);
$this->assertSame(401, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status());
$this->assertSame(401, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status());
$this->assertSame(429, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status()); // 3rd from same IP
}
} }

View file

@ -15,16 +15,12 @@ final class AuthControllerTest extends TestCase
'role' => 'operator', 'site_scope' => null, 'failed_attempts' => 0, 'locked_until' => 0, 'role' => 'operator', 'site_scope' => null, 'failed_attempts' => 0, 'locked_until' => 0,
]]); ]]);
} }
private function sess(): ArraySessionStore {
// Model the real flow: GET /login already seeded the session csrf token before POST.
$s = new ArraySessionStore(); $s->set('csrf', 'tok'); return $s;
}
private function req(string $u, string $p): Request { private function req(string $u, string $p): Request {
return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p, 'csrf' => 'tok']); return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]);
} }
public function testLoginSuccessSetsSession(): void { public function testLoginSuccessSetsSession(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$sess = $this->sess(); $sess = new ArraySessionStore();
$res = $c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4'); $res = $c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4');
$this->assertSame(200, $res->status()); $this->assertSame(200, $res->status());
$this->assertSame(1, $sess->get('user_id')); $this->assertSame(1, $sess->get('user_id'));
@ -32,39 +28,39 @@ final class AuthControllerTest extends TestCase
} }
public function testBadPasswordIs401(): void { public function testBadPasswordIs401(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$this->assertSame(401, $c->login($this->req('alice', 'nope'), $this->sess(), now: 1000, clientIp: '1.2.3.4')->status()); $this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status());
} }
public function testLockoutAfterMaxAttempts(): void { public function testLockoutAfterMaxAttempts(): void {
$repo = $this->users(); $repo = $this->users();
$c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900); $c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900);
for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), $this->sess(), now: 1000, clientIp: '1.2.3.4'); for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
// now locked, even correct password is 423 // now locked, even correct password is 423
$this->assertSame(423, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: '1.2.3.4')->status()); $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status());
} }
public function testExpiredLockResetsAttempts(): void { public function testExpiredLockResetsAttempts(): void {
$repo = $this->users(); $repo = $this->users();
$c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900); $c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900);
// lock the account with 3 bad tries at now=1000 // lock the account with 3 bad tries at now=1000
for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), $this->sess(), now: 1000, clientIp: '1.2.3.4'); for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
// locked_until is now 1000 + 900 = 1900, verify it's locked // locked_until is now 1000 + 900 = 1900, verify it's locked
$this->assertSame(423, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: '1.2.3.4')->status()); $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4')->status());
// now at now=2000 (after lock window expires at 1900), login with correct password should succeed // now at now=2000 (after lock window expires at 1900), login with correct password should succeed
$sess = $this->sess(); $sess = new ArraySessionStore();
$res = $c->login($this->req('alice', 'pw'), $sess, now: 2000, clientIp: '1.2.3.4'); $res = $c->login($this->req('alice', 'pw'), $sess, now: 2000, clientIp: '1.2.3.4');
$this->assertSame(200, $res->status()); $this->assertSame(200, $res->status());
$this->assertSame(1, $sess->get('user_id')); $this->assertSame(1, $sess->get('user_id'));
} }
public function testUnknownUsernameReturns401GenericBody(): void { public function testUnknownUsernameReturns401GenericBody(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$res = $c->login($this->req('nobody', 'whatever'), $this->sess(), now: 1000, clientIp: '1.2.3.4'); $res = $c->login($this->req('nobody', 'whatever'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
$this->assertSame(401, $res->status()); $this->assertSame(401, $res->status());
// identical generic body to the wrong-password case (no user-enumeration leak) // identical generic body to the wrong-password case (no user-enumeration leak)
$wrong = $c->login($this->req('alice', 'nope'), $this->sess(), now: 1000, clientIp: '1.2.3.4'); $wrong = $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000, clientIp: '1.2.3.4');
$this->assertSame($wrong->body(), $res->body()); $this->assertSame($wrong->body(), $res->body());
} }
public function testLogoutClearsSession(): void { public function testLogoutClearsSession(): void {
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter()); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
$sess = $this->sess(); $sess = new ArraySessionStore();
$c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4'); $c->login($this->req('alice', 'pw'), $sess, now: 1000, clientIp: '1.2.3.4');
$this->assertSame(1, $sess->get('user_id')); $this->assertSame(1, $sess->get('user_id'));
$res = $c->logout($sess); $res = $c->logout($sess);
@ -76,22 +72,8 @@ final class AuthControllerTest extends TestCase
// throttled before the (expensive) Argon2id verify runs repeatedly. // throttled before the (expensive) Argon2id verify runs repeatedly.
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter(), loginIpLimitPerMin: 2); $c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter(), loginIpLimitPerMin: 2);
$ip = '9.9.9.9'; $ip = '9.9.9.9';
$this->assertSame(200, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: $ip)->status()); $this->assertSame(200, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
$this->assertSame(200, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: $ip)->status()); $this->assertSame(200, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
$this->assertSame(429, $c->login($this->req('alice', 'pw'), $this->sess(), now: 1000, clientIp: $ip)->status()); $this->assertSame(429, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
}
public function testLoginRejectsMissingOrWrongCsrf(): void {
// Login CSRF: a forged cross-site POST would carry no (or a wrong) csrf field and must be
// rejected 403 BEFORE any credential check — even with an otherwise-valid password.
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter());
// missing csrf field in the body
$missing = new Request('POST', '/login', [], '', [], ['username' => 'alice', 'password' => 'pw']);
$this->assertSame(403, $c->login($missing, $this->sess(), now: 1000, clientIp: '1.2.3.4')->status());
// wrong csrf token
$wrong = new Request('POST', '/login', [], '', [], ['username' => 'alice', 'password' => 'pw', 'csrf' => 'nope']);
$this->assertSame(403, $c->login($wrong, $this->sess(), now: 1000, clientIp: '1.2.3.4')->status());
// session that never seeded a token (empty expected) also rejects, even with a matching-looking field
$noTok = new ArraySessionStore();
$this->assertSame(403, $c->login($this->req('alice', 'pw'), $noTok, now: 1000, clientIp: '1.2.3.4')->status());
} }
} }

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy};
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;
final class DashboardTest extends TestCase
{
private function authed(string $role = 'operator'): ArraySessionStore {
$s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s;
}
public function testListRequiresAuth(): void {
$repo = new InMemoryEventRepository();
$c = new EventListController($repo, new ArraySessionStore());
$this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status());
}
public function testListReturnsRecent(): void {
$repo = new InMemoryEventRepository();
$repo->insert(['severity' => 'high', 'event_type' => 'fall']);
$c = new EventListController($repo, $this->authed());
$res = $c->json(new Request('GET', '/api/events', [], '', [], []));
$this->assertSame(200, $res->status());
$this->assertCount(1, json_decode($res->body(), true)['events']);
}
public function testAcknowledgeRequiresPermissionAndSetsUser(): void {
$repo = new InMemoryEventRepository();
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);
$req = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'tok'], '', [], []);
// viewer denied (RBAC check fires before CSRF)
$cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
$this->assertSame(403, $cv->acknowledge($req, ['id' => (string)$id])->status());
// operator allowed
$co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
$this->assertSame(200, $co->acknowledge($req, ['id' => (string)$id])->status());
$this->assertSame('u', $repo->find($id)['acknowledged_by']);
}
public function testAcknowledgeRejectsMissingOrWrongCsrfToken(): void {
$repo = new InMemoryEventRepository();
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);
$c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
// missing header
$missing = new Request('POST', "/events/$id/ack", [], '', [], []);
$this->assertSame(403, $c->acknowledge($missing, ['id' => (string)$id])->status());
// wrong token
$wrong = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'nope'], '', [], []);
$this->assertSame(403, $c->acknowledge($wrong, ['id' => (string)$id])->status());
// acknowledge did not proceed
$this->assertNull($repo->find($id)['acknowledged_by']);
}
public function testSnapshotProxyAuthAnd404(): void {
$snaps = new InMemorySnapshotRepository();
$proxy = new SnapshotProxy($snaps, new ArraySessionStore()); // unauth
$this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status());
$proxy2 = new SnapshotProxy($snaps, $this->authed());
$this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status());
}
}

View file

@ -15,35 +15,6 @@ final class EventValidatorTest extends TestCase
$this->assertTrue($r['ok'], implode(',', $r['errors'])); $this->assertTrue($r['ok'], implode(',', $r['errors']));
$this->assertSame('fall', $r['clean']['event_type']); $this->assertSame('fall', $r['clean']['event_type']);
} }
public function testRejectsOverlongIds(): void {
// An overlong id must be a 400 (invalid), not a MySQL 'Data too long' 500 that the edge
// then retries forever. Columns: device_id/camera_id VARCHAR(64), zone_id CHAR(32).
$long = $this->valid(); $long['device_id'] = str_repeat('x', 65);
$this->assertFalse(EventValidator::validate($long)['ok']);
$cam = $this->valid(); $cam['camera_id'] = str_repeat('y', 65);
$this->assertFalse(EventValidator::validate($cam)['ok']);
$zone = $this->valid(); $zone['zone_id'] = str_repeat('z', 33);
$this->assertFalse(EventValidator::validate($zone)['ok']);
// a normal-length id still passes
$ok = $this->valid(); $ok['device_id'] = str_repeat('x', 64);
$this->assertTrue(EventValidator::validate($ok)['ok']);
}
public function testAcceptsAndCleansEventUuid(): void {
$p = $this->valid(); $p['event_uuid'] = str_repeat('a', 32);
$r = EventValidator::validate($p);
$this->assertTrue($r['ok'], implode(',', $r['errors']));
$this->assertSame(str_repeat('a', 32), $r['clean']['event_uuid']);
// Optional: omitting it is valid and clean carries null (legacy edges send no uuid).
$none = EventValidator::validate($this->valid());
$this->assertTrue($none['ok']);
$this->assertNull($none['clean']['event_uuid']);
}
public function testRejectsMalformedEventUuid(): void {
foreach (['XYZ', str_repeat('a', 31), str_repeat('A', 32), 'g' . str_repeat('a', 31)] as $bad) {
$p = $this->valid(); $p['event_uuid'] = $bad;
$this->assertFalse(EventValidator::validate($p)['ok'], "uuid '$bad' must be rejected");
}
}
public function testRejectsUnknownEventType(): void { public function testRejectsUnknownEventType(): void {
$p = $this->valid(); $p['event_type'] = 'explode'; $p = $this->valid(); $p['event_type'] = 'explode';
$this->assertFalse(EventValidator::validate($p)['ok']); $this->assertFalse(EventValidator::validate($p)['ok']);

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Config;
use App\Http\Request;
use App\Security\Hmac;
use App\Ingest\IngestController;
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryNonceRepository,
InMemoryRateLimiter, InMemoryEventRepository, InMemorySnapshotRepository};
use PHPUnit\Framework\TestCase;
final class IngestControllerTest extends TestCase
{
private string $dir;
protected function setUp(): void { $this->dir = sys_get_temp_dir() . '/snaps' . bin2hex(random_bytes(4)); mkdir($this->dir); }
private function make(array $secrets = ['pi-1' => 'sekret']): array {
$cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'rate_limit_per_min' => 60]);
$dev = new InMemoryDeviceRepository($secrets);
$nonce = new InMemoryNonceRepository();
$rl = new InMemoryRateLimiter();
$events = new InMemoryEventRepository();
$snaps = new InMemorySnapshotRepository();
$c = new IngestController($cfg, $dev, $nonce, $rl, $events, $snaps);
return [$c, $events, $snaps, $nonce];
}
private function jpegB64(): string { $im = imagecreatetruecolor(8, 8); ob_start(); imagejpeg($im); $b = ob_get_clean(); return base64_encode($b); }
private function signedReq(string $secret, array $payload, string $nonce = 'n1', string $ts = '1000'): Request {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
$sig = Hmac::sign($secret, $nonce, $ts, $body);
return new Request('POST', '/api/events',
['X-Nonce' => $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $body, [], []);
}
private function payload(): array {
return ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'high', 'occurred_at' => 1000.0, 'snapshots' => [$this->jpegB64()]];
}
public function testAcceptsValidSignedEvent(): void {
[$c, $events, $snaps] = $this->make();
$res = $c->handle($this->signedReq('sekret', $this->payload()), now: 1000, clientIp: '1.2.3.4');
$this->assertSame(201, $res->status());
$this->assertCount(1, $events->rows);
$id = json_decode($res->body(), true)['event_id'];
$this->assertCount(1, $snaps->forEvent($id)); // snapshot re-encoded + stored
}
public function testRejectsBadSignature(): void {
[$c] = $this->make();
$req = $this->signedReq('WRONG', $this->payload());
$this->assertSame(401, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status());
}
public function testRejectsReplayedNonce(): void {
[$c] = $this->make();
$req = $this->signedReq('sekret', $this->payload());
$this->assertSame(201, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status());
$this->assertSame(401, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status()); // same nonce again
}
public function testRejectsStaleTimestamp(): void {
[$c] = $this->make();
$req = $this->signedReq('sekret', $this->payload(), ts: '1000');
$this->assertSame(401, $c->handle($req, now: 5000, clientIp: '1.2.3.4')->status()); // 4000s > 300 window
}
public function testRejectsUnknownDevice(): void {
[$c] = $this->make([]); // no secrets
$this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000, clientIp: '1.2.3.4')->status());
}
public function testIpFloodRateLimited(): void {
// Attacker without a valid device secret floods from one IP; the pre-auth
// IP limiter must trip BEFORE any HMAC/device-secret work, protecting
// real devices from having their own per-device quota consumed.
$cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'ingest_ip_rate_limit_per_min' => 2]);
$dev = new InMemoryDeviceRepository(['pi-1' => 'sekret']);
$nonce = new InMemoryNonceRepository();
$rl = new InMemoryRateLimiter();
$events = new InMemoryEventRepository();
$snaps = new InMemorySnapshotRepository();
$c = new IngestController($cfg, $dev, $nonce, $rl, $events, $snaps);
$garbage = new Request('POST', '/api/events', [], 'not-json', [], []);
$this->assertSame(401, $c->handle($garbage, now: 1000, clientIp: '9.9.9.9')->status());
$this->assertSame(401, $c->handle($garbage, now: 1000, clientIp: '9.9.9.9')->status());
$res = $c->handle($garbage, now: 1000, clientIp: '9.9.9.9');
$this->assertSame(429, $res->status()); // 3rd request from same IP: rate limited pre-auth
}
}

View file

@ -47,11 +47,8 @@ final class PdoIntegrationTest extends TestCase
public function testEventInsertFindAcknowledge(): void public function testEventInsertFindAcknowledge(): void
{ {
$repo = new PdoEventRepository($this->pdo); $repo = new PdoEventRepository($this->pdo);
$uuid = str_repeat('a', 32);
$id = $repo->insert([ $id = $repo->insert([
'event_uuid' => $uuid,
'device_id' => 'dev-1', 'device_id' => 'dev-1',
'site' => 'factory-a',
'camera_id' => 'cam-1', 'camera_id' => 'cam-1',
'event_type' => 'fall', 'event_type' => 'fall',
'severity' => 'critical', 'severity' => 'critical',
@ -66,20 +63,10 @@ final class PdoIntegrationTest extends TestCase
$found = $repo->find($id); $found = $repo->find($id);
$this->assertNotNull($found); $this->assertNotNull($found);
$this->assertSame('dev-1', $found['device_id']); $this->assertSame('dev-1', $found['device_id']);
$this->assertSame('factory-a', $found['site']);
$this->assertSame($uuid, $found['event_uuid']);
$this->assertSame('critical', $found['severity']); $this->assertSame('critical', $found['severity']);
$this->assertSame(['confidence' => 0.9], $found['metrics']); $this->assertSame(['confidence' => 0.9], $found['metrics']);
$this->assertNull($found['acknowledged_by']); $this->assertNull($found['acknowledged_by']);
// Idempotency key lookup (P1-9).
$this->assertSame($id, $repo->idByUuid($uuid));
$this->assertNull($repo->idByUuid(str_repeat('z', 32)));
// Site-scoped page (P3-2b): matches its own site, excluded from another.
$this->assertCount(1, $repo->page(10, null, null, null, 'factory-a'));
$this->assertCount(0, $repo->page(10, null, null, null, 'factory-b'));
$recent = $repo->recent(10, 'critical'); $recent = $repo->recent(10, 'critical');
$this->assertCount(1, $recent); $this->assertCount(1, $recent);
$this->assertSame([], $repo->recent(10, 'info')); $this->assertSame([], $repo->recent(10, 'info'));
@ -148,16 +135,14 @@ 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,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
site TEXT,
camera_id TEXT NOT NULL, camera_id TEXT NOT NULL,
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
severity TEXT NOT NULL, severity TEXT NOT NULL,
@ -171,7 +156,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

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
EventRepository, SnapshotRepository, UserRepository, ZoneRepository,
ThresholdRepository, AuditRepository};
final class InMemoryDeviceRepository implements DeviceRepository {
public array $heartbeats = [];
public function __construct(private array $secrets = [], private array $versions = []) {}
public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; }
public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
public function configVersion(string $d): int { return $this->versions[$d] ?? 1; }
}
final class InMemoryNonceRepository implements NonceRepository {
public array $store = [];
public function seen(string $n): bool { return isset($this->store[$n]); }
public function remember(string $n, int $exp): void { $this->store[$n] = $exp; }
}
final class InMemoryRateLimiter implements RateLimiterRepository {
public array $counts = [];
public function hit(string $k, int $w): int { $key = "$k:$w"; return $this->counts[$key] = ($this->counts[$key] ?? 0) + 1; }
}
final class InMemoryEventRepository implements EventRepository {
public array $rows = []; private int $seq = 0;
public function insert(array $e): int { $id = ++$this->seq; $this->rows[$id] = $e + ['event_id' => $id, 'acknowledged_by' => null]; return $id; }
public function recent(int $limit, ?string $sev): array {
$r = array_reverse($this->rows, true);
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
return array_slice(array_values($r), 0, $limit);
}
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
}
final class InMemorySnapshotRepository implements SnapshotRepository {
public array $rows = []; public array $paths = [];
public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; }
public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
}
final class InMemoryUserRepository implements UserRepository {
public function __construct(public array $users = []) {}
public function byUsername(string $u): ?array { return $this->users[$u] ?? null; }
public function recordFailure(int $id, int $lock): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = ($row['failed_attempts'] ?? 0) + 1; $row['locked_until'] = $lock; } }
public function clearFailures(int $id): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = 0; $row['locked_until'] = 0; } }
}
final class InMemoryZoneRepository implements ZoneRepository {
public array $zones = []; private int $seq = 0;
public function forDevice(string $d): array { return array_values(array_filter($this->zones, fn($z) => ($z['device_id'] ?? '') === $d)); }
public function forCamera(string $c): array { return array_values(array_filter($this->zones, fn($z) => $z['camera_id'] === $c)); }
public function save(array $z): string { $id = $z['zone_id'] ?? ('z' . (++$this->seq)); $this->zones[$id] = $z + ['zone_id' => $id]; return $id; }
public function delete(string $id): bool { if (!isset($this->zones[$id])) return false; unset($this->zones[$id]); return true; }
}
final class InMemoryThresholdRepository implements ThresholdRepository {
public function __construct(private array $data = []) {}
public function forDevice(string $d): array { return $this->data[$d] ?? []; }
}
final class InMemoryAuditRepository implements AuditRepository {
public array $log = [];
public function append(string $a, string $act, string $t, array $b, array $af): void { $this->log[] = compact('a', 'act', 't', 'b', 'af'); }
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Geometry\Polygon;
use App\Dashboard\ZoneConfigController;
use App\Tests\Support\{InMemoryZoneRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;
final class ZoneConfigTest extends TestCase
{
private function authed(string $role = 'operator'): ArraySessionStore {
$s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s;
}
public function testPolygonValidation(): void {
$this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
$this->assertFalse(Polygon::validate([[0,0],[10,0]])); // <3
$this->assertFalse(Polygon::validate([[0,0],[10,0],['x',10]])); // non-numeric
}
private function saveReq(array $body, array $headers = ['X-Csrf-Token' => 'tok']): Request {
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
}
public function testSaveRequiresPermission(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
$res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
$this->assertSame(403, $res->status());
}
public function testSaveValidZone(): void {
$repo = new InMemoryZoneRepository();
$c = new ZoneConfigController($repo, 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]]]));
$this->assertSame(201, $res->status());
$this->assertCount(1, $repo->forCamera('cam-1'));
}
public function testSaveRejectsBadLevelOrPolygon(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), 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' => 'warning', 'polygon' => [[0,0]]]))->status());
}
public function testSaveRejectsMissingOrWrongCsrf(): void {
$repo = new InMemoryZoneRepository();
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator'));
$body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]];
// missing header
$this->assertSame(403, $c->save($this->saveReq($body, []))->status());
// wrong token
$this->assertSame(403, $c->save($this->saveReq($body, ['X-Csrf-Token' => 'nope']))->status());
// save did not proceed
$this->assertCount(0, $repo->forCamera('cam-1'));
}
public function testDeleteRejectsMissingOrWrongCsrf(): void {
$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]]']);
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator'));
// missing header
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
// wrong token
$wrong = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'nope'], '', [], []);
$this->assertSame(403, $c->delete($wrong, ['id' => 'z1'])->status());
// delete did not proceed
$this->assertCount(1, $repo->forCamera('cam-1'));
// valid token proceeds
$ok = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
$this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status());
$this->assertCount(0, $repo->forCamera('cam-1'));
}
}

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.

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

@ -20,17 +20,12 @@ 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
TARGET_ARCH="${TARGET_ARCH:-aarch64}" TARGET_ARCH="${TARGET_ARCH:-aarch64}"
HAILO_ARCH="${HAILO_ARCH:-hailo8}" # 26 TOPS AI HAT+ (Hailo-8); set hailo8l for the 13 TOPS AI Kit RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
# Mandatory for EVERY backend (cv2/numpy/requests are on the unconditional startup chain).
# onnxruntime is the CPU-MoveNet fallback only and publishes no 32-bit-ARM wheel / no sdist, so
# it is downloaded best-effort — a Hailo bundle need not carry it (install_offline treats it optional).
RUNTIME_PKGS=(numpy requests opencv-python-headless)
OPTIONAL_PKGS=(onnxruntime)
MODE="native" MODE="native"
[ "${1:-}" = "--cross" ] && MODE="cross" [ "${1:-}" = "--cross" ] && MODE="cross"
@ -38,13 +33,12 @@ MODE="native"
echo ">> build mode : $MODE" echo ">> build mode : $MODE"
echo ">> python target : $PYVER ($PYABI)" echo ">> python target : $PYVER ($PYABI)"
echo ">> target arch : $TARGET_ARCH" echo ">> target arch : $TARGET_ARCH"
echo ">> hailo arch : $HAILO_ARCH"
echo ">> output : $OUT_DIR" echo ">> output : $OUT_DIR"
rm -rf "$STAGE" rm -rf "$STAGE"
mkdir -p "$WHEELHOUSE" "$STAGE/src" "$STAGE/models" "$STAGE/deploy/systemd" "$STAGE/docs" mkdir -p "$WHEELHOUSE" "$STAGE/src" "$STAGE/models" "$STAGE/deploy/systemd" "$STAGE/docs"
echo ">> [1/6] downloading runtime wheels -> wheelhouse/" echo ">> [1/5] downloading runtime wheels -> wheelhouse/"
if [ "$MODE" = "cross" ]; then if [ "$MODE" = "cross" ]; then
# Best-effort cross download. Try the common manylinux aarch64 tags. # Best-effort cross download. Try the common manylinux aarch64 tags.
python3 -m pip download \ python3 -m pip download \
@ -53,14 +47,6 @@ if [ "$MODE" = "cross" ]; then
--platform "manylinux2014_${TARGET_ARCH}" \ --platform "manylinux2014_${TARGET_ARCH}" \
--platform "manylinux_2_28_${TARGET_ARCH}" \ --platform "manylinux_2_28_${TARGET_ARCH}" \
-d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}" -d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# CPU fallback wheel, best-effort — onnxruntime may have no matching aarch64/$PYABI tag.
python3 -m pip download \
--only-binary=:all: \
--python-version "$PYVER" --implementation cp --abi "$PYABI" \
--platform "manylinux2014_${TARGET_ARCH}" \
--platform "manylinux_2_28_${TARGET_ARCH}" \
-d "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime aarch64/$PYABI wheel unavailable; Hailo-only bundle (no CPU MoveNet)."
else else
# Native: wheels for THIS host's interpreter/arch (run on the Pi / arm64 CI). # Native: wheels for THIS host's interpreter/arch (run on the Pi / arm64 CI).
# Guard the ABI: numpy/onnxruntime ship version-specific cpXY wheels, so a build # Guard the ABI: numpy/onnxruntime ship version-specific cpXY wheels, so a build
@ -73,131 +59,37 @@ else
echo " or use --cross to pin aarch64/$PYABI wheels explicitly." >&2 echo " or use --cross to pin aarch64/$PYABI wheels explicitly." >&2
exit 1 exit 1
fi fi
# Guard the ARCH too: a native build on an x86_64 workstation would download x86_64 wheels
# while the tarball is still named/declared aarch64 (line 27/86/92) — a bundle that installs
# then fails to import on the Pi. numpy/opencv/onnxruntime are per-arch, so this is fatal.
HOST_ARCH="$(uname -m)"
if [ "$HOST_ARCH" != "$TARGET_ARCH" ]; then
echo "ERROR: native build host arch is $HOST_ARCH but TARGET_ARCH is $TARGET_ARCH." >&2
echo " Build natively ON an $TARGET_ARCH host (a Pi 5 / arm64 CI), or use --cross." >&2
exit 1
fi
python3 -m pip download -d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}" python3 -m pip download -d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# CPU fallback wheel, best-effort — a Hailo bundle is complete without it.
python3 -m pip download -d "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime unavailable for this host; Hailo-only bundle (no CPU MoveNet)."
fi fi
echo ">> [2/6] fetching MoveNet ONNX model (optional CPU fallback; build-machine only)" echo ">> [2/5] fetching MoveNet ONNX model (build-machine only)"
# Optional: the MoveNet source may be unreachable, and a Hailo-8 delivery runs off the HEF. Do ( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet )
# not abort the whole bundle over the CPU fallback — stage it if we can, otherwise carry on. cp "$REPO_ROOT/models/movenet_lightning.onnx" "$STAGE/models/"
if ( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet ); then
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$STAGE/models/"
else
echo " -> MoveNet ONNX unavailable; Hailo-only bundle (CPU fallback not included)."
fi
echo ">> [3/6] fetching Hailo $HAILO_ARCH yolov8s_pose HEF (build-machine only)" echo ">> [3/5] staging edge source (package + pyproject)"
# Mandatory for a Hailo delivery: this is the primary pose backend. fetch_hailo_hef verifies the
# download against a pinned sha256 and refuses a mismatch, so a bad/tampered HEF aborts the build.
( cd "$REPO_ROOT" && HAILO_ARCH="$HAILO_ARCH" python3 -m edge.tools.fetch_hailo_hef --arch "$HAILO_ARCH" )
cp "$REPO_ROOT/models/yolov8s_pose.hef" "$STAGE/models/"
echo ">> [4/6] staging edge source (package + pyproject)"
cp -r "$REPO_ROOT/edge" "$STAGE/src/edge" cp -r "$REPO_ROOT/edge" "$STAGE/src/edge"
cp "$REPO_ROOT/pyproject.toml" "$STAGE/src/" cp "$REPO_ROOT/pyproject.toml" "$STAGE/src/"
# do not ship compiled caches # do not ship compiled caches
find "$STAGE/src" -name '__pycache__' -type d -prune -exec rm -rf {} + find "$STAGE/src" -name '__pycache__' -type d -prune -exec rm -rf {} +
echo ">> [5/6] staging deploy assets + install script" echo ">> [4/5] staging deploy assets + install script"
cp "$REPO_ROOT/deploy/systemd/move-detect-edge.service" "$STAGE/deploy/systemd/" cp "$REPO_ROOT/deploy/systemd/move-detect-edge.service" "$STAGE/deploy/systemd/"
cp "$REPO_ROOT/deploy/systemd/move-detect-edge.env.example" "$STAGE/deploy/systemd/" cp "$REPO_ROOT/deploy/systemd/move-detect-edge.env.example" "$STAGE/deploy/systemd/"
cp "$REPO_ROOT/deploy/bundle/install_offline.sh" "$STAGE/" cp "$REPO_ROOT/deploy/bundle/install_offline.sh" "$STAGE/"
cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true
chmod +x "$STAGE/install_offline.sh" chmod +x "$STAGE/install_offline.sh"
# Supply-chain integrity (air-gap: the target can't reach PyPI to re-resolve, so pin + hash here).
# requirements.lock — exact name==version of every bundled wheel, for a reproducible rebuild.
# sbom.json — CycloneDX component list: wheels AND models (name, version, sha256).
# SHA256SUMS — sha256 of EVERY staged file (code, unit, installer, env template, models,
# wheels, lock, sbom, MANIFEST). Generated LAST so it covers everything
# install_offline.sh lays down and runs as root — not just the wheels/models.
echo ">> generating requirements.lock + sbom.json"
: > "$STAGE/requirements.lock"
{
printf '{\n "bomFormat": "CycloneDX",\n "specVersion": "1.5",\n "version": 1,\n "components": [\n'
sep=""
for whl in "$WHEELHOUSE"/*.whl; do
[ -e "$whl" ] || continue
base="$(basename "$whl")"
name="$(echo "$base" | cut -d- -f1)" # wheel: {name}-{version}-{pyabi}-...-{plat}.whl
ver="$(echo "$base" | cut -d- -f2)"
sum="$(sha256sum "$whl" | cut -d' ' -f1)"
echo "${name}==${ver}" >> "$STAGE/requirements.lock"
printf '%s {"type":"library","name":"%s","version":"%s","purl":"pkg:pypi/%s@%s","hashes":[{"alg":"SHA-256","content":"%s"}]}' \
"$sep" "$name" "$ver" "$name" "$ver" "$sum"
sep=$',\n'
done
# ML weights are shipped artifacts too — record them so a customer-side SBOM audit is complete.
for mdl in "$STAGE"/models/*; do
[ -e "$mdl" ] || continue
mbase="$(basename "$mdl")"
msum="$(sha256sum "$mdl" | cut -d' ' -f1)"
printf '%s {"type":"file","name":"%s","hashes":[{"alg":"SHA-256","content":"%s"}]}' \
"$sep" "$mbase" "$msum"
sep=$',\n'
done
printf '\n ]\n}\n'
} > "$STAGE/sbom.json"
sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock"
# Which pose backends this bundle can actually run, so the operator knows before it crosses the
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
{ {
echo "bundle: move-detect-edge" echo "bundle: move-detect-edge"
echo "built_mode: $MODE" echo "built_mode: $MODE"
echo "python: $PYVER ($PYABI)" echo "python: $PYVER ($PYABI)"
echo "target_arch: $TARGET_ARCH" echo "target_arch: $TARGET_ARCH"
echo "hailo_arch: $HAILO_ARCH"
echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')"
echo "integrity: SHA256SUMS covers ALL bundle files (verified on install)$([ "$SIGNED" = yes ] && echo ', SHA256SUMS.asc (signed)')"
echo "models:"
ls -1 "$STAGE/models"
echo "wheels:" echo "wheels:"
ls -1 "$WHEELHOUSE" ls -1 "$WHEELHOUSE"
} > "$STAGE/MANIFEST.txt" } > "$STAGE/MANIFEST.txt"
# SHA256SUMS LAST: hash every staged file EXCEPT the manifest itself and its detached signature, echo ">> [5/5] packaging tarball"
# so the daemon source, systemd unit, installer, env template, models and wheels are ALL covered. TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}.tar.gz"
# A tampered edge/daemon.py can no longer pass `sha256sum -c` and then be installed and run. tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle"
( cd "$STAGE" && find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' -print0 \
| sort -z | xargs -0 sha256sum > SHA256SUMS )
# Optional detached GPG signature over the integrity file. When present, install_offline.sh
# REQUIRES it to verify (a failed signature is fatal there); when absent the bundle offers
# corruption-detection only. Provenance-critical deliveries MUST set SIGN_KEY.
if [ -n "${SIGN_KEY:-}" ]; then
gpg --local-user "$SIGN_KEY" --armor --detach-sign --output "$STAGE/SHA256SUMS.asc" "$STAGE/SHA256SUMS"
echo ">> signed SHA256SUMS with key $SIGN_KEY -> SHA256SUMS.asc"
else
echo ">> NOTE: SHA256SUMS is unsigned (corruption-detection only). Set SIGN_KEY=<gpg-id> for a"
echo ">> detached signature; install_offline.sh will then require it to verify."
fi
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
# 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"
tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install"
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."

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

@ -18,78 +18,7 @@ STATE=/var/lib/move-detect
ETC=/etc/move-detect ETC=/etc/move-detect
SVC_USER=movedetect SVC_USER=movedetect
WHEELHOUSE="$HERE/wheelhouse" WHEELHOUSE="$HERE/wheelhouse"
# Mandatory for every backend. onnxruntime is deliberately NOT here — see the optional RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
# install below; a Hailo bundle need not carry its wheel.
RUNTIME_PKGS=(numpy requests opencv-python-headless)
OPTIONAL_PKGS=(onnxruntime)
echo ">> [0/6] verify bundle integrity BEFORE touching the system"
# Verify EVERY bundled file against the build-time SHA256SUMS before any mutation of $PREFIX. A
# truncated/tampered bundle must abort HERE — with the previous install still intact — never after
# rm -rf has already destroyed it and left the Restart=always unit to run half-copied code. The
# build now hashes the daemon source, unit and this installer too, so tampered code cannot pass.
ALLOW_UNVERIFIED="${ALLOW_UNVERIFIED:-0}"
if [ -f "$HERE/SHA256SUMS" ]; then
( cd "$HERE" && sha256sum -c SHA256SUMS ) \
|| { echo "ERROR: bundle integrity check failed (SHA256SUMS mismatch) — refusing to install." >&2; exit 1; }
# sha256sum -c only checks LISTED files; an INJECTED extra wheel (which could win pip's version
# resolution) would pass unnoticed. Refuse any staged file not present in the manifest.
EXTRA="$( cd "$HERE" && comm -23 \
<(find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' | sed 's|^\./||' | sort) \
<(awk '{print $2}' SHA256SUMS | sed 's|^[*]||; s|^\./||' | sort) )"
if [ -n "$EXTRA" ]; then
echo "ERROR: bundle contains file(s) not listed in SHA256SUMS (possible injection):" >&2
echo "$EXTRA" >&2
exit 1
fi
# A detached signature proves PROVENANCE — an unsigned SHA256SUMS only detects corruption, since
# an attacker who can modify the bundle can strip the .asc and regenerate the hashes. Set
# REQUIRE_SIGNATURE=1 for untrusted transport to make a missing/invalid signature fatal.
REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-0}"
if [ -f "$HERE/SHA256SUMS.asc" ]; then
gpg --verify "$HERE/SHA256SUMS.asc" "$HERE/SHA256SUMS" 2>/dev/null \
|| { echo "ERROR: SHA256SUMS.asc present but its signature did NOT verify — refusing to install." >&2; exit 1; }
echo " -> SHA256SUMS signature verified"
elif [ "$REQUIRE_SIGNATURE" = 1 ]; then
echo "ERROR: REQUIRE_SIGNATURE=1 but this bundle has no SHA256SUMS.asc. An unsigned bundle" >&2
echo "detects corruption only, not tampering — refusing. Build with SIGN_KEY, or clear the flag." >&2
exit 1
else
echo " -> NOTE: unsigned bundle (corruption-detection only; set REQUIRE_SIGNATURE=1 to require a signature)."
fi
echo " -> bundle integrity OK"
elif [ "$ALLOW_UNVERIFIED" = 1 ]; then
echo " -> WARNING: no SHA256SUMS; ALLOW_UNVERIFIED=1 set — installing WITHOUT integrity check." >&2
else
echo "ERROR: no SHA256SUMS in this bundle — refusing to install unverified code. Rebuild with a" >&2
echo "current build_offline_bundle.sh, or (NOT recommended) re-run with ALLOW_UNVERIFIED=1." >&2
exit 1
fi
echo ">> [0b/6] Hailo arch preflight (BEFORE any mutation)"
# A hailo8 HEF on a hailo8l board (or vice-versa) imports fine but the daemon dies with
# HAILO_INVALID_HEF(26) on every start, forever. Catch the WRONG bundle here — using MANIFEST.txt
# (staged) + hailortcli (a system CLI, no venv needed) — so a mismatch aborts with the previous
# install still intact, not after the HEF/source/venv have already been overwritten.
BUNDLE_HAILO_ARCH="$(awk -F': ' '/^hailo_arch:/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}' "$HERE/MANIFEST.txt" 2>/dev/null || true)"
if [ -n "$BUNDLE_HAILO_ARCH" ]; then
BOARD_HAILO_ARCH=""
if command -v hailortcli >/dev/null 2>&1; then
BOARD_HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \
| awk -F': ' '/[Aa]rchitecture/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}')"
fi
if [ -n "$BOARD_HAILO_ARCH" ] && [ "$BUNDLE_HAILO_ARCH" != "$BOARD_HAILO_ARCH" ]; then
echo "ERROR: Hailo arch mismatch — this bundle's HEF is '$BUNDLE_HAILO_ARCH' but the board is" >&2
echo "'$BOARD_HAILO_ARCH'. It would crash-loop with HAILO_INVALID_HEF(26). Rebuild with" >&2
echo "HAILO_ARCH=$BOARD_HAILO_ARCH (deploy/bundle/build_offline_bundle.sh)." >&2
exit 1
elif [ -z "$BOARD_HAILO_ARCH" ]; then
echo " -> NOTE: could not read the board's Hailo arch (hailortcli?) to verify the bundle's" >&2
echo " '$BUNDLE_HAILO_ARCH'; if the daemon later fails HAILO_INVALID_HEF(26), the arch is wrong." >&2
else
echo " -> Hailo arch OK ($BUNDLE_HAILO_ARCH)"
fi
fi
echo ">> [1/6] service account + groups" echo ">> [1/6] service account + groups"
if ! id -u "$SVC_USER" >/dev/null 2>&1; then if ! id -u "$SVC_USER" >/dev/null 2>&1; then
@ -102,24 +31,15 @@ done
echo ">> [2/6] lay down source, model, docs under $PREFIX" echo ">> [2/6] lay down source, model, docs under $PREFIX"
install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs" install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs"
# Fully replace the package tree on re-install/upgrade, atomically. Stage the new tree beside the # Fully replace the package tree on re-install/upgrade. Without the rm, `cp -r SRC DEST`
# old and rename over it: an interrupted copy leaves the previous WORKING tree untouched (the # nests SRC *inside* an existing DEST (edge/edge/...) and the OLD code keeps running.
# Restart=always unit keeps running old code) instead of a half-copied one. The rename also avoids
# `cp -r SRC DEST` nesting SRC *inside* an existing DEST (edge/edge/...) and running the OLD code.
rm -rf "$PREFIX/src/edge.new"
cp -r "$HERE/src/edge" "$PREFIX/src/edge.new"
rm -rf "$PREFIX/src/edge" rm -rf "$PREFIX/src/edge"
mv "$PREFIX/src/edge.new" "$PREFIX/src/edge" cp -r "$HERE/src/edge" "$PREFIX/src/edge"
cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true
# Copy whichever pose models the bundle carries. Each is optional on its own — a Hailo bundle cp "$HERE/models/"*.onnx "$PREFIX/models/"
# ships only the .hef, a CPU bundle only the .onnx — so a missing glob must not abort the install
# (the [5/6] backend gate below is what refuses a bundle with NO usable backend).
cp "$HERE/models/"*.hef "$PREFIX/models/" 2>/dev/null || true
cp "$HERE/models/"*.onnx "$PREFIX/models/" 2>/dev/null || true
cp "$HERE/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true cp "$HERE/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true
echo ">> [3/6] python venv + offline wheel install (--no-index)" echo ">> [3/6] python venv + offline wheel install (--no-index)"
# Integrity was already verified up-front in step [0/6], before any system mutation.
# --system-site-packages so the venv can import the Pi SYSTEM packages that are NOT # --system-site-packages so the venv can import the Pi SYSTEM packages that are NOT
# pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and # pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and
# picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install # picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install
@ -129,96 +49,21 @@ echo ">> [3/6] python venv + offline wheel install (--no-index)"
python3 -m venv --system-site-packages "$PREFIX/venv" python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" --upgrade pip >/dev/null 2>&1 || true "$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" --upgrade pip >/dev/null 2>&1 || true
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${RUNTIME_PKGS[@]}" "$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# Optional: only present if the bundle was built for the CPU MoveNet backend. Still --no-index
# (zero fetches, air-gap intact); absent simply means Hailo-only.
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime not in the wheelhouse; Hailo-only bundle (CPU MoveNet unavailable)."
echo ">> [4/6] verify the edge package imports offline" echo ">> [4/6] verify the edge package imports offline"
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, onnxruntime, cv2, numpy, requests; print('edge import OK')"
# on the unconditional startup chain; the daemon's server clients import requests at startup).
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
# not carry it. Report, don't fail: a hard `import onnxruntime` here refuses the install over
# a package the daemon never loads.
if PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import onnxruntime" >/dev/null 2>&1; then
echo " -> onnxruntime present; CPU MoveNet available"
else
echo " -> onnxruntime absent; CPU MoveNet unavailable (set POSE_BACKEND=hailo in $ETC/edge.env)"
fi
echo ">> [5/6] pose backend gate + state dir + env file"
# Do NOT hand over a Pi whose POSE_BACKEND cannot load. The template ships POSE_BACKEND=movenet,
# but a Hailo-only bundle carries no onnxruntime/ONNX — installing it verbatim would crash-loop.
# Probe what can ACTUALLY run (like setup_online's [6/8]) and reconcile, or refuse.
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
pyhas() { PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; }
state() { if "$@" >/dev/null 2>&1; then echo present; else echo MISSING; fi; }
HAILO_OK=0; MOVENET_OK=0
if [ -f "$HEF_DEST" ] && pyhas hailo_platform; then HAILO_OK=1; fi
if [ -f "$ONNX_DEST" ] && pyhas onnxruntime; then MOVENET_OK=1; fi
if [ "$HAILO_OK" != 1 ] && [ "$MOVENET_OK" != 1 ]; then
echo "" >&2
echo "ERROR: no usable pose backend in this bundle — refusing to install a daemon that" >&2
echo "cannot start." >&2
echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform')" >&2
echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2
echo " Rebuild the bundle for the target backend (deploy/bundle/build_offline_bundle.sh)." >&2
exit 1
fi
# Prefer Hailo (the accelerator) when usable; MoveNet is the CPU fallback. (Arch already
# preflighted in [0b/6].)
if [ "$HAILO_OK" = 1 ]; then DEFAULT_BACKEND=hailo; else DEFAULT_BACKEND=movenet; fi
backend_usable() { # 0 (true) iff the named backend can actually load in THIS bundle
case "$1" in
hailo) [ "$HAILO_OK" = 1 ] ;;
movenet) [ "$MOVENET_OK" = 1 ] ;;
*) return 1 ;;
esac
}
echo ">> [5/6] state dir + env file"
install -d -m 0750 "$STATE" install -d -m 0750 "$STATE"
chown "$SVC_USER":"$SVC_USER" "$STATE" chown "$SVC_USER":"$SVC_USER" "$STATE"
install -d -m 0750 "$ETC" install -d -m 0750 "$ETC"
if [ ! -f "$ETC/edge.env" ]; then if [ ! -f "$ETC/edge.env" ]; then
POSE_BACKEND="$DEFAULT_BACKEND"
install -m 0640 "$HERE/deploy/systemd/move-detect-edge.env.example" "$ETC/edge.env" install -m 0640 "$HERE/deploy/systemd/move-detect-edge.env.example" "$ETC/edge.env"
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env" chown root:"$SVC_USER" "$ETC/edge.env"
echo " -> wrote $ETC/edge.env (POSE_BACKEND=$POSE_BACKEND; EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)" echo " -> wrote $ETC/edge.env from template (EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)"
else else
# Preserve the operator's DELIBERATE backend choice when it still works in this bundle; only echo " -> $ETC/edge.env already exists; left unchanged"
# switch when the current one can't load — the crash-loop this reconcile exists to prevent
# (e.g. a stale POSE_BACKEND=movenet on a Hailo-only bundle with no onnxruntime).
CURRENT_BACKEND="$(sed -n 's|^POSE_BACKEND=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)"
if backend_usable "$CURRENT_BACKEND"; then
POSE_BACKEND="$CURRENT_BACKEND"
echo " -> $ETC/edge.env POSE_BACKEND=$POSE_BACKEND is usable in this bundle; kept"
else
POSE_BACKEND="$DEFAULT_BACKEND"
if grep -q '^POSE_BACKEND=' "$ETC/edge.env"; then
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env"
else
printf 'POSE_BACKEND=%s\n' "$POSE_BACKEND" >> "$ETC/edge.env"
fi
echo " -> reconciled $ETC/edge.env POSE_BACKEND: '${CURRENT_BACKEND:-unset}' (unusable) -> '$POSE_BACKEND'"
fi
fi fi
# For the hailo backend, HAILO_HEF must point at a HEF that actually EXISTS — correct a missing
# key OR one left pointing at a stale/absent path, else the daemon crash-loops on a bad path.
if [ "$POSE_BACKEND" = hailo ]; then
CURRENT_HEF="$(sed -n 's|^HAILO_HEF=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)"
if [ -z "$CURRENT_HEF" ] || [ ! -f "$CURRENT_HEF" ]; then
if grep -q '^HAILO_HEF=' "$ETC/edge.env"; then
sed -i "s|^HAILO_HEF=.*|HAILO_HEF=$HEF_DEST|" "$ETC/edge.env"
else
printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env"
fi
echo " -> set HAILO_HEF=$HEF_DEST (was '${CURRENT_HEF:-unset}')"
fi
fi
chown root:"$SVC_USER" "$ETC/edge.env"
echo ">> [6/6] systemd unit" echo ">> [6/6] systemd unit"
install -m 0644 "$HERE/deploy/systemd/move-detect-edge.service" /etc/systemd/system/ install -m 0644 "$HERE/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
@ -226,11 +71,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"

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

@ -5,18 +5,9 @@
# 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
#
# Non-interactive: pass values as env vars (these override device.env), e.g.
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://server CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
# 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,56 +22,21 @@ 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 apt-get update
# /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend for the first few minutes — apt-get install -y python3-picamera2 python3-venv python3-pip
# 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.
# -o DPkg::Lock::Timeout is not enough on its own: apt applies it to the dpkg locks, but
# `apt-get update`'s lists lock is taken with a single non-blocking fcntl(F_SETLK) and no
# retry. That wait has to live here, in the shell.
# Retry ONLY on lock contention; a real error (e.g. hailo-all absent from the repo) must
# still fail fast so the `|| echo WARN` fallback below stays instant.
apt_retry() {
local tries=30 i=1 rc log
log="$(mktemp)"
while :; do
rc=0
# LC_ALL=C is load-bearing, not cosmetic: apt gettext-translates its errors, and sudo
# passes the caller's LANG/LC_* straight through. On a ko_KR Pi the lists-lock message
# renders as "%s 디렉터리를 잠글 수 없습니다", the grep below misses it, and lock
# contention gets misread as a real error — fail-fast, zero retries, exactly the bug
# this function exists to fix. Pinning it per-call (not script-wide) keeps pip/venv and
# fetch_movenet at [4/9]-[5/9] on the system locale.
LC_ALL=C apt-get -o DPkg::Lock::Timeout=60 "$@" 2>&1 | tee "$log" || rc=${PIPESTATUS[0]}
if [ "$rc" -eq 0 ]; then rm -f "$log"; return 0; fi
if ! grep -qE 'Could not get lock|Unable to lock|Unable to acquire the dpkg' "$log"; then
rm -f "$log"; return "$rc"
fi
if [ "$i" -ge "$tries" ]; then
rm -f "$log"
echo " ERROR: apt/dpkg still locked after $((tries * 10))s. See who holds it with:" >&2
echo " systemctl status apt-daily.service apt-daily-upgrade.service unattended-upgrades.service" >&2
return "$rc"
fi
echo " apt/dpkg locked by another process (apt-daily/unattended-upgrades); retry $i/$tries in 10s..."
i=$((i + 1)); sleep 10
done
}
apt_retry update
apt_retry install -y python3-picamera2 python3-venv python3-pip
# hailo-all is the Hailo driver+runtime; only present in Pi repos with a Hailo HAT. # hailo-all is the Hailo driver+runtime; only present in Pi repos with a Hailo HAT.
# Best-effort so a Pi without it still installs and runs on CPU MoveNet. # Best-effort so a Pi without it still installs and runs on CPU MoveNet.
apt_retry install -y hailo-all || echo " WARN: hailo-all not installed — Hailo accel unavailable, CPU MoveNet will be used." apt-get 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,172 +44,42 @@ 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`: # numpy is intentionally NOT pinned here: it comes in transitively via onnxruntime, and
# cv2 edge/snapshot.py imports it at MODULE level, and that module is on the # leaving the system numpy in place (the venv sees it) avoids shadowing the numpy that
# unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND # the apt python3-picamera2 stack was compiled against.
# can reach build_pose() without it. "$PREFIX/venv/bin/pip" install requests onnxruntime opencv-python-headless
# requests the daemon builds its server/alert clients with no injected HTTP fn, so
# `import requests` fires at startup regardless of backend.
# numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives
# transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless
# hard-requires numpy and opencv is mandatory, so numpy still always arrives; leaving it
# unnamed lets the resolver keep the apt numpy that python3-picamera2 was compiled against
# whenever that copy satisfies opencv's floor.
"$PREFIX/venv/bin/pip" install requests opencv-python-headless
# onnxruntime is the CPU-MoveNet-ONLY dep: movenet_pose.py imports it inside
# MoveNetPoseAdapter.__init__, and the Hailo path never imports the module at all. It
# publishes NO sdist and NO 32-bit-ARM wheel, so pip can legitimately find zero candidates
# and exit non-zero in seconds — which under `set -euo pipefail` used to kill this installer
# on a Hailo-8 Pi that never needed it. Best-effort, like hailo-all at [1/9]; [6/9] decides
# whether a backend actually exists.
"$PREFIX/venv/bin/pip" install onnxruntime \
|| echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)."
echo ">> [5/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 ( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet )
# fetches them there) and carried across with the repo copy — air-gap rule "build pulls, target cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true
# doesn't". This installer NEVER downloads a model on the Pi. On-device fetch was removed because
# it was fragile in two ways at once: a partial repo copy made `-m edge.tools.fetch_*` fail with a
# ModuleNotFoundError that the `|| echo WARN` mislabeled as "download failed", and the MoveNet URL
# is dead upstream anyway. MoveNet is the OPTIONAL CPU fallback; stage it only if present.
if cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null; then
echo " -> MoveNet ONNX staged from the delivery"
else
echo " note: no MoveNet ONNX in the delivery (expected for a Hailo device)."
fi
echo ">> [6/9] pose backend" echo ">> [6/8] pose backend"
POSE_BACKEND=movenet
HEF_DEST="$PREFIX/models/yolov8s_pose.hef" HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx" if [ -n "${HAILO_HEF:-}" ] && [ -f "${HAILO_HEF:-}" ]; then
if [ -n "${HAILO_HEF:-}" ]; then
# An explicit path that does not exist is a typo, not a request to fall back. The old single
# guarded `cp` skipped it with NO warning and produced the byte-identical HEF=MISSING table
# as the unset case, never echoing the bad value — and if an earlier run had left a
# $HEF_DEST behind, that stale HEF got adopted as though it were the one named.
[ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; }
cp "$HAILO_HEF" "$HEF_DEST" cp "$HAILO_HEF" "$HEF_DEST"
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
echo " WARN: no Hailo HEF in the delivery (models/yolov8s_pose.hef) and HAILO_HEF unset —"
echo " no Hailo backend. The probe below decides if CPU MoveNet can carry the install."
fi
# Probe what can ACTUALLY run, in the venv the service will use. A HEF on disk is not enough
# (hailo_platform comes from apt hailo-all, best-effort at [1/9]), and a pip exit code is not
# enough either (a wheel can install and still fail to load its .so). Both probes must sit
# inside `if` conditions — a bare `a && b` list that fails is fatal under set -e.
pyhas() { "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; }
state() { if "$@" >/dev/null 2>&1; then echo present; else echo MISSING; fi; }
HAILO_OK=0; MOVENET_OK=0
if [ -f "$HEF_DEST" ] && pyhas hailo_platform; then HAILO_OK=1; fi
if [ -f "$ONNX_DEST" ] && pyhas onnxruntime; then MOVENET_OK=1; fi
if [ "$HAILO_OK" = 1 ]; then
POSE_BACKEND=hailo POSE_BACKEND=hailo
echo " -> Hailo HEF + hailo_platform present; POSE_BACKEND=hailo" echo " -> Hailo HEF installed; POSE_BACKEND=hailo"
[ "$MOVENET_OK" = 1 ] || echo " (CPU MoveNet fallback unavailable — Hailo does not need it)" else
elif [ "$MOVENET_OK" = 1 ]; then echo " -> no HAILO_HEF given; starting on CPU MoveNet."
POSE_BACKEND=movenet
echo " -> no usable Hailo; starting on CPU MoveNet."
echo " For Hailo: copy yolov8s_pose.hef to $HEF_DEST, set POSE_BACKEND=hailo in" echo " For Hailo: copy yolov8s_pose.hef to $HEF_DEST, set POSE_BACKEND=hailo in"
echo " $ETC/edge.env, then: sudo systemctl restart move-detect-edge" echo " $ETC/edge.env, then: sudo systemctl restart move-detect-edge"
else
# Fail HERE, loudly, naming the fix — not at 3am. build_pose() runs outside the daemon's
# try/except, so a missing backend is an uncaught traceback (ModuleNotFoundError on the
# MoveNet path, KeyError 'HAILO_HEF' on the Hailo one) on every Restart=always cycle,
# forever — a unit that looks installed and never processes a frame.
# The board's own answer is the most diagnostic field here: it separates "no HEF" from
# "driver installed, reboot pending" from "no Hailo at all" — three different fixes.
HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \
| sed -n 's/.*Device Architecture:[[:space:]]*//p' | head -1)"
echo "" >&2
echo "ERROR: no usable pose backend — refusing to install a daemon that cannot start." >&2
echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform') board=${HAILO_ARCH:-NOT DETECTED}" >&2
echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2
echo "" >&2
echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2
echo "" >&2
echo " A) Hailo-8 HAT (the intended path). The HEF ships IN the delivery" >&2
echo " (models/yolov8s_pose.hef, staged on the build machine) — this installer does NOT" >&2
echo " download it. Read the table above:" >&2
echo " HEF=MISSING -> this Pi's copy of the delivery has no" >&2
echo " models/yolov8s_pose.hef. Re-copy it, or point at one:" >&2
echo " sudo HAILO_HEF=/path/to/yolov8s_pose.hef deploy/setup_online.sh" >&2
echo " hailo_platform=MISSING -> the HailoRT driver/runtime is not up yet:" >&2
echo " sudo apt-get install -y hailo-all && sudo reboot" >&2
echo " (/dev/hailo0 appears only after that reboot). Confirm:" >&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 " $PREFIX/venv/bin/pip install onnxruntime" >&2
echo "" >&2
echo " If onnxruntime AND hailo-all both refuse to install, check the userland arch:" >&2
echo " dpkg --print-architecture" >&2
echo " 'armhf' means 32-bit Pi OS. Neither package ships for it (uname -m still says" >&2
echo " aarch64 there — the kernel is 64-bit, the userland is not). Reflash 64-bit Pi OS." >&2
exit 1
fi fi
echo ">> [7/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 echo " -> $ETC/edge.env already exists; left unchanged"
# -> re-run. So [6/9] 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
# "POSE_BACKEND=hailo" — and with onnxruntime absent that is a ModuleNotFoundError crash
# loop the installer just called a success.
# Reconcile ONLY the backend keys, in place: DEVICE_SECRET and the operator's other answers
# must survive untouched.
cur="$(sed -n 's/^POSE_BACKEND=//p' "$ETC/edge.env" | head -1)"
if [ "$cur" = "$POSE_BACKEND" ]; then
echo " -> $ETC/edge.env already exists; left unchanged"
else
if [ -n "$cur" ]; then
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env"
else
printf 'POSE_BACKEND=%s\n' "$POSE_BACKEND" >> "$ETC/edge.env"
fi
grep -q '^HAILO_HEF=' "$ETC/edge.env" || printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env"
grep -q '^MOVENET_ONNX=' "$ETC/edge.env" || printf 'MOVENET_ONNX=%s\n' "$ONNX_DEST" >> "$ETC/edge.env"
# sed -i and >> can leave the file owned root:root, and the unit reads it as movedetect
# via EnvironmentFile= — a wrong group here fails the start with a bare permission error.
chown root:"$SVC_USER" "$ETC/edge.env"
chmod 0640 "$ETC/edge.env"
echo " -> $ETC/edge.env: POSE_BACKEND ${cur:-<unset>} -> $POSE_BACKEND (re-run reconciled)"
fi
else else
# 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 (cloud base URL): " CAFE24_URL
[ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS [ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
fi fi
DEVICE_ID="${DEVICE_ID:-pi-01}" DEVICE_ID="${DEVICE_ID:-pi-01}"
@ -273,69 +99,14 @@ 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
systemctl enable --now move-detect-edge
diagnose() {
echo "" >&2
echo "ERROR: move-detect-edge did not come up. systemd's reason:" >&2
echo "--------------------------------------------------------------------" >&2
systemctl status --no-pager --full move-detect-edge >&2 || true
journalctl -xeu move-detect-edge -n 50 --no-pager >&2 || true
echo "--------------------------------------------------------------------" >&2
echo "Full log: sudo journalctl -xeu move-detect-edge" >&2
exit 1
}
# enable + restart rather than `enable --now`, so a re-run also picks up a rewritten
# edge.env instead of no-op'ing on an already-running unit.
systemctl enable move-detect-edge
systemctl restart move-detect-edge || diagnose
# Type=exec fails the job on exec-step errors, but a Python traceback *after* execve still
# leaves `restart` returning 0. RestartSec=5 means a crash-looping unit reads as
# "activating" here, which is-active correctly rejects. This is a crash detector, not a
# readiness gate: under Type=exec the unit is active the moment execve() succeeds, so a
# slow camera/Hailo init cannot false-fail it.
sleep 5
systemctl is-active --quiet move-detect-edge || diagnose
# is-active alone is phase-dependent: a crash at T<=~0.2s after execve is already back in
# "active" by the time we sample (T + RestartSec=5 <= 5.2), so a crash-looping daemon can
# read as healthy. NRestarts is phase-independent — for a unit we just started, any restart
# at all means it has already died once.
if [ "$(systemctl show -p NRestarts --value move-detect-edge 2>/dev/null || echo 0)" != "0" ]; then
echo "" >&2
echo "ERROR: move-detect-edge has already restarted at least once — it is crash-looping." >&2
diagnose
fi
echo "" echo ""
echo "Done. The edge daemon is installed and started." echo "Done. The edge daemon is installed and started."

View file

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

View file

@ -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,23 +9,9 @@ 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
# Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in
# [Service] it is an unknown key — silently ignored, leaving the default 5-starts/10s
# limit to defeat Restart=always below.
StartLimitIntervalSec=0
[Service] [Service]
# Type=exec, not simple: with simple, systemd marks the start job done the instant fork() Type=simple
# returns, so an exec-step failure (226/NAMESPACE, 216/GROUP, 217/USER, 203/EXEC) never
# reaches `systemctl enable --now` and the installer reports success over a dead daemon.
Type=exec
# Dedicated unprivileged account; must be in the groups that own the camera and # Dedicated unprivileged account; must be in the groups that own the camera and
# the Hailo accelerator device nodes. # the Hailo accelerator device nodes.
User=movedetect User=movedetect
@ -42,36 +28,21 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge
# periodic-op exceptions internally so a single glitch never exits the process. # periodic-op exceptions internally so a single glitch never exits the process.
Restart=always Restart=always
RestartSec=5 RestartSec=5
StartLimitIntervalSec=0
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it). # Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
# systemd creates/owns /var/lib/move-detect for the service user. # systemd creates/owns /var/lib/move-detect for the service user.
StateDirectory=move-detect StateDirectory=move-detect
StateDirectoryMode=0750 StateDirectoryMode=0750
# Camera + accelerator device access. Once ANY DeviceAllow= is present, systemd denies every # Camera + accelerator device access (USB UVC = /dev/video*, Hailo = /dev/hailo0).
# unlisted device node, so the CSI camera needs its nodes explicitly:
# char-video4linux USB UVC (/dev/video*) AND the CSI capture nodes
# char-media picamera2/libcamera opens /dev/media* to configure the CSI pipeline (Pi 5)
# char-dma_heap libcamera allocates capture buffers from the dma-heap on Pi 5
# Without char-media + char-dma_heap a Hailo Pi with a CSI camera is denied its own camera by this
# unit's policy. (OFF-HW: verify on the Pi — `journalctl` shows a libcamera EPERM/open failure.)
#
# NOTE: DeviceAllow= takes a device-node path OR a `char-<group>`/`block-<group>` name — NOT a
# directory. `/dev/dma_heap/` (a directory, trailing slash) is rejected at cgroup-realize time and
# silently dropped, leaving dma-heap DENIED under this deny-by-default policy. Use the char group
# (dma_heap appears in /proc/devices as a char device) so the whole /dev/dma_heap/* set is allowed.
DeviceAllow=/dev/hailo0 rw DeviceAllow=/dev/hailo0 rw
DeviceAllow=char-video4linux rw DeviceAllow=char-video4linux rw
DeviceAllow=char-media rw
DeviceAllow=char-dma_heap rw
# Conservative hardening (loosen if it blocks device access on your image). # Conservative hardening (loosen if it blocks device access on your image).
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict
# Do NOT add ReadWritePaths=/var/lib/move-detect here. StateDirectory= above already emits ReadWritePaths=/var/lib/move-detect
# a recursive read-write bind mount for that exact path, which outranks a ReadWritePaths=
# entry for the same path and drops it before any path is resolved. It is dead config: it
# can neither cause nor fix a state-dir problem.
ProtectHome=true ProtectHome=true
PrivateTmp=true PrivateTmp=true

View file

@ -1,287 +0,0 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
verification of every blocker against real code. Ordered for sequential fixing. Each item is
checked off only after a test locks it and verification passes.
Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low ·
`OFF-HW` = cannot be verified without the Hailo board.
---
## Phase 1 — Silent failure & misconfiguration (the #1 delivery risk)
A safety system that *looks* healthy while doing nothing is the worst failure. Make every
misconfig/degradation fail LOUD.
- [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_serverclient.py.
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
DONE — env.example + installer prompt now show/require the `/move_detect` subdir.
- [x] **P1-3 [H]** `edge/config.py:40` + `edge/daemon.py` — empty `CAFE24_URL`/`DEVICE_SECRET`
defaults let the daemon run green while every upload fails and the queue grows forever.
**Fix:** fail loud at startup if either is blank.
DONE — `require_upload_config(cfg)` raises at daemon startup; test_config.py locks it.
- [x] **P1-4 [H]** `edge/daemon.py:23` — `run_loop` swallows every per-frame/per-camera
exception with no log and no degraded-monitoring signal → a permanently broken pipeline
looks green. **Fix:** rate-limited `logging.exception` + consecutive-error counter.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the server cannot tell a dead device from a live one.
**Fix:** real fps + per-camera frame-age/health in the heartbeat body.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
test_daemon.py locks fps math + failing-camera flag.
- [x] **P1-6 [H]** `edge/adapters/uvc_source.py:12` — a camera that stops delivering frames is
never detected or reopened; monitoring dies silently. **Fix:** fail loud on open failure;
emit `device_fault`/`monitoring_degraded` when a camera goes stale.
DONE — open-failure raises (build_runtimes logs+skips); read() releases+reopens after a
streak; heartbeat's cameras_erroring (P1-5) reports the fault. test_uvc_source.py locks it.
- [x] **P1-7 [M]** `edge/queue.py` — offline sqlite queue has no size cap/TTL; an outage (or a
permanent misconfig) fills the SD card, then new events drop silently. **Fix:** bounded
queue (row/byte budget), log dropped-oldest count.
DONE — `max_rows` (default 2000) evicts oldest on enqueue; test_queue.py locks it.
- [x] **P1-8 [H]** `edge/queue.py:52` — a permanently-rejected event (e.g. 400) is retried
forever at the head, blocking the whole queue. **Fix:** drop/quarantine after N failures.
DONE — `attempts` column; a row is dropped after `max_send_attempts` (default 20) so a
poison head unwedges the queue; test locks the drain-past-poison behavior.
- [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-generated `event_id` in the payload; server dedups on it.
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`
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.
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server).
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
DONE — `frame_interval_from_env` validates; test_config.py locks it.
- [x] **P1-11 [L]** `edge/daemon.py:120` — `QUEUE_DB` defaults to relative `queue.db`,
unwritable under the hardened unit (`ProtectSystem=strict`) → crash loop when env omitted.
**Fix:** absolute default under the state dir.
DONE — `queue_db_path` defaults to /var/lib/move-detect/queue.db; test_config.py locks it.
## Phase 2 — Core safety correctness (fall / zone / tracking)
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
(zone.py → engine.process → run_once threads frame_wh); Polygon::validate is range-agnostic
so PHP stores it opaquely. test_zone.py locks the normalized contract.
- [x] **P2-2 [B]** `edge/engine.py:72` — a `pinch` (협착) zone falls through to
`ZONE_WARNING`; the PINCH event + HIGH severity are never emitted. **Fix:** map
`pinch→PINCH`, `restricted→ZONE_RESTRICTED`, else WARNING.
DONE — dict map pinch→PINCH/restricted→ZONE_RESTRICTED; test_engine.py locks PINCH+HIGH.
- [x] **P2-3 [H]** `edge/detectors/fall.py:65` — a per-frame keypoint-confidence gate
silently disables fall, no_motion AND ghost for an occluded/low-confidence fallen person —
exactly when someone is on the floor. **Fix:** use bbox cues when keypoints are low-conf.
DONE — bbox cues (aspect/height-collapse) always run; keypoint cues gated by kp_ok;
test_fall.py locks both the low-conf detection and the no-false-positive-standing case.
- [x] **P2-4 [H]** `edge/detectors/ghost.py:38` — a ghost candidate is cancelled when ANY
person (a colleague) passes near the loss point. **Fix:** only the plausibly-same track
cancels it.
DONE — candidate snapshots the active track ids at loss time; only a NEW track (not a
known bystander) near the loss point cancels. test_ghost.py locks the bystander case.
- [x] **P2-5 [H]** `edge/geometry.py:66` — stillness compares raw per-frame displacement to a
fixed threshold with no dt normalization → immobility/no_motion flips with FPS. **Fix:**
normalize by elapsed time.
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
- [ ] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
reset per-track safety state on a low-confidence match.
- [ ] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
- [ ] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
confidence-gate the ankles, fall back to bbox-bottom-center.
DONE — `_foot` averages only ankles with conf ≥ MIN_ANKLE_CONF (0.2, the existing 'visible'
gate); with neither trustworthy it falls back to bbox bottom-centre. No tuning knob — reuses
the codebase's keypoint-visible convention. test_zone.py locks the fallback.
- [x] **P2-10 [M]** `edge/adapters/movenet_pose.py:34` — the CPU MoveNet fallback is
single-person; with 2+ workers all but one are invisible to every detector, no warning.
**Fix:** warn loudly that MoveNet fallback is single-person (Hailo is multi-person).
DONE — MoveNetPoseAdapter.__init__ logs a startup WARNING ("SINGLE-PERSON … use POSE_BACKEND=
hailo at multi-person sites"). test_movenet_pose.py asserts the warning via caplog.
- [x] **P2-11 [L]** `edge/geometry.py:39` — `head_below_hips` uses the NOSE keypoint
regardless of confidence. **Fix:** confidence-gate.
DONE — `head_below_hips` returns False when nose conf < nose_thr (0.2), so an unconfident/
hallucinated nose no longer fires the fall cue. test_geometry.py locks it; test_fall.py stays
green (the cue's confident-nose path is unchanged).
## Phase 3 — Privacy / PIPA (delivery compliance)
- [x] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
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.
- [x] **P3-2 [H]** `server/src/Ingest/IngestController.php:68` — worker snapshots are stored
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on 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.
- [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored
in the session but never enforced; any authenticated user sees every device's worker
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
`events.site` (server-derived, never client-supplied). `App\Security\SiteScope` centralizes the
rule (NULL scope = global operator). The event LIST filters in SQL (`page(...,$site)`); event
DETAIL + ACK and the SNAPSHOT proxy guard per-row and return 404 (not 403) cross-site so
existence never leaks. Migration ALTERs + provisioning UPDATEs documented in schema.sql.
Tests: list scoped, detail cross-site 404, snapshot cross-site 404 / same-site 200.
- [x] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
`fetch_hailo_hef.py`.
DONE (with a caveat) — rewrote to stage-validate: rejects HTML/LFS-pointer, optional pinned
sha256+size, temp-file staging, fails LOUD. DISCOVERY: the old URL is DEAD (GitHub 404 / HTML
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
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.
## Phase 4 — Server hardening (shared hosting)
- [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`.
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
backstop. Package build verified.
- [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error.
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.
- [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows.
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
gated 1/100). Full PHP suite green.
- [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params.
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
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
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
`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
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
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error.
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
- [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
order write→insert, log failures, reconcile orphans.
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
file write, DB index) now `error_log`s instead of a silent `continue`; a DB-index failure after
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
(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
`login` never checks. **Fix:** verify it (or drop it).
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
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
`testLoginRejectsMissingOrWrongCsrf`.
- [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
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
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
lengths in the validator.
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 `/`
(parse_url scheme-relative). **Fix:** collapse/normalize.
## Phase 5 — Install / bundle / supply-chain
DONE — pathFor collapses leading slashes before parse_url; BasePathTest locks it.
- [x] **P5-1 [B]** `deploy/bundle/install_offline.sh:79` — the OFFLINE installer has no
backend-usability gate (setup_online's [6/8] does): it installs `POSE_BACKEND=movenet`
verbatim even when the bundle carries no working backend. **Fix:** mirror the [6/8] probe.
DONE — [5/6] probes HEF+hailo_platform / onnx+onnxruntime, reconciles POSE_BACKEND in the
installed env, hard-fails if neither. test_deploy_artifacts.py locks the gate.
- [x] **P5-2 [H]** `deploy/bundle/build_offline_bundle.sh:55` — a native build on x86 produces
an aarch64-named tarball full of x86_64 wheels. **Fix:** assert `uname -m == TARGET_ARCH`.
DONE — native branch now hard-fails when uname -m != TARGET_ARCH.
- [x] **P5-3 [M]** `deploy/bundle/build_offline_bundle.sh:65` — the air-gapped bundle never
carries the Hailo HEF, so offline Hailo install is impossible. **Fix:** stage the HEF (via
`fetch_hailo_hef --arch`) into the bundle.
DONE — build now fetches the `$HAILO_ARCH` (default hailo8) HEF via `fetch_hailo_hef` (pinned
sha256, aborts on mismatch) and stages `yolov8s_pose.hef`. Also fixed two latent breaks that
made the offline path unusable regardless: the MoveNet ONNX fetch was unconditional so its dead
source aborted the whole build under `set -e` (now guarded/optional → Hailo-only bundle), and
`install_offline.sh` copied only `*.onnx` so a bundled HEF never reached `$PREFIX/models` for the
backend gate to find (now copies `*.hef` too). MANIFEST lists models + available pose_backends.
Tests: fetches/stages HEF, MoveNet optional, installer copies the HEF.
- [x] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests,
MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin +
hash + manifest digests.
DONE — build emits `SHA256SUMS` (sha256 of every wheel + model), `requirements.lock` (exact
`name==version` pins for a reproducible rebuild), and `sbom.json` (CycloneDX 1.5: name, version,
pypi purl, sha256). Optional detached GPG signature via `SIGN_KEY` → `SHA256SUMS.asc`.
`install_offline.sh` runs `sha256sum -c SHA256SUMS` (and verifies the signature if present)
BEFORE any pip install, hard-failing on mismatch. Generation logic verified end-to-end on a
synthetic stage (valid JSON, `sha256sum -c` passes). Tests lock the manifest + the install-time
check.
- [x] **P5-5 [H]** `deploy/systemd/move-detect-edge.service:46` `OFF-HW` — `DeviceAllow` omits
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
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
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
---
## Progress log
(updated as each item lands — newest first)
- **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
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 +
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
P2-6/7/8 (safety-timing / tracking-match — deferred to on-hardware commissioning).
- **2026-07-17 — off-hardware solo work COMPLETE (35/43).** Landed this pass, each test-locked:
P4-8 (login CSRF), P4-6 (zone 400-not-500), P4-5 (cursor pagination), P4-7 (snapshot atomicity
+ orphan cleanup), P5-3 (HEF in the offline bundle + fixed the broken build), P5-4 (pin + hash +
SBOM + install-time verify), P2-9/10/11 (keypoint confidence gating + MoveNet single-person
warning). phpunit 90, pytest 157 — all green.
REMAINING 8, none solo-doable:
· P1-9, P3-2b — need a DATA-MODEL decision (event dedup key + schema column; site↔device model).
· P2-6, P2-7, P2-8 — safety-timing / tracking-match logic that must be validated ON HARDWARE.
Changing them blind could make fall detection MISS events (worse than today's over-alarm,
which is fail-safe), so they are deliberately deferred to on-device commissioning.
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
knobs — tune with real camera footage on the Pi.)
- **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch
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
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
(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
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).
Nothing committed yet.
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

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

View file

@ -1,5 +1,3 @@
import logging
import cv2 import cv2
import numpy as np import numpy as np
@ -19,13 +17,6 @@ class MoveNetPoseAdapter:
session = onnxruntime.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) session = onnxruntime.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
self.session = session self.session = session
self._input_name = session.get_inputs()[0].name self._input_name = session.get_inputs()[0].name
# MoveNet SinglePose returns ONE person per frame. At a multi-worker site every worker
# but one is invisible to fall/zone/ghost detection with no other symptom — so say it
# loudly at startup. The Hailo backend (yolov8s_pose) is multi-person.
logging.getLogger("edge.pose").warning(
"MoveNet CPU fallback is SINGLE-PERSON: only one worker is tracked per frame; any "
"additional workers are invisible to fall/zone/ghost detection. Use the Hailo "
"backend (POSE_BACKEND=hailo) at multi-person sites.")
def infer(self, frame) -> list: def infer(self, frame) -> list:
h, w = frame.shape[:2] h, w = frame.shape[:2]

View file

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

View file

@ -2,28 +2,9 @@ import base64
import hashlib import hashlib
import hmac import hmac
import json import json
from enum import Enum
from edge.events import Event from edge.events import Event
# Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent
# with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC
# and URL. Send an explicit UA on every request. ServerClient imports this too.
USER_AGENT = "move-detect-edge/1.0"
class SendResult(Enum):
"""Outcome of one upload attempt. The offline queue must tell a transient outage (retry
forever — a real fall alert has to survive an overnight internet outage) apart from a
permanent rejection (poison — count toward the drop cap), which a bare bool cannot express.
DELIVERED is the only truthy value, so `if client.send(...)` keeps working unchanged."""
DELIVERED = "delivered"
REJECTED = "rejected" # a definite 4xx (not 429): the server will never accept this event
TRANSIENT = "transient" # network error / timeout / 429 / 5xx — connectivity or server hiccup
def __bool__(self) -> bool:
return self is SendResult.DELIVERED
def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str: def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str:
if isinstance(body, str): if isinstance(body, str):
@ -46,7 +27,7 @@ class PostClient:
http_post = requests.post http_post = requests.post
self._post = http_post self._post = http_post
def send(self, event: Event, snapshots: list[bytes]) -> SendResult: def send(self, event: Event, snapshots: list[bytes]) -> bool:
payload = event.to_payload(self.device_id) payload = event.to_payload(self.device_id)
payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots] payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots]
body = json.dumps(payload, sort_keys=True) body = json.dumps(payload, sort_keys=True)
@ -54,33 +35,12 @@ class PostClient:
ts = str(self.now_fn()) ts = str(self.now_fn())
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-Nonce": nonce, "X-Nonce": nonce,
"X-Timestamp": ts, "X-Timestamp": ts,
"X-Signature": sign(self.secret, nonce, ts, body.encode()), "X-Signature": sign(self.secret, nonce, ts, body.encode()),
} }
try: try:
# allow_redirects=False: the .htaccess http->https 302 must not silently become a resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout)
# GET (405/404). A redirect is a CAFE24_URL misconfiguration to surface, not follow.
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
allow_redirects=False)
except Exception: except Exception:
# Connection refused / DNS / timeout — the server is unreachable, not rejecting us. return False
return SendResult.TRANSIENT return 200 <= resp.status_code < 300
code = resp.status_code
if 200 <= code < 300:
return SendResult.DELIVERED
# Transient — "back off and retry", NOT a permanent verdict on the event: all 5xx plus the
# 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
# a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection.
#
# 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.REJECTED

View file

@ -11,32 +11,18 @@ from edge.models import BBox
def _head_boxes(persons: list) -> list: def _head_boxes(persons: list) -> list:
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed # 배경인물 머리영역 근사: 코 주변 작은 박스 (MVP; 실제는 얼굴 검출 포트로 교체)
# ~40x40 patch — a close worker fills far more than 40px, so a constant box left the face
# fully identifiable. Centered on the nose when it is confident, else on the bbox's top
# (the head is the upper part of the person) so a bad keypoint can't move the blur off it.
boxes = [] boxes = []
for p in persons: for p in persons:
bb = p.bbox
nose = p.keypoints[0] nose = p.keypoints[0]
half_w = max(8.0, 0.25 * bb.width) boxes.append(BBox(nose.x - 20, nose.y - 25, nose.x + 20, nose.y + 15))
if nose.conf >= 0.3:
cx, cy = nose.x, nose.y
else:
cx, cy = bb.cx, bb.y1 + 0.12 * bb.height
boxes.append(BBox(cx - half_w, cy - 0.18 * bb.height,
cx + half_w, cy + 0.08 * bb.height))
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
@ -45,11 +31,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
f = tr.latest f = tr.latest
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now) f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame) ring.push(now, frame)
h, w = frame.shape[:2] events = engine.process(tracks, lost, now)
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
# 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:

View file

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

View file

@ -1,4 +1,3 @@
import math
from dataclasses import dataclass from dataclasses import dataclass
from edge.detectors.fall import FallConfig from edge.detectors.fall import FallConfig
@ -25,89 +24,13 @@ def _f(env: dict, key: str, default: float) -> float:
return float(env[key]) if key in env else default return float(env[key]) if key in env else default
def frame_interval_from_env(env: dict) -> float:
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
systemd restart loop (Restart=always + StartLimitIntervalSec=0)."""
raw = env.get("TARGET_FPS", "12")
try:
fps = float(raw)
except (TypeError, ValueError):
raise ValueError(f"TARGET_FPS={raw!r} is not a number")
if fps <= 0:
raise ValueError(f"TARGET_FPS={raw!r} must be > 0")
return 1.0 / fps
def queue_db_path(env: dict) -> str:
"""Absolute sqlite path for the offline queue. A relative 'queue.db' resolves against the
unit's read-only WorkingDirectory under ProtectSystem=strict and cannot be created ->
crash loop. Default to the systemd StateDirectory, the one writable path."""
return env.get("QUEUE_DB") or "/var/lib/move-detect/queue.db"
# Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real
# provisioned value. A unit left at any of these uploads nothing while looking healthy.
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
# Per-device secrets issued by the server are HMAC-derived (long hex); anything shorter is a
# stub left in the template, not a real key.
_MIN_SECRET_LEN = 16
def require_upload_config(cfg: Config) -> None:
"""Fail LOUD at startup when the daemon cannot possibly deliver events.
load_config defaults cafe24_url and secret to '' so unit tests can build a Config from a
minimal env — but a real daemon with either blank, left at a ship-time placeholder, or on
plaintext http runs perfectly green while every upload fails (a bad/empty URL makes requests
raise, which PostClient catches and returns False; http is refused by allow_redirects=False),
and the offline queue grows until the SD card is full. The operator sees a healthy unit and
an empty dashboard, or worker snapshots leave the Pi in plaintext. Refuse to start instead.
"""
problems: list[str] = []
url = cfg.cafe24_url.strip()
secret = cfg.secret.strip()
if not url:
problems.append("CAFE24_URL not set")
elif not url.lower().startswith("https://"):
problems.append(f"CAFE24_URL must be https:// (got {url!r}) — plaintext would leak "
"worker snapshots and the .htaccess redirect is not followed")
elif any(m in url.lower() for m in _PLACEHOLDER_MARKERS):
problems.append(f"CAFE24_URL is still a template placeholder ({url!r})")
if not secret:
problems.append("DEVICE_SECRET not set")
elif any(m in secret.lower() for m in _PLACEHOLDER_MARKERS):
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
elif len(secret) < _MIN_SECRET_LEN:
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
"per-device secret issued by the server, not a stub")
if problems:
raise ValueError(
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
"but upload nothing. CAFE24_URL must include the app path, e.g. "
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the server."
)
def load_config(env: dict) -> Config: def load_config(env: dict) -> Config:
camera_id = env.get("CAMERA_ID", "cam-01") camera_id = env.get("CAMERA_ID", "cam-01")
# Per-second stillness threshold (units/second), shared by fall no-motion and immobility.
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
motion_thr = _f(env, "MOTION_THR", 0.25)
if not math.isfinite(motion_thr) or motion_thr <= 0:
raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}")
fall = FallConfig( fall = FallConfig(
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0), prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0), no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
motion_thr=motion_thr,
) )
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0), immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0))
motion_thr=motion_thr)
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0)) ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75)) zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0)) engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))

View file

@ -1,136 +1,42 @@
import os import os
# Rate-limit for the swallowed-exception log: a camera failing every frame must be VISIBLE
# (not a silent green unit) but must not spam the journal. Log the 1st of a run of consecutive
# failures, then every LOG_EVERY-th, and again after any recovery.
_LOG_EVERY = 300
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
import sys
import traceback
print(msg, file=sys.stderr)
if exc is not None:
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300, sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None, log=None, frame_interval=0.0, zones_for=None):
heartbeat_interval_s=None, health_fn=None, configured_cameras=None):
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All # `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
# cameras share this loop, one shared clock read per tick, and the periodic # cameras share this loop, one shared clock read per tick, and the periodic
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged, # sync/heartbeat/flush. A per-camera step exception is counted, never fatal.
# never fatal — but it is no longer SILENT (a permanently broken pipeline used to look
# perfectly green).
runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime] runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime]
if zones_for is None: if zones_for is None:
from edge.zones import zones_from_config from edge.zones import zones_from_config
zones_for = lambda cfg, rt: zones_from_config(cfg) zones_for = lambda cfg, rt: zones_from_config(cfg)
if log is None:
log = _default_log
if health_fn is None:
from edge.health import read_health
health_fn = read_health
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0} counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc=None):
counters["errors"] += 1
n = streak.get(key, 0) + 1
streak[key] = n
if n == 1 or n % _LOG_EVERY == 0:
if exc is not None:
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc)
else:
log(f"{label} (consecutive #{n})", None)
beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
last_beat = None # wall time of the last heartbeat, for the time-based cadence
reported_version = None # last config version the server told us; echoed in heartbeats
i = 0 i = 0
while iterations is None or i < iterations: while iterations is None or i < iterations:
i += 1 i += 1
now = clock() # one timestamp per tick, shared across cameras now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None:
beat_anchor = (now, 0)
if last_beat is None:
last_beat = now
for rt in runtimes: for rt in runtimes:
try: try:
produced = rt.step(now) rt.step(now)
except Exception as e:
cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} step failed", e)
continue
if produced is None:
# step() returned no frame (unplugged/browned-out camera). Count it toward the
# erroring streak instead of as a healthy frame — otherwise a dead camera ticks
# frames++ forever and reports fps~=target with cameras_erroring=0 (a silent
# monitoring outage that looks green). A single dropped frame self-clears on the
# next good frame, so only SUSTAINED starvation shows as erroring.
cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} produced no frame")
else:
counters["frames"] += 1 counters["frames"] += 1
streak.pop(id(rt), None) # recovered — reset the streak except Exception:
counters["errors"] += 1
try: try:
if sync_every and i % sync_every == 0: if sync_every and i % sync_every == 0:
cfg = client.fetch_config() cfg = client.fetch_config()
if cfg is not None: if cfg is not None:
for rt in runtimes: for rt in runtimes:
rt.apply_zones(zones_for(cfg, rt)) rt.apply_zones(zones_for(cfg, rt))
# Remember the config version so heartbeats can echo it back — the server's
# config-drift indicator is meaningless until the device reports what it runs.
if isinstance(cfg, dict) and cfg.get("version") is not None:
reported_version = int(cfg["version"])
counters["syncs"] += 1 counters["syncs"] += 1
# Heartbeat cadence: time-based when heartbeat_interval_s is set (production), so the if heartbeat_every and i % heartbeat_every == 0:
# interval is predictable regardless of per-tick cost (CPU MoveNet, multi-cam, thermal if client.send_heartbeat({"fps": 0.0, "frames": counters["frames"]}):
# throttle can push a tick past 100ms) — an iteration count cannot guarantee a wall-clock
# cadence, and the server marks a device offline on ELAPSED time. Falls back to the
# iteration count when unset (used by the deterministic unit tests).
if heartbeat_interval_s:
do_beat = (now - last_beat) >= heartbeat_interval_s
else:
do_beat = bool(heartbeat_every) and (i % heartbeat_every == 0)
if do_beat:
# Measured fps over the window since the last beat, and per-camera health, so
# the server can tell a live device from a degraded/dead one (was hardcoded 0.0
# + a frames counter that ticked even when no frame was read).
t0, f0 = beat_anchor
elapsed = now - t0
fps = (counters["frames"] - f0) / elapsed if elapsed > 0 else 0.0
# cameras_total is the CONFIGURED count, not just the started one: a camera that
# failed to construct at boot (wrong /dev index, unseated ribbon) is skipped by
# build_runtimes and would otherwise vanish from the heartbeat entirely — the
# server could never see that a configured camera is missing. Count the not-started
# cameras as erroring so a 4-cam device that lost one reports 4 total / 1 erroring.
total = configured_cameras if configured_cameras is not None else len(runtimes)
not_started = max(0, total - len(runtimes))
erroring = not_started + sum(1 for rt in runtimes if id(rt) in streak)
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
"cameras_total": total, "cameras_erroring": erroring,
"errors": counters["errors"], "queue_pending": queue.pending()}
# Pi thermal telemetry (None off-Pi or on failure → field omitted, never a fake 0).
health = health_fn()
if health.get("temp") is not None:
metrics["temp"] = health["temp"]
if health.get("throttle") is not None:
metrics["throttle"] = health["throttle"]
if reported_version is not None:
metrics["config_version"] = reported_version
if client.send_heartbeat(metrics):
counters["beats"] += 1 counters["beats"] += 1
beat_anchor = (now, counters["frames"])
last_beat = now
if flush_every and i % flush_every == 0: if flush_every and i % flush_every == 0:
queue.flush() queue.flush()
counters["flushes"] += 1 counters["flushes"] += 1
streak.pop("periodic", None) except Exception:
except Exception as e: counters["errors"] += 1
note("periodic", "periodic op (sync/heartbeat/flush) failed", e)
if frame_interval: if frame_interval:
sleep(frame_interval) sleep(frame_interval)
return counters return counters
@ -181,9 +87,8 @@ def build_runtimes(cameras, make_source, make_runtime, log=None):
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device) def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
import time import time
from dataclasses import replace from dataclasses import replace
from edge.config import (frame_interval_from_env, load_config, queue_db_path, from edge.config import load_config
require_upload_config) from edge.cloudclient import CloudClient
from edge.serverclient import ServerClient
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
@ -207,33 +112,24 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
return MoveNetPoseAdapter(os.environ["MOVENET_ONNX"]) return MoveNetPoseAdapter(os.environ["MOVENET_ONNX"])
cfg = load_config(dict(os.environ)) cfg = load_config(dict(os.environ))
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
nonce = lambda: os.urandom(8).hex() nonce = lambda: os.urandom(8).hex()
# Two clocks, deliberately: the loop/detector timers run on a MONOTONIC clock so an NTP step now = time.time
# (a Pi 5 has no RTC battery; it boots on stale fake-hwclock time until chrony corrects it)
# cannot instantly satisfy immobile_s, expire every track, or reset an immobility countdown.
# Wall time is used only where a real timestamp is required: the HMAC X-Timestamp headers,
# which the server validates against its own wall clock, and Event.occurred_at.
mono = time.monotonic
wall = time.time
base = cfg.cafe24_url.rstrip("/") base = cfg.cafe24_url.rstrip("/")
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, wall) client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, wall) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(queue_db_path(dict(os.environ)), post) queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
pose = build_pose() # shared across cameras pose = build_pose() # shared across cameras
def make_runtime(cam, source): def make_runtime(cam, source):
# Per-camera config so events are stamped with that camera's id. # Per-camera config so events are stamped with that camera's id.
cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam)) cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam))
return PipelineRuntime(cam_cfg, source, pose, queue, wall_clock=wall) return PipelineRuntime(cam_cfg, source, pose, queue)
# A missing/unseated camera is skipped (logged), not fatal — the others still run. # A missing/unseated camera is skipped (logged), not fatal — the others still run.
cameras = parse_cameras(dict(os.environ), cfg.camera_id) runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
runtimes = build_runtimes(cameras, build_source, make_runtime) build_source, make_runtime)
run_loop(runtimes, client, queue, clock=mono, sleep=time.sleep, iterations=None, run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
frame_interval=frame_interval_from_env(dict(os.environ)), frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")),
heartbeat_interval_s=30, # predictable cadence; server marks offline after 120s (4x)
configured_cameras=len(cameras), # so a camera that failed to start stays visible
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id)) zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))

View file

@ -15,7 +15,7 @@ class FallConfig:
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴) height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
prone_confirm_s: float = 3.0 prone_confirm_s: float = 3.0
no_motion_s: float = 10.0 no_motion_s: float = 10.0
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env motion_thr: float = 0.02 # 정규화 관절변위 임계
min_conf: float = 0.5 min_conf: float = 0.5
min_visible: int = 10 min_visible: int = 10
recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간 recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간
@ -40,19 +40,18 @@ class FallDetector:
def forget(self, track_id: int) -> None: def forget(self, track_id: int) -> None:
self._states.pop(track_id, None) self._states.pop(track_id, None)
def _prone(self, kps, bbox, st: _State, kp_ok: bool) -> tuple[bool, dict]: def _prone(self, kps, bbox, st: _State) -> tuple[bool, dict]:
angle = torso_angle_deg(kps)
ar = bbox.aspect ar = bbox.aspect
remaining = 1.0 remaining = 1.0
if st.baseline_height: if st.baseline_height:
remaining = bbox.height / st.baseline_height remaining = bbox.height / st.baseline_height
# bbox cues need NO keypoints — a fallen person is wide-and-short, or their bbox height prone = (
# collapsed. These stay live even when the pose model has lost confidence (occluded / angle > self.cfg.fallen_angle
# on the floor), which is exactly when a fall must not be missed. or ar > self.cfg.aspect_ratio
prone = ar > self.cfg.aspect_ratio or remaining < self.cfg.height_collapse or remaining < self.cfg.height_collapse # 높이 급붕괴(정면/후면 낙상)
angle = torso_angle_deg(kps) or head_below_hips(kps)
# keypoint cues only when the keypoints are actually trustworthy, else they are noise. )
if kp_ok:
prone = prone or angle > self.cfg.fallen_angle or head_below_hips(kps)
return prone, {"torso_angle": round(angle, 1), return prone, {"torso_angle": round(angle, 1),
"aspect_ratio": round(ar, 2), "aspect_ratio": round(ar, 2),
"height_remaining": round(remaining, 2)} "height_remaining": round(remaining, 2)}
@ -62,34 +61,20 @@ class FallDetector:
f = track.latest f = track.latest
kps, bbox = f.keypoints, f.bbox kps, bbox = f.keypoints, f.bbox
# Confidence gate — no longer a blanket early-return (that disabled fall/no_motion/ # 신뢰도 게이트
# ghost for the occluded person on the ground). It now only decides whether the if not core_visible(kps, self.cfg.min_conf) or visible_count(kps) < self.cfg.min_visible:
# KEYPOINT-based cues are trustworthy; the bbox cues always run. return None, {}
kp_ok = core_visible(kps, self.cfg.min_conf) and visible_count(kps) >= self.cfg.min_visible
prone, meta = self._prone(kps, bbox, st, kp_ok) prone, meta = self._prone(kps, bbox, st)
# incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze. Only trust a # incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze
# confident standing frame for the baseline so a bad low-conf bbox can't corrupt it. if st.name == "STANDING" and not prone:
if st.name == "STANDING" and not prone and kp_ok:
st.baseline_height = bbox.height st.baseline_height = bbox.height
if not prone: if not prone:
if st.name in ("FALLING", "FALLEN", "NO_MOTION"): if st.name in ("FALLING", "FALLEN", "NO_MOTION"):
# Only a CONFIDENT non-prone frame may recover an active incident. A low-conf
# (kp_ok False) frame's bbox-only "not prone" reading is untrusted: occlusion of
# a still-fallen worker must FREEZE the incident, not run the recovery countdown
# on noise. Freezing also protects baseline_height below (only seeded here, when
# kp_ok holds, so a bad low-conf bbox can't corrupt the next fall's collapse cue).
if not kp_ok:
# Occlusion PAUSES recovery — it must not count toward the grace window. Reset
# so recovery requires a FRESH, continuous run of confident non-prone frames;
# otherwise a confident frame at t0 plus a lone confident frame after a long
# low-conf gap would recover instantly on wall-clock elapsed alone.
st.not_prone_since = None
return None, {}
# 단일 튐(noisy) 프레임만으로 CRITICAL 사고를 즉시 취소하지 않도록 # 단일 튐(noisy) 프레임만으로 CRITICAL 사고를 즉시 취소하지 않도록
# recover_grace_s만큼 지속된 (신뢰) 비-prone을 요구한다. # recover_grace_s만큼 지속된 비-prone을 요구한다.
if st.not_prone_since is None: if st.not_prone_since is None:
st.not_prone_since = now st.not_prone_since = now
return None, {} return None, {}

View file

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

View file

@ -6,7 +6,7 @@ from edge.geometry import track_displacement
@dataclass @dataclass
class ImmobilityConfig: class ImmobilityConfig:
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env motion_thr: float = 0.02
immobile_s: float = 120.0 immobile_s: float = 120.0
zone_immobile_s: float = 30.0 zone_immobile_s: float = 30.0

View file

@ -3,9 +3,6 @@ from dataclasses import dataclass
from edge.tracking import Track from edge.tracking import Track
from edge.models import L_ANKLE, R_ANKLE from edge.models import L_ANKLE, R_ANKLE
# Below this an ankle keypoint is treated as unusable (typically hallucinated at conf~0).
MIN_ANKLE_CONF = 0.2
def point_in_polygon(x: float, y: float, poly: list[tuple[float, float]]) -> bool: def point_in_polygon(x: float, y: float, poly: list[tuple[float, float]]) -> bool:
inside = False inside = False
@ -43,36 +40,16 @@ class ZoneIntrusionDetector:
self._inside_since = {k: v for k, v in self._inside_since.items() if k[0] != track_id} self._inside_since = {k: v for k, v in self._inside_since.items() if k[0] != track_id}
self._fired = {k for k in self._fired if k[0] != track_id} self._fired = {k for k in self._fired if k[0] != track_id}
def _foot(self, track: Track, frame_wh=None) -> tuple[float, float]: def _foot(self, track: Track) -> tuple[float, float]:
p = track.latest kps = track.latest.keypoints
a, b = p.keypoints[L_ANKLE], p.keypoints[R_ANKLE] a, b = kps[L_ANKLE], kps[R_ANKLE]
# Average only the ankles the model is confident about. A hallucinated ankle (conf~0, return ((a.x + b.x) / 2, (a.y + b.y) / 2)
# often pinned to (0,0) or a frame corner) would otherwise drag the foot point into or
# out of a zone. With neither ankle trustworthy, fall back to the bbox bottom-centre — a
# robust standing-foot proxy (same bbox-fallback discipline as fall.py's cues).
good = [k for k in (a, b) if k.conf >= MIN_ANKLE_CONF]
if good:
fx = sum(k.x for k in good) / len(good)
fy = sum(k.y for k in good) / len(good)
else:
fx, fy = p.bbox.cx, p.bbox.y2
# Zones are stored NORMALIZED (0..1 of the image) by the dashboard, which stretches the
# camera snapshot to a fixed canvas. The foot is camera-native pixels, so normalize it
# by the frame size to compare in the same space — otherwise every zone is misplaced
# (a zone drawn at image-centre would be checked against a raw pixel like 480, matching
# nothing on a 1280-wide frame). frame_wh=None keeps raw coords for unit tests that
# supply their own matching space.
if frame_wh:
w, h = frame_wh
if w > 0 and h > 0:
fx, fy = fx / w, fy / h
return (fx, fy)
def active_zone_ids(self, track_id: int) -> set: def active_zone_ids(self, track_id: int) -> set:
return {z for (tid, z) in self._inside_since if tid == track_id} return {z for (tid, z) in self._inside_since if tid == track_id}
def update(self, track: Track, now: float, frame_wh=None) -> list[dict]: def update(self, track: Track, now: float) -> list[dict]:
fx, fy = self._foot(track, frame_wh) fx, fy = self._foot(track)
out = [] out = []
for zone in self.zones: for zone in self.zones:
key = (track.track_id, zone.zone_id) key = (track.track_id, zone.zone_id)

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