Compare commits
24 commits
34dcf557e9
...
9ed759befc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed759befc | ||
|
|
bf5255e9eb | ||
|
|
752afc3f00 | ||
|
|
aa4378e906 | ||
|
|
5444f19d45 | ||
|
|
f062667a65 | ||
|
|
a2d2d24793 | ||
|
|
3f2823a764 | ||
|
|
5d511b04f2 | ||
|
|
3a8a5106cd | ||
|
|
2ea0420de7 | ||
|
|
930490d1b4 | ||
|
|
70c9f4e0fe | ||
|
|
f1ee7f0a36 | ||
|
|
949c6d05c3 | ||
|
|
1fb7a5da58 | ||
|
|
99070f5e87 | ||
|
|
9a0ff1fc5e | ||
|
|
d0ba621b42 | ||
|
|
c66b034921 | ||
|
|
36b1f94571 | ||
|
|
7a86545367 | ||
|
|
1187df5628 | ||
|
|
9386dfc580 |
169 changed files with 7924 additions and 1684 deletions
10
.gitattributes
vendored
10
.gitattributes
vendored
|
|
@ -4,3 +4,13 @@
|
|||
*.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,7 +12,12 @@ dist/
|
|||
# Edge runtime artifacts
|
||||
*.db
|
||||
*.hef
|
||||
snapshots/
|
||||
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
|
||||
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
|
||||
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
|
||||
# edge never writes a snapshots directory anyway: they stay in memory and are base64'd into
|
||||
# the queue db or POSTed (edge/queue.py, edge/alert.py).
|
||||
/snapshots/
|
||||
queue.db
|
||||
|
||||
# Secrets / local config
|
||||
|
|
@ -25,3 +30,6 @@ Thumbs.db
|
|||
|
||||
# Tool caches
|
||||
.impeccable/
|
||||
|
||||
# Pi provisioning (real device secret) — never commit
|
||||
deploy/device.env
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
<?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,97 +0,0 @@
|
|||
/* 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; }
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
})();
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
<?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();
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?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();
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
<?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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<?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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
<?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'); }
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<?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 cloud POST / config-poll / heartbeat, and even that tolerates
|
||||
network use is the server 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,19 +23,41 @@ 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 this repo onto
|
||||
the Pi (git clone or scp), then:
|
||||
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:
|
||||
|
||||
```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, 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -55,16 +77,18 @@ wheel tags may not resolve; if it fails, build natively):
|
|||
deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311
|
||||
```
|
||||
|
||||
Output: `dist/move-detect-edge-bundle-aarch64-cp311.tar.gz` (+ `MANIFEST.txt`
|
||||
listing the bundled wheels). This single file crosses the air gap.
|
||||
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.
|
||||
|
||||
## 2. Transfer + install (on the air-gapped Pi)
|
||||
|
||||
Copy the tarball across the gap (USB, one-way transfer, etc.), then:
|
||||
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
|
||||
|
||||
```bash
|
||||
tar xzf move-detect-edge-bundle-aarch64-cp311.tar.gz
|
||||
cd move-detect-edge-bundle
|
||||
# tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz
|
||||
cd 2_pi_install
|
||||
sudo ./install_offline.sh
|
||||
```
|
||||
|
||||
|
|
@ -81,8 +105,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 cloud (see
|
||||
`cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
|
||||
`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
|
||||
model (`/opt/move-detect/models/movenet_lightning.onnx`).
|
||||
|
||||
## 4. Verify the air gap (do this before trusting a build)
|
||||
|
|
@ -93,7 +117,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 cloud is reachable) heartbeats.
|
||||
frames processed and (if the server 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.)
|
||||
|
||||
|
|
@ -125,14 +149,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 cloud zones are routed to the
|
||||
the Hailo device (round-robin) and the offline queue, and server 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 cloud.
|
||||
Each `camera_id` must match the `camera_id` on that camera's zones in the server.
|
||||
|
||||
> **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
|
||||
|
|
@ -141,7 +165,7 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
|
|||
|
||||
## Not bundled (by design)
|
||||
|
||||
- **MySQL / the cloud app** — a separate host; see `cloud/docs/cloud-deploy.md`.
|
||||
- **MySQL / the server app** — a separate host; see `server/docs/server-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.
|
||||
|
|
|
|||
130
deploy/bundle/build_offline_bundle.sh
Normal file → Executable file
130
deploy/bundle/build_offline_bundle.sh
Normal file → Executable file
|
|
@ -20,12 +20,17 @@ set -euo pipefail
|
|||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
||||
STAGE="$OUT_DIR/move-detect-edge-bundle"
|
||||
STAGE="$OUT_DIR/2_pi_install"
|
||||
WHEELHOUSE="$STAGE/wheelhouse"
|
||||
PYVER="${PYVER:-3.11}" # Pi OS Bookworm ships CPython 3.11
|
||||
PYABI="cp${PYVER//./}" # cp311
|
||||
TARGET_ARCH="${TARGET_ARCH:-aarch64}"
|
||||
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
|
||||
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)
|
||||
|
||||
MODE="native"
|
||||
[ "${1:-}" = "--cross" ] && MODE="cross"
|
||||
|
|
@ -33,12 +38,13 @@ 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/5] downloading runtime wheels -> wheelhouse/"
|
||||
echo ">> [1/6] downloading runtime wheels -> wheelhouse/"
|
||||
if [ "$MODE" = "cross" ]; then
|
||||
# Best-effort cross download. Try the common manylinux aarch64 tags.
|
||||
python3 -m pip download \
|
||||
|
|
@ -47,6 +53,14 @@ 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
|
||||
|
|
@ -59,37 +73,131 @@ 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/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/"
|
||||
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
|
||||
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$STAGE/models/"
|
||||
else
|
||||
echo " -> MoveNet ONNX unavailable; Hailo-only bundle (CPU fallback not included)."
|
||||
fi
|
||||
|
||||
echo ">> [3/5] staging edge source (package + pyproject)"
|
||||
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)"
|
||||
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 ">> [4/5] staging deploy assets + install script"
|
||||
echo ">> [5/6] 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"
|
||||
|
||||
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"
|
||||
# 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 ">> DONE: $TARBALL"
|
||||
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."
|
||||
|
|
|
|||
186
deploy/bundle/install_offline.sh
Normal file → Executable file
186
deploy/bundle/install_offline.sh
Normal file → Executable file
|
|
@ -18,7 +18,78 @@ STATE=/var/lib/move-detect
|
|||
ETC=/etc/move-detect
|
||||
SVC_USER=movedetect
|
||||
WHEELHOUSE="$HERE/wheelhouse"
|
||||
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
|
||||
# Mandatory for every backend. onnxruntime is deliberately NOT here — see the optional
|
||||
# install below; a Hailo bundle need not carry its wheel.
|
||||
RUNTIME_PKGS=(numpy requests opencv-python-headless)
|
||||
OPTIONAL_PKGS=(onnxruntime)
|
||||
|
||||
echo ">> [0/6] verify bundle integrity BEFORE touching the system"
|
||||
# Verify EVERY bundled file against the build-time SHA256SUMS before any mutation of $PREFIX. A
|
||||
# truncated/tampered bundle must abort HERE — with the previous install still intact — never after
|
||||
# rm -rf has already destroyed it and left the Restart=always unit to run half-copied code. The
|
||||
# build now hashes the daemon source, unit and this installer too, so tampered code cannot pass.
|
||||
ALLOW_UNVERIFIED="${ALLOW_UNVERIFIED:-0}"
|
||||
if [ -f "$HERE/SHA256SUMS" ]; then
|
||||
( cd "$HERE" && sha256sum -c SHA256SUMS ) \
|
||||
|| { echo "ERROR: bundle integrity check failed (SHA256SUMS mismatch) — refusing to install." >&2; exit 1; }
|
||||
# sha256sum -c only checks LISTED files; an INJECTED extra wheel (which could win pip's version
|
||||
# resolution) would pass unnoticed. Refuse any staged file not present in the manifest.
|
||||
EXTRA="$( cd "$HERE" && comm -23 \
|
||||
<(find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' | sed 's|^\./||' | sort) \
|
||||
<(awk '{print $2}' SHA256SUMS | sed 's|^[*]||; s|^\./||' | sort) )"
|
||||
if [ -n "$EXTRA" ]; then
|
||||
echo "ERROR: bundle contains file(s) not listed in SHA256SUMS (possible injection):" >&2
|
||||
echo "$EXTRA" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A detached signature proves PROVENANCE — an unsigned SHA256SUMS only detects corruption, since
|
||||
# an attacker who can modify the bundle can strip the .asc and regenerate the hashes. Set
|
||||
# REQUIRE_SIGNATURE=1 for untrusted transport to make a missing/invalid signature fatal.
|
||||
REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-0}"
|
||||
if [ -f "$HERE/SHA256SUMS.asc" ]; then
|
||||
gpg --verify "$HERE/SHA256SUMS.asc" "$HERE/SHA256SUMS" 2>/dev/null \
|
||||
|| { echo "ERROR: SHA256SUMS.asc present but its signature did NOT verify — refusing to install." >&2; exit 1; }
|
||||
echo " -> SHA256SUMS signature verified"
|
||||
elif [ "$REQUIRE_SIGNATURE" = 1 ]; then
|
||||
echo "ERROR: REQUIRE_SIGNATURE=1 but this bundle has no SHA256SUMS.asc. An unsigned bundle" >&2
|
||||
echo "detects corruption only, not tampering — refusing. Build with SIGN_KEY, or clear the flag." >&2
|
||||
exit 1
|
||||
else
|
||||
echo " -> NOTE: unsigned bundle (corruption-detection only; set REQUIRE_SIGNATURE=1 to require a signature)."
|
||||
fi
|
||||
echo " -> bundle integrity OK"
|
||||
elif [ "$ALLOW_UNVERIFIED" = 1 ]; then
|
||||
echo " -> WARNING: no SHA256SUMS; ALLOW_UNVERIFIED=1 set — installing WITHOUT integrity check." >&2
|
||||
else
|
||||
echo "ERROR: no SHA256SUMS in this bundle — refusing to install unverified code. Rebuild with a" >&2
|
||||
echo "current build_offline_bundle.sh, or (NOT recommended) re-run with ALLOW_UNVERIFIED=1." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">> [0b/6] Hailo arch preflight (BEFORE any mutation)"
|
||||
# A hailo8 HEF on a hailo8l board (or vice-versa) imports fine but the daemon dies with
|
||||
# HAILO_INVALID_HEF(26) on every start, forever. Catch the WRONG bundle here — using MANIFEST.txt
|
||||
# (staged) + hailortcli (a system CLI, no venv needed) — so a mismatch aborts with the previous
|
||||
# install still intact, not after the HEF/source/venv have already been overwritten.
|
||||
BUNDLE_HAILO_ARCH="$(awk -F': ' '/^hailo_arch:/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}' "$HERE/MANIFEST.txt" 2>/dev/null || true)"
|
||||
if [ -n "$BUNDLE_HAILO_ARCH" ]; then
|
||||
BOARD_HAILO_ARCH=""
|
||||
if command -v hailortcli >/dev/null 2>&1; then
|
||||
BOARD_HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \
|
||||
| awk -F': ' '/[Aa]rchitecture/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}')"
|
||||
fi
|
||||
if [ -n "$BOARD_HAILO_ARCH" ] && [ "$BUNDLE_HAILO_ARCH" != "$BOARD_HAILO_ARCH" ]; then
|
||||
echo "ERROR: Hailo arch mismatch — this bundle's HEF is '$BUNDLE_HAILO_ARCH' but the board is" >&2
|
||||
echo "'$BOARD_HAILO_ARCH'. It would crash-loop with HAILO_INVALID_HEF(26). Rebuild with" >&2
|
||||
echo "HAILO_ARCH=$BOARD_HAILO_ARCH (deploy/bundle/build_offline_bundle.sh)." >&2
|
||||
exit 1
|
||||
elif [ -z "$BOARD_HAILO_ARCH" ]; then
|
||||
echo " -> NOTE: could not read the board's Hailo arch (hailortcli?) to verify the bundle's" >&2
|
||||
echo " '$BUNDLE_HAILO_ARCH'; if the daemon later fails HAILO_INVALID_HEF(26), the arch is wrong." >&2
|
||||
else
|
||||
echo " -> Hailo arch OK ($BUNDLE_HAILO_ARCH)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">> [1/6] service account + groups"
|
||||
if ! id -u "$SVC_USER" >/dev/null 2>&1; then
|
||||
|
|
@ -31,15 +102,24 @@ 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. Without the rm, `cp -r SRC DEST`
|
||||
# nests SRC *inside* an existing DEST (edge/edge/...) and the OLD code keeps running.
|
||||
# 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"
|
||||
rm -rf "$PREFIX/src/edge"
|
||||
cp -r "$HERE/src/edge" "$PREFIX/src/edge"
|
||||
mv "$PREFIX/src/edge.new" "$PREFIX/src/edge"
|
||||
cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true
|
||||
cp "$HERE/models/"*.onnx "$PREFIX/models/"
|
||||
# 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/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
|
||||
|
|
@ -49,21 +129,96 @@ 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"
|
||||
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, onnxruntime, cv2, numpy, requests; print('edge import OK')"
|
||||
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
|
||||
# on the unconditional startup chain; the daemon's server clients import requests at startup).
|
||||
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
|
||||
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
|
||||
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
|
||||
# not carry it. Report, don't fail: a hard `import onnxruntime` here refuses the install over
|
||||
# a package the daemon never loads.
|
||||
if PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import onnxruntime" >/dev/null 2>&1; then
|
||||
echo " -> onnxruntime present; CPU MoveNet available"
|
||||
else
|
||||
echo " -> onnxruntime absent; CPU MoveNet unavailable (set POSE_BACKEND=hailo in $ETC/edge.env)"
|
||||
fi
|
||||
|
||||
echo ">> [5/6] pose backend gate + state dir + env file"
|
||||
# Do NOT hand over a Pi whose POSE_BACKEND cannot load. The template ships POSE_BACKEND=movenet,
|
||||
# but a Hailo-only bundle carries no onnxruntime/ONNX — installing it verbatim would crash-loop.
|
||||
# Probe what can ACTUALLY run (like setup_online's [6/8]) and reconcile, or refuse.
|
||||
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
|
||||
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
|
||||
pyhas() { PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; }
|
||||
state() { if "$@" >/dev/null 2>&1; then echo present; else echo MISSING; fi; }
|
||||
HAILO_OK=0; MOVENET_OK=0
|
||||
if [ -f "$HEF_DEST" ] && pyhas hailo_platform; then HAILO_OK=1; fi
|
||||
if [ -f "$ONNX_DEST" ] && pyhas onnxruntime; then MOVENET_OK=1; fi
|
||||
if [ "$HAILO_OK" != 1 ] && [ "$MOVENET_OK" != 1 ]; then
|
||||
echo "" >&2
|
||||
echo "ERROR: no usable pose backend in this bundle — refusing to install a daemon that" >&2
|
||||
echo "cannot start." >&2
|
||||
echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform')" >&2
|
||||
echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2
|
||||
echo " Rebuild the bundle for the target backend (deploy/bundle/build_offline_bundle.sh)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Prefer Hailo (the accelerator) when usable; MoveNet is the CPU fallback. (Arch already
|
||||
# preflighted in [0b/6].)
|
||||
if [ "$HAILO_OK" = 1 ]; then DEFAULT_BACKEND=hailo; else DEFAULT_BACKEND=movenet; fi
|
||||
backend_usable() { # 0 (true) iff the named backend can actually load in THIS bundle
|
||||
case "$1" in
|
||||
hailo) [ "$HAILO_OK" = 1 ] ;;
|
||||
movenet) [ "$MOVENET_OK" = 1 ] ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo ">> [5/6] state dir + env file"
|
||||
install -d -m 0750 "$STATE"
|
||||
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"
|
||||
chown root:"$SVC_USER" "$ETC/edge.env"
|
||||
echo " -> wrote $ETC/edge.env from template (EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)"
|
||||
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
|
||||
echo " -> $ETC/edge.env already exists; left unchanged"
|
||||
# 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 ">> [6/6] systemd unit"
|
||||
install -m 0644 "$HERE/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
|
||||
|
|
@ -71,6 +226,11 @@ systemctl daemon-reload
|
|||
|
||||
echo ""
|
||||
echo "Installed. Next:"
|
||||
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"
|
||||
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"
|
||||
|
|
|
|||
297
deploy/setup_online.sh
Normal file → Executable file
297
deploy/setup_online.sh
Normal file → Executable file
|
|
@ -5,9 +5,18 @@
|
|||
# Run from a clone/copy of this repo, as root:
|
||||
# sudo deploy/setup_online.sh
|
||||
#
|
||||
# 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
|
||||
# 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.
|
||||
# Interactive (a real terminal): it prompts for anything not passed.
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -22,21 +31,56 @@ STATE=/var/lib/move-detect
|
|||
ETC=/etc/move-detect
|
||||
SVC_USER=movedetect
|
||||
|
||||
echo ">> [1/8] system packages (CSI camera, venv; Hailo runtime best-effort)"
|
||||
echo ">> [1/9] system packages (CSI camera, venv; Hailo runtime best-effort)"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y python3-picamera2 python3-venv python3-pip
|
||||
# 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
|
||||
# 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-get install -y hailo-all || echo " WARN: hailo-all not installed — Hailo accel unavailable, CPU MoveNet will be used."
|
||||
apt_retry install -y hailo-all || echo " WARN: hailo-all not installed — Hailo accel unavailable, CPU MoveNet will be used."
|
||||
|
||||
echo ">> [2/8] service account + device groups"
|
||||
echo ">> [2/9] 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/8] source + dirs under $PREFIX"
|
||||
echo ">> [3/9] 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"
|
||||
|
|
@ -44,42 +88,172 @@ 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/8] venv (+ system site-packages) + pip runtime deps"
|
||||
echo ">> [4/9] venv (+ system site-packages) + pip runtime deps"
|
||||
python3 -m venv --system-site-packages "$PREFIX/venv"
|
||||
"$PREFIX/venv/bin/pip" install --upgrade pip >/dev/null
|
||||
# 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
|
||||
# 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)."
|
||||
|
||||
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/8] pose backend"
|
||||
POSE_BACKEND=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"
|
||||
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 " -> 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"
|
||||
echo " note: no MoveNet ONNX in the delivery (expected for a Hailo device)."
|
||||
fi
|
||||
|
||||
echo ">> [7/8] config -> $ETC/edge.env"
|
||||
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
|
||||
POSE_BACKEND=movenet
|
||||
echo " -> no usable Hailo; starting on CPU MoveNet."
|
||||
echo " For Hailo: copy yolov8s_pose.hef to $HEF_DEST, set POSE_BACKEND=hailo in"
|
||||
echo " $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"
|
||||
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 cloud): " DEVICE_SECRET; echo; }
|
||||
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (cloud base URL): " CAFE24_URL
|
||||
[ -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 "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
|
||||
fi
|
||||
DEVICE_ID="${DEVICE_ID:-pi-01}"
|
||||
|
|
@ -99,14 +273,69 @@ 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 cloud will accept events."
|
||||
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the server will accept events."
|
||||
echo " -> wrote $ETC/edge.env"
|
||||
fi
|
||||
|
||||
echo ">> [8/8] systemd service"
|
||||
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"
|
||||
install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now move-detect-edge
|
||||
|
||||
diagnose() {
|
||||
echo "" >&2
|
||||
echo "ERROR: move-detect-edge did not come up. systemd's reason:" >&2
|
||||
echo "--------------------------------------------------------------------" >&2
|
||||
systemctl status --no-pager --full move-detect-edge >&2 || true
|
||||
journalctl -xeu move-detect-edge -n 50 --no-pager >&2 || true
|
||||
echo "--------------------------------------------------------------------" >&2
|
||||
echo "Full log: sudo journalctl -xeu move-detect-edge" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# enable + restart rather than `enable --now`, so a re-run also picks up a rewritten
|
||||
# edge.env instead of no-op'ing on an already-running unit.
|
||||
systemctl enable move-detect-edge
|
||||
systemctl restart move-detect-edge || diagnose
|
||||
# Type=exec fails the job on exec-step errors, but a Python traceback *after* execve still
|
||||
# leaves `restart` returning 0. RestartSec=5 means a crash-looping unit reads as
|
||||
# "activating" here, which is-active correctly rejects. This is a crash detector, not a
|
||||
# readiness gate: under Type=exec the unit is active the moment execve() succeeds, so a
|
||||
# slow camera/Hailo init cannot false-fail it.
|
||||
sleep 5
|
||||
systemctl is-active --quiet move-detect-edge || diagnose
|
||||
# is-active alone is phase-dependent: a crash at T<=~0.2s after execve is already back in
|
||||
# "active" by the time we sample (T + RestartSec=5 <= 5.2), so a crash-looping daemon can
|
||||
# read as healthy. NRestarts is phase-independent — for a unit we just started, any restart
|
||||
# at all means it has already died once.
|
||||
if [ "$(systemctl show -p NRestarts --value move-detect-edge 2>/dev/null || echo 0)" != "0" ]; then
|
||||
echo "" >&2
|
||||
echo "ERROR: move-detect-edge has already restarted at least once — it is crash-looping." >&2
|
||||
diagnose
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. The edge daemon is installed and started."
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@
|
|||
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).
|
||||
CAFE24_URL=https://cloud.example.com
|
||||
# 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
|
||||
# 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 cloud being offline (events buffer in the local queue
|
||||
# The daemon tolerates the server being offline (events buffer in the local queue
|
||||
# and flush on reconnect), so this unit only *wants* the network, not requires it.
|
||||
|
||||
[Unit]
|
||||
|
|
@ -9,9 +9,23 @@ 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=simple
|
||||
# Type=exec, not simple: with simple, systemd marks the start job done the instant fork()
|
||||
# returns, so an exec-step failure (226/NAMESPACE, 216/GROUP, 217/USER, 203/EXEC) never
|
||||
# reaches `systemctl enable --now` and the installer reports success over a dead daemon.
|
||||
Type=exec
|
||||
# Dedicated unprivileged account; must be in the groups that own the camera and
|
||||
# the Hailo accelerator device nodes.
|
||||
User=movedetect
|
||||
|
|
@ -28,21 +42,36 @@ 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 (USB UVC = /dev/video*, Hailo = /dev/hailo0).
|
||||
# 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.
|
||||
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
|
||||
ReadWritePaths=/var/lib/move-detect
|
||||
# Do NOT add ReadWritePaths=/var/lib/move-detect here. StateDirectory= above already emits
|
||||
# a recursive read-write bind mount for that exact path, which outranks a ReadWritePaths=
|
||||
# entry for the same path and drops it before any path is resolved. It is dead config: it
|
||||
# can neither cause nor fix a state-dir problem.
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
|
|
|
|||
287
docs/delivery-audit.md
Normal file
287
docs/delivery-audit.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# 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,17 +1,45 @@
|
|||
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
|
||||
# 파일럿 · 운영 런북
|
||||
|
||||
이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다.
|
||||
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
|
||||
이 문서는 두 경로를 다룬다. **자신의 상황에 맞는 절에서 시작하라:**
|
||||
|
||||
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
|
||||
- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
|
||||
- **§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 알림.
|
||||
|
||||
---
|
||||
|
||||
## 1. 빌드 머신 준비 (모델 · onnxruntime)
|
||||
## 0. 폐쇄망 Pi 현장 설치·운영
|
||||
|
||||
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서
|
||||
> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
|
||||
실장비 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는 이미 번들로 설치됨).
|
||||
> 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
|
||||
|
||||
```bash
|
||||
pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들)
|
||||
|
|
@ -25,23 +53,23 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|
|||
|
||||
---
|
||||
|
||||
## 2. 클라우드 로컬 기동
|
||||
## 2. 서버 로컬 기동
|
||||
|
||||
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
|
||||
[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
||||
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
||||
|
||||
1. DB 생성 후 스키마 임포트: `mysql <db> < cloud/schema.sql` (10개 테이블).
|
||||
2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
||||
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
|
||||
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
||||
(`snapshot_dir`는 쓰기 가능 경로로).
|
||||
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
||||
CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
|
||||
CLI 스니펫과 SQL은 server-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 cloud/public`
|
||||
4. 기동: `php -S localhost:8000 -t server/public`
|
||||
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
||||
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조.
|
||||
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -53,7 +81,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` |
|
||||
|
|
@ -83,10 +111,13 @@ python -m edge # 또는 move-detect-edge
|
|||
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
|
||||
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
|
||||
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
|
||||
7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명).
|
||||
7. **하트비트:** **30초 시간기반**(`heartbeat_interval_s=30`)으로 `POST /api/heartbeat`(서명).
|
||||
부하와 무관하게 주기가 일정해 서버의 오프라인 판정(120초, 4배 마진)이 정상 장치를 오프라인으로
|
||||
깜빡이지 않는다. temp/throttle/queue_pending/config_version 텔레메트리를 함께 싣는다.
|
||||
|
||||
> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로
|
||||
> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다.
|
||||
> cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()`
|
||||
> 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를
|
||||
> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -94,7 +125,7 @@ python -m edge # 또는 move-detect-edge
|
|||
|
||||
```bash
|
||||
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
|
||||
cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings)
|
||||
cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # 서버(0 warnings)
|
||||
```
|
||||
|
||||
모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —
|
||||
|
|
@ -102,11 +133,15 @@ cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations #
|
|||
|
||||
---
|
||||
|
||||
## 6. 파일럿 한계 (명시)
|
||||
## 6. 한계 (명시)
|
||||
|
||||
- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속.
|
||||
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2.
|
||||
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
|
||||
- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save`
|
||||
패키징은 별도 후속 태스크.
|
||||
> **이 절은 §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`로 편의 실행할 뿐이다.
|
||||
- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# P1-B 클라우드 수신·대시보드 (Cloud Ingest + Dashboard) Implementation Plan
|
||||
# P1-B 서버 수신·대시보드 (Server 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\` → `cloud/src/`).
|
||||
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `cloud/public/`만 웹 노출.
|
||||
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `server/src/`).
|
||||
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `server/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 파일명), **웹루트 밖**(`cloud/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
|
||||
- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`server/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/시크릿 파일(`cloud/config.php`는 `.gitignore`), 하드코딩 금지.
|
||||
- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`server/config.php`는 `.gitignore`), 하드코딩 금지.
|
||||
- **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24).
|
||||
- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
cloud/
|
||||
server/
|
||||
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 @@ cloud/
|
|||
## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/composer.json`, `cloud/phpunit.xml`, `cloud/src/Config.php`, `cloud/config.example.php`, `cloud/tests/ConfigTest.php`, `cloud/.gitignore`
|
||||
- Create: `server/composer.json`, `server/phpunit.xml`, `server/src/Config.php`, `server/config.example.php`, `server/tests/ConfigTest.php`, `server/.gitignore`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (없음)
|
||||
|
|
@ -74,7 +74,7 @@ cloud/
|
|||
|
||||
```json
|
||||
{
|
||||
"name": "move-detect/cloud",
|
||||
"name": "move-detect/server",
|
||||
"type": "project",
|
||||
"require": { "php": ">=8.2" },
|
||||
"require-dev": { "phpunit/phpunit": "^11" },
|
||||
|
|
@ -95,7 +95,7 @@ cloud/
|
|||
</phpunit>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `.gitignore`** (cloud/)
|
||||
- [ ] **Step 3: `.gitignore`** (server/)
|
||||
|
||||
```
|
||||
/vendor/
|
||||
|
|
@ -104,7 +104,7 @@ cloud/
|
|||
!/storage/snapshots/.gitkeep
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php`
|
||||
- [ ] **Step 4: 실패 테스트** — `server/tests/ConfigTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -135,10 +135,10 @@ final class ConfigTest extends TestCase
|
|||
|
||||
- [ ] **Step 5: 실패 확인**
|
||||
|
||||
Run: `cd cloud && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
|
||||
Run: `cd server && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
|
||||
Expected: FAIL — `Class "App\Config" not found`
|
||||
|
||||
- [ ] **Step 6: 구현** — `cloud/src/Config.php`
|
||||
- [ ] **Step 6: 구현** — `server/src/Config.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -180,8 +180,8 @@ return [
|
|||
Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -189,13 +189,13 @@ git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config"
|
|||
## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Security/Hmac.php`, `cloud/tests/HmacTest.php`
|
||||
- Create: `server/src/Security/Hmac.php`, `server/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: 실패 테스트** — `cloud/tests/HmacTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/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: 구현** — `cloud/src/Security/Hmac.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Security/Hmac.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -256,8 +256,8 @@ final class Hmac
|
|||
Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS
|
||||
|
||||
```bash
|
||||
git add cloud/src/Security/Hmac.php cloud/tests/HmacTest.php
|
||||
git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
|
||||
git add server/src/Security/Hmac.php server/tests/HmacTest.php
|
||||
git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -265,7 +265,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
|
|||
## Task 3: Repository 인터페이스 + InMemory 테스트더블
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `cloud/tests/Support/InMemoryRepositories.php`, `cloud/tests/RepositoryContractTest.php`
|
||||
- Create: `server/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `server/tests/Support/InMemoryRepositories.php`, `server/tests/RepositoryContractTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (없음)
|
||||
|
|
@ -282,7 +282,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
|
|||
|
||||
- [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의.
|
||||
|
||||
`cloud/src/Repository/DeviceRepository.php`:
|
||||
`server/src/Repository/DeviceRepository.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -413,7 +413,7 @@ interface AuditRepository
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `cloud/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
|
||||
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `server/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -479,7 +479,7 @@ final class InMemoryAuditRepository implements AuditRepository {
|
|||
}
|
||||
```
|
||||
|
||||
그리고 `cloud/tests/RepositoryContractTest.php`:
|
||||
그리고 `server/tests/RepositoryContractTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -523,8 +523,8 @@ Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS.
|
|||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -532,13 +532,13 @@ git commit -m "feat(cloud): repository interfaces + in-memory test doubles"
|
|||
## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증)
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Ingest/EventValidator.php`, `cloud/tests/EventValidatorTest.php`
|
||||
- Create: `server/src/Ingest/EventValidator.php`, `server/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: 실패 테스트** — `cloud/tests/EventValidatorTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/EventValidatorTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -579,7 +579,7 @@ final class EventValidatorTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/EventValidator.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Ingest/EventValidator.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -627,8 +627,8 @@ final class EventValidator
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add cloud/src/Ingest/EventValidator.php cloud/tests/EventValidatorTest.php
|
||||
git commit -m "feat(cloud): event payload validator with enum whitelist"
|
||||
git add server/src/Ingest/EventValidator.php server/tests/EventValidatorTest.php
|
||||
git commit -m "feat(server): event payload validator with enum whitelist"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -636,13 +636,13 @@ git commit -m "feat(cloud): event payload validator with enum whitelist"
|
|||
## Task 5: ImageProcessor (gd 재인코딩 + UUID)
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Ingest/ImageProcessor.php`, `cloud/tests/ImageProcessorTest.php`
|
||||
- Create: `server/src/Ingest/ImageProcessor.php`, `server/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: 실패 테스트** — `cloud/tests/ImageProcessorTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/ImageProcessorTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -677,7 +677,7 @@ final class ImageProcessorTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/ImageProcessor.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Ingest/ImageProcessor.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -710,8 +710,8 @@ final class ImageProcessor
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add cloud/src/Ingest/ImageProcessor.php cloud/tests/ImageProcessorTest.php
|
||||
git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
|
||||
git add server/src/Ingest/ImageProcessor.php server/tests/ImageProcessorTest.php
|
||||
git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -719,7 +719,7 @@ git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
|
|||
## Task 6: Http Request/Response
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Http/Request.php`, `cloud/src/Http/Response.php`, `cloud/tests/HttpTest.php`
|
||||
- Create: `server/src/Http/Request.php`, `server/src/Http/Response.php`, `server/tests/HttpTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (없음)
|
||||
|
|
@ -727,7 +727,7 @@ git commit -m "feat(cloud): 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: 실패 테스트** — `cloud/tests/HttpTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/HttpTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -755,7 +755,7 @@ final class HttpTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Http/Request.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Http/Request.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -792,7 +792,7 @@ final class Request
|
|||
}
|
||||
```
|
||||
|
||||
`cloud/src/Http/Response.php`:
|
||||
`server/src/Http/Response.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -829,8 +829,8 @@ final class Response
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -838,13 +838,13 @@ git commit -m "feat(cloud): Http Request/Response primitives"
|
|||
## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Ingest/IngestController.php`, `cloud/tests/IngestControllerTest.php`
|
||||
- Create: `server/src/Ingest/IngestController.php`, `server/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: 실패 테스트** — `cloud/tests/IngestControllerTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/IngestControllerTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -919,7 +919,7 @@ final class IngestControllerTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/IngestController.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Ingest/IngestController.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -991,8 +991,8 @@ final class IngestController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add cloud/src/Ingest/IngestController.php cloud/tests/IngestControllerTest.php
|
||||
git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
|
||||
git add server/src/Ingest/IngestController.php server/tests/IngestControllerTest.php
|
||||
git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1000,7 +1000,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
|
|||
## Task 8: Password (argon2id) + Csrf
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Security/Password.php`, `cloud/src/Security/Csrf.php`, `cloud/tests/PasswordCsrfTest.php`
|
||||
- Create: `server/src/Security/Password.php`, `server/src/Security/Csrf.php`, `server/tests/PasswordCsrfTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (없음)
|
||||
|
|
@ -1008,7 +1008,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
|
|||
- `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: 실패 테스트** — `cloud/tests/PasswordCsrfTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/PasswordCsrfTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1036,7 +1036,7 @@ final class PasswordCsrfTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Security/Password.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Security/Password.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1054,7 +1054,7 @@ final class Password
|
|||
}
|
||||
```
|
||||
|
||||
`cloud/src/Security/Csrf.php`:
|
||||
`server/src/Security/Csrf.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1073,8 +1073,8 @@ final class Csrf
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1082,13 +1082,13 @@ git commit -m "feat(cloud): argon2id password + CSRF token helpers"
|
|||
## Task 9: Rbac (역할→권한)
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Auth/Rbac.php`, `cloud/tests/RbacTest.php`
|
||||
- Create: `server/src/Auth/Rbac.php`, `server/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: 실패 테스트** — `cloud/tests/RbacTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/RbacTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1123,7 +1123,7 @@ final class RbacTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Auth/Rbac.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Auth/Rbac.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1146,8 +1146,8 @@ final class Rbac
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add cloud/src/Auth/Rbac.php cloud/tests/RbacTest.php
|
||||
git commit -m "feat(cloud): RBAC permission matrix"
|
||||
git add server/src/Auth/Rbac.php server/tests/RbacTest.php
|
||||
git commit -m "feat(server): RBAC permission matrix"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1155,7 +1155,7 @@ git commit -m "feat(cloud): RBAC permission matrix"
|
|||
## Task 10: Session + AuthController (로그인/brute-force 잠금)
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Auth/Session.php`, `cloud/src/Auth/AuthController.php`, `cloud/tests/AuthControllerTest.php`
|
||||
- Create: `server/src/Auth/Session.php`, `server/src/Auth/AuthController.php`, `server/tests/AuthControllerTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3)
|
||||
|
|
@ -1163,7 +1163,7 @@ git commit -m "feat(cloud): 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: 실패 테스트** — `cloud/tests/AuthControllerTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/AuthControllerTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1210,7 +1210,7 @@ final class AuthControllerTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Auth/Session.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Auth/Session.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1240,7 +1240,7 @@ final class PhpSessionStore implements SessionStore {
|
|||
}
|
||||
```
|
||||
|
||||
`cloud/src/Auth/AuthController.php`:
|
||||
`server/src/Auth/AuthController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1300,8 +1300,8 @@ final class AuthController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1309,13 +1309,13 @@ git commit -m "feat(cloud): session store + auth controller with lockout"
|
|||
## Task 11: Router + 프론트컨트롤러 준비
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Http/Router.php`, `cloud/tests/RouterTest.php`
|
||||
- Create: `server/src/Http/Router.php`, `server/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: 실패 테스트** — `cloud/tests/RouterTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/RouterTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1347,7 +1347,7 @@ final class RouterTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Http/Router.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Http/Router.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1379,8 +1379,8 @@ final class Router
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
git add cloud/src/Http/Router.php cloud/tests/RouterTest.php
|
||||
git commit -m "feat(cloud): path-param router + dispatch"
|
||||
git add server/src/Http/Router.php server/tests/RouterTest.php
|
||||
git commit -m "feat(server): path-param router + dispatch"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1388,7 +1388,7 @@ git commit -m "feat(cloud): path-param router + dispatch"
|
|||
## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Dashboard/EventListController.php`, `cloud/src/Dashboard/EventDetailController.php`, `cloud/src/Dashboard/SnapshotProxy.php`, `cloud/tests/DashboardTest.php`
|
||||
- Create: `server/src/Dashboard/EventListController.php`, `server/src/Dashboard/EventDetailController.php`, `server/src/Dashboard/SnapshotProxy.php`, `server/tests/DashboardTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3)
|
||||
|
|
@ -1397,7 +1397,7 @@ git commit -m "feat(cloud): 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: 실패 테스트** — `cloud/tests/DashboardTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/DashboardTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1451,7 +1451,7 @@ final class DashboardTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Dashboard/EventListController.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Dashboard/EventListController.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1476,7 +1476,7 @@ final class EventListController
|
|||
}
|
||||
```
|
||||
|
||||
`cloud/src/Dashboard/EventDetailController.php`:
|
||||
`server/src/Dashboard/EventDetailController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1516,7 +1516,7 @@ final class EventDetailController
|
|||
}
|
||||
```
|
||||
|
||||
`cloud/src/Dashboard/SnapshotProxy.php`:
|
||||
`server/src/Dashboard/SnapshotProxy.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1545,8 +1545,8 @@ final class SnapshotProxy
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1554,7 +1554,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
|
|||
## Task 13: Polygon 검증 + ZoneConfigController
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Geometry/Polygon.php`, `cloud/src/Dashboard/ZoneConfigController.php`, `cloud/tests/ZoneConfigTest.php`
|
||||
- Create: `server/src/Geometry/Polygon.php`, `server/src/Dashboard/ZoneConfigController.php`, `server/tests/ZoneConfigTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3)
|
||||
|
|
@ -1562,7 +1562,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
|
|||
- `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: 실패 테스트** — `cloud/tests/ZoneConfigTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/ZoneConfigTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1610,7 +1610,7 @@ final class ZoneConfigTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Geometry/Polygon.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Geometry/Polygon.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1631,7 +1631,7 @@ final class Polygon
|
|||
}
|
||||
```
|
||||
|
||||
`cloud/src/Dashboard/ZoneConfigController.php`:
|
||||
`server/src/Dashboard/ZoneConfigController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1698,8 +1698,8 @@ final class ZoneConfigController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
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)"
|
||||
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)"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1707,7 +1707,7 @@ git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
|
|||
## Task 14: ConfigController + HeartbeatController (Pi 동기 API)
|
||||
|
||||
**Files:**
|
||||
- Create: `cloud/src/Api/ConfigController.php`, `cloud/src/Api/HeartbeatController.php`, `cloud/tests/ApiSyncTest.php`
|
||||
- Create: `server/src/Api/ConfigController.php`, `server/src/Api/HeartbeatController.php`, `server/tests/ApiSyncTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1)
|
||||
|
|
@ -1715,7 +1715,7 @@ git commit -m "feat(cloud): 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: 실패 테스트** — `cloud/tests/ApiSyncTest.php`
|
||||
- [ ] **Step 1: 실패 테스트** — `server/tests/ApiSyncTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1768,7 +1768,7 @@ final class ApiSyncTest extends TestCase
|
|||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현** — `cloud/src/Api/ConfigController.php`
|
||||
- [ ] **Step 3: 구현** — `server/src/Api/ConfigController.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1802,7 +1802,7 @@ final class ConfigController
|
|||
}
|
||||
```
|
||||
|
||||
`cloud/src/Api/HeartbeatController.php`:
|
||||
`server/src/Api/HeartbeatController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1840,8 +1840,8 @@ final class HeartbeatController
|
|||
- [ ] **Step 4: 통과 + 커밋**
|
||||
|
||||
```bash
|
||||
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)"
|
||||
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)"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -1849,11 +1849,11 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
|
|||
## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합)
|
||||
|
||||
**Files:**
|
||||
- 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`
|
||||
- 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`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만).
|
||||
- Produces: `cloud/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
|
||||
- Produces: `server/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
|
||||
|
||||
라우트 표:
|
||||
| method | path | handler |
|
||||
|
|
@ -1872,7 +1872,7 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
|
|||
| GET | /snap/{uuid} | SnapshotProxy::serve |
|
||||
| GET | /, /zones, /login | View render |
|
||||
|
||||
- [ ] **Step 1: 라우터 스모크 테스트** — `cloud/tests/WiringSmokeTest.php`
|
||||
- [ ] **Step 1: 라우터 스모크 테스트** — `server/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: 프론트컨트롤러** — `cloud/public/index.php`
|
||||
- [ ] **Step 3: 프론트컨트롤러** — `server/public/index.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
|
@ -1970,7 +1970,7 @@ $r->add('GET', '/zones', fn() => $render('zones'));
|
|||
$r->dispatch(Request::fromGlobals())->send();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: .htaccess** — `cloud/public/.htaccess`
|
||||
- [ ] **Step 4: .htaccess** — `server/public/.htaccess`
|
||||
|
||||
```apache
|
||||
RewriteEngine On
|
||||
|
|
@ -1994,8 +1994,8 @@ RewriteRule ^ index.php [QSA,L]
|
|||
Run: `./vendor/bin/phpunit` (전체) — green.
|
||||
|
||||
```bash
|
||||
git add cloud/public cloud/src/View cloud/tests/WiringSmokeTest.php
|
||||
git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
|
||||
git add server/public server/src/View server/tests/WiringSmokeTest.php
|
||||
git commit -m "feat(server): front controller wiring + views + zone-draw UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -2005,13 +2005,13 @@ git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
|
|||
> ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차.
|
||||
|
||||
**Files:**
|
||||
- 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`
|
||||
- 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`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: repository 인터페이스(T3), `Config`(T1)
|
||||
- Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement).
|
||||
|
||||
- [ ] **Step 1: Database factory** — `cloud/src/Db/Database.php`
|
||||
- [ ] **Step 1: Database factory** — `server/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로 구현. 예 `cloud/src/Repository/Pdo/PdoNonceRepository.php`:
|
||||
- [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `server/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 통합 테스트** — `cloud/tests/PdoIntegrationTest.php`:
|
||||
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `server/tests/PdoIntegrationTest.php`:
|
||||
|
||||
```php
|
||||
protected function setUp(): void {
|
||||
|
|
@ -2082,15 +2082,15 @@ protected function setUp(): void {
|
|||
|
||||
로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지.
|
||||
|
||||
- [ ] **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 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 6: 전체 스위트 + 커밋**
|
||||
|
||||
Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip).
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -2118,7 +2118,7 @@ git commit -m "feat(cloud): PDO repositories + schema + Cafe24 deploy doc"
|
|||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md`.**
|
||||
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-server-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) CloudClient가 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) ServerClient가 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/
|
||||
cloudclient.py # CloudClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
|
||||
serverclient.py # ServerClient: 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/
|
||||
cloud/
|
||||
server/
|
||||
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) + cloud/tests/
|
||||
tests/ (edge) + server/tests/
|
||||
docs/pilot-runbook.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: CloudClient — config 폴링 + 하트비트 (HMAC 서명)
|
||||
## Task 1: ServerClient — config 폴링 + 하트비트 (HMAC 서명)
|
||||
|
||||
**Files:** Create `edge/cloudclient.py`, `tests/test_cloudclient.py`
|
||||
**Files:** Create `edge/serverclient.py`, `tests/test_serverclient.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
|
||||
- Produces:
|
||||
- `CloudClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
|
||||
- `ServerClient(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_cloudclient.py`
|
||||
- [ ] **Step 1: 실패 테스트** — `tests/test_serverclient.py`
|
||||
|
||||
```python
|
||||
import json
|
||||
from edge.cloudclient import CloudClient
|
||||
from edge.serverclient import ServerClient
|
||||
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 = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
|
||||
c = ServerClient("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 = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
|
||||
c = ServerClient("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 = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
|
||||
c = ServerClient("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_cloudclient.py -v` → FAIL (ModuleNotFoundError)
|
||||
- [ ] **Step 2: 실패 확인** — `pytest tests/test_serverclient.py -v` → FAIL (ModuleNotFoundError)
|
||||
|
||||
- [ ] **Step 3: 구현** — `edge/cloudclient.py`
|
||||
- [ ] **Step 3: 구현** — `edge/serverclient.py`
|
||||
|
||||
```python
|
||||
import json
|
||||
|
|
@ -99,7 +99,7 @@ import json
|
|||
from edge.alert import sign
|
||||
|
||||
|
||||
class CloudClient:
|
||||
class ServerClient:
|
||||
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 CloudClient:
|
|||
return 200 <= resp.status_code < 300
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_cloudclient.py -v` → PASS
|
||||
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_serverclient.py -v` → PASS
|
||||
|
||||
```bash
|
||||
git add edge/cloudclient.py tests/test_cloudclient.py
|
||||
git commit -m "feat(edge): CloudClient for config poll + heartbeat (HMAC-signed)"
|
||||
git add edge/serverclient.py tests/test_serverclient.py
|
||||
git commit -m "feat(edge): ServerClient 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 cloud config zones/thresholds into Zone objects"
|
||||
git commit -m "feat(edge): parse server 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), `CloudClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
|
||||
- Consumes: `PipelineRuntime` (T3), `ServerClient` (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.cloudclient import CloudClient
|
||||
from edge.serverclient import ServerClient
|
||||
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 = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
|
||||
client = ServerClient(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 `cloud/src/View/event_detail.php`; Create `cloud/public/assets/detail.js`; Modify `cloud/public/index.php`; Modify `cloud/tests/WiringSmokeTest.php`
|
||||
**Files:** Modify `server/src/View/event_detail.php`; Create `server/public/assets/detail.js`; Modify `server/public/index.php`; Modify `server/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: 스모크 테스트 갱신** — `cloud/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd cloud && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
|
||||
- [ ] **Step 1: 스모크 테스트 갱신** — `server/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd server && ./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** — `cloud/public/assets/detail.js`
|
||||
- [ ] **Step 3: detail.js** — `server/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 cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l cloud/public/index.php`.
|
||||
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l server/public/index.php`.
|
||||
|
||||
```bash
|
||||
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"
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
|
||||
|
||||
**Files:** Modify `cloud/public/assets/app.js`
|
||||
**Files:** Modify `server/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 cloud/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
|
||||
- [ ] **Step 2: 통과 + 커밋** — `node --check server/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
|
||||
|
||||
```bash
|
||||
git add cloud/public/assets/app.js
|
||||
git commit -m "feat(cloud): dashboard rows link to detail + desk Web Audio on critical"
|
||||
git add server/public/assets/app.js
|
||||
git commit -m "feat(server): 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 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`.
|
||||
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`.
|
||||
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:** `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) 일관.
|
||||
**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) 일관.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,11 @@ class HailoPoseAdapter:
|
|||
for info in out_infos}
|
||||
bindings = configured.create_bindings(output_buffers=buffers)
|
||||
bindings.input().set_buffer(np.ascontiguousarray(frame))
|
||||
configured.run([bindings], timeout_ms=10000)
|
||||
# Positional, NOT timeout_ms=. Upstream is `def run(self, bindings, timeout)` in
|
||||
# every HailoRT that supports Hailo-8 (v4.19.0 / v4.20.0 / v4.23.0 / master), so
|
||||
# the keyword form is a TypeError on EVERY frame — and run_loop swallows it
|
||||
# (edge/daemon.py:23-24), so it presents as a green unit emitting zero events.
|
||||
configured.run([bindings], 10000)
|
||||
return [buffers[info.name][None, ...] if buffers[info.name].ndim == 3
|
||||
else buffers[info.name] for info in out_infos]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import logging
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
|
@ -17,6 +19,13 @@ 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,10 +4,43 @@ import cv2
|
|||
|
||||
|
||||
class UvcSource:
|
||||
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 __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 read(self):
|
||||
return self.cap.read()
|
||||
ok, frame = self.cap.read()
|
||||
if ok:
|
||||
self._fail_streak = 0
|
||||
return ok, frame
|
||||
# A USB cam that browns out / is unplugged returns (False, None) indefinitely. After a
|
||||
# streak, release and reopen so a recovered device resumes on its own rather than the
|
||||
# camera staying dead until someone restarts the service.
|
||||
self._fail_streak += 1
|
||||
if self._fail_streak >= self._reopen_after:
|
||||
self._fail_streak = 0
|
||||
try:
|
||||
self.cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.cap = self._open_cap()
|
||||
except Exception:
|
||||
pass # still dead — the daemon's error/heartbeat path surfaces it
|
||||
return False, None
|
||||
|
|
|
|||
|
|
@ -2,9 +2,28 @@ 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):
|
||||
|
|
@ -27,7 +46,7 @@ class PostClient:
|
|||
http_post = requests.post
|
||||
self._post = http_post
|
||||
|
||||
def send(self, event: Event, snapshots: list[bytes]) -> bool:
|
||||
def send(self, event: Event, snapshots: list[bytes]) -> SendResult:
|
||||
payload = event.to_payload(self.device_id)
|
||||
payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots]
|
||||
body = json.dumps(payload, sort_keys=True)
|
||||
|
|
@ -35,12 +54,33 @@ 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:
|
||||
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout)
|
||||
# allow_redirects=False: the .htaccess http->https 302 must not silently become a
|
||||
# GET (405/404). A redirect is a CAFE24_URL misconfiguration to surface, not follow.
|
||||
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
|
||||
allow_redirects=False)
|
||||
except Exception:
|
||||
return False
|
||||
return 200 <= resp.status_code < 300
|
||||
# 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
|
||||
|
|
|
|||
28
edge/app.py
28
edge/app.py
|
|
@ -11,18 +11,32 @@ from edge.models import BBox
|
|||
|
||||
|
||||
def _head_boxes(persons: list) -> list:
|
||||
# 배경인물 머리영역 근사: 코 주변 작은 박스 (MVP; 실제는 얼굴 검출 포트로 교체)
|
||||
# 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.
|
||||
boxes = []
|
||||
for p in persons:
|
||||
bb = p.bbox
|
||||
nose = p.keypoints[0]
|
||||
boxes.append(BBox(nose.x - 20, nose.y - 25, nose.x + 20, nose.y + 15))
|
||||
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))
|
||||
return boxes
|
||||
|
||||
|
||||
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn):
|
||||
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
|
||||
ok, frame = source.read()
|
||||
if not ok:
|
||||
return []
|
||||
# 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
|
||||
now = now_fn()
|
||||
persons = pose.infer(frame)
|
||||
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
|
||||
|
|
@ -31,7 +45,11 @@ 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)
|
||||
events = engine.process(tracks, lost, now)
|
||||
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)
|
||||
for tr in lost:
|
||||
pool.forget(tr.track_id)
|
||||
if events:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from edge.detectors.fall import FallConfig
|
||||
|
|
@ -24,13 +25,89 @@ 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))
|
||||
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
|
||||
motion_thr=motion_thr)
|
||||
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
|
||||
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,42 +1,129 @@
|
|||
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):
|
||||
frame_interval=0.0, zones_for=None, log=None,
|
||||
heartbeat_interval_s=None, health_fn=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, never fatal.
|
||||
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
|
||||
# never fatal — but it is no longer SILENT (a permanently broken pipeline used to look
|
||||
# perfectly green).
|
||||
runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime]
|
||||
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:
|
||||
rt.step(now)
|
||||
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:
|
||||
counters["frames"] += 1
|
||||
except Exception:
|
||||
counters["errors"] += 1
|
||||
streak.pop(id(rt), None) # recovered — reset the streak
|
||||
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
|
||||
if heartbeat_every and i % heartbeat_every == 0:
|
||||
if client.send_heartbeat({"fps": 0.0, "frames": counters["frames"]}):
|
||||
# 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):
|
||||
counters["beats"] += 1
|
||||
beat_anchor = (now, counters["frames"])
|
||||
last_beat = now
|
||||
if flush_every and i % flush_every == 0:
|
||||
queue.flush()
|
||||
counters["flushes"] += 1
|
||||
except Exception:
|
||||
counters["errors"] += 1
|
||||
streak.pop("periodic", None)
|
||||
except Exception as e:
|
||||
note("periodic", "periodic op (sync/heartbeat/flush) failed", e)
|
||||
if frame_interval:
|
||||
sleep(frame_interval)
|
||||
return counters
|
||||
|
|
@ -87,8 +174,9 @@ 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 load_config
|
||||
from edge.cloudclient import CloudClient
|
||||
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
|
||||
require_upload_config)
|
||||
from edge.serverclient import ServerClient
|
||||
from edge.alert import PostClient
|
||||
from edge.queue import OfflineQueue
|
||||
from edge.runtime import PipelineRuntime
|
||||
|
|
@ -112,24 +200,32 @@ 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()
|
||||
now = time.time
|
||||
# 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
|
||||
base = cfg.cafe24_url.rstrip("/")
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
return PipelineRuntime(cam_cfg, source, pose, queue, wall_clock=wall)
|
||||
|
||||
# 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=now, sleep=time.sleep, iterations=None,
|
||||
frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")),
|
||||
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)
|
||||
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.02 # 정규화 관절변위 임계
|
||||
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
|
||||
min_conf: float = 0.5
|
||||
min_visible: int = 10
|
||||
recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간
|
||||
|
|
@ -40,18 +40,19 @@ class FallDetector:
|
|||
def forget(self, track_id: int) -> None:
|
||||
self._states.pop(track_id, None)
|
||||
|
||||
def _prone(self, kps, bbox, st: _State) -> tuple[bool, dict]:
|
||||
angle = torso_angle_deg(kps)
|
||||
def _prone(self, kps, bbox, st: _State, kp_ok: bool) -> tuple[bool, dict]:
|
||||
ar = bbox.aspect
|
||||
remaining = 1.0
|
||||
if st.baseline_height:
|
||||
remaining = bbox.height / st.baseline_height
|
||||
prone = (
|
||||
angle > self.cfg.fallen_angle
|
||||
or ar > self.cfg.aspect_ratio
|
||||
or remaining < self.cfg.height_collapse # 높이 급붕괴(정면/후면 낙상)
|
||||
or head_below_hips(kps)
|
||||
)
|
||||
# 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)
|
||||
return prone, {"torso_angle": round(angle, 1),
|
||||
"aspect_ratio": round(ar, 2),
|
||||
"height_remaining": round(remaining, 2)}
|
||||
|
|
@ -61,20 +62,34 @@ class FallDetector:
|
|||
f = track.latest
|
||||
kps, bbox = f.keypoints, f.bbox
|
||||
|
||||
# 신뢰도 게이트
|
||||
if not core_visible(kps, self.cfg.min_conf) or visible_count(kps) < self.cfg.min_visible:
|
||||
return None, {}
|
||||
# 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
|
||||
|
||||
prone, meta = self._prone(kps, bbox, st)
|
||||
prone, meta = self._prone(kps, bbox, st, kp_ok)
|
||||
|
||||
# incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze
|
||||
if st.name == "STANDING" and not prone:
|
||||
# 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:
|
||||
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,13 +26,20 @@ class GhostIncidentDetector:
|
|||
state = self._states.get(tr.track_id, "STANDING")
|
||||
if state in _DANGEROUS:
|
||||
b = tr.latest.bbox
|
||||
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now)
|
||||
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT
|
||||
# exist then can be the victim re-appearing (a re-acquired track gets a new
|
||||
# id); a track already present is a bystander/colleague and must not cancel
|
||||
# the incident by walking over the loss point.
|
||||
bystanders = set(self._states) - {tr.track_id}
|
||||
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, bystanders)
|
||||
self._states.pop(tr.track_id, None)
|
||||
|
||||
def update(self, active_tracks: list[Track], now: float) -> list[dict]:
|
||||
# 인근 재출현이면 후보 취소
|
||||
for tid, (spatial, _state, height, _lost_at) in list(self._candidates.items()):
|
||||
# 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현).
|
||||
for tid, (spatial, _state, height, _lost_at, bystanders) in list(self._candidates.items()):
|
||||
for tr in active_tracks:
|
||||
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):
|
||||
|
|
@ -40,7 +47,7 @@ class GhostIncidentDetector:
|
|||
break
|
||||
# 대기 초과 방출
|
||||
out = []
|
||||
for tid, (spatial, state, _height, lost_at) in list(self._candidates.items()):
|
||||
for tid, (spatial, state, _height, lost_at, _by) in list(self._candidates.items()):
|
||||
if now - lost_at >= self.cfg.reappear_s:
|
||||
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.02
|
||||
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
|
||||
immobile_s: float = 120.0
|
||||
zone_immobile_s: float = 30.0
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ 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
|
||||
|
|
@ -40,16 +43,36 @@ 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) -> 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 _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 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) -> list[dict]:
|
||||
fx, fy = self._foot(track)
|
||||
def update(self, track: Track, now: float, frame_wh=None) -> list[dict]:
|
||||
fx, fy = self._foot(track, frame_wh)
|
||||
out = []
|
||||
for zone in self.zones:
|
||||
key = (track.track_id, zone.zone_id)
|
||||
|
|
|
|||
|
|
@ -40,44 +40,56 @@ class EventEngine:
|
|||
self._last_emit = {k: v for k, v in self._last_emit.items()
|
||||
if not (len(k) >= 2 and k[1] == track_id)}
|
||||
|
||||
def _mk(self, etype: EventType, now: float, track_id=None, zone_id=None,
|
||||
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
|
||||
metrics=None, spatial=None) -> Event:
|
||||
m = metrics or {}
|
||||
duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0
|
||||
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
|
||||
zone_id, now, duration_s, m, spatial)
|
||||
zone_id, occurred_at, duration_s, m, spatial)
|
||||
|
||||
def process(self, active_tracks: list[Track], lost_tracks: list[Track],
|
||||
now: float) -> list[Event]:
|
||||
now: float, frame_wh=None, wall_now: float | None = None) -> list[Event]:
|
||||
# `now` is a MONOTONIC clock (never jumps): it drives every detector timer, hysteresis,
|
||||
# and cooldown, so an NTP step cannot instantly satisfy immobile_s / expire all tracks /
|
||||
# reset a genuine immobility countdown. `wall_now` is real wall-clock time, used ONLY to
|
||||
# stamp Event.occurred_at (the server correlates and retains by wall time). Defaults to
|
||||
# `now` so single-clock callers (unit tests) keep their existing occurred_at behavior.
|
||||
occurred_at = wall_now if wall_now is not None else now
|
||||
events: list[Event] = []
|
||||
self.ghost.on_lost(lost_tracks, now)
|
||||
|
||||
for tr in active_tracks:
|
||||
zone_hits = self.zone.update(tr, now)
|
||||
# frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone
|
||||
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
|
||||
zone_hits = self.zone.update(tr, now, frame_wh)
|
||||
in_zone = bool(self.zone.active_zone_ids(tr.track_id))
|
||||
|
||||
sig, meta = self.fall.update(tr, now)
|
||||
state = self.fall._states.get(tr.track_id)
|
||||
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
|
||||
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
|
||||
events.append(self._mk(EventType.FALL, now, tr.track_id, metrics=meta))
|
||||
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, metrics=meta))
|
||||
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
|
||||
events.append(self._mk(EventType.NO_MOTION, now, tr.track_id, metrics=meta))
|
||||
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta))
|
||||
|
||||
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
|
||||
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
|
||||
events.append(self._mk(EventType.IMMOBILITY, now, tr.track_id, metrics=imeta))
|
||||
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, metrics=imeta))
|
||||
|
||||
for hit in zone_hits:
|
||||
etype = (EventType.ZONE_RESTRICTED if hit["level"] == "restricted"
|
||||
else EventType.ZONE_WARNING)
|
||||
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
|
||||
# PINCH/HIGH — the old `restricted else WARNING` silently downgraded it. The
|
||||
# dashboard offers exactly these three levels (see zones.php).
|
||||
etype = {"pinch": EventType.PINCH,
|
||||
"restricted": EventType.ZONE_RESTRICTED}.get(
|
||||
hit["level"], EventType.ZONE_WARNING)
|
||||
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
|
||||
events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"]))
|
||||
events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
|
||||
|
||||
for g in self.ghost.update(active_tracks, now):
|
||||
key = (EventType.GHOST, g["track_id"])
|
||||
if self._allow(key, now):
|
||||
events.append(self._mk(EventType.GHOST, now, track_id=g["track_id"],
|
||||
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
|
||||
spatial=g["spatial"],
|
||||
metrics={"last_state": g["last_state"],
|
||||
"waited_s": g["waited_s"]}))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from dataclasses import dataclass
|
||||
import uuid as _uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
|
|
@ -49,10 +50,15 @@ class Event:
|
|||
duration_s: float
|
||||
metrics: dict
|
||||
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
|
||||
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
|
||||
# offline queue and every retry, so a POST that succeeded server-side but timed out
|
||||
# client-side is deduplicated by the server (see server IngestController) instead of stored twice.
|
||||
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
|
||||
|
||||
def to_payload(self, device_id: str) -> dict:
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"event_uuid": self.event_uuid,
|
||||
"camera_id": self.camera_id,
|
||||
"event_type": self.event_type.value,
|
||||
"severity": self.severity.value,
|
||||
|
|
|
|||
|
|
@ -34,9 +34,15 @@ def torso_angle_deg(keypoints: list[Keypoint]) -> float:
|
|||
return math.degrees(math.atan2(abs(vx), abs(vy))) if (vx or vy) else 0.0
|
||||
|
||||
|
||||
def head_below_hips(keypoints: list[Keypoint]) -> bool:
|
||||
def head_below_hips(keypoints: list[Keypoint], nose_thr: float = 0.2) -> bool:
|
||||
# Only trust the nose if the model is confident about it. An unconfident nose (commonly
|
||||
# hallucinated at (0,0) / a frame corner) would otherwise assert — or silently suppress —
|
||||
# this fall cue on data we don't trust. Same 'visible' gate as visible_count().
|
||||
nose = keypoints[NOSE]
|
||||
if nose.conf < nose_thr:
|
||||
return False
|
||||
_, hy = _hip_center(keypoints)
|
||||
return keypoints[NOSE].y > hy
|
||||
return nose.y > hy
|
||||
|
||||
|
||||
def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int:
|
||||
|
|
@ -55,12 +61,37 @@ def normalize(value: float, bbox_height: float) -> float:
|
|||
return value / bbox_height if bbox_height else 0.0
|
||||
|
||||
|
||||
# Keypoints below this confidence are treated as noise (commonly hallucinated at a frame
|
||||
# corner). Averaging them into the motion estimate makes a still track read as moving, which
|
||||
# suppresses no_motion/immobility exactly when they matter (a motionless fallen worker).
|
||||
MOTION_MIN_CONF = 0.2
|
||||
MOTION_MIN_KP = 3
|
||||
|
||||
|
||||
def track_displacement(track) -> float:
|
||||
"""Mean per-keypoint displacement between the last two frames, normalized by bbox height. 1.0 if <2 frames."""
|
||||
"""Mean per-keypoint SPEED between the last two frames: displacement normalized by bbox
|
||||
height AND by elapsed time (per second). Without the time term the value scales with the
|
||||
frame rate, so the same physical stillness reads as 'moving' at low fps and 'still' at
|
||||
high fps — flipping immobility/no_motion with the daemon's actual (load-dependent) fps.
|
||||
Returns 1.0 (moving/unknown) with <2 frames or a non-positive dt.
|
||||
|
||||
Only keypoints the model is confident about in BOTH frames are averaged; below
|
||||
MOTION_MIN_KP trustworthy points it falls back to the bbox centre (a stable proxy that
|
||||
doesn't ride on one noisy joint).
|
||||
|
||||
NOTE: motion_thr in FallConfig/ImmobilityConfig is a per-SECOND threshold (units/second),
|
||||
tunable via MOTION_THR in the environment; recalibrate on device against real jitter."""
|
||||
if len(track.frames) < 2:
|
||||
return 1.0
|
||||
a, b = track.frames[-2], track.frames[-1]
|
||||
dt = b.t - a.t
|
||||
if dt <= 0:
|
||||
return 1.0
|
||||
h = b.bbox.height or 1.0
|
||||
d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5
|
||||
for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints)
|
||||
return normalize(d, h)
|
||||
pairs = [(p, q) for p, q in zip(a.keypoints, b.keypoints)
|
||||
if p.conf >= MOTION_MIN_CONF and q.conf >= MOTION_MIN_CONF]
|
||||
if len(pairs) >= MOTION_MIN_KP:
|
||||
d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 for p, q in pairs) / len(pairs)
|
||||
else:
|
||||
d = ((a.bbox.cx - b.bbox.cx) ** 2 + (a.bbox.cy - b.bbox.cy) ** 2) ** 0.5
|
||||
return normalize(d, h) / dt
|
||||
|
|
|
|||
47
edge/health.py
Normal file
47
edge/health.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Pi thermal telemetry for the heartbeat.
|
||||
|
||||
Reports CPU temperature (C) and whether the SoC is currently throttling — the #1 cause of
|
||||
Hailo inference falling off a cliff in a hot summer factory. Off-Pi (no `vcgencmd`) or on any
|
||||
failure every field is None so the caller OMITS it: the dashboard must never show a fabricated
|
||||
temp=0 / throttle=false that hides an overheating device.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def parse_temp(out: str):
|
||||
"""`vcgencmd measure_temp` -> "temp=54.3'C" -> 54.3. None if unparseable."""
|
||||
try:
|
||||
return float(out.split("=", 1)[1].split("'", 1)[0])
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_throttle(out: str):
|
||||
"""`vcgencmd get_throttled` -> "throttled=0x50005". True when any CURRENTLY-active bit is
|
||||
set: bit0 under-voltage now, bit1 ARM freq capped now, bit2 currently throttled, bit3 soft
|
||||
temp limit now. The 0x1_0000+ bits are "happened since boot" latches, deliberately ignored
|
||||
so a past blip does not show as a permanent warning. None if unparseable."""
|
||||
try:
|
||||
val = int(out.strip().split("=", 1)[1], 16)
|
||||
return bool(val & 0xF)
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def read_health(run=None, which=None) -> dict:
|
||||
"""{'temp': float|None, 'throttle': bool|None}. `run`/`which` are injectable for tests;
|
||||
default to subprocess/shutil. Returns Nones immediately when vcgencmd is absent (off-Pi)."""
|
||||
which = which or shutil.which
|
||||
run = run or _run
|
||||
if which("vcgencmd") is None:
|
||||
return {"temp": None, "throttle": None}
|
||||
return {"temp": parse_temp(run(["vcgencmd", "measure_temp"])),
|
||||
"throttle": parse_throttle(run(["vcgencmd", "get_throttled"]))}
|
||||
|
||||
|
||||
def _run(cmd: list) -> str: # pragma: no cover (thin subprocess wrapper, exercised on device)
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout
|
||||
except Exception:
|
||||
return ""
|
||||
133
edge/probe.py
Normal file
133
edge/probe.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Camera probe: open every configured camera, grab a frame, report what came back.
|
||||
|
||||
Run by deploy/setup_online.sh BEFORE the service is enabled, so a miswired ribbon or a
|
||||
USB camera that enumerated on a different /dev/video index fails during the install --
|
||||
while an operator is still standing at the Pi -- instead of becoming a Restart=always
|
||||
unit that monitors one camera less and looks perfectly healthy on the dashboard.
|
||||
|
||||
Standalone too:
|
||||
/opt/move-detect/venv/bin/python -m edge.probe # uses CAMERAS from env
|
||||
CAMERAS=uvc:2:cam-02 python -m edge.probe --save /tmp/probe # write preview PNGs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from edge.daemon import parse_cameras
|
||||
|
||||
# A camera can open and still hand back nothing for the first few reads while the sensor
|
||||
# settles (UVC auto-exposure, CSI pipeline start). Retry rather than fail a healthy camera.
|
||||
_READ_ATTEMPTS = 8
|
||||
_READ_DELAY_S = 0.25
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeResult:
|
||||
camera_id: str
|
||||
backend: str
|
||||
device: str
|
||||
ok: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def _describe(frame) -> str:
|
||||
shape = getattr(frame, "shape", None)
|
||||
if not shape or len(shape) < 2:
|
||||
return "frame of unknown shape"
|
||||
h, w = shape[0], shape[1]
|
||||
ch = shape[2] if len(shape) > 2 else 1
|
||||
return f"{w}x{h}, {ch} channel(s)"
|
||||
|
||||
|
||||
def _default_build_source(backend: str, device: str):
|
||||
"""Same construction the daemon uses (edge/daemon.py build_source)."""
|
||||
if backend == "csi":
|
||||
from edge.adapters.picamera_source import PiCameraSource
|
||||
return PiCameraSource(int(device))
|
||||
from edge.adapters.uvc_source import UvcSource
|
||||
return UvcSource(int(device))
|
||||
|
||||
|
||||
def probe_one(backend: str, device: str, camera_id: str, *, build_source=None,
|
||||
save_dir: str | None = None) -> ProbeResult:
|
||||
"""Open one camera, read a frame, optionally save a preview PNG."""
|
||||
build_source = build_source or _default_build_source
|
||||
try:
|
||||
source = build_source(backend, device)
|
||||
except Exception as exc: # device missing, wrong index, picamera2 not installed
|
||||
return ProbeResult(camera_id, backend, device, False, f"open failed: {exc}")
|
||||
|
||||
try:
|
||||
for attempt in range(_READ_ATTEMPTS):
|
||||
ok, frame = source.read()
|
||||
if ok and frame is not None:
|
||||
detail = _describe(frame)
|
||||
if save_dir:
|
||||
detail += f"; {_save_preview(frame, save_dir, camera_id)}"
|
||||
return ProbeResult(camera_id, backend, device, True, detail)
|
||||
if attempt < _READ_ATTEMPTS - 1:
|
||||
time.sleep(_READ_DELAY_S)
|
||||
return ProbeResult(camera_id, backend, device, False,
|
||||
f"opened but returned no frame in {_READ_ATTEMPTS} reads")
|
||||
finally:
|
||||
close = getattr(source, "close", None) or getattr(source, "release", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
pass # a probe must never fail the install on a teardown hiccup
|
||||
|
||||
|
||||
def _save_preview(frame, save_dir: str, camera_id: str) -> str:
|
||||
try:
|
||||
import cv2
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
path = os.path.join(save_dir, f"{camera_id}.png")
|
||||
cv2.imwrite(path, frame)
|
||||
return f"preview {path}"
|
||||
except Exception as exc:
|
||||
return f"preview failed: {exc}"
|
||||
|
||||
|
||||
def probe_all(env=None, *, build_source=None, save_dir: str | None = None) -> list[ProbeResult]:
|
||||
env = dict(os.environ if env is None else env)
|
||||
cams = parse_cameras(env, env.get("DEVICE_ID", "cam-01"))
|
||||
return [probe_one(be, dev, cid, build_source=build_source, save_dir=save_dir)
|
||||
for be, dev, cid in cams]
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description="probe every camera in CAMERAS")
|
||||
ap.add_argument("--save", metavar="DIR", help="write a preview PNG per camera")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
try:
|
||||
results = probe_all(save_dir=args.save)
|
||||
except ValueError as exc: # malformed CAMERAS -- a config error, not a camera fault
|
||||
print(f"CAMERAS is invalid: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
for r in results:
|
||||
mark = "OK " if r.ok else "FAIL"
|
||||
print(f" [{mark}] {r.camera_id} {r.backend}:{r.device} -- {r.detail}")
|
||||
|
||||
failed = [r for r in results if not r.ok]
|
||||
if failed:
|
||||
print(f"{len(failed)}/{len(results)} camera(s) failed.", file=sys.stderr)
|
||||
if any(r.backend == "uvc" for r in failed):
|
||||
print(" USB cameras enumerate in boot order -- check `v4l2-ctl --list-devices` "
|
||||
"and fix the index in CAMERAS.", file=sys.stderr)
|
||||
if any(r.backend == "csi" for r in failed):
|
||||
print(" CSI: check the ribbon seating/orientation and `rpicam-hello --list-cameras`.",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
|
|
@ -2,24 +2,39 @@ import base64
|
|||
import json
|
||||
import sqlite3
|
||||
|
||||
from edge.alert import SendResult
|
||||
from edge.events import Event, EventType, Severity
|
||||
|
||||
|
||||
class OfflineQueue:
|
||||
def __init__(self, db_path: str, client):
|
||||
def __init__(self, db_path: str, client, max_rows: int = 2000,
|
||||
max_send_attempts: int = 20):
|
||||
self.client = client
|
||||
self.max_rows = max_rows # SD-card guard: evict oldest beyond this
|
||||
self.max_send_attempts = max_send_attempts # drop a poison event after this many tries
|
||||
self.db = sqlite3.connect(db_path)
|
||||
self.db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS queue ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT)"
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT, "
|
||||
"attempts INTEGER DEFAULT 0)"
|
||||
)
|
||||
# Migrate an older DB that predates the attempts column.
|
||||
try:
|
||||
self.db.execute("ALTER TABLE queue ADD COLUMN attempts INTEGER DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
self.db.commit()
|
||||
|
||||
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
|
||||
# Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the
|
||||
# server dedups it. Rows queued before this field existed simply get a fresh uuid (they
|
||||
# predate dedup, so a first-and-only delivery is correct for them).
|
||||
uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
|
||||
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
|
||||
payload["camera_id"], payload["track_id"], payload["zone_id"],
|
||||
payload["occurred_at"], payload["duration_s"], payload["metrics"],
|
||||
tuple(payload["spatial"]) if payload.get("spatial") else None)
|
||||
tuple(payload["spatial"]) if payload.get("spatial") else None,
|
||||
**uuid_kw)
|
||||
return e, snaps
|
||||
|
||||
def enqueue(self, event: Event, snapshots: list[bytes]) -> None:
|
||||
|
|
@ -30,6 +45,12 @@ class OfflineQueue:
|
|||
snaps = [base64.b64encode(s).decode() for s in snapshots]
|
||||
self.db.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)",
|
||||
(json.dumps(payload), json.dumps(snaps)))
|
||||
# Bound the queue so a multi-day outage — or a permanent misconfig that queues every
|
||||
# event forever — cannot fill the SD card and hard-crash the Pi. Keep the newest
|
||||
# max_rows; a fresh fall alert matters more than a stale one.
|
||||
self.db.execute(
|
||||
"DELETE FROM queue WHERE id NOT IN "
|
||||
"(SELECT id FROM queue ORDER BY id DESC LIMIT ?)", (self.max_rows,))
|
||||
self.db.commit()
|
||||
|
||||
def try_send(self, event: Event, snapshots: list[bytes]) -> bool:
|
||||
|
|
@ -39,18 +60,53 @@ class OfflineQueue:
|
|||
return False
|
||||
|
||||
def flush(self) -> int:
|
||||
rows = self.db.execute("SELECT id, payload, snaps FROM queue ORDER BY id").fetchall()
|
||||
rows = self.db.execute(
|
||||
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
|
||||
sent = 0
|
||||
for rid, payload_json, snaps_json in rows:
|
||||
for rid, payload_json, snaps_json, attempts in rows:
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
|
||||
event, snapshots = self._rehydrate(payload, snaps)
|
||||
if self.client.send(event, snapshots):
|
||||
except Exception:
|
||||
# An undecodable row — a torn SD-card write, or schema drift from an older edge
|
||||
# build whose payload lacks a key _rehydrate indexes — can NEVER be delivered.
|
||||
# flush() breaks on the first undelivered row to preserve order, so leaving this
|
||||
# row in place would wedge every queued safety event BEHIND it forever. Drop the
|
||||
# single unrecoverable row and continue; losing it beats freezing the whole queue.
|
||||
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
|
||||
self.db.commit()
|
||||
continue
|
||||
# A row queued before event_uuid existed has none stored; _rehydrate minted a fresh
|
||||
# one just now. Persist it so every retry of this row reuses the SAME key — otherwise
|
||||
# each flush sends a different uuid and a timeout-retried delivery duplicates on the
|
||||
# server (the P1-9 bug, still open for transitional rows).
|
||||
if not payload.get("event_uuid"):
|
||||
payload["event_uuid"] = event.event_uuid
|
||||
self.db.execute("UPDATE queue SET payload = ? WHERE id = ?",
|
||||
(json.dumps(payload), rid))
|
||||
self.db.commit()
|
||||
|
||||
result = self.client.send(event, snapshots)
|
||||
if result: # SendResult.DELIVERED (only truthy result)
|
||||
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
|
||||
self.db.commit()
|
||||
sent += 1
|
||||
else:
|
||||
break # 여전히 오프라인 — 순서 보존
|
||||
continue
|
||||
# Not delivered. Only a DEFINITE rejection (4xx) counts toward the poison drop cap —
|
||||
# a transient outage (network error / 429 / 5xx) must retry forever so a real fall
|
||||
# alert survives an overnight outage; max_rows already bounds the disk. This is the
|
||||
# fix for the old bool send(), which counted every offline failure toward the cap and
|
||||
# silently deleted the oldest queued safety events after ~20 flushes of downtime.
|
||||
if result is SendResult.REJECTED:
|
||||
attempts += 1
|
||||
if attempts >= self.max_send_attempts:
|
||||
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
|
||||
self.db.commit()
|
||||
continue # poison dropped — let the events behind it drain
|
||||
self.db.execute("UPDATE queue SET attempts = ? WHERE id = ?", (attempts, rid))
|
||||
self.db.commit()
|
||||
break # preserve order; retry the whole queue next flush
|
||||
return sent
|
||||
|
||||
def pending(self) -> int:
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
|||
|
||||
|
||||
class PipelineRuntime:
|
||||
def __init__(self, config, source, pose, sink):
|
||||
def __init__(self, config, source, pose, sink, wall_clock=None):
|
||||
self.config = config
|
||||
self.camera_id = config.camera_id # for per-camera zone routing (multi-cam)
|
||||
self.source = source
|
||||
self.pose = pose
|
||||
self.sink = sink
|
||||
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
|
||||
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
|
||||
self._wall = wall_clock
|
||||
self.tracker = IouTracker()
|
||||
self.pool = TrackSmootherPool()
|
||||
self.zone_detector = ZoneIntrusionDetector([], config.zone)
|
||||
|
|
@ -32,7 +35,7 @@ class PipelineRuntime:
|
|||
def step(self, now: float) -> list:
|
||||
return run_once(self.source, self.pose, self.tracker, self.pool,
|
||||
self.engine, self.ring, self.snapshotter, self.sink,
|
||||
now_fn=lambda: now)
|
||||
now_fn=lambda: now, wall_fn=self._wall)
|
||||
|
||||
def apply_zones(self, zones: list) -> None:
|
||||
self.zone_detector.zones = list(zones)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import json
|
||||
|
||||
from edge.alert import sign
|
||||
from edge.alert import USER_AGENT, sign
|
||||
|
||||
|
||||
class CloudClient:
|
||||
class ServerClient:
|
||||
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,13 +22,15 @@ class CloudClient:
|
|||
def _headers(self, body: str) -> dict:
|
||||
nonce = self.nonce_fn()
|
||||
ts = str(self.now_fn())
|
||||
return {"X-Nonce": nonce, "X-Timestamp": ts,
|
||||
# User-Agent: cafe24's WAF 403s the default python-requests UA (see edge/alert.py).
|
||||
return {"User-Agent": USER_AGENT, "X-Nonce": nonce, "X-Timestamp": ts,
|
||||
"X-Signature": sign(self.secret, nonce, ts, body)}
|
||||
|
||||
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)
|
||||
resp = self._get(url, headers=self._headers(""), timeout=self.timeout,
|
||||
allow_redirects=False)
|
||||
except Exception:
|
||||
return None
|
||||
if 200 <= resp.status_code < 300:
|
||||
|
|
@ -44,7 +46,7 @@ class CloudClient:
|
|||
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)
|
||||
headers=headers, timeout=self.timeout, allow_redirects=False)
|
||||
except Exception:
|
||||
return False
|
||||
return 200 <= resp.status_code < 300
|
||||
261
edge/tools/fetch_hailo_hef.py
Normal file
261
edge/tools/fetch_hailo_hef.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""빌드/온라인 머신 전용: Hailo yolov8s_pose HEF 다운로드(폐쇄망 번들용). MIT (Hailo Model Zoo).
|
||||
|
||||
Mirrors edge/tools/fetch_movenet.py: pure stdlib, no deps, idempotent (re-running with the
|
||||
file already in place verifies and returns without re-downloading).
|
||||
|
||||
AIR GAP (rules/common/build-deployment.md): this runs on the BUILD machine or on an
|
||||
INTERNET-CONNECTED Pi (deploy/setup_online.sh [6/8]) — never on the air-gapped target. The
|
||||
offline bundle carries the resulting .hef across the gap (.gitignore ignores *.hef, so it is
|
||||
a build artifact, never a committed one).
|
||||
|
||||
REFUSES TO GUESS THE BOARD. A Hailo-8 HEF does not load on a Hailo-8L and vice versa:
|
||||
[HailoRT] [error] HEF format is not compatible with device. Device arch: HAILO8L, HEF arch: HAILO8
|
||||
[HailoRT] [error] CHECK_SUCCESS failed with status=HAILO_INVALID_HEF(26)
|
||||
so the arch is read from the board (`hailortcli fw-control identify`) or must be passed
|
||||
explicitly with --arch. With neither, this exits non-zero rather than install a coin-flip.
|
||||
|
||||
WHY v2.14.0: it is what the official hailo-rpi5-examples download_resources.sh pins, and its
|
||||
HEF header is version 1 — parsed by every HailoRT from 4.18 through 4.23, so one file works on
|
||||
both Bookworm (hailo-all 4.20) and Trixie (hailo-all 5.1.1 -> hailort 4.23). Newer model-zoo
|
||||
releases (v2.15.0+) emit HEF header version 2, which HailoRT 4.18/4.19 reject outright.
|
||||
|
||||
CONTRACT (verified 2026-07-16 by decoding the HEF protobuf itself, BOTH arches — matches
|
||||
edge/adapters/hailo_pose.py and tests/test_hailo_pose.py exactly):
|
||||
input_layer1 640x640x3 uint8 RGB; a `normalization1` layer does 0-255 scaling ON CHIP
|
||||
output_layer1..9 3 strides x {box DFL (H,W,64), score (H,W,1, sigmoid on chip),
|
||||
keypoints (H,W,51)} @ 80x80, 40x40, 20x20
|
||||
NO on-chip NMS (zero nms/hailo_nms markers anywhere in either file) -> the host-side DFL
|
||||
decode + NMS in hailo_pose.py is the correct and necessary post-processing.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
MZ_VERSION = "v2.14.0"
|
||||
BASE = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled"
|
||||
MODEL = "yolov8s_pose"
|
||||
|
||||
# Hailo publishes NO checksum for these objects: .sha256 / .md5 / SHA256SUMS sidecars all
|
||||
# return HTTP 403 and bucket listing is denied, so there is no upstream manifest to verify
|
||||
# against. The digests below are therefore NOT vendor-published — they were computed from the
|
||||
# bytes these URLs served on 2026-07-16 and are pinned so that a later download which differs
|
||||
# (mirror swap, truncation, tampering) fails loudly instead of installing wrong weights.
|
||||
# To re-pin deliberately: curl the URL, sha256sum it, update BOTH sha256 and size here.
|
||||
#
|
||||
# The S3 ETag is a usable cross-check for hailo8 ONLY (single-part upload -> ETag == MD5
|
||||
# 9c0542f0009ce952e16f9d32febebe01). hailo8l is a MULTIPART upload (ETag ends "-2"), so its
|
||||
# ETag is a digest-of-digests, not an MD5 — do not "fix" it to compare equal.
|
||||
HEFS = {
|
||||
"hailo8": { # 26 TOPS "AI HAT+"
|
||||
"sha256": "4f06be93349a21fcc28ee0a746ca84f1c0e17aee99bfd1f59596a477d5160f99",
|
||||
"size": 10294713,
|
||||
},
|
||||
"hailo8l": { # 13 TOPS "AI Kit"
|
||||
"sha256": "7293a6b88a2a7bf9ea07819e92057d67e947300f8d0e87f49d50bb060fb959b1",
|
||||
"size": 22658846,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", f"{MODEL}.hef")
|
||||
|
||||
|
||||
def url_for(arch):
|
||||
return f"{BASE}/{MZ_VERSION}/{arch}/{MODEL}.hef"
|
||||
|
||||
|
||||
def sha256_of(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _arch_from_enum_name(name):
|
||||
"""Map a hailo_platform DeviceArchitectureTypes member NAME to our HEF key.
|
||||
-> 'hailo8' | 'hailo8l' | None. Same first-match 8L-before-8 discipline as the hailortcli
|
||||
regex ('HAILO8L' contains 'HAILO8'), so it also folds silicon revisions ('HAILO8_A0' /
|
||||
'HAILO8_B0') onto 'hailo8', and returns None for any non-8 family (Hailo-10/15) we have no
|
||||
HEF for. Never guesses."""
|
||||
n = (name or "").upper()
|
||||
if "HAILO8L" in n:
|
||||
return "hailo8l"
|
||||
if "HAILO8" in n:
|
||||
return "hailo8"
|
||||
return None
|
||||
|
||||
|
||||
def _arch_via_hailortcli(run):
|
||||
"""First signal: parse `hailortcli fw-control identify`. -> 'hailo8'|'hailo8l'|None."""
|
||||
try:
|
||||
p = run(["hailortcli", "fw-control", "identify"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if p.returncode != 0:
|
||||
return None
|
||||
# HAILO8L must precede HAILO8 in the alternation: regex alternation is first-match, so
|
||||
# (HAILO8|HAILO8L) would match the "HAILO8" prefix of "HAILO8L" and mis-detect an AI Kit
|
||||
# as an AI HAT+ — the exact HAILO_INVALID_HEF(26) this function exists to prevent.
|
||||
m = re.search(r"Device Architecture\s*:\s*(HAILO8L|HAILO8)\b", p.stdout or "", re.I)
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
|
||||
def _arch_via_python_api(): # pragma: no cover (device-only: needs hailo_platform + a board)
|
||||
"""Second signal: read the arch through the hailo_platform bindings instead of parsing
|
||||
hailortcli text. Runs the SAME firmware identify underneath, so it does NOT rescue a board
|
||||
whose firmware will not answer — but it (a) works when hailortcli is absent from PATH
|
||||
(hailo-all ships the Python bindings independently) and (b) returns a typed enum, so it is
|
||||
not defeated by the \\b regex on develop-firmware strings like 'HAILO8_B0'. Never raises:
|
||||
any missing symbol/attr or absent board -> None.
|
||||
UNVERIFIED: the API names (Device.scan / control.identify().device_architecture) are from
|
||||
Hailo's pyhailort and are not runnable in this repo. The broad except makes a wrong name
|
||||
safe — it yields None (refuse-to-guess), never a crash or a guess."""
|
||||
try:
|
||||
from hailo_platform import Device
|
||||
ids = Device.scan()
|
||||
if not ids:
|
||||
return None
|
||||
with Device(ids[0]) as dev:
|
||||
arch = dev.control.identify().device_architecture
|
||||
return _arch_from_enum_name(getattr(arch, "name", str(arch)))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def detect_arch(run=subprocess.run, python_api=_arch_via_python_api):
|
||||
"""Read the arch off the board itself. -> 'hailo8' | 'hailo8l' | None.
|
||||
|
||||
Two independent signals, in order: (1) `hailortcli fw-control identify` text, (2) the
|
||||
hailo_platform bindings. Both read the board's ARCHITECTURE, which is reported even when
|
||||
the serial/part/product identity fields are unprogrammed — a Hailo-8 commonly shows
|
||||
'Device Architecture: HAILO8' with 'Serial Number: <N/A>'. So a serial-less board is NOT
|
||||
why this returns None. None still means "no board answered": no hailortcli AND no
|
||||
bindings, no /dev/hailo0 (driver installed, not rebooted), or an arch we have no HEF for.
|
||||
None is never papered over with a default — an operator who knows the board bypasses
|
||||
detection entirely with --arch / HAILO_ARCH."""
|
||||
arch = _arch_via_hailortcli(run)
|
||||
if arch is not None:
|
||||
return arch
|
||||
# hailortcli missing/non-zero, or its Device Architecture line absent/unparseable. The
|
||||
# in-process bindings survive a missing CLI and the \b regex fragility; still None if no
|
||||
# board answers.
|
||||
return python_api()
|
||||
|
||||
|
||||
def fetch(arch, dest, force=False, log=print):
|
||||
"""Download (or verify in place) the yolov8s_pose HEF for `arch`. -> dest path."""
|
||||
spec = HEFS[arch]
|
||||
url = url_for(arch)
|
||||
|
||||
if os.path.exists(dest) and not force:
|
||||
if sha256_of(dest) == spec["sha256"]:
|
||||
log(f"{dest} already present and matches the pinned {arch} digest; nothing to do")
|
||||
return dest
|
||||
# Covers the nastiest case: a previous run left the OTHER arch's HEF (or a truncated
|
||||
# one) here. Silently keeping it is how a board ends up loading a HEF it will reject.
|
||||
# ASCII only in printed strings: a Windows build machine's console is often cp949/cp1252,
|
||||
# where an em-dash raises UnicodeEncodeError. That would crash the tamper-detection path
|
||||
# itself, replacing "digest mismatch" with a traceback about a dash.
|
||||
log(f"WARNING: {dest} exists but does not match the pinned {arch} digest - replacing it")
|
||||
|
||||
dest_dir = os.path.dirname(dest) or "."
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
log(f"downloading {MODEL} HEF for {arch} ({spec['size']} bytes, model-zoo {MZ_VERSION})")
|
||||
log(f" {url}")
|
||||
log(f" -> {dest}")
|
||||
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=f".{MODEL}.", suffix=".part")
|
||||
os.close(fd)
|
||||
# Staged through a temp file in the SAME directory, then os.replace()'d: an
|
||||
# interrupted download must never leave a half-written .hef at `dest`, because the
|
||||
# installer's next step only tests -f and would happily install the truncation.
|
||||
with urllib.request.urlopen(url, timeout=60) as r, open(tmp, "wb") as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
|
||||
size = os.path.getsize(tmp)
|
||||
digest = sha256_of(tmp)
|
||||
if size != spec["size"] or digest != spec["sha256"]:
|
||||
raise SystemExit(
|
||||
f"ERROR: downloaded {MODEL}.hef for {arch} does not match the pinned digest.\n"
|
||||
f" url : {url}\n"
|
||||
f" expected: {spec['size']} bytes, sha256 {spec['sha256']}\n"
|
||||
f" got : {size} bytes, sha256 {digest}\n"
|
||||
f" Refusing to install it. If upstream legitimately re-published this model,\n"
|
||||
f" re-pin HEFS['{arch}'] in {os.path.basename(__file__)} deliberately."
|
||||
)
|
||||
os.replace(tmp, dest)
|
||||
tmp = None
|
||||
finally:
|
||||
if tmp and os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
log(f"done: {dest} (sha256 verified)")
|
||||
return dest
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Download the Hailo yolov8s_pose HEF for THIS board "
|
||||
"(build/online machine only - never the air-gapped target).")
|
||||
ap.add_argument("--arch", choices=sorted(HEFS),
|
||||
help="target arch. Omit to read it from the board via `hailortcli "
|
||||
"fw-control identify`, or set the HAILO_ARCH env var. Pass "
|
||||
"explicitly to cross-fetch on a build machine with no board.")
|
||||
ap.add_argument("--dest", default=DEFAULT_DEST, help=f"output path (default: {DEFAULT_DEST})")
|
||||
ap.add_argument("--force", action="store_true", help="re-download even if the digest matches")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# Operator override for a board auto-detect cannot read (e.g. a Hailo-8 whose firmware
|
||||
# reports no identity fields, or a Pi where hailortcli is not yet on PATH). Precedence:
|
||||
# --arch flag > HAILO_ARCH env > board auto-detect. argparse already validated the flag
|
||||
# against HEFS; validate the env value against the same allow-list — an unknown value is a
|
||||
# typo, not a request to fall back.
|
||||
env_arch = os.environ.get("HAILO_ARCH", "").strip().lower() or None
|
||||
if env_arch is not None and env_arch not in HEFS:
|
||||
sys.exit(
|
||||
f"ERROR: HAILO_ARCH={os.environ['HAILO_ARCH']!r} is not a supported arch.\n"
|
||||
f" Use exactly one of: {', '.join(sorted(HEFS))} (lowercase)."
|
||||
)
|
||||
|
||||
detected = detect_arch()
|
||||
override = args.arch or env_arch
|
||||
arch = override or detected
|
||||
|
||||
if arch is None:
|
||||
sys.exit(
|
||||
"ERROR: cannot determine the Hailo arch, and refusing to guess.\n"
|
||||
" A Hailo-8 HEF does not load on a Hailo-8L (HAILO_INVALID_HEF(26)), so there is\n"
|
||||
" no safe default here.\n"
|
||||
"\n"
|
||||
" On the Pi this usually means the board is not answering yet:\n"
|
||||
" hailortcli fw-control identify # expect 'Device Architecture: HAILO8'\n"
|
||||
" dpkg -s hailo-all >/dev/null 2>&1 || sudo apt-get install -y hailo-all\n"
|
||||
" ls -l /dev/hailo0 # absent -> the driver needs a REBOOT\n"
|
||||
"\n"
|
||||
" If the board answers but prints no 'Device Architecture' line (some Hailo-8\n"
|
||||
" boards report no serial/identity), name the arch yourself:\n"
|
||||
" HAILO_ARCH=hailo8 python -m edge.tools.fetch_hailo_hef # 26 TOPS AI HAT+\n"
|
||||
" HAILO_ARCH=hailo8l python -m edge.tools.fetch_hailo_hef # 13 TOPS AI Kit"
|
||||
)
|
||||
|
||||
if override and detected and override != detected:
|
||||
# Legitimate when cross-fetching for a fleet; disastrous when it is a typo. Say it
|
||||
# loudly, but let the explicit override win — the operator stated an intent.
|
||||
src = "--arch" if args.arch else "HAILO_ARCH"
|
||||
print(f"WARNING: {src} {override} overrides the detected board ({detected}). "
|
||||
f"This HEF will NOT load on this machine's board.", file=sys.stderr)
|
||||
|
||||
fetch(arch, os.path.abspath(args.dest), force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,16 +1,87 @@
|
|||
"""빌드머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0."""
|
||||
"""빌드/온라인 머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0.
|
||||
|
||||
Integrity is enforced (spec: SHA256/GPG verify of every vendored model). The download is staged
|
||||
to a temp file, validated, and only then moved into place — a truncated / HTML-error-page /
|
||||
tampered download must never be saved as the model (onnxruntime would then fail to load it, and
|
||||
the operator would see a cryptic runtime error instead of a clear fetch failure).
|
||||
|
||||
STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on
|
||||
external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is
|
||||
no known-good sha256 to pin here yet. Until a working source is set:
|
||||
* this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets),
|
||||
instead of silently saving garbage;
|
||||
* set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes.
|
||||
For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is
|
||||
non-blocking; the CPU fallback needs this resolved.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
# MoveNet Lightning ONNX (예시 소스; 조직 미러로 교체 가능)
|
||||
URL = "https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/movenet_singlepose_lightning_4.onnx"
|
||||
URL = os.environ.get(
|
||||
"MOVENET_ONNX_URL",
|
||||
"https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/"
|
||||
"movenet_singlepose_lightning_4.onnx",
|
||||
)
|
||||
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
|
||||
|
||||
# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned
|
||||
# (still validated as a real ONNX below, but not byte-identical-verified).
|
||||
PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "")
|
||||
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0")
|
||||
|
||||
|
||||
def _looks_like_onnx(head: bytes) -> bool:
|
||||
# ONNX is a protobuf ModelProto; it is NOT text. Reject an HTML error page / LFS pointer
|
||||
# outright (the current dead URL returns exactly that).
|
||||
stripped = head.lstrip()
|
||||
if stripped[:1] in (b"<", b"{") or stripped[:5].lower() == b"<!doc":
|
||||
return False
|
||||
if b"version https://git-lfs" in head:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(os.path.dirname(DEST), exist_ok=True)
|
||||
print(f"downloading MoveNet ONNX -> {DEST}")
|
||||
urllib.request.urlretrieve(URL, DEST)
|
||||
print("done")
|
||||
dest = os.path.abspath(DEST)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
print(f"downloading MoveNet ONNX\n {URL}\n -> {dest}")
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest), prefix=".movenet.", suffix=".part")
|
||||
os.close(fd)
|
||||
urllib.request.urlretrieve(URL, tmp)
|
||||
|
||||
with open(tmp, "rb") as f:
|
||||
head = f.read(64)
|
||||
if not _looks_like_onnx(head):
|
||||
raise SystemExit(
|
||||
f"ERROR: {URL}\n did not return an ONNX model (looks like HTML / an error page /"
|
||||
f" an LFS pointer).\n Set MOVENET_ONNX_URL to a valid source and pin"
|
||||
f" MOVENET_ONNX_SHA256. (Hailo path does not need this model.)"
|
||||
)
|
||||
|
||||
size = os.path.getsize(tmp)
|
||||
digest = hashlib.sha256(open(tmp, "rb").read()).hexdigest()
|
||||
if PINNED_SHA256 and (digest != PINNED_SHA256 or (PINNED_SIZE and size != PINNED_SIZE)):
|
||||
raise SystemExit(
|
||||
f"ERROR: MoveNet ONNX does not match the pinned digest.\n"
|
||||
f" expected: {PINNED_SIZE} bytes, sha256 {PINNED_SHA256}\n"
|
||||
f" got : {size} bytes, sha256 {digest}\n Refusing to install it."
|
||||
)
|
||||
if not PINNED_SHA256:
|
||||
print(f" WARNING: integrity not pinned (MOVENET_ONNX_SHA256 unset). Got {size} bytes,"
|
||||
f" sha256 {digest}. Pin this once the source is trusted.", file=sys.stderr)
|
||||
|
||||
os.replace(tmp, dest)
|
||||
tmp = None
|
||||
print(f"done: {dest} ({size} bytes)")
|
||||
finally:
|
||||
if tmp and os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,24 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from edge.detectors.zone import Zone
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Zones are stored NORMALIZED (0..1 of the image) by the current dashboard. Polygons drawn by the
|
||||
# pre-normalization dashboard were saved in canvas PIXELS (values in the hundreds); after the edge
|
||||
# normalizes the foot point, a pixel polygon can never match — silently killing that zone's
|
||||
# intrusion detection with no error. Flag such rows loudly so the operator redraws them, instead
|
||||
# of failing dark. A normalized coordinate may round slightly past 1.0, so allow a small margin.
|
||||
_NORMALIZED_MAX = 1.5
|
||||
|
||||
|
||||
def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
|
||||
return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon)
|
||||
|
||||
|
||||
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
||||
"""Parse cloud config zone rows into Zone objects. When camera_id is given, only
|
||||
"""Parse server config zone rows into Zone objects. When camera_id is given, only
|
||||
rows for that camera are kept (multi-camera routing); None keeps all rows."""
|
||||
out: list[Zone] = []
|
||||
rows = config.get("zones", []) or []
|
||||
|
|
@ -19,6 +33,13 @@ def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
|||
polygon = [(float(p[0]), float(p[1])) for p in pts]
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
if _looks_like_pixels(polygon):
|
||||
log.warning(
|
||||
"zone %s on camera %s has un-normalized (pixel-space) coordinates %r — it "
|
||||
"would match nothing after foot normalization. Skipping; redraw this zone in "
|
||||
"the dashboard so it is stored normalized (0..1).",
|
||||
row.get("zone_id"), row.get("camera_id"), polygon[:2])
|
||||
continue
|
||||
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
|
||||
except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError):
|
||||
continue
|
||||
|
|
|
|||
1
cloud/.gitignore → server/.gitignore
vendored
1
cloud/.gitignore → server/.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
|||
/config.php
|
||||
/storage/snapshots/*
|
||||
!/storage/snapshots/.gitkeep
|
||||
/.phpunit.result.cache
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "move-detect/cloud",
|
||||
"name": "move-detect/server",
|
||||
"type": "project",
|
||||
"require": { "php": ">=8.2" },
|
||||
"require-dev": { "phpunit/phpunit": "^11" },
|
||||
0
cloud/composer.lock → server/composer.lock
generated
0
cloud/composer.lock → server/composer.lock
generated
16
server/config.example.php
Normal file
16
server/config.example.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
return [
|
||||
'db_dsn' => 'mysql:host=localhost;dbname=move_detect;charset=utf8mb4',
|
||||
'db_user' => 'CHANGEME',
|
||||
'db_pass' => 'CHANGEME',
|
||||
'snapshot_dir' => __DIR__ . '/storage/snapshots',
|
||||
// Every key App\Config reads is listed, defaults included. They all have `?? default`
|
||||
// fallbacks, so an omitted key is silently absent rather than an error — an operator
|
||||
// who never sees it here cannot know the knob exists.
|
||||
'nonce_ttl' => 86400,
|
||||
'timestamp_window' => 300,
|
||||
'rate_limit_per_min' => 60,
|
||||
'ingest_ip_rate_limit_per_min' => 300,
|
||||
// PIPA retention: worker snapshots are purged after this many days (0 disables).
|
||||
'snapshot_retention_days' => 7,
|
||||
];
|
||||
165
server/deploy/README-UPLOAD.md
Normal file
165
server/deploy/README-UPLOAD.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# 카페24 업로드 안내
|
||||
|
||||
이 폴더의 **내용물**을 카페24 FTP **최상위**에 그대로 올리면 됩니다.
|
||||
고칠 파일은 **`config.php` 하나뿐**입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 올리는 위치 (제일 중요)
|
||||
|
||||
**폴더 2개만** 올리면 됩니다. `move_detect_app` 과 `www`.
|
||||
|
||||
```
|
||||
/ <- FTP 최상위
|
||||
├── (기존 사이트 파일) <- 건드리지 않습니다
|
||||
├── move_detect_app/ <- [올리기 1] 통째로. 웹에서 안 보입니다
|
||||
│ ├── config.php 여기만 고칩니다
|
||||
│ ├── src/
|
||||
│ ├── vendor/
|
||||
│ ├── storage/snapshots/ 사진 저장
|
||||
│ └── schema.sql DB 넣고 나면 지웁니다
|
||||
└── www/ <- 원래 있던 폴더 (다른 사이트가 씀)
|
||||
├── (기존 사이트 파일) <- 건드리지 않습니다
|
||||
└── move_detect/ <- [올리기 2] 이 폴더만 새로 생깁니다
|
||||
├── index.php
|
||||
├── .htaccess
|
||||
├── _preflight.php
|
||||
└── assets/
|
||||
```
|
||||
|
||||
접속 주소는 **`https://내도메인/move_detect/`** 입니다.
|
||||
|
||||
`README-UPLOAD.md`(이 파일)는 올릴 필요 없습니다.
|
||||
|
||||
**왜 `move_detect_app` 을 `www` 밖에 두나:** `config.php` 에 DB 비밀번호가, `storage/snapshots/`
|
||||
에 작업자 사진이 들어갑니다. `www` 안에 있으면 같은 `www` 를 쓰는 다른 사이트 주소로 접근될
|
||||
수 있습니다. 한 폴더로 묶은 건 최상위에 있는 남의 파일(`vendor/`, `config.php` 같은 흔한
|
||||
이름)과 겹쳐 덮어쓰는 사고를 막기 위해서입니다.
|
||||
|
||||
업로드가 끝나면 FTP 클라이언트에서 권한을 지정하세요. zip 과 FTP 는 유닉스 권한을
|
||||
옮기지 못하므로, 패키지에 넣어둔 권한은 올리는 순간 사라집니다.
|
||||
|
||||
| 대상 | 권한 |
|
||||
|---|---|
|
||||
| `move_detect_app/config.php` | **600** (DB 비밀번호가 들어있습니다) |
|
||||
| `move_detect_app/storage/snapshots/` | 750 |
|
||||
|
||||
`_preflight.php` 가 실제 권한과 노출 여부를 확인해줍니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. `config.php` 고치기
|
||||
|
||||
DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
|
||||
|
||||
```php
|
||||
'db_dsn' => 'mysql:host=localhost;dbname=여기에_DB이름;charset=utf8mb4',
|
||||
'db_user' => '여기에_MySQL아이디',
|
||||
'db_pass' => '여기에_MySQL비밀번호',
|
||||
```
|
||||
|
||||
나머지 값은 그대로 두면 됩니다.
|
||||
비밀번호는 메일/메신저/깃에 올리지 마세요.
|
||||
|
||||
---
|
||||
|
||||
## 3. DB 만들기
|
||||
|
||||
### 처음 설치(빈 DB)
|
||||
|
||||
1. 카페24 관리자 → **phpMyAdmin** 열기
|
||||
2. 이 앱용 DB 선택
|
||||
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
|
||||
4. 표 10개가 생겼는지 확인
|
||||
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
|
||||
|
||||
### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
|
||||
|
||||
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
|
||||
> 대신 **마이그레이션**을 실행하세요.
|
||||
|
||||
1. phpMyAdmin → 해당 DB → **SQL** 탭
|
||||
2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행
|
||||
(site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함)
|
||||
3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인
|
||||
4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS 켜기
|
||||
|
||||
카페24 관리자에서 **무료 SSL 인증서**를 켭니다.
|
||||
|
||||
선택이 아니라 필수입니다. 로그인 세션 쿠키가 `cookie_secure=true` 라서,
|
||||
HTTP 로는 브라우저가 쿠키를 아예 안 보냅니다 → 로그인이 안 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 점검하기
|
||||
|
||||
브라우저에서 엽니다:
|
||||
|
||||
```
|
||||
https://내도메인/move_detect/_preflight.php
|
||||
```
|
||||
|
||||
전부 **OK** 여야 합니다. 실패한 줄에 무엇을 하면 되는지 적혀 있습니다.
|
||||
|
||||
특히 이 둘은 카페24 요금제에 따라 없을 수 있고, 없으면 카페24에 문의해야 합니다:
|
||||
|
||||
| 항목 | 없으면 생기는 일 |
|
||||
|---|---|
|
||||
| **Argon2id** | 로그인 페이지가 즉시 죽습니다. 제일 흔한 실패 원인입니다. |
|
||||
| **gd** | 이벤트는 저장되는데 **사진만 조용히 사라집니다.** 에러도 안 납니다. |
|
||||
|
||||
**확인이 끝나면 `www/move_detect/_preflight.php` 를 지우세요.**
|
||||
|
||||
---
|
||||
|
||||
## 6. 관리자 계정 만들기
|
||||
|
||||
앱에 회원가입 기능이 없습니다. 첫 계정은 직접 넣습니다.
|
||||
|
||||
비밀번호 해시를 만듭니다(로컬 PC 에서 실행 — 평문이 phpMyAdmin 기록에 남지 않게):
|
||||
|
||||
```bash
|
||||
php -r "echo password_hash('여기에_진짜_비밀번호', PASSWORD_ARGON2ID), \"\n\";"
|
||||
```
|
||||
|
||||
phpMyAdmin → SQL 탭:
|
||||
|
||||
```sql
|
||||
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until)
|
||||
VALUES ('admin', '여기에_해시_붙여넣기', 'admin', NULL, 0, 0);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 라즈베리파이(장비) 등록
|
||||
|
||||
장비마다 비밀키를 하나씩 만들어 DB 에 넣습니다.
|
||||
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32)), \"\n\";"
|
||||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO move_devices (device_id, name, secret, config_version)
|
||||
VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기', 1);
|
||||
```
|
||||
|
||||
같은 값을 파이 쪽 `/etc/move-detect/edge.env` 의 `DEVICE_SECRET` 에 넣습니다.
|
||||
`CAFE24_URL` 에는 `https://내도메인/move_detect` 를 넣습니다. (앱이 서브폴더에 있으므로
|
||||
뒤의 `/move_detect` 까지 반드시 포함해야 합니다.)
|
||||
|
||||
비밀키는 메일/메신저로 보내지 마세요.
|
||||
|
||||
---
|
||||
|
||||
## 8. 마지막 확인
|
||||
|
||||
- [ ] `https://내도메인/move_detect/login` 이 열린다
|
||||
- [ ] admin 으로 로그인되고 대시보드가 뜬다
|
||||
- [ ] `schema.sql` 을 서버에서 지웠다
|
||||
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
|
||||
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)
|
||||
269
server/deploy/build_cafe24_package.sh
Executable file
269
server/deploy/build_cafe24_package.sh
Executable file
|
|
@ -0,0 +1,269 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the Cafe24 upload package: a folder whose contents are dragged into the hosting
|
||||
# account's FTP root as-is.
|
||||
#
|
||||
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
|
||||
# folder. The hosting account executes nothing — no shell, no composer, no installer: the
|
||||
# operator uploads files over FTP and edits config.php. That is also why this lives under
|
||||
# server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
|
||||
# root on the target.
|
||||
#
|
||||
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
|
||||
# server/deploy/build_cafe24_package.sh
|
||||
#
|
||||
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip
|
||||
#
|
||||
# dist/1_cafe24_upload/
|
||||
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
|
||||
# │ ├── config.php the ONLY file the operator edits
|
||||
# │ ├── src/ vendor/ storage/snapshots/
|
||||
# │ └── schema.sql import via phpMyAdmin, then DELETE from the host
|
||||
# ├── www/
|
||||
# │ └── move_detect/ -> UPLOAD into the existing www/. Reached at
|
||||
# │ ├── index.php https://<domain>/move_detect/
|
||||
# │ └── .htaccess _preflight.php assets/
|
||||
# └── README-UPLOAD.md -> for the operator to read; not uploaded
|
||||
#
|
||||
# Two upload targets, and everything private is ONE folder. That matters on a shared account:
|
||||
# this www/ already serves another site, so the FTP root may hold that site's files too —
|
||||
# spraying src/, vendor/ and config.php across the root risks overwriting them (vendor/ and
|
||||
# config.php are ordinary names in any PHP project).
|
||||
#
|
||||
# The app is mounted in a SUBDIRECTORY of www/ for the same reason: nothing secret may sit
|
||||
# under a docroot the other site also serves. index.php finds move_detect_app/ by walking up
|
||||
# rather than hardcoding '/..', and the URL prefix comes from SCRIPT_NAME, so this same
|
||||
# package also works dropped straight into www/.
|
||||
#
|
||||
# APP_DIR= override the www/ subdirectory; empty mounts the app directly in www/.
|
||||
# PRIVATE_DIR= override the private folder name (must match index.php's APP_PRIVATE_DIR).
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR-move_detect}"
|
||||
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SERVER="$REPO_ROOT/server"
|
||||
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
||||
STAGE="$OUT_DIR/1_cafe24_upload"
|
||||
ZIP="$OUT_DIR/move-detect-cafe24.zip"
|
||||
|
||||
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
|
||||
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
|
||||
|
||||
echo ">> [1/6] clean stage: $STAGE"
|
||||
rm -rf "$STAGE"
|
||||
# WEB is where the app's own files land: www/move_detect by default, www/ when APP_DIR is
|
||||
# empty. Everything else stays at the stage root = the FTP root = one level above www/.
|
||||
WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"
|
||||
# PRIV holds everything that must never be web-reachable, in ONE folder so it cannot
|
||||
# collide with the co-hosted site's files at the FTP root.
|
||||
PRIV="$STAGE/$PRIVATE_DIR"
|
||||
mkdir -p "$WEB" "$PRIV/src" "$PRIV/storage/snapshots"
|
||||
|
||||
echo ">> [2/6] generating the no-dev autoloader"
|
||||
# public/index.php hard-requires ../vendor/autoload.php, so vendor/ MUST ship — the app has
|
||||
# no runtime composer *packages* (composer.lock's "packages" is empty), but it does need
|
||||
# composer's generated autoloader to map App\ -> src/.
|
||||
#
|
||||
# --no-dev is load-bearing, not tidiness: the working tree's vendor/ carries PHPUnit and a
|
||||
# dev autoload_files.php that eagerly requires tests/Support/InMemoryRepositories.php on
|
||||
# every request — a file this package deliberately excludes. Shipping it fatals every hit.
|
||||
#
|
||||
# --classmap-authoritative because src/Auth/Session.php declares three symbols
|
||||
# (SessionStore, ArraySessionStore, PhpSessionStore) under one filename matching none of
|
||||
# them; that is why composer.json carries an explicit classmap entry for it. Plain PSR-4
|
||||
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
|
||||
# `new PhpSessionStore()` — the 4th statement of every request.
|
||||
#
|
||||
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/
|
||||
# in place, so restore the dev autoloader on exit or local phpunit breaks.
|
||||
restore_dev_autoloader() {
|
||||
echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)"
|
||||
( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
|
||||
}
|
||||
trap restore_dev_autoloader EXIT
|
||||
|
||||
( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
|
||||
|
||||
# Precondition for everything below: this package stages ONLY composer's generated
|
||||
# autoloader, which is correct exactly while the app has no runtime packages. The day
|
||||
# someone adds one to composer.json's `require`, that staging silently omits its code and
|
||||
# the host fatals on a missing class. Fail here, on the commit that adds the dependency,
|
||||
# rather than on the hosting account.
|
||||
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
|
||||
"$SERVER/composer.lock" || {
|
||||
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
|
||||
echo " autoloader, so their code would be missing from the package. Teach" >&2
|
||||
echo " step [3/6] to stage the real vendor tree before shipping again." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
|
||||
mkdir -p "$PRIV/vendor/composer"
|
||||
cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php"
|
||||
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in
|
||||
# phpunit/, sebastian/, myclabs/ and friends.
|
||||
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
|
||||
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
|
||||
cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f"
|
||||
done
|
||||
cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
|
||||
|
||||
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
|
||||
# regenerate them — they are the package inventory written by the last `composer install`,
|
||||
# so they still list every dev package (and its exact version, which is a free CVE shopping
|
||||
# list for anyone who can read them). Nothing here needs them: autoload_real.php eagerly
|
||||
# requires only platform_check.php, and Composer\InstalledVersions reads installed.php
|
||||
# lazily, on a call this app never makes.
|
||||
|
||||
# Guard: the dev autoload_files.php requires tests/Support/InMemoryRepositories.php, which
|
||||
# is not in this package. If it slipped through, every request 500s — fail the build loudly
|
||||
# rather than ship it.
|
||||
if [ -f "$PRIV/vendor/composer/autoload_files.php" ]; then
|
||||
echo "ERROR: autoload_files.php present — that is the DEV autoloader (it requires tests/)." >&2
|
||||
echo " composer dump-autoload --no-dev did not take effect." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Guard: no autoload map may point outside src/. Grep the maps that actually drive loading
|
||||
# rather than the whole tree — InstalledVersions.php names PHPUnit in a docblock, which is
|
||||
# harmless prose, and matching it would make this check cry wolf.
|
||||
for map in autoload_classmap.php autoload_psr4.php autoload_static.php autoload_namespaces.php; do
|
||||
if grep -qiE "phpunit|sebastian|myclabs|/tests/" "$PRIV/vendor/composer/$map"; then
|
||||
echo "ERROR: $map references dev deps or tests/ — --no-dev did not take effect." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Guard: no dev package directory may exist in the staged vendor/.
|
||||
if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -q .; then
|
||||
echo "ERROR: unexpected package directories under the staged vendor/:" >&2
|
||||
find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">> [4/6] staging app source"
|
||||
cp -r "$SERVER/src/." "$PRIV/src/"
|
||||
cp -r "$SERVER/public/." "$WEB/"
|
||||
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
|
||||
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
|
||||
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
|
||||
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
|
||||
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
|
||||
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
|
||||
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
|
||||
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
|
||||
|
||||
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
|
||||
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
|
||||
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
|
||||
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \
|
||||
-o -name '.pytest_cache' -o -name '.mypy_cache' -o -name '.DS_Store' \
|
||||
-o -name '.phpunit.result.cache' -o -name 'Thumbs.db' \) -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the
|
||||
# failure is silent rather than loud: IngestController does
|
||||
# `if (file_put_contents(...) === false) continue;`, so a missing dir means the snapshot is
|
||||
# dropped while the API still answers 201 to the edge device. The event row lands in MySQL
|
||||
# with no image, the dashboard shows an event with no photo, and with display_errors=Off
|
||||
# (correct for production) not even a warning survives. That is permanent evidence loss.
|
||||
touch "$PRIV/storage/snapshots/.gitkeep"
|
||||
# Build-local only. Neither zip nor FTP carries unix modes, so these do NOT reach the host —
|
||||
# they are correct for someone who rsyncs the stage directly, and a no-op otherwise. The
|
||||
# host-side modes are the operator's job (README-UPLOAD.md) and _preflight.php verifies the
|
||||
# live value rather than trusting these.
|
||||
chmod 750 "$PRIV/storage/snapshots"
|
||||
chmod 600 "$PRIV/config.php"
|
||||
|
||||
echo ">> [5/6] verifying the package resolves every class index.php touches"
|
||||
# Prove the autoloader works against the STAGED layout, not the repo's.
|
||||
php -r '
|
||||
$stage = $argv[1];
|
||||
require $stage . "/vendor/autoload.php";
|
||||
$classes = ["App\\Config", "App\\Http\\Router", "App\\Http\\Request", "App\\Http\\Response",
|
||||
"App\\Db\\Database", "App\\Auth\\PhpSessionStore", "App\\Auth\\AuthController",
|
||||
"App\\Auth\\CsrfController", "App\\Auth\\Rbac", "App\\Ingest\\IngestController",
|
||||
"App\\Ingest\\EventValidator", "App\\Ingest\\ImageProcessor", "App\\Api\\ConfigController",
|
||||
"App\\Api\\HeartbeatController", "App\\Dashboard\\EventListController",
|
||||
"App\\Dashboard\\EventDetailController", "App\\Dashboard\\SnapshotProxy",
|
||||
"App\\Dashboard\\ZoneConfigController", "App\\Geometry\\Polygon",
|
||||
"App\\Repository\\Pdo\\PdoDeviceRepository", "App\\Repository\\Pdo\\PdoNonceRepository",
|
||||
"App\\Repository\\Pdo\\PdoRateLimiterRepository", "App\\Repository\\Pdo\\PdoEventRepository",
|
||||
"App\\Repository\\Pdo\\PdoSnapshotRepository", "App\\Repository\\Pdo\\PdoUserRepository",
|
||||
"App\\Repository\\Pdo\\PdoZoneRepository", "App\\Repository\\Pdo\\PdoThresholdRepository",
|
||||
"App\\Repository\\Pdo\\PdoAuditRepository"];
|
||||
$missing = array_values(array_filter($classes, fn($c) => !class_exists($c) && !interface_exists($c)));
|
||||
if ($missing) { fwrite(STDERR, "ERROR: unresolvable in the package: " . implode(", ", $missing) . "\n"); exit(1); }
|
||||
echo " -> " . count($classes) . " classes resolve through the packaged autoloader\n";
|
||||
' "$PRIV"
|
||||
|
||||
# Prove index.php ACTUALLY finds $PRIV from where it is mounted, at the real depth, rather
|
||||
# than trusting that the tree looks right. This is the check that would have caught a
|
||||
# hardcoded '/..' — the walk-up must cross www/ and land on the private folder.
|
||||
php -r '
|
||||
[$_, $web, $priv] = $argv;
|
||||
$found = null;
|
||||
$isAppRoot = fn(string $d) => is_file("$d/config.php") && is_file("$d/vendor/autoload.php")
|
||||
&& is_file("$d/src/Config.php");
|
||||
for ($dir = dirname($web), $i = 0; $i < 4; $i++) {
|
||||
foreach (["$dir/move_detect_app", $dir] as $c) { if ($isAppRoot($c)) { $found = $c; break 2; } }
|
||||
if (dirname($dir) === $dir) break;
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($found === null) { fwrite(STDERR, "ERROR: index.php could not resolve the app root from $web\n"); exit(1); }
|
||||
if (realpath($found) !== realpath($priv)) {
|
||||
fwrite(STDERR, "ERROR: app root resolved to $found, expected $priv\n"); exit(1);
|
||||
}
|
||||
echo " -> index.php resolves its app root across www/ to " . basename($found) . "/\n";
|
||||
' "$WEB" "$PRIV"
|
||||
|
||||
# Every staged .php must parse under the build php. Catches a truncated copy.
|
||||
while IFS= read -r -d '' f; do
|
||||
php -l "$f" >/dev/null || { echo "ERROR: php -l failed: $f" >&2; exit 1; }
|
||||
done < <(find "$STAGE" -name '*.php' -print0)
|
||||
echo " -> every staged .php parses"
|
||||
|
||||
echo ">> [6/6] zipping"
|
||||
rm -f "$ZIP"
|
||||
if [ "${MAKE_ZIP:-0}" != "1" ]; then
|
||||
# Default: leave the folder uncompressed. The operator uploads $STAGE/ over FTP as-is,
|
||||
# which is also the only path that cannot mangle directory separators (see below).
|
||||
echo " -> skipped (MAKE_ZIP=1 to also produce $ZIP)"
|
||||
elif command -v zip >/dev/null 2>&1; then
|
||||
( cd "$STAGE" && zip -rq "$ZIP" . )
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
# No `zip` (common on Windows/Git Bash). Use Python's zipfile with POSIX (forward-slash)
|
||||
# arcnames. PowerShell's Compress-Archive writes BACKSLASH path separators, which Cafe24's
|
||||
# extractor and some unzip tools do NOT normalise — the app tree then unpacks as flat files
|
||||
# (move_detect_app\src\... as one filename) and the deploy silently breaks. Prefer python.
|
||||
( cd "$STAGE" && python3 - "$ZIP" <<'PY'
|
||||
import os, sys, zipfile
|
||||
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for root, _dirs, files in os.walk("."):
|
||||
for name in files:
|
||||
full = os.path.join(root, name)
|
||||
arc = os.path.relpath(full, ".").replace(os.sep, "/") # force forward slashes
|
||||
z.write(full, arc)
|
||||
PY
|
||||
)
|
||||
elif command -v powershell >/dev/null 2>&1; then
|
||||
# Last resort. Compress-Archive uses backslash separators (see above) — the .htaccess/README
|
||||
# note the operator to extract with a slash-aware tool, or prefer the python path.
|
||||
win_stage="$(cygpath -w "$STAGE" 2>/dev/null || echo "$STAGE")"
|
||||
win_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")"
|
||||
powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force"
|
||||
echo " WARN: archived via Compress-Archive (backslash paths). Verify the host unpacks the tree correctly."
|
||||
else
|
||||
echo " WARN: no zip/python3/powershell — skipping the archive; upload $STAGE/ directly."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "DONE."
|
||||
echo " package : $STAGE"
|
||||
[ -f "$ZIP" ] && echo " zip : $ZIP"
|
||||
echo ""
|
||||
echo "Upload: put the CONTENTS of $STAGE into the Cafe24 FTP root."
|
||||
echo " www/* merges into the existing www/ (DocumentRoot)"
|
||||
echo " src/ vendor/ storage/ config.php sit BESIDE www/, never inside it"
|
||||
echo ""
|
||||
echo "Then, on the host: edit config.php (DB name/user/password), import schema.sql via"
|
||||
echo "phpMyAdmin and delete it, and open https://<domain>/_preflight.php to confirm the PHP"
|
||||
echo "build is usable — then DELETE _preflight.php. See README-UPLOAD.md."
|
||||
31
server/deploy/config.template.php
Normal file
31
server/deploy/config.template.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
// ============================================================================
|
||||
// move-detect — 설정 파일 (이 파일만 고치면 됩니다)
|
||||
// 위치: 카페24 FTP 최상위 /config.php (www/ 안이 아닙니다!)
|
||||
// 권한: 업로드 후 FTP 클라이언트에서 600 으로 지정하세요. zip/FTP 는 유닉스 권한을
|
||||
// 옮기지 못하므로 여기서 미리 줄 수 없습니다. _preflight.php 가 확인해줍니다.
|
||||
// ============================================================================
|
||||
//
|
||||
// 고칠 곳은 아래 "1. 데이터베이스" 세 줄뿐입니다. 값은 카페24 관리자 페이지의
|
||||
// MySQL 정보에서 그대로 복사하세요. 나머지는 손대지 않아도 동작합니다.
|
||||
//
|
||||
return [
|
||||
// ---- 1. 데이터베이스 (반드시 채우세요) ------------------------------
|
||||
// CHANGEME_DB_NAME → 카페24에서 만든 DB 이름
|
||||
'db_dsn' => 'mysql:host=localhost;dbname=CHANGEME_DB_NAME;charset=utf8mb4',
|
||||
'db_user' => 'CHANGEME_DB_USER', // 카페24 MySQL 아이디
|
||||
'db_pass' => 'CHANGEME_DB_PASSWORD', // 카페24 MySQL 비밀번호 — 비밀. 어디에도 붙여넣지 마세요.
|
||||
|
||||
// ---- 2. 저장 경로 (그대로 두세요) -----------------------------------
|
||||
// 여기서 __DIR__ 은 이 config.php 가 있는 폴더(= FTP 최상위)입니다.
|
||||
// src/Config.php 의 기본값은 '/../storage/snapshots' 이지만 그것은 src/ 기준이라
|
||||
// '..' 이 하나 더 붙습니다. 둘 다 결국 같은 <최상위>/storage/snapshots 를 가리킵니다.
|
||||
'snapshot_dir' => __DIR__ . '/storage/snapshots',
|
||||
|
||||
// ---- 3. 조절값 (기본값. 필요할 때만 바꾸세요) ------------------------
|
||||
'nonce_ttl' => 86400, // 재전송 방지 nonce 보관 시간(초)
|
||||
'timestamp_window' => 300, // HMAC 허용 시간 오차(초)
|
||||
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
|
||||
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
|
||||
'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성)
|
||||
];
|
||||
64
server/deploy/htaccess.www
Normal file
64
server/deploy/htaccess.www
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# move-detect cloud — belongs in the DocumentRoot (Cafe24: /home/hosting_users/<ID>/www/).
|
||||
# Built into the upload package as www/.htaccess by build_cafe24_package.sh.
|
||||
#
|
||||
# mod_rewrite is confirmed on Cafe24 web hosting. mod_headers and mod_authz_core are not,
|
||||
# so every dependent block is wrapped in <IfModule> and degrades instead of 500-ing.
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# 1) Force HTTPS. This is REQUIRED, not hardening: src/Auth/Session.php hard-codes
|
||||
# 'cookie_secure' => true, so over plain HTTP the browser never sends the session
|
||||
# cookie back — login appears to succeed, then silently loses the session on the very
|
||||
# next request. All three conditions must hold before redirecting, so a TLS-terminating
|
||||
# proxy that sets any one of them cannot trigger a redirect loop.
|
||||
# R=302 on purpose. Promote to R=301 only after verifying in a real browser: a wrong
|
||||
# 301 is cached by clients and is very hard to walk back.
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteCond %{SERVER_PORT} !^443$
|
||||
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=302,L]
|
||||
|
||||
# 2) Front controller — every non-file, non-directory path goes to index.php.
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
Header always set Referrer-Policy "no-referrer"
|
||||
</IfModule>
|
||||
|
||||
# 3) No directory listings. Backstop against an unauthenticated listing of
|
||||
# storage/snapshots/ (JPEGs of people at work) if it is ever misplaced under the docroot.
|
||||
Options -Indexes
|
||||
|
||||
# 4) Deny dotfiles (.git, .env, .htaccess, .user.ini, .phpunit.result.cache, ...).
|
||||
<FilesMatch "^\.">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# 5) Deny anything Apache would serve as PLAINTEXT for lack of a handler. schema.sql is the
|
||||
# sharp one: it is full DDL, and unlike a .php file PHP would not swallow it.
|
||||
<FilesMatch "\.(sql|md|json|lock|dist|ini|log|sh|ya?ml|cache)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# NOTE: there are deliberately no deny rules for src/ vendor/ storage/ config.php here.
|
||||
# Those live OUTSIDE the DocumentRoot (siblings of www/), where no .htaccess under www/
|
||||
# could reach them and none is needed. Placement is the control; keep them out of www/.
|
||||
160
server/deploy/preflight.php
Normal file
160
server/deploy/preflight.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
// Uploaded as www/_preflight.php. Open https://<domain>/_preflight.php once, confirm every
|
||||
// row is OK, then DELETE THIS FILE.
|
||||
//
|
||||
// It exists because three host-level facts are unknowable from the repo and every one of
|
||||
// them fails badly on a stock PHP build:
|
||||
// * no argon2 -> src/Security/Password.php calls password_hash(..., PASSWORD_ARGON2ID)
|
||||
// unguarded, and AuthController::DUMMY_HASH runs verify() on EVERY login
|
||||
// attempt including unknown usernames -> the login page fatals for
|
||||
// everyone, instantly.
|
||||
// * no gd -> ImageProcessor::reencode() returns ok=false and IngestController's
|
||||
// `continue` swallows it: events save with no snapshot, API still 201s.
|
||||
// * no pdo_mysql -> every request 500s at Database::connect(). (mysqli being present does
|
||||
// NOT imply pdo_mysql.)
|
||||
//
|
||||
// Deliberately prints booleans only — no phpinfo(), no paths, no config values — so the
|
||||
// window between upload and deletion leaks nothing useful.
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
|
||||
// Find the app root exactly the way public/index.php does — container first, then the bare
|
||||
// directory, probing each level for any marker. Using ANY marker (not all three) is
|
||||
// deliberate here and differs from index.php on purpose: this page exists to DIAGNOSE a
|
||||
// half-finished upload, so it must still locate a root that is missing vendor/ or config.php
|
||||
// and then report precisely which piece is absent.
|
||||
const APP_PRIVATE_DIR = 'move_detect_app';
|
||||
$root = null;
|
||||
$hasAnyMarker = static fn (string $d): bool =>
|
||||
is_file($d . '/config.php') || is_file($d . '/vendor/autoload.php') || is_file($d . '/src/Config.php');
|
||||
for ($d = dirname(__DIR__), $i = 0; $i < 4; $i++) {
|
||||
foreach ([$d . '/' . APP_PRIVATE_DIR, $d] as $cand) {
|
||||
if ($hasAnyMarker($cand)) { $root = $cand; break 2; }
|
||||
}
|
||||
if (dirname($d) === $d) break;
|
||||
$d = dirname($d);
|
||||
}
|
||||
// Nothing found anywhere IS the diagnosis: fall back to where the files SHOULD be, so every
|
||||
// row below reports a truthful "missing" instead of crashing on null.
|
||||
$root = $root ?? dirname(dirname(__DIR__)) . '/' . APP_PRIVATE_DIR;
|
||||
|
||||
$checks = [
|
||||
'PHP >= 8.2' => [
|
||||
PHP_VERSION_ID >= 80200,
|
||||
'Cafe24 관리자에서 PHP 버전을 8.2 이상으로 올리세요. (주의: 카페24는 PHP 버전 변경 시 데이터와 DB가 초기화된다고 안내합니다. 반드시 업로드 전에 정하세요.)',
|
||||
],
|
||||
'pdo_mysql 확장' => [
|
||||
in_array('mysql', PDO::getAvailableDrivers(), true),
|
||||
'DB 연결이 전혀 안 됩니다. 카페24에 pdo_mysql 활성화를 요청하세요.',
|
||||
],
|
||||
'gd 확장' => [
|
||||
function_exists('imagecreatefromstring') && function_exists('imagejpeg'),
|
||||
'사진(스냅샷)이 조용히 사라집니다. 이벤트는 저장되는데 사진만 없어집니다. 카페24에 gd 활성화를 요청하세요.',
|
||||
],
|
||||
'Argon2id 암호 해시' => [
|
||||
defined('PASSWORD_ARGON2ID') && in_array('argon2id', password_algos(), true),
|
||||
'로그인 페이지가 즉시 죽습니다. 이게 가장 흔한 배포 실패 원인입니다. 카페24에 Argon2 포함 PHP 빌드를 요청하세요.',
|
||||
],
|
||||
'config.php 있음' => [
|
||||
is_readable($root . '/config.php'),
|
||||
'FTP 최상위(www 밖)에 config.php 를 올리세요.',
|
||||
],
|
||||
// Checks the parsed VALUES, not the file text. Grepping the raw file for 'CHANGEME'
|
||||
// would also match the template's explanatory comments, which survive a correct edit —
|
||||
// so a properly-filled config would report 실패 forever.
|
||||
'config.php 를 수정했음' => (function () use ($root) {
|
||||
$fix = 'config.php 안의 CHANGEME 를 실제 DB 이름/아이디/비밀번호로 바꾸세요.';
|
||||
if (!is_readable($root . '/config.php')) {
|
||||
return [false, $fix];
|
||||
}
|
||||
$cfg = @require $root . '/config.php';
|
||||
if (!is_array($cfg)) {
|
||||
return [false, 'config.php 가 PHP 배열을 return 하지 않습니다. 파일이 깨졌는지 확인하세요.'];
|
||||
}
|
||||
foreach (['db_dsn', 'db_user', 'db_pass'] as $k) {
|
||||
if (str_contains((string)($cfg[$k] ?? ''), 'CHANGEME')) {
|
||||
return [false, $fix];
|
||||
}
|
||||
}
|
||||
return [true, ''];
|
||||
})(),
|
||||
'storage/snapshots 쓰기 가능' => [
|
||||
is_dir($root . '/storage/snapshots') && is_writable($root . '/storage/snapshots'),
|
||||
'사진이 조용히 사라집니다. 폴더를 만들고 권한 750 으로 주세요.',
|
||||
],
|
||||
'src/ 있음' => [
|
||||
is_readable($root . '/src/Config.php'),
|
||||
'src/ 폴더를 FTP 최상위(www 밖)에 올리세요.',
|
||||
],
|
||||
'vendor/ 오토로더 있음' => [
|
||||
is_readable($root . '/vendor/autoload.php'),
|
||||
'vendor/ 폴더를 FTP 최상위(www 밖)에 올리세요. 없으면 모든 페이지가 죽습니다.',
|
||||
],
|
||||
// These probe THIS directory (the one the web serves), not $root. If the secrets turned
|
||||
// up here, they are downloadable.
|
||||
'src/ 가 웹에 노출되지 않음' => [
|
||||
!is_readable(__DIR__ . '/src/Config.php'),
|
||||
'src/ 가 웹에서 접근 가능한 폴더 안에 있습니다. FTP 최상위(www 밖)로 옮기세요.',
|
||||
],
|
||||
'config.php 가 웹에 노출되지 않음' => [
|
||||
!is_readable(__DIR__ . '/config.php'),
|
||||
'config.php 가 웹에서 접근 가능한 폴더 안에 있습니다. DB 비밀번호가 노출됩니다. FTP 최상위(www 밖)로 옮기세요.',
|
||||
],
|
||||
// Reports the LIVE mode rather than trusting the build. The package's chmod 600 cannot
|
||||
// survive a zip round-trip or an FTP upload (neither carries unix modes), so the only
|
||||
// honest place to check this is here, on the host. 0077 — NOT 0o077, which is PHP 8.1+
|
||||
// syntax and would make this file fail to parse on the very old build we want to warn about.
|
||||
'config.php 권한이 600' => [
|
||||
is_readable($root . '/config.php')
|
||||
&& (fileperms($root . '/config.php') & 0077) === 0,
|
||||
'FTP 클라이언트에서 config.php 권한을 600 으로 바꾸세요. DB 비밀번호가 들어있습니다.',
|
||||
],
|
||||
'HTTPS 로 접속됨' => [
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| ($_SERVER['SERVER_PORT'] ?? '') === '443'
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
|
||||
'세션 쿠키가 cookie_secure=true 라서 HTTP 로는 로그인이 안 됩니다. 카페24에서 무료 SSL 을 켜세요.',
|
||||
],
|
||||
];
|
||||
|
||||
$failed = array_keys(array_filter($checks, fn($c) => !$c[0]));
|
||||
?>
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>move-detect 설치 점검</title>
|
||||
<style>
|
||||
body { font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
td { border-bottom: 1px solid #e5e5e5; padding: .5rem .4rem; vertical-align: top; }
|
||||
td:first-child { white-space: nowrap; }
|
||||
.ok { color: #0a7c2f; font-weight: 600; }
|
||||
.no { color: #c02626; font-weight: 600; }
|
||||
.fix { color: #666; font-size: 13px; }
|
||||
.banner { padding: .75rem 1rem; border-radius: 6px; margin: 1rem 0; }
|
||||
.good { background: #e8f6ec; }
|
||||
.bad { background: #fdeaea; }
|
||||
.warn { background: #fff6d8; border: 1px solid #e8cf7a; }
|
||||
</style>
|
||||
<h1>move-detect 설치 점검</h1>
|
||||
|
||||
<?php if ($failed): ?>
|
||||
<p class="banner bad"><strong><?= count($failed) ?>개 항목 실패.</strong> 아래 빨간 줄의 안내를 먼저 해결하세요.</p>
|
||||
<?php else: ?>
|
||||
<p class="banner good"><strong>전부 통과.</strong> <a href="/login">/login</a> 으로 접속해 관리자 로그인을 확인하세요.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<table>
|
||||
<?php foreach ($checks as $name => [$ok, $fix]): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($name, ENT_QUOTES) ?></td>
|
||||
<td class="<?= $ok ? 'ok' : 'no' ?>"><?= $ok ? 'OK' : '실패' ?></td>
|
||||
<td class="fix"><?= $ok ? '' : htmlspecialchars($fix, ENT_QUOTES) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
|
||||
<p class="banner warn">
|
||||
<strong>확인이 끝나면 이 파일(<code>www/_preflight.php</code>)을 반드시 지우세요.</strong>
|
||||
진단용이라 계속 열어둘 이유가 없습니다.
|
||||
</p>
|
||||
5
server/deploy/user.ini
Normal file
5
server/deploy/user.ini
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
; Shipped as www/move_detect/.user.ini (Cafe24 honors per-directory .user.ini).
|
||||
; Backstops index.php's ini_set for errors raised before the script parses/runs, so a stack
|
||||
; trace with the DB DSN/user can never reach the browser on a shared host that ships On.
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
# Cafe24 Cloud Deploy Guide (P1-B)
|
||||
# Cafe24 Server Deploy Guide (P1-B)
|
||||
|
||||
This document covers deploying the `cloud/` PHP application (ingest API + dashboard) to a
|
||||
This document covers deploying the `server/` 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 `cloud/public/` may be reachable from the web. Everything else must sit **outside**
|
||||
Only `server/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,38 +15,73 @@ 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 ("html" folder in Cafe24's control panel)
|
||||
├── index.php
|
||||
├── .htaccess
|
||||
└── assets/
|
||||
└── 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.
|
||||
```
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
## 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 `cloud/composer.json`'s `"php": ">=8.2"` (the app is written
|
||||
- [ ] **PHP version** matches `server/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
|
||||
|
|
@ -77,7 +112,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 `cloud/.gitignore`):
|
||||
by `server/.gitignore`):
|
||||
|
||||
```bash
|
||||
cp config.example.php config.php
|
||||
|
|
@ -104,7 +139,7 @@ or version control.
|
|||
|
||||
## 4. Import the schema
|
||||
|
||||
`cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
|
||||
`server/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
|
||||
|
|
@ -116,12 +151,19 @@ or version control.
|
|||
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
|
||||
attack surface for no benefit.
|
||||
|
||||
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
|
||||
**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
|
||||
(`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
|
||||
|
||||
|
|
@ -153,14 +195,14 @@ php -r "echo bin2hex(random_bytes(32)), \"\n\";"
|
|||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO devices (device_id, name, secret, config_version)
|
||||
INSERT INTO move_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 devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the
|
||||
`UPDATE move_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
|
||||
|
|
@ -180,7 +222,7 @@ php -r "echo password_hash('CHANGE-ME-to-a-real-passphrase', PASSWORD_ARGON2ID),
|
|||
`PASSWORD_ARGON2ID`.)
|
||||
|
||||
```sql
|
||||
INSERT INTO users (username, password_hash, role, site_scope, failed_attempts, locked_until)
|
||||
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until)
|
||||
VALUES ('admin', '<paste the argon2id hash here>', 'admin', NULL, 0, 0);
|
||||
```
|
||||
|
||||
98
server/migrations/2026-07-17-p1-9-p3-2b.sql
Normal file
98
server/migrations/2026-07-17-p1-9-p3-2b.sql
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
-- ===========================================================================
|
||||
-- Migration: P1-9 (event dedup) + P3-2b (site-scoped access control)
|
||||
-- + retention / rate-limit reap indexes
|
||||
--
|
||||
-- Apply ONCE to an EXISTING move_detect database. A FRESH install already has
|
||||
-- all of this via schema.sql — do NOT run this on a database created from the
|
||||
-- current schema.sql.
|
||||
--
|
||||
-- RUN IN THREE PARTS, IN ORDER. Part C (the events.site backfill) MUST run
|
||||
-- AFTER Part B has set devices.site — otherwise it matches 0 rows and every
|
||||
-- historical event stays site=NULL and DISAPPEARS from a scoped operator's
|
||||
-- dashboard the moment you scope a user. Do not skip Part C.
|
||||
--
|
||||
-- How to run: phpMyAdmin -> your DB -> "SQL" tab. MySQL (unlike MariaDB) has no
|
||||
-- "ADD COLUMN IF NOT EXISTS": if a column/index already exists, that ONE
|
||||
-- statement errors — skip it and run the rest.
|
||||
-- ===========================================================================
|
||||
|
||||
-- ===========================================================================
|
||||
-- PART A — schema changes (safe to run as-is)
|
||||
-- ===========================================================================
|
||||
|
||||
-- A1) devices.site — the access-control site each device belongs to.
|
||||
ALTER TABLE move_devices
|
||||
ADD COLUMN site VARCHAR(64) NULL AFTER name,
|
||||
ADD KEY ix_devices_site (site);
|
||||
|
||||
-- A2) events.site — denormalized from move_devices.site at ingest, for the dashboard's
|
||||
-- site-scoped event list.
|
||||
ALTER TABLE move_events
|
||||
ADD COLUMN site VARCHAR(64) NULL AFTER device_id,
|
||||
ADD KEY ix_events_site_occurred_at (site, occurred_at);
|
||||
|
||||
-- A3) events.event_uuid — edge-generated idempotency key (P1-9). The UNIQUE key
|
||||
-- makes a re-sent event collide (dedup) instead of duplicating. MySQL allows
|
||||
-- multiple NULLs in a UNIQUE index, so legacy rows without a uuid are fine.
|
||||
ALTER TABLE move_events
|
||||
ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id,
|
||||
ADD UNIQUE KEY uq_events_uuid (event_uuid);
|
||||
|
||||
-- A4) event_snapshots.created_at index — PdoSnapshotRepository::purgeOlderThan()
|
||||
-- runs "SELECT ... WHERE created_at < ?" inside live ingest requests. Without
|
||||
-- this index it full-scans the snapshot table on every triggered purge.
|
||||
ALTER TABLE move_event_snapshots
|
||||
ADD KEY ix_event_snapshots_created (created_at);
|
||||
|
||||
-- A5) event_snapshots (event_id, content_hash) UNIQUE — lets the ingest dedup path
|
||||
-- re-store an event's MISSING snapshots idempotently (a re-sent event carries
|
||||
-- the same re-encoded bytes = same content_hash, so a duplicate/racing store
|
||||
-- collides instead of writing a second copy). If this fails on "Duplicate
|
||||
-- entry", the table already has duplicate (event_id, content_hash) rows —
|
||||
-- remove the dups, then re-run this one statement.
|
||||
ALTER TABLE move_event_snapshots
|
||||
ADD UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash);
|
||||
|
||||
-- A6) rate_limits.window_start index — the reap "DELETE ... WHERE window_start < ?"
|
||||
-- cannot use the (rate_key, window_start) PRIMARY KEY (window_start is the
|
||||
-- trailing column), so it full-scans and locks rows on the pre-auth hot path.
|
||||
ALTER TABLE move_rate_limits
|
||||
ADD KEY ix_rate_limits_window (window_start);
|
||||
|
||||
-- ===========================================================================
|
||||
-- PART B — provisioning (EDIT the values for your deployment, then run).
|
||||
-- Assign every device to its site here; the backfill in Part C reads it.
|
||||
-- ===========================================================================
|
||||
-- UPDATE move_devices SET site = 'factory-a' WHERE device_id = 'pi-1';
|
||||
-- UPDATE move_devices SET site = 'factory-b' WHERE device_id = 'pi-2';
|
||||
-- -- ... one row per device ...
|
||||
|
||||
-- ===========================================================================
|
||||
-- PART C — backfill historical events from their device's site.
|
||||
-- RUN THIS ONLY AFTER Part B. Re-run it whenever you add/relabel a
|
||||
-- device's site so its past events reappear for scoped operators.
|
||||
-- ===========================================================================
|
||||
UPDATE move_events e
|
||||
JOIN move_devices d ON d.device_id = e.device_id
|
||||
SET e.site = d.site
|
||||
WHERE e.site IS NULL AND d.site IS NOT NULL;
|
||||
|
||||
-- ===========================================================================
|
||||
-- PART D — scope dashboard users (run AFTER Part C, so scoped users immediately
|
||||
-- see their backfilled history). NULL = global operator; '' is NOT
|
||||
-- global (matches nothing — an accidental blank fails closed).
|
||||
-- ===========================================================================
|
||||
-- UPDATE move_users SET site_scope = 'factory-a' WHERE username = 'operator-a';
|
||||
|
||||
-- ---- Verification (run these; each must confirm the object/state) -----------
|
||||
-- SHOW COLUMNS FROM move_devices LIKE 'site';
|
||||
-- SHOW COLUMNS FROM move_events LIKE 'site';
|
||||
-- SHOW COLUMNS FROM move_events LIKE 'event_uuid';
|
||||
-- SHOW INDEX FROM move_events WHERE Key_name = 'uq_events_uuid';
|
||||
-- SHOW INDEX FROM move_event_snapshots WHERE Key_name = 'ix_event_snapshots_created';
|
||||
-- SHOW INDEX FROM move_event_snapshots WHERE Key_name = 'uq_event_snapshots_event_hash';
|
||||
-- SHOW INDEX FROM move_rate_limits WHERE Key_name = 'ix_rate_limits_window';
|
||||
-- Events still unscoped after Part C (expect 0, or only rows whose device has no
|
||||
-- site assigned yet — go back and finish Part B for those devices):
|
||||
-- SELECT COUNT(*) FROM move_events e JOIN move_devices d ON d.device_id = e.device_id
|
||||
-- WHERE e.site IS NULL AND d.site IS NOT NULL;
|
||||
23
server/migrations/2026-07-20-move-table-prefix.sql
Normal file
23
server/migrations/2026-07-20-move-table-prefix.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- cloud/migrations/2026-07-20-move-table-prefix.sql
|
||||
-- Rename every P1-B table to the `move_` prefix (shared-hosting DB co-tenancy).
|
||||
-- Run ONCE in phpMyAdmin BEFORE deploying code that reads the prefixed names.
|
||||
-- Fresh installs: skip this, cloud/schema.sql already creates prefixed tables.
|
||||
--
|
||||
-- RENAME TABLE is atomic across all pairs and preserves indexes, constraints
|
||||
-- and FKs (fk_event_snapshots_event follows its parent automatically).
|
||||
|
||||
RENAME TABLE
|
||||
users TO move_users,
|
||||
devices TO move_devices,
|
||||
cameras TO move_cameras,
|
||||
zones TO move_zones,
|
||||
events TO move_events,
|
||||
event_snapshots TO move_event_snapshots,
|
||||
thresholds TO move_thresholds,
|
||||
nonces TO move_nonces,
|
||||
rate_limits TO move_rate_limits,
|
||||
audit_log TO move_audit_log;
|
||||
|
||||
-- Zone-editor background frame: SELECT ... FROM move_event_snapshots JOIN move_events
|
||||
-- WHERE e.camera_id = ? would otherwise full-scan an unbounded table on an interactive page.
|
||||
ALTER TABLE move_events ADD INDEX ix_events_camera (camera_id, event_id);
|
||||
246
server/public/assets/app.css
Normal file
246
server/public/assets/app.css
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/* ==================================================================
|
||||
MOVE·DETECT dashboard — light, calm, minimal.
|
||||
A safety board is calm most of the time and urgent rarely, so it stays
|
||||
near-monochrome with ONE interactive accent (indigo) and spends its only
|
||||
real color on the unacknowledged-critical alarm. Soft cards, generous
|
||||
whitespace, big tabular numbers.
|
||||
Air-gapped (폐쇄망): system fonts only, no external assets fetched at runtime.
|
||||
================================================================== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--bg:#f5f6f8; --card:#ffffff; --line:#e7e9ee;
|
||||
--ink:#161a20; --ink-2:#5b636e; --ink-3:#949aa4;
|
||||
|
||||
--accent:#4f46e5; /* indigo — only where you act (nav, links, focus) */
|
||||
--ok:#16a34a; /* "감시 중" live-monitoring green */
|
||||
|
||||
/* Severity (reserved, semantic — never reused as a decorative color). Identity is
|
||||
always carried by a text label too (NF9: never color alone). */
|
||||
--crit:#dc2626; --high:#ea580c; --warn:#ca8a04; --info:#64748b;
|
||||
--crit-bg:#fdf3f3; --crit-line:#f4c9c9;
|
||||
|
||||
--border:var(--line); /* back-compat alias */
|
||||
--text:var(--ink);
|
||||
|
||||
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
|
||||
--mono:ui-monospace,"Cascadia Mono","Cascadia Code",Consolas,"D2Coding","Roboto Mono",monospace;
|
||||
--shadow:0 1px 2px rgba(20,24,31,.04), 0 8px 24px -18px rgba(20,24,31,.5);
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body {
|
||||
margin:0;
|
||||
font-family:var(--font);
|
||||
color:var(--ink);
|
||||
background:var(--bg);
|
||||
line-height:1.45;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
|
||||
/* ---- Header ------------------------------------------------------ */
|
||||
.app-header {
|
||||
display:flex; align-items:center; gap:1rem;
|
||||
padding:.85rem 1.6rem;
|
||||
background:var(--card); border-bottom:1px solid var(--line);
|
||||
}
|
||||
.app-brand { display:flex; align-items:center; gap:.55rem; font-weight:700; font-size:.98rem; letter-spacing:-.01em; }
|
||||
/* Live-monitoring dot — the board is watching. Calm green, not an alarm. */
|
||||
.live-dot { width:8px; height:8px; border-radius:50%; background:var(--ok); box-shadow:0 0 0 3px rgba(22,163,74,.14); flex:none; }
|
||||
.brand-name { color:var(--ink); }
|
||||
.brand-sub { display:none; } /* the mono wordmark is gone; keep the hook harmless */
|
||||
|
||||
.app-nav { display:flex; gap:.2rem; margin-left:1.8rem; margin-right:auto; }
|
||||
.app-nav a {
|
||||
position:relative;
|
||||
color:var(--ink-2); text-decoration:none;
|
||||
font-size:.87rem; font-weight:500;
|
||||
padding:.45rem .75rem; border-radius:8px;
|
||||
}
|
||||
.app-nav a:hover, .app-nav a:focus-visible { background:#f1f2f5; color:var(--ink); text-decoration:none; }
|
||||
.app-nav a.is-active { color:var(--accent); }
|
||||
.app-nav a.is-active::after {
|
||||
content:""; position:absolute; left:.75rem; right:.75rem; bottom:-.86rem; height:2px;
|
||||
background:var(--accent); border-radius:2px;
|
||||
}
|
||||
.app-session { display:flex; align-items:center; gap:.7rem; font-size:.85rem; color:var(--ink-2); }
|
||||
.app-session .who { color:var(--ink); }
|
||||
.app-session a { color:var(--accent); text-decoration:none; font-weight:500; }
|
||||
.app-session a:hover { text-decoration:underline; text-underline-offset:3px; }
|
||||
|
||||
/* ---- Buttons / fields -------------------------------------------- */
|
||||
button, .btn {
|
||||
font:inherit; font-size:.83rem; font-weight:500; cursor:pointer;
|
||||
padding:.42rem .8rem;
|
||||
color:var(--ink); background:#fff;
|
||||
border:1px solid var(--line); border-radius:9px;
|
||||
transition:transform .1s, background .15s, border-color .15s;
|
||||
}
|
||||
button:hover, .btn:hover { background:#f7f8fa; border-color:#dcdfe4; }
|
||||
button:active, .btn:active { transform:scale(.98); }
|
||||
:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
|
||||
input, select {
|
||||
font:inherit; font-size:.9rem; color:var(--ink);
|
||||
padding:.5rem .65rem;
|
||||
background:#fff; border:1px solid var(--line); border-radius:9px;
|
||||
}
|
||||
input::placeholder { color:var(--ink-3); }
|
||||
input:focus, select:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); }
|
||||
label { color:var(--ink-2); font-size:.85rem; }
|
||||
|
||||
/* ---- Page shell -------------------------------------------------- */
|
||||
.app-main { max-width:1000px; margin:0 auto; padding:2rem 1.6rem 4rem; }
|
||||
.page-head { display:flex; align-items:baseline; gap:.8rem; margin:0 0 .25rem; flex-wrap:wrap; }
|
||||
h1 { font-size:1.5rem; font-weight:750; letter-spacing:-.025em; margin:.1rem 0 0; }
|
||||
/* Live watch pill (set by app.js from poll health): green when polling, muted when stale.
|
||||
The dot is a ::before so app.js can freely overwrite the text via textContent. */
|
||||
.watch { font-size:.82rem; font-weight:600; color:var(--ok); display:inline-flex; align-items:center; gap:.35rem; }
|
||||
.watch::before { content:""; width:6px; height:6px; border-radius:50%; background:currentColor; flex:none; }
|
||||
.watch.is-stale { color:var(--ink-3); }
|
||||
.page-sub { color:var(--ink-3); font-size:.88rem; margin:.15rem 0 1.8rem; }
|
||||
|
||||
/* ---- Status strip: KPI tiles ------------------------------------- */
|
||||
/* Unacknowledged counts per severity, computed client-side from the same /api/events
|
||||
array the list renders (no extra endpoint). Identity is a colored dot + a text label,
|
||||
never color alone (NF9). A zero tile dims but never hides, so the strip never reflows. */
|
||||
.status-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:.85rem; margin:0 0 2rem; }
|
||||
.stat-tile {
|
||||
background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||
padding:1.05rem 1.15rem; box-shadow:var(--shadow);
|
||||
}
|
||||
.stat-tile .stat-label { display:flex; align-items:center; gap:.45rem; font-size:.79rem; font-weight:500; color:var(--ink-2); }
|
||||
.stat-tile .stat-label .led { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dot,#c4c9d0); }
|
||||
.stat-tile .stat-label .icon { display:none; } /* the dot carries the color; the emoji is dropped in this theme */
|
||||
.stat-tile .stat-count {
|
||||
display:block; margin-top:.5rem;
|
||||
font-size:2.3rem; font-weight:730; letter-spacing:-.04em; line-height:1;
|
||||
color:var(--ink); font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.stat-tile.is-zero { opacity:.62; }
|
||||
.stat-tile.is-zero .stat-count { color:var(--ink-3); }
|
||||
.tile-critical { --dot:var(--crit); }
|
||||
.tile-high { --dot:var(--high); }
|
||||
.tile-warning { --dot:var(--warn); }
|
||||
.tile-info { --dot:var(--info); }
|
||||
/* The one moment of color: an unacknowledged critical tints its tile. */
|
||||
.tile-critical:not(.is-zero) { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.tile-critical:not(.is-zero) .stat-count { color:var(--crit); }
|
||||
|
||||
/* ---- Device / camera health panel -------------------------------- */
|
||||
/* Surfaces the heartbeat telemetry the edge already reports (online/offline, fps, temp,
|
||||
throttle, config drift). A dead camera is a safety blind spot; an offline card takes the
|
||||
same red the critical alarm does. State is never color alone — the "오프라인/응답 없음"
|
||||
text carries it too (NF9). */
|
||||
.device-strip { display:flex; flex-wrap:wrap; gap:.6rem; margin:0 0 2rem; }
|
||||
.device-strip.is-empty { color:var(--ink-3); font-size:.85rem; }
|
||||
.device-card {
|
||||
flex:1 1 11rem; min-width:11rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||
padding:.7rem .85rem; box-shadow:var(--shadow);
|
||||
}
|
||||
.device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.9rem; }
|
||||
.device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; }
|
||||
.device-card .device-meta { margin-top:.3rem; font-family:var(--mono); font-size:.76rem; color:var(--ink-3); }
|
||||
.device-card .device-warn { margin-top:.35rem; font-size:.72rem; font-weight:600; color:var(--warn); }
|
||||
.device-card.is-offline { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.device-card.is-offline .device-name .dot { background:var(--crit); }
|
||||
/* Device poll went stale (500 / WAF): dim the last-known cards and say so, never freeze silently. */
|
||||
.device-strip.is-stale { opacity:.6; }
|
||||
.device-stale-note { flex-basis:100%; font-size:.72rem; color:var(--ink-3); }
|
||||
/* "new events arrived while paging" reload affordance — honest about a possible gap in the list. */
|
||||
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .8rem; padding:.5rem .75rem;
|
||||
font-size:.8rem; color:var(--ink-2); background:var(--card); border:1px solid var(--line); border-radius:8px; }
|
||||
.page-gap-hint[hidden] { display:none; }
|
||||
|
||||
/* ---- Event list: soft cards -------------------------------------- */
|
||||
.event-row {
|
||||
display:flex; align-items:center; gap:1rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||
padding:.95rem 1.15rem; margin-bottom:.6rem; box-shadow:var(--shadow);
|
||||
}
|
||||
/* An unacknowledged critical is the only row that takes color; the tint + red badge +
|
||||
"확인 필요" chip make it unmissable without shouting. */
|
||||
.event-row.is-unacknowledged.sev-row-critical { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
|
||||
/* Evidence thumbnail (newest snapshot) — a small camera still on the left of the row. */
|
||||
.event-thumb { width:64px; height:44px; object-fit:cover; border-radius:8px; border:1px solid var(--line); background:#0f1216; flex:none; }
|
||||
|
||||
.severity-badge { display:inline-flex; align-items:center; gap:.45rem; flex:none; min-width:66px; font-size:.84rem; font-weight:600; white-space:nowrap; }
|
||||
/* app.js/detail.js build the badge as <span.icon>(emoji)</span><span.label>text</span>.
|
||||
In this theme the icon becomes a colored dot (emoji hidden) and the label carries identity. */
|
||||
.severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; }
|
||||
.sev-critical { color:var(--crit); }
|
||||
.sev-high { color:var(--high); }
|
||||
.sev-warning { color:var(--warn); }
|
||||
.sev-info { color:var(--info); }
|
||||
|
||||
.event-meta { flex:1; min-width:0; }
|
||||
.event-type { font-weight:650; font-size:1rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; }
|
||||
.event-time { margin-top:.2rem; font-family:var(--mono); font-size:.79rem; color:var(--ink-3); }
|
||||
.event-detail-link { color:inherit; text-decoration:none; }
|
||||
.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }
|
||||
/* "확인 필요" chip, appended by app.js to an unacknowledged critical row. */
|
||||
.chip { font-size:.72rem; font-weight:600; color:var(--crit); background:#fbe2e2; padding:.12rem .5rem; border-radius:999px; }
|
||||
|
||||
.ack-btn { margin-left:auto; flex:none; }
|
||||
.ack-btn:disabled { cursor:default; color:var(--ink-3); background:none; border:none; box-shadow:none; font-family:var(--mono); font-size:.8rem; }
|
||||
|
||||
/* ---- Section labels --------------------------------------------- */
|
||||
.section-label { font-size:.79rem; font-weight:600; color:var(--ink-3); letter-spacing:.04em; margin:0 0 .7rem; }
|
||||
|
||||
/* ---- List toolbar: filters + "더 보기" --------------------------- */
|
||||
.list-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.7rem; }
|
||||
.list-head .section-label { margin:0; }
|
||||
.list-filters { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||
.list-filters select { font-size:.82rem; padding:.35rem .5rem; }
|
||||
.filter-check { display:inline-flex; align-items:center; gap:.35rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; }
|
||||
.filter-check input { accent-color:var(--accent); }
|
||||
.list-more { display:flex; justify-content:center; margin-top:.5rem; }
|
||||
.list-more button[hidden] { display:none; }
|
||||
|
||||
/* ---- Login ------------------------------------------------------- */
|
||||
.login-form {
|
||||
display:flex; flex-direction:column; gap:.65rem; max-width:340px; margin:3.5rem auto 0;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:16px; padding:1.8rem;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.login-form h1 { margin:.1rem 0 .5rem; }
|
||||
.form-error { color:var(--crit); font-size:.85rem; }
|
||||
|
||||
/* ---- Zone editor ------------------------------------------------- */
|
||||
.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; }
|
||||
.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; }
|
||||
#zone-canvas {
|
||||
border:1px solid var(--line); border-radius:12px;
|
||||
background:#0f1216 center / contain no-repeat; /* dark canvas: a camera frame reads best on dark */
|
||||
cursor:crosshair; max-width:100%;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
#zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; }
|
||||
#zone-list li {
|
||||
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
||||
max-width:360px; padding:.5rem .75rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:10px;
|
||||
font-size:.85rem; color:var(--ink-2);
|
||||
}
|
||||
#zone-status { font-family:var(--mono); font-size:.82rem; color:var(--ink-3); }
|
||||
|
||||
/* ---- Incident detail -------------------------------------------- */
|
||||
.detail-row { margin:.35rem 0; }
|
||||
.detail-row .detail-label { color:var(--ink-3); font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.05em; }
|
||||
#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; }
|
||||
.detail-snapshot {
|
||||
max-width:100%; height:auto;
|
||||
border:1px solid var(--line); border-radius:12px; background:#fff; padding:4px;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
|
||||
/* ---- Responsive -------------------------------------------------- */
|
||||
@media (max-width:720px) {
|
||||
.status-strip { grid-template-columns:repeat(2,1fr); }
|
||||
.app-nav { margin-left:0; }
|
||||
.app-main { padding:1.5rem 1.1rem 3rem; }
|
||||
}
|
||||
@media (max-width:420px) {
|
||||
.status-strip { grid-template-columns:1fr 1fr; }
|
||||
}
|
||||
557
server/public/assets/app.js
Normal file
557
server/public/assets/app.js
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
/**
|
||||
* Events dashboard: polls GET /api/events every 5s, renders the list with
|
||||
* severity text+icon+color (never color alone, per NF9), pins unacknowledged
|
||||
* events to the top, and wires the acknowledge button to POST /events/{id}/ack.
|
||||
*
|
||||
* XSS-safe: all dynamic values are set via textContent, never innerHTML.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
|
||||
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME. Every URL below must
|
||||
// go through it — a bare '/api/...' would address the other site instead.
|
||||
var B = window.APP_BASE || '';
|
||||
|
||||
var POLL_INTERVAL_MS = 5000;
|
||||
|
||||
var SEVERITY_LABELS = {
|
||||
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔
|
||||
high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠
|
||||
warning: { icon: '△', text: '경고', cls: 'sev-warning' }, // △
|
||||
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } // ℹ
|
||||
};
|
||||
|
||||
var root = document.getElementById('events-root');
|
||||
if (!root) return;
|
||||
|
||||
// Live-monitoring pill: honest about poll health — green "실시간 감시 중" while events
|
||||
// are flowing, muted "연결 지연" when a poll fails, so a stale board never looks live.
|
||||
var watchEl = document.getElementById('watch-status');
|
||||
function setWatch(ok) {
|
||||
if (!watchEl) return;
|
||||
watchEl.textContent = ok ? '실시간 감시 중' : '연결 지연';
|
||||
watchEl.classList.toggle('is-stale', !ok);
|
||||
}
|
||||
|
||||
var csrfToken = null;
|
||||
function loadCsrfToken() {
|
||||
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) { return res.ok ? res.json() : null; })
|
||||
.then(function (data) { if (data && data.token) csrfToken = data.token; })
|
||||
.catch(function () { /* ack will fail with 403 and re-enable the button */ });
|
||||
}
|
||||
|
||||
function severityInfo(sev) {
|
||||
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
||||
}
|
||||
|
||||
function formatTime(occurredAt) {
|
||||
if (!occurredAt && occurredAt !== 0) return '';
|
||||
var d = new Date(Number(occurredAt) * 1000);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function buildBadge(sev) {
|
||||
var info = severityInfo(sev);
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'severity-badge ' + info.cls;
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = info.icon;
|
||||
var label = document.createElement('span');
|
||||
label.className = 'label';
|
||||
label.textContent = info.text;
|
||||
badge.appendChild(icon);
|
||||
badge.appendChild(label);
|
||||
return badge;
|
||||
}
|
||||
|
||||
// --- desk audio alert: repeating beep while any unacknowledged critical exists ---
|
||||
// No binary asset (air-gapped friendly): a WebAudio oscillator tone. The browser
|
||||
// blocks audio until a user gesture, so the context is created on first click.
|
||||
var audioCtx = null;
|
||||
var alarmTimer = null;
|
||||
var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately
|
||||
|
||||
function unlockAudio() {
|
||||
if (!audioCtx) {
|
||||
var AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (AC) audioCtx = new AC();
|
||||
}
|
||||
document.removeEventListener('click', unlockAudio);
|
||||
}
|
||||
|
||||
function beep() {
|
||||
if (!audioCtx) return;
|
||||
try {
|
||||
var osc = audioCtx.createOscillator();
|
||||
var gain = audioCtx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = 880;
|
||||
gain.gain.value = 0.2;
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.4);
|
||||
} catch (e) {
|
||||
// audio unavailable; the visual list still surfaces the critical event
|
||||
}
|
||||
}
|
||||
|
||||
function updateAlarm(events) {
|
||||
var hasUnackedCritical = false;
|
||||
events.forEach(function (ev) {
|
||||
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||||
if (ev.severity === 'critical' && !acked) {
|
||||
hasUnackedCritical = true;
|
||||
if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); }
|
||||
}
|
||||
});
|
||||
if (hasUnackedCritical && !alarmTimer) {
|
||||
alarmTimer = setInterval(beep, 3000);
|
||||
} else if (!hasUnackedCritical && alarmTimer) {
|
||||
clearInterval(alarmTimer);
|
||||
alarmTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRow(ev) {
|
||||
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||||
|
||||
// sev-row-* drives the severity-colored left rail (and the critical glow) on
|
||||
// unacknowledged rows. Constrain to the known tiers so the class can't carry an
|
||||
// arbitrary server value into the DOM.
|
||||
var sevRow = SEVERITY_LABELS.hasOwnProperty(ev.severity) ? ev.severity : 'info';
|
||||
var row = document.createElement('div');
|
||||
row.className = 'event-row sev-row-' + sevRow + (isAcked ? '' : ' is-unacknowledged');
|
||||
|
||||
// Evidence thumbnail (newest snapshot for this event), when one exists. The <img> goes
|
||||
// through /snap/{uuid}, which re-checks session + site scope, so the uuid grants nothing.
|
||||
if (ev.thumb_uuid) {
|
||||
var thumb = document.createElement('img');
|
||||
thumb.className = 'event-thumb';
|
||||
thumb.src = B + '/snap/' + encodeURIComponent(ev.thumb_uuid);
|
||||
thumb.alt = '스냅샷';
|
||||
thumb.loading = 'lazy';
|
||||
// A snapshot can be purged (PIPA retention) after the row was fetched; drop the <img>
|
||||
// rather than leave a broken-image box.
|
||||
thumb.onerror = function () { if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
|
||||
row.appendChild(thumb);
|
||||
}
|
||||
|
||||
row.appendChild(buildBadge(ev.severity));
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'event-meta';
|
||||
|
||||
var type = document.createElement('div');
|
||||
type.className = 'event-type';
|
||||
var typeLink = document.createElement('a');
|
||||
typeLink.className = 'event-detail-link';
|
||||
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
|
||||
typeLink.textContent = String(ev.event_type || '');
|
||||
type.appendChild(typeLink);
|
||||
// The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip —
|
||||
// secondary encoding alongside the red tint, so it never rides on color alone.
|
||||
if (!isAcked && ev.severity === 'critical') {
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.textContent = '확인 필요';
|
||||
type.appendChild(chip);
|
||||
}
|
||||
meta.appendChild(type);
|
||||
|
||||
var sub = document.createElement('div');
|
||||
sub.className = 'event-time';
|
||||
var cam = ev.camera_id ? String(ev.camera_id) : '';
|
||||
var time = formatTime(ev.occurred_at);
|
||||
sub.textContent = [cam, time].filter(Boolean).join(' · ');
|
||||
meta.appendChild(sub);
|
||||
|
||||
row.appendChild(meta);
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'ack-btn';
|
||||
if (isAcked) {
|
||||
btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
btn.textContent = '확인';
|
||||
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
|
||||
}
|
||||
row.appendChild(btn);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function acknowledge(eventId, btn) {
|
||||
btn.disabled = true;
|
||||
fetch(B + '/events/' + encodeURIComponent(eventId) + '/ack', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('ack_failed:' + res.status);
|
||||
// Reflect the ack on a "더 보기"-loaded (older-window) row too. loadEvents() only refreshes
|
||||
// the live window, so without this an event acked from the paged tail snaps straight back
|
||||
// to unacknowledged on the next render — inviting a duplicate ack that overwrites the
|
||||
// original acknowledged_by audit record.
|
||||
patchOlderAck(eventId);
|
||||
return loadEvents();
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function patchOlderAck(eventId) {
|
||||
for (var i = 0; i < olderEvents.length; i++) {
|
||||
var ev = olderEvents[i];
|
||||
if (ev && ev.event_id === eventId && !isAcked(ev)) {
|
||||
// New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
|
||||
olderEvents[i] = Object.assign({}, ev, { acknowledged_by: window.APP_USER || '확인' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status strip: count UNACKNOWLEDGED events per severity from the same array the
|
||||
// list renders. Stat tiles (not a chart): small integers a desk operator scans.
|
||||
// Every tier tile is always drawn (zero -> muted) so the strip never reflows.
|
||||
var statusRoot = document.getElementById('events-status');
|
||||
var STATUS_TIERS = [
|
||||
{ key: 'critical', cls: 'tile-critical' },
|
||||
{ key: 'high', cls: 'tile-high' },
|
||||
{ key: 'warning', cls: 'tile-warning' },
|
||||
{ key: 'info', cls: 'tile-info' }
|
||||
];
|
||||
|
||||
function isAcked(ev) {
|
||||
return ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||||
}
|
||||
|
||||
function renderStatus(events) {
|
||||
if (!statusRoot) return;
|
||||
var counts = { critical: 0, high: 0, warning: 0, info: 0 };
|
||||
events.forEach(function (ev) {
|
||||
if (isAcked(ev)) return;
|
||||
var sev = counts.hasOwnProperty(ev.severity) ? ev.severity : 'info';
|
||||
counts[sev] += 1;
|
||||
});
|
||||
|
||||
statusRoot.textContent = '';
|
||||
var frag = document.createDocumentFragment();
|
||||
STATUS_TIERS.forEach(function (tier) {
|
||||
var info = severityInfo(tier.key);
|
||||
var n = counts[tier.key];
|
||||
|
||||
var tile = document.createElement('div');
|
||||
tile.className = 'stat-tile ' + tier.cls + (n === 0 ? ' is-zero' : '');
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'stat-label';
|
||||
var led = document.createElement('span');
|
||||
led.className = 'led';
|
||||
led.setAttribute('aria-hidden', 'true');
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = info.icon;
|
||||
var text = document.createElement('span');
|
||||
text.textContent = '미확인 ' + info.text;
|
||||
label.appendChild(led);
|
||||
label.appendChild(icon);
|
||||
label.appendChild(text);
|
||||
|
||||
var count = document.createElement('span');
|
||||
count.className = 'stat-count';
|
||||
count.textContent = String(n);
|
||||
|
||||
tile.appendChild(label);
|
||||
tile.appendChild(count);
|
||||
frag.appendChild(tile);
|
||||
});
|
||||
statusRoot.appendChild(frag);
|
||||
}
|
||||
|
||||
// --- filter + pagination state ---------------------------------------------------
|
||||
// Filters are CLIENT-SIDE (they narrow only the displayed list); the status strip and the
|
||||
// audible alarm keep considering the full live set, so filtering the view can never silence a
|
||||
// real unacknowledged critical. Pagination is SERVER-SIDE (keyset cursor): "더 보기" pulls
|
||||
// events older than the live window into an accumulated tail that the 5s poll does not wipe.
|
||||
var liveEvents = []; // newest window from the poll (drives status + alarm)
|
||||
var olderEvents = []; // events pulled in by "더 보기", older than the live window
|
||||
var moreCursor = null; // cursor for the NEXT "더 보기" (null = no older page)
|
||||
var pagedByUser = false; // once true, polls stop resetting moreCursor to the live boundary
|
||||
var newestIdAtPaging = null; // newest live event_id at the moment paging began (gap detection)
|
||||
var pageGapHint = null; // lazily-built "new events arrived while paging" reload affordance
|
||||
var filters = { severity: '', camera: '', unackedOnly: false };
|
||||
var cameraSel = document.getElementById('camera-filter');
|
||||
var moreBtn = document.getElementById('load-more');
|
||||
|
||||
function matchesFilters(ev) {
|
||||
if (filters.severity && ev.severity !== filters.severity) return false;
|
||||
if (filters.camera && String(ev.camera_id || '') !== filters.camera) return false;
|
||||
if (filters.unackedOnly && isAcked(ev)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function combinedEvents() {
|
||||
var seen = {}, out = [];
|
||||
liveEvents.concat(olderEvents).forEach(function (ev) {
|
||||
if (seen[ev.event_id]) return; // dedup: a poll may overlap the paged tail by an event
|
||||
seen[ev.event_id] = true;
|
||||
out.push(ev);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Populate the camera dropdown from the camera_ids actually present, rebuilding only when that
|
||||
// set changes so a poll never resets the operator's current selection.
|
||||
function refreshCameraOptions(events) {
|
||||
if (!cameraSel) return;
|
||||
var cams = {};
|
||||
events.forEach(function (ev) { if (ev.camera_id) cams[String(ev.camera_id)] = true; });
|
||||
var list = Object.keys(cams).sort();
|
||||
var want = list.join('|');
|
||||
if (cameraSel.getAttribute('data-opts') === want) return;
|
||||
cameraSel.setAttribute('data-opts', want);
|
||||
var current = cameraSel.value;
|
||||
cameraSel.textContent = '';
|
||||
var all = document.createElement('option');
|
||||
all.value = ''; all.textContent = '전체 카메라';
|
||||
cameraSel.appendChild(all);
|
||||
list.forEach(function (c) {
|
||||
var o = document.createElement('option');
|
||||
o.value = c; o.textContent = c;
|
||||
cameraSel.appendChild(o);
|
||||
});
|
||||
// Preserve the operator's active camera filter even if that camera has no event in the current
|
||||
// window. Silently resetting to "전체 카메라" floods the list with other cameras' events, which
|
||||
// reads as the filtered camera's activity. Keep the selection as an option so the filter stays
|
||||
// applied (the list then honestly shows "none" for a quiet camera).
|
||||
if (current && !cams[current]) {
|
||||
var keep = document.createElement('option');
|
||||
keep.value = current; keep.textContent = current + ' (최근 이벤트 없음)';
|
||||
cameraSel.appendChild(keep);
|
||||
}
|
||||
cameraSel.value = current; // always preserve the selection ('' = 전체 카메라 is preserved too)
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
renderStatus(liveEvents); // counts always from the full live window, never the filtered view
|
||||
updateAlarm(liveEvents); // alarm too — before any early return
|
||||
|
||||
var all = combinedEvents();
|
||||
refreshCameraOptions(all);
|
||||
|
||||
var shown = all.filter(matchesFilters).sort(function (a, b) {
|
||||
var aA = isAcked(a), bA = isAcked(b);
|
||||
if (aA !== bA) return aA ? 1 : -1; // unacknowledged first
|
||||
var at = Number(a.occurred_at) || 0, bt = Number(b.occurred_at) || 0;
|
||||
if (at !== bt) return bt - at; // then newest first
|
||||
return (b.event_id || 0) - (a.event_id || 0);
|
||||
});
|
||||
|
||||
root.textContent = '';
|
||||
root.removeAttribute('aria-busy');
|
||||
if (shown.length === 0) {
|
||||
var anyFilter = filters.severity || filters.camera || filters.unackedOnly;
|
||||
root.textContent = anyFilter ? '조건에 맞는 이벤트가 없습니다.' : '표시할 이벤트가 없습니다.';
|
||||
} else {
|
||||
var frag = document.createDocumentFragment();
|
||||
shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
|
||||
root.appendChild(frag);
|
||||
}
|
||||
if (moreBtn) moreBtn.hidden = (moreCursor === null);
|
||||
updatePageGapHint();
|
||||
}
|
||||
|
||||
function updatePageGapHint() {
|
||||
// A gap exists once new events have arrived since the operator paged: some events have slid out
|
||||
// of the live window into a band that was never fetched. Surface it honestly with a reload,
|
||||
// rather than let the operator scroll a timeline with a silent hole in the middle.
|
||||
var gap = pagedByUser && liveEvents.length > 0 && newestIdAtPaging !== null
|
||||
&& (liveEvents[0].event_id || 0) > newestIdAtPaging;
|
||||
if (!pageGapHint) {
|
||||
pageGapHint = document.createElement('div');
|
||||
pageGapHint.className = 'page-gap-hint';
|
||||
var span = document.createElement('span');
|
||||
span.textContent = '페이징 이후 새 이벤트가 도착해 목록 일부가 밀렸을 수 있습니다.';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = '새로고침';
|
||||
btn.addEventListener('click', function () { window.location.reload(); });
|
||||
pageGapHint.appendChild(span);
|
||||
pageGapHint.appendChild(btn);
|
||||
if (root.parentNode) root.parentNode.insertBefore(pageGapHint, root); // sibling above the list, survives root clears
|
||||
}
|
||||
pageGapHint.hidden = !gap;
|
||||
}
|
||||
|
||||
function loadEvents() {
|
||||
// Newest window (server MAX_LIMIT=200). The audible alarm and the "unacknowledged first"
|
||||
// pinning only consider this set, so it stays the full cap — during an event burst a smaller
|
||||
// page could push an unacknowledged critical off it and updateAlarm() would clear the alarm
|
||||
// while a fall is still unacknowledged.
|
||||
return fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) {
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||
if (!res.ok) throw new Error('list_failed:' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data && Array.isArray(data.events)) {
|
||||
liveEvents = data.events;
|
||||
// Track the live boundary as the next-page cursor only until the operator starts paging;
|
||||
// after that "더 보기" advances moreCursor itself and a poll must not reset it.
|
||||
if (!pagedByUser) moreCursor = data.next_cursor || null;
|
||||
renderList();
|
||||
}
|
||||
setWatch(true);
|
||||
})
|
||||
.catch(function () {
|
||||
// Leave the last rendered state on transient poll failure, but flag the staleness.
|
||||
setWatch(false);
|
||||
});
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (moreCursor === null) return;
|
||||
if (moreBtn) { moreBtn.disabled = true; moreBtn.textContent = '불러오는 중...'; }
|
||||
fetch(B + '/api/events?limit=200&cursor=' + encodeURIComponent(moreCursor), { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) {
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||
if (!res.ok) throw new Error('more_failed:' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data && Array.isArray(data.events)) {
|
||||
// Capture the newest live id at the moment paging begins. If newer events arrive after
|
||||
// this, the live window (newest 200) slides forward and events between its new bottom and
|
||||
// the frozen paged boundary belong to neither array — combinedEvents() drops them
|
||||
// silently. We cannot re-fetch that middle band cheaply, so we at least SURFACE it: the
|
||||
// gap hint (renderList) tells the operator to reload for the complete timeline.
|
||||
if (!pagedByUser) newestIdAtPaging = (liveEvents[0] && liveEvents[0].event_id) || 0;
|
||||
pagedByUser = true;
|
||||
olderEvents = olderEvents.concat(data.events);
|
||||
moreCursor = data.next_cursor || null;
|
||||
renderList();
|
||||
}
|
||||
})
|
||||
.catch(function () { /* leave state; the button re-enables below */ })
|
||||
.then(function () {
|
||||
if (moreBtn) { moreBtn.disabled = false; moreBtn.textContent = '더 보기'; }
|
||||
});
|
||||
}
|
||||
|
||||
// Logout is wired in the shared layout footer (src/View/layout.php) so it works on every page,
|
||||
// not just this one — app.js bails early on pages without #events-root.
|
||||
|
||||
// --- device / camera health panel -------------------------------------------------
|
||||
// GET /api/devices surfaces the heartbeat telemetry the edge already reports (online/offline,
|
||||
// fps, temp, throttle, config drift). A dead camera is a safety blind spot, so this is the
|
||||
// panel that turns "the board looks calm" into "the board is actually watching".
|
||||
var devicesRoot = document.getElementById('devices-root');
|
||||
|
||||
function fmtAgo(secs) {
|
||||
if (secs === null || secs === undefined) return '응답 없음';
|
||||
if (secs < 60) return secs + '초 전';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + '분 전';
|
||||
return Math.floor(secs / 3600) + '시간 전';
|
||||
}
|
||||
|
||||
function renderDevices(devices) {
|
||||
if (!devicesRoot) return;
|
||||
devicesRoot.textContent = '';
|
||||
if (!devices || devices.length === 0) {
|
||||
devicesRoot.className = 'device-strip is-empty';
|
||||
devicesRoot.textContent = '등록된 장치가 없습니다.';
|
||||
return;
|
||||
}
|
||||
devicesRoot.className = 'device-strip';
|
||||
var frag = document.createDocumentFragment();
|
||||
devices.forEach(function (d) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'device-card' + (d.online ? '' : ' is-offline');
|
||||
|
||||
var name = document.createElement('div');
|
||||
name.className = 'device-name';
|
||||
var dot = document.createElement('span');
|
||||
dot.className = 'dot';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
var nm = document.createElement('span');
|
||||
nm.textContent = d.name || d.device_id; // never color alone: the "오프라인" text below carries state too
|
||||
name.appendChild(dot);
|
||||
name.appendChild(nm);
|
||||
card.appendChild(name);
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'device-meta';
|
||||
var parts = [];
|
||||
if (d.online) parts.push(fmtAgo(d.seconds_since));
|
||||
else if (d.seconds_since === null) parts.push('응답 없음');
|
||||
else parts.push('오프라인 · ' + fmtAgo(d.seconds_since));
|
||||
if (d.fps !== null && d.fps !== undefined) parts.push((Math.round(d.fps * 10) / 10) + ' fps');
|
||||
if (d.temp !== null && d.temp !== undefined && d.temp > 0) parts.push(Math.round(d.temp) + '°');
|
||||
meta.textContent = parts.join(' · ');
|
||||
card.appendChild(meta);
|
||||
|
||||
if (d.throttle || d.config_drift) {
|
||||
var warn = document.createElement('div');
|
||||
warn.className = 'device-warn';
|
||||
var w = [];
|
||||
if (d.throttle) w.push('과열 스로틀');
|
||||
if (d.config_drift) w.push('설정 미적용');
|
||||
warn.textContent = w.join(' · ');
|
||||
card.appendChild(warn);
|
||||
}
|
||||
frag.appendChild(card);
|
||||
});
|
||||
devicesRoot.appendChild(frag);
|
||||
}
|
||||
|
||||
function markDevicesStale() {
|
||||
// The device panel's whole job is a trustworthy online/offline verdict, so a silently frozen
|
||||
// panel (poll 500 / WAF-blocked while events still flow) is worse than an empty one: a camera
|
||||
// that dies afterwards keeps showing "온라인". Flag the staleness instead of hiding it. Cleared
|
||||
// automatically on the next successful poll (renderDevices resets the class + note).
|
||||
if (!devicesRoot) return;
|
||||
devicesRoot.classList.add('is-stale');
|
||||
if (!devicesRoot.querySelector('.device-stale-note')) {
|
||||
var note = document.createElement('div');
|
||||
note.className = 'device-stale-note';
|
||||
note.textContent = '상태 갱신 지연 — 아래는 마지막으로 확인된 상태입니다';
|
||||
devicesRoot.insertBefore(note, devicesRoot.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function loadDevices() {
|
||||
return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) {
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||
if (!res.ok) throw new Error('devices_failed:' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) { if (data && Array.isArray(data.devices)) renderDevices(data.devices); })
|
||||
.catch(function () { markDevicesStale(); }); // keep the last state, but say it is stale
|
||||
}
|
||||
|
||||
document.addEventListener('click', unlockAudio);
|
||||
|
||||
// Filter bar (client-side) + "더 보기" (server-side cursor).
|
||||
var sevSel = document.getElementById('severity-filter');
|
||||
if (sevSel) sevSel.addEventListener('change', function () { filters.severity = sevSel.value; renderList(); });
|
||||
if (cameraSel) cameraSel.addEventListener('change', function () { filters.camera = cameraSel.value; renderList(); });
|
||||
var unackedChk = document.getElementById('unacked-only');
|
||||
if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); });
|
||||
if (moreBtn) moreBtn.addEventListener('click', loadMore);
|
||||
|
||||
loadCsrfToken();
|
||||
loadEvents();
|
||||
loadDevices();
|
||||
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
|
||||
})();
|
||||
|
|
@ -10,6 +10,10 @@
|
|||
(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' },
|
||||
|
|
@ -97,7 +101,7 @@
|
|||
snaps.forEach(function (s) {
|
||||
if (!s || !s.file_uuid) return;
|
||||
var img = document.createElement('img');
|
||||
img.src = '/snap/' + encodeURIComponent(s.file_uuid);
|
||||
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||||
img.alt = '사고 스냅샷';
|
||||
img.className = 'detail-snapshot';
|
||||
img.loading = 'lazy';
|
||||
|
|
@ -105,9 +109,9 @@
|
|||
});
|
||||
}
|
||||
|
||||
fetch('/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||||
fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) {
|
||||
if (res.status === 401) { window.location.href = '/login'; return null; }
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||
if (!res.ok) throw new Error('detail_failed:' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
|
|
@ -8,6 +8,10 @@
|
|||
(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;
|
||||
|
||||
|
|
@ -26,7 +30,7 @@
|
|||
var formData = new FormData(form);
|
||||
var body = new URLSearchParams(formData).toString();
|
||||
|
||||
fetch('/login', {
|
||||
fetch(B + '/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body
|
||||
|
|
@ -34,7 +38,7 @@
|
|||
.then(function (res) {
|
||||
if (res.status === 200) {
|
||||
// Success: redirect to dashboard
|
||||
window.location.href = '/';
|
||||
window.location.href = B + '/';
|
||||
return null;
|
||||
}
|
||||
// Error: parse response and show error message
|
||||
|
|
@ -4,15 +4,22 @@
|
|||
* - 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.
|
||||
* - 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.
|
||||
* - "현재 화면 불러오기" 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.
|
||||
*
|
||||
* 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');
|
||||
|
|
@ -21,6 +28,7 @@
|
|||
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');
|
||||
|
|
@ -42,16 +50,23 @@
|
|||
}
|
||||
|
||||
function loadCsrfToken() {
|
||||
return fetch('/api/csrf', { headers: { Accept: 'application/json' } })
|
||||
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) { return res.ok ? res.json() : null; })
|
||||
.then(function (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 || '[]');
|
||||
return Array.isArray(pts) ? pts : [];
|
||||
if (!Array.isArray(pts)) return [];
|
||||
return pts.map(function (p) {
|
||||
return [p[0] * canvas.width, p[1] * canvas.height];
|
||||
});
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -116,16 +131,32 @@
|
|||
var img = new Image();
|
||||
img.onload = function () { backgroundImage = img; redraw(); };
|
||||
img.onerror = function () { backgroundImage = null; redraw(); };
|
||||
img.src = '/snap/' + encodeURIComponent(uuid);
|
||||
img.src = B + '/snap/' + encodeURIComponent(uuid);
|
||||
}
|
||||
|
||||
// Load the camera's most recent frame as the drawing background. The uuid comes from the
|
||||
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
|
||||
function loadLatestFrame() {
|
||||
var camera = cameraInput.value.trim();
|
||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||
setStatus('현재 화면을 불러오는 중...');
|
||||
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) throw new Error('이 카메라의 저장된 화면이 아직 없습니다. 이벤트가 한 번 발생해야 배경이 생깁니다.');
|
||||
if (!r.ok) throw new Error('불러오기 실패 (' + r.status + ')');
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) { loadBackground(data.uuid); setStatus('현재 화면을 배경으로 불러왔습니다.'); })
|
||||
.catch(function (e) { setStatus(e.message); });
|
||||
}
|
||||
|
||||
function loadZones() {
|
||||
var camera = cameraInput.value.trim();
|
||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||
setStatus('불러오는 중...');
|
||||
fetch('/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
||||
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) {
|
||||
if (res.status === 401) { window.location.href = '/login'; return null; }
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||
if (!res.ok) throw new Error('load_failed:' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
|
|
@ -148,18 +179,22 @@
|
|||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
||||
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
|
||||
setStatus('저장 중...');
|
||||
fetch('/api/zones', {
|
||||
fetch(B + '/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,
|
||||
polygon: currentPoints
|
||||
// Normalize canvas pixels -> 0..1 of the image (the edge un-normalizes with the
|
||||
// camera's own resolution). See the parsePolygon contract note above.
|
||||
polygon: currentPoints.map(function (p) {
|
||||
return [p[0] / canvas.width, p[1] / canvas.height];
|
||||
})
|
||||
})
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 401) { window.location.href = '/login'; return null; }
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||
if (!res.ok) throw new Error('save_failed:' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
|
|
@ -173,12 +208,12 @@
|
|||
}
|
||||
|
||||
function deleteZone(zoneId) {
|
||||
fetch('/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
|
||||
fetch(B + '/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return; }
|
||||
if (!res.ok) throw new Error('delete_failed:' + res.status);
|
||||
loadZones();
|
||||
})
|
||||
|
|
@ -197,6 +232,7 @@
|
|||
|
||||
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);
|
||||
|
||||
126
server/public/index.php
Normal file
126
server/public/index.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// The app root — the directory holding config.php, src/, vendor/ and storage/ — is found by
|
||||
// walking up from this file rather than hardcoded as '/..'. Its depth genuinely varies:
|
||||
// one level up when the docroot is the app's own directory, but two when the app is mounted
|
||||
// in a subdirectory of a www/ that already serves another site, since nothing secret may live
|
||||
// under a docroot that other site also serves. On shared hosting the private files are kept
|
||||
// together in a single APP_PRIVATE_DIR beside www/, so that name is probed at every level.
|
||||
//
|
||||
// Two deliberate choices guard against binding to the CO-HOSTED site's files:
|
||||
// * the container is probed BEFORE the bare directory, so our own root always wins;
|
||||
// * all three markers must be present. config.php + vendor/ alone are ordinary names in any
|
||||
// PHP project sharing this account — src/Config.php is specific to this app.
|
||||
const APP_PRIVATE_DIR = 'move_detect_app';
|
||||
(static function (): void {
|
||||
$isAppRoot = static fn (string $d): bool =>
|
||||
is_file($d . '/config.php')
|
||||
&& is_file($d . '/vendor/autoload.php')
|
||||
&& is_file($d . '/src/Config.php');
|
||||
|
||||
for ($dir = dirname(__DIR__), $i = 0; $i < 4; $i++) {
|
||||
foreach ([$dir . '/' . APP_PRIVATE_DIR, $dir] as $candidate) {
|
||||
if ($isAppRoot($candidate)) {
|
||||
define('APP_ROOT', $candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dirname($dir) === $dir) break; // filesystem root; dirname() would spin here
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
http_response_code(500);
|
||||
exit('config.php / vendor/autoload.php / src/Config.php not found above ' . __DIR__);
|
||||
})();
|
||||
|
||||
require APP_ROOT . '/vendor/autoload.php';
|
||||
|
||||
use App\Config;
|
||||
use App\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,
|
||||
DeviceStatusController};
|
||||
|
||||
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
|
||||
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
|
||||
// and convert any uncaught Throwable into a generic 500 that is logged, not shown. (A .user.ini
|
||||
// in the docroot backstops this for errors raised before this file even parses.)
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
set_exception_handler(static function (\Throwable $e): void {
|
||||
error_log('move-detect: ' . $e);
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
echo '{"error":"internal"}';
|
||||
});
|
||||
|
||||
// The URL prefix this app is mounted under: '' at the web root, '/move_detect' in a
|
||||
// subdirectory. Server-derived (SCRIPT_NAME), never client input. Views and the JS bundles
|
||||
// prefix every link/fetch with it; without it they'd address the other site sharing www/.
|
||||
define('APP_BASE', Request::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? '')));
|
||||
|
||||
$cfg = Config::fromArray(require APP_ROOT . '/config.php');
|
||||
$pdo = Database::connect($cfg);
|
||||
// Scope the session cookie to the mount, so a co-hosted site under the same www/ is never
|
||||
// sent this app's session id.
|
||||
$sess = new PhpSessionStore(APP_BASE . '/');
|
||||
$now = time();
|
||||
|
||||
$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, $rate);
|
||||
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
|
||||
$auth = new AuthController($users, $audit, $rate);
|
||||
$csrfCtl = new CsrfController();
|
||||
$list = new EventListController($events, $snaps, $sess);
|
||||
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
||||
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
||||
$deviceStatus = new DeviceStatusController($devices, $sess);
|
||||
|
||||
$render = function (string $view): Response {
|
||||
ob_start(); require APP_ROOT . '/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, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
|
||||
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
|
||||
$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', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
|
||||
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
|
||||
$r->add('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));
|
||||
// Background frame for the zone editor: the newest snapshot this camera produced.
|
||||
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
|
||||
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
|
||||
$r->add('POST', '/api/zones/{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 users (
|
||||
CREATE TABLE move_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,9 +55,10 @@ CREATE TABLE 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 devices (
|
||||
CREATE TABLE move_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,
|
||||
|
|
@ -66,7 +67,8 @@ CREATE TABLE 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)
|
||||
PRIMARY KEY (device_id),
|
||||
KEY ix_devices_site (site)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -75,7 +77,7 @@ CREATE TABLE 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 cameras (
|
||||
CREATE TABLE move_cameras (
|
||||
camera_id VARCHAR(64) NOT NULL,
|
||||
device_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(190) NULL,
|
||||
|
|
@ -92,7 +94,7 @@ CREATE TABLE 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 zones (
|
||||
CREATE TABLE move_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,
|
||||
|
|
@ -110,9 +112,11 @@ CREATE TABLE zones (
|
|||
-- App\Ingest\EventValidator before insert(); the CHECK constraints below are
|
||||
-- defense-in-depth only, not the primary validation layer.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE events (
|
||||
CREATE TABLE move_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,
|
||||
|
|
@ -125,9 +129,15 @@ CREATE TABLE 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'
|
||||
|
|
@ -141,7 +151,7 @@ CREATE TABLE events (
|
|||
-- events is safe because IngestController always inserts the parent event row
|
||||
-- before calling SnapshotRepository::add() for its snapshots.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE event_snapshots (
|
||||
CREATE TABLE move_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
|
||||
|
|
@ -149,8 +159,13 @@ CREATE TABLE 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),
|
||||
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (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)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
|
@ -159,7 +174,7 @@ CREATE TABLE 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 thresholds (
|
||||
CREATE TABLE move_thresholds (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
device_id VARCHAR(64) NOT NULL,
|
||||
camera_id VARCHAR(64) NULL,
|
||||
|
|
@ -180,11 +195,11 @@ CREATE TABLE thresholds (
|
|||
-- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard,
|
||||
-- not the earlier seen() check).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE nonces (
|
||||
CREATE TABLE move_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 nonces WHERE expires_at < ?` cleanup job
|
||||
KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM move_nonces WHERE expires_at < ?` cleanup job
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -193,11 +208,14 @@ CREATE TABLE nonces (
|
|||
-- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in
|
||||
-- PdoRateLimiterRepository::hit() -- no read-modify-write race window.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE rate_limits (
|
||||
CREATE TABLE move_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)
|
||||
PRIMARY KEY (rate_key, window_start),
|
||||
-- The reap `DELETE ... WHERE window_start < ?` cannot use the PK (window_start is its trailing
|
||||
-- column), so give it its own index to avoid a locking full-scan on the pre-auth hot path.
|
||||
KEY ix_rate_limits_window (window_start)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -205,7 +223,7 @@ CREATE TABLE 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 audit_log (
|
||||
CREATE TABLE move_audit_log (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
actor VARCHAR(190) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
|
|
@ -217,3 +235,25 @@ CREATE TABLE 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,17 +5,23 @@ namespace App\Api;
|
|||
use App\Config;
|
||||
use App\Http\{Request, Response};
|
||||
use App\Security\Hmac;
|
||||
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository};
|
||||
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
|
||||
|
||||
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): Response
|
||||
public function get(Request $req, int $now, string $clientIp = ''): 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') ?? '';
|
||||
51
server/src/Api/HeartbeatController.php
Normal file
51
server/src/Api/HeartbeatController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?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, RateLimiterRepository};
|
||||
|
||||
final class HeartbeatController
|
||||
{
|
||||
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces,
|
||||
private ?RateLimiterRepository $rateLimiter = null) {}
|
||||
|
||||
public function post(Request $req, int $now, string $clientIp = ''): Response
|
||||
{
|
||||
// Pre-auth per-IP anti-flood, like /api/events — an unauthenticated attacker must not
|
||||
// be able to hammer this endpoint (each hit does HMAC + a DB write) unthrottled.
|
||||
if ($this->rateLimiter !== null && $clientIp !== ''
|
||||
&& $this->rateLimiter->hit('ip:' . $clientIp, intdiv($now, 60)) > $this->cfg->ingestIpRateLimitPerMin()) {
|
||||
return Response::json(['error' => 'rate_limited'], 429);
|
||||
}
|
||||
$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());
|
||||
|
||||
// config_version is null when the device did not report one (older edge builds). Storing a
|
||||
// fake 0 made the dashboard's config-drift check (reported !== published) fire on EVERY
|
||||
// device forever, since published versions start at 1. Keep it null until a real version
|
||||
// arrives so drift only ever means genuine drift.
|
||||
$reportedVersion = isset($b['config_version']) ? (int)$b['config_version'] : null;
|
||||
$this->devices->touchHeartbeat($deviceId, [
|
||||
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
|
||||
'throttle' => (bool)($b['throttle'] ?? false),
|
||||
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
|
||||
'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;
|
||||
use App\Security\{Password, Csrf};
|
||||
use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository};
|
||||
|
||||
final class AuthController
|
||||
|
|
@ -28,6 +28,14 @@ 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);
|
||||
|
|
@ -67,8 +75,11 @@ final class AuthController
|
|||
|
||||
public function logout(SessionStore $sess): Response
|
||||
{
|
||||
$sess->clear();
|
||||
// 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();
|
||||
return Response::json(['ok' => true], 200);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,15 @@ final class ArraySessionStore implements SessionStore {
|
|||
}
|
||||
|
||||
final class PhpSessionStore implements SessionStore {
|
||||
public function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) session_start([
|
||||
/** @param string $cookiePath Scope the cookie to the mount ('/' at the web root,
|
||||
* '/move_detect/' in a subdirectory) so a co-hosted site
|
||||
* sharing the same www/ is never sent this session id. */
|
||||
public function __construct(string $cookiePath = '/') {
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start([
|
||||
'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax',
|
||||
]); }
|
||||
'cookie_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,4 +16,5 @@ 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); }
|
||||
}
|
||||
59
server/src/Dashboard/DeviceStatusController.php
Normal file
59
server/src/Dashboard/DeviceStatusController.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Dashboard;
|
||||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Auth\SessionStore;
|
||||
use App\Repository\DeviceRepository;
|
||||
|
||||
/**
|
||||
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
|
||||
* reports fps/temp/throttle/config_version via POST /api/heartbeat (touchHeartbeat); this only
|
||||
* surfaces what is already stored, so it adds no new data pipeline. Session-guarded and
|
||||
* site-scoped exactly like the event list: a scoped operator sees only their site's devices.
|
||||
*
|
||||
* The one derived field is liveness: a device is "online" if it sent a heartbeat within
|
||||
* OFFLINE_AFTER_S. That is computed server-side against the request clock so a client with a
|
||||
* wrong local time cannot mislabel a live camera as down (or a dead one as up) — a dead camera
|
||||
* is a safety blind spot, so this verdict has to be trustworthy.
|
||||
*/
|
||||
final class DeviceStatusController
|
||||
{
|
||||
// ~2 minutes without a heartbeat = offline. The edge beats on a fixed 30s WALL-CLOCK cadence
|
||||
// (daemon.run_loop heartbeat_interval_s), not an iteration count, so this is a 4x margin: three
|
||||
// beats can be dropped (server hiccup, a failed POST) before a healthy device flaps offline,
|
||||
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
|
||||
private const OFFLINE_AFTER_S = 120;
|
||||
|
||||
public function __construct(private DeviceRepository $devices, private SessionStore $sess) {}
|
||||
|
||||
public function json(Request $req, int $now): Response
|
||||
{
|
||||
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||
|
||||
// Same scoping rule as EventListController: only a genuinely null scope is global; an
|
||||
// empty-string scope is a misconfiguration that must match nothing (fail closed).
|
||||
$scope = $this->sess->get('site_scope');
|
||||
$site = is_string($scope) ? $scope : null;
|
||||
|
||||
$devices = array_map(function (array $d) use ($now): array {
|
||||
$hb = $d['last_heartbeat_at'];
|
||||
$secondsSince = $hb !== null ? max(0, $now - $hb) : null;
|
||||
$online = $secondsSince !== null && $secondsSince <= self::OFFLINE_AFTER_S;
|
||||
return [
|
||||
'device_id' => $d['device_id'],
|
||||
'name' => $d['name'],
|
||||
'online' => $online,
|
||||
'seconds_since'=> $secondsSince, // null = never reported
|
||||
'fps' => $d['last_fps'],
|
||||
'temp' => $d['last_temp'],
|
||||
'throttle' => $d['last_throttle'],
|
||||
// The device is running an older config than the server has published for it.
|
||||
'config_drift' => $d['reported_config_version'] !== null
|
||||
&& $d['reported_config_version'] !== $d['config_version'],
|
||||
];
|
||||
}, $this->devices->listStatus($site));
|
||||
|
||||
return Response::json(['devices' => $devices], 200);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ namespace App\Dashboard;
|
|||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Auth\{SessionStore, Rbac};
|
||||
use App\Security\Csrf;
|
||||
use App\Security\{Csrf, SiteScope};
|
||||
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
||||
|
||||
final class EventDetailController
|
||||
|
|
@ -14,12 +14,20 @@ 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) return Response::json(['error' => 'not_found'], 404);
|
||||
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
|
||||
return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200);
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +39,10 @@ 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", [], []);
|
||||
75
server/src/Dashboard/EventListController.php
Normal file
75
server/src/Dashboard/EventListController.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Dashboard;
|
||||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Auth\SessionStore;
|
||||
use App\Repository\{EventRepository, SnapshotRepository};
|
||||
|
||||
final class EventListController
|
||||
{
|
||||
private const DEFAULT_LIMIT = 50;
|
||||
private const MAX_LIMIT = 200;
|
||||
|
||||
public function __construct(private EventRepository $events, private SnapshotRepository $snaps, 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');
|
||||
$limit = self::clampLimit($req->query('limit'));
|
||||
[$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor'));
|
||||
// Site-scope access control (P3-2b): a scoped operator sees only their site's events; a
|
||||
// null/empty scope is a global operator (no site filter). Pushed into the SQL WHERE.
|
||||
// Pass the scope through as-is: only a genuinely null scope is global (no filter). An
|
||||
// empty-string scope is a misconfiguration that must match NOTHING (fail closed), not be
|
||||
// silently promoted to global by collapsing it to null here.
|
||||
$scope = $this->sess->get('site_scope');
|
||||
$site = is_string($scope) ? $scope : null;
|
||||
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site);
|
||||
|
||||
// Attach a thumbnail uuid (newest snapshot) per event, batched in one query — the list
|
||||
// shows evidence images inline instead of only on the detail page. The image itself still
|
||||
// goes through /snap/{uuid}, which re-checks session + site scope, so exposing the uuid
|
||||
// here grants nothing the row's owner could not already fetch.
|
||||
if ($rows !== []) {
|
||||
$thumbs = $this->snaps->thumbUuidsFor(array_map(static fn ($r) => (int)$r['event_id'], $rows));
|
||||
foreach ($rows as &$row) { $row['thumb_uuid'] = $thumbs[(int)$row['event_id']] ?? null; }
|
||||
unset($row);
|
||||
}
|
||||
|
||||
// Advertise a next cursor only on a full page — a short page means we hit the end.
|
||||
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
|
||||
$next = null;
|
||||
if (count($rows) === $limit && $rows !== []) {
|
||||
$last = $rows[count($rows) - 1];
|
||||
// occurred_at arrives as a PHP float (native PDO fetch + mapRow cast). Plainly
|
||||
// stringifying it uses the `precision` ini (14 sig figs), which ROUNDS a microsecond
|
||||
// epoch and shifts the keyset boundary — the page-edge row then duplicates or is
|
||||
// skipped. Format at full microsecond precision (the edge's own source resolution) so
|
||||
// the value round-trips to the exact same DOUBLE when MySQL parses the bound string.
|
||||
$ts = rtrim(rtrim(sprintf('%.6f', (float)$last['occurred_at']), '0'), '.');
|
||||
$next = $ts . ':' . $last['event_id'];
|
||||
}
|
||||
return Response::json(['events' => $rows, 'next_cursor' => $next], 200);
|
||||
}
|
||||
|
||||
private static function clampLimit(mixed $raw): int
|
||||
{
|
||||
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_LIMIT;
|
||||
return max(1, min(self::MAX_LIMIT, (int)$raw));
|
||||
}
|
||||
|
||||
/** @return array{0: ?string, 1: ?int} parsed (occurred_at, event_id), or null/null if absent/malformed. */
|
||||
private static function parseCursor(string $raw): array
|
||||
{
|
||||
if ($raw === '' || !str_contains($raw, ':')) return [null, null];
|
||||
[$ts, $id] = explode(':', $raw, 2);
|
||||
if (!is_numeric($ts) || $id === '' || !ctype_digit($id)) return [null, null];
|
||||
// Keep occurred_at as its exact numeric STRING. Casting to float here would round it to
|
||||
// PHP's ~14 significant digits (the `precision` ini) on the way back into the query,
|
||||
// shifting the keyset boundary so the page-edge row is duplicated or skipped. MySQL parses
|
||||
// the string to the same DOUBLE the column holds, so string end-to-end is lossless.
|
||||
return [$ts, (int)$id];
|
||||
}
|
||||
}
|
||||
41
server/src/Dashboard/SnapshotProxy.php
Normal file
41
server/src/Dashboard/SnapshotProxy.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Dashboard;
|
||||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Auth\SessionStore;
|
||||
use App\Security\SiteScope;
|
||||
use App\Repository\{SnapshotRepository, EventRepository};
|
||||
|
||||
final class SnapshotProxy
|
||||
{
|
||||
public function __construct(
|
||||
private SnapshotRepository $snaps, private EventRepository $events, 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);
|
||||
// Site-scope (P3-2b): a snapshot inherits its owning event's site; a scoped operator must
|
||||
// not fetch another site's worker image (PIPA). 404, not 403, so it can't be used to probe
|
||||
// which uuids exist. Global operators skip the lookup.
|
||||
$scope = $this->sess->get('site_scope');
|
||||
if (!SiteScope::isGlobal(is_string($scope) ? $scope : null)) {
|
||||
$eventId = $this->snaps->eventIdByUuid($uuid);
|
||||
$event = $eventId !== null ? $this->events->find($eventId) : null;
|
||||
if ($event === null || !SiteScope::allows((string)$scope, $event['site'] ?? null)) {
|
||||
return Response::json(['error' => 'not_found'], 404);
|
||||
}
|
||||
}
|
||||
// A snapshot is immutable per uuid, so let the browser cache it. Without this the event
|
||||
// list — which rebuilds every <img src=/snap/{uuid}> on each 5s poll — re-downloads every
|
||||
// visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open
|
||||
// dashboard, tens of GB/day that can trip Cafe24 shared-hosting traffic caps. `private` so
|
||||
// a shared proxy never caches one operator's site's worker image for another (PIPA).
|
||||
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200,
|
||||
['Cache-Control' => 'private, max-age=86400, immutable']);
|
||||
}
|
||||
}
|
||||
133
server/src/Dashboard/ZoneConfigController.php
Normal file
133
server/src/Dashboard/ZoneConfigController.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Dashboard;
|
||||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Auth\{SessionStore, Rbac};
|
||||
use App\Security\{Csrf, SiteScope};
|
||||
use App\Geometry\Polygon;
|
||||
use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotRepository, ZoneOwnershipException};
|
||||
|
||||
final class ZoneConfigController
|
||||
{
|
||||
private const LEVELS = ['warning','restricted','pinch'];
|
||||
|
||||
public function __construct(
|
||||
private ZoneRepository $zones, private DeviceRepository $devices,
|
||||
private AuditRepository $audit, private SessionStore $sess,
|
||||
private SnapshotRepository $snapshots
|
||||
) {}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// A scoped operator may only see/touch zones on their own site (P3-2b). A zone carries no site
|
||||
// column; its site is its owning device's (devices.site via zones.device_id). A global
|
||||
// (null-scope) caller passes everything. Mirrors EventDetailController::outOfScope — callers
|
||||
// return 404 (never 403) so a cross-site id is indistinguishable from a non-existent one.
|
||||
private function inScope(string $deviceId): bool
|
||||
{
|
||||
$scope = $this->sess->get('site_scope');
|
||||
return SiteScope::allows(is_string($scope) ? $scope : null, $this->devices->siteFor($deviceId));
|
||||
}
|
||||
|
||||
public function list(Request $req, array $params): Response
|
||||
{
|
||||
// A role gate (not just "authenticated"): without it any logged-in account could enumerate
|
||||
// another site's hazard-zone geometry. view_events is held by every dashboard role.
|
||||
if ($g = $this->guard('view_events')) return $g;
|
||||
$zones = $this->zones->forCamera((string)($params['camera'] ?? ''));
|
||||
// Drop any zone whose owning device is outside the caller's site scope.
|
||||
$zones = array_values(array_filter($zones, fn($z) => $this->inScope((string)($z['device_id'] ?? ''))));
|
||||
return Response::json(['zones' => $zones], 200);
|
||||
}
|
||||
|
||||
/** GET /api/cameras/{camera}/latest-snapshot -> {"uuid": "..."} or 404.
|
||||
* The zone editor loads /snap/{uuid} as its canvas background so zones are drawn over the
|
||||
* camera's actual view. Returns only the uuid: the image itself still goes through
|
||||
* SnapshotProxy, which re-checks the session and the site scope before serving bytes. */
|
||||
public function latestSnapshot(Request $req, array $params): Response
|
||||
{
|
||||
if ($g = $this->guard('view_events')) return $g;
|
||||
$shot = $this->snapshots->latestForCamera((string)($params['camera'] ?? ''));
|
||||
// 404 for out-of-scope as well as missing -- a scoped operator must not be able to
|
||||
// tell "another site's camera" apart from "no such camera" (mirrors list()).
|
||||
if ($shot === null || !$this->inScope((string)($shot['device_id'] ?? ''))) {
|
||||
return Response::json(['error' => 'not_found'], 404);
|
||||
}
|
||||
return Response::json(['uuid' => $shot['file_uuid']], 200);
|
||||
}
|
||||
|
||||
public function save(Request $req): Response
|
||||
{
|
||||
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);
|
||||
}
|
||||
$deviceId = (string)($b['device_id'] ?? '');
|
||||
// The zone MUST name a real device: it is delivered to the edge only via
|
||||
// GET /api/config -> zones->forDevice($deviceId), so a zone saved with a blank or mistyped
|
||||
// device_id is enforced by nobody while the dashboard still shows it as active (a silent
|
||||
// safety hole). A global (null-scope) operator's inScope() check below passes for an unknown
|
||||
// device (siteFor()->null, allows(null,null) true), so it cannot catch this — check existence
|
||||
// explicitly. 400: it is a client input error, not a hidden/cross-site resource.
|
||||
if ($deviceId === '' || !$this->devices->exists($deviceId)) {
|
||||
return Response::json(['error' => 'unknown_device'], 400);
|
||||
}
|
||||
// Scope BEFORE the write: a scoped operator must not create or overwrite a zone on another
|
||||
// site's device. For an update, the EXISTING zone's device must also be in scope, so a
|
||||
// caller cannot re-home a cross-site zone under one of their own device ids.
|
||||
if (!$this->inScope($deviceId)) return Response::json(['error' => 'not_found'], 404);
|
||||
$zoneId = $b['zone_id'] ?? null;
|
||||
if (is_string($zoneId) && $zoneId !== '') {
|
||||
$existing = $this->zones->find($zoneId);
|
||||
if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) {
|
||||
return Response::json(['error' => 'not_found'], 404);
|
||||
}
|
||||
}
|
||||
try {
|
||||
$zoneId = $this->zones->save([
|
||||
'zone_id' => $b['zone_id'] ?? null,
|
||||
'device_id' => $deviceId,
|
||||
'camera_id' => (string)($b['camera_id'] ?? ''),
|
||||
'level' => $level,
|
||||
'polygon_json' => json_encode($polygon),
|
||||
]);
|
||||
} catch (ZoneOwnershipException $e) {
|
||||
// Editing a zone with a device_id/camera_id that does not own it (or a zone deleted
|
||||
// concurrently) is a CLIENT error — 400. A real \PDOException is NOT caught here (it no
|
||||
// longer matches this narrowed type), so DB faults surface as 500 instead of hiding.
|
||||
return Response::json(['error' => 'invalid_zone'], 400);
|
||||
}
|
||||
$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'] ?? '');
|
||||
// Resolve the zone and scope-check its owning device BEFORE deleting, so a scoped operator
|
||||
// cannot delete another site's zone by id. Unknown id and cross-site id both return 404.
|
||||
$existing = $this->zones->find($id);
|
||||
if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) {
|
||||
return Response::json(['error' => 'not_found'], 404);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
67
server/src/Http/Request.php
Normal file
67
server/src/Http/Request.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?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; }
|
||||
|
||||
/**
|
||||
* The URL prefix the app is mounted under, derived from SCRIPT_NAME:
|
||||
* '/index.php' => '' (web root), '/move_detect/index.php' => '/move_detect'.
|
||||
*
|
||||
* SCRIPT_NAME is set by the web server, never by the caller. Deriving the mount point
|
||||
* from client-controlled input (a query param, a header) would let anyone relocate the
|
||||
* app and route around the prefix.
|
||||
*/
|
||||
public static function baseFrom(string $scriptName): string
|
||||
{
|
||||
if ($scriptName === '' || $scriptName[0] !== '/') return '';
|
||||
$dir = str_replace('\\', '/', dirname($scriptName));
|
||||
return $dir === '/' || $dir === '.' ? '' : rtrim($dir, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The route path for a REQUEST_URI: query string dropped, mount prefix removed.
|
||||
* Routes are declared as '/login', '/api/events', ... so without this every route 404s
|
||||
* whenever the app is mounted anywhere but the web root.
|
||||
*/
|
||||
public static function pathFor(string $requestUri, string $base): string
|
||||
{
|
||||
// Collapse leading slashes FIRST: parse_url('//api/events') reads 'api' as a
|
||||
// scheme-relative host and returns '/events', silently dropping the first segment.
|
||||
$requestUri = '/' . ltrim($requestUri, '/');
|
||||
$path = parse_url($requestUri, PHP_URL_PATH) ?: '/';
|
||||
if ($base !== '' && ($path === $base || str_starts_with($path, $base . '/'))) {
|
||||
// Match $base exactly or as a whole segment. A bare str_starts_with($path, $base)
|
||||
// would also strip '/move_detected/x' and route it as 'ed/x'.
|
||||
$path = substr($path, strlen($base));
|
||||
}
|
||||
return $path === '' ? '/' : $path;
|
||||
}
|
||||
|
||||
public static function fromGlobals(): self
|
||||
{
|
||||
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
|
||||
$base = self::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||
return new self(
|
||||
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
self::pathFor((string)($_SERVER['REQUEST_URI'] ?? '/'), $base),
|
||||
$headers, file_get_contents('php://input') ?: '', $_GET, $_POST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,9 @@ 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): self {
|
||||
return new self($status, ['Content-Type' => $contentType], $b);
|
||||
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 redirect(string $to): self {
|
||||
return new self(302, ['Location' => $to], '');
|
||||
|
|
@ -15,7 +15,11 @@ final class Router
|
|||
foreach ($this->routes as [$method, $regex, $handler]) {
|
||||
if ($req->method() !== $method) continue;
|
||||
if (preg_match($regex, $req->path(), $m)) {
|
||||
$params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||
// 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));
|
||||
return $handler($req, $params);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,10 +31,24 @@ 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'],
|
||||
159
server/src/Ingest/IngestController.php
Normal file
159
server/src/Ingest/IngestController.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?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,
|
||||
// Opportunistic retention runs here because Cafe24 shared hosting has no cron, and the
|
||||
// edge POSTs to this route 24/7 — so this is the one path that fires automatically. The
|
||||
// gate keeps it rare (a purge query per ~1/200 ingests); tests inject `fn() => true`.
|
||||
private ?\Closure $purgeGate = null,
|
||||
) {
|
||||
$this->purgeGate ??= static fn (): bool => mt_rand(1, 200) === 1;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$clean = $v['clean'];
|
||||
// Site denormalization (P3-2b): the dashboard filters events by site for access control,
|
||||
// so stamp the DEVICE's site onto the event here. Server-derived from the authenticated
|
||||
// device_id — a client-supplied site is never trusted.
|
||||
$clean['site'] = $this->devices->siteFor($deviceId);
|
||||
|
||||
// Idempotency (P1-9): a POST can succeed server-side but time out client-side; the edge
|
||||
// then re-queues and re-sends the SAME event_uuid. Return the already-stored event
|
||||
// instead of inserting a duplicate alert.
|
||||
$uuid = $clean['event_uuid'] ?? null;
|
||||
if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) {
|
||||
return $this->dedupResponse($existing, $payload);
|
||||
}
|
||||
try {
|
||||
$eventId = $this->events->insert($clean);
|
||||
} catch (\PDOException $e) {
|
||||
// Lost the insert race to a concurrent retry with the same uuid (the UNIQUE key
|
||||
// fired). That is a successful dedup, not a 500 — return the winning row.
|
||||
if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) {
|
||||
return $this->dedupResponse($existing, $payload);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
foreach (($payload['snapshots'] ?? []) as $b64) {
|
||||
$this->storeSnapshot($eventId, (string)$b64);
|
||||
}
|
||||
// PIPA retention: purge worker snapshots older than the configured window. Runs rarely
|
||||
// (the gate) and off the critical path — the event is already stored.
|
||||
$days = $this->cfg->snapshotRetentionDays();
|
||||
if ($days > 0 && ($this->purgeGate)()) {
|
||||
$this->snapshots->purgeOlderThan($now - $days * 86400);
|
||||
}
|
||||
return Response::json(['event_id' => $eventId], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* A dedup hit means the event row already exists. But the first attempt may have died AFTER
|
||||
* the event insert and BEFORE its snapshots were stored (php-fpm timeout / OOM mid re-encode),
|
||||
* leaving the event with zero evidence images. The edge re-sends the identical payload WITH
|
||||
* the snapshot bytes on every retry, so when the stored event has no snapshots and this retry
|
||||
* carries some, store them now before returning — otherwise the worker photo is lost forever
|
||||
* (the edge stops retrying once it sees the 200). Idempotent: skipped once snapshots exist.
|
||||
*/
|
||||
private function dedupResponse(int $existing, array $payload): Response
|
||||
{
|
||||
$snaps = $payload['snapshots'] ?? [];
|
||||
// Recover missing evidence: if the stored event has FEWER snapshots than this retry
|
||||
// carries, the first attempt died mid-snapshot (all or some lost). Re-store — the UNIQUE
|
||||
// (event_id, content_hash) index makes already-present snapshots collide harmlessly, so
|
||||
// this fills only the gaps, never duplicates, and is safe if two retries race.
|
||||
if (is_array($snaps) && count($snaps) > count($this->snapshots->forEvent($existing))) {
|
||||
foreach ($snaps as $b64) {
|
||||
$this->storeSnapshot($existing, (string)$b64);
|
||||
}
|
||||
}
|
||||
return Response::json(['event_id' => $existing, 'dedup' => true], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one snapshot: re-encode → write file → index in the DB, in that order so the
|
||||
* bytes exist on disk before a row can point at them. Failures are logged (never silently
|
||||
* swallowed) and, critically, a DB-index failure AFTER the file lands deletes the orphan
|
||||
* JPEG so disk never accumulates evidence with no index row. A snapshot problem never fails
|
||||
* the ingest — the event itself is already stored and the 201 stands.
|
||||
*/
|
||||
private function storeSnapshot(int $eventId, string $b64): void
|
||||
{
|
||||
$img = ImageProcessor::reencode($b64);
|
||||
if (!$img['ok']) {
|
||||
error_log("ingest: snapshot re-encode failed for event $eventId (malformed or oversized)");
|
||||
return;
|
||||
}
|
||||
$path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg';
|
||||
$bytes = (string)$img['bytes'];
|
||||
$written = file_put_contents($path, $bytes);
|
||||
// A short write (disk quota exhausted on shared hosting) returns fewer bytes than
|
||||
// requested WITHOUT returning false. The truncated JPEG must never be indexed and served
|
||||
// as evidence — remove it and bail. The event's 201 still stands.
|
||||
if ($written === false || $written !== strlen($bytes)) {
|
||||
if ($written !== false) @unlink($path);
|
||||
error_log("ingest: snapshot file write failed/short for event $eventId at $path");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->snapshots->add($eventId, $img['uuid'], $img['hash']);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($path);
|
||||
error_log("ingest: snapshot index failed for event $eventId, removed orphan {$img['uuid']}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
23
server/src/Repository/DeviceRepository.php
Normal file
23
server/src/Repository/DeviceRepository.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository;
|
||||
|
||||
interface DeviceRepository
|
||||
{
|
||||
public function secretFor(string $deviceId): ?string;
|
||||
/** True if a device row with this id is provisioned. Distinct from siteFor(), which returns
|
||||
* null for BOTH an unknown device and a known-but-unassigned one — a zone save needs to tell
|
||||
* those apart so it never persists a zone against a device_id no device will ever fetch. */
|
||||
public function exists(string $deviceId): bool;
|
||||
/** The device's access-control site (devices.site), or null if unassigned/unknown. */
|
||||
public function siteFor(string $deviceId): ?string;
|
||||
public function touchHeartbeat(string $deviceId, array $meta): void;
|
||||
public function configVersion(string $deviceId): int;
|
||||
|
||||
/** All devices with their health telemetry, for the dashboard's device-status panel.
|
||||
* Site-scoped: a non-null $site returns only that site's devices (a scoped operator);
|
||||
* null returns every device (a global operator). Each row:
|
||||
* [device_id, name, site, last_fps, last_temp, last_throttle, config_version,
|
||||
* reported_config_version, last_heartbeat_at]. Read-only. */
|
||||
public function listStatus(?string $site): array;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue