Compare commits
No commits in common. "9ed759befcf61566238c94694c571121a9ee9e48" and "34dcf557e985dbb601d145624773b123171dcc55" have entirely different histories.
9ed759befc
...
34dcf557e9
169 changed files with 1679 additions and 7919 deletions
10
.gitattributes
vendored
10
.gitattributes
vendored
|
|
@ -4,13 +4,3 @@
|
|||
*.sh text eol=lf
|
||||
*.service 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
10
.gitignore
vendored
|
|
@ -12,12 +12,7 @@ dist/
|
|||
# Edge runtime artifacts
|
||||
*.db
|
||||
*.hef
|
||||
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
|
||||
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
|
||||
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
|
||||
# edge never writes a snapshots directory anyway: they stay in memory and are base64'd into
|
||||
# the queue db or POSTed (edge/queue.py, edge/alert.py).
|
||||
/snapshots/
|
||||
snapshots/
|
||||
queue.db
|
||||
|
||||
# Secrets / local config
|
||||
|
|
@ -30,6 +25,3 @@ Thumbs.db
|
|||
|
||||
# Tool caches
|
||||
.impeccable/
|
||||
|
||||
# Pi provisioning (real device secret) — never commit
|
||||
deploy/device.env
|
||||
|
|
|
|||
1
server/.gitignore → cloud/.gitignore
vendored
1
server/.gitignore → cloud/.gitignore
vendored
|
|
@ -2,4 +2,3 @@
|
|||
/config.php
|
||||
/storage/snapshots/*
|
||||
!/storage/snapshots/.gitkeep
|
||||
/.phpunit.result.cache
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "move-detect/server",
|
||||
"name": "move-detect/cloud",
|
||||
"type": "project",
|
||||
"require": { "php": ">=8.2" },
|
||||
"require-dev": { "phpunit/phpunit": "^11" },
|
||||
0
server/composer.lock → cloud/composer.lock
generated
0
server/composer.lock → cloud/composer.lock
generated
9
cloud/config.example.php
Normal file
9
cloud/config.example.php
Normal 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,
|
||||
];
|
||||
|
|
@ -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
|
||||
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.
|
||||
|
||||
## 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),
|
||||
or be blocked by hosting-level access rules if your plan cannot place files outside the
|
||||
web root at all.
|
||||
|
|
@ -15,73 +15,38 @@ web root at all.
|
|||
```
|
||||
/home/<account>/ <- NOT web-reachable
|
||||
├── src/ <- App\ classes (App\Db, App\Repository\Pdo, ...)
|
||||
├── vendor/ <- Composer's generated autoloader ONLY (see below)
|
||||
├── storage/
|
||||
│ └── snapshots/ <- re-encoded JPEGs; must be writable by the PHP process
|
||||
├── config.php <- copied from config.example.php, secrets filled in (see §3)
|
||||
├── schema.sql <- only needed transiently for the phpMyAdmin import (§4);
|
||||
│ delete it from the server afterwards
|
||||
└── public/ <- web root. On Cafe24 it is named `www`; when that `www`
|
||||
├── index.php already serves another site the app is mounted in a
|
||||
├── .htaccess subdirectory (`www/move_detect/`) and the entries above
|
||||
└── assets/ sit at the FTP root — see below.
|
||||
└── public/ <- web root ("html" folder in Cafe24's control panel)
|
||||
├── index.php
|
||||
├── .htaccess
|
||||
└── assets/
|
||||
```
|
||||
|
||||
`server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
|
||||
itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
|
||||
hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a
|
||||
subdirectory of one — in the latter case the app root is two levels up, not one, because
|
||||
nothing secret may sit under a docroot the other site also serves.
|
||||
`cloud/public/index.php` resolves its dependencies as `__DIR__ . '/../config.php'`,
|
||||
`__DIR__ . '/../src/View/...'`, etc. — so `src/`, `config.php`, and `storage/` must be
|
||||
exactly one directory above `public/`, matching the layout above.
|
||||
|
||||
The URL prefix comes from `SCRIPT_NAME` (`App\Http\Request::baseFrom()`), which the web
|
||||
server sets — never from client input. `Request::pathFor()` strips it before routing, and
|
||||
views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without that, routes
|
||||
declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
|
||||
resolve against the co-hosted site.
|
||||
**Do not upload `vendor/`.** It contains only `phpunit` and its dev dependencies (see
|
||||
`cloud/composer.json` — `require-dev` only, `require` has no packages beyond the `php`
|
||||
version constraint). Shipping it would put a large, unused, occasionally-vulnerable
|
||||
dependency tree on a production box for no benefit; leave it off the transfer entirely.
|
||||
|
||||
`server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
|
||||
(empty) to mount the app directly in `www/`.
|
||||
|
||||
**`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
|
||||
`require __DIR__ . '/../vendor/autoload.php'`, so a transfer without `vendor/` fatals on
|
||||
every request, including §10's own smoke check. There are no runtime Composer *packages*
|
||||
(`composer.lock`'s `packages` is empty), but the app does need Composer's generated
|
||||
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
|
||||
|
||||
```bash
|
||||
cd 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.
|
||||
If your Cafe24 plan cannot place folders outside the web root, upload `src/`, `storage/`,
|
||||
and `config.php` into `public/` but add explicit deny rules for them in
|
||||
`public/.htaccess` (e.g. `RewriteRule ^(src|storage)/ - [F]` and
|
||||
`RewriteRule ^config\.php$ - [F]`, placed **before** the existing rewrite-to-index rule) —
|
||||
placing them outside the web root is strongly preferred and should be the default choice.
|
||||
|
||||
## 2. Host prerequisites — verify before importing data
|
||||
|
||||
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:
|
||||
|
||||
- [ ] **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
|
||||
version Cafe24 provisions for your plan).
|
||||
- [ ] **`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`
|
||||
|
||||
Copy the example and fill in real values — **never commit `config.php`** (already covered
|
||||
by `server/.gitignore`):
|
||||
by `cloud/.gitignore`):
|
||||
|
||||
```bash
|
||||
cp config.example.php config.php
|
||||
|
|
@ -139,7 +104,7 @@ or version control.
|
|||
|
||||
## 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`).
|
||||
|
||||
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
|
||||
attack surface for no benefit.
|
||||
|
||||
**Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression
|
||||
default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a
|
||||
hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an
|
||||
older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1
|
||||
blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel
|
||||
(or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either
|
||||
request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied
|
||||
timestamp (the code already passes one on every insert).
|
||||
|
||||
Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
|
||||
(on `role`, `event_type`, `severity`, `level`) are parsed but silently not enforced — harmless,
|
||||
since the application already whitelists these values before every insert
|
||||
If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
|
||||
(on `role`, `event_type`, `severity`, `level`) will be 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`).
|
||||
Everything else in the schema (tables, keys, indexes, `JSON` columns) requires only
|
||||
MySQL 5.7+.
|
||||
|
||||
## 5. Storage directory
|
||||
|
||||
|
|
@ -195,14 +153,14 @@ php -r "echo bin2hex(random_bytes(32)), \"\n\";"
|
|||
```
|
||||
|
||||
```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);
|
||||
```
|
||||
|
||||
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
|
||||
`/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.
|
||||
|
||||
## 7. First admin account
|
||||
|
|
@ -222,7 +180,7 @@ php -r "echo password_hash('CHANGE-ME-to-a-real-passphrase', PASSWORD_ARGON2ID),
|
|||
`PASSWORD_ARGON2ID`.)
|
||||
|
||||
```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);
|
||||
```
|
||||
|
||||
97
cloud/public/assets/app.css
Normal file
97
cloud/public/assets/app.css
Normal 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
217
cloud/public/assets/app.js
Normal 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);
|
||||
})();
|
||||
|
|
@ -10,10 +10,6 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
|
||||
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
|
||||
var B = window.APP_BASE || '';
|
||||
|
||||
var SEVERITY_LABELS = {
|
||||
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' },
|
||||
high: { icon: '⚠', text: '높음', cls: 'sev-high' },
|
||||
|
|
@ -101,7 +97,7 @@
|
|||
snaps.forEach(function (s) {
|
||||
if (!s || !s.file_uuid) return;
|
||||
var img = document.createElement('img');
|
||||
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||||
img.src = '/snap/' + encodeURIComponent(s.file_uuid);
|
||||
img.alt = '사고 스냅샷';
|
||||
img.className = 'detail-snapshot';
|
||||
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) {
|
||||
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);
|
||||
return res.json();
|
||||
})
|
||||
|
|
@ -8,10 +8,6 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
|
||||
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
|
||||
var B = window.APP_BASE || '';
|
||||
|
||||
var form = document.querySelector('.login-form');
|
||||
if (!form) return;
|
||||
|
||||
|
|
@ -30,7 +26,7 @@
|
|||
var formData = new FormData(form);
|
||||
var body = new URLSearchParams(formData).toString();
|
||||
|
||||
fetch(B + '/login', {
|
||||
fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body
|
||||
|
|
@ -38,7 +34,7 @@
|
|||
.then(function (res) {
|
||||
if (res.status === 200) {
|
||||
// Success: redirect to dashboard
|
||||
window.location.href = B + '/';
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
// Error: parse response and show error message
|
||||
|
|
@ -4,22 +4,15 @@
|
|||
* - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones.
|
||||
* - "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.
|
||||
* - "현재 화면 불러오기" fetches the camera's newest snapshot uuid via
|
||||
* GET /api/cameras/{camera}/latest-snapshot and draws it as the canvas
|
||||
* background, so zones are drawn over what the camera actually sees.
|
||||
* - 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.
|
||||
* - If a `?snapshot={uuid}` query param is present, that image (served via
|
||||
* the authenticated /snap/{uuid} proxy) is drawn as the canvas background;
|
||||
* otherwise the canvas keeps its plain dark backdrop.
|
||||
*
|
||||
* XSS-safe: all dynamic text is set via textContent, never innerHTML.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
|
||||
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
|
||||
var B = window.APP_BASE || '';
|
||||
|
||||
var canvas = document.getElementById('zone-canvas');
|
||||
if (!canvas) return;
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
|
@ -28,7 +21,6 @@
|
|||
var cameraInput = document.getElementById('camera-id');
|
||||
var levelSelect = document.getElementById('zone-level');
|
||||
var loadBtn = document.getElementById('zone-load-btn');
|
||||
var frameBtn = document.getElementById('zone-frame-btn');
|
||||
var clearBtn = document.getElementById('zone-clear-btn');
|
||||
var saveBtn = document.getElementById('zone-save-btn');
|
||||
var listEl = document.getElementById('zone-list');
|
||||
|
|
@ -50,23 +42,16 @@
|
|||
}
|
||||
|
||||
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 (data) { if (data && data.token) csrfToken = data.token; })
|
||||
.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) {
|
||||
try {
|
||||
var pts = JSON.parse(zone.polygon_json || '[]');
|
||||
if (!Array.isArray(pts)) return [];
|
||||
return pts.map(function (p) {
|
||||
return [p[0] * canvas.width, p[1] * canvas.height];
|
||||
});
|
||||
return Array.isArray(pts) ? pts : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -131,32 +116,16 @@
|
|||
var img = new Image();
|
||||
img.onload = function () { backgroundImage = img; redraw(); };
|
||||
img.onerror = function () { backgroundImage = null; redraw(); };
|
||||
img.src = B + '/snap/' + encodeURIComponent(uuid);
|
||||
}
|
||||
|
||||
// Load the camera's most recent frame as the drawing background. The uuid comes from the
|
||||
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
|
||||
function loadLatestFrame() {
|
||||
var camera = cameraInput.value.trim();
|
||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||
setStatus('현재 화면을 불러오는 중...');
|
||||
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) throw new Error('이 카메라의 저장된 화면이 아직 없습니다. 이벤트가 한 번 발생해야 배경이 생깁니다.');
|
||||
if (!r.ok) throw new Error('불러오기 실패 (' + r.status + ')');
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) { loadBackground(data.uuid); setStatus('현재 화면을 배경으로 불러왔습니다.'); })
|
||||
.catch(function (e) { setStatus(e.message); });
|
||||
img.src = '/snap/' + encodeURIComponent(uuid);
|
||||
}
|
||||
|
||||
function loadZones() {
|
||||
var camera = cameraInput.value.trim();
|
||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||
setStatus('불러오는 중...');
|
||||
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
||||
fetch('/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
||||
.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);
|
||||
return res.json();
|
||||
})
|
||||
|
|
@ -179,22 +148,18 @@
|
|||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
|
||||
setStatus('저장 중...');
|
||||
fetch(B + '/api/zones', {
|
||||
fetch('/api/zones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
|
||||
body: JSON.stringify({
|
||||
device_id: device,
|
||||
camera_id: camera,
|
||||
level: levelSelect.value,
|
||||
// Normalize canvas pixels -> 0..1 of the image (the edge un-normalizes with the
|
||||
// camera's own resolution). See the parsePolygon contract note above.
|
||||
polygon: currentPoints.map(function (p) {
|
||||
return [p[0] / canvas.width, p[1] / canvas.height];
|
||||
})
|
||||
polygon: currentPoints
|
||||
})
|
||||
})
|
||||
.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);
|
||||
return res.json();
|
||||
})
|
||||
|
|
@ -208,12 +173,12 @@
|
|||
}
|
||||
|
||||
function deleteZone(zoneId) {
|
||||
fetch(B + '/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
|
||||
fetch('/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||
})
|
||||
.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);
|
||||
loadZones();
|
||||
})
|
||||
|
|
@ -232,7 +197,6 @@
|
|||
|
||||
levelSelect.addEventListener('change', redraw);
|
||||
loadBtn.addEventListener('click', loadZones);
|
||||
if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
|
||||
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
|
||||
saveBtn.addEventListener('click', saveZone);
|
||||
|
||||
66
cloud/public/index.php
Normal file
66
cloud/public/index.php
Normal 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();
|
||||
|
|
@ -36,7 +36,7 @@ SET NAMES utf8mb4;
|
|||
-- 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.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE move_users (
|
||||
CREATE TABLE users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(190) NOT NULL,
|
||||
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
|
||||
-- (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,
|
||||
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
|
||||
config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config
|
||||
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
|
||||
last_heartbeat_at BIGINT UNSIGNED NULL,
|
||||
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
|
||||
PRIMARY KEY (device_id),
|
||||
KEY ix_devices_site (site)
|
||||
PRIMARY KEY (device_id)
|
||||
) 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
|
||||
-- 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,
|
||||
device_id VARCHAR(64) NOT 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
|
||||
-- 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
|
||||
device_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
|
||||
-- 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_uuid CHAR(32) NULL, -- edge-generated idempotency key (dedup a re-sent event); NULL for legacy rows
|
||||
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,
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
severity VARCHAR(16) NOT NULL,
|
||||
|
|
@ -129,15 +125,9 @@ CREATE TABLE move_events (
|
|||
acknowledged_at BIGINT UNSIGNED NULL,
|
||||
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
||||
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_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_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
|
||||
CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||
'fall','no_motion','immobility','ghost','zone_warning',
|
||||
'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
|
||||
-- before calling SnapshotRepository::add() for its snapshots.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE move_event_snapshots (
|
||||
CREATE TABLE event_snapshots (
|
||||
snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
event_id INT UNSIGNED NOT NULL,
|
||||
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,
|
||||
PRIMARY KEY (snapshot_id),
|
||||
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_created (created_at), -- retention purge scans WHERE created_at < ?
|
||||
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES move_events (event_id)
|
||||
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id)
|
||||
ON DELETE CASCADE
|
||||
) 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 --
|
||||
-- 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,
|
||||
device_id VARCHAR(64) NOT 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,
|
||||
-- not the earlier seen() check).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE move_nonces (
|
||||
CREATE TABLE nonces (
|
||||
nonce VARCHAR(190) NOT NULL,
|
||||
expires_at BIGINT UNSIGNED NOT NULL,
|
||||
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;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -208,14 +193,11 @@ CREATE TABLE move_nonces (
|
|||
-- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in
|
||||
-- PdoRateLimiterRepository::hit() -- no read-modify-write race window.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE move_rate_limits (
|
||||
CREATE TABLE rate_limits (
|
||||
rate_key VARCHAR(190) NOT NULL,
|
||||
window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket
|
||||
count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
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)
|
||||
PRIMARY KEY (rate_key, window_start)
|
||||
) 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
|
||||
-- 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,
|
||||
actor VARCHAR(190) 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_actor (actor)
|
||||
) 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).
|
||||
-- ===========================================================================
|
||||
|
|
@ -5,23 +5,17 @@ namespace App\Api;
|
|||
use App\Config;
|
||||
use App\Http\{Request, Response};
|
||||
use App\Security\Hmac;
|
||||
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
|
||||
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository};
|
||||
|
||||
final class ConfigController
|
||||
{
|
||||
public function __construct(
|
||||
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
|
||||
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');
|
||||
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
|
||||
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';
|
||||
38
cloud/src/Api/HeartbeatController.php
Normal file
38
cloud/src/Api/HeartbeatController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ declare(strict_types=1);
|
|||
namespace App\Auth;
|
||||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Security\{Password, Csrf};
|
||||
use App\Security\Password;
|
||||
use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository};
|
||||
|
||||
final class AuthController
|
||||
|
|
@ -28,14 +28,6 @@ final class AuthController
|
|||
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');
|
||||
$password = (string)$req->post('password');
|
||||
$u = $this->users->byUsername($username);
|
||||
|
|
@ -75,11 +67,8 @@ final class AuthController
|
|||
|
||||
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->regenerate();
|
||||
return Response::json(['ok' => true], 200);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,15 +18,9 @@ final class ArraySessionStore implements SessionStore {
|
|||
}
|
||||
|
||||
final class PhpSessionStore implements SessionStore {
|
||||
/** @param string $cookiePath Scope the cookie to the mount ('/' at the web root,
|
||||
* '/move_detect/' in a subdirectory) so a co-hosted site
|
||||
* sharing the same www/ is never sent this session id. */
|
||||
public function __construct(string $cookiePath = '/') {
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start([
|
||||
public function __construct() { 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 set(string $k, mixed $v): void { $_SESSION[$k] = $v; }
|
||||
public function clear(): void { $_SESSION = []; session_destroy(); }
|
||||
|
|
@ -16,5 +16,4 @@ final class Config
|
|||
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 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); }
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ namespace App\Dashboard;
|
|||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Auth\{SessionStore, Rbac};
|
||||
use App\Security\{Csrf, SiteScope};
|
||||
use App\Security\Csrf;
|
||||
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
||||
|
||||
final class EventDetailController
|
||||
|
|
@ -14,20 +14,12 @@ final class EventDetailController
|
|||
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
|
||||
{
|
||||
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$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);
|
||||
}
|
||||
|
||||
|
|
@ -39,10 +31,6 @@ final class EventDetailController
|
|||
return Response::json(['error' => 'csrf'], 403);
|
||||
}
|
||||
$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');
|
||||
if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404);
|
||||
$this->audit->append($user, 'acknowledge', "event:$id", [], []);
|
||||
20
cloud/src/Dashboard/EventListController.php
Normal file
20
cloud/src/Dashboard/EventListController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
21
cloud/src/Dashboard/SnapshotProxy.php
Normal file
21
cloud/src/Dashboard/SnapshotProxy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
66
cloud/src/Dashboard/ZoneConfigController.php
Normal file
66
cloud/src/Dashboard/ZoneConfigController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
cloud/src/Http/Request.php
Normal file
32
cloud/src/Http/Request.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,9 +12,8 @@ final class Response
|
|||
public static function text(string $s, int $status = 200): self {
|
||||
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 {
|
||||
// Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden.
|
||||
return new self($status, ['Content-Type' => $contentType] + $headers, $b);
|
||||
public static function bytes(string $b, string $contentType, int $status = 200): self {
|
||||
return new self($status, ['Content-Type' => $contentType], $b);
|
||||
}
|
||||
public static function redirect(string $to): self {
|
||||
return new self(302, ['Location' => $to], '');
|
||||
|
|
@ -15,11 +15,7 @@ final class Router
|
|||
foreach ($this->routes as [$method, $regex, $handler]) {
|
||||
if ($req->method() !== $method) continue;
|
||||
if (preg_match($regex, $req->path(), $m)) {
|
||||
// Decode captured params: a camera_id/zone_id with a space or Korean text is
|
||||
// %-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));
|
||||
$params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||
return $handler($req, $params);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,24 +31,10 @@ final class EventValidator
|
|||
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['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' => []];
|
||||
|
||||
$clean = [
|
||||
'event_uuid' => isset($p['event_uuid']) ? (string)$p['event_uuid'] : null,
|
||||
'device_id' => (string)$p['device_id'],
|
||||
'camera_id' => (string)$p['camera_id'],
|
||||
'event_type' => (string)$p['event_type'],
|
||||
73
cloud/src/Ingest/IngestController.php
Normal file
73
cloud/src/Ingest/IngestController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
10
cloud/src/Repository/DeviceRepository.php
Normal file
10
cloud/src/Repository/DeviceRepository.php
Normal 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;
|
||||
}
|
||||
11
cloud/src/Repository/EventRepository.php
Normal file
11
cloud/src/Repository/EventRepository.php
Normal 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;
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ final class PdoAuditRepository implements AuditRepository
|
|||
public function append(string $actor, string $action, string $target, array $before, array $after): void
|
||||
{
|
||||
$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 (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$st->execute([
|
||||
50
cloud/src/Repository/Pdo/PdoDeviceRepository.php
Normal file
50
cloud/src/Repository/Pdo/PdoDeviceRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ use PDO;
|
|||
final class PdoEventRepository implements EventRepository
|
||||
{
|
||||
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';
|
||||
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
|
@ -16,15 +16,13 @@ final class PdoEventRepository implements EventRepository
|
|||
public function insert(array $event): int
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'INSERT INTO move_events
|
||||
(event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
|
||||
'INSERT INTO events
|
||||
(device_id, camera_id, event_type, severity, track_id, zone_id,
|
||||
occurred_at, duration_s, metrics_json, received_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$st->execute([
|
||||
$event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy)
|
||||
(string)$event['device_id'],
|
||||
$event['site'] ?? null, // denormalized from move_devices.site by IngestController
|
||||
(string)$event['camera_id'],
|
||||
(string)$event['event_type'],
|
||||
(string)$event['severity'],
|
||||
|
|
@ -38,20 +36,12 @@ final class PdoEventRepository implements EventRepository
|
|||
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
|
||||
{
|
||||
if ($severity !== null) {
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT ' . self::SELECT_COLUMNS . '
|
||||
FROM move_events WHERE severity = ?
|
||||
FROM events WHERE severity = ?
|
||||
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
|
||||
);
|
||||
$st->bindValue(1, $severity, PDO::PARAM_STR);
|
||||
|
|
@ -59,7 +49,7 @@ final class PdoEventRepository implements EventRepository
|
|||
} else {
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT ' . self::SELECT_COLUMNS . '
|
||||
FROM move_events
|
||||
FROM events
|
||||
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
|
||||
);
|
||||
$st->bindValue(1, $limit, PDO::PARAM_INT);
|
||||
|
|
@ -68,45 +58,9 @@ final class PdoEventRepository implements EventRepository
|
|||
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
|
||||
{
|
||||
$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]);
|
||||
$row = $st->fetch();
|
||||
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
|
||||
// already-acknowledged event by the same user would otherwise report
|
||||
// 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]);
|
||||
if ($exists->fetchColumn() === false) {
|
||||
return false;
|
||||
}
|
||||
$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]);
|
||||
return true;
|
||||
|
|
@ -135,9 +89,7 @@ final class PdoEventRepository implements EventRepository
|
|||
{
|
||||
return [
|
||||
'event_id' => (int)$row['event_id'],
|
||||
'event_uuid' => $row['event_uuid'] ?? null,
|
||||
'device_id' => $row['device_id'],
|
||||
'site' => $row['site'] ?? null,
|
||||
'camera_id' => $row['camera_id'],
|
||||
'event_type' => $row['event_type'],
|
||||
'severity' => $row['severity'],
|
||||
|
|
@ -20,21 +20,14 @@ final class PdoNonceRepository implements NonceRepository
|
|||
|
||||
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]);
|
||||
return (bool)$st->fetchColumn();
|
||||
}
|
||||
|
||||
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]);
|
||||
// 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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
cloud/src/Repository/Pdo/PdoRateLimiterRepository.php
Normal file
32
cloud/src/Repository/Pdo/PdoRateLimiterRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
50
cloud/src/Repository/Pdo/PdoSnapshotRepository.php
Normal file
50
cloud/src/Repository/Pdo/PdoSnapshotRepository.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ final class PdoThresholdRepository implements ThresholdRepository
|
|||
|
||||
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]);
|
||||
|
||||
$out = [];
|
||||
|
|
@ -13,7 +13,7 @@ final class PdoUserRepository implements UserRepository
|
|||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'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]);
|
||||
$row = $st->fetch();
|
||||
|
|
@ -36,14 +36,14 @@ final class PdoUserRepository implements UserRepository
|
|||
// Increment in SQL (not read-then-write in PHP) so concurrent failed
|
||||
// logins for the same account can't clobber each other's count.
|
||||
$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]);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,27 +3,17 @@ declare(strict_types=1);
|
|||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\ZoneRepository;
|
||||
use App\Repository\ZoneOwnershipException;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class PdoZoneRepository implements ZoneRepository
|
||||
{
|
||||
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
|
||||
{
|
||||
$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]);
|
||||
return $st->fetchAll();
|
||||
|
|
@ -32,7 +22,7 @@ final class PdoZoneRepository implements ZoneRepository
|
|||
public function forCamera(string $cameraId): array
|
||||
{
|
||||
$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]);
|
||||
return $st->fetchAll();
|
||||
|
|
@ -52,7 +42,7 @@ final class PdoZoneRepository implements ZoneRepository
|
|||
|
||||
$newId = bin2hex(random_bytes(16));
|
||||
$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 (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]);
|
||||
|
|
@ -61,7 +51,7 @@ final class PdoZoneRepository implements ZoneRepository
|
|||
|
||||
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]);
|
||||
return $st->rowCount() > 0;
|
||||
}
|
||||
|
|
@ -89,15 +79,15 @@ final class PdoZoneRepository implements ZoneRepository
|
|||
string $polygonJson,
|
||||
): string {
|
||||
$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]);
|
||||
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(
|
||||
'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 = ?'
|
||||
);
|
||||
$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
|
||||
// above and this UPDATE. Disambiguate with a targeted existence
|
||||
// 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]);
|
||||
if ($stillExists->fetchColumn() === false) {
|
||||
throw new ZoneOwnershipException('zone was deleted concurrently');
|
||||
throw new RuntimeException('zone was deleted concurrently');
|
||||
}
|
||||
}
|
||||
return $zoneId;
|
||||
10
cloud/src/Repository/SnapshotRepository.php
Normal file
10
cloud/src/Repository/SnapshotRepository.php
Normal 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;
|
||||
}
|
||||
|
|
@ -6,8 +6,6 @@ interface ZoneRepository
|
|||
{
|
||||
public function forDevice(string $deviceId): 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 delete(string $zoneId): bool;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ render_header('이벤트 상세');
|
|||
<h1>이벤트 상세</h1>
|
||||
<div id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
||||
<div id="event-detail-snapshots"></div>
|
||||
<p><a href="<?= view_url('/') ?>">← 목록으로</a></p>
|
||||
<script src="<?= view_url('/assets/detail.js') ?>"></script>
|
||||
<p><a href="/">← 목록으로</a></p>
|
||||
<script src="/assets/detail.js"></script>
|
||||
<?php
|
||||
render_footer();
|
||||
9
cloud/src/View/events.php
Normal file
9
cloud/src/View/events.php
Normal 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
53
cloud/src/View/layout.php
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ $csrf = $_SESSION['csrf'];
|
|||
|
||||
render_header('로그인');
|
||||
?>
|
||||
<form method="post" action="<?= view_url('/login') ?>" class="login-form">
|
||||
<form method="post" action="/login" class="login-form">
|
||||
<h1>로그인</h1>
|
||||
<input type="hidden" name="csrf" value="<?= view_e($csrf) ?>">
|
||||
<label for="username">아이디</label>
|
||||
|
|
@ -20,6 +20,6 @@ render_header('로그인');
|
|||
<button type="submit">로그인</button>
|
||||
<p id="login-error" class="form-error" role="alert" hidden></p>
|
||||
</form>
|
||||
<script src="<?= view_url('/assets/login.js') ?>" defer></script>
|
||||
<script src="/assets/login.js" defer></script>
|
||||
<?php
|
||||
render_footer();
|
||||
|
|
@ -9,7 +9,6 @@ render_header('구역 설정');
|
|||
<label for="camera-id">카메라 ID</label>
|
||||
<input type="text" id="camera-id" placeholder="cam-01">
|
||||
<button type="button" id="zone-load-btn">불러오기</button>
|
||||
<button type="button" id="zone-frame-btn">현재 화면 불러오기</button>
|
||||
</div>
|
||||
<div class="zone-controls">
|
||||
<label for="zone-level">등급</label>
|
||||
|
|
@ -24,6 +23,6 @@ render_header('구역 설정');
|
|||
<canvas id="zone-canvas" width="960" height="540" tabindex="0" aria-label="구역 편집 캔버스: 클릭하여 폴리곤 점을 추가합니다"></canvas>
|
||||
<ul id="zone-list" aria-live="polite"></ul>
|
||||
<p id="zone-status" role="status"></p>
|
||||
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
|
||||
<script src="/assets/zone-draw.js"></script>
|
||||
<?php
|
||||
render_footer();
|
||||
|
|
@ -5,7 +5,7 @@ use App\Config;
|
|||
use App\Http\Request;
|
||||
use App\Security\Hmac;
|
||||
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;
|
||||
|
||||
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);
|
||||
$this->assertSame(200, $res->status());
|
||||
$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 {
|
||||
$cfg = Config::fromArray([]);
|
||||
|
|
@ -98,15 +85,4 @@ final class ApiSyncTest extends TestCase
|
|||
$req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-stale', '1000');
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
|
@ -15,16 +15,12 @@ final class AuthControllerTest extends TestCase
|
|||
'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 {
|
||||
return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p, 'csrf' => 'tok']);
|
||||
return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]);
|
||||
}
|
||||
public function testLoginSuccessSetsSession(): void {
|
||||
$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');
|
||||
$this->assertSame(200, $res->status());
|
||||
$this->assertSame(1, $sess->get('user_id'));
|
||||
|
|
@ -32,39 +28,39 @@ final class AuthControllerTest extends TestCase
|
|||
}
|
||||
public function testBadPasswordIs401(): void {
|
||||
$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 {
|
||||
$repo = $this->users();
|
||||
$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
|
||||
$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 {
|
||||
$repo = $this->users();
|
||||
$c = new AuthController($repo, new InMemoryAuditRepository(), new InMemoryRateLimiter(), maxAttempts: 3, lockSeconds: 900);
|
||||
// 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
|
||||
$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
|
||||
$sess = $this->sess();
|
||||
$sess = new ArraySessionStore();
|
||||
$res = $c->login($this->req('alice', 'pw'), $sess, now: 2000, clientIp: '1.2.3.4');
|
||||
$this->assertSame(200, $res->status());
|
||||
$this->assertSame(1, $sess->get('user_id'));
|
||||
}
|
||||
public function testUnknownUsernameReturns401GenericBody(): void {
|
||||
$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());
|
||||
// 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());
|
||||
}
|
||||
public function testLogoutClearsSession(): void {
|
||||
$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');
|
||||
$this->assertSame(1, $sess->get('user_id'));
|
||||
$res = $c->logout($sess);
|
||||
|
|
@ -76,22 +72,8 @@ final class AuthControllerTest extends TestCase
|
|||
// throttled before the (expensive) Argon2id verify runs repeatedly.
|
||||
$c = new AuthController($this->users(), new InMemoryAuditRepository(), new InMemoryRateLimiter(), loginIpLimitPerMin: 2);
|
||||
$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'), $this->sess(), now: 1000, clientIp: $ip)->status());
|
||||
$this->assertSame(429, $c->login($this->req('alice', 'pw'), $this->sess(), 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());
|
||||
$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'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
|
||||
$this->assertSame(429, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000, clientIp: $ip)->status());
|
||||
}
|
||||
}
|
||||
60
cloud/tests/DashboardTest.php
Normal file
60
cloud/tests/DashboardTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -15,35 +15,6 @@ final class EventValidatorTest extends TestCase
|
|||
$this->assertTrue($r['ok'], implode(',', $r['errors']));
|
||||
$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 {
|
||||
$p = $this->valid(); $p['event_type'] = 'explode';
|
||||
$this->assertFalse(EventValidator::validate($p)['ok']);
|
||||
86
cloud/tests/IngestControllerTest.php
Normal file
86
cloud/tests/IngestControllerTest.php
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -47,11 +47,8 @@ final class PdoIntegrationTest extends TestCase
|
|||
public function testEventInsertFindAcknowledge(): void
|
||||
{
|
||||
$repo = new PdoEventRepository($this->pdo);
|
||||
$uuid = str_repeat('a', 32);
|
||||
$id = $repo->insert([
|
||||
'event_uuid' => $uuid,
|
||||
'device_id' => 'dev-1',
|
||||
'site' => 'factory-a',
|
||||
'camera_id' => 'cam-1',
|
||||
'event_type' => 'fall',
|
||||
'severity' => 'critical',
|
||||
|
|
@ -66,20 +63,10 @@ final class PdoIntegrationTest extends TestCase
|
|||
$found = $repo->find($id);
|
||||
$this->assertNotNull($found);
|
||||
$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(['confidence' => 0.9], $found['metrics']);
|
||||
$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');
|
||||
$this->assertCount(1, $recent);
|
||||
$this->assertSame([], $repo->recent(10, 'info'));
|
||||
|
|
@ -148,16 +135,14 @@ final class PdoIntegrationTest extends TestCase
|
|||
private static function sqliteSchema(): string
|
||||
{
|
||||
return <<<'SQL'
|
||||
CREATE TABLE move_nonces (
|
||||
CREATE TABLE nonces (
|
||||
nonce TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE move_events (
|
||||
CREATE TABLE events (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_uuid TEXT UNIQUE,
|
||||
device_id TEXT NOT NULL,
|
||||
site TEXT,
|
||||
camera_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
|
|
@ -171,7 +156,7 @@ final class PdoIntegrationTest extends TestCase
|
|||
received_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE move_zones (
|
||||
CREATE TABLE zones (
|
||||
zone_id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
camera_id TEXT NOT NULL,
|
||||
61
cloud/tests/Support/InMemoryRepositories.php
Normal file
61
cloud/tests/Support/InMemoryRepositories.php
Normal 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'); }
|
||||
}
|
||||
69
cloud/tests/ZoneConfigTest.php
Normal file
69
cloud/tests/ZoneConfigTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-installable bundle + systemd unit for the `move-detect` edge daemon.
|
||||
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
|
||||
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).
|
||||
|
||||
> 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)
|
||||
|
||||
If the Pi itself has internet, skip the offline bundle entirely. Copy the built delivery
|
||||
folder onto the Pi — **copy the working tree, not a fresh `git clone`**: the pose model
|
||||
`models/yolov8s_pose.hef` is git-ignored (a build artifact), so a clone arrives without it.
|
||||
Use `scp -r`/USB from a build machine that has already staged `models/` (see step 1). Then:
|
||||
If the Pi itself has internet, skip the offline bundle entirely. Copy this repo onto
|
||||
the Pi (git clone or scp), then:
|
||||
|
||||
```bash
|
||||
sudo deploy/setup_online.sh
|
||||
```
|
||||
|
||||
It installs the system packages (`python3-picamera2`, and `hailo-all` if present), builds
|
||||
the venv, pip-installs the runtime deps, **places the pose model from the delivery's
|
||||
`models/`** (it does NOT download models on the Pi — the HEF/ONNX are fetched on the build
|
||||
machine per the air-gap rule, `build pulls, target doesn't`), writes `/etc/move-detect/edge.env`
|
||||
(prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `CAMERAS`, or take them from env
|
||||
vars), probes every camera, installs the systemd unit, and starts the service. It uses Hailo
|
||||
when `models/yolov8s_pose.hef` is present (or pass `HAILO_HEF=/path/to.hef`), else CPU MoveNet
|
||||
when `models/movenet_lightning.onnx` + onnxruntime are present. The rest of this doc is the
|
||||
**air-gapped** path.
|
||||
|
||||
### Secret handling (`deploy/device.env`)
|
||||
|
||||
If you pre-provision with `deploy/device.env`, that file holds this device's **HMAC secret in
|
||||
plaintext**. It is gitignored (never committed), but it rides onto the Pi with the "copy the
|
||||
working tree" transfer. After a successful install:
|
||||
|
||||
1. **Delete it:** `shred -u deploy/device.env` (the live secret now lives only in
|
||||
`/etc/move-detect/edge.env`, `0640 root:movedetect`). The installer already `chmod 600`s it
|
||||
while running, but that is defense-in-depth, not a substitute for removing it.
|
||||
2. **Rotate if it was ever exposed** (shared over chat, a shell history, a shared drive):
|
||||
update `move_devices.secret` on the server and the `DEVICE_SECRET` in the Pi's
|
||||
`/etc/move-detect/edge.env`, then restart the service. A leaked secret lets anyone forge
|
||||
signed events/heartbeats for this device.
|
||||
|
||||
Prefer `device.env` over passing `DEVICE_SECRET=...` on the `sudo` command line, which leaves the
|
||||
secret in the shell history.
|
||||
It installs the system packages (`python3-picamera2`, and `hailo-all` if present),
|
||||
builds the venv, pip-installs the runtime deps, fetches the MoveNet model, writes
|
||||
`/etc/move-detect/edge.env` (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` /
|
||||
`CAMERAS`, or take them from env vars), installs the systemd unit, and starts the service.
|
||||
It begins on CPU MoveNet; drop a `yolov8s_pose.hef` in and pass `HAILO_HEF=...` (or edit
|
||||
`edge.env`) to switch to Hailo. The rest of this doc is the **air-gapped** path.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -77,18 +55,16 @@ wheel tags may not resolve; if it fails, build natively):
|
|||
deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311
|
||||
```
|
||||
|
||||
Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled
|
||||
wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step.
|
||||
Set `MAKE_TARBALL=1` if you would rather transfer one
|
||||
`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file.
|
||||
Output: `dist/move-detect-edge-bundle-aarch64-cp311.tar.gz` (+ `MANIFEST.txt`
|
||||
listing the bundled wheels). This single file crosses the air gap.
|
||||
|
||||
## 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
|
||||
# tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz
|
||||
cd 2_pi_install
|
||||
tar xzf move-detect-edge-bundle-aarch64-cp311.tar.gz
|
||||
cd move-detect-edge-bundle
|
||||
sudo ./install_offline.sh
|
||||
```
|
||||
|
||||
|
|
@ -105,8 +81,8 @@ sudo systemctl enable --now move-detect-edge
|
|||
journalctl -u move-detect-edge -f
|
||||
```
|
||||
|
||||
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
|
||||
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
|
||||
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the cloud (see
|
||||
`cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
|
||||
model (`/opt/move-detect/models/movenet_lightning.onnx`).
|
||||
|
||||
## 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 +
|
||||
numpy + requests all import from the venv).
|
||||
- [ ] `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
|
||||
→ 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
|
||||
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:
|
||||
|
||||
```ini
|
||||
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
|
||||
> 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)
|
||||
|
||||
- **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
|
||||
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.
|
||||
|
|
|
|||
128
deploy/bundle/build_offline_bundle.sh
Executable file → Normal file
128
deploy/bundle/build_offline_bundle.sh
Executable file → Normal file
|
|
@ -20,17 +20,12 @@ set -euo pipefail
|
|||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
||||
STAGE="$OUT_DIR/2_pi_install"
|
||||
STAGE="$OUT_DIR/move-detect-edge-bundle"
|
||||
WHEELHOUSE="$STAGE/wheelhouse"
|
||||
PYVER="${PYVER:-3.11}" # Pi OS Bookworm ships CPython 3.11
|
||||
PYABI="cp${PYVER//./}" # cp311
|
||||
TARGET_ARCH="${TARGET_ARCH:-aarch64}"
|
||||
HAILO_ARCH="${HAILO_ARCH:-hailo8}" # 26 TOPS AI HAT+ (Hailo-8); set hailo8l for the 13 TOPS AI Kit
|
||||
# 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)
|
||||
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
|
||||
|
||||
MODE="native"
|
||||
[ "${1:-}" = "--cross" ] && MODE="cross"
|
||||
|
|
@ -38,13 +33,12 @@ MODE="native"
|
|||
echo ">> build mode : $MODE"
|
||||
echo ">> python target : $PYVER ($PYABI)"
|
||||
echo ">> target arch : $TARGET_ARCH"
|
||||
echo ">> hailo arch : $HAILO_ARCH"
|
||||
echo ">> output : $OUT_DIR"
|
||||
|
||||
rm -rf "$STAGE"
|
||||
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
|
||||
# Best-effort cross download. Try the common manylinux aarch64 tags.
|
||||
python3 -m pip download \
|
||||
|
|
@ -53,14 +47,6 @@ if [ "$MODE" = "cross" ]; then
|
|||
--platform "manylinux2014_${TARGET_ARCH}" \
|
||||
--platform "manylinux_2_28_${TARGET_ARCH}" \
|
||||
-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
|
||||
# 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
|
||||
|
|
@ -73,131 +59,37 @@ else
|
|||
echo " or use --cross to pin aarch64/$PYABI wheels explicitly." >&2
|
||||
exit 1
|
||||
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[@]}"
|
||||
# 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
|
||||
|
||||
echo ">> [2/6] fetching MoveNet ONNX model (optional CPU fallback; build-machine only)"
|
||||
# Optional: the MoveNet source may be unreachable, and a Hailo-8 delivery runs off the HEF. Do
|
||||
# not abort the whole bundle over the CPU fallback — stage it if we can, otherwise carry on.
|
||||
if ( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet ); then
|
||||
echo ">> [2/5] fetching MoveNet ONNX model (build-machine only)"
|
||||
( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet )
|
||||
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)"
|
||||
# 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)"
|
||||
echo ">> [3/5] staging edge source (package + pyproject)"
|
||||
cp -r "$REPO_ROOT/edge" "$STAGE/src/edge"
|
||||
cp "$REPO_ROOT/pyproject.toml" "$STAGE/src/"
|
||||
# do not ship compiled caches
|
||||
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.env.example" "$STAGE/deploy/systemd/"
|
||||
cp "$REPO_ROOT/deploy/bundle/install_offline.sh" "$STAGE/"
|
||||
cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true
|
||||
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 "built_mode: $MODE"
|
||||
echo "python: $PYVER ($PYABI)"
|
||||
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:"
|
||||
ls -1 "$WHEELHOUSE"
|
||||
} > "$STAGE/MANIFEST.txt"
|
||||
|
||||
# SHA256SUMS LAST: hash every staged file EXCEPT the manifest itself and its detached signature,
|
||||
# so the daemon source, systemd unit, installer, env template, models and wheels are ALL covered.
|
||||
# A tampered edge/daemon.py can no longer pass `sha256sum -c` and then be installed and run.
|
||||
( 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 ">> [5/5] packaging tarball"
|
||||
TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}.tar.gz"
|
||||
tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle"
|
||||
echo ">> DONE: $TARBALL"
|
||||
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."
|
||||
|
|
|
|||
188
deploy/bundle/install_offline.sh
Executable file → Normal file
188
deploy/bundle/install_offline.sh
Executable file → Normal file
|
|
@ -18,78 +18,7 @@ STATE=/var/lib/move-detect
|
|||
ETC=/etc/move-detect
|
||||
SVC_USER=movedetect
|
||||
WHEELHOUSE="$HERE/wheelhouse"
|
||||
# Mandatory for every backend. onnxruntime is deliberately NOT here — see the optional
|
||||
# install below; a Hailo bundle need not carry its wheel.
|
||||
RUNTIME_PKGS=(numpy requests opencv-python-headless)
|
||||
OPTIONAL_PKGS=(onnxruntime)
|
||||
|
||||
echo ">> [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
|
||||
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
|
||||
|
||||
echo ">> [1/6] service account + groups"
|
||||
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"
|
||||
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
|
||||
# old and rename over it: an interrupted copy leaves the previous WORKING tree untouched (the
|
||||
# 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"
|
||||
# Fully replace the package tree on re-install/upgrade. Without the rm, `cp -r SRC DEST`
|
||||
# nests SRC *inside* an existing DEST (edge/edge/...) and the OLD code keeps running.
|
||||
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
|
||||
# Copy whichever pose models the bundle carries. Each is optional on its own — a Hailo bundle
|
||||
# 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/models/"*.onnx "$PREFIX/models/"
|
||||
cp "$HERE/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true
|
||||
|
||||
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
|
||||
# pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and
|
||||
# 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"
|
||||
"$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[@]}"
|
||||
# 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"
|
||||
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
|
||||
# on the unconditional startup chain; the daemon's server clients import requests at startup).
|
||||
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
|
||||
}
|
||||
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, onnxruntime, cv2, numpy, requests; print('edge import OK')"
|
||||
|
||||
echo ">> [5/6] state dir + env file"
|
||||
install -d -m 0750 "$STATE"
|
||||
chown "$SVC_USER":"$SVC_USER" "$STATE"
|
||||
install -d -m 0750 "$ETC"
|
||||
if [ ! -f "$ETC/edge.env" ]; then
|
||||
POSE_BACKEND="$DEFAULT_BACKEND"
|
||||
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"
|
||||
echo " -> wrote $ETC/edge.env (POSE_BACKEND=$POSE_BACKEND; EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)"
|
||||
else
|
||||
# Preserve the operator's DELIBERATE backend choice when it still works in this bundle; only
|
||||
# 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
|
||||
# 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 " -> wrote $ETC/edge.env from template (EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)"
|
||||
else
|
||||
echo " -> $ETC/edge.env already exists; left unchanged"
|
||||
fi
|
||||
|
||||
echo ">> [6/6] systemd unit"
|
||||
install -m 0644 "$HERE/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
|
||||
|
|
@ -226,11 +71,6 @@ systemctl daemon-reload
|
|||
|
||||
echo ""
|
||||
echo "Installed. Next:"
|
||||
echo " 1. edit $ETC/edge.env (device id/secret, server 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 " otherwise yields a green unit that silently monitors one camera less (build_runtimes"
|
||||
echo " logs-and-skips a camera it cannot open). This mirrors setup_online.sh's [8/9] probe,"
|
||||
echo " which the offline path cannot run at install time (CAMERAS is only set in step 1):"
|
||||
echo " sudo sh -c 'cd $PREFIX/src && CAMERAS=\$(sed -n \"s/^CAMERAS=//p\" $ETC/edge.env) $PREFIX/venv/bin/python -m edge.probe'"
|
||||
echo " 3. sudo systemctl enable --now move-detect-edge"
|
||||
echo " 4. journalctl -u move-detect-edge -f"
|
||||
echo " 1. edit $ETC/edge.env (device id/secret, cloud URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
|
||||
echo " 2. sudo systemctl enable --now move-detect-edge"
|
||||
echo " 3. journalctl -u move-detect-edge -f"
|
||||
|
|
|
|||
291
deploy/setup_online.sh
Executable file → Normal file
291
deploy/setup_online.sh
Executable file → Normal file
|
|
@ -5,18 +5,9 @@
|
|||
# Run from a clone/copy of this repo, as root:
|
||||
# sudo deploy/setup_online.sh
|
||||
#
|
||||
# Pre-provisioned (shortest path): put the values in deploy/device.env on the BUILD machine
|
||||
# (DEVICE_ID / DEVICE_SECRET / CAFE24_URL / CAMERAS, one KEY=value per line, gitignored), copy
|
||||
# the repo to the Pi, and the whole install is just:
|
||||
# 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.
|
||||
# Non-interactive: pass values as env vars, e.g.
|
||||
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
|
||||
# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh
|
||||
# Interactive (a real terminal): it prompts for anything not passed.
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -31,56 +22,21 @@ STATE=/var/lib/move-detect
|
|||
ETC=/etc/move-detect
|
||||
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
|
||||
# A freshly-booted Pi runs apt-daily / apt-daily-upgrade / unattended-upgrades, which hold
|
||||
# /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend for the first few minutes —
|
||||
# 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
|
||||
apt-get update
|
||||
apt-get install -y python3-picamera2 python3-venv python3-pip
|
||||
# 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.
|
||||
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"
|
||||
for g in video render plugdev hailo; do
|
||||
getent group "$g" >/dev/null 2>&1 && usermod -aG "$g" "$SVC_USER" || true
|
||||
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"
|
||||
rm -rf "$PREFIX/src/edge" # full replace on re-run (no nested edge/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 {} +
|
||||
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"
|
||||
"$PREFIX/venv/bin/pip" install --upgrade pip >/dev/null
|
||||
# MANDATORY for every backend — do not soften these to `|| echo WARN`:
|
||||
# cv2 edge/snapshot.py imports it at MODULE level, and that module is on the
|
||||
# unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND
|
||||
# can reach build_pose() without it.
|
||||
# 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)."
|
||||
# numpy is intentionally NOT pinned here: it comes in transitively via onnxruntime, and
|
||||
# leaving the system numpy in place (the venv sees it) avoids shadowing the numpy that
|
||||
# the apt python3-picamera2 stack was compiled against.
|
||||
"$PREFIX/venv/bin/pip" install requests onnxruntime opencv-python-headless
|
||||
|
||||
echo ">> [5/9] MoveNet CPU model (from the delivery, not downloaded)"
|
||||
# Models ship IN the delivery, staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh
|
||||
# fetches them there) and carried across with the repo copy — air-gap rule "build pulls, target
|
||||
# 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 ">> [5/8] MoveNet CPU model"
|
||||
( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet )
|
||||
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true
|
||||
|
||||
echo ">> [6/9] pose backend"
|
||||
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
|
||||
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
|
||||
if [ -n "${HAILO_HEF:-}" ]; then
|
||||
# An explicit path that does not exist is a typo, not a request to fall back. The old single
|
||||
# guarded `cp` skipped it with NO warning and produced the byte-identical HEF=MISSING table
|
||||
# as the unset case, never echoing the bad value — and if an earlier run had left a
|
||||
# $HEF_DEST behind, that stale HEF got adopted as though it were the one named.
|
||||
[ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; }
|
||||
cp "$HAILO_HEF" "$HEF_DEST"
|
||||
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
|
||||
echo " -> Hailo HEF + hailo_platform present; POSE_BACKEND=hailo"
|
||||
[ "$MOVENET_OK" = 1 ] || echo " (CPU MoveNet fallback unavailable — Hailo does not need it)"
|
||||
elif [ "$MOVENET_OK" = 1 ]; then
|
||||
echo ">> [6/8] pose backend"
|
||||
POSE_BACKEND=movenet
|
||||
echo " -> no usable Hailo; starting on CPU MoveNet."
|
||||
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
|
||||
if [ -n "${HAILO_HEF:-}" ] && [ -f "${HAILO_HEF:-}" ]; then
|
||||
cp "$HAILO_HEF" "$HEF_DEST"
|
||||
POSE_BACKEND=hailo
|
||||
echo " -> Hailo HEF installed; POSE_BACKEND=hailo"
|
||||
else
|
||||
echo " -> no HAILO_HEF given; starting on CPU MoveNet."
|
||||
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"
|
||||
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
|
||||
|
||||
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 "$ETC"
|
||||
if [ -f "$ETC/edge.env" ]; then
|
||||
# Re-running IS the normal Hailo flow: install -> reboot so the driver creates /dev/hailo0
|
||||
# -> re-run. So [6/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
|
||||
# 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:=}"
|
||||
if [ -t 0 ]; then
|
||||
[ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID
|
||||
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from server): " DEVICE_SECRET; echo; }
|
||||
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https://<domain>/move_detect — include the subdir!): " CAFE24_URL
|
||||
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; }
|
||||
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (cloud base URL): " CAFE24_URL
|
||||
[ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
|
||||
fi
|
||||
DEVICE_ID="${DEVICE_ID:-pi-01}"
|
||||
|
|
@ -273,69 +99,14 @@ TARGET_FPS=12
|
|||
EOF
|
||||
chown root:"$SVC_USER" "$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"
|
||||
fi
|
||||
|
||||
echo ">> [8/9] camera check (open every camera in CAMERAS and grab a frame)"
|
||||
# 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"
|
||||
echo ">> [8/8] systemd service"
|
||||
install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
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
|
||||
systemctl enable --now move-detect-edge
|
||||
|
||||
echo ""
|
||||
echo "Done. The edge daemon is installed and started."
|
||||
|
|
|
|||
|
|
@ -10,11 +10,8 @@
|
|||
DEVICE_ID=pi-01
|
||||
# DEVICE_SECRET: per-device HMAC secret; must match devices.secret.
|
||||
DEVICE_SECRET=CHANGEME
|
||||
# CAFE24_URL: cloud base URL (POST events / poll config / heartbeat). MUST include the app's
|
||||
# subdirectory when the cloud is mounted under an existing www/ (the cafe24 package default):
|
||||
# use https://<domain>/move_detect, NOT https://<domain>. A wrong/missing path 404s every
|
||||
# upload — the daemon now refuses to start on a blank value, but a wrong path it cannot detect.
|
||||
CAFE24_URL=https://your-domain.example/move_detect
|
||||
# CAFE24_URL: cloud base URL (POST events / poll config / heartbeat).
|
||||
CAFE24_URL=https://cloud.example.com
|
||||
# CAMERA_ID: stamped on events and matched to dashboard zones.
|
||||
CAMERA_ID=cam-01
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망).
|
||||
# Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then
|
||||
# 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.
|
||||
|
||||
[Unit]
|
||||
|
|
@ -9,23 +9,9 @@ Description=move-detect edge daemon (workplace-safety AI CCTV)
|
|||
Documentation=file:/opt/move-detect/docs/pilot-runbook.md
|
||||
Wants=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]
|
||||
# Type=exec, not simple: with simple, systemd marks the start job done the instant fork()
|
||||
# returns, so an exec-step failure (226/NAMESPACE, 216/GROUP, 217/USER, 203/EXEC) never
|
||||
# reaches `systemctl enable --now` and the installer reports success over a dead daemon.
|
||||
Type=exec
|
||||
Type=simple
|
||||
# Dedicated unprivileged account; must be in the groups that own the camera and
|
||||
# the Hailo accelerator device nodes.
|
||||
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.
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
|
||||
# systemd creates/owns /var/lib/move-detect for the service user.
|
||||
StateDirectory=move-detect
|
||||
StateDirectoryMode=0750
|
||||
|
||||
# Camera + accelerator device access. Once ANY DeviceAllow= is present, systemd denies every
|
||||
# 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.
|
||||
# Camera + accelerator device access (USB UVC = /dev/video*, Hailo = /dev/hailo0).
|
||||
DeviceAllow=/dev/hailo0 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).
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
# Do NOT add ReadWritePaths=/var/lib/move-detect here. StateDirectory= above already emits
|
||||
# a recursive read-write bind mount for that exact path, which outranks a ReadWritePaths=
|
||||
# entry for the same path and drops it before any path is resolved. It is dead config: it
|
||||
# can neither cause nor fix a state-dir problem.
|
||||
ReadWritePaths=/var/lib/move-detect
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -1,45 +1,17 @@
|
|||
# 파일럿 · 운영 런북
|
||||
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
|
||||
|
||||
이 문서는 두 경로를 다룬다. **자신의 상황에 맞는 절에서 시작하라:**
|
||||
이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다.
|
||||
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
|
||||
|
||||
- **§0 폐쇄망 Pi 현장 설치·운영** — Hailo·Pi5 실장비를 오프라인 번들로 설치한 경우. systemd 유닛의
|
||||
`Documentation=`이 이 문서를 가리키므로 현장 운영자는 **§0만** 보면 된다.
|
||||
- **§1~§6 노트북 웹캠 개발 파일럿** — 개발/데모 머신에서 CPU MoveNet로 엔드투엔드를 돌리는 최소 경로.
|
||||
**이 절들의 `pip install`·모델 `fetch` 명령은 개발/빌드 머신 전용이다 — 폐쇄망 Pi에서 실행하지 말 것.**
|
||||
|
||||
- 엣지: `python -m edge` (또는 `move-detect-edge`) — pose → 감지 → HMAC 서명 POST.
|
||||
- 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
|
||||
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
|
||||
- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
|
||||
|
||||
---
|
||||
|
||||
## 0. 폐쇄망 Pi 현장 설치·운영
|
||||
## 1. 빌드 머신 준비 (모델 · onnxruntime)
|
||||
|
||||
실장비 Hailo·Pi5는 **오프라인 전체 번들**로 설치한다. 런타임 휠·pose 모델(HEF)·소스·systemd 유닛이
|
||||
모두 번들에 동봉되어 있으므로 **Pi에서 인터넷을 받지 않는다** (아래 §1의 `pip install`/`fetch`는 절대
|
||||
실행 금지 — 빌드 머신 전용).
|
||||
|
||||
1. **설치:** 번들 절차는 [deploy/README.md](../deploy/README.md)를 따른다. 빌드 머신에서
|
||||
`deploy/bundle/build_offline_bundle.sh`(aarch64)로 번들을 만들어 Pi로 옮기고,
|
||||
`sudo ./install_offline.sh`가 SHA256SUMS 검증 → 오프라인 휠 설치 → HEF 배치 → 유닛 설치를 수행한다.
|
||||
(온라인 프로비저닝은 `sudo deploy/setup_online.sh` — 카메라 프로브까지 자동.)
|
||||
2. **멀티카메라:** `/etc/move-detect/edge.env`의 `CAMERAS=csi:0:cam-01,uvc:0:cam-02`
|
||||
형식으로 1 Pi에 2~4대를 배선한다(형식 `backend:device:camera_id`).
|
||||
3. **enable 전 카메라 프로브** (설치 스크립트가 출력하는 명령) — 잘못된 인덱스/미체결 리본이 만드는
|
||||
"초록불인데 한 대 덜 감시" 상태를 현장에서 잡는다.
|
||||
4. **기동·확인:** `sudo systemctl enable --now move-detect-edge` → `journalctl -u move-detect-edge -f`.
|
||||
5. **시크릿 위생:** 프로비저닝에 `deploy/device.env`를 썼다면 설치 후 삭제·필요시 로테이션
|
||||
(deploy/README.md "Secret handling").
|
||||
|
||||
> 시크릿·서버 URL·모델 경로 등 env 키는 §3 표와 동일하다. 감지 임계값은 서버 대시보드의 구역·임계값이
|
||||
> 다음 config 폴링에 반영된다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 빌드/개발 머신 준비 (모델 · onnxruntime) — 폐쇄망 Pi에서 실행 금지
|
||||
|
||||
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망 Pi)에서는
|
||||
> 인터넷을 받지 않으며, 아래 두 명령을 Pi에서 실행하면 안 된다(§0 참조 — Pi는 이미 번들로 설치됨).
|
||||
> 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
|
||||
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서
|
||||
> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
|
||||
|
||||
```bash
|
||||
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 등)은
|
||||
[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개 테이블).
|
||||
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
||||
1. DB 생성 후 스키마 임포트: `mysql <db> < cloud/schema.sql` (10개 테이블).
|
||||
2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
||||
(`snapshot_dir`는 쓰기 가능 경로로).
|
||||
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
||||
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
|
||||
CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
|
||||
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
|
||||
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
|
||||
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
|
||||
- `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에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
||||
`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_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
|
||||
| `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` |
|
||||
| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` |
|
||||
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
|
||||
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
|
||||
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
|
||||
|
|
@ -111,13 +83,10 @@ python -m edge # 또는 move-detect-edge
|
|||
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
|
||||
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
|
||||
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
|
||||
7. **하트비트:** **30초 시간기반**(`heartbeat_interval_s=30`)으로 `POST /api/heartbeat`(서명).
|
||||
부하와 무관하게 주기가 일정해 서버의 오프라인 판정(120초, 4배 마진)이 정상 장치를 오프라인으로
|
||||
깜빡이지 않는다. temp/throttle/queue_pending/config_version 텔레메트리를 함께 싣는다.
|
||||
7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명).
|
||||
|
||||
> cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()`
|
||||
> 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를
|
||||
> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계).
|
||||
> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로
|
||||
> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -125,7 +94,7 @@ python -m edge # 또는 move-detect-edge
|
|||
|
||||
```bash
|
||||
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` —
|
||||
|
|
@ -133,15 +102,11 @@ cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations #
|
|||
|
||||
---
|
||||
|
||||
## 6. 한계 (명시)
|
||||
## 6. 파일럿 한계 (명시)
|
||||
|
||||
> **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를
|
||||
> 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다.
|
||||
|
||||
- **노트북 파일럿은 단일 카메라 · CPU pose(저 fps)** — 폐쇄망 Pi(§0)는 Hailo 가속 + 멀티카메라
|
||||
(`CAMERAS=` 다중 배선)를 사용한다.
|
||||
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2(양끝 인프라는 존재, 배선 대기).
|
||||
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook·미확인 에스컬레이션 메일은 후속(P3).
|
||||
- **폐쇄망 전체 번들은 이미 존재한다** — `deploy/bundle/`에 오프라인 휠·HEF·소스·SHA256SUMS 검증
|
||||
설치를 제공한다(§0). 노트북 파일럿만 `pip install`/`fetch`로 편의 실행할 뿐이다.
|
||||
- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속.
|
||||
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2.
|
||||
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
|
||||
- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save`
|
||||
패키징은 별도 후속 태스크.
|
||||
- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -10,22 +10,22 @@
|
|||
|
||||
## Global Constraints
|
||||
|
||||
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `server/src/`).
|
||||
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `server/public/`만 웹 노출.
|
||||
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `cloud/src/`).
|
||||
- **폐쇄망/공유호스팅:** 런타임 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`.
|
||||
- **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** 검증.
|
||||
- **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer.
|
||||
- **심각도 색상 단독 금지(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).
|
||||
- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
server/
|
||||
cloud/
|
||||
composer.json # PSR-4 autoload + phpunit(dev)
|
||||
phpunit.xml
|
||||
config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore)
|
||||
|
|
@ -64,7 +64,7 @@ server/
|
|||
## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)
|
||||
|
||||
**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:**
|
||||
- Consumes: (없음)
|
||||
|
|
@ -74,7 +74,7 @@ server/
|
|||
|
||||
```json
|
||||
{
|
||||
"name": "move-detect/server",
|
||||
"name": "move-detect/cloud",
|
||||
"type": "project",
|
||||
"require": { "php": ">=8.2" },
|
||||
"require-dev": { "phpunit/phpunit": "^11" },
|
||||
|
|
@ -95,7 +95,7 @@ server/
|
|||
</phpunit>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `.gitignore`** (server/)
|
||||
- [ ] **Step 3: `.gitignore`** (cloud/)
|
||||
|
||||
```
|
||||
/vendor/
|
||||
|
|
@ -104,7 +104,7 @@ server/
|
|||
!/storage/snapshots/.gitkeep
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 실패 테스트** — `server/tests/ConfigTest.php`
|
||||
- [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -135,10 +135,10 @@ final class ConfigTest extends TestCase
|
|||
|
||||
- [ ] **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`
|
||||
|
||||
- [ ] **Step 6: 구현** — `server/src/Config.php`
|
||||
- [ ] **Step 6: 구현** — `cloud/src/Config.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -180,8 +180,8 @@ return [
|
|||
Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS
|
||||
|
||||
```bash
|
||||
git add server/composer.json server/phpunit.xml server/.gitignore server/config.example.php server/src/Config.php server/tests/ConfigTest.php
|
||||
git commit -m "feat(server): P1-B scaffold — composer autoload + Config"
|
||||
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(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 계약 일치
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/Security/Hmac.php`, `server/tests/HmacTest.php`
|
||||
- Create: `cloud/src/Security/Hmac.php`, `cloud/tests/HmacTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- 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, 상수시간).
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/HmacTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/HmacTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -229,7 +229,7 @@ final class HmacTest extends TestCase
|
|||
|
||||
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
|
||||
|
|
@ -256,8 +256,8 @@ final class Hmac
|
|||
Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS
|
||||
|
||||
```bash
|
||||
git add server/src/Security/Hmac.php server/tests/HmacTest.php
|
||||
git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
|
||||
git add cloud/src/Security/Hmac.php cloud/tests/HmacTest.php
|
||||
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 테스트더블
|
||||
|
||||
**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:**
|
||||
- Consumes: (없음)
|
||||
|
|
@ -282,7 +282,7 @@ git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
|
|||
|
||||
- [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의.
|
||||
|
||||
`server/src/Repository/DeviceRepository.php`:
|
||||
`cloud/src/Repository/DeviceRepository.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
|
||||
|
|
@ -479,7 +479,7 @@ final class InMemoryAuditRepository implements AuditRepository {
|
|||
}
|
||||
```
|
||||
|
||||
그리고 `server/tests/RepositoryContractTest.php`:
|
||||
그리고 `cloud/tests/RepositoryContractTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -523,8 +523,8 @@ Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS.
|
|||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Repository/*.php server/tests/Support/InMemoryRepositories.php server/tests/RepositoryContractTest.php
|
||||
git commit -m "feat(server): repository interfaces + in-memory test doubles"
|
||||
git add cloud/src/Repository/*.php cloud/tests/Support/InMemoryRepositories.php cloud/tests/RepositoryContractTest.php
|
||||
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 화이트리스트 + 스키마 검증)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/Ingest/EventValidator.php`, `server/tests/EventValidatorTest.php`
|
||||
- Create: `cloud/src/Ingest/EventValidator.php`, `cloud/tests/EventValidatorTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- 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), 문자열 항목.
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/EventValidatorTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/EventValidatorTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -579,7 +579,7 @@ final class EventValidatorTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Ingest/EventValidator.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/EventValidator.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -627,8 +627,8 @@ final class EventValidator
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Ingest/EventValidator.php server/tests/EventValidatorTest.php
|
||||
git commit -m "feat(server): event payload validator with enum whitelist"
|
||||
git add cloud/src/Ingest/EventValidator.php cloud/tests/EventValidatorTest.php
|
||||
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)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/Ingest/ImageProcessor.php`, `server/tests/ImageProcessorTest.php`
|
||||
- Create: `cloud/src/Ingest/ImageProcessor.php`, `cloud/tests/ImageProcessorTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- 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(재인코딩 바이트).
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/ImageProcessorTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ImageProcessorTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -677,7 +677,7 @@ final class ImageProcessorTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Ingest/ImageProcessor.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/ImageProcessor.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -710,8 +710,8 @@ final class ImageProcessor
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Ingest/ImageProcessor.php server/tests/ImageProcessorTest.php
|
||||
git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"
|
||||
git add cloud/src/Ingest/ImageProcessor.php cloud/tests/ImageProcessorTest.php
|
||||
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
|
||||
|
||||
**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:**
|
||||
- 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\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
|
||||
|
|
@ -755,7 +755,7 @@ final class HttpTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Http/Request.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Http/Request.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -792,7 +792,7 @@ final class Request
|
|||
}
|
||||
```
|
||||
|
||||
`server/src/Http/Response.php`:
|
||||
`cloud/src/Http/Response.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -829,8 +829,8 @@ final class Response
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Http/Request.php server/src/Http/Response.php server/tests/HttpTest.php
|
||||
git commit -m "feat(server): Http Request/Response primitives"
|
||||
git add cloud/src/Http/Request.php cloud/src/Http/Response.php cloud/tests/HttpTest.php
|
||||
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) — 보안 파이프라인 통합
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/Ingest/IngestController.php`, `server/tests/IngestControllerTest.php`
|
||||
- Create: `cloud/src/Ingest/IngestController.php`, `cloud/tests/IngestControllerTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- 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}`.
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/IngestControllerTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/IngestControllerTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -919,7 +919,7 @@ final class IngestControllerTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Ingest/IngestController.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/IngestController.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -991,8 +991,8 @@ final class IngestController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Ingest/IngestController.php server/tests/IngestControllerTest.php
|
||||
git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
|
||||
git add cloud/src/Ingest/IngestController.php cloud/tests/IngestControllerTest.php
|
||||
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
|
||||
|
||||
**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:**
|
||||
- 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\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
|
||||
|
|
@ -1036,7 +1036,7 @@ final class PasswordCsrfTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Security/Password.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Security/Password.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1054,7 +1054,7 @@ final class Password
|
|||
}
|
||||
```
|
||||
|
||||
`server/src/Security/Csrf.php`:
|
||||
`cloud/src/Security/Csrf.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1073,8 +1073,8 @@ final class Csrf
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Security/Password.php server/src/Security/Csrf.php server/tests/PasswordCsrfTest.php
|
||||
git commit -m "feat(server): argon2id password + CSRF token helpers"
|
||||
git add cloud/src/Security/Password.php cloud/src/Security/Csrf.php cloud/tests/PasswordCsrfTest.php
|
||||
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 (역할→권한)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/Auth/Rbac.php`, `server/tests/RbacTest.php`
|
||||
- Create: `cloud/src/Auth/Rbac.php`, `cloud/tests/RbacTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- 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).
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/RbacTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/RbacTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1123,7 +1123,7 @@ final class RbacTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Auth/Rbac.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Auth/Rbac.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1146,8 +1146,8 @@ final class Rbac
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Auth/Rbac.php server/tests/RbacTest.php
|
||||
git commit -m "feat(server): RBAC permission matrix"
|
||||
git add cloud/src/Auth/Rbac.php cloud/tests/RbacTest.php
|
||||
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 잠금)
|
||||
|
||||
**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:**
|
||||
- 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).
|
||||
- `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
|
||||
|
|
@ -1210,7 +1210,7 @@ final class AuthControllerTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Auth/Session.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Auth/Session.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1240,7 +1240,7 @@ final class PhpSessionStore implements SessionStore {
|
|||
}
|
||||
```
|
||||
|
||||
`server/src/Auth/AuthController.php`:
|
||||
`cloud/src/Auth/AuthController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1300,8 +1300,8 @@ final class AuthController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Auth/Session.php server/src/Auth/AuthController.php server/tests/AuthControllerTest.php
|
||||
git commit -m "feat(server): session store + auth controller with lockout"
|
||||
git add cloud/src/Auth/Session.php cloud/src/Auth/AuthController.php cloud/tests/AuthControllerTest.php
|
||||
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 + 프론트컨트롤러 준비
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/Http/Router.php`, `server/tests/RouterTest.php`
|
||||
- Create: `cloud/src/Http/Router.php`, `cloud/tests/RouterTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- 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).
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/RouterTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/RouterTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1347,7 +1347,7 @@ final class RouterTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Http/Router.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Http/Router.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1379,8 +1379,8 @@ final class Router
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Http/Router.php server/tests/RouterTest.php
|
||||
git commit -m "feat(server): path-param router + dispatch"
|
||||
git add cloud/src/Http/Router.php cloud/tests/RouterTest.php
|
||||
git commit -m "feat(cloud): path-param router + dispatch"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1388,7 +1388,7 @@ git commit -m "feat(server): path-param router + dispatch"
|
|||
## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시
|
||||
|
||||
**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:**
|
||||
- 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`).
|
||||
- `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
|
||||
|
|
@ -1451,7 +1451,7 @@ final class DashboardTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Dashboard/EventListController.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Dashboard/EventListController.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1476,7 +1476,7 @@ final class EventListController
|
|||
}
|
||||
```
|
||||
|
||||
`server/src/Dashboard/EventDetailController.php`:
|
||||
`cloud/src/Dashboard/EventDetailController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1516,7 +1516,7 @@ final class EventDetailController
|
|||
}
|
||||
```
|
||||
|
||||
`server/src/Dashboard/SnapshotProxy.php`:
|
||||
`cloud/src/Dashboard/SnapshotProxy.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1545,8 +1545,8 @@ final class SnapshotProxy
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Dashboard/EventListController.php server/src/Dashboard/EventDetailController.php server/src/Dashboard/SnapshotProxy.php server/tests/DashboardTest.php
|
||||
git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot proxy"
|
||||
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(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
|
||||
|
||||
**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:**
|
||||
- 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]` 숫자.
|
||||
- `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
|
||||
|
|
@ -1610,7 +1610,7 @@ final class ZoneConfigTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Geometry/Polygon.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Geometry/Polygon.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1631,7 +1631,7 @@ final class Polygon
|
|||
}
|
||||
```
|
||||
|
||||
`server/src/Dashboard/ZoneConfigController.php`:
|
||||
`cloud/src/Dashboard/ZoneConfigController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1698,8 +1698,8 @@ final class ZoneConfigController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Geometry/Polygon.php server/src/Dashboard/ZoneConfigController.php server/tests/ZoneConfigTest.php
|
||||
git commit -m "feat(server): polygon validation + zone config controller (RBAC)"
|
||||
git add cloud/src/Geometry/Polygon.php cloud/src/Dashboard/ZoneConfigController.php cloud/tests/ZoneConfigTest.php
|
||||
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)
|
||||
|
||||
**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:**
|
||||
- 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\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
|
||||
|
|
@ -1768,7 +1768,7 @@ final class ApiSyncTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `server/src/Api/ConfigController.php`
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Api/ConfigController.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1802,7 +1802,7 @@ final class ConfigController
|
|||
}
|
||||
```
|
||||
|
||||
`server/src/Api/HeartbeatController.php`:
|
||||
`cloud/src/Api/HeartbeatController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1840,8 +1840,8 @@ final class HeartbeatController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add server/src/Api/ConfigController.php server/src/Api/HeartbeatController.php server/tests/ApiSyncTest.php
|
||||
git commit -m "feat(server): device config + heartbeat sync API (HMAC)"
|
||||
git add cloud/src/Api/ConfigController.php cloud/src/Api/HeartbeatController.php cloud/tests/ApiSyncTest.php
|
||||
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 (통합)
|
||||
|
||||
**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:**
|
||||
- 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 |
|
||||
|
|
@ -1872,7 +1872,7 @@ git commit -m "feat(server): device config + heartbeat sync API (HMAC)"
|
|||
| GET | /snap/{uuid} | SnapshotProxy::serve |
|
||||
| GET | /, /zones, /login | View render |
|
||||
|
||||
- [ ] **Step 1: 라우터 스모크 테스트** — `server/tests/WiringSmokeTest.php`
|
||||
- [ ] **Step 1: 라우터 스모크 테스트** — `cloud/tests/WiringSmokeTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1902,7 +1902,7 @@ final class WiringSmokeTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS.
|
||||
|
||||
- [ ] **Step 3: 프론트컨트롤러** — `server/public/index.php`
|
||||
- [ ] **Step 3: 프론트컨트롤러** — `cloud/public/index.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1970,7 +1970,7 @@ $r->add('GET', '/zones', fn() => $render('zones'));
|
|||
$r->dispatch(Request::fromGlobals())->send();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: .htaccess** — `server/public/.htaccess`
|
||||
- [ ] **Step 4: .htaccess** — `cloud/public/.htaccess`
|
||||
|
||||
```apache
|
||||
RewriteEngine On
|
||||
|
|
@ -1994,8 +1994,8 @@ RewriteRule ^ index.php [QSA,L]
|
|||
Run: `./vendor/bin/phpunit` (전체) — green.
|
||||
|
||||
```bash
|
||||
git add server/public server/src/View server/tests/WiringSmokeTest.php
|
||||
git commit -m "feat(server): front controller wiring + views + zone-draw UI"
|
||||
git add cloud/public cloud/src/View cloud/tests/WiringSmokeTest.php
|
||||
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 + 배포 절차.
|
||||
|
||||
**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:**
|
||||
- Consumes: repository 인터페이스(T3), `Config`(T1)
|
||||
- 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
|
||||
|
|
@ -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 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
|
||||
|
|
@ -2069,7 +2069,7 @@ final class PdoNonceRepository implements NonceRepository
|
|||
- `PdoThresholdRepository`: forDevice(SELECT key/value → assoc).
|
||||
- `PdoAuditRepository`: append(INSERT, before/after=json_encode).
|
||||
|
||||
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `server/tests/PdoIntegrationTest.php`:
|
||||
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `cloud/tests/PdoIntegrationTest.php`:
|
||||
|
||||
```php
|
||||
protected function setUp(): void {
|
||||
|
|
@ -2082,15 +2082,15 @@ protected function setUp(): void {
|
|||
|
||||
로컬 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: 전체 스위트 + 커밋**
|
||||
|
||||
Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip).
|
||||
|
||||
```bash
|
||||
git add server/src/Db server/src/Repository/Pdo server/schema.sql server/docs/server-deploy.md server/tests/PdoIntegrationTest.php
|
||||
git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc"
|
||||
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(cloud): PDO repositories + schema + Cafe24 deploy doc"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -2118,7 +2118,7 @@ git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc"
|
|||
|
||||
## 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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
**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
|
||||
|
||||
- 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`).
|
||||
- **폐쇄망 지속:** 새 의존성(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관절을 그 순서로 매핑.
|
||||
- YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음.
|
||||
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
```
|
||||
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
|
||||
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()
|
||||
|
|
@ -30,33 +30,33 @@ edge/
|
|||
movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap
|
||||
tools/
|
||||
fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/
|
||||
server/
|
||||
cloud/
|
||||
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/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
|
||||
tests/ (edge) + server/tests/
|
||||
tests/ (edge) + cloud/tests/
|
||||
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:**
|
||||
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
|
||||
- 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.
|
||||
- `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
|
||||
import json
|
||||
from edge.serverclient import ServerClient
|
||||
from edge.cloudclient import CloudClient
|
||||
from edge.alert import sign
|
||||
|
||||
class _Resp:
|
||||
|
|
@ -68,30 +68,30 @@ def test_fetch_config_signs_and_parses():
|
|||
def fake_get(url, headers=None, timeout=None):
|
||||
captured["url"] = url; captured["headers"] = headers
|
||||
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()
|
||||
assert cfg["version"] == 3
|
||||
assert "device_id=pi-1" in captured["url"]
|
||||
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "")
|
||||
|
||||
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
|
||||
|
||||
def test_send_heartbeat_signs_body():
|
||||
captured = {}
|
||||
def fake_post(url, data=None, headers=None, timeout=None):
|
||||
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
|
||||
body = captured["data"]
|
||||
assert json.loads(body)["device_id"] == "pi-1"
|
||||
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
|
||||
import json
|
||||
|
|
@ -99,7 +99,7 @@ import json
|
|||
from edge.alert import sign
|
||||
|
||||
|
||||
class ServerClient:
|
||||
class CloudClient:
|
||||
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):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
|
@ -146,16 +146,16 @@ class ServerClient:
|
|||
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
|
||||
git add edge/serverclient.py tests/test_serverclient.py
|
||||
git commit -m "feat(edge): ServerClient for config poll + heartbeat (HMAC-signed)"
|
||||
git add edge/cloudclient.py tests/test_cloudclient.py
|
||||
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`
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ def thresholds_from_config(config: dict) -> dict:
|
|||
|
||||
```bash
|
||||
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`
|
||||
|
||||
**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:
|
||||
- `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` — 실장비 엔트리)
|
||||
|
|
@ -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)
|
||||
import time
|
||||
from edge.config import load_config
|
||||
from edge.serverclient import ServerClient
|
||||
from edge.cloudclient import CloudClient
|
||||
from edge.alert import PostClient
|
||||
from edge.queue import OfflineQueue
|
||||
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()
|
||||
now = time.time
|
||||
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)
|
||||
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
|
||||
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:**
|
||||
- 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).
|
||||
|
||||
- [ ] **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 (스텁 교체)**
|
||||
|
||||
|
|
@ -612,7 +612,7 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
|
|||
<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
|
||||
(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'));
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
git add server/src/View/event_detail.php server/public/assets/detail.js server/public/index.php server/tests/WiringSmokeTest.php
|
||||
git commit -m "feat(server): incident detail view renders snapshot images"
|
||||
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(cloud): incident detail view renders snapshot images"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
|
||||
|
||||
**Files:** Modify `server/public/assets/app.js`
|
||||
**Files:** Modify `cloud/public/assets/app.js`
|
||||
|
||||
**Interfaces:**
|
||||
- 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)` 호출.
|
||||
|
||||
- [ ] **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
|
||||
git add server/public/assets/app.js
|
||||
git commit -m "feat(server): dashboard rows link to detail + desk Web Audio on critical"
|
||||
git add cloud/public/assets/app.js
|
||||
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`, 노트북 파일럿 절차:
|
||||
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` 테이블에 등록.
|
||||
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(서버와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
|
||||
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`.
|
||||
4. **실행:** `move-detect-edge` (또는 `python -m edge`).
|
||||
5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush.
|
||||
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
|
||||
|
||||
**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 ✓
|
||||
- config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓
|
||||
- **범위 밖(명시):** 실 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은 빌드 편의(구현자 노트로 검증 지시).
|
||||
|
||||
**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) 일관.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -169,11 +169,7 @@ class HailoPoseAdapter:
|
|||
for info in out_infos}
|
||||
bindings = configured.create_bindings(output_buffers=buffers)
|
||||
bindings.input().set_buffer(np.ascontiguousarray(frame))
|
||||
# Positional, NOT timeout_ms=. Upstream is `def run(self, bindings, timeout)` in
|
||||
# every HailoRT that supports Hailo-8 (v4.19.0 / v4.20.0 / v4.23.0 / master), so
|
||||
# the keyword form is a TypeError on EVERY frame — and run_loop swallows it
|
||||
# (edge/daemon.py:23-24), so it presents as a green unit emitting zero events.
|
||||
configured.run([bindings], 10000)
|
||||
configured.run([bindings], timeout_ms=10000)
|
||||
return [buffers[info.name][None, ...] if buffers[info.name].ndim == 3
|
||||
else buffers[info.name] for info in out_infos]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import logging
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
|
@ -19,13 +17,6 @@ class MoveNetPoseAdapter:
|
|||
session = onnxruntime.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
|
||||
self.session = session
|
||||
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:
|
||||
h, w = frame.shape[:2]
|
||||
|
|
|
|||
|
|
@ -4,43 +4,10 @@ import cv2
|
|||
|
||||
|
||||
class UvcSource:
|
||||
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
|
||||
# _open lets tests inject a fake capture; production uses cv2.VideoCapture.
|
||||
self._dev = dev
|
||||
self._mjpeg = mjpeg
|
||||
self._reopen_after = reopen_after
|
||||
self._open = _open or (lambda d: cv2.VideoCapture(d))
|
||||
self._fail_streak = 0
|
||||
self.cap = self._open_cap()
|
||||
|
||||
def _open_cap(self):
|
||||
cap = self._open(self._dev)
|
||||
# Fail LOUD: a device that never opens must not become a silently-dead capture that
|
||||
# returns (False, None) forever while the daemon looks healthy. build_runtimes catches
|
||||
# this and logs+skips the camera.
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"UVC device {self._dev!r} failed to open")
|
||||
if self._mjpeg:
|
||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
|
||||
return cap
|
||||
def __init__(self, dev, mjpeg: bool = True):
|
||||
self.cap = cv2.VideoCapture(dev)
|
||||
if mjpeg:
|
||||
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
|
||||
|
||||
def read(self):
|
||||
ok, frame = self.cap.read()
|
||||
if ok:
|
||||
self._fail_streak = 0
|
||||
return ok, frame
|
||||
# A USB cam that browns out / is unplugged returns (False, None) indefinitely. After a
|
||||
# streak, release and reopen so a recovered device resumes on its own rather than the
|
||||
# camera staying dead until someone restarts the service.
|
||||
self._fail_streak += 1
|
||||
if self._fail_streak >= self._reopen_after:
|
||||
self._fail_streak = 0
|
||||
try:
|
||||
self.cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.cap = self._open_cap()
|
||||
except Exception:
|
||||
pass # still dead — the daemon's error/heartbeat path surfaces it
|
||||
return False, None
|
||||
return self.cap.read()
|
||||
|
|
|
|||
|
|
@ -2,28 +2,9 @@ import base64
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
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:
|
||||
if isinstance(body, str):
|
||||
|
|
@ -46,7 +27,7 @@ class PostClient:
|
|||
http_post = requests.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["snapshots"] = [base64.b64encode(s).decode() for s in snapshots]
|
||||
body = json.dumps(payload, sort_keys=True)
|
||||
|
|
@ -54,33 +35,12 @@ class PostClient:
|
|||
ts = str(self.now_fn())
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-Nonce": nonce,
|
||||
"X-Timestamp": ts,
|
||||
"X-Signature": sign(self.secret, nonce, ts, body.encode()),
|
||||
}
|
||||
try:
|
||||
# allow_redirects=False: the .htaccess http->https 302 must not silently become a
|
||||
# GET (405/404). A redirect is a CAFE24_URL misconfiguration to surface, not follow.
|
||||
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
|
||||
allow_redirects=False)
|
||||
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout)
|
||||
except Exception:
|
||||
# Connection refused / DNS / timeout — the server is unreachable, not rejecting us.
|
||||
return SendResult.TRANSIENT
|
||||
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
|
||||
return False
|
||||
return 200 <= resp.status_code < 300
|
||||
|
|
|
|||
28
edge/app.py
28
edge/app.py
|
|
@ -11,32 +11,18 @@ from edge.models import BBox
|
|||
|
||||
|
||||
def _head_boxes(persons: list) -> list:
|
||||
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
|
||||
# ~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.
|
||||
# 배경인물 머리영역 근사: 코 주변 작은 박스 (MVP; 실제는 얼굴 검출 포트로 교체)
|
||||
boxes = []
|
||||
for p in persons:
|
||||
bb = p.bbox
|
||||
nose = p.keypoints[0]
|
||||
half_w = max(8.0, 0.25 * bb.width)
|
||||
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))
|
||||
boxes.append(BBox(nose.x - 20, nose.y - 25, nose.x + 20, nose.y + 15))
|
||||
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()
|
||||
if not ok:
|
||||
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
|
||||
# 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
|
||||
return []
|
||||
now = now_fn()
|
||||
persons = pose.infer(frame)
|
||||
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.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
|
||||
ring.push(now, frame)
|
||||
h, w = frame.shape[:2]
|
||||
# `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)
|
||||
events = engine.process(tracks, lost, now)
|
||||
for tr in lost:
|
||||
pool.forget(tr.track_id)
|
||||
if events:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
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,
|
||||
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
|
@ -22,15 +22,13 @@ class ServerClient:
|
|||
def _headers(self, body: str) -> dict:
|
||||
nonce = self.nonce_fn()
|
||||
ts = str(self.now_fn())
|
||||
# User-Agent: cafe24's WAF 403s the default python-requests UA (see edge/alert.py).
|
||||
return {"User-Agent": USER_AGENT, "X-Nonce": nonce, "X-Timestamp": ts,
|
||||
return {"X-Nonce": nonce, "X-Timestamp": ts,
|
||||
"X-Signature": sign(self.secret, nonce, ts, body)}
|
||||
|
||||
def fetch_config(self) -> dict | None:
|
||||
url = f"{self.base_url}/api/config?device_id={self.device_id}"
|
||||
try:
|
||||
resp = self._get(url, headers=self._headers(""), timeout=self.timeout,
|
||||
allow_redirects=False)
|
||||
resp = self._get(url, headers=self._headers(""), timeout=self.timeout)
|
||||
except Exception:
|
||||
return None
|
||||
if 200 <= resp.status_code < 300:
|
||||
|
|
@ -46,7 +44,7 @@ class ServerClient:
|
|||
headers = {"Content-Type": "application/json", **self._headers(body)}
|
||||
try:
|
||||
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:
|
||||
return False
|
||||
return 200 <= resp.status_code < 300
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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(
|
||||
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.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),
|
||||
motion_thr=motion_thr)
|
||||
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0))
|
||||
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
|
||||
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))
|
||||
|
|
|
|||
132
edge/daemon.py
132
edge/daemon.py
|
|
@ -1,129 +1,42 @@
|
|||
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,
|
||||
sync_every=150, heartbeat_every=900, flush_every=300,
|
||||
frame_interval=0.0, zones_for=None, log=None,
|
||||
heartbeat_interval_s=None, health_fn=None):
|
||||
frame_interval=0.0, zones_for=None):
|
||||
# `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
|
||||
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
|
||||
# never fatal — but it is no longer SILENT (a permanently broken pipeline used to look
|
||||
# perfectly green).
|
||||
# sync/heartbeat/flush. A per-camera step exception is counted, never fatal.
|
||||
runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime]
|
||||
if zones_for is None:
|
||||
from edge.zones import zones_from_config
|
||||
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}
|
||||
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
|
||||
while iterations is None or i < iterations:
|
||||
i += 1
|
||||
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:
|
||||
try:
|
||||
produced = 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:
|
||||
rt.step(now)
|
||||
counters["frames"] += 1
|
||||
streak.pop(id(rt), None) # recovered — reset the streak
|
||||
except Exception:
|
||||
counters["errors"] += 1
|
||||
try:
|
||||
if sync_every and i % sync_every == 0:
|
||||
cfg = client.fetch_config()
|
||||
if cfg is not None:
|
||||
for rt in runtimes:
|
||||
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
|
||||
# Heartbeat cadence: time-based when heartbeat_interval_s is set (production), so the
|
||||
# interval is predictable regardless of per-tick cost (CPU MoveNet, multi-cam, thermal
|
||||
# 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
|
||||
erroring = sum(1 for rt in runtimes if id(rt) in streak)
|
||||
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
|
||||
"cameras_total": len(runtimes), "cameras_erroring": erroring,
|
||||
"errors": counters["errors"], "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):
|
||||
if heartbeat_every and i % heartbeat_every == 0:
|
||||
if client.send_heartbeat({"fps": 0.0, "frames": counters["frames"]}):
|
||||
counters["beats"] += 1
|
||||
beat_anchor = (now, counters["frames"])
|
||||
last_beat = now
|
||||
if flush_every and i % flush_every == 0:
|
||||
queue.flush()
|
||||
counters["flushes"] += 1
|
||||
streak.pop("periodic", None)
|
||||
except Exception as e:
|
||||
note("periodic", "periodic op (sync/heartbeat/flush) failed", e)
|
||||
except Exception:
|
||||
counters["errors"] += 1
|
||||
if frame_interval:
|
||||
sleep(frame_interval)
|
||||
return counters
|
||||
|
|
@ -174,9 +87,8 @@ def build_runtimes(cameras, make_source, make_runtime, log=None):
|
|||
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
|
||||
require_upload_config)
|
||||
from edge.serverclient import ServerClient
|
||||
from edge.config import load_config
|
||||
from edge.cloudclient import CloudClient
|
||||
from edge.alert import PostClient
|
||||
from edge.queue import OfflineQueue
|
||||
from edge.runtime import PipelineRuntime
|
||||
|
|
@ -200,32 +112,24 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
|
|||
return MoveNetPoseAdapter(os.environ["MOVENET_ONNX"])
|
||||
|
||||
cfg = load_config(dict(os.environ))
|
||||
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
|
||||
nonce = lambda: os.urandom(8).hex()
|
||||
# Two clocks, deliberately: the loop/detector timers run on a MONOTONIC clock so an NTP step
|
||||
# (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
|
||||
now = time.time
|
||||
base = cfg.cafe24_url.rstrip("/")
|
||||
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, wall)
|
||||
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, wall)
|
||||
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
|
||||
client = CloudClient(base, 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)
|
||||
|
||||
pose = build_pose() # shared across cameras
|
||||
|
||||
def make_runtime(cam, source):
|
||||
# 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))
|
||||
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.
|
||||
runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
|
||||
build_source, make_runtime)
|
||||
|
||||
run_loop(runtimes, client, queue, clock=mono, sleep=time.sleep, iterations=None,
|
||||
frame_interval=frame_interval_from_env(dict(os.environ)),
|
||||
heartbeat_interval_s=30, # predictable cadence; server marks offline after 120s (4x)
|
||||
run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
|
||||
frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")),
|
||||
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class FallConfig:
|
|||
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
|
||||
prone_confirm_s: float = 3.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_visible: int = 10
|
||||
recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간
|
||||
|
|
@ -40,19 +40,18 @@ class FallDetector:
|
|||
def forget(self, track_id: int) -> 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
|
||||
remaining = 1.0
|
||||
if 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
|
||||
# collapsed. These stay live even when the pose model has lost confidence (occluded /
|
||||
# on the floor), which is exactly when a fall must not be missed.
|
||||
prone = ar > self.cfg.aspect_ratio or remaining < self.cfg.height_collapse
|
||||
angle = torso_angle_deg(kps)
|
||||
# keypoint cues only when the keypoints are actually trustworthy, else they are noise.
|
||||
if kp_ok:
|
||||
prone = prone or angle > self.cfg.fallen_angle or head_below_hips(kps)
|
||||
prone = (
|
||||
angle > self.cfg.fallen_angle
|
||||
or ar > self.cfg.aspect_ratio
|
||||
or remaining < self.cfg.height_collapse # 높이 급붕괴(정면/후면 낙상)
|
||||
or head_below_hips(kps)
|
||||
)
|
||||
return prone, {"torso_angle": round(angle, 1),
|
||||
"aspect_ratio": round(ar, 2),
|
||||
"height_remaining": round(remaining, 2)}
|
||||
|
|
@ -62,34 +61,20 @@ class FallDetector:
|
|||
f = track.latest
|
||||
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
|
||||
# KEYPOINT-based cues are trustworthy; the bbox cues always run.
|
||||
kp_ok = core_visible(kps, self.cfg.min_conf) and visible_count(kps) >= self.cfg.min_visible
|
||||
# 신뢰도 게이트
|
||||
if not core_visible(kps, self.cfg.min_conf) or visible_count(kps) < self.cfg.min_visible:
|
||||
return None, {}
|
||||
|
||||
prone, meta = self._prone(kps, bbox, st, kp_ok)
|
||||
prone, meta = self._prone(kps, bbox, st)
|
||||
|
||||
# incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze. Only trust a
|
||||
# confident standing frame for the baseline so a bad low-conf bbox can't corrupt it.
|
||||
if st.name == "STANDING" and not prone and kp_ok:
|
||||
# incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze
|
||||
if st.name == "STANDING" and not prone:
|
||||
st.baseline_height = bbox.height
|
||||
|
||||
if not prone:
|
||||
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 사고를 즉시 취소하지 않도록
|
||||
# recover_grace_s만큼 지속된 (신뢰) 비-prone을 요구한다.
|
||||
# recover_grace_s만큼 지속된 비-prone을 요구한다.
|
||||
if st.not_prone_since is None:
|
||||
st.not_prone_since = now
|
||||
return None, {}
|
||||
|
|
|
|||
|
|
@ -26,20 +26,13 @@ class GhostIncidentDetector:
|
|||
state = self._states.get(tr.track_id, "STANDING")
|
||||
if state in _DANGEROUS:
|
||||
b = tr.latest.bbox
|
||||
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT
|
||||
# exist then can be the victim re-appearing (a re-acquired track gets a new
|
||||
# id); a track already present is a bystander/colleague and must not cancel
|
||||
# the incident by walking over the loss point.
|
||||
bystanders = set(self._states) - {tr.track_id}
|
||||
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, bystanders)
|
||||
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now)
|
||||
self._states.pop(tr.track_id, None)
|
||||
|
||||
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:
|
||||
if tr.track_id in bystanders:
|
||||
continue # a colleague who was already here — not the reappearing victim
|
||||
b = tr.latest.bbox
|
||||
dist = ((b.cx - spatial[0]) ** 2 + (b.cy - spatial[1]) ** 2) ** 0.5
|
||||
if dist <= self.cfg.reappear_dist * (height or 1.0):
|
||||
|
|
@ -47,7 +40,7 @@ class GhostIncidentDetector:
|
|||
break
|
||||
# 대기 초과 방출
|
||||
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:
|
||||
out.append({"track_id": tid, "spatial": spatial, "last_state": state,
|
||||
"waited_s": round(now - lost_at, 1)})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from edge.geometry import track_displacement
|
|||
|
||||
@dataclass
|
||||
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
|
||||
zone_immobile_s: float = 30.0
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ from dataclasses import dataclass
|
|||
from edge.tracking import Track
|
||||
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:
|
||||
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._fired = {k for k in self._fired if k[0] != track_id}
|
||||
|
||||
def _foot(self, track: Track, frame_wh=None) -> tuple[float, float]:
|
||||
p = track.latest
|
||||
a, b = p.keypoints[L_ANKLE], p.keypoints[R_ANKLE]
|
||||
# Average only the ankles the model is confident about. A hallucinated ankle (conf~0,
|
||||
# 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 _foot(self, track: Track) -> tuple[float, float]:
|
||||
kps = track.latest.keypoints
|
||||
a, b = kps[L_ANKLE], kps[R_ANKLE]
|
||||
return ((a.x + b.x) / 2, (a.y + b.y) / 2)
|
||||
|
||||
def active_zone_ids(self, track_id: int) -> set:
|
||||
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]:
|
||||
fx, fy = self._foot(track, frame_wh)
|
||||
def update(self, track: Track, now: float) -> list[dict]:
|
||||
fx, fy = self._foot(track)
|
||||
out = []
|
||||
for zone in self.zones:
|
||||
key = (track.track_id, zone.zone_id)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue