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

View file

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

View file

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

View file

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

View file

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

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
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
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_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,
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.
@ -35,7 +35,7 @@ misconfig/degradation fail LOUD.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the 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.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
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.
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
client-generated `event_id` in the payload; 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
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
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
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
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)
- [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
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
@ -125,13 +125,13 @@ misconfig/degradation fail LOUD.
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
- [x] **P3-2 [H]** `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`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
- [x] **P3-2b [M]** `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
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
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
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).
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`.
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
backstop. Package build verified.
- [x] **P4-2 [H]** `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
error.
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
- [x] **P4-3 [M]** `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
rows.
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
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
captured params.
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.
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
- [x] **P4-6 [M]** `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
clear error.
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
- [x] **P4-7 [L]** `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:**
order write→insert, log failures, reconcile orphans.
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
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
(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).
DONE — verified (completed the half-built defense, not dropped). login() checks
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
`testLoginRejectsMissingOrWrongCsrf`.
- [x] **P4-9 [L]** `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.
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
lengths in the validator.
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.
## 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
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
- [x] **P5-6 [L]** `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.
---
@ -253,7 +253,7 @@ misconfig/degradation fail LOUD.
(updated as each item lands — newest first)
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
P1-9: edge `event_uuid` idempotency key (stable through the queue) + 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
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:
@ -272,16 +272,16 @@ misconfig/degradation fail LOUD.
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
knobs — tune with real camera footage on the Pi.)
- **2026-07-17 — P4-6 + P4-8 landed (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
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
correctness (P2-3,4,5). Remaining: P1-9 (deferred to 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
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.
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.
- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
- **§0 폐쇄망 Pi 현장 설치·운영** — Hailo·Pi5 실장비를 오프라인 번들로 설치한 경우. systemd 유닛의
`Documentation=`이 이 문서를 가리키므로 현장 운영자는 **§0만** 보면 된다.
- **§1~§6 노트북 웹캠 개발 파일럿** — 개발/데모 머신에서 CPU MoveNet로 엔드투엔드를 돌리는 최소 경로.
**이 절들의 `pip install`·모델 `fetch` 명령은 개발/빌드 머신 전용이다 — 폐쇄망 Pi에서 실행하지 말 것.**
- 엣지: `python -m edge` (또는 `move-detect-edge`) — pose → 감지 → HMAC 서명 POST.
- 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
---
## 1. 빌드 머신 준비 (모델 · onnxruntime)
## 0. 폐쇄망 Pi 현장 설치·운영
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서
> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
실장비 Hailo·Pi5는 **오프라인 전체 번들**로 설치한다. 런타임 휠·pose 모델(HEF)·소스·systemd 유닛이
모두 번들에 동봉되어 있으므로 **Pi에서 인터넷을 받지 않는다** (아래 §1의 `pip install`/`fetch`는 절대
실행 금지 — 빌드 머신 전용).
1. **설치:** 번들 절차는 [deploy/README.md](../deploy/README.md)를 따른다. 빌드 머신에서
`deploy/bundle/build_offline_bundle.sh`(aarch64)로 번들을 만들어 Pi로 옮기고,
`sudo ./install_offline.sh`가 SHA256SUMS 검증 → 오프라인 휠 설치 → HEF 배치 → 유닛 설치를 수행한다.
(온라인 프로비저닝은 `sudo deploy/setup_online.sh` — 카메라 프로브까지 자동.)
2. **멀티카메라:** `/etc/move-detect/edge.env`의 `CAMERAS=csi:0:cam-01,uvc:0:cam-02`
형식으로 1 Pi에 2~4대를 배선한다(형식 `backend:device:camera_id`).
3. **enable 전 카메라 프로브** (설치 스크립트가 출력하는 명령) — 잘못된 인덱스/미체결 리본이 만드는
"초록불인데 한 대 덜 감시" 상태를 현장에서 잡는다.
4. **기동·확인:** `sudo systemctl enable --now move-detect-edge` → `journalctl -u move-detect-edge -f`.
5. **시크릿 위생:** 프로비저닝에 `deploy/device.env`를 썼다면 설치 후 삭제·필요시 로테이션
(deploy/README.md "Secret handling").
> 시크릿·서버 URL·모델 경로 등 env 키는 §3 표와 동일하다. 감지 임계값은 서버 대시보드의 구역·임계값이
> 다음 config 폴링에 반영된다.
---
## 1. 빌드/개발 머신 준비 (모델 · onnxruntime) — 폐쇄망 Pi에서 실행 금지
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망 Pi)에서는
> 인터넷을 받지 않으며, 아래 두 명령을 Pi에서 실행하면 안 된다(§0 참조 — Pi는 이미 번들로 설치됨).
> 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
```bash
pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들)
@ -25,23 +53,23 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
---
## 2. 클라우드 로컬 기동
## 2. 서버 로컬 기동
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만.
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
1. DB 생성 후 스키마 임포트: `mysql <db> < cloud/schema.sql` (10개 테이블).
2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
4. 기동: `php -S localhost:8000 -t cloud/public`
4. 기동: `php -S localhost:8000 -t server/public`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조.
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
---
@ -53,7 +81,7 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|-----|------|------|
| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` |
| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` |
| `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` |
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
@ -83,10 +111,13 @@ python -m edge # 또는 move-detect-edge
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명).
7. **하트비트:** **30초 시간기반**(`heartbeat_interval_s=30`)으로 `POST /api/heartbeat`(서명).
부하와 무관하게 주기가 일정해 서버의 오프라인 판정(120초, 4배 마진)이 정상 장치를 오프라인으로
깜빡이지 않는다. temp/throttle/queue_pending/config_version 텔레메트리를 함께 싣는다.
> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로
> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다.
> cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()`
> 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를
> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계).
---
@ -94,7 +125,7 @@ python -m edge # 또는 move-detect-edge
```bash
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings)
cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # 서버(0 warnings)
```
모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —
@ -102,11 +133,15 @@ cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations #
---
## 6. 파일럿 한계 (명시)
## 6. 한계 (명시)
- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속.
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2.
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save`
패키징은 별도 후속 태스크.
> **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를
> 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다.
- **노트북 파일럿은 단일 카메라 · CPU pose(저 fps)** — 폐쇄망 Pi(§0)는 Hailo 가속 + 멀티카메라
(`CAMERAS=` 다중 배선)를 사용한다.
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2(양끝 인프라는 존재, 배선 대기).
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook·미확인 에스컬레이션 메일은 후속(P3).
- **폐쇄망 전체 번들은 이미 존재한다** — `deploy/bundle/`에 오프라인 휠·HEF·소스·SHA256SUMS 검증
설치를 제공한다(§0). 노트북 파일럿만 `pip install`/`fetch`로 편의 실행할 뿐이다.
- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).

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

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

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
# 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"
@ -65,7 +65,7 @@ class PostClient:
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
allow_redirects=False)
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
code = resp.status_code
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
# 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.
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.REJECTED

View file

@ -29,10 +29,14 @@ def _head_boxes(persons: list) -> list:
return boxes
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn):
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
ok, frame = source.read()
if not ok:
return []
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
# frame" apart from "frame processed, no events" — a dead/unplugged USB camera returns
# (False, None) forever, and counting it as a healthy frame made a monitoring outage
# look perfectly green (fps~=target, cameras_erroring=0).
return None
now = now_fn()
persons = pose.infer(frame)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
@ -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)
ring.push(now, frame)
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:
pool.forget(tr.track_id)
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
# provisioned value. A unit left at any of these uploads nothing while looking healthy.
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
# Per-device secrets issued by the 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.
_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)")
elif len(secret) < _MIN_SECRET_LEN:
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
"per-device secret issued by the cloud, not a stub")
"per-device secret issued by the server, not a stub")
if problems:
raise ValueError(
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
"but upload nothing. CAFE24_URL must include the app path, e.g. "
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the 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,
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
# 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,
@ -29,41 +30,74 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
zones_for = lambda cfg, rt: zones_from_config(cfg)
if log is None:
log = _default_log
if health_fn is None:
from edge.health import read_health
health_fn = read_health
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc):
def note(key, label, exc=None):
counters["errors"] += 1
n = streak.get(key, 0) + 1
streak[key] = n
if n == 1 or n % _LOG_EVERY == 0:
if exc is not None:
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc)
else:
log(f"{label} (consecutive #{n})", None)
beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
last_beat = None # wall time of the last heartbeat, for the time-based cadence
reported_version = None # last config version the server told us; echoed in heartbeats
i = 0
while iterations is None or i < iterations:
i += 1
now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None:
beat_anchor = (now, 0)
if last_beat is None:
last_beat = now
for rt in runtimes:
try:
rt.step(now)
counters["frames"] += 1
streak.pop(id(rt), None) # recovered — reset the streak
produced = rt.step(now)
except Exception as e:
cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} step failed", e)
continue
if produced is None:
# step() returned no frame (unplugged/browned-out camera). Count it toward the
# erroring streak instead of as a healthy frame — otherwise a dead camera ticks
# frames++ forever and reports fps~=target with cameras_erroring=0 (a silent
# monitoring outage that looks green). A single dropped frame self-clears on the
# next good frame, so only SUSTAINED starvation shows as erroring.
cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} produced no frame")
else:
counters["frames"] += 1
streak.pop(id(rt), None) # recovered — reset the streak
try:
if sync_every and i % sync_every == 0:
cfg = client.fetch_config()
if cfg is not None:
for rt in runtimes:
rt.apply_zones(zones_for(cfg, rt))
# Remember the config version so heartbeats can echo it back — the server's
# config-drift indicator is meaningless until the device reports what it runs.
if isinstance(cfg, dict) and cfg.get("version") is not None:
reported_version = int(cfg["version"])
counters["syncs"] += 1
if heartbeat_every and i % heartbeat_every == 0:
# Heartbeat cadence: time-based when heartbeat_interval_s is set (production), so the
# interval is predictable regardless of per-tick cost (CPU MoveNet, multi-cam, thermal
# throttle can push a tick past 100ms) — an iteration count cannot guarantee a wall-clock
# cadence, and the server marks a device offline on ELAPSED time. Falls back to the
# iteration count when unset (used by the deterministic unit tests).
if heartbeat_interval_s:
do_beat = (now - last_beat) >= heartbeat_interval_s
else:
do_beat = bool(heartbeat_every) and (i % heartbeat_every == 0)
if do_beat:
# Measured fps over the window since the last beat, and per-camera health, so
# the 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).
t0, f0 = beat_anchor
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)
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
"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):
counters["beats"] += 1
beat_anchor = (now, counters["frames"])
last_beat = now
if flush_every and i % flush_every == 0:
queue.flush()
counters["flushes"] += 1
@ -133,7 +176,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
from dataclasses import replace
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
require_upload_config)
from edge.cloudclient import CloudClient
from edge.serverclient import ServerClient
from edge.alert import PostClient
from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime
@ -159,10 +202,16 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
cfg = load_config(dict(os.environ))
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
nonce = lambda: os.urandom(8).hex()
now = time.time
# Two clocks, deliberately: the loop/detector timers run on a MONOTONIC clock so an NTP step
# (a Pi 5 has no RTC battery; it boots on stale fake-hwclock time until chrony corrects it)
# cannot instantly satisfy immobile_s, expire every track, or reset an immobility countdown.
# Wall time is used only where a real timestamp is required: the HMAC X-Timestamp headers,
# which the server validates against its own wall clock, and Event.occurred_at.
mono = time.monotonic
wall = time.time
base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, wall)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, wall)
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
pose = build_pose() # shared across cameras
@ -170,12 +219,13 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
def make_runtime(cam, source):
# Per-camera config so events are stamped with that camera's id.
cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam))
return PipelineRuntime(cam_cfg, source, pose, queue)
return PipelineRuntime(cam_cfg, source, pose, queue, wall_clock=wall)
# A missing/unseated camera is skipped (logged), not fatal — the others still run.
runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
build_source, make_runtime)
run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
run_loop(runtimes, client, queue, clock=mono, sleep=time.sleep, iterations=None,
frame_interval=frame_interval_from_env(dict(os.environ)),
heartbeat_interval_s=30, # predictable cadence; server marks offline after 120s (4x)
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))

View file

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

View file

@ -52,7 +52,7 @@ class Event:
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
# offline queue and every retry, so a POST that succeeded server-side but timed out
# client-side is deduplicated by the 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)
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]]:
# 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).
uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
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()
sent = 0
for rid, payload_json, snaps_json, attempts in rows:
try:
payload = json.loads(payload_json)
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
event, snapshots = self._rehydrate(payload, snaps)
except Exception:
# An undecodable row — a torn SD-card write, or schema drift from an older edge
# build whose payload lacks a key _rehydrate indexes — can NEVER be delivered.
# flush() breaks on the first undelivered row to preserve order, so leaving this
# row in place would wedge every queued safety event BEHIND it forever. Drop the
# single unrecoverable row and continue; losing it beats freezing the whole queue.
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
self.db.commit()
continue
# A row queued before event_uuid existed has none stored; _rehydrate minted a fresh
# one just now. Persist it so every retry of this row reuses the SAME key — otherwise
# each flush sends a different uuid and a timeout-retried delivery duplicates on the
# 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"):
payload["event_uuid"] = event.event_uuid
self.db.execute("UPDATE queue SET payload = ? WHERE id = ?",

View file

@ -10,12 +10,15 @@ from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
class PipelineRuntime:
def __init__(self, config, source, pose, sink):
def __init__(self, config, source, pose, sink, wall_clock=None):
self.config = config
self.camera_id = config.camera_id # for per-camera zone routing (multi-cam)
self.source = source
self.pose = pose
self.sink = sink
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
self._wall = wall_clock
self.tracker = IouTracker()
self.pool = TrackSmootherPool()
self.zone_detector = ZoneIntrusionDetector([], config.zone)
@ -32,7 +35,7 @@ class PipelineRuntime:
def step(self, now: float) -> list:
return run_once(self.source, self.pose, self.tracker, self.pool,
self.engine, self.ring, self.snapshotter, self.sink,
now_fn=lambda: now)
now_fn=lambda: now, wall_fn=self._wall)
def apply_zones(self, zones: list) -> None:
self.zone_detector.zones = list(zones)

View file

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

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]:
"""Parse cloud config zone rows into Zone objects. When camera_id is given, only
"""Parse server config zone rows into Zone objects. When camera_id is given, only
rows for that camera are kept (multi-camera routing); None keeps all rows."""
out: list[Zone] = []
rows = config.get("zones", []) or []

View file

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

View file

@ -129,7 +129,7 @@ php -r "echo password_hash('여기에_진짜_비밀번호', PASSWORD_ARGON2ID),
phpMyAdmin → 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);
```
@ -144,7 +144,7 @@ php -r "echo bin2hex(random_bytes(32)), \"\n\";"
```
```sql
INSERT INTO devices (device_id, name, secret, config_version)
INSERT INTO move_devices (device_id, name, secret, config_version)
VALUES ('pi-01', '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
# 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
# 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.
#
# 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.
# │ ├── config.php the ONLY file the operator edits
# │ ├── src/ vendor/ storage/snapshots/
@ -42,9 +42,9 @@ APP_DIR="${APP_DIR-move_detect}"
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLOUD="$REPO_ROOT/cloud"
SERVER="$REPO_ROOT/server"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/cafe24"
STAGE="$OUT_DIR/1_cafe24_upload"
ZIP="$OUT_DIR/move-detect-cafe24.zip"
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
@ -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
# `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.
restore_dev_autoloader() {
echo ">> restoring the dev autoloader in cloud/vendor (so local phpunit keeps working)"
( cd "$CLOUD" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)"
( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
}
trap restore_dev_autoloader EXIT
( cd "$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
# 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,
# rather than on the hosting account.
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 " 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
@ -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)"
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
# phpunit/, sebastian/, myclabs/ and friends.
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
cp "$CLOUD/vendor/composer/$f" "$PRIV/vendor/composer/$f"
cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f"
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
# 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
echo ">> [4/6] staging app source"
cp -r "$CLOUD/src/." "$PRIV/src/"
cp -r "$CLOUD/public/." "$WEB/"
cp "$CLOUD/schema.sql" "$PRIV/schema.sql"
# The hardened DocumentRoot .htaccess replaces cloud/public/'s dev-local rewrite-only one.
cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess"
cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php"
cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php"
cp -r "$SERVER/src/." "$PRIV/src/"
cp -r "$SERVER/public/." "$WEB/"
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$CLOUD/deploy/user.ini" "$WEB/.user.ini"
cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
# `cp -r` above copies whatever is in 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
# 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__' \
@ -223,15 +223,36 @@ echo " -> every staged .php parses"
echo ">> [6/6] zipping"
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" . )
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
# 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_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")"
powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force"
echo " WARN: archived via Compress-Archive (backslash paths). Verify the host unpacks the tree correctly."
else
echo " WARN: no zip/powershell — skipping the archive; upload $STAGE/ directly."
echo " WARN: no zip/python3/powershell — skipping the archive; upload $STAGE/ directly."
fi
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
dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with
prepared statements — there is nothing to `composer install` on the production host.
## 1. Directory layout on the server
Only `cloud/public/` may be reachable from the web. Everything else must sit **outside**
Only `server/public/` may be reachable from the web. Everything else must sit **outside**
the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is),
or be blocked by hosting-level access rules if your plan cannot place files outside the
web root at all.
@ -27,7 +27,7 @@ web root at all.
└── 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
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
@ -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
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/`.
**`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:
```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
@ -63,7 +63,7 @@ deliberately excludes.
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`.
`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.
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
SSH if available) — do not assume, since PHP builds vary by hosting plan:
- [ ] **PHP version** matches `cloud/composer.json`'s `"php": ">=8.2"` (the app is written
- [ ] **PHP version** matches `server/composer.json`'s `"php": ">=8.2"` (the app is written
and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact
version Cafe24 provisions for your plan).
- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include
@ -112,7 +112,7 @@ SSH if available) — do not assume, since PHP builds vary by hosting plan:
## 3. `config.php`
Copy the example and fill in real values — **never commit `config.php`** (already covered
by `cloud/.gitignore`):
by `server/.gitignore`):
```bash
cp config.example.php config.php
@ -139,7 +139,7 @@ or version control.
## 4. Import the schema
`cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
@ -151,12 +151,19 @@ or version control.
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
attack surface for no benefit.
If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) will be parsed but silently not enforced —
harmless, since the application already whitelists these values before every insert
**Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression
default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a
hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an
older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1
blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel
(or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either
request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied
timestamp (the code already passes one on every insert).
Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) are parsed but silently not enforced — harmless,
since the application already whitelists these values before every insert
(`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`).
Everything else in the schema (tables, keys, indexes, `JSON` columns) requires only
MySQL 5.7+.
## 5. Storage directory
@ -188,14 +195,14 @@ php -r "echo bin2hex(random_bytes(32)), \"\n\";"
```
```sql
INSERT INTO devices (device_id, name, secret, config_version)
INSERT INTO move_devices (device_id, name, secret, config_version)
VALUES ('pi-01', 'Line 3 — Fall Cam', '<paste the generated secret here>', 1);
```
Deliver the secret to the device provisioning process out-of-band (not email/chat) and
configure it as the device's `HMAC` signing key for `X-Signature` on
`/api/events`, `/api/heartbeat`, and `/api/config`. Rotate a compromised secret by
`UPDATE devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the
`UPDATE move_devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the
old value stops working immediately since `secretFor()` always reads the current row.
## 7. First admin account
@ -215,7 +222,7 @@ php -r "echo password_hash('CHANGE-ME-to-a-real-passphrase', PASSWORD_ARGON2ID),
`PASSWORD_ARGON2ID`.)
```sql
INSERT INTO users (username, password_hash, role, site_scope, failed_attempts, locked_until)
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until)
VALUES ('admin', '<paste the argon2id hash here>', 'admin', NULL, 0, 0);
```

View file

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

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

View file

@ -45,7 +45,8 @@ use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterR
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
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
// 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);
$auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController();
$list = new EventListController($events, $sess);
$list = new EventListController($events, $snaps, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
$deviceStatus = new DeviceStatusController($devices, $sess);
$render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
@ -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', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
// Background frame for the zone editor: the newest snapshot this camera produced.
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));

View file

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

View file

@ -35,9 +35,15 @@ final class HeartbeatController
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
// config_version is null when the device did not report one (older edge builds). Storing a
// fake 0 made the dashboard's config-drift check (reported !== published) fire on EVERY
// device forever, since published versions start at 1. Keep it null until a real version
// arrives so drift only ever means genuine drift.
$reportedVersion = isset($b['config_version']) ? (int)$b['config_version'] : null;
$this->devices->touchHeartbeat($deviceId, [
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0),
'throttle' => (bool)($b['throttle'] ?? false),
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
'at' => $now,
]);
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\Auth\SessionStore;
use App\Repository\EventRepository;
use App\Repository\{EventRepository, SnapshotRepository};
final class EventListController
{
private const DEFAULT_LIMIT = 50;
private const MAX_LIMIT = 200;
public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function __construct(private EventRepository $events, private SnapshotRepository $snaps, private SessionStore $sess) {}
public function json(Request $req): Response
{
@ -28,6 +28,16 @@ final class EventListController
$site = is_string($scope) ? $scope : null;
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site);
// Attach a thumbnail uuid (newest snapshot) per event, batched in one query — the list
// shows evidence images inline instead of only on the detail page. The image itself still
// goes through /snap/{uuid}, which re-checks session + site scope, so exposing the uuid
// here grants nothing the row's owner could not already fetch.
if ($rows !== []) {
$thumbs = $this->snaps->thumbUuidsFor(array_map(static fn ($r) => (int)$r['event_id'], $rows));
foreach ($rows as &$row) { $row['thumb_uuid'] = $thumbs[(int)$row['event_id']] ?? null; }
unset($row);
}
// Advertise a next cursor only on a full page — a short page means we hit the end.
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
$next = null;

View file

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

View file

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

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['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
// it on retry so the 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.
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['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
{
$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 (?, ?, ?, ?, ?, ?)'
);
$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
{
$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,
occurred_at, duration_s, metrics_json, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
@ -24,7 +24,7 @@ final class PdoEventRepository implements EventRepository
$st->execute([
$event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy)
(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['event_type'],
(string)$event['severity'],
@ -40,7 +40,7 @@ final class PdoEventRepository implements EventRepository
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]);
$id = $st->fetchColumn();
return $id === false ? null : (int)$id;
@ -51,7 +51,7 @@ final class PdoEventRepository implements EventRepository
if ($severity !== null) {
$st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . '
FROM events WHERE severity = ?
FROM move_events WHERE severity = ?
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
);
$st->bindValue(1, $severity, PDO::PARAM_STR);
@ -59,7 +59,7 @@ final class PdoEventRepository implements EventRepository
} else {
$st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . '
FROM events
FROM move_events
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
);
$st->bindValue(1, $limit, PDO::PARAM_INT);
@ -92,7 +92,7 @@ final class PdoEventRepository implements EventRepository
$binds[] = [$beforeTs, PDO::PARAM_STR];
$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);
$sql .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
@ -106,7 +106,7 @@ final class PdoEventRepository implements EventRepository
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]);
$row = $st->fetch();
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
// already-acknowledged event by the same user would otherwise report
// 0 affected rows and this method would wrongly return false.
$exists = $this->pdo->prepare('SELECT 1 FROM events WHERE event_id = ? LIMIT 1');
$exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
$exists->execute([$id]);
if ($exists->fetchColumn() === false) {
return false;
}
$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]);
return true;

View file

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

View file

@ -18,13 +18,13 @@ final class PdoRateLimiterRepository implements RateLimiterRepository
public function hit(string $key, int $windowStart): int
{
$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'
);
$upsert->execute([$key, $windowStart]);
$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]);
$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.
if (mt_rand(1, 100) === 1) {
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]);
} catch (\PDOException $e) {
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
{
$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 (?, ?, ?, ?)'
);
$st->execute([$eventId, $fileUuid, $contentHash, time()]);
@ -21,13 +21,59 @@ final class PdoSnapshotRepository implements SnapshotRepository
public function forEvent(int $eventId): array
{
$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'
);
$st->execute([$eventId]);
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
{
// Validate BEFORE building any filesystem path or touching the DB --
@ -39,7 +85,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
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]);
if ($st->fetchColumn() === false) {
return null;
@ -53,7 +99,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) {
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]);
$id = $st->fetchColumn();
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
// PIPA leak we are closing, so never delete the row before its file.
$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(2, $limit, PDO::PARAM_INT);
$sel->execute();
@ -92,7 +138,7 @@ final class PdoSnapshotRepository implements SnapshotRepository
return 0;
}
$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);
return count($ids);
}

View file

@ -11,7 +11,7 @@ final class PdoThresholdRepository implements ThresholdRepository
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]);
$out = [];

View file

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

View file

@ -13,7 +13,7 @@ final class PdoZoneRepository implements ZoneRepository
public function find(string $zoneId): ?array
{
$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]);
$row = $st->fetch();
@ -23,7 +23,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forDevice(string $deviceId): array
{
$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]);
return $st->fetchAll();
@ -32,7 +32,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forCamera(string $cameraId): array
{
$st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE camera_id = ?'
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE camera_id = ?'
);
$st->execute([$cameraId]);
return $st->fetchAll();
@ -52,7 +52,7 @@ final class PdoZoneRepository implements ZoneRepository
$newId = bin2hex(random_bytes(16));
$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 (?, ?, ?, ?, ?, ?)'
);
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]);
@ -61,7 +61,7 @@ final class PdoZoneRepository implements ZoneRepository
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]);
return $st->rowCount() > 0;
}
@ -89,7 +89,7 @@ final class PdoZoneRepository implements ZoneRepository
string $polygonJson,
): string {
$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]);
if ($owns->fetchColumn() === false) {
@ -97,7 +97,7 @@ final class PdoZoneRepository implements ZoneRepository
}
$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 = ?'
);
$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
// above and this UPDATE. Disambiguate with a targeted existence
// 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]);
if ($stillExists->fetchColumn() === false) {
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')) {
function render_header(string $title): void {
$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>
<html lang="ko">
@ -34,17 +38,24 @@ if (!function_exists('render_header')) {
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= view_e($title) ?> · 이동 감지 대시보드</title>
<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>
<body>
<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">
<a href="<?= view_url('/') ?>">이벤트</a>
<a href="<?= view_url('/zones') ?>">구역 설정</a>
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a>
<a href="<?= view_url('/zones') ?>" class="app-nav-link<?= $navZones ?>">구역 설정</a>
</nav>
<div class="app-session">
<?php if ($username !== null): ?>
<span><?= view_e($username) ?></span>
<span class="who"><?= view_e($username) ?></span>
<button type="button" id="logout-btn">로그아웃</button>
<?php else: ?>
<a href="<?= view_url('/login') ?>">로그인</a>
@ -60,6 +71,19 @@ if (!function_exists('render_footer')) {
function render_footer(): void {
?>
</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>
</html>
<?php

View file

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

View file

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

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