Compare commits

..

10 commits

Author SHA1 Message Date
유창욱
9ed759befc docs(deploy): fix MySQL/runbook/secret guidance; offline camera probe
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
  defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
  only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
2026-07-25 19:21:01 +09:00
유창욱
bf5255e9eb feat(dashboard): device-status panel + event-list rework, hardened
- Device-status panel (DeviceStatusController): site-scoped liveness over stored
  heartbeat telemetry. Fix config_drift false-positive by storing NULL until the
  device actually reports a version (was firing "설정 미적용" on every device).
- Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone
  is never persisted against a device that will never fetch it.
- Snapshot proxy sends immutable private cache headers, ending the 5s-poll
  re-download of every thumbnail (a Cafe24 traffic-cap hazard).
- Event list: paged-in acknowledgements stick; honesty affordances for the paging
  gap, a stale device poll, and a filtered camera aging out; logout wired for
  every page via the shared layout.
2026-07-25 19:20:42 +09:00
유창욱
752afc3f00 feat(edge): heartbeat telemetry + monotonic timers + queue/camera robustness
- Split clocks: detector timers/cooldowns run on a MONOTONIC clock (immune to an
  NTP step on an RTC-less Pi); wall time is used only for the HMAC X-Timestamp
  and Event.occurred_at. Fixes false IMMOBILITY / dropped tracks after a clock jump.
- Heartbeat is time-based (30s, predictable regardless of load) and now carries
  temp/throttle/queue_pending/config_version telemetry.
- A dead (no-frame) camera counts as erroring instead of a healthy frame, so a
  silent monitoring outage no longer looks green.
- Offline queue: isolate one undecodable row instead of wedging every event behind
  it; classify 401 (stale clock) as transient, never a poison-drop.
- New edge/health.py: vcgencmd temp/throttle parsing, None off-Pi (no fake 0).
2026-07-25 19:20:23 +09:00
유창욱
aa4378e906 feat(dashboard): load a camera's latest frame as the zone-editor background
Drawing hazard zones on a blank canvas meant guessing pixel coordinates.
The editor can now pull the camera's most recent snapshot and draw over
what the camera actually sees.

- SnapshotRepository::latestForCamera() (+ Pdo/in-memory impls), ordered by
  snapshot_id, not the device-reported occurred_at (a skewed Pi RTC would
  otherwise pin 'latest' to a stale frame)
- GET /api/cameras/{camera}/latest-snapshot -> {uuid}; returns only the id,
  the bytes still go through SnapshotProxy's session + site-scope check.
  Out-of-scope and missing both 404, so a scoped operator cannot probe for
  another site's cameras
- ix_events_camera (camera_id, event_id) so the lookup is not a full scan
- '현재 화면 불러오기' button in the zone editor

Tests: PHP 113 pass (4 skipped, 4 new), pytest 174 pass.
2026-07-20 23:41:58 +09:00
유창욱
5444f19d45 feat(deploy): probe every camera before enabling the service
build_runtimes() logs-and-skips a camera it cannot open, so a wrong
/dev/video index or an unseated ribbon produced a green systemd unit
that silently monitored one camera less.

- edge/probe.py: opens each camera in CAMERAS, retries while the sensor
  settles, reports frame geometry, optionally saves preview PNGs, and
  exits non-zero with a backend-specific hint (v4l2-ctl / rpicam-hello)
- setup_online.sh: new step [8/9] runs it against the installed package
  and the CAMERAS line actually written to edge.env, and refuses to
  enable the unit on failure. SKIP_CAMERA_CHECK=1 opts out.

Tests: 174 pass, 2 skipped (7 new).
2026-07-20 23:22:20 +09:00
유창욱
f062667a65 feat(deploy): read pre-provisioned values from deploy/device.env
The online Pi install required a four-variable command line typed on the
device. setup_online.sh now reads DEVICE_ID / DEVICE_SECRET / CAFE24_URL /
CAMERAS from deploy/device.env (written on the build machine) so the whole
install is 'sudo deploy/setup_online.sh'.

Real env vars still take precedence, so the documented long-form command
overrides a stale device.env. device.env is gitignored -- it carries the
device HMAC secret in plaintext.
2026-07-20 23:06:39 +09:00
유창욱
a2d2d24793 build: name the two delivery folders and stop archiving by default
Delivery had two ambiguously-named outputs and always produced an
archive, which made it unclear what actually gets uploaded/copied.

- dist/1_cafe24_upload/  -> FTP-upload contents to the Cafe24 web host
- dist/2_pi_install/     -> copy folder to the Pi, run ./install_offline.sh

Archives are now opt-in: MAKE_ZIP=1 (Cafe24) / MAKE_TARBALL=1 (Pi).
Docs and tests updated to the new paths.

Tests: 165 pass, 2 skipped.
2026-07-20 12:58:44 +09:00
유창욱
3f2823a764 refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.

- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match

Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
2026-07-20 11:30:52 +09:00
유창욱
5d511b04f2 refactor(cloud): prefix all DB tables with move_
Renames the 10 P1-B tables (users, devices, cameras, zones, events,
event_snapshots, thresholds, nonces, rate_limits, audit_log) to a
move_ prefix so the schema can co-tenant a shared-hosting database.

- schema.sql + all App\Repository\Pdo\* queries + tests + deploy docs
- new migrations/2026-07-20-move-table-prefix.sql: atomic RENAME TABLE
  for existing installs; must run BEFORE deploying this code
- index/constraint names left unchanged (no cross-table collision)

Tests: 109 pass, 4 skipped.
2026-07-20 11:23:30 +09:00
유창욱
3a8a5106cd fix(cafe24): zip with forward-slash paths (python fallback before Compress-Archive)
On a Windows/Git-Bash build box with no `zip`, the packager fell back to
PowerShell Compress-Archive, which writes BACKSLASH path separators. Cafe24's
extractor (and some unzip tools) do not normalise them, so the app tree unpacked
as flat files (move_detect_app\src\... as a single filename) and the deploy
silently broke — a likely cause of the repeated install failures.

Prefer a Python zipfile step (POSIX arcnames) between `zip` and PowerShell.
Verified: rebuilt zip extracts with a correct move_detect_app/src/... tree and
no "backslashes as path separators" warning.
2026-07-18 22:34:19 +09:00
128 changed files with 2689 additions and 952 deletions

3
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +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;
/** The event_id that owns this snapshot uuid, or null. Lets the proxy resolve a
* snapshot's site (via the owning event) for access control. */
public function eventIdByUuid(string $uuid): ?int;
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
* worker images must not be kept forever. Returns the number purged. */
public function purgeOlderThan(int $cutoff): int;
}

View file

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

View file

@ -3,7 +3,7 @@
Offline-installable bundle + systemd unit for the `move-detect` edge daemon. Offline-installable bundle + systemd unit for the `move-detect` edge daemon.
The bundle is fully self-contained (all wheels + pose model + source): the Pi The bundle is fully self-contained (all wheels + pose model + source): the Pi
performs **zero network fetches** at install or run time. The only runtime performs **zero network fetches** at install or run time. The only runtime
network use is the 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). being offline (events buffer in the local sqlite queue and flush on reconnect).
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the > Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
@ -23,19 +23,41 @@ being offline (events buffer in the local sqlite queue and flush on reconnect).
## Quick start — Pi with internet (one command) ## Quick start — Pi with internet (one command)
If the Pi itself has internet, skip the offline bundle entirely. Copy this repo onto If the Pi itself has internet, skip the offline bundle entirely. Copy the built delivery
the Pi (git clone or scp), then: 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 ```bash
sudo deploy/setup_online.sh sudo deploy/setup_online.sh
``` ```
It installs the system packages (`python3-picamera2`, and `hailo-all` if present), It installs the system packages (`python3-picamera2`, and `hailo-all` if present), builds
builds the venv, pip-installs the runtime deps, fetches the MoveNet model, writes the venv, pip-installs the runtime deps, **places the pose model from the delivery's
`/etc/move-detect/edge.env` (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `models/`** (it does NOT download models on the Pi — the HEF/ONNX are fetched on the build
`CAMERAS`, or take them from env vars), installs the systemd unit, and starts the service. machine per the air-gap rule, `build pulls, target doesn't`), writes `/etc/move-detect/edge.env`
It begins on CPU MoveNet; drop a `yolov8s_pose.hef` in and pass `HAILO_HEF=...` (or edit (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `CAMERAS`, or take them from env
`edge.env`) to switch to Hailo. The rest of this doc is the **air-gapped** path. 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 deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311
``` ```
Output: `dist/move-detect-edge-bundle-aarch64-cp311.tar.gz` (+ `MANIFEST.txt` Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled
listing the bundled wheels). This single file crosses the air gap. 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) ## 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 ```bash
tar xzf move-detect-edge-bundle-aarch64-cp311.tar.gz # tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz
cd move-detect-edge-bundle cd 2_pi_install
sudo ./install_offline.sh sudo ./install_offline.sh
``` ```
@ -81,8 +105,8 @@ sudo systemctl enable --now move-detect-edge
journalctl -u move-detect-edge -f journalctl -u move-detect-edge -f
``` ```
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the cloud (see `DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
`cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled `server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
model (`/opt/move-detect/models/movenet_lightning.onnx`). model (`/opt/move-detect/models/movenet_lightning.onnx`).
## 4. Verify the air gap (do this before trusting a build) ## 4. Verify the air gap (do this before trusting a build)
@ -93,7 +117,7 @@ model (`/opt/move-detect/models/movenet_lightning.onnx`).
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 + - [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
numpy + requests all import from the venv). numpy + requests all import from the venv).
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows - [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
frames processed and (if the cloud is reachable) heartbeats. frames processed and (if the server is reachable) heartbeats.
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore - [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
→ the queue flushes. (End-to-end behavior verified in the edge e2e harness.) → the queue flushes. (End-to-end behavior verified in the edge e2e harness.)
@ -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 **Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several
cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share
the Hailo device (round-robin) and the offline queue, and 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: matching camera. Example — CSI + USB together:
```ini ```ini
CAMERAS=csi:0:cam-01,uvc:0:cam-02 CAMERAS=csi:0:cam-01,uvc:0:cam-02
``` ```
Each `camera_id` must match the `camera_id` on that camera's zones in the 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 > **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't
> carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer > carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer
@ -141,7 +165,7 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
## Not bundled (by design) ## 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 - **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above); HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
the default CPU MoveNet + USB path needs none of them. the default CPU MoveNet + USB path needs none of them.

View file

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

View file

@ -136,7 +136,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
echo ">> [4/6] verify the edge package imports offline" echo ">> [4/6] verify the edge package imports offline"
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level # cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
# on the unconditional startup chain; the daemon's cloud clients import requests at startup). # 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')" PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime # onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may # ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
@ -226,6 +226,11 @@ systemctl daemon-reload
echo "" echo ""
echo "Installed. Next:" echo "Installed. Next:"
echo " 1. edit $ETC/edge.env (device id/secret, cloud URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)" echo " 1. edit $ETC/edge.env (device id/secret, server URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
echo " 2. sudo systemctl enable --now move-detect-edge" echo " 2. camera check BEFORE enabling — a wrong /dev/video index or an unseated CSI ribbon"
echo " 3. journalctl -u move-detect-edge -f" 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"

View file

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

View file

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

View file

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

View file

@ -1,17 +1,45 @@
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드) # 파일럿 · 운영 런북
이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다. 이 문서는 두 경로를 다룬다. **자신의 상황에 맞는 절에서 시작하라:**
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST. - **§0 폐쇄망 Pi 현장 설치·운영** — Hailo·Pi5 실장비를 오프라인 번들로 설치한 경우. systemd 유닛의
- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림. `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 ```bash
pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들) 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 등)은 로컬 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개 테이블). 1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로). (`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. 3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` - `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` - `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
4. 기동: `php -S localhost:8000 -t cloud/public` 4. 기동: `php -S localhost:8000 -t server/public`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 - 세션 쿠키가 `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_ID` | `devices.device_id`와 일치 | `pi-01` |
| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` | | `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` | | `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` |
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` | | `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` | | `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` | | `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
@ -83,10 +111,13 @@ python -m edge # 또는 move-detect-edge
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것. ≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 → 6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송. 복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
7. **하트비트:** `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로 > cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()`
> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다. > 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를
> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계).
--- ---
@ -94,7 +125,7 @@ python -m edge # 또는 move-detect-edge
```bash ```bash
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip) 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` — 모델을 번들한 뒤 실추론 경로까지 확인하려면 `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 가속은 후속. > **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2. > 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다.
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save` - **노트북 파일럿은 단일 카메라 · 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). - **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).

View file

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

View file

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

View file

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

View file

@ -29,10 +29,14 @@ def _head_boxes(persons: list) -> list:
return boxes 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() ok, frame = source.read()
if not ok: 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() now = now_fn()
persons = pose.infer(frame) persons = pose.infer(frame)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
@ -42,7 +46,10 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now) f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame) ring.push(now, frame)
h, w = frame.shape[:2] h, w = frame.shape[:2]
events = engine.process(tracks, lost, now, frame_wh=(w, h)) # `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
wall_now = wall_fn() if wall_fn is not None else None
events = engine.process(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
for tr in lost: for tr in lost:
pool.forget(tr.track_id) pool.forget(tr.track_id)
if events: if events:

View file

@ -49,7 +49,7 @@ def queue_db_path(env: dict) -> str:
# Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real # 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. # provisioned value. A unit left at any of these uploads nothing while looking healthy.
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx") _PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
# Per-device secrets issued by the cloud are HMAC-derived (long hex); anything shorter is a # 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. # stub left in the template, not a real key.
_MIN_SECRET_LEN = 16 _MIN_SECRET_LEN = 16
@ -82,13 +82,13 @@ def require_upload_config(cfg: Config) -> None:
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)") problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
elif len(secret) < _MIN_SECRET_LEN: elif len(secret) < _MIN_SECRET_LEN:
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the " problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
"per-device secret issued by the cloud, not a stub") "per-device secret issued by the server, not a stub")
if problems: if problems:
raise ValueError( raise ValueError(
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run " "FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
"but upload nothing. CAFE24_URL must include the app path, e.g. " "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 cloud." "https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the server."
) )

View file

@ -17,7 +17,8 @@ def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300, sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None, log=None): frame_interval=0.0, zones_for=None, log=None,
heartbeat_interval_s=None, health_fn=None):
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All # `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
# cameras share this loop, one shared clock read per tick, and the periodic # cameras share this loop, one shared clock read per tick, and the periodic
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged, # sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
@ -29,41 +30,74 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
zones_for = lambda cfg, rt: zones_from_config(cfg) zones_for = lambda cfg, rt: zones_from_config(cfg)
if log is None: if log is None:
log = _default_log log = _default_log
if health_fn is None:
from edge.health import read_health
health_fn = read_health
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0} counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc): def note(key, label, exc=None):
counters["errors"] += 1 counters["errors"] += 1
n = streak.get(key, 0) + 1 n = streak.get(key, 0) + 1
streak[key] = n streak[key] = n
if n == 1 or n % _LOG_EVERY == 0: if n == 1 or n % _LOG_EVERY == 0:
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc) 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 beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
last_beat = None # wall time of the last heartbeat, for the time-based cadence
reported_version = None # last config version the server told us; echoed in heartbeats
i = 0 i = 0
while iterations is None or i < iterations: while iterations is None or i < iterations:
i += 1 i += 1
now = clock() # one timestamp per tick, shared across cameras now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None: if beat_anchor is None:
beat_anchor = (now, 0) beat_anchor = (now, 0)
if last_beat is None:
last_beat = now
for rt in runtimes: for rt in runtimes:
try: try:
rt.step(now) produced = rt.step(now)
counters["frames"] += 1
streak.pop(id(rt), None) # recovered — reset the streak
except Exception as e: except Exception as e:
cam = getattr(rt, "camera_id", "?") cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} step failed", e) 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
streak.pop(id(rt), None) # recovered — reset the streak
try: try:
if sync_every and i % sync_every == 0: if sync_every and i % sync_every == 0:
cfg = client.fetch_config() cfg = client.fetch_config()
if cfg is not None: if cfg is not None:
for rt in runtimes: for rt in runtimes:
rt.apply_zones(zones_for(cfg, rt)) rt.apply_zones(zones_for(cfg, rt))
# Remember the config version so heartbeats can echo it back — the server's
# config-drift indicator is meaningless until the device reports what it runs.
if isinstance(cfg, dict) and cfg.get("version") is not None:
reported_version = int(cfg["version"])
counters["syncs"] += 1 counters["syncs"] += 1
if heartbeat_every and i % heartbeat_every == 0: # 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 # Measured fps over the window since the last beat, and per-camera health, so
# the cloud can tell a live device from a degraded/dead one (was hardcoded 0.0 # 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). # + a frames counter that ticked even when no frame was read).
t0, f0 = beat_anchor t0, f0 = beat_anchor
elapsed = now - t0 elapsed = now - t0
@ -71,10 +105,19 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
erroring = sum(1 for rt in runtimes if id(rt) in streak) erroring = sum(1 for rt in runtimes if id(rt) in streak)
metrics = {"fps": round(fps, 2), "frames": counters["frames"], metrics = {"fps": round(fps, 2), "frames": counters["frames"],
"cameras_total": len(runtimes), "cameras_erroring": erroring, "cameras_total": len(runtimes), "cameras_erroring": erroring,
"errors": counters["errors"]} "errors": counters["errors"], "queue_pending": queue.pending()}
# Pi thermal telemetry (None off-Pi or on failure → field omitted, never a fake 0).
health = health_fn()
if health.get("temp") is not None:
metrics["temp"] = health["temp"]
if health.get("throttle") is not None:
metrics["throttle"] = health["throttle"]
if reported_version is not None:
metrics["config_version"] = reported_version
if client.send_heartbeat(metrics): if client.send_heartbeat(metrics):
counters["beats"] += 1 counters["beats"] += 1
beat_anchor = (now, counters["frames"]) beat_anchor = (now, counters["frames"])
last_beat = now
if flush_every and i % flush_every == 0: if flush_every and i % flush_every == 0:
queue.flush() queue.flush()
counters["flushes"] += 1 counters["flushes"] += 1
@ -133,7 +176,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
from dataclasses import replace from dataclasses import replace
from edge.config import (frame_interval_from_env, load_config, queue_db_path, from edge.config import (frame_interval_from_env, load_config, queue_db_path,
require_upload_config) require_upload_config)
from edge.cloudclient import CloudClient from edge.serverclient import ServerClient
from edge.alert import PostClient from edge.alert import PostClient
from edge.queue import OfflineQueue from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime from edge.runtime import PipelineRuntime
@ -159,10 +202,16 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
cfg = load_config(dict(os.environ)) cfg = load_config(dict(os.environ))
require_upload_config(cfg) # fail loud now, not silently upload nothing forever require_upload_config(cfg) # fail loud now, not silently upload nothing forever
nonce = lambda: os.urandom(8).hex() nonce = lambda: os.urandom(8).hex()
now = time.time # 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("/") base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now) client = ServerClient(base, cfg.device_id, cfg.secret, nonce, wall)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, wall)
queue = OfflineQueue(queue_db_path(dict(os.environ)), post) queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
pose = build_pose() # shared across cameras pose = build_pose() # shared across cameras
@ -170,12 +219,13 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
def make_runtime(cam, source): def make_runtime(cam, source):
# Per-camera config so events are stamped with that camera's id. # Per-camera config so events are stamped with that camera's id.
cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam)) cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam))
return PipelineRuntime(cam_cfg, source, pose, queue) return PipelineRuntime(cam_cfg, source, pose, queue, wall_clock=wall)
# A missing/unseated camera is skipped (logged), not fatal — the others still run. # A missing/unseated camera is skipped (logged), not fatal — the others still run.
runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id), runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
build_source, make_runtime) build_source, make_runtime)
run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None, run_loop(runtimes, client, queue, clock=mono, sleep=time.sleep, iterations=None,
frame_interval=frame_interval_from_env(dict(os.environ)), 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)) zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))

View file

@ -40,15 +40,21 @@ class EventEngine:
self._last_emit = {k: v for k, v in self._last_emit.items() self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)} 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: metrics=None, spatial=None) -> Event:
m = metrics or {} m = metrics or {}
duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0 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, 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], def process(self, active_tracks: list[Track], lost_tracks: list[Track],
now: float, frame_wh=None) -> 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] = [] events: list[Event] = []
self.ghost.on_lost(lost_tracks, now) self.ghost.on_lost(lost_tracks, now)
@ -62,13 +68,13 @@ class EventEngine:
state = self.fall._states.get(tr.track_id) state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING") 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): 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): 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) fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now): 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: for hit in zone_hits:
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit # 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
@ -78,12 +84,12 @@ class EventEngine:
"restricted": EventType.ZONE_RESTRICTED}.get( "restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING) hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now): if self._allow((etype, tr.track_id, hit["zone_id"]), now):
events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"])) events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
for g in self.ghost.update(active_tracks, now): for g in self.ghost.update(active_tracks, now):
key = (EventType.GHOST, g["track_id"]) key = (EventType.GHOST, g["track_id"])
if self._allow(key, now): 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"], spatial=g["spatial"],
metrics={"last_state": g["last_state"], metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]})) "waited_s": g["waited_s"]}))

View file

@ -52,7 +52,7 @@ class Event:
spatial: tuple | None # (x, y) 유령사고 등 공간 기준 spatial: tuple | None # (x, y) 유령사고 등 공간 기준
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the # 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 # offline queue and every retry, so a POST that succeeded server-side but timed out
# client-side is deduplicated by the cloud (see cloud IngestController) instead of stored twice. # client-side is deduplicated by the server (see server IngestController) instead of stored twice.
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex) event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
def to_payload(self, device_id: str) -> dict: def to_payload(self, device_id: str) -> dict:

47
edge/health.py Normal file
View 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
View 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())

View file

@ -27,7 +27,7 @@ class OfflineQueue:
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]: 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 # Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the
# cloud dedups it. Rows queued before this field existed simply get a fresh uuid (they # 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). # 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 {} uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]), e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
@ -64,13 +64,23 @@ class OfflineQueue:
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall() "SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
sent = 0 sent = 0
for rid, payload_json, snaps_json, attempts in rows: for rid, payload_json, snaps_json, attempts in rows:
payload = json.loads(payload_json) try:
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)] payload = json.loads(payload_json)
event, snapshots = self._rehydrate(payload, snaps) snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
event, snapshots = self._rehydrate(payload, snaps)
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 # 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 # 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 # each flush sends a different uuid and a timeout-retried delivery duplicates on the
# cloud (the P1-9 bug, still open for transitional rows). # server (the P1-9 bug, still open for transitional rows).
if not payload.get("event_uuid"): if not payload.get("event_uuid"):
payload["event_uuid"] = event.event_uuid payload["event_uuid"] = event.event_uuid
self.db.execute("UPDATE queue SET payload = ? WHERE id = ?", self.db.execute("UPDATE queue SET payload = ? WHERE id = ?",

View file

@ -10,12 +10,15 @@ from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
class PipelineRuntime: class PipelineRuntime:
def __init__(self, config, source, pose, sink): def __init__(self, config, source, pose, sink, wall_clock=None):
self.config = config self.config = config
self.camera_id = config.camera_id # for per-camera zone routing (multi-cam) self.camera_id = config.camera_id # for per-camera zone routing (multi-cam)
self.source = source self.source = source
self.pose = pose self.pose = pose
self.sink = sink 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.tracker = IouTracker()
self.pool = TrackSmootherPool() self.pool = TrackSmootherPool()
self.zone_detector = ZoneIntrusionDetector([], config.zone) self.zone_detector = ZoneIntrusionDetector([], config.zone)
@ -32,7 +35,7 @@ class PipelineRuntime:
def step(self, now: float) -> list: def step(self, now: float) -> list:
return run_once(self.source, self.pose, self.tracker, self.pool, return run_once(self.source, self.pose, self.tracker, self.pool,
self.engine, self.ring, self.snapshotter, self.sink, 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: def apply_zones(self, zones: list) -> None:
self.zone_detector.zones = list(zones) self.zone_detector.zones = list(zones)

View file

@ -3,7 +3,7 @@ import json
from edge.alert import USER_AGENT, sign from edge.alert import USER_AGENT, sign
class CloudClient: class ServerClient:
def __init__(self, base_url: str, device_id: str, secret: str, def __init__(self, base_url: str, device_id: str, secret: str,
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0): nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")

View file

@ -18,7 +18,7 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: 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.""" rows for that camera are kept (multi-camera routing); None keeps all rows."""
out: list[Zone] = [] out: list[Zone] = []
rows = config.get("zones", []) or [] rows = config.get("zones", []) or []

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);

View 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
View 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);
})();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,12 @@ final class SnapshotProxy
return Response::json(['error' => 'not_found'], 404); return Response::json(['error' => 'not_found'], 404);
} }
} }
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200); // 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']);
} }
} }

View file

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

View file

@ -12,8 +12,9 @@ final class Response
public static function text(string $s, int $status = 200): self { public static function text(string $s, int $status = 200): self {
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s); return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
} }
public static function bytes(string $b, string $contentType, int $status = 200): self { public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self {
return new self($status, ['Content-Type' => $contentType], $b); // 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 { public static function redirect(string $to): self {
return new self(302, ['Location' => $to], ''); return new self(302, ['Location' => $to], '');

View file

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

View file

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

View file

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

View file

@ -0,0 +1,96 @@
<?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 move_devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$secret = $st->fetchColumn();
return $secret === false ? null : (string)$secret;
}
public function exists(string $deviceId): bool
{
$st = $this->pdo->prepare('SELECT 1 FROM move_devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
return $st->fetchColumn() !== false;
}
public function siteFor(string $deviceId): ?string
{
$st = $this->pdo->prepare('SELECT site FROM move_devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$site = $st->fetchColumn();
// false = no such device; null = device exists but unassigned. Both -> null (unscoped).
return ($site === false || $site === null) ? null : (string)$site;
}
public function touchHeartbeat(string $deviceId, array $meta): void
{
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
// only reaches this after secretFor() already succeeded for the same
// device_id, so the row is guaranteed to exist. Device rows are provisioned
// out-of-band by an admin (see server/docs/server-deploy.md), never created
// implicitly from an inbound heartbeat.
$st = $this->pdo->prepare(
'UPDATE move_devices
SET last_fps = ?, last_temp = ?, last_throttle = ?,
reported_config_version = ?, last_heartbeat_at = ?
WHERE device_id = ?'
);
// config_version stays NULL (not 0) until the device actually reports one, so the
// dashboard's config-drift check does not fire on every device (see HeartbeatController).
$reportedVersion = $meta['config_version'] ?? null;
$st->execute([
(float)($meta['fps'] ?? 0.0),
(float)($meta['temp'] ?? 0.0),
!empty($meta['throttle']) ? 1 : 0,
$reportedVersion !== null ? (int)$reportedVersion : null,
(int)($meta['at'] ?? time()),
$deviceId,
]);
}
public function configVersion(string $deviceId): int
{
$st = $this->pdo->prepare('SELECT config_version FROM move_devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$version = $st->fetchColumn();
return $version === false ? 1 : (int)$version;
}
public function listStatus(?string $site): array
{
// Site-scope access control, same rule as the event list: a non-null scope filters to
// that site; a global operator (null) sees every device. Pushed into the WHERE.
$sql = 'SELECT device_id, name, site, last_fps, last_temp, last_throttle,
config_version, reported_config_version, last_heartbeat_at
FROM move_devices';
$binds = [];
if ($site !== null) { $sql .= ' WHERE site = ?'; $binds[] = $site; }
$sql .= ' ORDER BY device_id';
$st = $this->pdo->prepare($sql);
$st->execute($binds);
return array_map(static function (array $r): array {
return [
'device_id' => (string)$r['device_id'],
'name' => $r['name'] !== null ? (string)$r['name'] : null,
'site' => $r['site'] !== null ? (string)$r['site'] : null,
'last_fps' => $r['last_fps'] !== null ? (float)$r['last_fps'] : null,
'last_temp' => $r['last_temp'] !== null ? (float)$r['last_temp'] : null,
'last_throttle' => $r['last_throttle'] !== null ? (bool)(int)$r['last_throttle'] : null,
'config_version' => (int)$r['config_version'],
'reported_config_version' => $r['reported_config_version'] !== null ? (int)$r['reported_config_version'] : null,
'last_heartbeat_at' => $r['last_heartbeat_at'] !== null ? (int)$r['last_heartbeat_at'] : null,
];
}, $st->fetchAll());
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface SnapshotRepository
{
public function add(int $eventId, string $fileUuid, string $contentHash): void;
public function forEvent(int $eventId): array;
public function pathByUuid(string $uuid): ?string;
/** The event_id that owns this snapshot uuid, or null. Lets the proxy resolve a
* snapshot's site (via the owning event) for access control. */
public function eventIdByUuid(string $uuid): ?int;
/** Newest snapshot file_uuid for each of the given event ids, as [event_id => file_uuid].
* Batched (one query) so the event list can show a thumbnail per row without an N+1.
* Events with no snapshot are simply absent from the map. */
public function thumbUuidsFor(array $eventIds): array;
/** Most recent snapshot for a camera, as ['file_uuid' => ..., 'device_id' => ...], or null.
* Backs the zone editor's "load the latest frame" button: an operator draws hazard zones
* over what the camera actually sees instead of guessing coordinates on a blank canvas.
* device_id comes back so the caller can site-scope the result. */
public function latestForCamera(string $cameraId): ?array;
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
* worker images must not be kept forever. Returns the number purged. */
public function purgeOlderThan(int $cutoff): int;
}

View file

@ -0,0 +1,31 @@
<?php
require __DIR__ . '/layout.php';
render_header('이벤트');
?>
<div class="page-head">
<h1>이벤트</h1>
<span class="watch" id="watch-status" aria-live="polite">실시간 감시 중</span>
</div>
<p class="page-sub">감지 현황 · 5초마다 갱신</p>
<div id="events-status" class="status-strip" aria-label="미확인 이벤트 현황"></div>
<p class="section-label">장치 상태</p>
<div id="devices-root" class="device-strip" aria-live="polite" aria-label="장치·카메라 상태"></div>
<div class="list-head">
<p class="section-label">최근 이벤트</p>
<div class="list-filters">
<select id="severity-filter" aria-label="심각도 필터">
<option value="">전체 심각도</option>
<option value="critical">위급</option>
<option value="high">높음</option>
<option value="warning">경고</option>
<option value="info">정보</option>
</select>
<select id="camera-filter" aria-label="카메라 필터"><option value="">전체 카메라</option></select>
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만</label>
</div>
</div>
<div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
<script src="<?= view_url('/assets/app.js') ?>"></script>
<?php
render_footer();

View file

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

View file

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

View file

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

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