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

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() {
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) {
if (res.status === 401) {
window.location.href = B + '/login';

View file

@ -92,7 +92,7 @@ $csrfCtl = new CsrfController();
$list = new EventListController($events, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $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 {
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,
PRIMARY KEY (snapshot_id),
UNIQUE KEY uq_event_snapshots_uuid (file_uuid),
-- Idempotent snapshot recovery: a re-sent event carries the SAME re-encoded bytes (same
-- content_hash), so this makes a duplicate/racing store collide instead of writing a second
-- copy. Lets the dedup path safely re-store missing evidence without duplication.
UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash),
KEY ix_event_snapshots_event (event_id),
KEY ix_event_snapshots_created (created_at), -- retention purge scans WHERE created_at < ?
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -206,7 +211,10 @@ CREATE TABLE rate_limits (
rate_key VARCHAR(190) NOT NULL,
window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket
count INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (rate_key, window_start)
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;
-- ---------------------------------------------------------------------------
@ -228,19 +236,18 @@ CREATE TABLE audit_log (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===========================================================================
-- MIGRATIONS for an EXISTING database (fresh installs already have the columns
-- above). Apply once via phpMyAdmin, then set each device's site with:
-- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above):
-- do NOT copy the statements below by hand — they are SQL comments and will not
-- run. Apply the RUNNABLE migration instead:
--
-- cloud/migrations/2026-07-17-p1-9-p3-2b.sql
--
-- 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';
-- and scope a dashboard user with:
-- UPDATE users SET site_scope = 'factory-a' WHERE username = '...';
-- A user with site_scope = NULL is a global operator (sees every site).
-- ---------------------------------------------------------------------------
-- 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);
-- UPDATE users SET site_scope = 'factory-a' WHERE username = 'operator-a';
-- A user with site_scope = NULL is a global operator; '' is NOT global (matches
-- nothing — a blank scope fails closed).
-- ===========================================================================

View file

@ -21,8 +21,11 @@ final class EventListController
[$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor'));
// Site-scope access control (P3-2b): a scoped operator sees only their site's events; a
// null/empty scope is a global operator (no site filter). Pushed into the SQL WHERE.
// Pass the scope through as-is: only a genuinely null scope is global (no filter). An
// empty-string scope is a misconfiguration that must match NOTHING (fail closed), not be
// silently promoted to global by collapsing it to null here.
$scope = $this->sess->get('site_scope');
$site = (is_string($scope) && $scope !== '') ? $scope : null;
$site = is_string($scope) ? $scope : null;
$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.
@ -30,7 +33,13 @@ final class EventListController
$next = null;
if (count($rows) === $limit && $rows !== []) {
$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);
}
@ -41,12 +50,16 @@ final class EventListController
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
{
if ($raw === '' || !str_contains($raw, ':')) return [null, null];
[$ts, $id] = explode(':', $raw, 2);
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\Auth\{SessionStore, Rbac};
use App\Security\Csrf;
use App\Security\{Csrf, SiteScope};
use App\Geometry\Polygon;
use App\Repository\{ZoneRepository, AuditRepository};
use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, ZoneOwnershipException};
final class ZoneConfigController
{
private const LEVELS = ['warning','restricted','pinch'];
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
@ -23,10 +24,25 @@ final class ZoneConfigController
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
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
return Response::json(['zones' => $this->zones->forCamera((string)($params['camera'] ?? ''))], 200);
// A role gate (not just "authenticated"): without it any logged-in account could enumerate
// another site's hazard-zone geometry. view_events is held by every dashboard role.
if ($g = $this->guard('view_events')) return $g;
$zones = $this->zones->forCamera((string)($params['camera'] ?? ''));
// Drop any zone whose owning device is outside the caller's site scope.
$zones = array_values(array_filter($zones, fn($z) => $this->inScope((string)($z['device_id'] ?? ''))));
return Response::json(['zones' => $zones], 200);
}
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)) {
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 {
$zoneId = $this->zones->save([
'zone_id' => $b['zone_id'] ?? null,
'device_id' => (string)($b['device_id'] ?? ''),
'device_id' => $deviceId,
'camera_id' => (string)($b['camera_id'] ?? ''),
'level' => $level,
'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
// 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);
}
$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);
}
$id = (string)($params['id'] ?? '');
// Resolve the zone and scope-check its owning device BEFORE deleting, so a scoped operator
// cannot delete another site's zone by id. Unknown id and cross-site id both return 404.
$existing = $this->zones->find($id);
if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) {
return Response::json(['error' => 'not_found'], 404);
}
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
return Response::json(['ok' => true], 200);

View file

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

View file

@ -17,7 +17,7 @@ interface EventRepository
* $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).
*/
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 acknowledge(int $id, string $user): bool;
}

View file

@ -68,7 +68,7 @@ final class PdoEventRepository implements EventRepository
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.
$where = [];
@ -83,12 +83,13 @@ final class PdoEventRepository implements EventRepository
$binds[] = [$site, PDO::PARAM_STR];
}
// Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id)
// on the (occurred_at DESC, event_id DESC) order. occurred_at is bound as a string so the
// DECIMAL comparison keeps full precision (no float round-trip in the driver).
// on the (occurred_at DESC, event_id DESC) order. occurred_at arrives as its exact numeric
// STRING (never round-tripped through a PHP float) and is bound as a string, so MySQL
// parses it to the same DOUBLE the column holds — the page-edge boundary stays exact.
if ($beforeTs !== null && $beforeId !== null) {
$where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))';
$binds[] = [(string)$beforeTs, PDO::PARAM_STR];
$binds[] = [(string)$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeId, PDO::PARAM_INT];
}
$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
// 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) {
$del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000');
$del->execute([$windowStart - 2]);
try {
$del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000');
$del->execute([$windowStart - 2]);
} catch (\PDOException $e) {
error_log('rate_limits reap skipped: ' . $e->getMessage());
}
}
return $count;
}

View file

@ -77,11 +77,20 @@ final class PdoSnapshotRepository implements SnapshotRepository
$ids = [];
foreach ($rows as $r) {
$path = rtrim($this->snapshotDir, '/\\') . '/' . $r['file_uuid'] . '.jpg';
if (is_file($path)) {
@unlink($path);
// Only delete the DB row once its file is actually gone. If unlink() fails (perms, a
// 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'];
}
if (!$ids) {
return 0;
}
$in = implode(',', array_fill(0, count($ids), '?'));
$del = $this->pdo->prepare("DELETE FROM event_snapshots WHERE snapshot_id IN ($in)");
$del->execute($ids);

View file

@ -3,13 +3,23 @@ declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\ZoneRepository;
use App\Repository\ZoneOwnershipException;
use PDO;
use RuntimeException;
final class PdoZoneRepository implements ZoneRepository
{
public function __construct(private PDO $pdo) {}
public function find(string $zoneId): ?array
{
$st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE zone_id = ? LIMIT 1'
);
$st->execute([$zoneId]);
$row = $st->fetch();
return $row === false ? null : $row;
}
public function forDevice(string $deviceId): array
{
$st = $this->pdo->prepare(
@ -83,7 +93,7 @@ final class PdoZoneRepository implements ZoneRepository
);
$owns->execute([$zoneId, $deviceId, $cameraId]);
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(
@ -101,7 +111,7 @@ final class PdoZoneRepository implements ZoneRepository
$stillExists = $this->pdo->prepare('SELECT 1 FROM zones WHERE zone_id = ? LIMIT 1');
$stillExists->execute([$zoneId]);
if ($stillExists->fetchColumn() === false) {
throw new RuntimeException('zone was deleted concurrently');
throw new ZoneOwnershipException('zone was deleted concurrently');
}
}
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 forCamera(string $cameraId): array;
/** The single zone row (incl. device_id) by id, or null — for site-scope resolution. */
public function find(string $zoneId): ?array;
public function save(array $zone): string;
public function delete(string $zoneId): bool;
}

View file

@ -7,16 +7,17 @@ namespace App\Security;
* (users.site_scope); a device/event carries `site` (devices.site, denormalized
* onto events.site at ingest).
*
* A NULL/empty caller scope is a GLOBAL operator — sees every site. A scoped
* caller sees only rows whose site 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 anything.
* ONLY a NULL caller scope is a GLOBAL operator — sees every site. An empty-string scope is
* NOT global: it is a real (misconfigured) scope that matches nothing, so a scoping mistake
* fails CLOSED toward less access, never open. A scoped caller sees only rows whose site
* 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
{
public static function isGlobal(?string $callerScope): bool
{
return $callerScope === null || $callerScope === '';
return $callerScope === null;
}
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->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 {
$repo = new InMemoryEventRepository();
$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'];
$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 {
[$c] = $this->make();
$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);
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);
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);
// (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']]);
if ($beforeTs !== null && $beforeId !== null) {
$r = array_filter($r, fn($x) => ($x['occurred_at'] ?? 0) < $beforeTs
|| (($x['occurred_at'] ?? 0) == $beforeTs && $x['event_id'] < $beforeId));
$bt = (float)$beforeTs; // cursor carries occurred_at as a string; compare numerically
$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);
}
@ -52,7 +53,12 @@ final class InMemoryEventRepository implements EventRepository {
}
final class InMemorySnapshotRepository implements SnapshotRepository {
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 pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
public function eventIdByUuid(string $u): ?int {
@ -82,6 +88,7 @@ final class InMemoryZoneRepository implements ZoneRepository {
public array $zones = []; private int $seq = 0;
public function forDevice(string $d): array { return array_values(array_filter($this->zones, fn($z) => ($z['device_id'] ?? '') === $d)); }
public function forCamera(string $c): array { return array_values(array_filter($this->zones, fn($z) => $z['camera_id'] === $c)); }
public function 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 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\Geometry\Polygon;
use App\Dashboard\ZoneConfigController;
use App\Tests\Support\{InMemoryZoneRepository, InMemoryAuditRepository};
use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;
final class ZoneConfigTest extends TestCase
{
private function authed(string $role = 'operator'): ArraySessionStore {
$s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s;
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');
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 {
$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), [], []);
}
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]]]));
$this->assertSame(403, $res->status());
}
public function testSaveValidZone(): void {
$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]]]));
$this->assertSame(201, $res->status());
$this->assertCount(1, $repo->forCamera('cam-1'));
}
public function testSaveOwnershipMismatchIs400NotError(): void {
// The repo throws RuntimeException when the zone isn't owned by the given device/camera.
// The controller must turn that client error into 400, not let it become a 500.
// The repo throws ZoneOwnershipException when the zone isn't owned by the given
// 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 {
public function forDevice(string $d): 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; }
};
$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',
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
$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 {
$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' => 'warning', 'polygon' => [[0,0]]]))->status());
}
public function testSaveRejectsMissingOrWrongCsrf(): void {
$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]]];
// missing header
$this->assertSame(403, $c->save($this->saveReq($body, []))->status());
@ -66,7 +89,7 @@ final class ZoneConfigTest extends TestCase
public function testDeleteRejectsMissingOrWrongCsrf(): void {
$repo = new InMemoryZoneRepository();
$repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']);
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator'));
$c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'));
// missing header
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
$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->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"
# 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.
# sbom.json — CycloneDX component list (name, version, pypi purl, sha256).
echo ">> generating integrity manifest (SHA256SUMS, requirements.lock, sbom.json)"
( cd "$STAGE" && sha256sum wheelhouse/*.whl models/* > SHA256SUMS )
# sbom.json — CycloneDX component list: wheels AND models (name, version, sha256).
# SHA256SUMS — sha256 of EVERY staged file (code, unit, installer, env template, models,
# wheels, lock, sbom, MANIFEST). Generated LAST so it covers everything
# install_offline.sh lays down and runs as root — not just the wheels/models.
echo ">> generating requirements.lock + sbom.json"
: > "$STAGE/requirements.lock"
{
printf '{\n "bomFormat": "CycloneDX",\n "specVersion": "1.5",\n "version": 1,\n "components": [\n'
@ -138,22 +138,23 @@ echo ">> generating integrity manifest (SHA256SUMS, requirements.lock, sbom.json
"$sep" "$name" "$ver" "$name" "$ver" "$sum"
sep=$',\n'
done
# ML weights are shipped artifacts too — record them so a customer-side SBOM audit is complete.
for mdl in "$STAGE"/models/*; do
[ -e "$mdl" ] || continue
mbase="$(basename "$mdl")"
msum="$(sha256sum "$mdl" | cut -d' ' -f1)"
printf '%s {"type":"file","name":"%s","hashes":[{"alg":"SHA-256","content":"%s"}]}' \
"$sep" "$mbase" "$msum"
sep=$',\n'
done
printf '\n ]\n}\n'
} > "$STAGE/sbom.json"
sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock"
# 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
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
{
echo "bundle: move-detect-edge"
echo "built_mode: $MODE"
@ -161,15 +162,34 @@ HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
echo "target_arch: $TARGET_ARCH"
echo "hailo_arch: $HAILO_ARCH"
echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')"
echo "integrity: SHA256SUMS (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:"
ls -1 "$STAGE/models"
echo "wheels:"
ls -1 "$WHEELHOUSE"
} > "$STAGE/MANIFEST.txt"
# SHA256SUMS LAST: hash every staged file EXCEPT the manifest itself and its detached signature,
# so the daemon source, systemd unit, installer, env template, models and wheels are ALL covered.
# A tampered edge/daemon.py can no longer pass `sha256sum -c` and then be installed and run.
( cd "$STAGE" && find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' -print0 \
| sort -z | xargs -0 sha256sum > SHA256SUMS )
# Optional detached GPG signature over the integrity file. When present, install_offline.sh
# REQUIRES it to verify (a failed signature is fatal there); when absent the bundle offers
# corruption-detection only. Provenance-critical deliveries MUST set SIGN_KEY.
if [ -n "${SIGN_KEY:-}" ]; then
gpg --local-user "$SIGN_KEY" --armor --detach-sign --output "$STAGE/SHA256SUMS.asc" "$STAGE/SHA256SUMS"
echo ">> signed SHA256SUMS with key $SIGN_KEY -> SHA256SUMS.asc"
else
echo ">> NOTE: SHA256SUMS is unsigned (corruption-detection only). Set SIGN_KEY=<gpg-id> for a"
echo ">> detached signature; install_offline.sh will then require it to verify."
fi
echo ">> [6/6] packaging tarball"
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"
echo ">> DONE: $TARBALL"
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)
OPTIONAL_PKGS=(onnxruntime)
echo ">> [0/6] verify bundle integrity BEFORE touching the system"
# Verify EVERY bundled file against the build-time SHA256SUMS before any mutation of $PREFIX. A
# truncated/tampered bundle must abort HERE — with the previous install still intact — never after
# rm -rf has already destroyed it and left the Restart=always unit to run half-copied code. The
# build now hashes the daemon source, unit and this installer too, so tampered code cannot pass.
ALLOW_UNVERIFIED="${ALLOW_UNVERIFIED:-0}"
if [ -f "$HERE/SHA256SUMS" ]; then
( cd "$HERE" && sha256sum -c SHA256SUMS ) \
|| { echo "ERROR: bundle integrity check failed (SHA256SUMS mismatch) — refusing to install." >&2; exit 1; }
# sha256sum -c only checks LISTED files; an INJECTED extra wheel (which could win pip's version
# resolution) would pass unnoticed. Refuse any staged file not present in the manifest.
EXTRA="$( cd "$HERE" && comm -23 \
<(find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' | sed 's|^\./||' | sort) \
<(awk '{print $2}' SHA256SUMS | sed 's|^[*]||; s|^\./||' | sort) )"
if [ -n "$EXTRA" ]; then
echo "ERROR: bundle contains file(s) not listed in SHA256SUMS (possible injection):" >&2
echo "$EXTRA" >&2
exit 1
fi
# A detached signature proves PROVENANCE — an unsigned SHA256SUMS only detects corruption, since
# an attacker who can modify the bundle can strip the .asc and regenerate the hashes. Set
# REQUIRE_SIGNATURE=1 for untrusted transport to make a missing/invalid signature fatal.
REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-0}"
if [ -f "$HERE/SHA256SUMS.asc" ]; then
gpg --verify "$HERE/SHA256SUMS.asc" "$HERE/SHA256SUMS" 2>/dev/null \
|| { echo "ERROR: SHA256SUMS.asc present but its signature did NOT verify — refusing to install." >&2; exit 1; }
echo " -> SHA256SUMS signature verified"
elif [ "$REQUIRE_SIGNATURE" = 1 ]; then
echo "ERROR: REQUIRE_SIGNATURE=1 but this bundle has no SHA256SUMS.asc. An unsigned bundle" >&2
echo "detects corruption only, not tampering — refusing. Build with SIGN_KEY, or clear the flag." >&2
exit 1
else
echo " -> NOTE: unsigned bundle (corruption-detection only; set REQUIRE_SIGNATURE=1 to require a signature)."
fi
echo " -> bundle integrity OK"
elif [ "$ALLOW_UNVERIFIED" = 1 ]; then
echo " -> WARNING: no SHA256SUMS; ALLOW_UNVERIFIED=1 set — installing WITHOUT integrity check." >&2
else
echo "ERROR: no SHA256SUMS in this bundle — refusing to install unverified code. Rebuild with a" >&2
echo "current build_offline_bundle.sh, or (NOT recommended) re-run with ALLOW_UNVERIFIED=1." >&2
exit 1
fi
echo ">> [0b/6] Hailo arch preflight (BEFORE any mutation)"
# A hailo8 HEF on a hailo8l board (or vice-versa) imports fine but the daemon dies with
# HAILO_INVALID_HEF(26) on every start, forever. Catch the WRONG bundle here — using MANIFEST.txt
# (staged) + hailortcli (a system CLI, no venv needed) — so a mismatch aborts with the previous
# install still intact, not after the HEF/source/venv have already been overwritten.
BUNDLE_HAILO_ARCH="$(awk -F': ' '/^hailo_arch:/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}' "$HERE/MANIFEST.txt" 2>/dev/null || true)"
if [ -n "$BUNDLE_HAILO_ARCH" ]; then
BOARD_HAILO_ARCH=""
if command -v hailortcli >/dev/null 2>&1; then
BOARD_HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \
| awk -F': ' '/[Aa]rchitecture/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}')"
fi
if [ -n "$BOARD_HAILO_ARCH" ] && [ "$BUNDLE_HAILO_ARCH" != "$BOARD_HAILO_ARCH" ]; then
echo "ERROR: Hailo arch mismatch — this bundle's HEF is '$BUNDLE_HAILO_ARCH' but the board is" >&2
echo "'$BOARD_HAILO_ARCH'. It would crash-loop with HAILO_INVALID_HEF(26). Rebuild with" >&2
echo "HAILO_ARCH=$BOARD_HAILO_ARCH (deploy/bundle/build_offline_bundle.sh)." >&2
exit 1
elif [ -z "$BOARD_HAILO_ARCH" ]; then
echo " -> NOTE: could not read the board's Hailo arch (hailortcli?) to verify the bundle's" >&2
echo " '$BUNDLE_HAILO_ARCH'; if the daemon later fails HAILO_INVALID_HEF(26), the arch is wrong." >&2
else
echo " -> Hailo arch OK ($BUNDLE_HAILO_ARCH)"
fi
fi
echo ">> [1/6] service account + groups"
if ! id -u "$SVC_USER" >/dev/null 2>&1; then
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"
install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs"
# Fully replace the package tree on re-install/upgrade. Without the rm, `cp -r SRC DEST`
# nests SRC *inside* an existing DEST (edge/edge/...) and the OLD code keeps running.
# Fully replace the package tree on re-install/upgrade, atomically. Stage the new tree beside the
# old and rename over it: an interrupted copy leaves the previous WORKING tree untouched (the
# Restart=always unit keeps running old code) instead of a half-copied one. The rename also avoids
# `cp -r SRC DEST` nesting SRC *inside* an existing DEST (edge/edge/...) and running the OLD code.
rm -rf "$PREFIX/src/edge.new"
cp -r "$HERE/src/edge" "$PREFIX/src/edge.new"
rm -rf "$PREFIX/src/edge"
cp -r "$HERE/src/edge" "$PREFIX/src/edge"
mv "$PREFIX/src/edge.new" "$PREFIX/src/edge"
cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true
# 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
@ -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/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true
echo ">> [3/6] verify bundle integrity + python venv + offline wheel install (--no-index)"
# Supply-chain: verify every bundled wheel + model against the build-time SHA256SUMS BEFORE
# 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
echo ">> [3/6] python venv + offline wheel install (--no-index)"
# Integrity was already verified up-front in step [0/6], before any system mutation.
# --system-site-packages so the venv can import the Pi SYSTEM packages that are NOT
# pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and
# picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install
@ -100,13 +159,7 @@ state() { if "$@" >/dev/null 2>&1; then echo present; else echo MISSING; fi; }
HAILO_OK=0; MOVENET_OK=0
if [ -f "$HEF_DEST" ] && pyhas hailo_platform; then HAILO_OK=1; fi
if [ -f "$ONNX_DEST" ] && pyhas onnxruntime; then MOVENET_OK=1; fi
if [ "$HAILO_OK" = 1 ]; then
POSE_BACKEND=hailo
echo " -> Hailo HEF + hailo_platform present; POSE_BACKEND=hailo"
elif [ "$MOVENET_OK" = 1 ]; then
POSE_BACKEND=movenet
echo " -> CPU MoveNet usable; POSE_BACKEND=movenet"
else
if [ "$HAILO_OK" != 1 ] && [ "$MOVENET_OK" != 1 ]; then
echo "" >&2
echo "ERROR: no usable pose backend in this bundle — refusing to install a daemon that" >&2
echo "cannot start." >&2
@ -115,22 +168,57 @@ else
echo " Rebuild the bundle for the target backend (deploy/bundle/build_offline_bundle.sh)." >&2
exit 1
fi
# Prefer Hailo (the accelerator) when usable; MoveNet is the CPU fallback. (Arch already
# preflighted in [0b/6].)
if [ "$HAILO_OK" = 1 ]; then DEFAULT_BACKEND=hailo; else DEFAULT_BACKEND=movenet; fi
backend_usable() { # 0 (true) iff the named backend can actually load in THIS bundle
case "$1" in
hailo) [ "$HAILO_OK" = 1 ] ;;
movenet) [ "$MOVENET_OK" = 1 ] ;;
*) return 1 ;;
esac
}
install -d -m 0750 "$STATE"
chown "$SVC_USER":"$SVC_USER" "$STATE"
install -d -m 0750 "$ETC"
if [ ! -f "$ETC/edge.env" ]; then
POSE_BACKEND="$DEFAULT_BACKEND"
install -m 0640 "$HERE/deploy/systemd/move-detect-edge.env.example" "$ETC/edge.env"
# 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"
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)"
else
echo " -> $ETC/edge.env already exists; left unchanged"
# Preserve the operator's DELIBERATE backend choice when it still works in this bundle; only
# switch when the current one can't load — the crash-loop this reconcile exists to prevent
# (e.g. a stale POSE_BACKEND=movenet on a Hailo-only bundle with no onnxruntime).
CURRENT_BACKEND="$(sed -n 's|^POSE_BACKEND=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)"
if backend_usable "$CURRENT_BACKEND"; then
POSE_BACKEND="$CURRENT_BACKEND"
echo " -> $ETC/edge.env POSE_BACKEND=$POSE_BACKEND is usable in this bundle; kept"
else
POSE_BACKEND="$DEFAULT_BACKEND"
if grep -q '^POSE_BACKEND=' "$ETC/edge.env"; then
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env"
else
printf 'POSE_BACKEND=%s\n' "$POSE_BACKEND" >> "$ETC/edge.env"
fi
echo " -> reconciled $ETC/edge.env POSE_BACKEND: '${CURRENT_BACKEND:-unset}' (unusable) -> '$POSE_BACKEND'"
fi
fi
# For the hailo backend, HAILO_HEF must point at a HEF that actually EXISTS — correct a missing
# key OR one left pointing at a stale/absent path, else the daemon crash-loops on a bad path.
if [ "$POSE_BACKEND" = hailo ]; then
CURRENT_HEF="$(sed -n 's|^HAILO_HEF=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)"
if [ -z "$CURRENT_HEF" ] || [ ! -f "$CURRENT_HEF" ]; then
if grep -q '^HAILO_HEF=' "$ETC/edge.env"; then
sed -i "s|^HAILO_HEF=.*|HAILO_HEF=$HEF_DEST|" "$ETC/edge.env"
else
printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env"
fi
echo " -> set HAILO_HEF=$HEF_DEST (was '${CURRENT_HEF:-unset}')"
fi
fi
chown root:"$SVC_USER" "$ETC/edge.env"
echo ">> [6/6] systemd unit"
install -m 0644 "$HERE/deploy/systemd/move-detect-edge.service" /etc/systemd/system/

View file

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

View file

@ -2,6 +2,7 @@ import base64
import hashlib
import hmac
import json
from enum import Enum
from edge.events import Event
@ -11,6 +12,19 @@ from edge.events import Event
USER_AGENT = "move-detect-edge/1.0"
class SendResult(Enum):
"""Outcome of one upload attempt. The offline queue must tell a transient outage (retry
forever — a real fall alert has to survive an overnight internet outage) apart from a
permanent rejection (poison — count toward the drop cap), which a bare bool cannot express.
DELIVERED is the only truthy value, so `if client.send(...)` keeps working unchanged."""
DELIVERED = "delivered"
REJECTED = "rejected" # a definite 4xx (not 429): the server will never accept this event
TRANSIENT = "transient" # network error / timeout / 429 / 5xx — connectivity or server hiccup
def __bool__(self) -> bool:
return self is SendResult.DELIVERED
def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str:
if isinstance(body, str):
body = body.encode()
@ -32,7 +46,7 @@ class PostClient:
http_post = requests.post
self._post = http_post
def send(self, event: Event, snapshots: list[bytes]) -> bool:
def send(self, event: Event, snapshots: list[bytes]) -> SendResult:
payload = event.to_payload(self.device_id)
payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots]
body = json.dumps(payload, sort_keys=True)
@ -51,5 +65,15 @@ class PostClient:
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
allow_redirects=False)
except Exception:
return False
return 200 <= resp.status_code < 300
# Connection refused / DNS / timeout — the cloud is unreachable, not rejecting us.
return SendResult.TRANSIENT
code = resp.status_code
if 200 <= code < 300:
return SendResult.DELIVERED
# Transient — "back off and retry", NOT a permanent verdict on the event: all 5xx plus the
# 4xx codes that explicitly mean try-again (408 Request Timeout, 425 Too Early, 429 Too
# Many Requests). Counting these toward the poison drop cap would delete real events during
# a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection.
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 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"
# 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:
"""Fail LOUD at startup when the daemon cannot possibly deliver events.
load_config defaults cafe24_url and secret to '' so unit tests can build a Config from a
minimal env — but a real daemon with either blank runs perfectly green while every upload
fails (a bad/empty URL makes requests raise, which PostClient catches and returns False),
and the offline queue grows until the SD card is full. The operator sees a healthy unit
and an empty dashboard. Refuse to start instead.
minimal env — but a real daemon with either blank, left at a ship-time placeholder, or on
plaintext http runs perfectly green while every upload fails (a bad/empty URL makes requests
raise, which PostClient catches and returns False; http is refused by allow_redirects=False),
and the offline queue grows until the SD card is full. The operator sees a healthy unit and
an empty dashboard, or worker snapshots leave the Pi in plaintext. Refuse to start instead.
"""
missing = [name for name, val in (("CAFE24_URL", cfg.cafe24_url),
("DEVICE_SECRET", cfg.secret)) if not val.strip()]
if missing:
problems: list[str] = []
url = cfg.cafe24_url.strip()
secret = cfg.secret.strip()
if not url:
problems.append("CAFE24_URL not set")
elif not url.lower().startswith("https://"):
problems.append(f"CAFE24_URL must be https:// (got {url!r}) — plaintext would leak "
"worker snapshots and the .htaccess redirect is not followed")
elif any(m in url.lower() for m in _PLACEHOLDER_MARKERS):
problems.append(f"CAFE24_URL is still a template placeholder ({url!r})")
if not secret:
problems.append("DEVICE_SECRET not set")
elif any(m in secret.lower() for m in _PLACEHOLDER_MARKERS):
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
elif len(secret) < _MIN_SECRET_LEN:
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
"per-device secret issued by the cloud, not a stub")
if problems:
raise ValueError(
f"FATAL: {', '.join(missing)} not set in /etc/move-detect/edge.env — the daemon "
f"would run 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 "
f"cloud."
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
"but upload nothing. CAFE24_URL must include the app path, e.g. "
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the cloud."
)
def load_config(env: dict) -> Config:
camera_id = env.get("CAMERA_ID", "cam-01")
# Per-second stillness threshold (units/second), shared by fall no-motion and immobility.
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
motion_thr = _f(env, "MOTION_THR", 0.25)
if not math.isfinite(motion_thr) or motion_thr <= 0:
raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}")
fall = FallConfig(
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
motion_thr=motion_thr,
)
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0))
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
motion_thr=motion_thr)
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import json
from edge.events import Event, EventType, Severity
from edge.alert import sign, PostClient
from edge.alert import sign, PostClient, SendResult
def _event():
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,
http_post=fake_post)
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-Timestamp"] == "1000.0"
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.
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,
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():
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
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():
assert frame_interval_from_env({}) == pytest.approx(1 / 12)
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:
sh = f.read()
# 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).
assert "sha256sum wheelhouse/*.whl models/* > SHA256SUMS" in sh
# versions, hash EVERY artifact (not just wheels/models — the daemon source, unit and installer
# 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 "sbom.json" 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
def test_offline_installer_verifies_integrity_before_install():
with open(INSTALL_OFFLINE, encoding="utf-8") as f:
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 "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():
# Polygons are stored NORMALIZED (0..1 of the image); zones_from_config now rejects
# un-normalized (pixel-space) coordinates as legacy data.
return {"zones": [
{"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",
"polygon_json": "[[0,0],[5,0],[5,5]]"},
"polygon_json": "[[0,0],[0.5,0],[0.5,0.5]]"},
], "thresholds": {}}

View file

@ -1,5 +1,9 @@
import json
import sqlite3
from edge.events import Event, EventType, Severity
from edge.queue import OfflineQueue
from edge.alert import SendResult
def _event(n=1):
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:
"""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):
self.poison = poison_id
self.sent = []
def send(self, event, snapshots):
if event.track_id == self.poison:
return False
return SendResult.REJECTED
self.sent.append(event.track_id)
return True
return SendResult.DELIVERED
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()
assert client.sent == [2] # good event delivered
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
# Zones ship NORMALIZED (0..1 of the image); fixtures use that range.
def test_parses_zone_rows_json_string_polygon():
cfg = {"zones": [
{"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}}
zones = zones_from_config(cfg)
assert len(zones) == 1
assert zones[0].zone_id == "z1"
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
def test_skips_malformed_row():
@ -22,8 +26,8 @@ def test_empty_config():
def test_skips_short_point_without_crashing():
cfg = {"zones": [
{"zone_id": "bad", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9],[9,9]]"},
{"zone_id": "ok", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9,0],[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],[0.9,0],[0.9,0.9]]"},
]}
zones = zones_from_config(cfg)
# 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():
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)