run_loop now accepts a single runtime or a list; it steps every camera each tick
(one shared clock read), isolates a per-camera step exception, runs the periodic
sync/heartbeat/flush once, and routes zones per camera via a zones_for(cfg, rt)
callback. parse_cameras() reads CAMERAS='backend:device:camera_id,...' (single-camera
fallback when unset) and refuses to yield zero cameras. zones_from_config() gains a
camera_id filter; PipelineRuntime exposes camera_id. Single-camera behavior unchanged.
Implement the Pi5 + Hailo-8 HAT + CSI camera premise: real PiCameraSource
(picamera2), CAMERA_BACKEND=csi|uvc switch, and an offline install that exposes the
Pi system packages (HailoRT, picamera2) to the venv via --system-site-packages, with
Hailo/CSI prerequisites documented. Adversarial review clean.
Create the venv with --system-site-packages so it can import the Pi system packages
that are not pip wheels (hailo_platform via hailo-all, picamera2 via python3-picamera2).
Add CAMERA_BACKEND/CSI_DEVICE to the env template and document the Hailo-8 + CSI
prerequisites and air-gap handling in deploy/README.
Replace the PiCameraSource NotImplementedError stub with a working picamera2 CSI
source (SourcePort read()->(ok,frame), RGB888->BGR to match the pose adapters;
picamera2 lazy-imported as a system package, injected fake in tests). daemon.main()
gains CAMERA_BACKEND (csi|uvc) selecting PiCameraSource or UvcSource.
(a) Offline-installable bundle (wheelhouse + model + source) + systemd unit for
the air-gapped Pi 5 edge daemon (--no-index install, zero network fetches).
(b) Real HailoRT yolov8s_pose adapter (host DFL box + keypoint decode + NMS,
COCO-17), replacing the stub, selectable via POSE_BACKEND; device-validated on
bringup. Adversarial review clean on the decode/API; three confirmed deploy-config
defects (systemd inline-comment crash, non-idempotent install, unpinned wheel ABI)
fixed.
Offline-installable bundle (wheelhouse + model + source) built on a machine with
internet and installed on the air-gapped Pi with --no-index (zero fetches), plus a
systemd unit (auto-restart, device access, hardening) and env template. Runs the
package from source via python -m edge (no offline build backend). Drift-guard test
keeps the env template in sync with what the daemon reads and rejects inline comments
that systemd EnvironmentFile= would fold into values.
Replace the NotImplementedError stub with a working HailoPoseAdapter for Pi5 +
Hailo-8. yolov8s_pose has no on-chip NMS, so the adapter does host-side DFL box
decode + keypoint decode + greedy NMS + letterbox un-map + COCO-17 assembly
(numpy decode ported from Hailo's MIT PoseEstPostProcessing, locked by a synthetic
9-tensor unit test; the HailoRT device wiring is gated + device-validated on bringup).
daemon.main() gains a POSE_BACKEND switch (hailo|movenet).
Wire the already-merged P1-A (edge detection) and P1-B (cloud) together into a
laptop-webcam end-to-end pilot: real frames -> CPU MoveNet pose -> detection ->
HMAC-signed POST -> cloud -> admin sees snapshot, hears alert, acknowledges.
Adds the edge daemon loop, CloudClient config-poll/heartbeat, zone reverse-loop,
MoveNet ONNX adapter, cloud incident detail view, and desk Web Audio alerting.
Wrap sync_config, send_heartbeat, and queue.flush operations in separate
try/except to ensure daemon survives periodic operation exceptions. Critical
for unattended 24/7 pilot devices where a locked database or disk error would
otherwise kill the entire loop. Per-frame step() handling unchanged.
ConfigController::get and HeartbeatController::post verified HMAC but never
checked nonce replay or timestamp freshness, so a captured signed request
(e.g. a replayed heartbeat) could be replayed forever, masking a dead
device. Inject NonceRepository (+Config) into both and add the same
timestamp-window + nonce-remember checks IngestController already applies,
after HMAC verification and before the response/side effect.
Only SameSite=Lax protected the acknowledge and zone save/delete endpoints,
even though a Csrf helper and mintable token already existed. Add
GET /api/csrf (CsrfController::token) to mint/return a per-session token,
check it (auth -> rbac -> csrf -> mutate) in EventDetailController::acknowledge
and ZoneConfigController::save/delete, and wire app.js/zone-draw.js to fetch
the token on load and send it as X-Csrf-Token on every POST.
Every login attempt, including unknown users (via DUMMY_HASH), forces a full
Argon2id verify with no rate limit, letting a flood take down the login
endpoint / shared host via hashing-cost amplification. Add a pre-auth
per-IP limiter (RateLimiterRepository, default 20/min) as the first check
in AuthController::login, before any user lookup or password hashing.
An unauthenticated attacker could guess a real device_id and 429-lock that
camera by hammering /api/events before HMAC verification ran, dropping its
accident events. Add a cheap pre-auth per-IP limiter (Config::ingestIpRateLimitPerMin,
default 300/min) that fires before any device-secret lookup, and move the
existing per-device limiter to run only after HMAC + timestamp + nonce checks
succeed, so only authenticated requests consume a device's own quota.
Self-review (Task 16) follow-up fixes:
- PdoEventRepository::recent(): add event_id DESC as a secondary sort key
after occurred_at DESC so ties (equal device-reported timestamps) resolve
deterministically, matching InMemoryEventRepository's insertion-order
tie-break.
- PdoZoneRepository::updateOwned(): after the ownership-scoped UPDATE,
disambiguate a 0-rowCount() result (MySQL reports "changed" rows, not
"matched" rows) with a targeted existence check, so a genuine no-op
resubmit (identical level/polygon) still succeeds while a zone deleted
concurrently between the ownership check and the UPDATE now throws
instead of save() silently reporting success for a zone that no longer
exists.
Full suite still green: 59 tests, 114 assertions, 4 skipped, 0 failures,
0 warnings/deprecations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
Implements Task 16 (final, P1-B): App\Db\Database::connect(Config): PDO
factory and the 9 App\Repository\Pdo\Pdo*Repository classes backing the
repository interfaces consumed by controllers since T1-T15, all-prepared
statements, plus cloud/schema.sql (MySQL 8 DDL) and cloud/docs/cloud-deploy.md.
Security hardening incorporated per accumulated task reviews:
- PdoNonceRepository: nonces.nonce is PK; duplicate remember() throws instead
of racing seen() (closes T7 TOCTOU).
- PdoRateLimiterRepository::hit(): atomic INSERT ... ON DUPLICATE KEY UPDATE
upsert on (rate_key, window_start), no read-modify-write race.
- PdoSnapshotRepository::pathByUuid(): validates 32-hex uuid before building
any filesystem path (closes T12 path-traversal risk).
- PdoZoneRepository::save(): update path is ownership-scoped by
(zone_id, device_id, camera_id); throws on mismatch instead of silently
overwriting an unrelated zone (closes T13 cross-tenant overwrite risk).
- Database::connect(): ERRMODE_EXCEPTION, EMULATE_PREPARES=false, FETCH_ASSOC.
cloud/tests/PdoIntegrationTest.php round-trips Nonce/Event/Zone against a
real sqlite :memory: DB, gated via markTestSkipped when pdo_sqlite is
unavailable (this host has pdo_mysql only, no pdo_sqlite) -- suite stays
green with 4 tests skipped.
Full suite: 59 tests, 114 assertions, 4 skipped, 0 failures, 0
warnings/deprecations. php -l passes on all new files and on
cloud/public/index.php (T15), which references all new FQCNs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
Move InMemoryRepositories.php from require_once workaround to composer
files-autoload. Enables PSR-4 autoloading of test-double classes and
scales for 5+ future test files. All 8 tests pass with require_once removed.