- Heartbeat reports the CONFIGURED camera count, not just the started ones: a
camera that failed to construct at boot (wrong index / unseated ribbon) now
shows as total/erroring instead of vanishing, so the server can see it is dark.
- GHOST emits without an engine cooldown key (ghost.update already fires once per
never-reused track id) — removes a permanent _last_emit key that forget() could
never reclaim (a slow 24/7 leak).
- Malformed zone rows are logged loudly instead of silently swallowed, matching
the pixel-space branch's "don't fail dark" — a dead zone is no longer discovered
only after a worker enters it.
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
- Device-status panel (DeviceStatusController): site-scoped liveness over stored
heartbeat telemetry. Fix config_drift false-positive by storing NULL until the
device actually reports a version (was firing "설정 미적용" on every device).
- Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone
is never persisted against a device that will never fetch it.
- Snapshot proxy sends immutable private cache headers, ending the 5s-poll
re-download of every thumbnail (a Cafe24 traffic-cap hazard).
- Event list: paged-in acknowledgements stick; honesty affordances for the paging
gap, a stale device poll, and a filtered camera aging out; logout wired for
every page via the shared layout.
- Split clocks: detector timers/cooldowns run on a MONOTONIC clock (immune to an
NTP step on an RTC-less Pi); wall time is used only for the HMAC X-Timestamp
and Event.occurred_at. Fixes false IMMOBILITY / dropped tracks after a clock jump.
- Heartbeat is time-based (30s, predictable regardless of load) and now carries
temp/throttle/queue_pending/config_version telemetry.
- A dead (no-frame) camera counts as erroring instead of a healthy frame, so a
silent monitoring outage no longer looks green.
- Offline queue: isolate one undecodable row instead of wedging every event behind
it; classify 401 (stale clock) as transient, never a poison-drop.
- New edge/health.py: vcgencmd temp/throttle parsing, None off-Pi (no fake 0).
Drawing hazard zones on a blank canvas meant guessing pixel coordinates.
The editor can now pull the camera's most recent snapshot and draw over
what the camera actually sees.
- SnapshotRepository::latestForCamera() (+ Pdo/in-memory impls), ordered by
snapshot_id, not the device-reported occurred_at (a skewed Pi RTC would
otherwise pin 'latest' to a stale frame)
- GET /api/cameras/{camera}/latest-snapshot -> {uuid}; returns only the id,
the bytes still go through SnapshotProxy's session + site-scope check.
Out-of-scope and missing both 404, so a scoped operator cannot probe for
another site's cameras
- ix_events_camera (camera_id, event_id) so the lookup is not a full scan
- '현재 화면 불러오기' button in the zone editor
Tests: PHP 113 pass (4 skipped, 4 new), pytest 174 pass.
build_runtimes() logs-and-skips a camera it cannot open, so a wrong
/dev/video index or an unseated ribbon produced a green systemd unit
that silently monitored one camera less.
- edge/probe.py: opens each camera in CAMERAS, retries while the sensor
settles, reports frame geometry, optionally saves preview PNGs, and
exits non-zero with a backend-specific hint (v4l2-ctl / rpicam-hello)
- setup_online.sh: new step [8/9] runs it against the installed package
and the CAMERAS line actually written to edge.env, and refuses to
enable the unit on failure. SKIP_CAMERA_CHECK=1 opts out.
Tests: 174 pass, 2 skipped (7 new).
The online Pi install required a four-variable command line typed on the
device. setup_online.sh now reads DEVICE_ID / DEVICE_SECRET / CAFE24_URL /
CAMERAS from deploy/device.env (written on the build machine) so the whole
install is 'sudo deploy/setup_online.sh'.
Real env vars still take precedence, so the documented long-form command
overrides a stale device.env. device.env is gitignored -- it carries the
device HMAC secret in plaintext.
Delivery had two ambiguously-named outputs and always produced an
archive, which made it unclear what actually gets uploaded/copied.
- dist/1_cafe24_upload/ -> FTP-upload contents to the Cafe24 web host
- dist/2_pi_install/ -> copy folder to the Pi, run ./install_offline.sh
Archives are now opt-in: MAKE_ZIP=1 (Cafe24) / MAKE_TARBALL=1 (Pi).
Docs and tests updated to the new paths.
Tests: 165 pass, 2 skipped.
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.
- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match
Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
Renames the 10 P1-B tables (users, devices, cameras, zones, events,
event_snapshots, thresholds, nonces, rate_limits, audit_log) to a
move_ prefix so the schema can co-tenant a shared-hosting database.
- schema.sql + all App\Repository\Pdo\* queries + tests + deploy docs
- new migrations/2026-07-20-move-table-prefix.sql: atomic RENAME TABLE
for existing installs; must run BEFORE deploying this code
- index/constraint names left unchanged (no cross-table collision)
Tests: 109 pass, 4 skipped.
On a Windows/Git-Bash build box with no `zip`, the packager fell back to
PowerShell Compress-Archive, which writes BACKSLASH path separators. Cafe24's
extractor (and some unzip tools) do not normalise them, so the app tree unpacked
as flat files (move_detect_app\src\... as a single filename) and the deploy
silently broke — a likely cause of the repeated install failures.
Prefer a Python zipfile step (POSIX arcnames) between `zip` and PowerShell.
Verified: rebuilt zip extracts with a correct move_detect_app/src/... tree and
no "backslashes as path separators" warning.
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.
Two approved data-model changes.
P1-9 — ingest idempotency. A POST can succeed server-side but time out
client-side; the edge then re-queues and re-sends the same event, so the
cloud stored duplicate alerts. The edge Event now carries a stable
event_uuid (uuid4 hex) generated once at creation and preserved through
the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL
UNIQUE; EventValidator accepts/validates it; IngestController returns the
already-stored event (200, dedup:true) on a repeat uuid, race-safe against
the UNIQUE key (catches the insert-race PDOException and returns the
winner).
P3-2b — site-scoped access control. site_scope was stored in the session
but never enforced, so any authenticated user could read every site's
worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto
events.site (server-derived from the authenticated device_id, never
client-supplied). App\Security\SiteScope centralizes the rule (NULL scope
= global operator). The event list filters in SQL (page(...,$site)); event
detail + acknowledge and the snapshot proxy guard per-row and return 404
(not 403) cross-site so existence never leaks.
Schema migration ALTERs and per-device/user provisioning UPDATEs are
documented at the bottom of schema.sql.
Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one
row; list scoped to caller site; detail cross-site 404; snapshot
cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing
(4 skipped: PDO), pytest 158 passing (2 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Records the pass that closed P4-5/6/7/8, P5-3/4, P2-9/10/11 and documents
why the remaining 8 are not solo-doable (data-model decisions: P1-9,
P3-2b; on-hardware safety-timing validation: P2-6/7/8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three keypoint-trust hardening fixes that reuse the codebase's existing
"visible" confidence convention (no new tuning knobs):
- P2-9 zone.py: _foot averaged both ankles unconditionally, so a
hallucinated ankle (conf~0, often pinned to (0,0)) dragged the foot
point into or out of a zone. Now averages only ankles with conf >= 0.2
and falls back to the bbox bottom-centre when neither is trustworthy.
- P2-11 geometry.py: head_below_hips used the NOSE regardless of
confidence. Now returns False when the nose is below the visibility
threshold, so an unconfident/hallucinated nose can't fire the
head-below-hips fall cue. The confident-nose path is unchanged
(test_fall.py stays green).
- P2-10 movenet_pose.py: the CPU MoveNet fallback is single-person, so at
a multi-worker site every worker but one is silently invisible to
fall/zone/ghost detection. Construction now logs a loud startup WARNING
pointing to the Hailo backend.
Tests: test_zone (bbox fallback), test_geometry (low-conf nose), and
test_movenet_pose (caplog warning). Full python suite: 157 passed, 2
skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The offline bundle shipped unpinned pip deps with no digests, no SBOM,
and no signature — an air-gap target can't re-resolve from PyPI, so a
truncated or tampered wheel would install silently.
build_offline_bundle.sh now emits, from the resolved wheelhouse:
- SHA256SUMS — sha256 of every wheel + model
- requirements.lock — exact name==version pins for a reproducible rebuild
- sbom.json — CycloneDX 1.5 components (name, version, pypi purl, sha256)
- SHA256SUMS.asc — optional detached GPG signature when SIGN_KEY is set
MANIFEST advertises the integrity set and whether it is signed.
install_offline.sh verifies `sha256sum -c SHA256SUMS` (and the signature,
if present) BEFORE any pip install, hard-failing on mismatch; older
bundles without the file pass with a warning.
The generation logic was verified end-to-end on a synthetic stage:
requirements.lock/SHA256SUMS correct, `sha256sum -c` passes, sbom.json is
valid CycloneDX JSON. Both scripts pass `bash -n`.
Tests (test_deploy_artifacts.py): pinned integrity manifest is emitted
(+ optional SIGN_KEY), installer verifies integrity before install.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The air-gapped bundle never fetched the Hailo HEF, so an offline Hailo
install had no pose model. Two latent breaks also made the offline path
unusable regardless of P5-3:
- build [2/5] ran `fetch_movenet` unconditionally; with the MoveNet
source unreachable that aborted the whole build under `set -e`.
- install_offline copied only `*.onnx`, so even a HEF placed in the
bundle never reached $PREFIX/models for the backend gate to find.
Fixes:
- build_offline_bundle.sh fetches the $HAILO_ARCH (default hailo8) HEF
via edge.tools.fetch_hailo_hef (pinned sha256, aborts on mismatch) and
stages yolov8s_pose.hef. MoveNet ONNX is now optional (guarded fetch →
Hailo-only bundle if unavailable). onnxruntime moves to a best-effort
optional wheel. MANIFEST lists models + available pose_backends.
- install_offline.sh copies *.hef (and tolerates a missing *.onnx).
Tests (test_deploy_artifacts.py): bundle fetches+stages the HEF, MoveNet
is optional (guarded), installer copies the HEF. Both scripts pass
`bash -n`. Full python suite: 152 passed, 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The ingest snapshot loop silently `continue`d on re-encode and file-write
failures (evidence lost with a 201), and a DB-index throw after the file
landed left an orphan JPEG on disk with no row — or bubbled into a 500
that discarded an already-stored event.
Extracts storeSnapshot(): re-encode → write file → index, in that order
so bytes exist before a row points at them. Every failure is error_log'd
instead of swallowed; a DB-index failure after the write @unlinks the
orphan file. A snapshot problem no longer fails the ingest — the event is
already persisted, so the 201 stands.
Test: testSnapshotIndexFailureCleansOrphanFileButKeepsEvent uses a
throwing snapshot repo and asserts 201, the event is stored, and no *.jpg
is left in the snapshot dir.
phpunit: 90 passing (4 skipped: PDO-backed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The event list was hard-capped at the newest 100 with no way to reach
older events. Adds keyset pagination:
- EventRepository::page(limit, severity, beforeTs, beforeId) on both the
PDO and in-memory implementations, ordered (occurred_at DESC,
event_id DESC). The cursor is the last row's (occurred_at, event_id),
so pages stay stable as new events arrive at the head — no overlap, no
skips. occurred_at is bound as a string in SQL to keep DECIMAL
precision.
- EventListController reads `limit` (default 50, capped 200) and `cursor`
("occurred_at:event_id", malformed input ignored) and returns
`next_cursor` only on a full page. recent() is retained for existing
callers; the response gains next_cursor additively.
The live-tail dashboard keeps consuming page 1; a history-browse UI can
build on the same cursor. Test: testListPaginatesWithCursor.
phpunit: 89 passing (4 skipped: PDO-backed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
P4-8: AuthController::login now verifies the CSRF token the login form
already emits (Csrf::check over session vs posted field), returning 403
before the user lookup. Closes a login-CSRF / session-fixation hole where
a forged cross-site POST could silently log a victim into an attacker's
account. Tests seed the session+body token; adds
testLoginRejectsMissingOrWrongCsrf.
P4-6: ZoneConfigController::save wraps the repository write in
try/catch(\RuntimeException) so an ownership/device mismatch is a 400
(invalid_zone) client error, not an uncaught 500. Adds
testSaveOwnershipMismatchIs400NotError with a throwing repo double.
phpunit: 88 passing (4 skipped: PDO-backed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P4-9 [L] logout() rotates the session id before wiping (was regenerate-after-destroy → warning).
- P4-10 [L] EventValidator caps device_id/camera_id (64) + zone_id (32) → 400 not a MySQL
'Data too long' 500 the edge retries forever.
- P4-11 [L] Request::pathFor collapses leading slashes so '//api/events' isn't misrouted to '/events'.
- P5-6 [L] cafe24 build prunes dev caches (.ruff_cache/.impeccable/__pycache__/…) from the package.
python + phpunit (86) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P5-2 [H] build_offline_bundle.sh native branch hard-fails when uname -m != TARGET_ARCH
(an x86 build was producing an aarch64-named tarball of x86_64 wheels).
- P5-5 [H] systemd unit adds DeviceAllow char-media + /dev/dma_heap for the CSI camera —
picamera2/libcamera on Pi 5 needs them or the unit denies its own camera. OFF-HW: verify on Pi.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P3-1 [H] _head_boxes sizes the anonymization region from the person bbox (scales with
distance) + bbox-top fallback when the nose keypoint is low-confidence; a fixed ~40px
patch had left close faces identifiable. test_faceblur.py.
- P3-3 [H] fetch_movenet.py rewritten to stage-validate (reject HTML/LFS-pointer, optional
pinned sha256+size, fail loud) per the SHA256 spec requirement. Discovery: the old PINTO
raw URL is DEAD (GitHub 404 → it was silently saving an HTML page as the .onnx). Non-blocking
for the Hailo delivery path; the CPU MoveNet fallback needs a valid MOVENET_ONNX_URL pinned.
python + phpunit suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).
## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.
## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
(docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
scoped to the mount.
## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
setup_online.sh sets up an internet-connected Pi in a single command; the daemon now
skips a camera that fails to construct so an unseated CSI camera never crash-loops the
service. Adversarial review clean after the fix.
deploy/setup_online.sh installs system packages (picamera2, hailo-all best-effort),
builds the venv + pip deps, fetches the MoveNet model, writes /etc/move-detect/edge.env
(prompting or from env vars), installs the systemd unit and starts the service — one
command, no offline bundle. Starts on CPU MoveNet; drop in a HEF for Hailo. numpy left
to the system to avoid shadowing picamera2's build. Air-gapped path (deploy/bundle/*)
unchanged.
build_runtimes() builds each camera's source+runtime in a try/except: a camera that
raises at construction (e.g. an unseated/absent CSI camera whose Picamera2(...) throws)
is logged and skipped so the working cameras still run, instead of an unguarded
exception crash-looping the whole daemon (Restart=always). Raises only if no camera
comes up. Closes the eager-construction gap the review flagged.
CSI + USB (or any mix) at once: one PipelineRuntime per camera with its own
camera_id, sharing the pose adapter (Hailo round-robin), offline queue, and cloud
sync; zones routed per camera. run_loop generalized to N cameras with per-camera
error isolation; single-camera path unchanged. Adversarial review clean.
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.