fix(delivery): resolve codex + workflow review findings across edge/cloud/deploy

Two independent adversarial reviews (codex CLI + a 6-dimension verification
workflow) of the delivery-hardening branch surfaced ~40 findings; this closes
the confirmed correctness/security/deploy blockers plus a codex re-review pass.

edge:
- geometry: track_displacement per-second threshold recalibrated (0.02->0.25,
  env-tunable via MOTION_THR, validated finite/positive); confidence-filter
  keypoints with a bbox-center fallback so hallucinated points don't fake motion
- fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident
  (freeze + reset recovery timer so recovery needs a fresh confident streak);
  baseline height only seeded from trusted frames
- queue: PostClient.send() returns a tri-state SendResult; only definite 4xx
  count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx)
  never deletes queued safety events; legacy rows persist their minted uuid
- config: reject placeholder / http / short DEVICE_SECRET at startup
- zones: warn+skip legacy pixel-space polygons that would silently match nothing

cloud:
- zone endpoints wired into P3-2b site-scope (cross-site read/write/delete
  closed) + role gate on list; catch narrowed so DB faults surface as 500
- snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan)
- ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE
  (event_id, content_hash) makes recovery idempotent under retry/race
- event list cursor carries full-precision occurred_at (no float rounding at the
  page boundary); string keyset end-to-end
- SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap
- runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit
  reap fails open

deploy:
- offline bundle hashes EVERY file (fatal on missing/failed/injected; models in
  SBOM; arch in tarball name; REQUIRE_SIGNATURE option)
- installer verifies before any mutation, Hailo arch preflight before install,
  atomic src swap, backend reconcile preserves a working operator choice
- systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert)

Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4
skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings +
fixes) with the second-pass findings folded in.
This commit is contained in:
유창욱 2026-07-17 19:24:42 +09:00
parent 930490d1b4
commit 2ea0420de7
37 changed files with 932 additions and 168 deletions

View file

@ -65,12 +65,25 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
## 3. DB 만들기 ## 3. DB 만들기
### 처음 설치(빈 DB)
1. 카페24 관리자 → **phpMyAdmin** 열기 1. 카페24 관리자 → **phpMyAdmin** 열기
2. 이 앱용 DB 선택 2. 이 앱용 DB 선택
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행 3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
4. 표 10개가 생겼는지 확인 4. 표 10개가 생겼는지 확인
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다 5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
> 대신 **마이그레이션**을 실행하세요.
1. phpMyAdmin → 해당 DB → **SQL** 탭
2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행
(site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함)
3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인
4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다
--- ---
## 4. HTTPS 켜기 ## 4. HTTPS 켜기

View file

@ -0,0 +1,98 @@
-- ===========================================================================
-- Migration: P1-9 (event dedup) + P3-2b (site-scoped access control)
-- + retention / rate-limit reap indexes
--
-- Apply ONCE to an EXISTING move_detect database. A FRESH install already has
-- all of this via schema.sql — do NOT run this on a database created from the
-- current schema.sql.
--
-- RUN IN THREE PARTS, IN ORDER. Part C (the events.site backfill) MUST run
-- AFTER Part B has set devices.site — otherwise it matches 0 rows and every
-- historical event stays site=NULL and DISAPPEARS from a scoped operator's
-- dashboard the moment you scope a user. Do not skip Part C.
--
-- How to run: phpMyAdmin -> your DB -> "SQL" tab. MySQL (unlike MariaDB) has no
-- "ADD COLUMN IF NOT EXISTS": if a column/index already exists, that ONE
-- statement errors — skip it and run the rest.
-- ===========================================================================
-- ===========================================================================
-- PART A — schema changes (safe to run as-is)
-- ===========================================================================
-- A1) devices.site — the access-control site each device belongs to.
ALTER TABLE 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
-- site-scoped event list.
ALTER TABLE 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
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
ADD KEY ix_event_snapshots_created (created_at);
-- A5) event_snapshots (event_id, content_hash) UNIQUE — lets the ingest dedup path
-- re-store an event's MISSING snapshots idempotently (a re-sent event carries
-- the same re-encoded bytes = same content_hash, so a duplicate/racing store
-- collides instead of writing a second copy). If this fails on "Duplicate
-- entry", the table already has duplicate (event_id, content_hash) rows —
-- remove the dups, then re-run this one statement.
ALTER TABLE 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
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';
-- -- ... one row per device ...
-- ===========================================================================
-- PART C — backfill historical events from their device's site.
-- RUN THIS ONLY AFTER Part B. Re-run it whenever you add/relabel a
-- device's site so its past events reappear for scoped operators.
-- ===========================================================================
UPDATE events e
JOIN devices d ON d.device_id = e.device_id
SET e.site = d.site
WHERE e.site IS NULL AND d.site IS NOT NULL;
-- ===========================================================================
-- PART D — scope dashboard users (run AFTER Part C, so scoped users immediately
-- see their backfilled history). NULL = global operator; '' is NOT
-- global (matches nothing — an accidental blank fails closed).
-- ===========================================================================
-- UPDATE 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';
-- 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
-- WHERE e.site IS NULL AND d.site IS NOT NULL;

View file

@ -190,7 +190,11 @@
} }
function loadEvents() { function loadEvents() {
return fetch(B + '/api/events', { headers: { Accept: 'application/json' } }) // 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) { .then(function (res) {
if (res.status === 401) { if (res.status === 401) {
window.location.href = B + '/login'; window.location.href = B + '/login';

View file

@ -92,7 +92,7 @@ $csrfCtl = new CsrfController();
$list = new EventListController($events, $sess); $list = new EventListController($events, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $audit, $sess); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess);
$render = function (string $view): Response { $render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';

View file

@ -158,7 +158,12 @@ CREATE TABLE event_snapshots (
created_at BIGINT UNSIGNED NOT NULL, created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (snapshot_id), PRIMARY KEY (snapshot_id),
UNIQUE KEY uq_event_snapshots_uuid (file_uuid), UNIQUE KEY uq_event_snapshots_uuid (file_uuid),
-- Idempotent snapshot recovery: a re-sent event carries the SAME re-encoded bytes (same
-- content_hash), so this makes a duplicate/racing store collide instead of writing a second
-- copy. Lets the dedup path safely re-store missing evidence without duplication.
UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash),
KEY ix_event_snapshots_event (event_id), KEY ix_event_snapshots_event (event_id),
KEY ix_event_snapshots_created (created_at), -- retention purge scans WHERE created_at < ?
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id) CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -206,7 +211,10 @@ CREATE TABLE rate_limits (
rate_key VARCHAR(190) NOT NULL, rate_key VARCHAR(190) NOT NULL,
window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket
count INT UNSIGNED NOT NULL DEFAULT 0, count INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (rate_key, window_start) PRIMARY KEY (rate_key, window_start),
-- The reap `DELETE ... WHERE window_start < ?` cannot use the PK (window_start is its trailing
-- column), so give it its own index to avoid a locking full-scan on the pre-auth hot path.
KEY ix_rate_limits_window (window_start)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -228,19 +236,18 @@ CREATE TABLE audit_log (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- =========================================================================== -- ===========================================================================
-- MIGRATIONS for an EXISTING database (fresh installs already have the columns -- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above):
-- above). Apply once via phpMyAdmin, then set each device's site with: -- do NOT copy the statements below by hand — they are SQL comments and will not
-- run. Apply the RUNNABLE migration instead:
--
-- cloud/migrations/2026-07-17-p1-9-p3-2b.sql
--
-- It adds devices.site / events.site / events.event_uuid, the retention and
-- rate-limit reap indexes, AND backfills events.site from 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 devices SET site = 'factory-a' WHERE device_id = 'pi-1';
-- and scope a dashboard user with: -- UPDATE users SET site_scope = 'factory-a' WHERE username = 'operator-a';
-- UPDATE users SET site_scope = 'factory-a' WHERE username = '...'; -- A user with site_scope = NULL is a global operator; '' is NOT global (matches
-- A user with site_scope = NULL is a global operator (sees every site). -- nothing — a blank scope fails closed).
-- ---------------------------------------------------------------------------
-- P3-2b (site-scoped access control):
-- ALTER TABLE devices ADD COLUMN site VARCHAR(64) NULL AFTER name,
-- ADD KEY ix_devices_site (site);
-- ALTER TABLE events ADD COLUMN site VARCHAR(64) NULL AFTER device_id,
-- ADD KEY ix_events_site_occurred_at (site, occurred_at);
-- P1-9 (ingest idempotency / dedup):
-- ALTER TABLE events ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id,
-- ADD UNIQUE KEY uq_events_uuid (event_uuid);
-- =========================================================================== -- ===========================================================================

View file

@ -21,8 +21,11 @@ final class EventListController
[$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor')); [$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor'));
// Site-scope access control (P3-2b): a scoped operator sees only their site's events; a // Site-scope access control (P3-2b): a scoped operator sees only their site's events; a
// null/empty scope is a global operator (no site filter). Pushed into the SQL WHERE. // null/empty scope is a global operator (no site filter). Pushed into the SQL WHERE.
// Pass the scope through as-is: only a genuinely null scope is global (no filter). An
// empty-string scope is a misconfiguration that must match NOTHING (fail closed), not be
// silently promoted to global by collapsing it to null here.
$scope = $this->sess->get('site_scope'); $scope = $this->sess->get('site_scope');
$site = (is_string($scope) && $scope !== '') ? $scope : null; $site = is_string($scope) ? $scope : null;
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site); $rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site);
// Advertise a next cursor only on a full page — a short page means we hit the end. // Advertise a next cursor only on a full page — a short page means we hit the end.
@ -30,7 +33,13 @@ final class EventListController
$next = null; $next = null;
if (count($rows) === $limit && $rows !== []) { if (count($rows) === $limit && $rows !== []) {
$last = $rows[count($rows) - 1]; $last = $rows[count($rows) - 1];
$next = $last['occurred_at'] . ':' . $last['event_id']; // occurred_at arrives as a PHP float (native PDO fetch + mapRow cast). Plainly
// stringifying it uses the `precision` ini (14 sig figs), which ROUNDS a microsecond
// epoch and shifts the keyset boundary — the page-edge row then duplicates or is
// skipped. Format at full microsecond precision (the edge's own source resolution) so
// the value round-trips to the exact same DOUBLE when MySQL parses the bound string.
$ts = rtrim(rtrim(sprintf('%.6f', (float)$last['occurred_at']), '0'), '.');
$next = $ts . ':' . $last['event_id'];
} }
return Response::json(['events' => $rows, 'next_cursor' => $next], 200); return Response::json(['events' => $rows, 'next_cursor' => $next], 200);
} }
@ -41,12 +50,16 @@ final class EventListController
return max(1, min(self::MAX_LIMIT, (int)$raw)); return max(1, min(self::MAX_LIMIT, (int)$raw));
} }
/** @return array{0: ?float, 1: ?int} parsed (occurred_at, event_id), or null/null if absent/malformed. */ /** @return array{0: ?string, 1: ?int} parsed (occurred_at, event_id), or null/null if absent/malformed. */
private static function parseCursor(string $raw): array private static function parseCursor(string $raw): array
{ {
if ($raw === '' || !str_contains($raw, ':')) return [null, null]; if ($raw === '' || !str_contains($raw, ':')) return [null, null];
[$ts, $id] = explode(':', $raw, 2); [$ts, $id] = explode(':', $raw, 2);
if (!is_numeric($ts) || $id === '' || !ctype_digit($id)) return [null, null]; if (!is_numeric($ts) || $id === '' || !ctype_digit($id)) return [null, null];
return [(float)$ts, (int)$id]; // Keep occurred_at as its exact numeric STRING. Casting to float here would round it to
// PHP's ~14 significant digits (the `precision` ini) on the way back into the query,
// shifting the keyset boundary so the page-edge row is duplicated or skipped. MySQL parses
// the string to the same DOUBLE the column holds, so string end-to-end is lossless.
return [$ts, (int)$id];
} }
} }

View file

@ -4,16 +4,17 @@ namespace App\Dashboard;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac}; use App\Auth\{SessionStore, Rbac};
use App\Security\Csrf; use App\Security\{Csrf, SiteScope};
use App\Geometry\Polygon; use App\Geometry\Polygon;
use App\Repository\{ZoneRepository, AuditRepository}; use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, ZoneOwnershipException};
final class ZoneConfigController final class ZoneConfigController
{ {
private const LEVELS = ['warning','restricted','pinch']; private const LEVELS = ['warning','restricted','pinch'];
public function __construct( public function __construct(
private ZoneRepository $zones, private AuditRepository $audit, private SessionStore $sess private ZoneRepository $zones, private DeviceRepository $devices,
private AuditRepository $audit, private SessionStore $sess
) {} ) {}
private function guard(string $perm): ?Response private function guard(string $perm): ?Response
@ -23,10 +24,25 @@ final class ZoneConfigController
return null; return null;
} }
// A scoped operator may only see/touch zones on their own site (P3-2b). A zone carries no site
// column; its site is its owning device's (devices.site via zones.device_id). A global
// (null-scope) caller passes everything. Mirrors EventDetailController::outOfScope — callers
// return 404 (never 403) so a cross-site id is indistinguishable from a non-existent one.
private function inScope(string $deviceId): bool
{
$scope = $this->sess->get('site_scope');
return SiteScope::allows(is_string($scope) ? $scope : null, $this->devices->siteFor($deviceId));
}
public function list(Request $req, array $params): Response public function list(Request $req, array $params): Response
{ {
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); // A role gate (not just "authenticated"): without it any logged-in account could enumerate
return Response::json(['zones' => $this->zones->forCamera((string)($params['camera'] ?? ''))], 200); // another site's hazard-zone geometry. view_events is held by every dashboard role.
if ($g = $this->guard('view_events')) return $g;
$zones = $this->zones->forCamera((string)($params['camera'] ?? ''));
// Drop any zone whose owning device is outside the caller's site scope.
$zones = array_values(array_filter($zones, fn($z) => $this->inScope((string)($z['device_id'] ?? ''))));
return Response::json(['zones' => $zones], 200);
} }
public function save(Request $req): Response public function save(Request $req): Response
@ -41,17 +57,30 @@ final class ZoneConfigController
if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) { if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) {
return Response::json(['error' => 'invalid_zone'], 400); return Response::json(['error' => 'invalid_zone'], 400);
} }
$deviceId = (string)($b['device_id'] ?? '');
// Scope BEFORE the write: a scoped operator must not create or overwrite a zone on another
// site's device. For an update, the EXISTING zone's device must also be in scope, so a
// caller cannot re-home a cross-site zone under one of their own device ids.
if (!$this->inScope($deviceId)) return Response::json(['error' => 'not_found'], 404);
$zoneId = $b['zone_id'] ?? null;
if (is_string($zoneId) && $zoneId !== '') {
$existing = $this->zones->find($zoneId);
if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) {
return Response::json(['error' => 'not_found'], 404);
}
}
try { try {
$zoneId = $this->zones->save([ $zoneId = $this->zones->save([
'zone_id' => $b['zone_id'] ?? null, 'zone_id' => $b['zone_id'] ?? null,
'device_id' => (string)($b['device_id'] ?? ''), 'device_id' => $deviceId,
'camera_id' => (string)($b['camera_id'] ?? ''), 'camera_id' => (string)($b['camera_id'] ?? ''),
'level' => $level, 'level' => $level,
'polygon_json' => json_encode($polygon), 'polygon_json' => json_encode($polygon),
]); ]);
} catch (\RuntimeException $e) { } catch (ZoneOwnershipException $e) {
// Editing a zone with a device_id/camera_id that does not own it (or a zone deleted // Editing a zone with a device_id/camera_id that does not own it (or a zone deleted
// concurrently) is a CLIENT error, not a server fault — return 400, not a 500. // concurrently) is a CLIENT error — 400. A real \PDOException is NOT caught here (it no
// longer matches this narrowed type), so DB faults surface as 500 instead of hiding.
return Response::json(['error' => 'invalid_zone'], 400); return Response::json(['error' => 'invalid_zone'], 400);
} }
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]); $this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
@ -65,6 +94,12 @@ final class ZoneConfigController
return Response::json(['error' => 'csrf'], 403); return Response::json(['error' => 'csrf'], 403);
} }
$id = (string)($params['id'] ?? ''); $id = (string)($params['id'] ?? '');
// Resolve the zone and scope-check its owning device BEFORE deleting, so a scoped operator
// cannot delete another site's zone by id. Unknown id and cross-site id both return 404.
$existing = $this->zones->find($id);
if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) {
return Response::json(['error' => 'not_found'], 404);
}
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404); if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []); $this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
return Response::json(['ok' => true], 200); return Response::json(['ok' => true], 200);

View file

@ -77,7 +77,7 @@ final class IngestController
// instead of inserting a duplicate alert. // instead of inserting a duplicate alert.
$uuid = $clean['event_uuid'] ?? null; $uuid = $clean['event_uuid'] ?? null;
if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) { if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) {
return Response::json(['event_id' => $existing, 'dedup' => true], 200); return $this->dedupResponse($existing, $payload);
} }
try { try {
$eventId = $this->events->insert($clean); $eventId = $this->events->insert($clean);
@ -85,7 +85,7 @@ final class IngestController
// Lost the insert race to a concurrent retry with the same uuid (the UNIQUE key // Lost the insert race to a concurrent retry with the same uuid (the UNIQUE key
// fired). That is a successful dedup, not a 500 — return the winning row. // fired). That is a successful dedup, not a 500 — return the winning row.
if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) { if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) {
return Response::json(['event_id' => $existing, 'dedup' => true], 200); return $this->dedupResponse($existing, $payload);
} }
throw $e; throw $e;
} }
@ -101,6 +101,29 @@ final class IngestController
return Response::json(['event_id' => $eventId], 201); return Response::json(['event_id' => $eventId], 201);
} }
/**
* A dedup hit means the event row already exists. But the first attempt may have died AFTER
* the event insert and BEFORE its snapshots were stored (php-fpm timeout / OOM mid re-encode),
* leaving the event with zero evidence images. The edge re-sends the identical payload WITH
* the snapshot bytes on every retry, so when the stored event has no snapshots and this retry
* carries some, store them now before returning — otherwise the worker photo is lost forever
* (the edge stops retrying once it sees the 200). Idempotent: skipped once snapshots exist.
*/
private function dedupResponse(int $existing, array $payload): Response
{
$snaps = $payload['snapshots'] ?? [];
// Recover missing evidence: if the stored event has FEWER snapshots than this retry
// carries, the first attempt died mid-snapshot (all or some lost). Re-store — the UNIQUE
// (event_id, content_hash) index makes already-present snapshots collide harmlessly, so
// this fills only the gaps, never duplicates, and is safe if two retries race.
if (is_array($snaps) && count($snaps) > count($this->snapshots->forEvent($existing))) {
foreach ($snaps as $b64) {
$this->storeSnapshot($existing, (string)$b64);
}
}
return Response::json(['event_id' => $existing, 'dedup' => true], 200);
}
/** /**
* Persist one snapshot: re-encode → write file → index in the DB, in that order so the * Persist one snapshot: re-encode → write file → index in the DB, in that order so the
* bytes exist on disk before a row can point at them. Failures are logged (never silently * bytes exist on disk before a row can point at them. Failures are logged (never silently
@ -116,8 +139,14 @@ final class IngestController
return; return;
} }
$path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg'; $path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg';
if (file_put_contents($path, $img['bytes']) === false) { $bytes = (string)$img['bytes'];
error_log("ingest: snapshot file write failed for event $eventId at $path"); $written = file_put_contents($path, $bytes);
// A short write (disk quota exhausted on shared hosting) returns fewer bytes than
// requested WITHOUT returning false. The truncated JPEG must never be indexed and served
// as evidence — remove it and bail. The event's 201 still stands.
if ($written === false || $written !== strlen($bytes)) {
if ($written !== false) @unlink($path);
error_log("ingest: snapshot file write failed/short for event $eventId at $path");
return; return;
} }
try { try {

View file

@ -17,7 +17,7 @@ interface EventRepository
* $site (when non-null) restricts the page to events whose denormalized site matches — * $site (when non-null) restricts the page to events whose denormalized site matches —
* the SQL half of the site-scope access control (see App\Security\SiteScope). * the SQL half of the site-scope access control (see App\Security\SiteScope).
*/ */
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId, ?string $site = null): array; public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array;
public function find(int $id): ?array; public function find(int $id): ?array;
public function acknowledge(int $id, string $user): bool; public function acknowledge(int $id, string $user): bool;
} }

View file

@ -68,7 +68,7 @@ final class PdoEventRepository implements EventRepository
return array_map(self::mapRow(...), $st->fetchAll()); return array_map(self::mapRow(...), $st->fetchAll());
} }
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId, ?string $site = null): array public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array
{ {
// Bind list as [value, PDO type] pairs, positional in SQL order. // Bind list as [value, PDO type] pairs, positional in SQL order.
$where = []; $where = [];
@ -83,12 +83,13 @@ final class PdoEventRepository implements EventRepository
$binds[] = [$site, PDO::PARAM_STR]; $binds[] = [$site, PDO::PARAM_STR];
} }
// Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id) // Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id)
// on the (occurred_at DESC, event_id DESC) order. occurred_at is bound as a string so the // on the (occurred_at DESC, event_id DESC) order. occurred_at arrives as its exact numeric
// DECIMAL comparison keeps full precision (no float round-trip in the driver). // STRING (never round-tripped through a PHP float) and is bound as a string, so MySQL
// parses it to the same DOUBLE the column holds — the page-edge boundary stays exact.
if ($beforeTs !== null && $beforeId !== null) { if ($beforeTs !== null && $beforeId !== null) {
$where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))'; $where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))';
$binds[] = [(string)$beforeTs, PDO::PARAM_STR]; $binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [(string)$beforeTs, PDO::PARAM_STR]; $binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeId, PDO::PARAM_INT]; $binds[] = [$beforeId, PDO::PARAM_INT];
} }
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM events'; $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM events';

View file

@ -31,9 +31,17 @@ final class PdoRateLimiterRepository implements RateLimiterRepository
// Cron-less reap (Cafe24 has no scheduler): stale windows accumulate forever. Prune a // Cron-less reap (Cafe24 has no scheduler): stale windows accumulate forever. Prune a
// bounded batch of windows older than the current one (kept a couple back for safety). // bounded batch of windows older than the current one (kept a couple back for safety).
// Fail OPEN: this reap fires on the pre-auth hot path (every ingest/config/heartbeat), so
// a lock-wait timeout or deadlock (SQLSTATE 40001) under load must NOT turn the anti-flood
// guard into a 500 — the throttle exists to protect availability, not to break it. The
// count is already computed above; swallow reap failures and let the next hit retry.
if (mt_rand(1, 100) === 1) { if (mt_rand(1, 100) === 1) {
try {
$del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000'); $del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000');
$del->execute([$windowStart - 2]); $del->execute([$windowStart - 2]);
} catch (\PDOException $e) {
error_log('rate_limits reap skipped: ' . $e->getMessage());
}
} }
return $count; return $count;
} }

View file

@ -77,11 +77,20 @@ final class PdoSnapshotRepository implements SnapshotRepository
$ids = []; $ids = [];
foreach ($rows as $r) { foreach ($rows as $r) {
$path = rtrim($this->snapshotDir, '/\\') . '/' . $r['file_uuid'] . '.jpg'; $path = rtrim($this->snapshotDir, '/\\') . '/' . $r['file_uuid'] . '.jpg';
if (is_file($path)) { // Only delete the DB row once its file is actually gone. If unlink() fails (perms, a
@unlink($path); // lock, or a hosting-support restore that reassigned the owner), KEEP the row so the
// next purge retries it. Deleting the row while the JPEG survives makes the file
// permanently untrackable — the row-driven purge can never find it again — leaving a
// worker image on disk past the PIPA retention window, the exact leak this closes.
if (is_file($path) && @unlink($path) === false) {
error_log("snapshot purge: unlink failed for $path; keeping row for next purge");
continue;
} }
$ids[] = (int)$r['snapshot_id']; $ids[] = (int)$r['snapshot_id'];
} }
if (!$ids) {
return 0;
}
$in = implode(',', array_fill(0, count($ids), '?')); $in = implode(',', array_fill(0, count($ids), '?'));
$del = $this->pdo->prepare("DELETE FROM event_snapshots WHERE snapshot_id IN ($in)"); $del = $this->pdo->prepare("DELETE FROM event_snapshots WHERE snapshot_id IN ($in)");
$del->execute($ids); $del->execute($ids);

View file

@ -3,13 +3,23 @@ declare(strict_types=1);
namespace App\Repository\Pdo; namespace App\Repository\Pdo;
use App\Repository\ZoneRepository; use App\Repository\ZoneRepository;
use App\Repository\ZoneOwnershipException;
use PDO; use PDO;
use RuntimeException;
final class PdoZoneRepository implements ZoneRepository final class PdoZoneRepository implements ZoneRepository
{ {
public function __construct(private PDO $pdo) {} public function __construct(private PDO $pdo) {}
public function find(string $zoneId): ?array
{
$st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE zone_id = ? LIMIT 1'
);
$st->execute([$zoneId]);
$row = $st->fetch();
return $row === false ? null : $row;
}
public function forDevice(string $deviceId): array public function forDevice(string $deviceId): array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
@ -83,7 +93,7 @@ final class PdoZoneRepository implements ZoneRepository
); );
$owns->execute([$zoneId, $deviceId, $cameraId]); $owns->execute([$zoneId, $deviceId, $cameraId]);
if ($owns->fetchColumn() === false) { if ($owns->fetchColumn() === false) {
throw new RuntimeException('zone not found or not owned by the given device/camera'); throw new ZoneOwnershipException('zone not found or not owned by the given device/camera');
} }
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
@ -101,7 +111,7 @@ final class PdoZoneRepository implements ZoneRepository
$stillExists = $this->pdo->prepare('SELECT 1 FROM zones WHERE zone_id = ? LIMIT 1'); $stillExists = $this->pdo->prepare('SELECT 1 FROM zones WHERE zone_id = ? LIMIT 1');
$stillExists->execute([$zoneId]); $stillExists->execute([$zoneId]);
if ($stillExists->fetchColumn() === false) { if ($stillExists->fetchColumn() === false) {
throw new RuntimeException('zone was deleted concurrently'); throw new ZoneOwnershipException('zone was deleted concurrently');
} }
} }
return $zoneId; return $zoneId;

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Repository;
/**
* A zone save/update targeted a zone the caller does not own (device/camera mismatch), or one
* deleted concurrently. Distinct from \PDOException so ZoneConfigController can return 400 for
* this alone while letting real DB faults propagate to the generic 500 handler — previously the
* controller caught \RuntimeException, which \PDOException also extends, masking DB faults as an
* "invalid_zone" client error and swallowing them without a log.
*/
final class ZoneOwnershipException extends \RuntimeException {}

View file

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

View file

@ -7,16 +7,17 @@ namespace App\Security;
* (users.site_scope); a device/event carries `site` (devices.site, denormalized * (users.site_scope); a device/event carries `site` (devices.site, denormalized
* onto events.site at ingest). * onto events.site at ingest).
* *
* A NULL/empty caller scope is a GLOBAL operator — sees every site. A scoped * ONLY a NULL caller scope is a GLOBAL operator — sees every site. An empty-string scope is
* caller sees only rows whose site matches exactly (a row with no site is never * NOT global: it is a real (misconfigured) scope that matches nothing, so a scoping mistake
* visible to a scoped caller). List queries push this into SQL; per-row reads * fails CLOSED toward less access, never open. A scoped caller sees only rows whose site
* (event detail, snapshot serve) call allows() before returning anything. * matches exactly (a row with no site is never visible to a scoped caller). List queries push
* this into SQL; per-row reads (event detail, snapshot serve) call allows() before returning.
*/ */
final class SiteScope final class SiteScope
{ {
public static function isGlobal(?string $callerScope): bool public static function isGlobal(?string $callerScope): bool
{ {
return $callerScope === null || $callerScope === ''; return $callerScope === null;
} }
public static function allows(?string $callerScope, ?string $rowSite): bool public static function allows(?string $callerScope, ?string $rowSite): bool

View file

@ -42,6 +42,20 @@ final class DashboardTest extends TestCase
$this->assertSame(1, $p2['events'][0]['event_id']); $this->assertSame(1, $p2['events'][0]['event_id']);
$this->assertNull($p2['next_cursor']); $this->assertNull($p2['next_cursor']);
} }
public function testCursorPreservesMicrosecondPrecision(): void {
// Two events whose occurred_at differ only at the microsecond. The cursor must round-trip
// that value exactly — the old float stringification (precision=14) rounded it and made
// the page-edge row reappear on the next page (duplicate) or vanish (skip).
$repo = new InMemoryEventRepository();
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123456]); // id 1
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2
$c = new EventListController($repo, $this->authed());
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true);
$this->assertSame(2, $p1['events'][0]['event_id']); // newest first
$p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1', 'cursor' => $p1['next_cursor']], []))->body(), true);
$this->assertCount(1, $p2['events']);
$this->assertSame(1, $p2['events'][0]['event_id']); // id 2 not duplicated, id 1 not skipped
}
public function testAcknowledgeRequiresPermissionAndSetsUser(): void { public function testAcknowledgeRequiresPermissionAndSetsUser(): void {
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);

View file

@ -45,6 +45,24 @@ final class IngestControllerTest extends TestCase
$id = json_decode($res->body(), true)['event_id']; $id = json_decode($res->body(), true)['event_id'];
$this->assertCount(1, $snaps->forEvent($id)); // snapshot re-encoded + stored $this->assertCount(1, $snaps->forEvent($id)); // snapshot re-encoded + stored
} }
public function testDedupPathRestoresMissingSnapshots(): void {
// Simulate a first attempt that stored the event but died before its snapshots (php-fpm
// timeout mid re-encode): the event exists with ZERO evidence images. The edge re-sends
// the identical payload WITH the bytes; the dedup path must store them, not discard them.
[$c, $events, $snaps] = $this->make();
$uuid = str_repeat('a', 32);
$first = $this->payload(); $first['event_uuid'] = $uuid; $first['snapshots'] = [];
$res1 = $c->handle($this->signedReq('sekret', $first, nonce: 'n1'), now: 1000, clientIp: '1.2.3.4');
$this->assertSame(201, $res1->status());
$id = json_decode($res1->body(), true)['event_id'];
$this->assertCount(0, $snaps->forEvent($id)); // no evidence yet
$retry = $this->payload(); $retry['event_uuid'] = $uuid; // same key, now WITH snapshots
$res2 = $c->handle($this->signedReq('sekret', $retry, nonce: 'n2'), now: 1001, clientIp: '1.2.3.4');
$this->assertSame(200, $res2->status());
$this->assertTrue(json_decode($res2->body(), true)['dedup']);
$this->assertCount(1, $snaps->forEvent($id)); // evidence recovered on the retry
}
public function testRejectsBadSignature(): void { public function testRejectsBadSignature(): void {
[$c] = $this->make(); [$c] = $this->make();
$req = $this->signedReq('WRONG', $this->payload()); $req = $this->signedReq('WRONG', $this->payload());

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Security\SiteScope;
use PHPUnit\Framework\TestCase;
final class SiteScopeTest extends TestCase
{
public function testNullScopeIsGlobal(): void
{
$this->assertTrue(SiteScope::isGlobal(null));
$this->assertTrue(SiteScope::allows(null, 'factory-a')); // a global operator sees every site
$this->assertTrue(SiteScope::allows(null, null));
}
public function testEmptyStringScopeIsNotGlobal(): void
{
// A blank scope (an accidental '') must fail CLOSED — never be silently promoted to
// global access the way it was before this fix.
$this->assertFalse(SiteScope::isGlobal(''));
$this->assertFalse(SiteScope::allows('', 'factory-a'));
$this->assertFalse(SiteScope::allows('', null));
}
public function testScopedCallerSeesOnlyItsExactSite(): void
{
$this->assertTrue(SiteScope::allows('factory-a', 'factory-a'));
$this->assertFalse(SiteScope::allows('factory-a', 'factory-b'));
$this->assertFalse(SiteScope::allows('factory-a', null)); // an unscoped row is hidden from a scoped caller
}
}

View file

@ -35,15 +35,16 @@ final class InMemoryEventRepository implements EventRepository {
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev); if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
return array_slice(array_values($r), 0, $limit); return array_slice(array_values($r), 0, $limit);
} }
public function page(int $limit, ?string $sev, ?float $beforeTs, ?int $beforeId, ?string $site = null): array { public function page(int $limit, ?string $sev, ?string $beforeTs, ?int $beforeId, ?string $site = null): array {
$r = array_values($this->rows); $r = array_values($this->rows);
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev); if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
if ($site !== null) $r = array_filter($r, fn($x) => ($x['site'] ?? null) === $site); if ($site !== null) $r = array_filter($r, fn($x) => ($x['site'] ?? null) === $site);
// (occurred_at DESC, event_id DESC); events inserted without occurred_at sort as 0. // (occurred_at DESC, event_id DESC); events inserted without occurred_at sort as 0.
usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]); usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]);
if ($beforeTs !== null && $beforeId !== null) { if ($beforeTs !== null && $beforeId !== null) {
$r = array_filter($r, fn($x) => ($x['occurred_at'] ?? 0) < $beforeTs $bt = (float)$beforeTs; // cursor carries occurred_at as a string; compare numerically
|| (($x['occurred_at'] ?? 0) == $beforeTs && $x['event_id'] < $beforeId)); $r = array_filter($r, fn($x) => ($x['occurred_at'] ?? 0) < $bt
|| (($x['occurred_at'] ?? 0) == $bt && $x['event_id'] < $beforeId));
} }
return array_slice(array_values($r), 0, $limit); return array_slice(array_values($r), 0, $limit);
} }
@ -52,7 +53,12 @@ final class InMemoryEventRepository implements EventRepository {
} }
final class InMemorySnapshotRepository implements SnapshotRepository { final class InMemorySnapshotRepository implements SnapshotRepository {
public array $rows = []; public array $paths = []; public array $created = []; public array $rows = []; public array $paths = []; public array $created = [];
public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); } public function add(int $e, string $uuid, string $hash): void {
// Mirror the DB's UNIQUE (event_id, content_hash): a re-sent event's identical bytes
// collide instead of duplicating, so the dedup recovery path is idempotent.
foreach ($this->rows[$e] ?? [] as $r) { if ($r['content_hash'] === $hash) return; }
$this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time();
}
public function forEvent(int $e): array { return $this->rows[$e] ?? []; } public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; } public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
public function eventIdByUuid(string $u): ?int { public function eventIdByUuid(string $u): ?int {
@ -82,6 +88,7 @@ final class InMemoryZoneRepository implements ZoneRepository {
public array $zones = []; private int $seq = 0; public array $zones = []; private int $seq = 0;
public function forDevice(string $d): array { return array_values(array_filter($this->zones, fn($z) => ($z['device_id'] ?? '') === $d)); } public function forDevice(string $d): array { return array_values(array_filter($this->zones, fn($z) => ($z['device_id'] ?? '') === $d)); }
public function forCamera(string $c): array { return array_values(array_filter($this->zones, fn($z) => $z['camera_id'] === $c)); } public function forCamera(string $c): array { return array_values(array_filter($this->zones, fn($z) => $z['camera_id'] === $c)); }
public function find(string $id): ?array { return $this->zones[$id] ?? null; }
public function save(array $z): string { $id = $z['zone_id'] ?? ('z' . (++$this->seq)); $this->zones[$id] = $z + ['zone_id' => $id]; return $id; } public function save(array $z): string { $id = $z['zone_id'] ?? ('z' . (++$this->seq)); $this->zones[$id] = $z + ['zone_id' => $id]; return $id; }
public function delete(string $id): bool { if (!isset($this->zones[$id])) return false; unset($this->zones[$id]); return true; } public function delete(string $id): bool { if (!isset($this->zones[$id])) return false; unset($this->zones[$id]); return true; }
} }

View file

@ -5,13 +5,19 @@ use App\Http\Request;
use App\Auth\ArraySessionStore; use App\Auth\ArraySessionStore;
use App\Geometry\Polygon; use App\Geometry\Polygon;
use App\Dashboard\ZoneConfigController; use App\Dashboard\ZoneConfigController;
use App\Tests\Support\{InMemoryZoneRepository, InMemoryAuditRepository}; use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class ZoneConfigTest extends TestCase final class ZoneConfigTest extends TestCase
{ {
private function authed(string $role = 'operator'): ArraySessionStore { private function authed(string $role = 'operator', ?string $site = null): ArraySessionStore {
$s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s; $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok');
if ($site !== null) $s->set('site_scope', $site);
return $s;
}
/** Device repo mapping the fixture devices to their sites (empty = every device unscoped). */
private function devices(array $sites = []): InMemoryDeviceRepository {
return new InMemoryDeviceRepository([], [], $sites);
} }
public function testPolygonValidation(): void { public function testPolygonValidation(): void {
$this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]])); $this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
@ -22,39 +28,56 @@ final class ZoneConfigTest extends TestCase
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []); return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
} }
public function testSaveRequiresPermission(): void { public function testSaveRequiresPermission(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('viewer')); $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer'));
$res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
$this->assertSame(403, $res->status()); $this->assertSame(403, $res->status());
} }
public function testSaveValidZone(): void { public function testSaveValidZone(): void {
$repo = new InMemoryZoneRepository(); $repo = new InMemoryZoneRepository();
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
$this->assertSame(201, $res->status()); $this->assertSame(201, $res->status());
$this->assertCount(1, $repo->forCamera('cam-1')); $this->assertCount(1, $repo->forCamera('cam-1'));
} }
public function testSaveOwnershipMismatchIs400NotError(): void { public function testSaveOwnershipMismatchIs400NotError(): void {
// The repo throws RuntimeException when the zone isn't owned by the given device/camera. // The repo throws ZoneOwnershipException when the zone isn't owned by the given
// The controller must turn that client error into 400, not let it become a 500. // device/camera. The controller must turn that client error into 400, not a 500 — and,
// crucially, must NOT catch a real \PDOException (which also extends \RuntimeException).
$throwing = new class implements \App\Repository\ZoneRepository { $throwing = new class implements \App\Repository\ZoneRepository {
public function forDevice(string $d): array { return []; } public function forDevice(string $d): array { return []; }
public function forCamera(string $c): array { return []; } public function forCamera(string $c): array { return []; }
public function save(array $z): string { throw new \RuntimeException('not owned'); } public function find(string $id): ?array { return ['zone_id' => $id, 'device_id' => 'pi-2', 'camera_id' => 'cam-1']; }
public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); }
public function delete(string $id): bool { return true; } public function delete(string $id): bool { return true; }
}; };
$c = new ZoneConfigController($throwing, new InMemoryAuditRepository(), $this->authed('operator')); $c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1', $res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1',
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
$this->assertSame(400, $res->status()); $this->assertSame(400, $res->status());
} }
public function testSaveLetsDbFaultBecome500NotInvalidZone(): void {
// A genuine \PDOException must NOT be masked as 400 invalid_zone — it should propagate so
// the front controller returns 500 and the fault is not silently swallowed.
$dbFault = new class implements \App\Repository\ZoneRepository {
public function forDevice(string $d): array { return []; }
public function forCamera(string $c): array { return []; }
public function find(string $id): ?array { return ['zone_id' => $id, 'device_id' => 'pi-1', 'camera_id' => 'cam-1']; }
public function save(array $z): string { throw new \PDOException('server has gone away'); }
public function delete(string $id): bool { return true; }
};
$c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$this->expectException(\PDOException::class);
$c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1',
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
}
public function testSaveRejectsBadLevelOrPolygon(): void { public function testSaveRejectsBadLevelOrPolygon(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('operator')); $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status());
} }
public function testSaveRejectsMissingOrWrongCsrf(): void { public function testSaveRejectsMissingOrWrongCsrf(): void {
$repo = new InMemoryZoneRepository(); $repo = new InMemoryZoneRepository();
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
$body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]; $body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]];
// missing header // missing header
$this->assertSame(403, $c->save($this->saveReq($body, []))->status()); $this->assertSame(403, $c->save($this->saveReq($body, []))->status());
@ -66,7 +89,7 @@ final class ZoneConfigTest extends TestCase
public function testDeleteRejectsMissingOrWrongCsrf(): void { public function testDeleteRejectsMissingOrWrongCsrf(): void {
$repo = new InMemoryZoneRepository(); $repo = new InMemoryZoneRepository();
$repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); $repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']);
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
// missing header // missing header
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []); $missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status()); $this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
@ -80,4 +103,62 @@ final class ZoneConfigTest extends TestCase
$this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status()); $this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status());
$this->assertCount(0, $repo->forCamera('cam-1')); $this->assertCount(0, $repo->forCamera('cam-1'));
} }
// ---- P3-2b: site-scoped access control on zone endpoints ---------------------------------
private function seededRepo(): InMemoryZoneRepository {
$repo = new InMemoryZoneRepository();
// z-a belongs to device pi-a (factory-a); z-b to pi-b (factory-b).
$repo->save(['zone_id' => 'z-a', 'device_id' => 'pi-a', 'camera_id' => 'cam-a', 'level' => 'restricted', 'polygon_json' => '[[0,0],[1,0],[1,1]]']);
$repo->save(['zone_id' => 'z-b', 'device_id' => 'pi-b', 'camera_id' => 'cam-b', 'level' => 'restricted', 'polygon_json' => '[[0,0],[1,0],[1,1]]']);
return $repo;
}
private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController {
return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
new InMemoryAuditRepository(), $this->authed('operator', $site));
}
private function zonesOf($res): array {
return json_decode($res->body(), true)['zones'];
}
public function testScopedOperatorCannotDeleteCrossSiteZone(): void {
$repo = $this->seededRepo();
$c = $this->scopedController($repo, 'factory-b'); // scoped to factory-b
$ok = new Request('POST', '/api/zones/z-a/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
$this->assertSame(404, $c->delete($ok, ['id' => 'z-a'])->status()); // z-a is factory-a
$this->assertCount(1, $repo->forCamera('cam-a')); // still present
}
public function testScopedOperatorCannotReadCrossSiteZones(): void {
$repo = $this->seededRepo();
$c = $this->scopedController($repo, 'factory-b');
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
$this->assertSame(200, $res->status());
$this->assertSame([], $this->zonesOf($res)); // factory-a hidden
}
public function testScopedOperatorCannotCreateOnCrossSiteDevice(): void {
$repo = $this->seededRepo();
$c = $this->scopedController($repo, 'factory-b');
$res = $c->save($this->saveReq(['device_id' => 'pi-a', 'camera_id' => 'cam-a', 'level' => 'restricted', 'polygon' => [[0,0],[1,0],[1,1]]]));
$this->assertSame(404, $res->status());
}
public function testScopedOperatorSeesAndManagesOwnSite(): void {
$repo = $this->seededRepo();
$c = $this->scopedController($repo, 'factory-a');
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
$this->assertCount(1, $this->zonesOf($res)); // own site visible
$ok = new Request('POST', '/api/zones/z-a/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
$this->assertSame(200, $c->delete($ok, ['id' => 'z-a'])->status()); // own site deletable
}
public function testGlobalOperatorSeesEverySite(): void {
$repo = $this->seededRepo();
$c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']),
new InMemoryAuditRepository(), $this->authed('operator', null)); // no site_scope = global
$res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']);
$this->assertCount(1, $this->zonesOf($res));
}
public function testListRequiresAuthenticatedRole(): void {
$s = new ArraySessionStore(); // no user_id
$c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s);
$this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status());
}
} }

View file

@ -117,12 +117,12 @@ cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/de
chmod +x "$STAGE/install_offline.sh" chmod +x "$STAGE/install_offline.sh"
# Supply-chain integrity (air-gap: the target can't reach PyPI to re-resolve, so pin + hash here). # Supply-chain integrity (air-gap: the target can't reach PyPI to re-resolve, so pin + hash here).
# SHA256SUMS — sha256 of every wheel + model; install_offline verifies before installing.
# requirements.lock — exact name==version of every bundled wheel, for a reproducible rebuild. # requirements.lock — exact name==version of every bundled wheel, for a reproducible rebuild.
# sbom.json — CycloneDX component list (name, version, pypi purl, sha256). # sbom.json — CycloneDX component list: wheels AND models (name, version, sha256).
echo ">> generating integrity manifest (SHA256SUMS, requirements.lock, sbom.json)" # SHA256SUMS — sha256 of EVERY staged file (code, unit, installer, env template, models,
( cd "$STAGE" && sha256sum wheelhouse/*.whl models/* > SHA256SUMS ) # wheels, lock, sbom, MANIFEST). Generated LAST so it covers everything
# install_offline.sh lays down and runs as root — not just the wheels/models.
echo ">> generating requirements.lock + sbom.json"
: > "$STAGE/requirements.lock" : > "$STAGE/requirements.lock"
{ {
printf '{\n "bomFormat": "CycloneDX",\n "specVersion": "1.5",\n "version": 1,\n "components": [\n' printf '{\n "bomFormat": "CycloneDX",\n "specVersion": "1.5",\n "version": 1,\n "components": [\n'
@ -138,22 +138,23 @@ echo ">> generating integrity manifest (SHA256SUMS, requirements.lock, sbom.json
"$sep" "$name" "$ver" "$name" "$ver" "$sum" "$sep" "$name" "$ver" "$name" "$ver" "$sum"
sep=$',\n' sep=$',\n'
done done
# ML weights are shipped artifacts too — record them so a customer-side SBOM audit is complete.
for mdl in "$STAGE"/models/*; do
[ -e "$mdl" ] || continue
mbase="$(basename "$mdl")"
msum="$(sha256sum "$mdl" | cut -d' ' -f1)"
printf '%s {"type":"file","name":"%s","hashes":[{"alg":"SHA-256","content":"%s"}]}' \
"$sep" "$mbase" "$msum"
sep=$',\n'
done
printf '\n ]\n}\n' printf '\n ]\n}\n'
} > "$STAGE/sbom.json" } > "$STAGE/sbom.json"
sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock" sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock"
# Optional detached GPG signature over the integrity file. The air-gap target verifies via
# SHA256SUMS regardless; a signature adds provenance when a signing key exists on the build box.
if [ -n "${SIGN_KEY:-}" ]; then
gpg --local-user "$SIGN_KEY" --armor --detach-sign --output "$STAGE/SHA256SUMS.asc" "$STAGE/SHA256SUMS"
echo ">> signed SHA256SUMS with key $SIGN_KEY -> SHA256SUMS.asc"
else
echo ">> NOTE: SHA256SUMS is unsigned (set SIGN_KEY=<gpg-id> to add a detached signature)."
fi
# Which pose backends this bundle can actually run, so the operator knows before it crosses the # Which pose backends this bundle can actually run, so the operator knows before it crosses the
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX. # air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
{ {
echo "bundle: move-detect-edge" echo "bundle: move-detect-edge"
echo "built_mode: $MODE" echo "built_mode: $MODE"
@ -161,15 +162,34 @@ HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
echo "target_arch: $TARGET_ARCH" echo "target_arch: $TARGET_ARCH"
echo "hailo_arch: $HAILO_ARCH" echo "hailo_arch: $HAILO_ARCH"
echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')" echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')"
echo "integrity: SHA256SUMS (verified on install), requirements.lock, sbom.json$([ -f "$STAGE/SHA256SUMS.asc" ] && echo ', SHA256SUMS.asc (signed)')" echo "integrity: SHA256SUMS covers ALL bundle files (verified on install)$([ "$SIGNED" = yes ] && echo ', SHA256SUMS.asc (signed)')"
echo "models:" echo "models:"
ls -1 "$STAGE/models" ls -1 "$STAGE/models"
echo "wheels:" echo "wheels:"
ls -1 "$WHEELHOUSE" ls -1 "$WHEELHOUSE"
} > "$STAGE/MANIFEST.txt" } > "$STAGE/MANIFEST.txt"
# SHA256SUMS LAST: hash every staged file EXCEPT the manifest itself and its detached signature,
# so the daemon source, systemd unit, installer, env template, models and wheels are ALL covered.
# A tampered edge/daemon.py can no longer pass `sha256sum -c` and then be installed and run.
( cd "$STAGE" && find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' -print0 \
| sort -z | xargs -0 sha256sum > SHA256SUMS )
# Optional detached GPG signature over the integrity file. When present, install_offline.sh
# REQUIRES it to verify (a failed signature is fatal there); when absent the bundle offers
# corruption-detection only. Provenance-critical deliveries MUST set SIGN_KEY.
if [ -n "${SIGN_KEY:-}" ]; then
gpg --local-user "$SIGN_KEY" --armor --detach-sign --output "$STAGE/SHA256SUMS.asc" "$STAGE/SHA256SUMS"
echo ">> signed SHA256SUMS with key $SIGN_KEY -> SHA256SUMS.asc"
else
echo ">> NOTE: SHA256SUMS is unsigned (corruption-detection only). Set SIGN_KEY=<gpg-id> for a"
echo ">> detached signature; install_offline.sh will then require it to verify."
fi
echo ">> [6/6] packaging tarball" echo ">> [6/6] packaging tarball"
TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}.tar.gz" # 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" tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle"
echo ">> DONE: $TARBALL" echo ">> DONE: $TARBALL"
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi." echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."

View file

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

View file

@ -46,13 +46,18 @@ StateDirectoryMode=0750
# unlisted device node, so the CSI camera needs its nodes explicitly: # unlisted device node, so the CSI camera needs its nodes explicitly:
# char-video4linux USB UVC (/dev/video*) AND the CSI capture nodes # char-video4linux USB UVC (/dev/video*) AND the CSI capture nodes
# char-media picamera2/libcamera opens /dev/media* to configure the CSI pipeline (Pi 5) # char-media picamera2/libcamera opens /dev/media* to configure the CSI pipeline (Pi 5)
# /dev/dma_heap/* libcamera allocates capture buffers from the dma-heap on Pi 5 # char-dma_heap libcamera allocates capture buffers from the dma-heap on Pi 5
# Without char-media + dma_heap a Hailo Pi with a CSI camera is denied its own camera by this # Without char-media + char-dma_heap a Hailo Pi with a CSI camera is denied its own camera by this
# unit's policy. (OFF-HW: verify on the Pi — `journalctl` shows a libcamera EPERM/open failure.) # unit's policy. (OFF-HW: verify on the Pi — `journalctl` shows a libcamera EPERM/open failure.)
#
# NOTE: DeviceAllow= takes a device-node path OR a `char-<group>`/`block-<group>` name — NOT a
# directory. `/dev/dma_heap/` (a directory, trailing slash) is rejected at cgroup-realize time and
# silently dropped, leaving dma-heap DENIED under this deny-by-default policy. Use the char group
# (dma_heap appears in /proc/devices as a char device) so the whole /dev/dma_heap/* set is allowed.
DeviceAllow=/dev/hailo0 rw DeviceAllow=/dev/hailo0 rw
DeviceAllow=char-video4linux rw DeviceAllow=char-video4linux rw
DeviceAllow=char-media rw DeviceAllow=char-media rw
DeviceAllow=/dev/dma_heap/ rw DeviceAllow=char-dma_heap rw
# Conservative hardening (loosen if it blocks device access on your image). # Conservative hardening (loosen if it blocks device access on your image).
NoNewPrivileges=true NoNewPrivileges=true

View file

@ -2,6 +2,7 @@ import base64
import hashlib import hashlib
import hmac import hmac
import json import json
from enum import Enum
from edge.events import Event from edge.events import Event
@ -11,6 +12,19 @@ from edge.events import Event
USER_AGENT = "move-detect-edge/1.0" USER_AGENT = "move-detect-edge/1.0"
class SendResult(Enum):
"""Outcome of one upload attempt. The offline queue must tell a transient outage (retry
forever — a real fall alert has to survive an overnight internet outage) apart from a
permanent rejection (poison — count toward the drop cap), which a bare bool cannot express.
DELIVERED is the only truthy value, so `if client.send(...)` keeps working unchanged."""
DELIVERED = "delivered"
REJECTED = "rejected" # a definite 4xx (not 429): the server will never accept this event
TRANSIENT = "transient" # network error / timeout / 429 / 5xx — connectivity or server hiccup
def __bool__(self) -> bool:
return self is SendResult.DELIVERED
def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str: def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str:
if isinstance(body, str): if isinstance(body, str):
body = body.encode() body = body.encode()
@ -32,7 +46,7 @@ class PostClient:
http_post = requests.post http_post = requests.post
self._post = http_post self._post = http_post
def send(self, event: Event, snapshots: list[bytes]) -> bool: def send(self, event: Event, snapshots: list[bytes]) -> SendResult:
payload = event.to_payload(self.device_id) payload = event.to_payload(self.device_id)
payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots] payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots]
body = json.dumps(payload, sort_keys=True) body = json.dumps(payload, sort_keys=True)
@ -51,5 +65,15 @@ class PostClient:
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout, resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
allow_redirects=False) allow_redirects=False)
except Exception: except Exception:
return False # Connection refused / DNS / timeout — the cloud is unreachable, not rejecting us.
return 200 <= resp.status_code < 300 return SendResult.TRANSIENT
code = resp.status_code
if 200 <= code < 300:
return SendResult.DELIVERED
# Transient — "back off and retry", NOT a permanent verdict on the event: all 5xx plus the
# 4xx codes that explicitly mean try-again (408 Request Timeout, 425 Too Early, 429 Too
# Many Requests). Counting these toward the poison drop cap would delete real events during
# a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection.
if code in (408, 425, 429) or code >= 500:
return SendResult.TRANSIENT
return SendResult.REJECTED

View file

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

View file

@ -15,7 +15,7 @@ class FallConfig:
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴) height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
prone_confirm_s: float = 3.0 prone_confirm_s: float = 3.0
no_motion_s: float = 10.0 no_motion_s: float = 10.0
motion_thr: float = 0.02 # 정규화 관절변위 임계 motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
min_conf: float = 0.5 min_conf: float = 0.5
min_visible: int = 10 min_visible: int = 10
recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간 recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간
@ -76,8 +76,20 @@ class FallDetector:
if not prone: if not prone:
if st.name in ("FALLING", "FALLEN", "NO_MOTION"): if st.name in ("FALLING", "FALLEN", "NO_MOTION"):
# Only a CONFIDENT non-prone frame may recover an active incident. A low-conf
# (kp_ok False) frame's bbox-only "not prone" reading is untrusted: occlusion of
# a still-fallen worker must FREEZE the incident, not run the recovery countdown
# on noise. Freezing also protects baseline_height below (only seeded here, when
# kp_ok holds, so a bad low-conf bbox can't corrupt the next fall's collapse cue).
if not kp_ok:
# Occlusion PAUSES recovery — it must not count toward the grace window. Reset
# so recovery requires a FRESH, continuous run of confident non-prone frames;
# otherwise a confident frame at t0 plus a lone confident frame after a long
# low-conf gap would recover instantly on wall-clock elapsed alone.
st.not_prone_since = None
return None, {}
# 단일 튐(noisy) 프레임만으로 CRITICAL 사고를 즉시 취소하지 않도록 # 단일 튐(noisy) 프레임만으로 CRITICAL 사고를 즉시 취소하지 않도록
# recover_grace_s만큼 지속된 비-prone을 요구한다. # recover_grace_s만큼 지속된 (신뢰) 비-prone을 요구한다.
if st.not_prone_since is None: if st.not_prone_since is None:
st.not_prone_since = now st.not_prone_since = now
return None, {} return None, {}

View file

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

View file

@ -61,6 +61,13 @@ def normalize(value: float, bbox_height: float) -> float:
return value / bbox_height if bbox_height else 0.0 return value / bbox_height if bbox_height else 0.0
# Keypoints below this confidence are treated as noise (commonly hallucinated at a frame
# corner). Averaging them into the motion estimate makes a still track read as moving, which
# suppresses no_motion/immobility exactly when they matter (a motionless fallen worker).
MOTION_MIN_CONF = 0.2
MOTION_MIN_KP = 3
def track_displacement(track) -> float: def track_displacement(track) -> float:
"""Mean per-keypoint SPEED between the last two frames: displacement normalized by bbox """Mean per-keypoint SPEED between the last two frames: displacement normalized by bbox
height AND by elapsed time (per second). Without the time term the value scales with the height AND by elapsed time (per second). Without the time term the value scales with the
@ -68,8 +75,12 @@ def track_displacement(track) -> float:
high fps — flipping immobility/no_motion with the daemon's actual (load-dependent) fps. high fps — flipping immobility/no_motion with the daemon's actual (load-dependent) fps.
Returns 1.0 (moving/unknown) with <2 frames or a non-positive dt. Returns 1.0 (moving/unknown) with <2 frames or a non-positive dt.
NOTE: motion_thr in FallConfig/ImmobilityConfig is now a per-SECOND threshold; its default Only keypoints the model is confident about in BOTH frames are averaged; below
predates this change and should be re-tuned on device.""" MOTION_MIN_KP trustworthy points it falls back to the bbox centre (a stable proxy that
doesn't ride on one noisy joint).
NOTE: motion_thr in FallConfig/ImmobilityConfig is a per-SECOND threshold (units/second),
tunable via MOTION_THR in the environment; recalibrate on device against real jitter."""
if len(track.frames) < 2: if len(track.frames) < 2:
return 1.0 return 1.0
a, b = track.frames[-2], track.frames[-1] a, b = track.frames[-2], track.frames[-1]
@ -77,6 +88,10 @@ def track_displacement(track) -> float:
if dt <= 0: if dt <= 0:
return 1.0 return 1.0
h = b.bbox.height or 1.0 h = b.bbox.height or 1.0
d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 pairs = [(p, q) for p, q in zip(a.keypoints, b.keypoints)
for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints) if p.conf >= MOTION_MIN_CONF and q.conf >= MOTION_MIN_CONF]
if len(pairs) >= MOTION_MIN_KP:
d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 for p, q in pairs) / len(pairs)
else:
d = ((a.bbox.cx - b.bbox.cx) ** 2 + (a.bbox.cy - b.bbox.cy) ** 2) ** 0.5
return normalize(d, h) / dt return normalize(d, h) / dt

View file

@ -2,6 +2,7 @@ import base64
import json import json
import sqlite3 import sqlite3
from edge.alert import SendResult
from edge.events import Event, EventType, Severity from edge.events import Event, EventType, Severity
@ -66,23 +67,36 @@ class OfflineQueue:
payload = json.loads(payload_json) payload = json.loads(payload_json)
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)] snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
event, snapshots = self._rehydrate(payload, snaps) event, snapshots = self._rehydrate(payload, snaps)
if self.client.send(event, snapshots): # 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).
if not payload.get("event_uuid"):
payload["event_uuid"] = event.event_uuid
self.db.execute("UPDATE queue SET payload = ? WHERE id = ?",
(json.dumps(payload), rid))
self.db.commit()
result = self.client.send(event, snapshots)
if result: # SendResult.DELIVERED (only truthy result)
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
self.db.commit() self.db.commit()
sent += 1 sent += 1
continue continue
# Failed. A bool send() can't tell a transient outage from a permanently-rejected # Not delivered. Only a DEFINITE rejection (4xx) counts toward the poison drop cap —
# (poison) event, so count attempts: below the cap, stop and preserve order (an # a transient outage (network error / 429 / 5xx) must retry forever so a real fall
# outage recovers and the whole queue drains next flush); at the cap, DROP this # alert survives an overnight outage; max_rows already bounds the disk. This is the
# row and continue so a poison head can never wedge the queue forever. # fix for the old bool send(), which counted every offline failure toward the cap and
# silently deleted the oldest queued safety events after ~20 flushes of downtime.
if result is SendResult.REJECTED:
attempts += 1 attempts += 1
if attempts >= self.max_send_attempts: if attempts >= self.max_send_attempts:
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
self.db.commit() self.db.commit()
continue continue # poison dropped — let the events behind it drain
self.db.execute("UPDATE queue SET attempts = ? WHERE id = ?", (attempts, rid)) self.db.execute("UPDATE queue SET attempts = ? WHERE id = ?", (attempts, rid))
self.db.commit() self.db.commit()
break # transient — preserve order, retry the whole queue next time break # preserve order; retry the whole queue next flush
return sent return sent
def pending(self) -> int: def pending(self) -> int:

View file

@ -1,7 +1,21 @@
import json import json
import logging
from edge.detectors.zone import Zone from edge.detectors.zone import Zone
log = logging.getLogger(__name__)
# Zones are stored NORMALIZED (0..1 of the image) by the current dashboard. Polygons drawn by the
# pre-normalization dashboard were saved in canvas PIXELS (values in the hundreds); after the edge
# normalizes the foot point, a pixel polygon can never match — silently killing that zone's
# intrusion detection with no error. Flag such rows loudly so the operator redraws them, instead
# of failing dark. A normalized coordinate may round slightly past 1.0, so allow a small margin.
_NORMALIZED_MAX = 1.5
def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon)
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: 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 cloud config zone rows into Zone objects. When camera_id is given, only
@ -19,6 +33,13 @@ def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
polygon = [(float(p[0]), float(p[1])) for p in pts] polygon = [(float(p[0]), float(p[1])) for p in pts]
if len(polygon) < 3: if len(polygon) < 3:
continue continue
if _looks_like_pixels(polygon):
log.warning(
"zone %s on camera %s has un-normalized (pixel-space) coordinates %r — it "
"would match nothing after foot normalization. Skipping; redraw this zone in "
"the dashboard so it is stored normalized (0..1).",
row.get("zone_id"), row.get("camera_id"), polygon[:2])
continue
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon)) out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError): except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError):
continue continue

View file

@ -1,6 +1,6 @@
import json import json
from edge.events import Event, EventType, Severity from edge.events import Event, EventType, Severity
from edge.alert import sign, PostClient from edge.alert import sign, PostClient, SendResult
def _event(): def _event():
return Event(EventType.FALL, Severity.HIGH, "cam-01", 7, None, 1000.0, 3.0, return Event(EventType.FALL, Severity.HIGH, "cam-01", 7, None, 1000.0, 3.0,
@ -27,7 +27,7 @@ def test_send_posts_signed_payload():
nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, nonce_fn=lambda: "n1", now_fn=lambda: 1000.0,
http_post=fake_post) http_post=fake_post)
ok = client.send(_event(), [b"\xff\xd8jpeg"]) ok = client.send(_event(), [b"\xff\xd8jpeg"])
assert ok is True assert ok is SendResult.DELIVERED
assert captured["headers"]["X-Nonce"] == "n1" assert captured["headers"]["X-Nonce"] == "n1"
assert captured["headers"]["X-Timestamp"] == "1000.0" assert captured["headers"]["X-Timestamp"] == "1000.0"
payload = json.loads(captured["body"]) payload = json.loads(captured["body"])
@ -43,7 +43,26 @@ def test_send_posts_signed_payload():
# is a misconfiguration to surface, not to silently downgrade the POST. # is a misconfiguration to surface, not to silently downgrade the POST.
assert captured["kw"].get("allow_redirects") is False assert captured["kw"].get("allow_redirects") is False
def test_send_returns_false_on_error_code(): def test_send_classifies_5xx_as_transient():
# 5xx / 429 are "retry", not a permanent verdict — the offline queue must keep retrying, not
# count these toward the poison drop cap. (A bare bool could not express this distinction.)
client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
http_post=lambda *a, **k: _FakeResp(500)) http_post=lambda *a, **k: _FakeResp(500))
assert client.send(_event(), []) is False assert client.send(_event(), []) is SendResult.TRANSIENT
for code in (408, 425, 429): # try-again 4xx codes are transient, not permanent rejections
c = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
http_post=lambda *a, _c=code, **k: _FakeResp(_c))
assert c.send(_event(), []) is SendResult.TRANSIENT
def test_send_classifies_4xx_as_rejected():
# A definite 4xx (not 429) means the cloud will never accept this event — the queue counts it
# toward the poison drop cap so it can eventually be discarded and unblock the queue.
client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
http_post=lambda *a, **k: _FakeResp(400))
assert client.send(_event(), []) is SendResult.REJECTED
def test_send_treats_transport_error_as_transient():
def boom(*a, **k):
raise ConnectionError("network down")
client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, http_post=boom)
assert client.send(_event(), []) is SendResult.TRANSIENT

View file

@ -49,10 +49,39 @@ def test_require_upload_config_rejects_blank_url_and_secret():
def test_require_upload_config_passes_when_both_present(): def test_require_upload_config_passes_when_both_present():
cfg = load_config({"CAFE24_URL": "https://x/move_detect", "DEVICE_SECRET": "s"}) # A real per-device secret is a long HMAC-derived hex string, on an https URL.
cfg = load_config({"CAFE24_URL": "https://x/move_detect",
"DEVICE_SECRET": "0123456789abcdef0123"})
require_upload_config(cfg) # must not raise require_upload_config(cfg) # must not raise
def test_require_upload_config_rejects_placeholder_and_http():
# Non-empty ship-time placeholders pass a blank check but are never a real value; a unit left
# at one uploads nothing while looking healthy. http would leak worker snapshots in plaintext.
with pytest.raises(ValueError, match="DEVICE_SECRET"):
require_upload_config(load_config({"CAFE24_URL": "https://x/move_detect",
"DEVICE_SECRET": "CHANGEME"}))
with pytest.raises(ValueError, match="CAFE24_URL"):
require_upload_config(load_config({"CAFE24_URL": "https://your-domain.example/move_detect",
"DEVICE_SECRET": "0123456789abcdef0123"}))
with pytest.raises(ValueError, match="https"):
require_upload_config(load_config({"CAFE24_URL": "http://x/move_detect",
"DEVICE_SECRET": "0123456789abcdef0123"}))
with pytest.raises(ValueError, match="too short"):
require_upload_config(load_config({"CAFE24_URL": "https://x/move_detect",
"DEVICE_SECRET": "short"}))
def test_load_config_rejects_non_finite_or_nonpositive_motion_thr():
# A 0 / NaN / inf MOTION_THR would make every track read as "still" -> constant no_motion.
for bad in ("0", "-1", "nan", "inf"):
with pytest.raises(ValueError, match="MOTION_THR"):
load_config({"MOTION_THR": bad})
# A sane value flows through to both detectors.
cfg = load_config({"MOTION_THR": "0.3"})
assert cfg.fall.motion_thr == 0.3 and cfg.immobility.motion_thr == 0.3
def test_frame_interval_defaults_and_validates(): def test_frame_interval_defaults_and_validates():
assert frame_interval_from_env({}) == pytest.approx(1 / 12) assert frame_interval_from_env({}) == pytest.approx(1 / 12)
assert frame_interval_from_env({"TARGET_FPS": "24"}) == pytest.approx(1 / 24) assert frame_interval_from_env({"TARGET_FPS": "24"}) == pytest.approx(1 / 24)

View file

@ -249,17 +249,28 @@ def test_offline_bundle_emits_pinned_integrity_manifest():
with open(BUILD_OFFLINE, encoding="utf-8") as f: with open(BUILD_OFFLINE, encoding="utf-8") as f:
sh = f.read() sh = f.read()
# Air-gap supply-chain: the target can't re-resolve from PyPI, so the build must pin exact # Air-gap supply-chain: the target can't re-resolve from PyPI, so the build must pin exact
# versions, hash every artifact, and ship an SBOM. GPG signing is optional (SIGN_KEY). # versions, hash EVERY artifact (not just wheels/models — the daemon source, unit and installer
assert "sha256sum wheelhouse/*.whl models/* > SHA256SUMS" in sh # too), and ship an SBOM. GPG signing is optional (SIGN_KEY).
assert "find . -type f" in sh and "sha256sum > SHA256SUMS" in sh # hashes every staged file
assert "! -name SHA256SUMS" in sh # except the manifest itself
assert "requirements.lock" in sh # exact name==version pins for a reproducible rebuild assert "requirements.lock" in sh # exact name==version pins for a reproducible rebuild
assert "sbom.json" in sh assert "sbom.json" in sh
assert "CycloneDX" in sh assert "CycloneDX" in sh
assert 'for mdl in "$STAGE"/models/*' in sh # SBOM includes the ML model weights, not just wheels
assert 'if [ -n "${SIGN_KEY:-}" ]; then' in sh # optional detached signature assert 'if [ -n "${SIGN_KEY:-}" ]; then' in sh # optional detached signature
def test_offline_installer_verifies_integrity_before_install(): def test_offline_installer_verifies_integrity_before_install():
with open(INSTALL_OFFLINE, encoding="utf-8") as f: with open(INSTALL_OFFLINE, encoding="utf-8") as f:
sh = f.read() sh = f.read()
# A truncated/tampered wheel must fail loudly BEFORE any pip install, not install silently. # A truncated/tampered bundle must fail loudly BEFORE any system mutation — not after rm -rf
# has destroyed the previous install and the pip step has already run.
assert "sha256sum -c SHA256SUMS" in sh assert "sha256sum -c SHA256SUMS" in sh
assert "integrity check failed" in sh assert "integrity check failed" in sh
# Ordering (the substance of the fix): the verify must come BEFORE the first $PREFIX mutation
# and before the offline wheel install.
verify_at = sh.index("sha256sum -c SHA256SUMS")
assert verify_at < sh.index('rm -rf "$PREFIX/src/edge') # before destroying the old tree
assert verify_at < sh.index("install --no-index") # before installing wheels
# A missing SHA256SUMS must be FATAL (refuse), not a warn-and-proceed that installs blind.
assert "refusing to install unverified code" in sh

View file

@ -7,11 +7,13 @@ from edge.daemon import run_loop, parse_cameras, build_runtimes
def _cfg_two_cams(): def _cfg_two_cams():
# Polygons are stored NORMALIZED (0..1 of the image); zones_from_config now rejects
# un-normalized (pixel-space) coordinates as legacy data.
return {"zones": [ return {"zones": [
{"zone_id": "z1", "camera_id": "cam-01", "level": "restricted", {"zone_id": "z1", "camera_id": "cam-01", "level": "restricted",
"polygon_json": "[[0,0],[9,0],[9,9]]"}, "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]"},
{"zone_id": "z2", "camera_id": "cam-02", "level": "warning", {"zone_id": "z2", "camera_id": "cam-02", "level": "warning",
"polygon_json": "[[0,0],[5,0],[5,5]]"}, "polygon_json": "[[0,0],[0.5,0],[0.5,0.5]]"},
], "thresholds": {}} ], "thresholds": {}}

View file

@ -1,5 +1,9 @@
import json
import sqlite3
from edge.events import Event, EventType, Severity from edge.events import Event, EventType, Severity
from edge.queue import OfflineQueue from edge.queue import OfflineQueue
from edge.alert import SendResult
def _event(n=1): def _event(n=1):
return Event(EventType.FALL, Severity.HIGH, "cam-01", n, None, float(n), 3.0, {}, None) return Event(EventType.FALL, Severity.HIGH, "cam-01", n, None, float(n), 3.0, {}, None)
@ -37,15 +41,15 @@ def test_flush_resends_when_recovered(tmp_path):
class _PoisonClient: class _PoisonClient:
"""Rejects one specific event forever (e.g. a 400 the cloud will never accept).""" """Rejects one specific event forever with a definite 4xx the cloud will never accept."""
def __init__(self, poison_id): def __init__(self, poison_id):
self.poison = poison_id self.poison = poison_id
self.sent = [] self.sent = []
def send(self, event, snapshots): def send(self, event, snapshots):
if event.track_id == self.poison: if event.track_id == self.poison:
return False return SendResult.REJECTED
self.sent.append(event.track_id) self.sent.append(event.track_id)
return True return SendResult.DELIVERED
def test_queue_is_bounded_and_keeps_newest(tmp_path): def test_queue_is_bounded_and_keeps_newest(tmp_path):
@ -91,3 +95,54 @@ def test_poison_head_is_dropped_and_unblocks_the_queue(tmp_path):
q.flush() q.flush()
assert client.sent == [2] # good event delivered assert client.sent == [2] # good event delivered
assert q.pending() == 0 # poison dropped, queue drained assert q.pending() == 0 # poison dropped, queue drained
def test_sustained_outage_never_drops_events(tmp_path):
# A transient outage (network down / 5xx) must NEVER poison-drop queued events, no matter how
# long it lasts — a real fall alert has to survive an overnight outage. Only a definite 4xx
# rejection counts toward the drop cap (contrast test_poison_head_is_dropped above).
class _Outage:
up = False
sent = []
def send(self, event, snapshots):
if not _Outage.up:
return SendResult.TRANSIENT
_Outage.sent.append(event.track_id)
return SendResult.DELIVERED
client = _Outage()
q = OfflineQueue(str(tmp_path / "q.db"), client, max_send_attempts=3)
q.enqueue(_event(1), [b"x"])
for _ in range(10): # far beyond max_send_attempts
q.flush()
assert q.pending() == 1 # still queued — a transient failure is never poison-dropped
_Outage.up = True
q.flush()
assert q.pending() == 0 and _Outage.sent == [1] # drains once connectivity returns
def test_legacy_row_without_uuid_reuses_minted_uuid_across_retries(tmp_path):
# A row queued before event_uuid existed has none stored. On first flush a fresh uuid is
# minted; it MUST be persisted so every retry reuses the SAME key — else each failed-then-
# retried delivery sends a different uuid and the cloud stores duplicates (the P1-9 bug).
db = str(tmp_path / "q.db")
con = sqlite3.connect(db)
con.execute("CREATE TABLE queue (id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, "
"snaps TEXT, attempts INTEGER DEFAULT 0)")
legacy = {"event_type": "fall", "severity": "high", "camera_id": "cam-01", "track_id": 1,
"zone_id": None, "occurred_at": 100.0, "duration_s": 3.0, "metrics": {}, "spatial": None}
con.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)",
(json.dumps(legacy), json.dumps([])))
con.commit(); con.close()
class _Capture:
up = False
uuids = []
def send(self, event, snapshots):
_Capture.uuids.append(event.event_uuid)
return SendResult.DELIVERED if _Capture.up else SendResult.TRANSIENT
q = OfflineQueue(db, _Capture())
q.flush() # transient -> row stays; uuid minted and persisted
_Capture.up = True
q.flush() # delivered
assert len(_Capture.uuids) == 2
assert _Capture.uuids[0] == _Capture.uuids[1] # same key reused across retries

View file

@ -1,15 +1,19 @@
import logging
from edge.zones import zones_from_config, thresholds_from_config from edge.zones import zones_from_config, thresholds_from_config
# Zones ship NORMALIZED (0..1 of the image); fixtures use that range.
def test_parses_zone_rows_json_string_polygon(): def test_parses_zone_rows_json_string_polygon():
cfg = {"zones": [ cfg = {"zones": [
{"zone_id": "z1", "camera_id": "cam-1", "level": "restricted", {"zone_id": "z1", "camera_id": "cam-1", "level": "restricted",
"polygon_json": "[[0,0],[9,0],[9,9]]"}, "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]"},
], "thresholds": {"fall_prone_s": 3.0}} ], "thresholds": {"fall_prone_s": 3.0}}
zones = zones_from_config(cfg) zones = zones_from_config(cfg)
assert len(zones) == 1 assert len(zones) == 1
assert zones[0].zone_id == "z1" assert zones[0].zone_id == "z1"
assert zones[0].level == "restricted" assert zones[0].level == "restricted"
assert zones[0].polygon == [(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)] assert zones[0].polygon == [(0.0, 0.0), (0.9, 0.0), (0.9, 0.9)]
assert thresholds_from_config(cfg)["fall_prone_s"] == 3.0 assert thresholds_from_config(cfg)["fall_prone_s"] == 3.0
def test_skips_malformed_row(): def test_skips_malformed_row():
@ -22,8 +26,8 @@ def test_empty_config():
def test_skips_short_point_without_crashing(): def test_skips_short_point_without_crashing():
cfg = {"zones": [ cfg = {"zones": [
{"zone_id": "bad", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9],[9,9]]"}, {"zone_id": "bad", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9],[0.9,0.9]]"},
{"zone_id": "ok", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9,0],[9,9]]"}, {"zone_id": "ok", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]"},
]} ]}
zones = zones_from_config(cfg) zones = zones_from_config(cfg)
# bad row skipped, good row survives -- function did not crash # bad row skipped, good row survives -- function did not crash
@ -31,3 +35,19 @@ def test_skips_short_point_without_crashing():
def test_non_iterable_zones_returns_empty(): def test_non_iterable_zones_returns_empty():
assert zones_from_config({"zones": 5}) == [] assert zones_from_config({"zones": 5}) == []
def test_rejects_legacy_pixel_space_polygon_and_warns(caplog):
# A polygon saved by the pre-normalization dashboard is in canvas PIXELS (values in the
# hundreds). After the edge normalizes the foot to 0..1 it can never match, so such a zone
# would silently detect nothing. zones_from_config must DROP it and warn loudly (so the
# operator redraws it) rather than fail dark.
cfg = {"zones": [
{"zone_id": "legacy", "camera_id": "cam-1", "level": "restricted",
"polygon_json": "[[320,180],[600,180],[600,400]]"},
{"zone_id": "modern", "camera_id": "cam-1", "level": "restricted",
"polygon_json": "[[0.25,0.25],[0.75,0.25],[0.75,0.75]]"},
]}
with caplog.at_level(logging.WARNING):
zones = zones_from_config(cfg)
assert [z.zone_id for z in zones] == ["modern"] # legacy dropped, modern kept
assert any("legacy" in r.message and "pixel" in r.message for r in caplog.records)