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:
parent
930490d1b4
commit
2ea0420de7
37 changed files with 932 additions and 168 deletions
|
|
@ -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 켜기
|
||||
|
|
|
|||
98
cloud/migrations/2026-07-17-p1-9-p3-2b.sql
Normal file
98
cloud/migrations/2026-07-17-p1-9-p3-2b.sql
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
-- ===========================================================================
|
||||
-- Migration: P1-9 (event dedup) + P3-2b (site-scoped access control)
|
||||
-- + retention / rate-limit reap indexes
|
||||
--
|
||||
-- Apply ONCE to an EXISTING move_detect database. A FRESH install already has
|
||||
-- all of this via schema.sql — do NOT run this on a database created from the
|
||||
-- current schema.sql.
|
||||
--
|
||||
-- RUN IN THREE PARTS, IN ORDER. Part C (the events.site backfill) MUST run
|
||||
-- AFTER Part B has set devices.site — otherwise it matches 0 rows and every
|
||||
-- historical event stays site=NULL and DISAPPEARS from a scoped operator's
|
||||
-- dashboard the moment you scope a user. Do not skip Part C.
|
||||
--
|
||||
-- How to run: phpMyAdmin -> your DB -> "SQL" tab. MySQL (unlike MariaDB) has no
|
||||
-- "ADD COLUMN IF NOT EXISTS": if a column/index already exists, that ONE
|
||||
-- statement errors — skip it and run the rest.
|
||||
-- ===========================================================================
|
||||
|
||||
-- ===========================================================================
|
||||
-- PART A — schema changes (safe to run as-is)
|
||||
-- ===========================================================================
|
||||
|
||||
-- A1) devices.site — the access-control site each device belongs to.
|
||||
ALTER TABLE 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
-- ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
12
cloud/src/Repository/ZoneOwnershipException.php
Normal file
12
cloud/src/Repository/ZoneOwnershipException.php
Normal 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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
31
cloud/tests/SiteScopeTest.php
Normal file
31
cloud/tests/SiteScopeTest.php
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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, {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {}}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue