Compare commits

...

27 commits

Author SHA1 Message Date
유창욱
9d25e0a2d1 feat(dashboard): redesign as a dense AI CCTV operations console
Rework the events dashboard from the sparse light-minimal look (which read as
empty / unprofessional) into a professional, information-dense operations
console — keeping the LIGHT direction (the dark HMI was previously rejected).

- App shell: top bar with an "AI SAFETY CCTV" brand mark + product identity, and a
  device/camera rail (a second neutral layer) beside the detection stream.
- Density: device health + filters move into the rail; the four hero KPI cards
  become a compact inline pill strip; the stream is snapshot-forward.
- Every event row is a camera slot (evidence thumbnail, or a "무영상" placeholder)
  so the stream reads as a CCTV grid and rows stay aligned regardless of snapshots.
- Restrained near-monochrome; indigo for interaction only; severity colour spent on
  the unacknowledged-critical alarm only; contrast-checked ink; reduced-motion honoured.
- Pure CSS, system fonts, zero external assets (air-gap). DOM contract (ids/classes)
  unchanged, so polling/ack/alarm/filter logic is untouched. Verified in-browser.
2026-07-25 23:58:09 +09:00
유창욱
fe6a144754 fix(edge): stop hiding a failed camera; fix ghost dedup leak + silent zone drop
- 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.
2026-07-25 23:18:19 +09:00
유창욱
9ed759befc docs(deploy): fix MySQL/runbook/secret guidance; offline camera probe
- 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.
2026-07-25 19:21:01 +09:00
유창욱
bf5255e9eb feat(dashboard): device-status panel + event-list rework, hardened
- 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.
2026-07-25 19:20:42 +09:00
유창욱
752afc3f00 feat(edge): heartbeat telemetry + monotonic timers + queue/camera robustness
- 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).
2026-07-25 19:20:23 +09:00
유창욱
aa4378e906 feat(dashboard): load a camera's latest frame as the zone-editor background
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.
2026-07-20 23:41:58 +09:00
유창욱
5444f19d45 feat(deploy): probe every camera before enabling the service
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).
2026-07-20 23:22:20 +09:00
유창욱
f062667a65 feat(deploy): read pre-provisioned values from deploy/device.env
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.
2026-07-20 23:06:39 +09:00
유창욱
a2d2d24793 build: name the two delivery folders and stop archiving by default
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.
2026-07-20 12:58:44 +09:00
유창욱
3f2823a764 refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
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.
2026-07-20 11:30:52 +09:00
유창욱
5d511b04f2 refactor(cloud): prefix all DB tables with move_
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.
2026-07-20 11:23:30 +09:00
유창욱
3a8a5106cd fix(cafe24): zip with forward-slash paths (python fallback before Compress-Archive)
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.
2026-07-18 22:34:19 +09:00
유창욱
2ea0420de7 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.
2026-07-17 19:24:42 +09:00
유창욱
930490d1b4 feat(cloud,edge): event dedup (event_uuid) + site-scoped access control (P1-9, P3-2b)
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
2026-07-17 12:58:27 +09:00
유창욱
70c9f4e0fe docs(delivery-audit): mark off-hardware solo work complete (35/43)
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
2026-07-17 10:26:01 +09:00
유창욱
f1ee7f0a36 fix(edge): confidence-gate foot/nose keypoints, warn on MoveNet single-person (P2-9/10/11)
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
2026-07-17 10:25:04 +09:00
유창욱
949c6d05c3 feat(bundle): pin + hash + SBOM + verify offline bundle integrity (P5-4)
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
2026-07-17 10:18:57 +09:00
유창욱
1fb7a5da58 fix(bundle): carry the Hailo HEF in the offline bundle, ONNX optional (P5-3)
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
2026-07-17 10:15:13 +09:00
유창욱
99070f5e87 fix(cloud): don't swallow snapshot failures, clean orphan JPEGs (P4-7)
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
2026-07-17 10:08:28 +09:00
유창욱
9a0ff1fc5e feat(cloud): cursor pagination for the event list (P4-5)
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
2026-07-17 10:05:52 +09:00
유창욱
d0ba621b42 fix(cloud): verify login CSRF + zone ownership 400 not 500 (P4-8, P4-6)
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
2026-07-17 10:00:04 +09:00
유창욱
c66b034921 fix(cloud): logout order, url normalization, length caps, package hygiene (P4-9/10/11, P5-6)
- 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
2026-07-17 09:48:35 +09:00
유창욱
36b1f94571 fix(install): arch guard + CSI device access (P5-2, P5-5)
- 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
2026-07-17 09:38:18 +09:00
유창욱
7a86545367 feat(delivery-audit): privacy — face-blur sizing + model integrity (P3-1, P3-3)
- 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
2026-07-17 09:35:02 +09:00
유창욱
1187df5628 feat(delivery-audit): PIPA retention + cloud hardening (P3-2, P4-1..4)
- P3-2 [H] snapshot retention (7 days): snapshot_retention_days config;
  SnapshotRepository::purgeOlderThan (interface + Pdo + double, files-then-rows,
  bounded batch); opportunistic purge in the ingest path (Cafe24 has no cron).
- P4-1 [H] index.php sets display_errors=0 + a global exception handler → generic
  {"error":"internal"} + error_log (no DSN/db_user leak); ships www/move_detect/.user.ini.
- P4-2 [H] ConfigController + HeartbeatController take an optional rate limiter +
  clientIp; pre-auth per-IP anti-flood → 429 (were unthrottled).
- P4-3 [M] PdoNonceRepository::remember + PdoRateLimiterRepository::hit opportunistically
  reap expired rows (Cafe24 has no cron; tables grew unbounded).
- P4-4 [M] Router::dispatch rawurldecodes captured params (Korean/space camera_id could
  save zones but never load them).

Tests: python green; phpunit 84 green. See docs/delivery-audit.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
2026-07-17 09:28:16 +09:00
유창욱
9386dfc580 feat(delivery): install-path fixes + cafe24 package + delivery-audit hardening
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
2026-07-17 09:07:33 +09:00
유창욱
34dcf557e9 Merge feat/p1f-online-setup: one-command online Pi install + resilient camera startup
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.
2026-07-13 23:47:20 +09:00
169 changed files with 8093 additions and 1694 deletions

10
.gitattributes vendored
View file

@ -4,3 +4,13 @@
*.sh text eol=lf
*.service text eol=lf
move-detect-edge.env.example text eol=lf
# The cafe24 package is assembled on Windows but unpacked onto Linux hosting. A CRLF
# .htaccess makes Apache read directives with a trailing \r; PHP/JS tolerate CRLF but there
# is no reason to ship it. Normalize everything the package carries.
*.php text eol=lf
*.js text eol=lf
*.css text eol=lf
*.sql text eol=lf
*.md text eol=lf
htaccess.* text eol=lf

10
.gitignore vendored
View file

@ -12,7 +12,12 @@ dist/
# Edge runtime artifacts
*.db
*.hef
snapshots/
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
# edge never writes a snapshots directory anyway: they stay in memory and are base64'd into
# the queue db or POSTed (edge/queue.py, edge/alert.py).
/snapshots/
queue.db
# Secrets / local config
@ -25,3 +30,6 @@ Thumbs.db
# Tool caches
.impeccable/
# Pi provisioning (real device secret) — never commit
deploy/device.env

View file

@ -1,9 +0,0 @@
<?php
return [
'db_dsn' => 'mysql:host=localhost;dbname=move_detect;charset=utf8mb4',
'db_user' => 'CHANGEME',
'db_pass' => 'CHANGEME',
'snapshot_dir' => __DIR__ . '/storage/snapshots',
'timestamp_window' => 300,
'rate_limit_per_min' => 60,
];

View file

@ -1,97 +0,0 @@
/* Base layout ------------------------------------------------------- */
:root {
color-scheme: light;
--border: #d1d5db;
--text: #1f2937;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--text);
background: #f9fafb;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #111827;
color: #fff;
}
.app-nav a { color: #fff; margin-right: 1rem; text-decoration: none; }
.app-nav a:hover, .app-nav a:focus { text-decoration: underline; }
.app-session { display: flex; align-items: center; gap: 0.5rem; }
.app-session a { color: #fff; }
.app-main { padding: 1rem; max-width: 960px; margin: 0 auto; }
.login-form, .zone-controls { display: flex; flex-direction: column; gap: 0.5rem; max-width: 320px; }
.zone-controls { flex-direction: row; align-items: end; flex-wrap: wrap; max-width: none; }
.zone-controls label { display: flex; flex-direction: column; font-size: 0.85rem; }
.form-error { color: #991b1b; }
#zone-canvas {
border: 1px solid var(--border);
background: #111827 center / contain no-repeat;
cursor: crosshair;
max-width: 100%;
}
button {
cursor: pointer;
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 4px;
background: #fff;
}
button:hover { background: #f3f4f6; }
/* Event list ---------------------------------------------------------- */
.event-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.5rem;
background: #fff;
}
.event-row.is-unacknowledged { border-left: 4px solid #4b5563; }
.event-meta { flex: 1; }
.event-meta .event-type { font-weight: 600; }
.event-meta .event-time { color: #4b5563; font-size: 0.85rem; }
/* Severity badges (NF9): color-blind-safe — icon + text label + color.
Never rely on color alone. Pastel background + dark text pairs are
chosen for WCAG-AA contrast (>= 4.5:1 for normal-size text). */
.severity-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-weight: 700;
font-size: 0.85rem;
white-space: nowrap;
}
.severity-badge .icon { font-size: 1rem; line-height: 1; }
.sev-critical { background: #fee2e2; color: #991b1b; border: 1px solid #991b1b; }
.sev-high { background: #ffedd5; color: #9a3412; border: 1px solid #9a3412; }
.sev-warning { background: #fef9c3; color: #854d0e; border: 1px solid #854d0e; }
.sev-info { background: #e5e7eb; color: #374151; border: 1px solid #374151; }
.ack-btn { margin-left: auto; }
.ack-btn:disabled { opacity: 0.6; cursor: default; }
/* Dashboard row -> incident detail link (keyboard-focusable affordance). */
.event-detail-link { color: inherit; text-decoration: none; }
.event-detail-link:hover,
.event-detail-link:focus-visible { text-decoration: underline; }
/* Incident detail view (event_detail.php + detail.js). */
.detail-row { margin: 0.25rem 0; }
.detail-row .detail-label { color: #4b5563; font-weight: 600; }
#event-detail-snapshots { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; }
.detail-snapshot { max-width: 100%; height: auto; border: 1px solid #d1d5db; border-radius: 4px; }

View file

@ -1,217 +0,0 @@
/**
* Events dashboard: polls GET /api/events every 5s, renders the list with
* severity text+icon+color (never color alone, per NF9), pins unacknowledged
* events to the top, and wires the acknowledge button to POST /events/{id}/ack.
*
* XSS-safe: all dynamic values are set via textContent, never innerHTML.
*/
(function () {
'use strict';
var POLL_INTERVAL_MS = 5000;
var SEVERITY_LABELS = {
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔
high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠
warning: { icon: '△', text: '경고', cls: 'sev-warning' }, // △
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } // ℹ
};
var root = document.getElementById('events-root');
if (!root) return;
var csrfToken = null;
function loadCsrfToken() {
return fetch('/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data && data.token) csrfToken = data.token; })
.catch(function () { /* ack will fail with 403 and re-enable the button */ });
}
function severityInfo(sev) {
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
}
function formatTime(occurredAt) {
if (!occurredAt && occurredAt !== 0) return '';
var d = new Date(Number(occurredAt) * 1000);
if (isNaN(d.getTime())) return '';
return d.toLocaleString('ko-KR');
}
function buildBadge(sev) {
var info = severityInfo(sev);
var badge = document.createElement('span');
badge.className = 'severity-badge ' + info.cls;
var icon = document.createElement('span');
icon.className = 'icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = info.icon;
var label = document.createElement('span');
label.className = 'label';
label.textContent = info.text;
badge.appendChild(icon);
badge.appendChild(label);
return badge;
}
// --- desk audio alert: repeating beep while any unacknowledged critical exists ---
// No binary asset (air-gapped friendly): a WebAudio oscillator tone. The browser
// blocks audio until a user gesture, so the context is created on first click.
var audioCtx = null;
var alarmTimer = null;
var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately
function unlockAudio() {
if (!audioCtx) {
var AC = window.AudioContext || window.webkitAudioContext;
if (AC) audioCtx = new AC();
}
document.removeEventListener('click', unlockAudio);
}
function beep() {
if (!audioCtx) return;
try {
var osc = audioCtx.createOscillator();
var gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.value = 0.2;
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.4);
} catch (e) {
// audio unavailable; the visual list still surfaces the critical event
}
}
function updateAlarm(events) {
var hasUnackedCritical = false;
events.forEach(function (ev) {
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
if (ev.severity === 'critical' && !acked) {
hasUnackedCritical = true;
if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); }
}
});
if (hasUnackedCritical && !alarmTimer) {
alarmTimer = setInterval(beep, 3000);
} else if (!hasUnackedCritical && alarmTimer) {
clearInterval(alarmTimer);
alarmTimer = null;
}
}
function buildRow(ev) {
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
var row = document.createElement('div');
row.className = 'event-row' + (isAcked ? '' : ' is-unacknowledged');
row.appendChild(buildBadge(ev.severity));
var meta = document.createElement('div');
meta.className = 'event-meta';
var type = document.createElement('div');
type.className = 'event-type';
var typeLink = document.createElement('a');
typeLink.className = 'event-detail-link';
typeLink.href = '/events/' + encodeURIComponent(ev.event_id) + '/view';
typeLink.textContent = String(ev.event_type || '');
type.appendChild(typeLink);
meta.appendChild(type);
var sub = document.createElement('div');
sub.className = 'event-time';
var cam = ev.camera_id ? String(ev.camera_id) : '';
var time = formatTime(ev.occurred_at);
sub.textContent = [cam, time].filter(Boolean).join(' · ');
meta.appendChild(sub);
row.appendChild(meta);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ack-btn';
if (isAcked) {
btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
btn.disabled = true;
} else {
btn.textContent = '확인';
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
}
row.appendChild(btn);
return row;
}
function acknowledge(eventId, btn) {
btn.disabled = true;
fetch('/events/' + encodeURIComponent(eventId) + '/ack', {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
})
.then(function (res) {
if (!res.ok) throw new Error('ack_failed:' + res.status);
return loadEvents();
})
.catch(function () {
btn.disabled = false;
});
}
function render(events) {
var sorted = events.slice().sort(function (a, b) {
var aAcked = a.acknowledged_by !== null && a.acknowledged_by !== undefined && a.acknowledged_by !== '';
var bAcked = b.acknowledged_by !== null && b.acknowledged_by !== undefined && b.acknowledged_by !== '';
if (aAcked === bAcked) return 0;
return aAcked ? 1 : -1; // unacknowledged first
});
updateAlarm(sorted); // before any early return, so an emptied list also silences the alarm
root.textContent = '';
root.removeAttribute('aria-busy');
if (sorted.length === 0) {
root.textContent = '표시할 이벤트가 없습니다.';
return;
}
var frag = document.createDocumentFragment();
sorted.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
root.appendChild(frag);
}
function loadEvents() {
return fetch('/api/events', { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) {
window.location.href = '/login';
return null;
}
if (!res.ok) throw new Error('list_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (data && Array.isArray(data.events)) render(data.events);
})
.catch(function () {
// Leave the last rendered state on transient poll failure.
});
}
var logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function () {
fetch('/logout', { method: 'POST' }).then(function () { window.location.href = '/login'; });
});
}
document.addEventListener('click', unlockAudio);
loadCsrfToken();
loadEvents();
setInterval(loadEvents, POLL_INTERVAL_MS);
})();

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Config;
use App\Http\{Request, Router, Response};
use App\Db\Database;
use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
PdoThresholdRepository, PdoAuditRepository};
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController};
$cfg = Config::fromArray(require __DIR__ . '/../config.php');
$pdo = Database::connect($cfg);
$sess = new PhpSessionStore();
$now = time();
$devices = new PdoDeviceRepository($pdo);
$nonces = new PdoNonceRepository($pdo);
$rate = new PdoRateLimiterRepository($pdo);
$events = new PdoEventRepository($pdo);
$snaps = new PdoSnapshotRepository($pdo, $cfg->snapshotDir());
$users = new PdoUserRepository($pdo);
$zones = new PdoZoneRepository($pdo);
$thr = new PdoThresholdRepository($pdo);
$audit = new PdoAuditRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg);
$heartbeat = new HeartbeatController($cfg, $devices, $nonces);
$auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController();
$list = new EventListController($events, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $sess);
$zoneCtl = new ZoneConfigController($zones, $audit, $sess);
$render = function (string $view): Response {
ob_start(); require __DIR__ . '/../src/View/' . $view . '.php';
return Response::text((string)ob_get_clean());
};
$r = new Router();
$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now));
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now));
$r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess));
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
$r->add('GET', '/', fn() => $render('events'));
$r->add('GET', '/login', fn() => $render('login'));
$r->add('GET', '/zones', fn() => $render('zones'));
$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail'));
$r->dispatch(Request::fromGlobals())->send();

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Api;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository};
final class HeartbeatController
{
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces) {}
public function post(Request $req, int $now): Response
{
$b = $req->json();
$deviceId = (string)($b['device_id'] ?? '');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';
if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401);
}
if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
return Response::json(['error' => 'stale'], 401);
}
if ($nonce === '' || $this->nonces->seen($nonce)) {
return Response::json(['error' => 'replay'], 401);
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
$this->devices->touchHeartbeat($deviceId, [
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0),
'at' => $now,
]);
return Response::json(['ok' => true], 200);
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\EventRepository;
final class EventListController
{
public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$severity = $req->query('severity');
$rows = $this->events->recent(100, $severity);
return Response::json(['events' => $rows], 200);
}
}

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\SnapshotRepository;
final class SnapshotProxy
{
public function __construct(private SnapshotRepository $snaps, private SessionStore $sess) {}
public function serve(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$uuid = (string)($params['uuid'] ?? '');
$path = $this->snaps->pathByUuid($uuid);
if ($path === null || !is_file($path)) return Response::json(['error' => 'not_found'], 404);
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200);
}
}

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\Csrf;
use App\Geometry\Polygon;
use App\Repository\{ZoneRepository, AuditRepository};
final class ZoneConfigController
{
private const LEVELS = ['warning','restricted','pinch'];
public function __construct(
private ZoneRepository $zones, private AuditRepository $audit, private SessionStore $sess
) {}
private function guard(string $perm): ?Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), $perm)) return Response::json(['error' => 'forbidden'], 403);
return null;
}
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);
}
public function save(Request $req): Response
{
if ($g = $this->guard('edit_zones')) return $g;
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$b = $req->json();
$level = (string)($b['level'] ?? '');
$polygon = $b['polygon'] ?? [];
if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) {
return Response::json(['error' => 'invalid_zone'], 400);
}
$zoneId = $this->zones->save([
'zone_id' => $b['zone_id'] ?? null,
'device_id' => (string)($b['device_id'] ?? ''),
'camera_id' => (string)($b['camera_id'] ?? ''),
'level' => $level,
'polygon_json' => json_encode($polygon),
]);
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
return Response::json(['zone_id' => $zoneId], 201);
}
public function delete(Request $req, array $params): Response
{
if ($g = $this->guard('edit_zones')) return $g;
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$id = (string)($params['id'] ?? '');
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
return Response::json(['ok' => true], 200);
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http;
final class Request
{
private array $headers;
public function __construct(
private string $method, private string $path, array $headers,
private string $body, private array $query = [], private array $post = []
) {
$this->headers = [];
foreach ($headers as $k => $v) $this->headers[strtolower($k)] = $v;
}
public function method(): string { return $this->method; }
public function path(): string { return $this->path; }
public function header(string $name): ?string { return $this->headers[strtolower($name)] ?? null; }
public function body(): string { return $this->body; }
public function json(): array { $d = json_decode($this->body, true); return is_array($d) ? $d : []; }
public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; }
public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; }
public static function fromGlobals(): self
{
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
return new self(
$_SERVER['REQUEST_METHOD'] ?? 'GET',
parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/',
$headers, file_get_contents('php://input') ?: '', $_GET, $_POST
);
}
}

View file

@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ingest;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
EventRepository, SnapshotRepository};
final class IngestController
{
public function __construct(
private Config $cfg,
private DeviceRepository $devices,
private NonceRepository $nonces,
private RateLimiterRepository $rateLimiter,
private EventRepository $events,
private SnapshotRepository $snapshots,
) {}
public function handle(Request $req, int $now, string $clientIp): Response
{
$payload = $req->json();
$deviceId = (string)($payload['device_id'] ?? '');
$window = intdiv($now, 60);
// Pre-auth IP limit (cheap, anti-flood): guards the endpoint before any
// HMAC/device-secret work, so an unauthenticated attacker can't burn
// a real device's own quota (see per-device limit below, moved post-auth).
if ($clientIp !== '' && $this->rateLimiter->hit('ip:' . $clientIp, $window) > $this->cfg->ingestIpRateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$nonce = $req->header('X-Nonce') ?? '';
$ts = $req->header('X-Timestamp') ?? '';
$sig = $req->header('X-Signature') ?? '';
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
if ($secret === null || $nonce === '' || $ts === '' || $sig === '') {
return Response::json(['error' => 'unauthorized'], 401);
}
if (!Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401);
}
if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
return Response::json(['error' => 'unauthorized'], 401);
}
if ($this->nonces->seen($nonce)) {
return Response::json(['error' => 'unauthorized'], 401);
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
// Post-auth per-device limit: only authenticated (HMAC-verified) requests
// count toward a device's quota, so an attacker guessing device_id can no
// longer 429-lock a real camera.
if ($this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$v = EventValidator::validate($payload);
if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);
$eventId = $this->events->insert($v['clean']);
foreach (($payload['snapshots'] ?? []) as $b64) {
$img = ImageProcessor::reencode((string)$b64);
if (!$img['ok']) continue;
$path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg';
if (file_put_contents($path, $img['bytes']) === false) continue;
$this->snapshots->add($eventId, $img['uuid'], $img['hash']);
}
return Response::json(['event_id' => $eventId], 201);
}
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface DeviceRepository
{
public function secretFor(string $deviceId): ?string;
public function touchHeartbeat(string $deviceId, array $meta): void;
public function configVersion(string $deviceId): int;
}

View file

@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface EventRepository
{
public function insert(array $event): int;
public function recent(int $limit, ?string $severity): array;
public function find(int $id): ?array;
public function acknowledge(int $id, string $user): bool;
}

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\DeviceRepository;
use PDO;
final class PdoDeviceRepository implements DeviceRepository
{
public function __construct(private PDO $pdo) {}
public function secretFor(string $deviceId): ?string
{
$st = $this->pdo->prepare('SELECT secret FROM devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$secret = $st->fetchColumn();
return $secret === false ? null : (string)$secret;
}
public function touchHeartbeat(string $deviceId, array $meta): void
{
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
// only reaches this after secretFor() already succeeded for the same
// device_id, so the row is guaranteed to exist. Device rows are provisioned
// out-of-band by an admin (see cloud/docs/cloud-deploy.md), never created
// implicitly from an inbound heartbeat.
$st = $this->pdo->prepare(
'UPDATE devices
SET last_fps = ?, last_temp = ?, last_throttle = ?,
reported_config_version = ?, last_heartbeat_at = ?
WHERE device_id = ?'
);
$st->execute([
(float)($meta['fps'] ?? 0.0),
(float)($meta['temp'] ?? 0.0),
!empty($meta['throttle']) ? 1 : 0,
(int)($meta['config_version'] ?? 0),
(int)($meta['at'] ?? time()),
$deviceId,
]);
}
public function configVersion(string $deviceId): int
{
$st = $this->pdo->prepare('SELECT config_version FROM devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$version = $st->fetchColumn();
return $version === false ? 1 : (int)$version;
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\RateLimiterRepository;
use PDO;
/**
* Atomic upsert via MySQL's INSERT ... ON DUPLICATE KEY UPDATE against the
* unique (rate_key, window_start) primary key (see schema.sql) -- no
* read-modify-write race between reading the current count and writing the
* incremented value back.
*/
final class PdoRateLimiterRepository implements RateLimiterRepository
{
public function __construct(private PDO $pdo) {}
public function hit(string $key, int $windowStart): int
{
$upsert = $this->pdo->prepare(
'INSERT INTO rate_limits (rate_key, window_start, count) VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE count = count + 1'
);
$upsert->execute([$key, $windowStart]);
$select = $this->pdo->prepare(
'SELECT count FROM rate_limits WHERE rate_key = ? AND window_start = ?'
);
$select->execute([$key, $windowStart]);
return (int)$select->fetchColumn();
}
}

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\SnapshotRepository;
use PDO;
final class PdoSnapshotRepository implements SnapshotRepository
{
public function __construct(private PDO $pdo, private string $snapshotDir) {}
public function add(int $eventId, string $fileUuid, string $contentHash): void
{
$st = $this->pdo->prepare(
'INSERT INTO event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)'
);
$st->execute([$eventId, $fileUuid, $contentHash, time()]);
}
public function forEvent(int $eventId): array
{
$st = $this->pdo->prepare(
'SELECT file_uuid, content_hash FROM event_snapshots
WHERE event_id = ? ORDER BY snapshot_id ASC'
);
$st->execute([$eventId]);
return $st->fetchAll();
}
public function pathByUuid(string $uuid): ?string
{
// Validate BEFORE building any filesystem path or touching the DB --
// SnapshotProxy trusts whatever this method returns and does
// file_get_contents() on it, so a non-hex/wrong-length uuid must never
// reach path construction (closes the path-traversal risk flagged in
// the Task 12 review).
if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) {
return null;
}
$st = $this->pdo->prepare('SELECT 1 FROM event_snapshots WHERE file_uuid = ? LIMIT 1');
$st->execute([$uuid]);
if ($st->fetchColumn() === false) {
return null;
}
return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg';
}
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface SnapshotRepository
{
public function add(int $eventId, string $fileUuid, string $contentHash): void;
public function forEvent(int $eventId): array;
public function pathByUuid(string $uuid): ?string;
}

View file

@ -1,9 +0,0 @@
<?php
require __DIR__ . '/layout.php';
render_header('이벤트');
?>
<h1>이벤트</h1>
<div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
<script src="/assets/app.js"></script>
<?php
render_footer();

View file

@ -1,53 +0,0 @@
<?php
/**
* Shared header/nav + footer for server-rendered views.
* No business logic: only static markup + minimal session-derived display state
* (username is read from $_SESSION purely for display, not decisioning).
* render_header()/render_footer() bracket the page-specific <main> content.
*/
if (!function_exists('view_e')) {
function view_e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
}
if (!function_exists('render_header')) {
function render_header(string $title): void {
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= view_e($title) ?> · 이동 감지 대시보드</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<header class="app-header">
<nav class="app-nav">
<a href="/">이벤트</a>
<a href="/zones">구역 설정</a>
</nav>
<div class="app-session">
<?php if ($username !== null): ?>
<span><?= view_e($username) ?></span>
<button type="button" id="logout-btn">로그아웃</button>
<?php else: ?>
<a href="/login">로그인</a>
<?php endif; ?>
</div>
</header>
<main class="app-main">
<?php
}
}
if (!function_exists('render_footer')) {
function render_footer(): void {
?>
</main>
</body>
</html>
<?php
}
}

View file

@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy};
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;
final class DashboardTest 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;
}
public function testListRequiresAuth(): void {
$repo = new InMemoryEventRepository();
$c = new EventListController($repo, new ArraySessionStore());
$this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status());
}
public function testListReturnsRecent(): void {
$repo = new InMemoryEventRepository();
$repo->insert(['severity' => 'high', 'event_type' => 'fall']);
$c = new EventListController($repo, $this->authed());
$res = $c->json(new Request('GET', '/api/events', [], '', [], []));
$this->assertSame(200, $res->status());
$this->assertCount(1, json_decode($res->body(), true)['events']);
}
public function testAcknowledgeRequiresPermissionAndSetsUser(): void {
$repo = new InMemoryEventRepository();
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);
$req = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'tok'], '', [], []);
// viewer denied (RBAC check fires before CSRF)
$cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
$this->assertSame(403, $cv->acknowledge($req, ['id' => (string)$id])->status());
// operator allowed
$co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
$this->assertSame(200, $co->acknowledge($req, ['id' => (string)$id])->status());
$this->assertSame('u', $repo->find($id)['acknowledged_by']);
}
public function testAcknowledgeRejectsMissingOrWrongCsrfToken(): void {
$repo = new InMemoryEventRepository();
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);
$c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
// missing header
$missing = new Request('POST', "/events/$id/ack", [], '', [], []);
$this->assertSame(403, $c->acknowledge($missing, ['id' => (string)$id])->status());
// wrong token
$wrong = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'nope'], '', [], []);
$this->assertSame(403, $c->acknowledge($wrong, ['id' => (string)$id])->status());
// acknowledge did not proceed
$this->assertNull($repo->find($id)['acknowledged_by']);
}
public function testSnapshotProxyAuthAnd404(): void {
$snaps = new InMemorySnapshotRepository();
$proxy = new SnapshotProxy($snaps, new ArraySessionStore()); // unauth
$this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status());
$proxy2 = new SnapshotProxy($snaps, $this->authed());
$this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status());
}
}

View file

@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Config;
use App\Http\Request;
use App\Security\Hmac;
use App\Ingest\IngestController;
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryNonceRepository,
InMemoryRateLimiter, InMemoryEventRepository, InMemorySnapshotRepository};
use PHPUnit\Framework\TestCase;
final class IngestControllerTest extends TestCase
{
private string $dir;
protected function setUp(): void { $this->dir = sys_get_temp_dir() . '/snaps' . bin2hex(random_bytes(4)); mkdir($this->dir); }
private function make(array $secrets = ['pi-1' => 'sekret']): array {
$cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'rate_limit_per_min' => 60]);
$dev = new InMemoryDeviceRepository($secrets);
$nonce = new InMemoryNonceRepository();
$rl = new InMemoryRateLimiter();
$events = new InMemoryEventRepository();
$snaps = new InMemorySnapshotRepository();
$c = new IngestController($cfg, $dev, $nonce, $rl, $events, $snaps);
return [$c, $events, $snaps, $nonce];
}
private function jpegB64(): string { $im = imagecreatetruecolor(8, 8); ob_start(); imagejpeg($im); $b = ob_get_clean(); return base64_encode($b); }
private function signedReq(string $secret, array $payload, string $nonce = 'n1', string $ts = '1000'): Request {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
$sig = Hmac::sign($secret, $nonce, $ts, $body);
return new Request('POST', '/api/events',
['X-Nonce' => $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $body, [], []);
}
private function payload(): array {
return ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
'severity' => 'high', 'occurred_at' => 1000.0, 'snapshots' => [$this->jpegB64()]];
}
public function testAcceptsValidSignedEvent(): void {
[$c, $events, $snaps] = $this->make();
$res = $c->handle($this->signedReq('sekret', $this->payload()), now: 1000, clientIp: '1.2.3.4');
$this->assertSame(201, $res->status());
$this->assertCount(1, $events->rows);
$id = json_decode($res->body(), true)['event_id'];
$this->assertCount(1, $snaps->forEvent($id)); // snapshot re-encoded + stored
}
public function testRejectsBadSignature(): void {
[$c] = $this->make();
$req = $this->signedReq('WRONG', $this->payload());
$this->assertSame(401, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status());
}
public function testRejectsReplayedNonce(): void {
[$c] = $this->make();
$req = $this->signedReq('sekret', $this->payload());
$this->assertSame(201, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status());
$this->assertSame(401, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status()); // same nonce again
}
public function testRejectsStaleTimestamp(): void {
[$c] = $this->make();
$req = $this->signedReq('sekret', $this->payload(), ts: '1000');
$this->assertSame(401, $c->handle($req, now: 5000, clientIp: '1.2.3.4')->status()); // 4000s > 300 window
}
public function testRejectsUnknownDevice(): void {
[$c] = $this->make([]); // no secrets
$this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000, clientIp: '1.2.3.4')->status());
}
public function testIpFloodRateLimited(): void {
// Attacker without a valid device secret floods from one IP; the pre-auth
// IP limiter must trip BEFORE any HMAC/device-secret work, protecting
// real devices from having their own per-device quota consumed.
$cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'ingest_ip_rate_limit_per_min' => 2]);
$dev = new InMemoryDeviceRepository(['pi-1' => 'sekret']);
$nonce = new InMemoryNonceRepository();
$rl = new InMemoryRateLimiter();
$events = new InMemoryEventRepository();
$snaps = new InMemorySnapshotRepository();
$c = new IngestController($cfg, $dev, $nonce, $rl, $events, $snaps);
$garbage = new Request('POST', '/api/events', [], 'not-json', [], []);
$this->assertSame(401, $c->handle($garbage, now: 1000, clientIp: '9.9.9.9')->status());
$this->assertSame(401, $c->handle($garbage, now: 1000, clientIp: '9.9.9.9')->status());
$res = $c->handle($garbage, now: 1000, clientIp: '9.9.9.9');
$this->assertSame(429, $res->status()); // 3rd request from same IP: rate limited pre-auth
}
}

View file

@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
EventRepository, SnapshotRepository, UserRepository, ZoneRepository,
ThresholdRepository, AuditRepository};
final class InMemoryDeviceRepository implements DeviceRepository {
public array $heartbeats = [];
public function __construct(private array $secrets = [], private array $versions = []) {}
public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; }
public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
public function configVersion(string $d): int { return $this->versions[$d] ?? 1; }
}
final class InMemoryNonceRepository implements NonceRepository {
public array $store = [];
public function seen(string $n): bool { return isset($this->store[$n]); }
public function remember(string $n, int $exp): void { $this->store[$n] = $exp; }
}
final class InMemoryRateLimiter implements RateLimiterRepository {
public array $counts = [];
public function hit(string $k, int $w): int { $key = "$k:$w"; return $this->counts[$key] = ($this->counts[$key] ?? 0) + 1; }
}
final class InMemoryEventRepository implements EventRepository {
public array $rows = []; private int $seq = 0;
public function insert(array $e): int { $id = ++$this->seq; $this->rows[$id] = $e + ['event_id' => $id, 'acknowledged_by' => null]; return $id; }
public function recent(int $limit, ?string $sev): array {
$r = array_reverse($this->rows, true);
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
return array_slice(array_values($r), 0, $limit);
}
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
}
final class InMemorySnapshotRepository implements SnapshotRepository {
public array $rows = []; public array $paths = [];
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"; }
public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
}
final class InMemoryUserRepository implements UserRepository {
public function __construct(public array $users = []) {}
public function byUsername(string $u): ?array { return $this->users[$u] ?? null; }
public function recordFailure(int $id, int $lock): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = ($row['failed_attempts'] ?? 0) + 1; $row['locked_until'] = $lock; } }
public function clearFailures(int $id): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = 0; $row['locked_until'] = 0; } }
}
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 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; }
}
final class InMemoryThresholdRepository implements ThresholdRepository {
public function __construct(private array $data = []) {}
public function forDevice(string $d): array { return $this->data[$d] ?? []; }
}
final class InMemoryAuditRepository implements AuditRepository {
public array $log = [];
public function append(string $a, string $act, string $t, array $b, array $af): void { $this->log[] = compact('a', 'act', 't', 'b', 'af'); }
}

View file

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Geometry\Polygon;
use App\Dashboard\ZoneConfigController;
use App\Tests\Support\{InMemoryZoneRepository, 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;
}
public function testPolygonValidation(): void {
$this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
$this->assertFalse(Polygon::validate([[0,0],[10,0]])); // <3
$this->assertFalse(Polygon::validate([[0,0],[10,0],['x',10]])); // non-numeric
}
private function saveReq(array $body, array $headers = ['X-Csrf-Token' => 'tok']): Request {
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
}
public function testSaveRequiresPermission(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), 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'));
$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 testSaveRejectsBadLevelOrPolygon(): void {
$c = new ZoneConfigController(new InMemoryZoneRepository(), 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'));
$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());
// wrong token
$this->assertSame(403, $c->save($this->saveReq($body, ['X-Csrf-Token' => 'nope']))->status());
// save did not proceed
$this->assertCount(0, $repo->forCamera('cam-1'));
}
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'));
// missing header
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
// wrong token
$wrong = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'nope'], '', [], []);
$this->assertSame(403, $c->delete($wrong, ['id' => 'z1'])->status());
// delete did not proceed
$this->assertCount(1, $repo->forCamera('cam-1'));
// valid token proceeds
$ok = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
$this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status());
$this->assertCount(0, $repo->forCamera('cam-1'));
}
}

View file

@ -3,7 +3,7 @@
Offline-installable bundle + systemd unit for the `move-detect` edge daemon.
The bundle is fully self-contained (all wheels + pose model + source): the Pi
performs **zero network fetches** at install or run time. The only runtime
network use is the cloud POST / config-poll / heartbeat, and even that tolerates
network use is the server POST / config-poll / heartbeat, and even that tolerates
being offline (events buffer in the local sqlite queue and flush on reconnect).
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
@ -23,19 +23,41 @@ being offline (events buffer in the local sqlite queue and flush on reconnect).
## Quick start — Pi with internet (one command)
If the Pi itself has internet, skip the offline bundle entirely. Copy this repo onto
the Pi (git clone or scp), then:
If the Pi itself has internet, skip the offline bundle entirely. Copy the built delivery
folder onto the Pi — **copy the working tree, not a fresh `git clone`**: the pose model
`models/yolov8s_pose.hef` is git-ignored (a build artifact), so a clone arrives without it.
Use `scp -r`/USB from a build machine that has already staged `models/` (see step 1). Then:
```bash
sudo deploy/setup_online.sh
```
It installs the system packages (`python3-picamera2`, and `hailo-all` if present),
builds the venv, pip-installs the runtime deps, fetches the MoveNet model, writes
`/etc/move-detect/edge.env` (prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` /
`CAMERAS`, or take them from env vars), installs the systemd unit, and starts the service.
It begins on CPU MoveNet; drop a `yolov8s_pose.hef` in and pass `HAILO_HEF=...` (or edit
`edge.env`) to switch to Hailo. The rest of this doc is the **air-gapped** path.
It installs the system packages (`python3-picamera2`, and `hailo-all` if present), builds
the venv, pip-installs the runtime deps, **places the pose model from the delivery's
`models/`** (it does NOT download models on the Pi — the HEF/ONNX are fetched on the build
machine per the air-gap rule, `build pulls, target doesn't`), writes `/etc/move-detect/edge.env`
(prompting for `DEVICE_ID` / `DEVICE_SECRET` / `CAFE24_URL` / `CAMERAS`, or take them from env
vars), probes every camera, installs the systemd unit, and starts the service. It uses Hailo
when `models/yolov8s_pose.hef` is present (or pass `HAILO_HEF=/path/to.hef`), else CPU MoveNet
when `models/movenet_lightning.onnx` + onnxruntime are present. The rest of this doc is the
**air-gapped** path.
### Secret handling (`deploy/device.env`)
If you pre-provision with `deploy/device.env`, that file holds this device's **HMAC secret in
plaintext**. It is gitignored (never committed), but it rides onto the Pi with the "copy the
working tree" transfer. After a successful install:
1. **Delete it:** `shred -u deploy/device.env` (the live secret now lives only in
`/etc/move-detect/edge.env`, `0640 root:movedetect`). The installer already `chmod 600`s it
while running, but that is defense-in-depth, not a substitute for removing it.
2. **Rotate if it was ever exposed** (shared over chat, a shell history, a shared drive):
update `move_devices.secret` on the server and the `DEVICE_SECRET` in the Pi's
`/etc/move-detect/edge.env`, then restart the service. A leaked secret lets anyone forge
signed events/heartbeats for this device.
Prefer `device.env` over passing `DEVICE_SECRET=...` on the `sudo` command line, which leaves the
secret in the shell history.
---
@ -55,16 +77,18 @@ wheel tags may not resolve; if it fails, build natively):
deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311
```
Output: `dist/move-detect-edge-bundle-aarch64-cp311.tar.gz` (+ `MANIFEST.txt`
listing the bundled wheels). This single file crosses the air gap.
Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled
wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step.
Set `MAKE_TARBALL=1` if you would rather transfer one
`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file.
## 2. Transfer + install (on the air-gapped Pi)
Copy the tarball across the gap (USB, one-way transfer, etc.), then:
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
```bash
tar xzf move-detect-edge-bundle-aarch64-cp311.tar.gz
cd move-detect-edge-bundle
# tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz
cd 2_pi_install
sudo ./install_offline.sh
```
@ -81,8 +105,8 @@ sudo systemctl enable --now move-detect-edge
journalctl -u move-detect-edge -f
```
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the cloud (see
`cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
model (`/opt/move-detect/models/movenet_lightning.onnx`).
## 4. Verify the air gap (do this before trusting a build)
@ -93,7 +117,7 @@ model (`/opt/move-detect/models/movenet_lightning.onnx`).
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
numpy + requests all import from the venv).
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
frames processed and (if the cloud is reachable) heartbeats.
frames processed and (if the server is reachable) heartbeats.
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
→ the queue flushes. (End-to-end behavior verified in the edge e2e harness.)
@ -125,14 +149,14 @@ validated). USB webcam stays available anytime via `CAMERA_BACKEND=uvc`.
**Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several
cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share
the Hailo device (round-robin) and the offline queue, and cloud zones are routed to the
the Hailo device (round-robin) and the offline queue, and server zones are routed to the
matching camera. Example — CSI + USB together:
```ini
CAMERAS=csi:0:cam-01,uvc:0:cam-02
```
Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
Each `camera_id` must match the `camera_id` on that camera's zones in the server.
> **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't
> carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer
@ -141,7 +165,7 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
## Not bundled (by design)
- **MySQL / the cloud app** — a separate host; see `cloud/docs/cloud-deploy.md`.
- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`.
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
the default CPU MoveNet + USB path needs none of them.

130
deploy/bundle/build_offline_bundle.sh Normal file → Executable file
View file

@ -20,12 +20,17 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/move-detect-edge-bundle"
STAGE="$OUT_DIR/2_pi_install"
WHEELHOUSE="$STAGE/wheelhouse"
PYVER="${PYVER:-3.11}" # Pi OS Bookworm ships CPython 3.11
PYABI="cp${PYVER//./}" # cp311
TARGET_ARCH="${TARGET_ARCH:-aarch64}"
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
HAILO_ARCH="${HAILO_ARCH:-hailo8}" # 26 TOPS AI HAT+ (Hailo-8); set hailo8l for the 13 TOPS AI Kit
# Mandatory for EVERY backend (cv2/numpy/requests are on the unconditional startup chain).
# onnxruntime is the CPU-MoveNet fallback only and publishes no 32-bit-ARM wheel / no sdist, so
# it is downloaded best-effort — a Hailo bundle need not carry it (install_offline treats it optional).
RUNTIME_PKGS=(numpy requests opencv-python-headless)
OPTIONAL_PKGS=(onnxruntime)
MODE="native"
[ "${1:-}" = "--cross" ] && MODE="cross"
@ -33,12 +38,13 @@ MODE="native"
echo ">> build mode : $MODE"
echo ">> python target : $PYVER ($PYABI)"
echo ">> target arch : $TARGET_ARCH"
echo ">> hailo arch : $HAILO_ARCH"
echo ">> output : $OUT_DIR"
rm -rf "$STAGE"
mkdir -p "$WHEELHOUSE" "$STAGE/src" "$STAGE/models" "$STAGE/deploy/systemd" "$STAGE/docs"
echo ">> [1/5] downloading runtime wheels -> wheelhouse/"
echo ">> [1/6] downloading runtime wheels -> wheelhouse/"
if [ "$MODE" = "cross" ]; then
# Best-effort cross download. Try the common manylinux aarch64 tags.
python3 -m pip download \
@ -47,6 +53,14 @@ if [ "$MODE" = "cross" ]; then
--platform "manylinux2014_${TARGET_ARCH}" \
--platform "manylinux_2_28_${TARGET_ARCH}" \
-d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# CPU fallback wheel, best-effort — onnxruntime may have no matching aarch64/$PYABI tag.
python3 -m pip download \
--only-binary=:all: \
--python-version "$PYVER" --implementation cp --abi "$PYABI" \
--platform "manylinux2014_${TARGET_ARCH}" \
--platform "manylinux_2_28_${TARGET_ARCH}" \
-d "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime aarch64/$PYABI wheel unavailable; Hailo-only bundle (no CPU MoveNet)."
else
# Native: wheels for THIS host's interpreter/arch (run on the Pi / arm64 CI).
# Guard the ABI: numpy/onnxruntime ship version-specific cpXY wheels, so a build
@ -59,37 +73,131 @@ else
echo " or use --cross to pin aarch64/$PYABI wheels explicitly." >&2
exit 1
fi
# Guard the ARCH too: a native build on an x86_64 workstation would download x86_64 wheels
# while the tarball is still named/declared aarch64 (line 27/86/92) — a bundle that installs
# then fails to import on the Pi. numpy/opencv/onnxruntime are per-arch, so this is fatal.
HOST_ARCH="$(uname -m)"
if [ "$HOST_ARCH" != "$TARGET_ARCH" ]; then
echo "ERROR: native build host arch is $HOST_ARCH but TARGET_ARCH is $TARGET_ARCH." >&2
echo " Build natively ON an $TARGET_ARCH host (a Pi 5 / arm64 CI), or use --cross." >&2
exit 1
fi
python3 -m pip download -d "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# CPU fallback wheel, best-effort — a Hailo bundle is complete without it.
python3 -m pip download -d "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime unavailable for this host; Hailo-only bundle (no CPU MoveNet)."
fi
echo ">> [2/5] fetching MoveNet ONNX model (build-machine only)"
( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet )
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$STAGE/models/"
echo ">> [2/6] fetching MoveNet ONNX model (optional CPU fallback; build-machine only)"
# Optional: the MoveNet source may be unreachable, and a Hailo-8 delivery runs off the HEF. Do
# not abort the whole bundle over the CPU fallback — stage it if we can, otherwise carry on.
if ( cd "$REPO_ROOT" && python3 -m edge.tools.fetch_movenet ); then
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$STAGE/models/"
else
echo " -> MoveNet ONNX unavailable; Hailo-only bundle (CPU fallback not included)."
fi
echo ">> [3/5] staging edge source (package + pyproject)"
echo ">> [3/6] fetching Hailo $HAILO_ARCH yolov8s_pose HEF (build-machine only)"
# Mandatory for a Hailo delivery: this is the primary pose backend. fetch_hailo_hef verifies the
# download against a pinned sha256 and refuses a mismatch, so a bad/tampered HEF aborts the build.
( cd "$REPO_ROOT" && HAILO_ARCH="$HAILO_ARCH" python3 -m edge.tools.fetch_hailo_hef --arch "$HAILO_ARCH" )
cp "$REPO_ROOT/models/yolov8s_pose.hef" "$STAGE/models/"
echo ">> [4/6] staging edge source (package + pyproject)"
cp -r "$REPO_ROOT/edge" "$STAGE/src/edge"
cp "$REPO_ROOT/pyproject.toml" "$STAGE/src/"
# do not ship compiled caches
find "$STAGE/src" -name '__pycache__' -type d -prune -exec rm -rf {} +
echo ">> [4/5] staging deploy assets + install script"
echo ">> [5/6] staging deploy assets + install script"
cp "$REPO_ROOT/deploy/systemd/move-detect-edge.service" "$STAGE/deploy/systemd/"
cp "$REPO_ROOT/deploy/systemd/move-detect-edge.env.example" "$STAGE/deploy/systemd/"
cp "$REPO_ROOT/deploy/bundle/install_offline.sh" "$STAGE/"
cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/dev/null || true
chmod +x "$STAGE/install_offline.sh"
# Supply-chain integrity (air-gap: the target can't reach PyPI to re-resolve, so pin + hash here).
# requirements.lock — exact name==version of every bundled wheel, for a reproducible rebuild.
# 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'
sep=""
for whl in "$WHEELHOUSE"/*.whl; do
[ -e "$whl" ] || continue
base="$(basename "$whl")"
name="$(echo "$base" | cut -d- -f1)" # wheel: {name}-{version}-{pyabi}-...-{plat}.whl
ver="$(echo "$base" | cut -d- -f2)"
sum="$(sha256sum "$whl" | cut -d' ' -f1)"
echo "${name}==${ver}" >> "$STAGE/requirements.lock"
printf '%s {"type":"library","name":"%s","version":"%s","purl":"pkg:pypi/%s@%s","hashes":[{"alg":"SHA-256","content":"%s"}]}' \
"$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"
# 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"
echo "python: $PYVER ($PYABI)"
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 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"
echo ">> [5/5] packaging tarball"
TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}.tar.gz"
tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle"
# 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"
if [ "${MAKE_TARBALL:-0}" != "1" ]; then
# Default: leave $STAGE uncompressed — copy the folder to the Pi (USB/scp) and run
# install_offline.sh inside it. Set MAKE_TARBALL=1 for a single transferable file.
echo ">> skipped (MAKE_TARBALL=1 to also produce a .tar.gz)"
echo ">> DONE: $STAGE"
echo ">> copy this folder to the Pi, then run ./install_offline.sh inside it."
exit 0
fi
# 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/2_pi_install-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz"
tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install"
echo ">> DONE: $TARBALL"
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."

186
deploy/bundle/install_offline.sh Normal file → Executable file
View file

@ -18,7 +18,78 @@ STATE=/var/lib/move-detect
ETC=/etc/move-detect
SVC_USER=movedetect
WHEELHOUSE="$HERE/wheelhouse"
RUNTIME_PKGS=(numpy requests onnxruntime opencv-python-headless)
# Mandatory for every backend. onnxruntime is deliberately NOT here — see the optional
# install below; a Hailo bundle need not carry its wheel.
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
@ -31,15 +102,24 @@ 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
cp "$HERE/models/"*.onnx "$PREFIX/models/"
# 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
# (the [5/6] backend gate below is what refuses a bundle with NO usable backend).
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] 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
@ -49,21 +129,96 @@ echo ">> [3/6] python venv + offline wheel install (--no-index)"
python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" --upgrade pip >/dev/null 2>&1 || true
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${RUNTIME_PKGS[@]}"
# Optional: only present if the bundle was built for the CPU MoveNet backend. Still --no-index
# (zero fetches, air-gap intact); absent simply means Hailo-only.
"$PREFIX/venv/bin/pip" install --no-index --find-links "$WHEELHOUSE" "${OPTIONAL_PKGS[@]}" \
|| echo " -> onnxruntime not in the wheelhouse; Hailo-only bundle (CPU MoveNet unavailable)."
echo ">> [4/6] verify the edge package imports offline"
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, onnxruntime, cv2, numpy, requests; print('edge import OK')"
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
# on the unconditional startup chain; the daemon's server clients import requests at startup).
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
# not carry it. Report, don't fail: a hard `import onnxruntime` here refuses the install over
# a package the daemon never loads.
if PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import onnxruntime" >/dev/null 2>&1; then
echo " -> onnxruntime present; CPU MoveNet available"
else
echo " -> onnxruntime absent; CPU MoveNet unavailable (set POSE_BACKEND=hailo in $ETC/edge.env)"
fi
echo ">> [5/6] pose backend gate + state dir + env file"
# Do NOT hand over a Pi whose POSE_BACKEND cannot load. The template ships POSE_BACKEND=movenet,
# but a Hailo-only bundle carries no onnxruntime/ONNX — installing it verbatim would crash-loop.
# Probe what can ACTUALLY run (like setup_online's [6/8]) and reconcile, or refuse.
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
pyhas() { PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; }
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 ] && [ "$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
echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform')" >&2
echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2
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
}
echo ">> [5/6] state dir + env file"
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"
chown root:"$SVC_USER" "$ETC/edge.env"
echo " -> wrote $ETC/edge.env from template (EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)"
sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$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/
@ -71,6 +226,11 @@ systemctl daemon-reload
echo ""
echo "Installed. Next:"
echo " 1. edit $ETC/edge.env (device id/secret, cloud URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
echo " 2. sudo systemctl enable --now move-detect-edge"
echo " 3. journalctl -u move-detect-edge -f"
echo " 1. edit $ETC/edge.env (device id/secret, server URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
echo " 2. camera check BEFORE enabling — a wrong /dev/video index or an unseated CSI ribbon"
echo " otherwise yields a green unit that silently monitors one camera less (build_runtimes"
echo " logs-and-skips a camera it cannot open). This mirrors setup_online.sh's [8/9] probe,"
echo " which the offline path cannot run at install time (CAMERAS is only set in step 1):"
echo " sudo sh -c 'cd $PREFIX/src && CAMERAS=\$(sed -n \"s/^CAMERAS=//p\" $ETC/edge.env) $PREFIX/venv/bin/python -m edge.probe'"
echo " 3. sudo systemctl enable --now move-detect-edge"
echo " 4. journalctl -u move-detect-edge -f"

299
deploy/setup_online.sh Normal file → Executable file
View file

@ -5,9 +5,18 @@
# Run from a clone/copy of this repo, as root:
# sudo deploy/setup_online.sh
#
# Non-interactive: pass values as env vars, e.g.
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh
# Pre-provisioned (shortest path): put the values in deploy/device.env on the BUILD machine
# (DEVICE_ID / DEVICE_SECRET / CAFE24_URL / CAMERAS, one KEY=value per line, gitignored), copy
# the repo to the Pi, and the whole install is just:
# sudo deploy/setup_online.sh
#
# Non-interactive: pass values as env vars (these override device.env), e.g.
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://server CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
# deploy/setup_online.sh
# MODELS ARE NOT DOWNLOADED HERE. The pose HEF (and optional MoveNet ONNX) ship IN the delivery,
# staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh) — air-gap rule "build pulls,
# target doesn't". Copy the delivery (with its models/) to the Pi and this installer places them.
# Have a HEF elsewhere on the Pi? Point at it: HAILO_HEF=/home/pi/yolov8s_pose.hef.
# Interactive (a real terminal): it prompts for anything not passed.
set -euo pipefail
@ -22,21 +31,56 @@ STATE=/var/lib/move-detect
ETC=/etc/move-detect
SVC_USER=movedetect
echo ">> [1/8] system packages (CSI camera, venv; Hailo runtime best-effort)"
echo ">> [1/9] system packages (CSI camera, venv; Hailo runtime best-effort)"
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y python3-picamera2 python3-venv python3-pip
# A freshly-booted Pi runs apt-daily / apt-daily-upgrade / unattended-upgrades, which hold
# /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend for the first few minutes —
# exactly when someone runs a one-command installer. A bare apt-get then exits 100 with
# "E: Unable to lock directory /var/lib/apt/lists/" and `set -e` (set -e above) kills us here.
# -o DPkg::Lock::Timeout is not enough on its own: apt applies it to the dpkg locks, but
# `apt-get update`'s lists lock is taken with a single non-blocking fcntl(F_SETLK) and no
# retry. That wait has to live here, in the shell.
# Retry ONLY on lock contention; a real error (e.g. hailo-all absent from the repo) must
# still fail fast so the `|| echo WARN` fallback below stays instant.
apt_retry() {
local tries=30 i=1 rc log
log="$(mktemp)"
while :; do
rc=0
# LC_ALL=C is load-bearing, not cosmetic: apt gettext-translates its errors, and sudo
# passes the caller's LANG/LC_* straight through. On a ko_KR Pi the lists-lock message
# renders as "%s 디렉터리를 잠글 수 없습니다", the grep below misses it, and lock
# contention gets misread as a real error — fail-fast, zero retries, exactly the bug
# this function exists to fix. Pinning it per-call (not script-wide) keeps pip/venv and
# fetch_movenet at [4/9]-[5/9] on the system locale.
LC_ALL=C apt-get -o DPkg::Lock::Timeout=60 "$@" 2>&1 | tee "$log" || rc=${PIPESTATUS[0]}
if [ "$rc" -eq 0 ]; then rm -f "$log"; return 0; fi
if ! grep -qE 'Could not get lock|Unable to lock|Unable to acquire the dpkg' "$log"; then
rm -f "$log"; return "$rc"
fi
if [ "$i" -ge "$tries" ]; then
rm -f "$log"
echo " ERROR: apt/dpkg still locked after $((tries * 10))s. See who holds it with:" >&2
echo " systemctl status apt-daily.service apt-daily-upgrade.service unattended-upgrades.service" >&2
return "$rc"
fi
echo " apt/dpkg locked by another process (apt-daily/unattended-upgrades); retry $i/$tries in 10s..."
i=$((i + 1)); sleep 10
done
}
apt_retry update
apt_retry install -y python3-picamera2 python3-venv python3-pip
# hailo-all is the Hailo driver+runtime; only present in Pi repos with a Hailo HAT.
# Best-effort so a Pi without it still installs and runs on CPU MoveNet.
apt-get install -y hailo-all || echo " WARN: hailo-all not installed — Hailo accel unavailable, CPU MoveNet will be used."
apt_retry install -y hailo-all || echo " WARN: hailo-all not installed — Hailo accel unavailable, CPU MoveNet will be used."
echo ">> [2/8] service account + device groups"
echo ">> [2/9] service account + device groups"
id -u "$SVC_USER" >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER"
for g in video render plugdev hailo; do
getent group "$g" >/dev/null 2>&1 && usermod -aG "$g" "$SVC_USER" || true
done
echo ">> [3/8] source + dirs under $PREFIX"
echo ">> [3/9] source + dirs under $PREFIX"
install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs"
rm -rf "$PREFIX/src/edge" # full replace on re-run (no nested edge/edge)
cp -r "$REPO_ROOT/edge" "$PREFIX/src/edge"
@ -44,42 +88,172 @@ cp "$REPO_ROOT/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true
find "$PREFIX/src" -name __pycache__ -type d -prune -exec rm -rf {} +
cp "$REPO_ROOT/docs/pilot-runbook.md" "$PREFIX/docs/" 2>/dev/null || true
echo ">> [4/8] venv (+ system site-packages) + pip runtime deps"
echo ">> [4/9] venv (+ system site-packages) + pip runtime deps"
python3 -m venv --system-site-packages "$PREFIX/venv"
"$PREFIX/venv/bin/pip" install --upgrade pip >/dev/null
# numpy is intentionally NOT pinned here: it comes in transitively via onnxruntime, and
# leaving the system numpy in place (the venv sees it) avoids shadowing the numpy that
# the apt python3-picamera2 stack was compiled against.
"$PREFIX/venv/bin/pip" install requests onnxruntime opencv-python-headless
# MANDATORY for every backend — do not soften these to `|| echo WARN`:
# cv2 edge/snapshot.py imports it at MODULE level, and that module is on the
# unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND
# can reach build_pose() without it.
# requests the daemon builds its server/alert clients with no injected HTTP fn, so
# `import requests` fires at startup regardless of backend.
# numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives
# transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless
# hard-requires numpy and opencv is mandatory, so numpy still always arrives; leaving it
# unnamed lets the resolver keep the apt numpy that python3-picamera2 was compiled against
# whenever that copy satisfies opencv's floor.
"$PREFIX/venv/bin/pip" install requests opencv-python-headless
# onnxruntime is the CPU-MoveNet-ONLY dep: movenet_pose.py imports it inside
# MoveNetPoseAdapter.__init__, and the Hailo path never imports the module at all. It
# publishes NO sdist and NO 32-bit-ARM wheel, so pip can legitimately find zero candidates
# and exit non-zero in seconds — which under `set -euo pipefail` used to kill this installer
# on a Hailo-8 Pi that never needed it. Best-effort, like hailo-all at [1/9]; [6/9] decides
# whether a backend actually exists.
"$PREFIX/venv/bin/pip" install onnxruntime \
|| echo " WARN: onnxruntime did not install — CPU MoveNet unavailable (Hailo unaffected)."
echo ">> [5/8] MoveNet CPU model"
( cd "$REPO_ROOT" && "$PREFIX/venv/bin/python" -m edge.tools.fetch_movenet )
cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null || true
echo ">> [6/8] pose backend"
POSE_BACKEND=movenet
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
if [ -n "${HAILO_HEF:-}" ] && [ -f "${HAILO_HEF:-}" ]; then
cp "$HAILO_HEF" "$HEF_DEST"
POSE_BACKEND=hailo
echo " -> Hailo HEF installed; POSE_BACKEND=hailo"
echo ">> [5/9] MoveNet CPU model (from the delivery, not downloaded)"
# Models ship IN the delivery, staged on the BUILD machine (deploy/bundle/build_offline_bundle.sh
# fetches them there) and carried across with the repo copy — air-gap rule "build pulls, target
# doesn't". This installer NEVER downloads a model on the Pi. On-device fetch was removed because
# it was fragile in two ways at once: a partial repo copy made `-m edge.tools.fetch_*` fail with a
# ModuleNotFoundError that the `|| echo WARN` mislabeled as "download failed", and the MoveNet URL
# is dead upstream anyway. MoveNet is the OPTIONAL CPU fallback; stage it only if present.
if cp "$REPO_ROOT/models/movenet_lightning.onnx" "$PREFIX/models/" 2>/dev/null; then
echo " -> MoveNet ONNX staged from the delivery"
else
echo " -> no HAILO_HEF given; starting on CPU MoveNet."
echo " For Hailo: copy yolov8s_pose.hef to $HEF_DEST, set POSE_BACKEND=hailo in"
echo " $ETC/edge.env, then: sudo systemctl restart move-detect-edge"
echo " note: no MoveNet ONNX in the delivery (expected for a Hailo device)."
fi
echo ">> [7/8] config -> $ETC/edge.env"
echo ">> [6/9] pose backend"
HEF_DEST="$PREFIX/models/yolov8s_pose.hef"
ONNX_DEST="$PREFIX/models/movenet_lightning.onnx"
if [ -n "${HAILO_HEF:-}" ]; then
# An explicit path that does not exist is a typo, not a request to fall back. The old single
# guarded `cp` skipped it with NO warning and produced the byte-identical HEF=MISSING table
# as the unset case, never echoing the bad value — and if an earlier run had left a
# $HEF_DEST behind, that stale HEF got adopted as though it were the one named.
[ -f "$HAILO_HEF" ] || { echo "ERROR: HAILO_HEF=$HAILO_HEF does not exist." >&2; exit 1; }
cp "$HAILO_HEF" "$HEF_DEST"
echo " -> HEF installed from HAILO_HEF=$HAILO_HEF"
elif [ -f "$REPO_ROOT/models/yolov8s_pose.hef" ]; then
# The delivery carries the board-matching HEF, staged on the BUILD machine
# (build_offline_bundle.sh runs fetch_hailo_hef there, where hailortcli or --arch picks
# hailo8 vs hailo8l). This installer only PLACES it — it never downloads or guesses an arch
# on the Pi. Air-gap rule: build pulls, target doesn't.
cp "$REPO_ROOT/models/yolov8s_pose.hef" "$HEF_DEST"
echo " -> HEF staged from the delivery (models/yolov8s_pose.hef)"
else
echo " WARN: no Hailo HEF in the delivery (models/yolov8s_pose.hef) and HAILO_HEF unset —"
echo " no Hailo backend. The probe below decides if CPU MoveNet can carry the install."
fi
# Probe what can ACTUALLY run, in the venv the service will use. A HEF on disk is not enough
# (hailo_platform comes from apt hailo-all, best-effort at [1/9]), and a pip exit code is not
# enough either (a wheel can install and still fail to load its .so). Both probes must sit
# inside `if` conditions — a bare `a && b` list that fails is fatal under set -e.
pyhas() { "$PREFIX/venv/bin/python" -c "import $1" >/dev/null 2>&1; }
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"
[ "$MOVENET_OK" = 1 ] || echo " (CPU MoveNet fallback unavailable — Hailo does not need it)"
elif [ "$MOVENET_OK" = 1 ]; then
POSE_BACKEND=movenet
echo " -> no usable Hailo; starting on CPU MoveNet."
echo " For Hailo: copy yolov8s_pose.hef to $HEF_DEST, set POSE_BACKEND=hailo in"
echo " $ETC/edge.env, then: sudo systemctl restart move-detect-edge"
else
# Fail HERE, loudly, naming the fix — not at 3am. build_pose() runs outside the daemon's
# try/except, so a missing backend is an uncaught traceback (ModuleNotFoundError on the
# MoveNet path, KeyError 'HAILO_HEF' on the Hailo one) on every Restart=always cycle,
# forever — a unit that looks installed and never processes a frame.
# The board's own answer is the most diagnostic field here: it separates "no HEF" from
# "driver installed, reboot pending" from "no Hailo at all" — three different fixes.
HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \
| sed -n 's/.*Device Architecture:[[:space:]]*//p' | head -1)"
echo "" >&2
echo "ERROR: no usable pose backend — refusing to install a daemon that cannot start." >&2
echo " Hailo : HEF=$(state test -f "$HEF_DEST") hailo_platform=$(state "$PREFIX/venv/bin/python" -c 'import hailo_platform') board=${HAILO_ARCH:-NOT DETECTED}" >&2
echo " MoveNet: onnx=$(state test -f "$ONNX_DEST") onnxruntime=$(state "$PREFIX/venv/bin/python" -c 'import onnxruntime')" >&2
echo "" >&2
echo " Fix ONE of these, then re-run: sudo deploy/setup_online.sh" >&2
echo "" >&2
echo " A) Hailo-8 HAT (the intended path). The HEF ships IN the delivery" >&2
echo " (models/yolov8s_pose.hef, staged on the build machine) — this installer does NOT" >&2
echo " download it. Read the table above:" >&2
echo " HEF=MISSING -> this Pi's copy of the delivery has no" >&2
echo " models/yolov8s_pose.hef. Re-copy it, or point at one:" >&2
echo " sudo HAILO_HEF=/path/to/yolov8s_pose.hef deploy/setup_online.sh" >&2
echo " hailo_platform=MISSING -> the HailoRT driver/runtime is not up yet:" >&2
echo " sudo apt-get install -y hailo-all && sudo reboot" >&2
echo " (/dev/hailo0 appears only after that reboot). Confirm:" >&2
echo " hailortcli fw-control identify # -> Device Architecture: HAILO8" >&2
echo " B) CPU MoveNet: put models/movenet_lightning.onnx in the delivery, and" >&2
echo " $PREFIX/venv/bin/pip install onnxruntime" >&2
echo "" >&2
echo " If onnxruntime AND hailo-all both refuse to install, check the userland arch:" >&2
echo " dpkg --print-architecture" >&2
echo " 'armhf' means 32-bit Pi OS. Neither package ships for it (uname -m still says" >&2
echo " aarch64 there — the kernel is 64-bit, the userland is not). Reflash 64-bit Pi OS." >&2
exit 1
fi
echo ">> [7/9] config -> $ETC/edge.env"
install -d -m 0750 "$STATE"; chown "$SVC_USER":"$SVC_USER" "$STATE"
install -d -m 0750 "$ETC"
if [ -f "$ETC/edge.env" ]; then
echo " -> $ETC/edge.env already exists; left unchanged"
# Re-running IS the normal Hailo flow: install -> reboot so the driver creates /dev/hailo0
# -> re-run. So [6/9] can legitimately reach a different verdict than last time. Leaving the
# file wholly unchanged meant run #1's POSE_BACKEND=movenet survived while [6/9] printed
# "POSE_BACKEND=hailo" — and with onnxruntime absent that is a ModuleNotFoundError crash
# loop the installer just called a success.
# Reconcile ONLY the backend keys, in place: DEVICE_SECRET and the operator's other answers
# must survive untouched.
cur="$(sed -n 's/^POSE_BACKEND=//p' "$ETC/edge.env" | head -1)"
if [ "$cur" = "$POSE_BACKEND" ]; then
echo " -> $ETC/edge.env already exists; left unchanged"
else
if [ -n "$cur" ]; 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
grep -q '^HAILO_HEF=' "$ETC/edge.env" || printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env"
grep -q '^MOVENET_ONNX=' "$ETC/edge.env" || printf 'MOVENET_ONNX=%s\n' "$ONNX_DEST" >> "$ETC/edge.env"
# sed -i and >> can leave the file owned root:root, and the unit reads it as movedetect
# via EnvironmentFile= — a wrong group here fails the start with a bare permission error.
chown root:"$SVC_USER" "$ETC/edge.env"
chmod 0640 "$ETC/edge.env"
echo " -> $ETC/edge.env: POSE_BACKEND ${cur:-<unset>} -> $POSE_BACKEND (re-run reconciled)"
fi
else
# Pre-provisioning file: values written on the BUILD machine so the operator only has to
# type `sudo deploy/setup_online.sh`. A real env var always wins over the file, so the
# long-form command documented at the top still overrides a stale device.env.
PROVISION="${PROVISION:-$REPO_ROOT/deploy/device.env}"
if [ -f "$PROVISION" ]; then
echo ">> reading pre-provisioned values from $PROVISION"
# device.env holds the plaintext HMAC secret. It arrived via "copy the working tree" and may
# be world-readable (umask 022). Lock it to root-only now so it is not left readable by every
# local account for the life of the device. The secret still lands in /etc/move-detect/edge.env
# (0640 root:movedetect); once the install succeeds you should DELETE this file and rotate the
# secret if it was ever exposed — see deploy/README.md "Secret handling".
chmod 600 "$PROVISION" 2>/dev/null || true
while IFS='=' read -r k v; do
case "$k" in DEVICE_ID|DEVICE_SECRET|CAFE24_URL|CAMERAS) ;; *) continue ;; esac
[ -n "${!k:-}" ] || printf -v "$k" '%s' "$v"
done < <(grep -E '^[A-Z_][A-Z0-9_]*=' "$PROVISION") # digits matter: CAFE24_URL
fi
: "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}"
if [ -t 0 ]; then
[ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; }
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (cloud base URL): " CAFE24_URL
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from server): " DEVICE_SECRET; echo; }
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https://<domain>/move_detect — include the subdir!): " CAFE24_URL
[ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
fi
DEVICE_ID="${DEVICE_ID:-pi-01}"
@ -99,14 +273,69 @@ TARGET_FPS=12
EOF
chown root:"$SVC_USER" "$ETC/edge.env"
chmod 0640 "$ETC/edge.env"
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the cloud will accept events."
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the server will accept events."
echo " -> wrote $ETC/edge.env"
fi
echo ">> [8/8] systemd service"
echo ">> [8/9] camera check (open every camera in CAMERAS and grab a frame)"
# Do this BEFORE enabling the unit. build_runtimes() logs-and-skips a camera it cannot open,
# so a wrong /dev/video index or an unseated ribbon would otherwise produce a green unit that
# silently monitors one camera less. Fail here, while someone is still at the Pi.
# SKIP_CAMERA_CHECK=1 for a headless/bench install with no cameras attached yet.
if [ "${SKIP_CAMERA_CHECK:-0}" = "1" ]; then
echo " -> skipped (SKIP_CAMERA_CHECK=1)"
else
# Probe exactly what the unit will run with: the CAMERAS line as written to edge.env, and
# the installed copy of the package ($PREFIX/src, not the repo checkout).
probe_cams="$(sed -n 's/^CAMERAS=//p' "$ETC/edge.env" | head -1)"
if ( cd "$PREFIX/src" && CAMERAS="$probe_cams" "$PREFIX/venv/bin/python" -m edge.probe \
--save "$STATE/probe" ); then
echo " -> every camera delivered a frame (previews in $STATE/probe)"
chown -R "$SVC_USER":"$SVC_USER" "$STATE/probe" 2>/dev/null || true
else
echo "" >&2
echo "ERROR: camera check failed — refusing to enable the service." >&2
echo " Fix CAMERAS in $ETC/edge.env and re-run, or install with SKIP_CAMERA_CHECK=1" >&2
echo " to enable the service anyway (it will monitor only the cameras that work)." >&2
exit 1
fi
fi
echo ">> [9/9] systemd service"
install -m 0644 "$REPO_ROOT/deploy/systemd/move-detect-edge.service" /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now move-detect-edge
diagnose() {
echo "" >&2
echo "ERROR: move-detect-edge did not come up. systemd's reason:" >&2
echo "--------------------------------------------------------------------" >&2
systemctl status --no-pager --full move-detect-edge >&2 || true
journalctl -xeu move-detect-edge -n 50 --no-pager >&2 || true
echo "--------------------------------------------------------------------" >&2
echo "Full log: sudo journalctl -xeu move-detect-edge" >&2
exit 1
}
# enable + restart rather than `enable --now`, so a re-run also picks up a rewritten
# edge.env instead of no-op'ing on an already-running unit.
systemctl enable move-detect-edge
systemctl restart move-detect-edge || diagnose
# Type=exec fails the job on exec-step errors, but a Python traceback *after* execve still
# leaves `restart` returning 0. RestartSec=5 means a crash-looping unit reads as
# "activating" here, which is-active correctly rejects. This is a crash detector, not a
# readiness gate: under Type=exec the unit is active the moment execve() succeeds, so a
# slow camera/Hailo init cannot false-fail it.
sleep 5
systemctl is-active --quiet move-detect-edge || diagnose
# is-active alone is phase-dependent: a crash at T<=~0.2s after execve is already back in
# "active" by the time we sample (T + RestartSec=5 <= 5.2), so a crash-looping daemon can
# read as healthy. NRestarts is phase-independent — for a unit we just started, any restart
# at all means it has already died once.
if [ "$(systemctl show -p NRestarts --value move-detect-edge 2>/dev/null || echo 0)" != "0" ]; then
echo "" >&2
echo "ERROR: move-detect-edge has already restarted at least once — it is crash-looping." >&2
diagnose
fi
echo ""
echo "Done. The edge daemon is installed and started."

View file

@ -10,8 +10,11 @@
DEVICE_ID=pi-01
# DEVICE_SECRET: per-device HMAC secret; must match devices.secret.
DEVICE_SECRET=CHANGEME
# CAFE24_URL: cloud base URL (POST events / poll config / heartbeat).
CAFE24_URL=https://cloud.example.com
# CAFE24_URL: cloud base URL (POST events / poll config / heartbeat). MUST include the app's
# subdirectory when the cloud is mounted under an existing www/ (the cafe24 package default):
# use https://<domain>/move_detect, NOT https://<domain>. A wrong/missing path 404s every
# upload — the daemon now refuses to start on a blank value, but a wrong path it cannot detect.
CAFE24_URL=https://your-domain.example/move_detect
# CAMERA_ID: stamped on events and matched to dashboard zones.
CAMERA_ID=cam-01

View file

@ -1,7 +1,7 @@
# move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망).
# Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then
# sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge
# The daemon tolerates the cloud being offline (events buffer in the local queue
# The daemon tolerates the server being offline (events buffer in the local queue
# and flush on reconnect), so this unit only *wants* the network, not requires it.
[Unit]
@ -9,9 +9,23 @@ Description=move-detect edge daemon (workplace-safety AI CCTV)
Documentation=file:/opt/move-detect/docs/pilot-runbook.md
Wants=network-online.target
After=network-online.target
# Order after clock sync so Event.occurred_at is real wall time, not the stale fake-hwclock time a
# Pi 5 (no RTC battery) boots with. Detector TIMERS are already immune (they run on a monotonic
# clock — see daemon.main), so this only affects timestamp accuracy; Wants (not Requires) so an
# air-gapped Pi with no reachable NTP still starts rather than hanging forever on time-sync.
Wants=time-sync.target
After=time-sync.target
# Never stop retrying. This MUST live in [Unit]: systemd's parser table knows
# Unit.StartLimitIntervalSec but only Service.StartLimitInterval (no "Sec"), so in
# [Service] it is an unknown key — silently ignored, leaving the default 5-starts/10s
# limit to defeat Restart=always below.
StartLimitIntervalSec=0
[Service]
Type=simple
# Type=exec, not simple: with simple, systemd marks the start job done the instant fork()
# returns, so an exec-step failure (226/NAMESPACE, 216/GROUP, 217/USER, 203/EXEC) never
# reaches `systemctl enable --now` and the installer reports success over a dead daemon.
Type=exec
# Dedicated unprivileged account; must be in the groups that own the camera and
# the Hailo accelerator device nodes.
User=movedetect
@ -28,21 +42,36 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge
# periodic-op exceptions internally so a single glitch never exits the process.
Restart=always
RestartSec=5
StartLimitIntervalSec=0
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
# systemd creates/owns /var/lib/move-detect for the service user.
StateDirectory=move-detect
StateDirectoryMode=0750
# Camera + accelerator device access (USB UVC = /dev/video*, Hailo = /dev/hailo0).
# Camera + accelerator device access. Once ANY DeviceAllow= is present, systemd denies every
# 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)
# 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=char-dma_heap rw
# Conservative hardening (loosen if it blocks device access on your image).
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/move-detect
# Do NOT add ReadWritePaths=/var/lib/move-detect here. StateDirectory= above already emits
# a recursive read-write bind mount for that exact path, which outranks a ReadWritePaths=
# entry for the same path and drops it before any path is resolved. It is dead config: it
# can neither cause nor fix a state-dir problem.
ProtectHome=true
PrivateTmp=true

287
docs/delivery-audit.md Normal file
View file

@ -0,0 +1,287 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
verification of every blocker against real code. Ordered for sequential fixing. Each item is
checked off only after a test locks it and verification passes.
Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low ·
`OFF-HW` = cannot be verified without the Hailo board.
---
## Phase 1 — Silent failure & misconfiguration (the #1 delivery risk)
A safety system that *looks* healthy while doing nothing is the worst failure. Make every
misconfig/degradation fail LOUD.
- [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_serverclient.py.
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
DONE — env.example + installer prompt now show/require the `/move_detect` subdir.
- [x] **P1-3 [H]** `edge/config.py:40` + `edge/daemon.py` — empty `CAFE24_URL`/`DEVICE_SECRET`
defaults let the daemon run green while every upload fails and the queue grows forever.
**Fix:** fail loud at startup if either is blank.
DONE — `require_upload_config(cfg)` raises at daemon startup; test_config.py locks it.
- [x] **P1-4 [H]** `edge/daemon.py:23` — `run_loop` swallows every per-frame/per-camera
exception with no log and no degraded-monitoring signal → a permanently broken pipeline
looks green. **Fix:** rate-limited `logging.exception` + consecutive-error counter.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the server cannot tell a dead device from a live one.
**Fix:** real fps + per-camera frame-age/health in the heartbeat body.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
test_daemon.py locks fps math + failing-camera flag.
- [x] **P1-6 [H]** `edge/adapters/uvc_source.py:12` — a camera that stops delivering frames is
never detected or reopened; monitoring dies silently. **Fix:** fail loud on open failure;
emit `device_fault`/`monitoring_degraded` when a camera goes stale.
DONE — open-failure raises (build_runtimes logs+skips); read() releases+reopens after a
streak; heartbeat's cameras_erroring (P1-5) reports the fault. test_uvc_source.py locks it.
- [x] **P1-7 [M]** `edge/queue.py` — offline sqlite queue has no size cap/TTL; an outage (or a
permanent misconfig) fills the SD card, then new events drop silently. **Fix:** bounded
queue (row/byte budget), log dropped-oldest count.
DONE — `max_rows` (default 2000) evicts oldest on enqueue; test_queue.py locks it.
- [x] **P1-8 [H]** `edge/queue.py:52` — a permanently-rejected event (e.g. 400) is retried
forever at the head, blocking the whole queue. **Fix:** drop/quarantine after N failures.
DONE — `attempts` column; a row is dropped after `max_send_attempts` (default 20) so a
poison head unwedges the queue; test locks the drain-past-poison behavior.
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
client-generated `event_id` in the payload; server dedups on it.
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
creation, preserved through the offline queue + every retry). Server: `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.
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server).
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
DONE — `frame_interval_from_env` validates; test_config.py locks it.
- [x] **P1-11 [L]** `edge/daemon.py:120` — `QUEUE_DB` defaults to relative `queue.db`,
unwritable under the hardened unit (`ProtectSystem=strict`) → crash loop when env omitted.
**Fix:** absolute default under the state dir.
DONE — `queue_db_path` defaults to /var/lib/move-detect/queue.db; test_config.py locks it.
## Phase 2 — Core safety correctness (fall / zone / tracking)
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
(zone.py → engine.process → run_once threads frame_wh); Polygon::validate is range-agnostic
so PHP stores it opaquely. test_zone.py locks the normalized contract.
- [x] **P2-2 [B]** `edge/engine.py:72` — a `pinch` (협착) zone falls through to
`ZONE_WARNING`; the PINCH event + HIGH severity are never emitted. **Fix:** map
`pinch→PINCH`, `restricted→ZONE_RESTRICTED`, else WARNING.
DONE — dict map pinch→PINCH/restricted→ZONE_RESTRICTED; test_engine.py locks PINCH+HIGH.
- [x] **P2-3 [H]** `edge/detectors/fall.py:65` — a per-frame keypoint-confidence gate
silently disables fall, no_motion AND ghost for an occluded/low-confidence fallen person —
exactly when someone is on the floor. **Fix:** use bbox cues when keypoints are low-conf.
DONE — bbox cues (aspect/height-collapse) always run; keypoint cues gated by kp_ok;
test_fall.py locks both the low-conf detection and the no-false-positive-standing case.
- [x] **P2-4 [H]** `edge/detectors/ghost.py:38` — a ghost candidate is cancelled when ANY
person (a colleague) passes near the loss point. **Fix:** only the plausibly-same track
cancels it.
DONE — candidate snapshots the active track ids at loss time; only a NEW track (not a
known bystander) near the loss point cancels. test_ghost.py locks the bystander case.
- [x] **P2-5 [H]** `edge/geometry.py:66` — stillness compares raw per-frame displacement to a
fixed threshold with no dt normalization → immobility/no_motion flips with FPS. **Fix:**
normalize by elapsed time.
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
- [ ] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
reset per-track safety state on a low-confidence match.
- [ ] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
- [ ] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
confidence-gate the ankles, fall back to bbox-bottom-center.
DONE — `_foot` averages only ankles with conf ≥ MIN_ANKLE_CONF (0.2, the existing 'visible'
gate); with neither trustworthy it falls back to bbox bottom-centre. No tuning knob — reuses
the codebase's keypoint-visible convention. test_zone.py locks the fallback.
- [x] **P2-10 [M]** `edge/adapters/movenet_pose.py:34` — the CPU MoveNet fallback is
single-person; with 2+ workers all but one are invisible to every detector, no warning.
**Fix:** warn loudly that MoveNet fallback is single-person (Hailo is multi-person).
DONE — MoveNetPoseAdapter.__init__ logs a startup WARNING ("SINGLE-PERSON … use POSE_BACKEND=
hailo at multi-person sites"). test_movenet_pose.py asserts the warning via caplog.
- [x] **P2-11 [L]** `edge/geometry.py:39` — `head_below_hips` uses the NOSE keypoint
regardless of confidence. **Fix:** confidence-gate.
DONE — `head_below_hips` returns False when nose conf < nose_thr (0.2), so an unconfident/
hallucinated nose no longer fires the fall cue. test_geometry.py locks it; test_fall.py stays
green (the cue's confident-nose path is unchanged).
## Phase 3 — Privacy / PIPA (delivery compliance)
- [x] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
- [x] **P3-2 [H]** `server/src/Ingest/IngestController.php:68` — worker snapshots are stored
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
- [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored
in the session but never enforced; any authenticated user sees every device's worker
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
`events.site` (server-derived, never client-supplied). `App\Security\SiteScope` centralizes the
rule (NULL scope = global operator). The event LIST filters in SQL (`page(...,$site)`); event
DETAIL + ACK and the SNAPSHOT proxy guard per-row and return 404 (not 403) cross-site so
existence never leaks. Migration ALTERs + provisioning UPDATEs documented in schema.sql.
Tests: list scoped, detail cross-site 404, snapshot cross-site 404 / same-site 200.
- [x] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
`fetch_hailo_hef.py`.
DONE (with a caveat) — rewrote to stage-validate: rejects HTML/LFS-pointer, optional pinned
sha256+size, temp-file staging, fails LOUD. DISCOVERY: the old URL is DEAD (GitHub 404 / HTML
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
## Phase 4 — Server hardening (shared hosting)
- [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`.
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
backstop. Package build verified.
- [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error.
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
- [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows.
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
gated 1/100). Full PHP suite green.
- [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params.
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
- [x] **P4-5 [M]** `server/src/Dashboard/EventListController.php:17` — event list hard-capped at
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
- [x] **P4-6 [M]** `server/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error.
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
- [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
order write→insert, log failures, reconcile orphans.
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
file write, DB index) now `error_log`s instead of a silent `continue`; a DB-index failure after
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
(throwing repo double → asserts 201, event stored, no leftover *.jpg).
- [x] **P4-8 [L]** `server/src/Auth/AuthController.php:31` — login form emits a CSRF token that
`login` never checks. **Fix:** verify it (or drop it).
DONE — verified (completed the half-built defense, not dropped). login() checks
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
`testLoginRejectsMissingOrWrongCsrf`.
- [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
DONE — logout() rotates the id then wipes (was regenerate-after-destroy).
- [x] **P4-10 [L]** `server/src/Ingest/EventValidator.php:26` — no string length caps → an
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
lengths in the validator.
DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest.
- [x] **P4-11 [L]** `server/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
(parse_url scheme-relative). **Fix:** collapse/normalize.
## Phase 5 — Install / bundle / supply-chain
DONE — pathFor collapses leading slashes before parse_url; BasePathTest locks it.
- [x] **P5-1 [B]** `deploy/bundle/install_offline.sh:79` — the OFFLINE installer has no
backend-usability gate (setup_online's [6/8] does): it installs `POSE_BACKEND=movenet`
verbatim even when the bundle carries no working backend. **Fix:** mirror the [6/8] probe.
DONE — [5/6] probes HEF+hailo_platform / onnx+onnxruntime, reconciles POSE_BACKEND in the
installed env, hard-fails if neither. test_deploy_artifacts.py locks the gate.
- [x] **P5-2 [H]** `deploy/bundle/build_offline_bundle.sh:55` — a native build on x86 produces
an aarch64-named tarball full of x86_64 wheels. **Fix:** assert `uname -m == TARGET_ARCH`.
DONE — native branch now hard-fails when uname -m != TARGET_ARCH.
- [x] **P5-3 [M]** `deploy/bundle/build_offline_bundle.sh:65` — the air-gapped bundle never
carries the Hailo HEF, so offline Hailo install is impossible. **Fix:** stage the HEF (via
`fetch_hailo_hef --arch`) into the bundle.
DONE — build now fetches the `$HAILO_ARCH` (default hailo8) HEF via `fetch_hailo_hef` (pinned
sha256, aborts on mismatch) and stages `yolov8s_pose.hef`. Also fixed two latent breaks that
made the offline path unusable regardless: the MoveNet ONNX fetch was unconditional so its dead
source aborted the whole build under `set -e` (now guarded/optional → Hailo-only bundle), and
`install_offline.sh` copied only `*.onnx` so a bundled HEF never reached `$PREFIX/models` for the
backend gate to find (now copies `*.hef` too). MANIFEST lists models + available pose_backends.
Tests: fetches/stages HEF, MoveNet optional, installer copies the HEF.
- [x] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests,
MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin +
hash + manifest digests.
DONE — build emits `SHA256SUMS` (sha256 of every wheel + model), `requirements.lock` (exact
`name==version` pins for a reproducible rebuild), and `sbom.json` (CycloneDX 1.5: name, version,
pypi purl, sha256). Optional detached GPG signature via `SIGN_KEY` → `SHA256SUMS.asc`.
`install_offline.sh` runs `sha256sum -c SHA256SUMS` (and verifies the signature if present)
BEFORE any pip install, hard-failing on mismatch. Generation logic verified end-to-end on a
synthetic stage (valid JSON, `sha256sum -c` passes). Tests lock the manifest + the install-time
check.
- [x] **P5-5 [H]** `deploy/systemd/move-detect-edge.service:46` `OFF-HW` — `DeviceAllow` omits
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
- [x] **P5-6 [L]** `server/deploy/build_cafe24_package.sh` — dev cache dirs
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
---
## Progress log
(updated as each item lands — newest first)
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
P1-9: edge `event_uuid` idempotency key (stable through the queue) + server dedup on a UNIQUE
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
P2-6/7/8 (safety-timing / tracking-match — deferred to on-hardware commissioning).
- **2026-07-17 — off-hardware solo work COMPLETE (35/43).** Landed this pass, each test-locked:
P4-8 (login CSRF), P4-6 (zone 400-not-500), P4-5 (cursor pagination), P4-7 (snapshot atomicity
+ orphan cleanup), P5-3 (HEF in the offline bundle + fixed the broken build), P5-4 (pin + hash +
SBOM + install-time verify), P2-9/10/11 (keypoint confidence gating + MoveNet single-person
warning). phpunit 90, pytest 157 — all green.
REMAINING 8, none solo-doable:
· P1-9, P3-2b — need a DATA-MODEL decision (event dedup key + schema column; site↔device model).
· P2-6, P2-7, P2-8 — safety-timing / tracking-match logic that must be validated ON HARDWARE.
Changing them blind could make fall detection MISS events (worse than today's over-alarm,
which is fail-safe), so they are deliberately deferred to on-device commissioning.
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
knobs — tune with real camera footage on the Pi.)
- **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch
now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
correctness (P2-3,4,5). Remaining: P1-9 (deferred to server batch), P2-6..11 (mostly threshold
tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
decision: how many days), Phase 4 (server hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
Nothing committed yet.
DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package.

View file

@ -1,17 +1,45 @@
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
# 파일럿 · 운영 런북
이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다.
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
이 문서는 두 경로를 다룬다. **자신의 상황에 맞는 절에서 시작하라:**
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
- **§0 폐쇄망 Pi 현장 설치·운영** — Hailo·Pi5 실장비를 오프라인 번들로 설치한 경우. systemd 유닛의
`Documentation=`이 이 문서를 가리키므로 현장 운영자는 **§0만** 보면 된다.
- **§1~§6 노트북 웹캠 개발 파일럿** — 개발/데모 머신에서 CPU MoveNet로 엔드투엔드를 돌리는 최소 경로.
**이 절들의 `pip install`·모델 `fetch` 명령은 개발/빌드 머신 전용이다 — 폐쇄망 Pi에서 실행하지 말 것.**
- 엣지: `python -m edge` (또는 `move-detect-edge`) — pose → 감지 → HMAC 서명 POST.
- 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
---
## 1. 빌드 머신 준비 (모델 · onnxruntime)
## 0. 폐쇄망 Pi 현장 설치·운영
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서
> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
실장비 Hailo·Pi5는 **오프라인 전체 번들**로 설치한다. 런타임 휠·pose 모델(HEF)·소스·systemd 유닛이
모두 번들에 동봉되어 있으므로 **Pi에서 인터넷을 받지 않는다** (아래 §1의 `pip install`/`fetch`는 절대
실행 금지 — 빌드 머신 전용).
1. **설치:** 번들 절차는 [deploy/README.md](../deploy/README.md)를 따른다. 빌드 머신에서
`deploy/bundle/build_offline_bundle.sh`(aarch64)로 번들을 만들어 Pi로 옮기고,
`sudo ./install_offline.sh`가 SHA256SUMS 검증 → 오프라인 휠 설치 → HEF 배치 → 유닛 설치를 수행한다.
(온라인 프로비저닝은 `sudo deploy/setup_online.sh` — 카메라 프로브까지 자동.)
2. **멀티카메라:** `/etc/move-detect/edge.env`의 `CAMERAS=csi:0:cam-01,uvc:0:cam-02`
형식으로 1 Pi에 2~4대를 배선한다(형식 `backend:device:camera_id`).
3. **enable 전 카메라 프로브** (설치 스크립트가 출력하는 명령) — 잘못된 인덱스/미체결 리본이 만드는
"초록불인데 한 대 덜 감시" 상태를 현장에서 잡는다.
4. **기동·확인:** `sudo systemctl enable --now move-detect-edge` → `journalctl -u move-detect-edge -f`.
5. **시크릿 위생:** 프로비저닝에 `deploy/device.env`를 썼다면 설치 후 삭제·필요시 로테이션
(deploy/README.md "Secret handling").
> 시크릿·서버 URL·모델 경로 등 env 키는 §3 표와 동일하다. 감지 임계값은 서버 대시보드의 구역·임계값이
> 다음 config 폴링에 반영된다.
---
## 1. 빌드/개발 머신 준비 (모델 · onnxruntime) — 폐쇄망 Pi에서 실행 금지
> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망 Pi)에서는
> 인터넷을 받지 않으며, 아래 두 명령을 Pi에서 실행하면 안 된다(§0 참조 — Pi는 이미 번들로 설치됨).
> 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
```bash
pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들)
@ -25,23 +53,23 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
---
## 2. 클라우드 로컬 기동
## 2. 서버 로컬 기동
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만.
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
1. DB 생성 후 스키마 임포트: `mysql <db> < cloud/schema.sql` (10개 테이블).
2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
4. 기동: `php -S localhost:8000 -t cloud/public`
4. 기동: `php -S localhost:8000 -t server/public`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조.
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
---
@ -53,7 +81,7 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|-----|------|------|
| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` |
| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` |
| `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` |
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
@ -83,10 +111,13 @@ python -m edge # 또는 move-detect-edge
≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명).
7. **하트비트:** **30초 시간기반**(`heartbeat_interval_s=30`)으로 `POST /api/heartbeat`(서명).
부하와 무관하게 주기가 일정해 서버의 오프라인 판정(120초, 4배 마진)이 정상 장치를 오프라인으로
깜빡이지 않는다. temp/throttle/queue_pending/config_version 텔레메트리를 함께 싣는다.
> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로
> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다.
> cadence(`sync_every`/`flush_every`는 프레임 수, `heartbeat_interval_s`는 초)는 `daemon.main()`
> 기본값이며 env로 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를
> 임시 조정한다. 감지 타이머는 monotonic 시계로 돌아 NTP 시각 점프에 영향받지 않는다(occurred_at만 벽시계).
---
@ -94,7 +125,7 @@ python -m edge # 또는 move-detect-edge
```bash
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings)
cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # 서버(0 warnings)
```
모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —
@ -102,11 +133,15 @@ cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations #
---
## 6. 파일럿 한계 (명시)
## 6. 한계 (명시)
- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속.
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2.
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save`
패키징은 별도 후속 태스크.
> **이 절은 §1~§5의 노트북 웹캠 파일럿에 대한 것이다.** 폐쇄망 Pi 현장 설치(§0)는 아래 항목 다수를
> 이미 해소했다 — 실장비 Hailo 가속, 멀티카메라, 오프라인 전체 번들이 제공된다.
- **노트북 파일럿은 단일 카메라 · CPU pose(저 fps)** — 폐쇄망 Pi(§0)는 Hailo 가속 + 멀티카메라
(`CAMERAS=` 다중 배선)를 사용한다.
- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2(양끝 인프라는 존재, 배선 대기).
- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook·미확인 에스컬레이션 메일은 후속(P3).
- **폐쇄망 전체 번들은 이미 존재한다** — `deploy/bundle/`에 오프라인 휠·HEF·소스·SHA256SUMS 검증
설치를 제공한다(§0). 노트북 파일럿만 `pip install`/`fetch`로 편의 실행할 뿐이다.
- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).

View file

@ -1,4 +1,4 @@
# P1-B 클라우드 수신·대시보드 (Cloud Ingest + Dashboard) Implementation Plan
# P1-B 서버 수신·대시보드 (Server Ingest + Dashboard) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@ -10,22 +10,22 @@
## Global Constraints
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `cloud/src/`).
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `cloud/public/`만 웹 노출.
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `server/src/`).
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `server/public/`만 웹 노출.
- **HMAC 계약(P1-A와 정확히 일치):** 서명 = `hmac-sha256(nonce + timestamp + body, per_device_secret)` hex. P1-A `edge/alert.py::sign`과 바이트 동일하게 검증. 헤더 `X-Signature`, `X-Nonce`, `X-Timestamp`.
- **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초).
- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`cloud/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`server/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
- **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증.
- **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer.
- **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트.
- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`cloud/config.php`는 `.gitignore`), 하드코딩 금지.
- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`server/config.php`는 `.gitignore`), 하드코딩 금지.
- **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24).
- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.
## File Structure
```
cloud/
server/
composer.json # PSR-4 autoload + phpunit(dev)
phpunit.xml
config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore)
@ -64,7 +64,7 @@ cloud/
## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)
**Files:**
- Create: `cloud/composer.json`, `cloud/phpunit.xml`, `cloud/src/Config.php`, `cloud/config.example.php`, `cloud/tests/ConfigTest.php`, `cloud/.gitignore`
- Create: `server/composer.json`, `server/phpunit.xml`, `server/src/Config.php`, `server/config.example.php`, `server/tests/ConfigTest.php`, `server/.gitignore`
**Interfaces:**
- Consumes: (없음)
@ -74,7 +74,7 @@ cloud/
```json
{
"name": "move-detect/cloud",
"name": "move-detect/server",
"type": "project",
"require": { "php": ">=8.2" },
"require-dev": { "phpunit/phpunit": "^11" },
@ -95,7 +95,7 @@ cloud/
</phpunit>
```
- [ ] **Step 3: `.gitignore`** (cloud/)
- [ ] **Step 3: `.gitignore`** (server/)
```
/vendor/
@ -104,7 +104,7 @@ cloud/
!/storage/snapshots/.gitkeep
```
- [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php`
- [ ] **Step 4: 실패 테스트** — `server/tests/ConfigTest.php`
```php
<?php
@ -135,10 +135,10 @@ final class ConfigTest extends TestCase
- [ ] **Step 5: 실패 확인**
Run: `cd cloud && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
Run: `cd server && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
Expected: FAIL — `Class "App\Config" not found`
- [ ] **Step 6: 구현** — `cloud/src/Config.php`
- [ ] **Step 6: 구현** — `server/src/Config.php`
```php
<?php
@ -180,8 +180,8 @@ return [
Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS
```bash
git add cloud/composer.json cloud/phpunit.xml cloud/.gitignore cloud/config.example.php cloud/src/Config.php cloud/tests/ConfigTest.php
git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config"
git add server/composer.json server/phpunit.xml server/.gitignore server/config.example.php server/src/Config.php server/tests/ConfigTest.php
git commit -m "feat(server): P1-B scaffold — composer autoload + Config"
```
---
@ -189,13 +189,13 @@ git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config"
## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치
**Files:**
- Create: `cloud/src/Security/Hmac.php`, `cloud/tests/HmacTest.php`
- Create: `server/src/Security/Hmac.php`, `server/tests/HmacTest.php`
**Interfaces:**
- Consumes: (없음)
- Produces: `App\Security\Hmac::sign(string $secret, string $nonce, string $ts, string $body): string` (hex), `Hmac::verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool` (hash_equals, 상수시간).
- [ ] **Step 1: 실패 테스트** — `cloud/tests/HmacTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/HmacTest.php`
```php
<?php
@ -229,7 +229,7 @@ final class HmacTest extends TestCase
Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found)
- [ ] **Step 3: 구현** — `cloud/src/Security/Hmac.php`
- [ ] **Step 3: 구현** — `server/src/Security/Hmac.php`
```php
<?php
@ -256,8 +256,8 @@ final class Hmac
Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS
```bash
git add cloud/src/Security/Hmac.php cloud/tests/HmacTest.php
git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
git add server/src/Security/Hmac.php server/tests/HmacTest.php
git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
```
---
@ -265,7 +265,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
## Task 3: Repository 인터페이스 + InMemory 테스트더블
**Files:**
- Create: `cloud/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `cloud/tests/Support/InMemoryRepositories.php`, `cloud/tests/RepositoryContractTest.php`
- Create: `server/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `server/tests/Support/InMemoryRepositories.php`, `server/tests/RepositoryContractTest.php`
**Interfaces:**
- Consumes: (없음)
@ -282,7 +282,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
- [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의.
`cloud/src/Repository/DeviceRepository.php`:
`server/src/Repository/DeviceRepository.php`:
```php
<?php
@ -413,7 +413,7 @@ interface AuditRepository
}
```
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `cloud/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `server/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
```php
<?php
@ -479,7 +479,7 @@ final class InMemoryAuditRepository implements AuditRepository {
}
```
그리고 `cloud/tests/RepositoryContractTest.php`:
그리고 `server/tests/RepositoryContractTest.php`:
```php
<?php
@ -523,8 +523,8 @@ Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS.
- [ ] **Step 4: 커밋**
```bash
git add cloud/src/Repository/*.php cloud/tests/Support/InMemoryRepositories.php cloud/tests/RepositoryContractTest.php
git commit -m "feat(cloud): repository interfaces + in-memory test doubles"
git add server/src/Repository/*.php server/tests/Support/InMemoryRepositories.php server/tests/RepositoryContractTest.php
git commit -m "feat(server): repository interfaces + in-memory test doubles"
```
---
@ -532,13 +532,13 @@ git commit -m "feat(cloud): repository interfaces + in-memory test doubles"
## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증)
**Files:**
- Create: `cloud/src/Ingest/EventValidator.php`, `cloud/tests/EventValidatorTest.php`
- Create: `server/src/Ingest/EventValidator.php`, `server/tests/EventValidatorTest.php`
**Interfaces:**
- Consumes: (없음)
- Produces: `App\Ingest\EventValidator::validate(array $payload): array` — `['ok' => bool, 'errors' => string[], 'clean' => array]`. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/EventValidatorTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/EventValidatorTest.php`
```php
<?php
@ -579,7 +579,7 @@ final class EventValidatorTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Ingest/EventValidator.php`
- [ ] **Step 3: 구현** — `server/src/Ingest/EventValidator.php`
```php
<?php
@ -627,8 +627,8 @@ final class EventValidator
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Ingest/EventValidator.php cloud/tests/EventValidatorTest.php
git commit -m "feat(cloud): event payload validator with enum whitelist"
git add server/src/Ingest/EventValidator.php server/tests/EventValidatorTest.php
git commit -m "feat(server): event payload validator with enum whitelist"
```
---
@ -636,13 +636,13 @@ git commit -m "feat(cloud): event payload validator with enum whitelist"
## Task 5: ImageProcessor (gd 재인코딩 + UUID)
**Files:**
- Create: `cloud/src/Ingest/ImageProcessor.php`, `cloud/tests/ImageProcessorTest.php`
- Create: `server/src/Ingest/ImageProcessor.php`, `server/tests/ImageProcessorTest.php`
**Interfaces:**
- Consumes: (없음)
- Produces: `App\Ingest\ImageProcessor::reencode(string $base64, int $quality = 80): array` — `['ok'=>bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]`. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 `['ok'=>false]`. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트).
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ImageProcessorTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/ImageProcessorTest.php`
```php
<?php
@ -677,7 +677,7 @@ final class ImageProcessorTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Ingest/ImageProcessor.php`
- [ ] **Step 3: 구현** — `server/src/Ingest/ImageProcessor.php`
```php
<?php
@ -710,8 +710,8 @@ final class ImageProcessor
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Ingest/ImageProcessor.php cloud/tests/ImageProcessorTest.php
git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
git add server/src/Ingest/ImageProcessor.php server/tests/ImageProcessorTest.php
git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"
```
---
@ -719,7 +719,7 @@ git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
## Task 6: Http Request/Response
**Files:**
- Create: `cloud/src/Http/Request.php`, `cloud/src/Http/Response.php`, `cloud/tests/HttpTest.php`
- Create: `server/src/Http/Request.php`, `server/src/Http/Response.php`, `server/tests/HttpTest.php`
**Interfaces:**
- Consumes: (없음)
@ -727,7 +727,7 @@ git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
- `App\Http\Request(string $method, string $path, array $headers, string $body, array $query, array $post)` with getters `method(), path(), header(string $name): ?string, body(), json(): array, query(string $k): ?string, post(string $k): ?string`, and static `fromGlobals(): self`.
- `App\Http\Response`: static `json(array $data, int $status=200): self`, `text(string $s, int $status=200): self`, `bytes(string $b, string $contentType, int $status=200): self`, `redirect(string $to): self`; getters `status(), headers(), body()`; `send(): void`.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/HttpTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/HttpTest.php`
```php
<?php
@ -755,7 +755,7 @@ final class HttpTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Http/Request.php`
- [ ] **Step 3: 구현** — `server/src/Http/Request.php`
```php
<?php
@ -792,7 +792,7 @@ final class Request
}
```
`cloud/src/Http/Response.php`:
`server/src/Http/Response.php`:
```php
<?php
@ -829,8 +829,8 @@ final class Response
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Http/Request.php cloud/src/Http/Response.php cloud/tests/HttpTest.php
git commit -m "feat(cloud): Http Request/Response primitives"
git add server/src/Http/Request.php server/src/Http/Response.php server/tests/HttpTest.php
git commit -m "feat(server): Http Request/Response primitives"
```
---
@ -838,13 +838,13 @@ git commit -m "feat(cloud): Http Request/Response primitives"
## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합
**Files:**
- Create: `cloud/src/Ingest/IngestController.php`, `cloud/tests/IngestControllerTest.php`
- Create: `server/src/Ingest/IngestController.php`, `server/tests/IngestControllerTest.php`
**Interfaces:**
- Consumes: `Hmac` (T2), `EventValidator`/`ImageProcessor` (T4/T5), `Request`/`Response` (T6), `DeviceRepository`/`NonceRepository`/`RateLimiterRepository`/`EventRepository`/`SnapshotRepository` (T3), `Config` (T1)
- Produces: `App\Ingest\IngestController(Config, DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository)` with `handle(Request $req, int $now): Response`. 순서: rate limit → device secret 조회 → HMAC 검증 → timestamp 윈도우 → nonce replay → 검증 → 이미지 재인코딩·저장 → 이벤트+스냅샷 insert. 실패는 401/400/429, 성공 201 `{event_id}`.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/IngestControllerTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/IngestControllerTest.php`
```php
<?php
@ -919,7 +919,7 @@ final class IngestControllerTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Ingest/IngestController.php`
- [ ] **Step 3: 구현** — `server/src/Ingest/IngestController.php`
```php
<?php
@ -991,8 +991,8 @@ final class IngestController
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Ingest/IngestController.php cloud/tests/IngestControllerTest.php
git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
git add server/src/Ingest/IngestController.php server/tests/IngestControllerTest.php
git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
```
---
@ -1000,7 +1000,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
## Task 8: Password (argon2id) + Csrf
**Files:**
- Create: `cloud/src/Security/Password.php`, `cloud/src/Security/Csrf.php`, `cloud/tests/PasswordCsrfTest.php`
- Create: `server/src/Security/Password.php`, `server/src/Security/Csrf.php`, `server/tests/PasswordCsrfTest.php`
**Interfaces:**
- Consumes: (없음)
@ -1008,7 +1008,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
- `App\Security\Password::hash(string $p): string` (PASSWORD_ARGON2ID), `Password::verify(string $p, string $hash): bool`.
- `App\Security\Csrf::issue(callable $randHex): string`, `Csrf::check(string $expected, string $given): bool` (hash_equals).
- [ ] **Step 1: 실패 테스트** — `cloud/tests/PasswordCsrfTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/PasswordCsrfTest.php`
```php
<?php
@ -1036,7 +1036,7 @@ final class PasswordCsrfTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Security/Password.php`
- [ ] **Step 3: 구현** — `server/src/Security/Password.php`
```php
<?php
@ -1054,7 +1054,7 @@ final class Password
}
```
`cloud/src/Security/Csrf.php`:
`server/src/Security/Csrf.php`:
```php
<?php
@ -1073,8 +1073,8 @@ final class Csrf
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Security/Password.php cloud/src/Security/Csrf.php cloud/tests/PasswordCsrfTest.php
git commit -m "feat(cloud): argon2id password + CSRF token helpers"
git add server/src/Security/Password.php server/src/Security/Csrf.php server/tests/PasswordCsrfTest.php
git commit -m "feat(server): argon2id password + CSRF token helpers"
```
---
@ -1082,13 +1082,13 @@ git commit -m "feat(cloud): argon2id password + CSRF token helpers"
## Task 9: Rbac (역할→권한)
**Files:**
- Create: `cloud/src/Auth/Rbac.php`, `cloud/tests/RbacTest.php`
- Create: `server/src/Auth/Rbac.php`, `server/tests/RbacTest.php`
**Interfaces:**
- Consumes: (없음)
- Produces: `App\Auth\Rbac::can(string $role, string $permission): bool`. 권한: `view_events`(all), `acknowledge`(admin,operator), `edit_zones`(admin,operator), `manage_thresholds`(admin), `manage_compliance`(admin).
- [ ] **Step 1: 실패 테스트** — `cloud/tests/RbacTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/RbacTest.php`
```php
<?php
@ -1123,7 +1123,7 @@ final class RbacTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Auth/Rbac.php`
- [ ] **Step 3: 구현** — `server/src/Auth/Rbac.php`
```php
<?php
@ -1146,8 +1146,8 @@ final class Rbac
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Auth/Rbac.php cloud/tests/RbacTest.php
git commit -m "feat(cloud): RBAC permission matrix"
git add server/src/Auth/Rbac.php server/tests/RbacTest.php
git commit -m "feat(server): RBAC permission matrix"
```
---
@ -1155,7 +1155,7 @@ git commit -m "feat(cloud): RBAC permission matrix"
## Task 10: Session + AuthController (로그인/brute-force 잠금)
**Files:**
- Create: `cloud/src/Auth/Session.php`, `cloud/src/Auth/AuthController.php`, `cloud/tests/AuthControllerTest.php`
- Create: `server/src/Auth/Session.php`, `server/src/Auth/AuthController.php`, `server/tests/AuthControllerTest.php`
**Interfaces:**
- Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3)
@ -1163,7 +1163,7 @@ git commit -m "feat(cloud): RBAC permission matrix"
- `interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }`, `ArraySessionStore` (test), `PhpSessionStore` (prod, secure cookie).
- `App\Auth\AuthController(UserRepository $users, AuditRepository $audit, int $maxAttempts=5, int $lockSeconds=900)` with `login(Request $req, SessionStore $sess, int $now): Response` and `logout(SessionStore $sess): Response`. Locks after maxAttempts; 200 on success (sets user in session), 401 on bad creds, 423 when locked.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/AuthControllerTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/AuthControllerTest.php`
```php
<?php
@ -1210,7 +1210,7 @@ final class AuthControllerTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Auth/Session.php`
- [ ] **Step 3: 구현** — `server/src/Auth/Session.php`
```php
<?php
@ -1240,7 +1240,7 @@ final class PhpSessionStore implements SessionStore {
}
```
`cloud/src/Auth/AuthController.php`:
`server/src/Auth/AuthController.php`:
```php
<?php
@ -1300,8 +1300,8 @@ final class AuthController
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Auth/Session.php cloud/src/Auth/AuthController.php cloud/tests/AuthControllerTest.php
git commit -m "feat(cloud): session store + auth controller with lockout"
git add server/src/Auth/Session.php server/src/Auth/AuthController.php server/tests/AuthControllerTest.php
git commit -m "feat(server): session store + auth controller with lockout"
```
---
@ -1309,13 +1309,13 @@ git commit -m "feat(cloud): session store + auth controller with lockout"
## Task 11: Router + 프론트컨트롤러 준비
**Files:**
- Create: `cloud/src/Http/Router.php`, `cloud/tests/RouterTest.php`
- Create: `server/src/Http/Router.php`, `server/tests/RouterTest.php`
**Interfaces:**
- Consumes: `Request`/`Response` (T6)
- Produces: `App\Http\Router` with `add(string $method, string $path, callable $handler): void` and `dispatch(Request $req): Response` (404 if no match; path params `{id}` → passed to handler as second arg assoc array).
- [ ] **Step 1: 실패 테스트** — `cloud/tests/RouterTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/RouterTest.php`
```php
<?php
@ -1347,7 +1347,7 @@ final class RouterTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Http/Router.php`
- [ ] **Step 3: 구현** — `server/src/Http/Router.php`
```php
<?php
@ -1379,8 +1379,8 @@ final class Router
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Http/Router.php cloud/tests/RouterTest.php
git commit -m "feat(cloud): path-param router + dispatch"
git add server/src/Http/Router.php server/tests/RouterTest.php
git commit -m "feat(server): path-param router + dispatch"
```
---
@ -1388,7 +1388,7 @@ git commit -m "feat(cloud): path-param router + dispatch"
## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시
**Files:**
- Create: `cloud/src/Dashboard/EventListController.php`, `cloud/src/Dashboard/EventDetailController.php`, `cloud/src/Dashboard/SnapshotProxy.php`, `cloud/tests/DashboardTest.php`
- Create: `server/src/Dashboard/EventListController.php`, `server/src/Dashboard/EventDetailController.php`, `server/src/Dashboard/SnapshotProxy.php`, `server/tests/DashboardTest.php`
**Interfaces:**
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3)
@ -1397,7 +1397,7 @@ git commit -m "feat(cloud): path-param router + dispatch"
- `EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response`; `acknowledge(Request, array $params): Response` (RBAC `acknowledge`).
- `SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response` — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/DashboardTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/DashboardTest.php`
```php
<?php
@ -1451,7 +1451,7 @@ final class DashboardTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Dashboard/EventListController.php`
- [ ] **Step 3: 구현** — `server/src/Dashboard/EventListController.php`
```php
<?php
@ -1476,7 +1476,7 @@ final class EventListController
}
```
`cloud/src/Dashboard/EventDetailController.php`:
`server/src/Dashboard/EventDetailController.php`:
```php
<?php
@ -1516,7 +1516,7 @@ final class EventDetailController
}
```
`cloud/src/Dashboard/SnapshotProxy.php`:
`server/src/Dashboard/SnapshotProxy.php`:
```php
<?php
@ -1545,8 +1545,8 @@ final class SnapshotProxy
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Dashboard/EventListController.php cloud/src/Dashboard/EventDetailController.php cloud/src/Dashboard/SnapshotProxy.php cloud/tests/DashboardTest.php
git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot proxy"
git add server/src/Dashboard/EventListController.php server/src/Dashboard/EventDetailController.php server/src/Dashboard/SnapshotProxy.php server/tests/DashboardTest.php
git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot proxy"
```
---
@ -1554,7 +1554,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
## Task 13: Polygon 검증 + ZoneConfigController
**Files:**
- Create: `cloud/src/Geometry/Polygon.php`, `cloud/src/Dashboard/ZoneConfigController.php`, `cloud/tests/ZoneConfigTest.php`
- Create: `server/src/Geometry/Polygon.php`, `server/src/Dashboard/ZoneConfigController.php`, `server/tests/ZoneConfigTest.php`
**Interfaces:**
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3)
@ -1562,7 +1562,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
- `App\Geometry\Polygon::validate(array $points): bool` — ≥3점, 각 점 `[x,y]` 숫자.
- `ZoneConfigController(ZoneRepository, AuditRepository, SessionStore)`: `list(Request, array): Response` (camera별), `save(Request): Response` (RBAC edit_zones; level ∈ {warning,restricted,pinch}; polygon 검증; zone_id 반환), `delete(Request, array): Response`.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ZoneConfigTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/ZoneConfigTest.php`
```php
<?php
@ -1610,7 +1610,7 @@ final class ZoneConfigTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Geometry/Polygon.php`
- [ ] **Step 3: 구현** — `server/src/Geometry/Polygon.php`
```php
<?php
@ -1631,7 +1631,7 @@ final class Polygon
}
```
`cloud/src/Dashboard/ZoneConfigController.php`:
`server/src/Dashboard/ZoneConfigController.php`:
```php
<?php
@ -1698,8 +1698,8 @@ final class ZoneConfigController
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Geometry/Polygon.php cloud/src/Dashboard/ZoneConfigController.php cloud/tests/ZoneConfigTest.php
git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
git add server/src/Geometry/Polygon.php server/src/Dashboard/ZoneConfigController.php server/tests/ZoneConfigTest.php
git commit -m "feat(server): polygon validation + zone config controller (RBAC)"
```
---
@ -1707,7 +1707,7 @@ git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
## Task 14: ConfigController + HeartbeatController (Pi 동기 API)
**Files:**
- Create: `cloud/src/Api/ConfigController.php`, `cloud/src/Api/HeartbeatController.php`, `cloud/tests/ApiSyncTest.php`
- Create: `server/src/Api/ConfigController.php`, `server/src/Api/HeartbeatController.php`, `server/tests/ApiSyncTest.php`
**Interfaces:**
- Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1)
@ -1715,7 +1715,7 @@ git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
- `App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response` — `?device_id=`; device secret HMAC 검증(엣지 폴링). 반환 `{version, zones, thresholds}`.
- `App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response` — HMAC 검증 후 `touchHeartbeat(device_id, {fps,temp,throttle,config_version,at})`. 200.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ApiSyncTest.php`
- [ ] **Step 1: 실패 테스트** — `server/tests/ApiSyncTest.php`
```php
<?php
@ -1768,7 +1768,7 @@ final class ApiSyncTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Api/ConfigController.php`
- [ ] **Step 3: 구현** — `server/src/Api/ConfigController.php`
```php
<?php
@ -1802,7 +1802,7 @@ final class ConfigController
}
```
`cloud/src/Api/HeartbeatController.php`:
`server/src/Api/HeartbeatController.php`:
```php
<?php
@ -1840,8 +1840,8 @@ final class HeartbeatController
- [ ] **Step 4: 통과 + 커밋**
```bash
git add cloud/src/Api/ConfigController.php cloud/src/Api/HeartbeatController.php cloud/tests/ApiSyncTest.php
git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
git add server/src/Api/ConfigController.php server/src/Api/HeartbeatController.php server/tests/ApiSyncTest.php
git commit -m "feat(server): device config + heartbeat sync API (HMAC)"
```
---
@ -1849,11 +1849,11 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합)
**Files:**
- Create: `cloud/public/index.php`, `cloud/public/.htaccess`, `cloud/public/assets/{app.css,app.js,zone-draw.js}`, `cloud/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `cloud/tests/WiringSmokeTest.php`
- Create: `server/public/index.php`, `server/public/.htaccess`, `server/public/assets/{app.css,app.js,zone-draw.js}`, `server/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `server/tests/WiringSmokeTest.php`
**Interfaces:**
- Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만).
- Produces: `cloud/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
- Produces: `server/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
라우트 표:
| method | path | handler |
@ -1872,7 +1872,7 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
| GET | /snap/{uuid} | SnapshotProxy::serve |
| GET | /, /zones, /login | View render |
- [ ] **Step 1: 라우터 스모크 테스트** — `cloud/tests/WiringSmokeTest.php`
- [ ] **Step 1: 라우터 스모크 테스트** — `server/tests/WiringSmokeTest.php`
```php
<?php
@ -1902,7 +1902,7 @@ final class WiringSmokeTest extends TestCase
- [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS.
- [ ] **Step 3: 프론트컨트롤러** — `cloud/public/index.php`
- [ ] **Step 3: 프론트컨트롤러** — `server/public/index.php`
```php
<?php
@ -1970,7 +1970,7 @@ $r->add('GET', '/zones', fn() => $render('zones'));
$r->dispatch(Request::fromGlobals())->send();
```
- [ ] **Step 4: .htaccess** — `cloud/public/.htaccess`
- [ ] **Step 4: .htaccess** — `server/public/.htaccess`
```apache
RewriteEngine On
@ -1994,8 +1994,8 @@ RewriteRule ^ index.php [QSA,L]
Run: `./vendor/bin/phpunit` (전체) — green.
```bash
git add cloud/public cloud/src/View cloud/tests/WiringSmokeTest.php
git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
git add server/public server/src/View server/tests/WiringSmokeTest.php
git commit -m "feat(server): front controller wiring + views + zone-draw UI"
```
---
@ -2005,13 +2005,13 @@ git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
> ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차.
**Files:**
- Create: `cloud/src/Db/Database.php`, `cloud/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `cloud/schema.sql`, `cloud/docs/cloud-deploy.md`, `cloud/tests/PdoIntegrationTest.php`
- Create: `server/src/Db/Database.php`, `server/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `server/schema.sql`, `server/docs/server-deploy.md`, `server/tests/PdoIntegrationTest.php`
**Interfaces:**
- Consumes: repository 인터페이스(T3), `Config`(T1)
- Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement).
- [ ] **Step 1: Database factory** — `cloud/src/Db/Database.php`
- [ ] **Step 1: Database factory** — `server/src/Db/Database.php`
```php
<?php
@ -2035,7 +2035,7 @@ final class Database
- [ ] **Step 2: schema.sql** — MySQL DDL (P1-B 서브셋: users, devices, cameras, zones, events, event_snapshots, thresholds, nonces, rate_limits, audit_log). §7.4 스펙 기준. `event_type`/`severity`/`level`은 애플리케이션 화이트리스트(+ 선택 CHECK). `nonces(nonce PK, expires_at INDEX)`, `rate_limits(rate_key, window)` 유니크. sqlite 호환 노트(AUTOINCREMENT 등) 주석. metrics/polygon_json은 TEXT/JSON.
- [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `cloud/src/Repository/Pdo/PdoNonceRepository.php`:
- [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `server/src/Repository/Pdo/PdoNonceRepository.php`:
```php
<?php
@ -2069,7 +2069,7 @@ final class PdoNonceRepository implements NonceRepository
- `PdoThresholdRepository`: forDevice(SELECT key/value → assoc).
- `PdoAuditRepository`: append(INSERT, before/after=json_encode).
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `cloud/tests/PdoIntegrationTest.php`:
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `server/tests/PdoIntegrationTest.php`:
```php
protected function setUp(): void {
@ -2082,15 +2082,15 @@ protected function setUp(): void {
로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지.
- [ ] **Step 5: 배포 문서** — `cloud/docs/cloud-deploy.md`: Cafe24 업로드 절차(`public/`만 웹루트; `storage/`·`src/`·`config.php`는 웹루트 밖 또는 상위 디렉토리), `config.php` 작성(config.example.php 복사), `schema.sql` phpMyAdmin import, per-device secret 발급(admin), 최초 admin 계정 생성 CLI 스니펫(`password_hash` argon2id), HTTPS/HSTS 강제, PHP 버전·gd·openssl 확인, `vendor/`는 배포 제외(런타임 의존성 0), storage/snapshots 쓰기권한.
- [ ] **Step 5: 배포 문서** — `server/docs/server-deploy.md`: Cafe24 업로드 절차(`public/`만 웹루트; `storage/`·`src/`·`config.php`는 웹루트 밖 또는 상위 디렉토리), `config.php` 작성(config.example.php 복사), `schema.sql` phpMyAdmin import, per-device secret 발급(admin), 최초 admin 계정 생성 CLI 스니펫(`password_hash` argon2id), HTTPS/HSTS 강제, PHP 버전·gd·openssl 확인, `vendor/`는 배포 제외(런타임 의존성 0), storage/snapshots 쓰기권한.
- [ ] **Step 6: 전체 스위트 + 커밋**
Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip).
```bash
git add cloud/src/Db cloud/src/Repository/Pdo cloud/schema.sql cloud/docs/cloud-deploy.md cloud/tests/PdoIntegrationTest.php
git commit -m "feat(cloud): PDO repositories + schema + Cafe24 deploy doc"
git add server/src/Db server/src/Repository/Pdo server/schema.sql server/docs/server-deploy.md server/tests/PdoIntegrationTest.php
git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc"
```
---
@ -2118,7 +2118,7 @@ git commit -m "feat(cloud): PDO repositories + schema + Cafe24 deploy doc"
## Execution Handoff
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md`.**
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-server-ingest-dashboard.md`.**
실행 옵션:
1. **Subagent-Driven (추천)** — 태스크마다 새 subagent + 리뷰. `superpowers:subagent-driven-development`.

View file

@ -2,18 +2,18 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(클라우드) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 클라우드 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.)
**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(서버) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 서버 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.)
**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 클라우드 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) CloudClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 클라우드 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트.
**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 서버 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) ServerClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 서버 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트.
**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 클라우드는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지.
**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 서버는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지.
## Global Constraints
- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 클라우드 스위트 **0 warnings/0 deprecations** 유지.
- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 서버 스위트 **0 warnings/0 deprecations** 유지.
- **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`).
- **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐.
- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(클라우드 T14가 이미 검증). PostClient 서명 재사용.
- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(서버 T14가 이미 검증). PostClient 서명 재사용.
- **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑.
- YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음.
@ -21,7 +21,7 @@
```
edge/
cloudclient.py # CloudClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
serverclient.py # ServerClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
zones.py # zone rows(dict) -> Zone objects; thresholds
runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones()
daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main()
@ -30,33 +30,33 @@ edge/
movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap
tools/
fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/
cloud/
server/
src/View/event_detail.php # (replace stub) HTML detail shell + script
public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}>
public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical
public/index.php # (edit) add GET /events/{id}/view HTML route
tests/ (edge) + cloud/tests/
tests/ (edge) + server/tests/
docs/pilot-runbook.md
```
---
## Task 1: CloudClient — config 폴링 + 하트비트 (HMAC 서명)
## Task 1: ServerClient — config 폴링 + 하트비트 (HMAC 서명)
**Files:** Create `edge/cloudclient.py`, `tests/test_cloudclient.py`
**Files:** Create `edge/serverclient.py`, `tests/test_serverclient.py`
**Interfaces:**
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
- Produces:
- `CloudClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
- `ServerClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
- `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None.
- `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx.
- [ ] **Step 1: 실패 테스트** — `tests/test_cloudclient.py`
- [ ] **Step 1: 실패 테스트** — `tests/test_serverclient.py`
```python
import json
from edge.cloudclient import CloudClient
from edge.serverclient import ServerClient
from edge.alert import sign
class _Resp:
@ -68,30 +68,30 @@ def test_fetch_config_signs_and_parses():
def fake_get(url, headers=None, timeout=None):
captured["url"] = url; captured["headers"] = headers
return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}}))
c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
c = ServerClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
cfg = c.fetch_config()
assert cfg["version"] == 3
assert "device_id=pi-1" in captured["url"]
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "")
def test_fetch_config_none_on_error():
c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
c = ServerClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
assert c.fetch_config() is None
def test_send_heartbeat_signs_body():
captured = {}
def fake_post(url, data=None, headers=None, timeout=None):
captured["data"] = data; captured["headers"] = headers; return _Resp(200)
c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
c = ServerClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
assert c.send_heartbeat({"fps": 12.0}) is True
body = captured["data"]
assert json.loads(body)["device_id"] == "pi-1"
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body)
```
- [ ] **Step 2: 실패 확인** — `pytest tests/test_cloudclient.py -v` → FAIL (ModuleNotFoundError)
- [ ] **Step 2: 실패 확인** — `pytest tests/test_serverclient.py -v` → FAIL (ModuleNotFoundError)
- [ ] **Step 3: 구현** — `edge/cloudclient.py`
- [ ] **Step 3: 구현** — `edge/serverclient.py`
```python
import json
@ -99,7 +99,7 @@ import json
from edge.alert import sign
class CloudClient:
class ServerClient:
def __init__(self, base_url: str, device_id: str, secret: str,
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
self.base_url = base_url.rstrip("/")
@ -146,16 +146,16 @@ class CloudClient:
return 200 <= resp.status_code < 300
```
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_cloudclient.py -v` → PASS
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_serverclient.py -v` → PASS
```bash
git add edge/cloudclient.py tests/test_cloudclient.py
git commit -m "feat(edge): CloudClient for config poll + heartbeat (HMAC-signed)"
git add edge/serverclient.py tests/test_serverclient.py
git commit -m "feat(edge): ServerClient for config poll + heartbeat (HMAC-signed)"
```
---
## Task 2: 클라우드 zone 행 → Zone 객체 로딩
## Task 2: 서버 zone 행 → Zone 객체 로딩
**Files:** Create `edge/zones.py`, `tests/test_zones.py`
@ -225,7 +225,7 @@ def thresholds_from_config(config: dict) -> dict:
```bash
git add edge/zones.py tests/test_zones.py
git commit -m "feat(edge): parse cloud config zones/thresholds into Zone objects"
git commit -m "feat(edge): parse server config zones/thresholds into Zone objects"
```
---
@ -341,7 +341,7 @@ git commit -m "feat(edge): PipelineRuntime assembles components + hot-swaps zone
**Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml`
**Interfaces:**
- Consumes: `PipelineRuntime` (T3), `CloudClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
- Consumes: `PipelineRuntime` (T3), `ServerClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
- Produces:
- `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict.
- `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리)
@ -427,7 +427,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
import time
from edge.config import load_config
from edge.cloudclient import CloudClient
from edge.serverclient import ServerClient
from edge.alert import PostClient
from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime
@ -438,7 +438,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
nonce = lambda: os.urandom(8).hex()
now = time.time
base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
@ -591,15 +591,15 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
---
## Task 6: 클라우드 사고 상세 뷰 — 스냅샷 표시
## Task 6: 서버 사고 상세 뷰 — 스냅샷 표시
**Files:** Modify `cloud/src/View/event_detail.php`; Create `cloud/public/assets/detail.js`; Modify `cloud/public/index.php`; Modify `cloud/tests/WiringSmokeTest.php`
**Files:** Modify `server/src/View/event_detail.php`; Create `server/public/assets/detail.js`; Modify `server/public/index.php`; Modify `server/tests/WiringSmokeTest.php`
**Interfaces:**
- Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image).
- Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe).
- [ ] **Step 1: 스모크 테스트 갱신** — `cloud/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd cloud && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
- [ ] **Step 1: 스모크 테스트 갱신** — `server/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd server && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
- [ ] **Step 2: event_detail.php (스텁 교체)**
@ -612,7 +612,7 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
<script src="/assets/detail.js" defer></script>
```
- [ ] **Step 3: detail.js** — `cloud/public/assets/detail.js`
- [ ] **Step 3: detail.js** — `server/public/assets/detail.js`
```javascript
(function () {
@ -653,18 +653,18 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
$r->add('GET', '/events/{id}/view', fn() => $render('event_detail'));
```
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l cloud/public/index.php`.
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l server/public/index.php`.
```bash
git add cloud/src/View/event_detail.php cloud/public/assets/detail.js cloud/public/index.php cloud/tests/WiringSmokeTest.php
git commit -m "feat(cloud): incident detail view renders snapshot images"
git add server/src/View/event_detail.php server/public/assets/detail.js server/public/index.php server/tests/WiringSmokeTest.php
git commit -m "feat(server): incident detail view renders snapshot images"
```
---
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
**Files:** Modify `cloud/public/assets/app.js`
**Files:** Modify `server/public/assets/app.js`
**Interfaces:**
- Consumes: `GET /api/events` (기존 폴링).
@ -702,11 +702,11 @@ function updateAlarm(events) {
그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출.
- [ ] **Step 2: 통과 + 커밋** — `node --check cloud/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
- [ ] **Step 2: 통과 + 커밋** — `node --check server/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
```bash
git add cloud/public/assets/app.js
git commit -m "feat(cloud): dashboard rows link to detail + desk Web Audio on critical"
git add server/public/assets/app.js
git commit -m "feat(server): dashboard rows link to detail + desk Web Audio on critical"
```
---
@ -735,8 +735,8 @@ def test_real_movenet_runs():
- [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차:
1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`).
2. **클라우드:** `php -S localhost:8000 -t cloud/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록.
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(클라우드와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
2. **서버:** `php -S localhost:8000 -t server/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록.
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(서버와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
4. **실행:** `move-detect-edge` (또는 `python -m edge`).
5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush.
6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속.
@ -753,7 +753,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
## Self-Review
**1. 최소경로 커버리지 (기능검토 1~7):**
- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→클라우드 흐름(3) → T4 데몬+기존 PostClient ✓
- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→서버 흐름(3) → T4 데몬+기존 PostClient ✓
- 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓
- config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓
- **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지).
@ -761,7 +761,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
**2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시).
**3. Type consistency:** `CloudClient.sign` = PostClient/클라우드 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관.
**3. Type consistency:** `ServerClient.sign` = PostClient/서버 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관.
---

View file

@ -169,7 +169,11 @@ class HailoPoseAdapter:
for info in out_infos}
bindings = configured.create_bindings(output_buffers=buffers)
bindings.input().set_buffer(np.ascontiguousarray(frame))
configured.run([bindings], timeout_ms=10000)
# Positional, NOT timeout_ms=. Upstream is `def run(self, bindings, timeout)` in
# every HailoRT that supports Hailo-8 (v4.19.0 / v4.20.0 / v4.23.0 / master), so
# the keyword form is a TypeError on EVERY frame — and run_loop swallows it
# (edge/daemon.py:23-24), so it presents as a green unit emitting zero events.
configured.run([bindings], 10000)
return [buffers[info.name][None, ...] if buffers[info.name].ndim == 3
else buffers[info.name] for info in out_infos]

View file

@ -1,3 +1,5 @@
import logging
import cv2
import numpy as np
@ -17,6 +19,13 @@ class MoveNetPoseAdapter:
session = onnxruntime.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
self.session = session
self._input_name = session.get_inputs()[0].name
# MoveNet SinglePose returns ONE person per frame. At a multi-worker site every worker
# but one is invisible to fall/zone/ghost detection with no other symptom — so say it
# loudly at startup. The Hailo backend (yolov8s_pose) is multi-person.
logging.getLogger("edge.pose").warning(
"MoveNet CPU fallback is SINGLE-PERSON: only one worker is tracked per frame; any "
"additional workers are invisible to fall/zone/ghost detection. Use the Hailo "
"backend (POSE_BACKEND=hailo) at multi-person sites.")
def infer(self, frame) -> list:
h, w = frame.shape[:2]

View file

@ -4,10 +4,43 @@ import cv2
class UvcSource:
def __init__(self, dev, mjpeg: bool = True):
self.cap = cv2.VideoCapture(dev)
if mjpeg:
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
# _open lets tests inject a fake capture; production uses cv2.VideoCapture.
self._dev = dev
self._mjpeg = mjpeg
self._reopen_after = reopen_after
self._open = _open or (lambda d: cv2.VideoCapture(d))
self._fail_streak = 0
self.cap = self._open_cap()
def _open_cap(self):
cap = self._open(self._dev)
# Fail LOUD: a device that never opens must not become a silently-dead capture that
# returns (False, None) forever while the daemon looks healthy. build_runtimes catches
# this and logs+skips the camera.
if not cap.isOpened():
raise RuntimeError(f"UVC device {self._dev!r} failed to open")
if self._mjpeg:
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
return cap
def read(self):
return self.cap.read()
ok, frame = self.cap.read()
if ok:
self._fail_streak = 0
return ok, frame
# A USB cam that browns out / is unplugged returns (False, None) indefinitely. After a
# streak, release and reopen so a recovered device resumes on its own rather than the
# camera staying dead until someone restarts the service.
self._fail_streak += 1
if self._fail_streak >= self._reopen_after:
self._fail_streak = 0
try:
self.cap.release()
except Exception:
pass
try:
self.cap = self._open_cap()
except Exception:
pass # still dead — the daemon's error/heartbeat path surfaces it
return False, None

View file

@ -2,9 +2,28 @@ import base64
import hashlib
import hmac
import json
from enum import Enum
from edge.events import Event
# Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent
# with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC
# and URL. Send an explicit UA on every request. ServerClient imports this too.
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):
@ -27,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)
@ -35,12 +54,33 @@ class PostClient:
ts = str(self.now_fn())
headers = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-Nonce": nonce,
"X-Timestamp": ts,
"X-Signature": sign(self.secret, nonce, ts, body.encode()),
}
try:
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout)
# allow_redirects=False: the .htaccess http->https 302 must not silently become a
# GET (405/404). A redirect is a CAFE24_URL misconfiguration to surface, not follow.
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 server 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.
#
# 401 is transient too: the server returns it for a STALE timestamp (abs(now-ts) > window),
# which is exactly what an RTC-less Pi hits after a power cut until NTP steps the clock —
# the event is valid and will be accepted verbatim once time syncs. The only permanent 401
# cause is a wrong/rotated secret, a provisioning error to fix; retrying (events stay queued,
# bounded by max_rows) is far safer than poison-dropping a real fall alert. Poison-drop is
# reserved for a definite CONTENT rejection (400/422), never an auth/clock condition.
if code in (401, 408, 425, 429) or code >= 500:
return SendResult.TRANSIENT
return SendResult.REJECTED

View file

@ -11,18 +11,32 @@ from edge.models import BBox
def _head_boxes(persons: list) -> list:
# 배경인물 머리영역 근사: 코 주변 작은 박스 (MVP; 실제는 얼굴 검출 포트로 교체)
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
# ~40x40 patch — a close worker fills far more than 40px, so a constant box left the face
# fully identifiable. Centered on the nose when it is confident, else on the bbox's top
# (the head is the upper part of the person) so a bad keypoint can't move the blur off it.
boxes = []
for p in persons:
bb = p.bbox
nose = p.keypoints[0]
boxes.append(BBox(nose.x - 20, nose.y - 25, nose.x + 20, nose.y + 15))
half_w = max(8.0, 0.25 * bb.width)
if nose.conf >= 0.3:
cx, cy = nose.x, nose.y
else:
cx, cy = bb.cx, bb.y1 + 0.12 * bb.height
boxes.append(BBox(cx - half_w, cy - 0.18 * bb.height,
cx + half_w, cy + 0.08 * bb.height))
return boxes
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn):
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
ok, frame = source.read()
if not ok:
return []
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
# frame" apart from "frame processed, no events" — a dead/unplugged USB camera returns
# (False, None) forever, and counting it as a healthy frame made a monitoring outage
# look perfectly green (fps~=target, cameras_erroring=0).
return None
now = now_fn()
persons = pose.infer(frame)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
@ -31,7 +45,11 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
f = tr.latest
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame)
events = engine.process(tracks, lost, now)
h, w = frame.shape[:2]
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
wall_now = wall_fn() if wall_fn is not None else None
events = engine.process(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
for tr in lost:
pool.forget(tr.track_id)
if events:

View file

@ -1,3 +1,4 @@
import math
from dataclasses import dataclass
from edge.detectors.fall import FallConfig
@ -24,13 +25,89 @@ def _f(env: dict, key: str, default: float) -> float:
return float(env[key]) if key in env else default
def frame_interval_from_env(env: dict) -> float:
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
systemd restart loop (Restart=always + StartLimitIntervalSec=0)."""
raw = env.get("TARGET_FPS", "12")
try:
fps = float(raw)
except (TypeError, ValueError):
raise ValueError(f"TARGET_FPS={raw!r} is not a number")
if fps <= 0:
raise ValueError(f"TARGET_FPS={raw!r} must be > 0")
return 1.0 / fps
def queue_db_path(env: dict) -> str:
"""Absolute sqlite path for the offline queue. A relative 'queue.db' resolves against the
unit's read-only WorkingDirectory under ProtectSystem=strict and cannot be created ->
crash loop. Default to the systemd StateDirectory, the one writable path."""
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 server 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, 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.
"""
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 server, not a stub")
if problems:
raise ValueError(
"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 server."
)
def load_config(env: dict) -> Config:
camera_id = env.get("CAMERA_ID", "cam-01")
# Per-second stillness threshold (units/second), shared by fall no-motion and immobility.
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
motion_thr = _f(env, "MOTION_THR", 0.25)
if not math.isfinite(motion_thr) or motion_thr <= 0:
raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}")
fall = FallConfig(
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
motion_thr=motion_thr,
)
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0))
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
motion_thr=motion_thr)
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))

View file

@ -1,42 +1,136 @@
import os
# Rate-limit for the swallowed-exception log: a camera failing every frame must be VISIBLE
# (not a silent green unit) but must not spam the journal. Log the 1st of a run of consecutive
# failures, then every LOG_EVERY-th, and again after any recovery.
_LOG_EVERY = 300
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
import sys
import traceback
print(msg, file=sys.stderr)
if exc is not None:
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None):
frame_interval=0.0, zones_for=None, log=None,
heartbeat_interval_s=None, health_fn=None, configured_cameras=None):
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
# cameras share this loop, one shared clock read per tick, and the periodic
# sync/heartbeat/flush. A per-camera step exception is counted, never fatal.
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
# never fatal — but it is no longer SILENT (a permanently broken pipeline used to look
# perfectly green).
runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime]
if zones_for is None:
from edge.zones import zones_from_config
zones_for = lambda cfg, rt: zones_from_config(cfg)
if log is None:
log = _default_log
if health_fn is None:
from edge.health import read_health
health_fn = read_health
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc=None):
counters["errors"] += 1
n = streak.get(key, 0) + 1
streak[key] = n
if n == 1 or n % _LOG_EVERY == 0:
if exc is not None:
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc)
else:
log(f"{label} (consecutive #{n})", None)
beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
last_beat = None # wall time of the last heartbeat, for the time-based cadence
reported_version = None # last config version the server told us; echoed in heartbeats
i = 0
while iterations is None or i < iterations:
i += 1
now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None:
beat_anchor = (now, 0)
if last_beat is None:
last_beat = now
for rt in runtimes:
try:
rt.step(now)
produced = rt.step(now)
except Exception as e:
cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} step failed", e)
continue
if produced is None:
# step() returned no frame (unplugged/browned-out camera). Count it toward the
# erroring streak instead of as a healthy frame — otherwise a dead camera ticks
# frames++ forever and reports fps~=target with cameras_erroring=0 (a silent
# monitoring outage that looks green). A single dropped frame self-clears on the
# next good frame, so only SUSTAINED starvation shows as erroring.
cam = getattr(rt, "camera_id", "?")
note(id(rt), f"camera {cam} produced no frame")
else:
counters["frames"] += 1
except Exception:
counters["errors"] += 1
streak.pop(id(rt), None) # recovered — reset the streak
try:
if sync_every and i % sync_every == 0:
cfg = client.fetch_config()
if cfg is not None:
for rt in runtimes:
rt.apply_zones(zones_for(cfg, rt))
# Remember the config version so heartbeats can echo it back — the server's
# config-drift indicator is meaningless until the device reports what it runs.
if isinstance(cfg, dict) and cfg.get("version") is not None:
reported_version = int(cfg["version"])
counters["syncs"] += 1
if heartbeat_every and i % heartbeat_every == 0:
if client.send_heartbeat({"fps": 0.0, "frames": counters["frames"]}):
# Heartbeat cadence: time-based when heartbeat_interval_s is set (production), so the
# interval is predictable regardless of per-tick cost (CPU MoveNet, multi-cam, thermal
# throttle can push a tick past 100ms) — an iteration count cannot guarantee a wall-clock
# cadence, and the server marks a device offline on ELAPSED time. Falls back to the
# iteration count when unset (used by the deterministic unit tests).
if heartbeat_interval_s:
do_beat = (now - last_beat) >= heartbeat_interval_s
else:
do_beat = bool(heartbeat_every) and (i % heartbeat_every == 0)
if do_beat:
# Measured fps over the window since the last beat, and per-camera health, so
# the server can tell a live device from a degraded/dead one (was hardcoded 0.0
# + a frames counter that ticked even when no frame was read).
t0, f0 = beat_anchor
elapsed = now - t0
fps = (counters["frames"] - f0) / elapsed if elapsed > 0 else 0.0
# cameras_total is the CONFIGURED count, not just the started one: a camera that
# failed to construct at boot (wrong /dev index, unseated ribbon) is skipped by
# build_runtimes and would otherwise vanish from the heartbeat entirely — the
# server could never see that a configured camera is missing. Count the not-started
# cameras as erroring so a 4-cam device that lost one reports 4 total / 1 erroring.
total = configured_cameras if configured_cameras is not None else len(runtimes)
not_started = max(0, total - len(runtimes))
erroring = not_started + sum(1 for rt in runtimes if id(rt) in streak)
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
"cameras_total": total, "cameras_erroring": erroring,
"errors": counters["errors"], "queue_pending": queue.pending()}
# Pi thermal telemetry (None off-Pi or on failure → field omitted, never a fake 0).
health = health_fn()
if health.get("temp") is not None:
metrics["temp"] = health["temp"]
if health.get("throttle") is not None:
metrics["throttle"] = health["throttle"]
if reported_version is not None:
metrics["config_version"] = reported_version
if client.send_heartbeat(metrics):
counters["beats"] += 1
beat_anchor = (now, counters["frames"])
last_beat = now
if flush_every and i % flush_every == 0:
queue.flush()
counters["flushes"] += 1
except Exception:
counters["errors"] += 1
streak.pop("periodic", None)
except Exception as e:
note("periodic", "periodic op (sync/heartbeat/flush) failed", e)
if frame_interval:
sleep(frame_interval)
return counters
@ -87,8 +181,9 @@ def build_runtimes(cameras, make_source, make_runtime, log=None):
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
import time
from dataclasses import replace
from edge.config import load_config
from edge.cloudclient import CloudClient
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
require_upload_config)
from edge.serverclient import ServerClient
from edge.alert import PostClient
from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime
@ -112,24 +207,33 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
return MoveNetPoseAdapter(os.environ["MOVENET_ONNX"])
cfg = load_config(dict(os.environ))
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
nonce = lambda: os.urandom(8).hex()
now = time.time
# Two clocks, deliberately: the loop/detector timers run on a MONOTONIC clock so an NTP step
# (a Pi 5 has no RTC battery; it boots on stale fake-hwclock time until chrony corrects it)
# cannot instantly satisfy immobile_s, expire every track, or reset an immobility countdown.
# Wall time is used only where a real timestamp is required: the HMAC X-Timestamp headers,
# which the server validates against its own wall clock, and Event.occurred_at.
mono = time.monotonic
wall = time.time
base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, wall)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, wall)
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
pose = build_pose() # shared across cameras
def make_runtime(cam, source):
# Per-camera config so events are stamped with that camera's id.
cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam))
return PipelineRuntime(cam_cfg, source, pose, queue)
return PipelineRuntime(cam_cfg, source, pose, queue, wall_clock=wall)
# A missing/unseated camera is skipped (logged), not fatal — the others still run.
runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
build_source, make_runtime)
cameras = parse_cameras(dict(os.environ), cfg.camera_id)
runtimes = build_runtimes(cameras, build_source, make_runtime)
run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")),
run_loop(runtimes, client, queue, clock=mono, sleep=time.sleep, iterations=None,
frame_interval=frame_interval_from_env(dict(os.environ)),
heartbeat_interval_s=30, # predictable cadence; server marks offline after 120s (4x)
configured_cameras=len(cameras), # so a camera that failed to start stays visible
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))

View file

@ -15,7 +15,7 @@ class FallConfig:
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
prone_confirm_s: float = 3.0
no_motion_s: float = 10.0
motion_thr: float = 0.02 # 정규화 관절변위 임계
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
min_conf: float = 0.5
min_visible: int = 10
recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간
@ -40,18 +40,19 @@ class FallDetector:
def forget(self, track_id: int) -> None:
self._states.pop(track_id, None)
def _prone(self, kps, bbox, st: _State) -> tuple[bool, dict]:
angle = torso_angle_deg(kps)
def _prone(self, kps, bbox, st: _State, kp_ok: bool) -> tuple[bool, dict]:
ar = bbox.aspect
remaining = 1.0
if st.baseline_height:
remaining = bbox.height / st.baseline_height
prone = (
angle > self.cfg.fallen_angle
or ar > self.cfg.aspect_ratio
or remaining < self.cfg.height_collapse # 높이 급붕괴(정면/후면 낙상)
or head_below_hips(kps)
)
# bbox cues need NO keypoints — a fallen person is wide-and-short, or their bbox height
# collapsed. These stay live even when the pose model has lost confidence (occluded /
# on the floor), which is exactly when a fall must not be missed.
prone = ar > self.cfg.aspect_ratio or remaining < self.cfg.height_collapse
angle = torso_angle_deg(kps)
# keypoint cues only when the keypoints are actually trustworthy, else they are noise.
if kp_ok:
prone = prone or angle > self.cfg.fallen_angle or head_below_hips(kps)
return prone, {"torso_angle": round(angle, 1),
"aspect_ratio": round(ar, 2),
"height_remaining": round(remaining, 2)}
@ -61,20 +62,34 @@ class FallDetector:
f = track.latest
kps, bbox = f.keypoints, f.bbox
# 신뢰도 게이트
if not core_visible(kps, self.cfg.min_conf) or visible_count(kps) < self.cfg.min_visible:
return None, {}
# Confidence gate — no longer a blanket early-return (that disabled fall/no_motion/
# ghost for the occluded person on the ground). It now only decides whether the
# KEYPOINT-based cues are trustworthy; the bbox cues always run.
kp_ok = core_visible(kps, self.cfg.min_conf) and visible_count(kps) >= self.cfg.min_visible
prone, meta = self._prone(kps, bbox, st)
prone, meta = self._prone(kps, bbox, st, kp_ok)
# incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze
if st.name == "STANDING" and not prone:
# incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze. Only trust a
# confident standing frame for the baseline so a bad low-conf bbox can't corrupt it.
if st.name == "STANDING" and not prone and kp_ok:
st.baseline_height = bbox.height
if not prone:
if st.name in ("FALLING", "FALLEN", "NO_MOTION"):
# Only a CONFIDENT non-prone frame may recover an active incident. A low-conf
# (kp_ok False) frame's bbox-only "not prone" reading is untrusted: occlusion of
# a still-fallen worker must FREEZE the incident, not run the recovery countdown
# on noise. Freezing also protects baseline_height below (only seeded here, when
# kp_ok holds, so a bad low-conf bbox can't corrupt the next fall's collapse cue).
if not kp_ok:
# Occlusion PAUSES recovery — it must not count toward the grace window. Reset
# so recovery requires a FRESH, continuous run of confident non-prone frames;
# otherwise a confident frame at t0 plus a lone confident frame after a long
# low-conf gap would recover instantly on wall-clock elapsed alone.
st.not_prone_since = None
return None, {}
# 단일 튐(noisy) 프레임만으로 CRITICAL 사고를 즉시 취소하지 않도록
# recover_grace_s만큼 지속된 비-prone을 요구한다.
# recover_grace_s만큼 지속된 (신뢰) 비-prone을 요구한다.
if st.not_prone_since is None:
st.not_prone_since = now
return None, {}

View file

@ -26,13 +26,20 @@ class GhostIncidentDetector:
state = self._states.get(tr.track_id, "STANDING")
if state in _DANGEROUS:
b = tr.latest.bbox
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now)
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT
# exist then can be the victim re-appearing (a re-acquired track gets a new
# id); a track already present is a bystander/colleague and must not cancel
# the incident by walking over the loss point.
bystanders = set(self._states) - {tr.track_id}
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, bystanders)
self._states.pop(tr.track_id, None)
def update(self, active_tracks: list[Track], now: float) -> list[dict]:
# 인근 재출현이면 후보 취소
for tid, (spatial, _state, height, _lost_at) in list(self._candidates.items()):
# 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현).
for tid, (spatial, _state, height, _lost_at, bystanders) in list(self._candidates.items()):
for tr in active_tracks:
if tr.track_id in bystanders:
continue # a colleague who was already here — not the reappearing victim
b = tr.latest.bbox
dist = ((b.cx - spatial[0]) ** 2 + (b.cy - spatial[1]) ** 2) ** 0.5
if dist <= self.cfg.reappear_dist * (height or 1.0):
@ -40,7 +47,7 @@ class GhostIncidentDetector:
break
# 대기 초과 방출
out = []
for tid, (spatial, state, _height, lost_at) in list(self._candidates.items()):
for tid, (spatial, state, _height, lost_at, _by) in list(self._candidates.items()):
if now - lost_at >= self.cfg.reappear_s:
out.append({"track_id": tid, "spatial": spatial, "last_state": state,
"waited_s": round(now - lost_at, 1)})

View file

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

View file

@ -3,6 +3,9 @@ from dataclasses import dataclass
from edge.tracking import Track
from edge.models import L_ANKLE, R_ANKLE
# Below this an ankle keypoint is treated as unusable (typically hallucinated at conf~0).
MIN_ANKLE_CONF = 0.2
def point_in_polygon(x: float, y: float, poly: list[tuple[float, float]]) -> bool:
inside = False
@ -40,16 +43,36 @@ class ZoneIntrusionDetector:
self._inside_since = {k: v for k, v in self._inside_since.items() if k[0] != track_id}
self._fired = {k for k in self._fired if k[0] != track_id}
def _foot(self, track: Track) -> tuple[float, float]:
kps = track.latest.keypoints
a, b = kps[L_ANKLE], kps[R_ANKLE]
return ((a.x + b.x) / 2, (a.y + b.y) / 2)
def _foot(self, track: Track, frame_wh=None) -> tuple[float, float]:
p = track.latest
a, b = p.keypoints[L_ANKLE], p.keypoints[R_ANKLE]
# Average only the ankles the model is confident about. A hallucinated ankle (conf~0,
# often pinned to (0,0) or a frame corner) would otherwise drag the foot point into or
# out of a zone. With neither ankle trustworthy, fall back to the bbox bottom-centre — a
# robust standing-foot proxy (same bbox-fallback discipline as fall.py's cues).
good = [k for k in (a, b) if k.conf >= MIN_ANKLE_CONF]
if good:
fx = sum(k.x for k in good) / len(good)
fy = sum(k.y for k in good) / len(good)
else:
fx, fy = p.bbox.cx, p.bbox.y2
# Zones are stored NORMALIZED (0..1 of the image) by the dashboard, which stretches the
# camera snapshot to a fixed canvas. The foot is camera-native pixels, so normalize it
# by the frame size to compare in the same space — otherwise every zone is misplaced
# (a zone drawn at image-centre would be checked against a raw pixel like 480, matching
# nothing on a 1280-wide frame). frame_wh=None keeps raw coords for unit tests that
# supply their own matching space.
if frame_wh:
w, h = frame_wh
if w > 0 and h > 0:
fx, fy = fx / w, fy / h
return (fx, fy)
def active_zone_ids(self, track_id: int) -> set:
return {z for (tid, z) in self._inside_since if tid == track_id}
def update(self, track: Track, now: float) -> list[dict]:
fx, fy = self._foot(track)
def update(self, track: Track, now: float, frame_wh=None) -> list[dict]:
fx, fy = self._foot(track, frame_wh)
out = []
for zone in self.zones:
key = (track.track_id, zone.zone_id)

View file

@ -40,47 +40,62 @@ class EventEngine:
self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)}
def _mk(self, etype: EventType, now: float, track_id=None, zone_id=None,
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
metrics=None, spatial=None) -> Event:
m = metrics or {}
duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
zone_id, now, duration_s, m, spatial)
zone_id, occurred_at, duration_s, m, spatial)
def process(self, active_tracks: list[Track], lost_tracks: list[Track],
now: float) -> list[Event]:
now: float, frame_wh=None, wall_now: float | None = None) -> list[Event]:
# `now` is a MONOTONIC clock (never jumps): it drives every detector timer, hysteresis,
# and cooldown, so an NTP step cannot instantly satisfy immobile_s / expire all tracks /
# reset a genuine immobility countdown. `wall_now` is real wall-clock time, used ONLY to
# stamp Event.occurred_at (the server correlates and retains by wall time). Defaults to
# `now` so single-clock callers (unit tests) keep their existing occurred_at behavior.
occurred_at = wall_now if wall_now is not None else now
events: list[Event] = []
self.ghost.on_lost(lost_tracks, now)
for tr in active_tracks:
zone_hits = self.zone.update(tr, now)
# frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh)
in_zone = bool(self.zone.active_zone_ids(tr.track_id))
sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
events.append(self._mk(EventType.FALL, now, tr.track_id, metrics=meta))
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, metrics=meta))
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
events.append(self._mk(EventType.NO_MOTION, now, tr.track_id, metrics=meta))
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta))
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
events.append(self._mk(EventType.IMMOBILITY, now, tr.track_id, metrics=imeta))
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, metrics=imeta))
for hit in zone_hits:
etype = (EventType.ZONE_RESTRICTED if hit["level"] == "restricted"
else EventType.ZONE_WARNING)
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
# PINCH/HIGH — the old `restricted else WARNING` silently downgraded it. The
# dashboard offers exactly these three levels (see zones.php).
etype = {"pinch": EventType.PINCH,
"restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"]))
events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
for g in self.ghost.update(active_tracks, now):
key = (EventType.GHOST, g["track_id"])
if self._allow(key, now):
events.append(self._mk(EventType.GHOST, now, track_id=g["track_id"],
spatial=g["spatial"],
metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]}))
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it
# emits, and track ids are never reused, so a given track fires GHOST at most once ever.
# Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for
# a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that
# forget() can never reclaim (the track never appears in lost_tracks again).
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
spatial=g["spatial"],
metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]}))
# ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe.
for tr in lost_tracks:

View file

@ -1,4 +1,5 @@
from dataclasses import dataclass
import uuid as _uuid
from dataclasses import dataclass, field
from enum import Enum
@ -49,10 +50,15 @@ class Event:
duration_s: float
metrics: dict
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
# offline queue and every retry, so a POST that succeeded server-side but timed out
# client-side is deduplicated by the server (see server IngestController) instead of stored twice.
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
def to_payload(self, device_id: str) -> dict:
return {
"device_id": device_id,
"event_uuid": self.event_uuid,
"camera_id": self.camera_id,
"event_type": self.event_type.value,
"severity": self.severity.value,

View file

@ -34,9 +34,15 @@ def torso_angle_deg(keypoints: list[Keypoint]) -> float:
return math.degrees(math.atan2(abs(vx), abs(vy))) if (vx or vy) else 0.0
def head_below_hips(keypoints: list[Keypoint]) -> bool:
def head_below_hips(keypoints: list[Keypoint], nose_thr: float = 0.2) -> bool:
# Only trust the nose if the model is confident about it. An unconfident nose (commonly
# hallucinated at (0,0) / a frame corner) would otherwise assert — or silently suppress —
# this fall cue on data we don't trust. Same 'visible' gate as visible_count().
nose = keypoints[NOSE]
if nose.conf < nose_thr:
return False
_, hy = _hip_center(keypoints)
return keypoints[NOSE].y > hy
return nose.y > hy
def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int:
@ -55,12 +61,37 @@ 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 displacement between the last two frames, normalized by bbox height. 1.0 if <2 frames."""
"""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
frame rate, so the same physical stillness reads as 'moving' at low fps and 'still' at
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.
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]
dt = b.t - a.t
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)
return normalize(d, h)
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

47
edge/health.py Normal file
View file

@ -0,0 +1,47 @@
"""Pi thermal telemetry for the heartbeat.
Reports CPU temperature (C) and whether the SoC is currently throttling — the #1 cause of
Hailo inference falling off a cliff in a hot summer factory. Off-Pi (no `vcgencmd`) or on any
failure every field is None so the caller OMITS it: the dashboard must never show a fabricated
temp=0 / throttle=false that hides an overheating device.
"""
import shutil
import subprocess
def parse_temp(out: str):
"""`vcgencmd measure_temp` -> "temp=54.3'C" -> 54.3. None if unparseable."""
try:
return float(out.split("=", 1)[1].split("'", 1)[0])
except (IndexError, ValueError):
return None
def parse_throttle(out: str):
"""`vcgencmd get_throttled` -> "throttled=0x50005". True when any CURRENTLY-active bit is
set: bit0 under-voltage now, bit1 ARM freq capped now, bit2 currently throttled, bit3 soft
temp limit now. The 0x1_0000+ bits are "happened since boot" latches, deliberately ignored
so a past blip does not show as a permanent warning. None if unparseable."""
try:
val = int(out.strip().split("=", 1)[1], 16)
return bool(val & 0xF)
except (IndexError, ValueError):
return None
def read_health(run=None, which=None) -> dict:
"""{'temp': float|None, 'throttle': bool|None}. `run`/`which` are injectable for tests;
default to subprocess/shutil. Returns Nones immediately when vcgencmd is absent (off-Pi)."""
which = which or shutil.which
run = run or _run
if which("vcgencmd") is None:
return {"temp": None, "throttle": None}
return {"temp": parse_temp(run(["vcgencmd", "measure_temp"])),
"throttle": parse_throttle(run(["vcgencmd", "get_throttled"]))}
def _run(cmd: list) -> str: # pragma: no cover (thin subprocess wrapper, exercised on device)
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout
except Exception:
return ""

133
edge/probe.py Normal file
View file

@ -0,0 +1,133 @@
"""Camera probe: open every configured camera, grab a frame, report what came back.
Run by deploy/setup_online.sh BEFORE the service is enabled, so a miswired ribbon or a
USB camera that enumerated on a different /dev/video index fails during the install --
while an operator is still standing at the Pi -- instead of becoming a Restart=always
unit that monitors one camera less and looks perfectly healthy on the dashboard.
Standalone too:
/opt/move-detect/venv/bin/python -m edge.probe # uses CAMERAS from env
CAMERAS=uvc:2:cam-02 python -m edge.probe --save /tmp/probe # write preview PNGs
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from dataclasses import dataclass
from edge.daemon import parse_cameras
# A camera can open and still hand back nothing for the first few reads while the sensor
# settles (UVC auto-exposure, CSI pipeline start). Retry rather than fail a healthy camera.
_READ_ATTEMPTS = 8
_READ_DELAY_S = 0.25
@dataclass(frozen=True)
class ProbeResult:
camera_id: str
backend: str
device: str
ok: bool
detail: str
def _describe(frame) -> str:
shape = getattr(frame, "shape", None)
if not shape or len(shape) < 2:
return "frame of unknown shape"
h, w = shape[0], shape[1]
ch = shape[2] if len(shape) > 2 else 1
return f"{w}x{h}, {ch} channel(s)"
def _default_build_source(backend: str, device: str):
"""Same construction the daemon uses (edge/daemon.py build_source)."""
if backend == "csi":
from edge.adapters.picamera_source import PiCameraSource
return PiCameraSource(int(device))
from edge.adapters.uvc_source import UvcSource
return UvcSource(int(device))
def probe_one(backend: str, device: str, camera_id: str, *, build_source=None,
save_dir: str | None = None) -> ProbeResult:
"""Open one camera, read a frame, optionally save a preview PNG."""
build_source = build_source or _default_build_source
try:
source = build_source(backend, device)
except Exception as exc: # device missing, wrong index, picamera2 not installed
return ProbeResult(camera_id, backend, device, False, f"open failed: {exc}")
try:
for attempt in range(_READ_ATTEMPTS):
ok, frame = source.read()
if ok and frame is not None:
detail = _describe(frame)
if save_dir:
detail += f"; {_save_preview(frame, save_dir, camera_id)}"
return ProbeResult(camera_id, backend, device, True, detail)
if attempt < _READ_ATTEMPTS - 1:
time.sleep(_READ_DELAY_S)
return ProbeResult(camera_id, backend, device, False,
f"opened but returned no frame in {_READ_ATTEMPTS} reads")
finally:
close = getattr(source, "close", None) or getattr(source, "release", None)
if callable(close):
try:
close()
except Exception:
pass # a probe must never fail the install on a teardown hiccup
def _save_preview(frame, save_dir: str, camera_id: str) -> str:
try:
import cv2
os.makedirs(save_dir, exist_ok=True)
path = os.path.join(save_dir, f"{camera_id}.png")
cv2.imwrite(path, frame)
return f"preview {path}"
except Exception as exc:
return f"preview failed: {exc}"
def probe_all(env=None, *, build_source=None, save_dir: str | None = None) -> list[ProbeResult]:
env = dict(os.environ if env is None else env)
cams = parse_cameras(env, env.get("DEVICE_ID", "cam-01"))
return [probe_one(be, dev, cid, build_source=build_source, save_dir=save_dir)
for be, dev, cid in cams]
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="probe every camera in CAMERAS")
ap.add_argument("--save", metavar="DIR", help="write a preview PNG per camera")
args = ap.parse_args(argv)
try:
results = probe_all(save_dir=args.save)
except ValueError as exc: # malformed CAMERAS -- a config error, not a camera fault
print(f"CAMERAS is invalid: {exc}", file=sys.stderr)
return 2
for r in results:
mark = "OK " if r.ok else "FAIL"
print(f" [{mark}] {r.camera_id} {r.backend}:{r.device} -- {r.detail}")
failed = [r for r in results if not r.ok]
if failed:
print(f"{len(failed)}/{len(results)} camera(s) failed.", file=sys.stderr)
if any(r.backend == "uvc" for r in failed):
print(" USB cameras enumerate in boot order -- check `v4l2-ctl --list-devices` "
"and fix the index in CAMERAS.", file=sys.stderr)
if any(r.backend == "csi" for r in failed):
print(" CSI: check the ribbon seating/orientation and `rpicam-hello --list-cameras`.",
file=sys.stderr)
return 1
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

View file

@ -2,24 +2,39 @@ import base64
import json
import sqlite3
from edge.alert import SendResult
from edge.events import Event, EventType, Severity
class OfflineQueue:
def __init__(self, db_path: str, client):
def __init__(self, db_path: str, client, max_rows: int = 2000,
max_send_attempts: int = 20):
self.client = client
self.max_rows = max_rows # SD-card guard: evict oldest beyond this
self.max_send_attempts = max_send_attempts # drop a poison event after this many tries
self.db = sqlite3.connect(db_path)
self.db.execute(
"CREATE TABLE IF NOT EXISTS queue ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT)"
"id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT, "
"attempts INTEGER DEFAULT 0)"
)
# Migrate an older DB that predates the attempts column.
try:
self.db.execute("ALTER TABLE queue ADD COLUMN attempts INTEGER DEFAULT 0")
except sqlite3.OperationalError:
pass # column already exists
self.db.commit()
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
# Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the
# server dedups it. Rows queued before this field existed simply get a fresh uuid (they
# predate dedup, so a first-and-only delivery is correct for them).
uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
payload["camera_id"], payload["track_id"], payload["zone_id"],
payload["occurred_at"], payload["duration_s"], payload["metrics"],
tuple(payload["spatial"]) if payload.get("spatial") else None)
tuple(payload["spatial"]) if payload.get("spatial") else None,
**uuid_kw)
return e, snaps
def enqueue(self, event: Event, snapshots: list[bytes]) -> None:
@ -30,6 +45,12 @@ class OfflineQueue:
snaps = [base64.b64encode(s).decode() for s in snapshots]
self.db.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)",
(json.dumps(payload), json.dumps(snaps)))
# Bound the queue so a multi-day outage — or a permanent misconfig that queues every
# event forever — cannot fill the SD card and hard-crash the Pi. Keep the newest
# max_rows; a fresh fall alert matters more than a stale one.
self.db.execute(
"DELETE FROM queue WHERE id NOT IN "
"(SELECT id FROM queue ORDER BY id DESC LIMIT ?)", (self.max_rows,))
self.db.commit()
def try_send(self, event: Event, snapshots: list[bytes]) -> bool:
@ -39,18 +60,53 @@ class OfflineQueue:
return False
def flush(self) -> int:
rows = self.db.execute("SELECT id, payload, snaps FROM queue ORDER BY id").fetchall()
rows = self.db.execute(
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
sent = 0
for rid, payload_json, snaps_json in rows:
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):
for rid, payload_json, snaps_json, attempts in rows:
try:
payload = json.loads(payload_json)
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
event, snapshots = self._rehydrate(payload, snaps)
except Exception:
# An undecodable row — a torn SD-card write, or schema drift from an older edge
# build whose payload lacks a key _rehydrate indexes — can NEVER be delivered.
# flush() breaks on the first undelivered row to preserve order, so leaving this
# row in place would wedge every queued safety event BEHIND it forever. Drop the
# single unrecoverable row and continue; losing it beats freezing the whole queue.
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
self.db.commit()
continue
# 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
# server (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
else:
break # 여전히 오프라인 — 순서 보존
continue
# 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()
break # preserve order; retry the whole queue next flush
return sent
def pending(self) -> int:

View file

@ -10,12 +10,15 @@ from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
class PipelineRuntime:
def __init__(self, config, source, pose, sink):
def __init__(self, config, source, pose, sink, wall_clock=None):
self.config = config
self.camera_id = config.camera_id # for per-camera zone routing (multi-cam)
self.source = source
self.pose = pose
self.sink = sink
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
self._wall = wall_clock
self.tracker = IouTracker()
self.pool = TrackSmootherPool()
self.zone_detector = ZoneIntrusionDetector([], config.zone)
@ -32,7 +35,7 @@ class PipelineRuntime:
def step(self, now: float) -> list:
return run_once(self.source, self.pose, self.tracker, self.pool,
self.engine, self.ring, self.snapshotter, self.sink,
now_fn=lambda: now)
now_fn=lambda: now, wall_fn=self._wall)
def apply_zones(self, zones: list) -> None:
self.zone_detector.zones = list(zones)

View file

@ -1,9 +1,9 @@
import json
from edge.alert import sign
from edge.alert import USER_AGENT, sign
class CloudClient:
class ServerClient:
def __init__(self, base_url: str, device_id: str, secret: str,
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
self.base_url = base_url.rstrip("/")
@ -22,13 +22,15 @@ class CloudClient:
def _headers(self, body: str) -> dict:
nonce = self.nonce_fn()
ts = str(self.now_fn())
return {"X-Nonce": nonce, "X-Timestamp": ts,
# User-Agent: cafe24's WAF 403s the default python-requests UA (see edge/alert.py).
return {"User-Agent": USER_AGENT, "X-Nonce": nonce, "X-Timestamp": ts,
"X-Signature": sign(self.secret, nonce, ts, body)}
def fetch_config(self) -> dict | None:
url = f"{self.base_url}/api/config?device_id={self.device_id}"
try:
resp = self._get(url, headers=self._headers(""), timeout=self.timeout)
resp = self._get(url, headers=self._headers(""), timeout=self.timeout,
allow_redirects=False)
except Exception:
return None
if 200 <= resp.status_code < 300:
@ -44,7 +46,7 @@ class CloudClient:
headers = {"Content-Type": "application/json", **self._headers(body)}
try:
resp = self._post(f"{self.base_url}/api/heartbeat", data=body,
headers=headers, timeout=self.timeout)
headers=headers, timeout=self.timeout, allow_redirects=False)
except Exception:
return False
return 200 <= resp.status_code < 300

View file

@ -0,0 +1,261 @@
"""빌드/온라인 머신 전용: Hailo yolov8s_pose HEF 다운로드(폐쇄망 번들용). MIT (Hailo Model Zoo).
Mirrors edge/tools/fetch_movenet.py: pure stdlib, no deps, idempotent (re-running with the
file already in place verifies and returns without re-downloading).
AIR GAP (rules/common/build-deployment.md): this runs on the BUILD machine or on an
INTERNET-CONNECTED Pi (deploy/setup_online.sh [6/8]) — never on the air-gapped target. The
offline bundle carries the resulting .hef across the gap (.gitignore ignores *.hef, so it is
a build artifact, never a committed one).
REFUSES TO GUESS THE BOARD. A Hailo-8 HEF does not load on a Hailo-8L and vice versa:
[HailoRT] [error] HEF format is not compatible with device. Device arch: HAILO8L, HEF arch: HAILO8
[HailoRT] [error] CHECK_SUCCESS failed with status=HAILO_INVALID_HEF(26)
so the arch is read from the board (`hailortcli fw-control identify`) or must be passed
explicitly with --arch. With neither, this exits non-zero rather than install a coin-flip.
WHY v2.14.0: it is what the official hailo-rpi5-examples download_resources.sh pins, and its
HEF header is version 1 — parsed by every HailoRT from 4.18 through 4.23, so one file works on
both Bookworm (hailo-all 4.20) and Trixie (hailo-all 5.1.1 -> hailort 4.23). Newer model-zoo
releases (v2.15.0+) emit HEF header version 2, which HailoRT 4.18/4.19 reject outright.
CONTRACT (verified 2026-07-16 by decoding the HEF protobuf itself, BOTH arches — matches
edge/adapters/hailo_pose.py and tests/test_hailo_pose.py exactly):
input_layer1 640x640x3 uint8 RGB; a `normalization1` layer does 0-255 scaling ON CHIP
output_layer1..9 3 strides x {box DFL (H,W,64), score (H,W,1, sigmoid on chip),
keypoints (H,W,51)} @ 80x80, 40x40, 20x20
NO on-chip NMS (zero nms/hailo_nms markers anywhere in either file) -> the host-side DFL
decode + NMS in hailo_pose.py is the correct and necessary post-processing.
"""
import argparse
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
MZ_VERSION = "v2.14.0"
BASE = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled"
MODEL = "yolov8s_pose"
# Hailo publishes NO checksum for these objects: .sha256 / .md5 / SHA256SUMS sidecars all
# return HTTP 403 and bucket listing is denied, so there is no upstream manifest to verify
# against. The digests below are therefore NOT vendor-published — they were computed from the
# bytes these URLs served on 2026-07-16 and are pinned so that a later download which differs
# (mirror swap, truncation, tampering) fails loudly instead of installing wrong weights.
# To re-pin deliberately: curl the URL, sha256sum it, update BOTH sha256 and size here.
#
# The S3 ETag is a usable cross-check for hailo8 ONLY (single-part upload -> ETag == MD5
# 9c0542f0009ce952e16f9d32febebe01). hailo8l is a MULTIPART upload (ETag ends "-2"), so its
# ETag is a digest-of-digests, not an MD5 — do not "fix" it to compare equal.
HEFS = {
"hailo8": { # 26 TOPS "AI HAT+"
"sha256": "4f06be93349a21fcc28ee0a746ca84f1c0e17aee99bfd1f59596a477d5160f99",
"size": 10294713,
},
"hailo8l": { # 13 TOPS "AI Kit"
"sha256": "7293a6b88a2a7bf9ea07819e92057d67e947300f8d0e87f49d50bb060fb959b1",
"size": 22658846,
},
}
DEFAULT_DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", f"{MODEL}.hef")
def url_for(arch):
return f"{BASE}/{MZ_VERSION}/{arch}/{MODEL}.hef"
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _arch_from_enum_name(name):
"""Map a hailo_platform DeviceArchitectureTypes member NAME to our HEF key.
-> 'hailo8' | 'hailo8l' | None. Same first-match 8L-before-8 discipline as the hailortcli
regex ('HAILO8L' contains 'HAILO8'), so it also folds silicon revisions ('HAILO8_A0' /
'HAILO8_B0') onto 'hailo8', and returns None for any non-8 family (Hailo-10/15) we have no
HEF for. Never guesses."""
n = (name or "").upper()
if "HAILO8L" in n:
return "hailo8l"
if "HAILO8" in n:
return "hailo8"
return None
def _arch_via_hailortcli(run):
"""First signal: parse `hailortcli fw-control identify`. -> 'hailo8'|'hailo8l'|None."""
try:
p = run(["hailortcli", "fw-control", "identify"],
capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return None
if p.returncode != 0:
return None
# HAILO8L must precede HAILO8 in the alternation: regex alternation is first-match, so
# (HAILO8|HAILO8L) would match the "HAILO8" prefix of "HAILO8L" and mis-detect an AI Kit
# as an AI HAT+ — the exact HAILO_INVALID_HEF(26) this function exists to prevent.
m = re.search(r"Device Architecture\s*:\s*(HAILO8L|HAILO8)\b", p.stdout or "", re.I)
return m.group(1).lower() if m else None
def _arch_via_python_api(): # pragma: no cover (device-only: needs hailo_platform + a board)
"""Second signal: read the arch through the hailo_platform bindings instead of parsing
hailortcli text. Runs the SAME firmware identify underneath, so it does NOT rescue a board
whose firmware will not answer — but it (a) works when hailortcli is absent from PATH
(hailo-all ships the Python bindings independently) and (b) returns a typed enum, so it is
not defeated by the \\b regex on develop-firmware strings like 'HAILO8_B0'. Never raises:
any missing symbol/attr or absent board -> None.
UNVERIFIED: the API names (Device.scan / control.identify().device_architecture) are from
Hailo's pyhailort and are not runnable in this repo. The broad except makes a wrong name
safe — it yields None (refuse-to-guess), never a crash or a guess."""
try:
from hailo_platform import Device
ids = Device.scan()
if not ids:
return None
with Device(ids[0]) as dev:
arch = dev.control.identify().device_architecture
return _arch_from_enum_name(getattr(arch, "name", str(arch)))
except Exception:
return None
def detect_arch(run=subprocess.run, python_api=_arch_via_python_api):
"""Read the arch off the board itself. -> 'hailo8' | 'hailo8l' | None.
Two independent signals, in order: (1) `hailortcli fw-control identify` text, (2) the
hailo_platform bindings. Both read the board's ARCHITECTURE, which is reported even when
the serial/part/product identity fields are unprogrammed — a Hailo-8 commonly shows
'Device Architecture: HAILO8' with 'Serial Number: <N/A>'. So a serial-less board is NOT
why this returns None. None still means "no board answered": no hailortcli AND no
bindings, no /dev/hailo0 (driver installed, not rebooted), or an arch we have no HEF for.
None is never papered over with a default — an operator who knows the board bypasses
detection entirely with --arch / HAILO_ARCH."""
arch = _arch_via_hailortcli(run)
if arch is not None:
return arch
# hailortcli missing/non-zero, or its Device Architecture line absent/unparseable. The
# in-process bindings survive a missing CLI and the \b regex fragility; still None if no
# board answers.
return python_api()
def fetch(arch, dest, force=False, log=print):
"""Download (or verify in place) the yolov8s_pose HEF for `arch`. -> dest path."""
spec = HEFS[arch]
url = url_for(arch)
if os.path.exists(dest) and not force:
if sha256_of(dest) == spec["sha256"]:
log(f"{dest} already present and matches the pinned {arch} digest; nothing to do")
return dest
# Covers the nastiest case: a previous run left the OTHER arch's HEF (or a truncated
# one) here. Silently keeping it is how a board ends up loading a HEF it will reject.
# ASCII only in printed strings: a Windows build machine's console is often cp949/cp1252,
# where an em-dash raises UnicodeEncodeError. That would crash the tamper-detection path
# itself, replacing "digest mismatch" with a traceback about a dash.
log(f"WARNING: {dest} exists but does not match the pinned {arch} digest - replacing it")
dest_dir = os.path.dirname(dest) or "."
os.makedirs(dest_dir, exist_ok=True)
log(f"downloading {MODEL} HEF for {arch} ({spec['size']} bytes, model-zoo {MZ_VERSION})")
log(f" {url}")
log(f" -> {dest}")
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=f".{MODEL}.", suffix=".part")
os.close(fd)
# Staged through a temp file in the SAME directory, then os.replace()'d: an
# interrupted download must never leave a half-written .hef at `dest`, because the
# installer's next step only tests -f and would happily install the truncation.
with urllib.request.urlopen(url, timeout=60) as r, open(tmp, "wb") as f:
shutil.copyfileobj(r, f)
size = os.path.getsize(tmp)
digest = sha256_of(tmp)
if size != spec["size"] or digest != spec["sha256"]:
raise SystemExit(
f"ERROR: downloaded {MODEL}.hef for {arch} does not match the pinned digest.\n"
f" url : {url}\n"
f" expected: {spec['size']} bytes, sha256 {spec['sha256']}\n"
f" got : {size} bytes, sha256 {digest}\n"
f" Refusing to install it. If upstream legitimately re-published this model,\n"
f" re-pin HEFS['{arch}'] in {os.path.basename(__file__)} deliberately."
)
os.replace(tmp, dest)
tmp = None
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
log(f"done: {dest} (sha256 verified)")
return dest
def main(argv=None):
ap = argparse.ArgumentParser(
description="Download the Hailo yolov8s_pose HEF for THIS board "
"(build/online machine only - never the air-gapped target).")
ap.add_argument("--arch", choices=sorted(HEFS),
help="target arch. Omit to read it from the board via `hailortcli "
"fw-control identify`, or set the HAILO_ARCH env var. Pass "
"explicitly to cross-fetch on a build machine with no board.")
ap.add_argument("--dest", default=DEFAULT_DEST, help=f"output path (default: {DEFAULT_DEST})")
ap.add_argument("--force", action="store_true", help="re-download even if the digest matches")
args = ap.parse_args(argv)
# Operator override for a board auto-detect cannot read (e.g. a Hailo-8 whose firmware
# reports no identity fields, or a Pi where hailortcli is not yet on PATH). Precedence:
# --arch flag > HAILO_ARCH env > board auto-detect. argparse already validated the flag
# against HEFS; validate the env value against the same allow-list — an unknown value is a
# typo, not a request to fall back.
env_arch = os.environ.get("HAILO_ARCH", "").strip().lower() or None
if env_arch is not None and env_arch not in HEFS:
sys.exit(
f"ERROR: HAILO_ARCH={os.environ['HAILO_ARCH']!r} is not a supported arch.\n"
f" Use exactly one of: {', '.join(sorted(HEFS))} (lowercase)."
)
detected = detect_arch()
override = args.arch or env_arch
arch = override or detected
if arch is None:
sys.exit(
"ERROR: cannot determine the Hailo arch, and refusing to guess.\n"
" A Hailo-8 HEF does not load on a Hailo-8L (HAILO_INVALID_HEF(26)), so there is\n"
" no safe default here.\n"
"\n"
" On the Pi this usually means the board is not answering yet:\n"
" hailortcli fw-control identify # expect 'Device Architecture: HAILO8'\n"
" dpkg -s hailo-all >/dev/null 2>&1 || sudo apt-get install -y hailo-all\n"
" ls -l /dev/hailo0 # absent -> the driver needs a REBOOT\n"
"\n"
" If the board answers but prints no 'Device Architecture' line (some Hailo-8\n"
" boards report no serial/identity), name the arch yourself:\n"
" HAILO_ARCH=hailo8 python -m edge.tools.fetch_hailo_hef # 26 TOPS AI HAT+\n"
" HAILO_ARCH=hailo8l python -m edge.tools.fetch_hailo_hef # 13 TOPS AI Kit"
)
if override and detected and override != detected:
# Legitimate when cross-fetching for a fleet; disastrous when it is a typo. Say it
# loudly, but let the explicit override win — the operator stated an intent.
src = "--arch" if args.arch else "HAILO_ARCH"
print(f"WARNING: {src} {override} overrides the detected board ({detected}). "
f"This HEF will NOT load on this machine's board.", file=sys.stderr)
fetch(arch, os.path.abspath(args.dest), force=args.force)
if __name__ == "__main__":
main()

View file

@ -1,16 +1,87 @@
"""빌드머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0."""
"""빌드/온라인 머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0.
Integrity is enforced (spec: SHA256/GPG verify of every vendored model). The download is staged
to a temp file, validated, and only then moved into place — a truncated / HTML-error-page /
tampered download must never be saved as the model (onnxruntime would then fail to load it, and
the operator would see a cryptic runtime error instead of a clear fetch failure).
STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on
external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is
no known-good sha256 to pin here yet. Until a working source is set:
* this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets),
instead of silently saving garbage;
* set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes.
For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is
non-blocking; the CPU fallback needs this resolved.
"""
import hashlib
import os
import sys
import tempfile
import urllib.request
# MoveNet Lightning ONNX (예시 소스; 조직 미러로 교체 가능)
URL = "https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/movenet_singlepose_lightning_4.onnx"
URL = os.environ.get(
"MOVENET_ONNX_URL",
"https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/"
"movenet_singlepose_lightning_4.onnx",
)
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned
# (still validated as a real ONNX below, but not byte-identical-verified).
PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "")
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0")
def _looks_like_onnx(head: bytes) -> bool:
# ONNX is a protobuf ModelProto; it is NOT text. Reject an HTML error page / LFS pointer
# outright (the current dead URL returns exactly that).
stripped = head.lstrip()
if stripped[:1] in (b"<", b"{") or stripped[:5].lower() == b"<!doc":
return False
if b"version https://git-lfs" in head:
return False
return True
def main():
os.makedirs(os.path.dirname(DEST), exist_ok=True)
print(f"downloading MoveNet ONNX -> {DEST}")
urllib.request.urlretrieve(URL, DEST)
print("done")
dest = os.path.abspath(DEST)
os.makedirs(os.path.dirname(dest), exist_ok=True)
print(f"downloading MoveNet ONNX\n {URL}\n -> {dest}")
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest), prefix=".movenet.", suffix=".part")
os.close(fd)
urllib.request.urlretrieve(URL, tmp)
with open(tmp, "rb") as f:
head = f.read(64)
if not _looks_like_onnx(head):
raise SystemExit(
f"ERROR: {URL}\n did not return an ONNX model (looks like HTML / an error page /"
f" an LFS pointer).\n Set MOVENET_ONNX_URL to a valid source and pin"
f" MOVENET_ONNX_SHA256. (Hailo path does not need this model.)"
)
size = os.path.getsize(tmp)
digest = hashlib.sha256(open(tmp, "rb").read()).hexdigest()
if PINNED_SHA256 and (digest != PINNED_SHA256 or (PINNED_SIZE and size != PINNED_SIZE)):
raise SystemExit(
f"ERROR: MoveNet ONNX does not match the pinned digest.\n"
f" expected: {PINNED_SIZE} bytes, sha256 {PINNED_SHA256}\n"
f" got : {size} bytes, sha256 {digest}\n Refusing to install it."
)
if not PINNED_SHA256:
print(f" WARNING: integrity not pinned (MOVENET_ONNX_SHA256 unset). Got {size} bytes,"
f" sha256 {digest}. Pin this once the source is trusted.", file=sys.stderr)
os.replace(tmp, dest)
tmp = None
print(f"done: {dest} ({size} bytes)")
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
if __name__ == "__main__":
main()

View file

@ -1,10 +1,24 @@
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
"""Parse server config zone rows into Zone objects. When camera_id is given, only
rows for that camera are kept (multi-camera routing); None keeps all rows."""
out: list[Zone] = []
rows = config.get("zones", []) or []
@ -19,8 +33,22 @@ 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):
except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError) as e:
# Same principle as the pixel-space branch above: never fail dark. A row missing a key
# (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be
# dropped silently — intrusion detection for that zone dead with no log, no dashboard
# signal. Flag it so it can be fixed instead of discovered after a worker enters the zone.
zid = row.get("zone_id") if isinstance(row, dict) else None
log.warning("skipping malformed zone row %r (%s: %s) — its intrusion detection is "
"inactive until the row is fixed in the dashboard.", zid, type(e).__name__, e)
continue
return out

View file

@ -2,3 +2,4 @@
/config.php
/storage/snapshots/*
!/storage/snapshots/.gitkeep
/.phpunit.result.cache

View file

@ -1,5 +1,5 @@
{
"name": "move-detect/cloud",
"name": "move-detect/server",
"type": "project",
"require": { "php": ">=8.2" },
"require-dev": { "phpunit/phpunit": "^11" },

16
server/config.example.php Normal file
View file

@ -0,0 +1,16 @@
<?php
return [
'db_dsn' => 'mysql:host=localhost;dbname=move_detect;charset=utf8mb4',
'db_user' => 'CHANGEME',
'db_pass' => 'CHANGEME',
'snapshot_dir' => __DIR__ . '/storage/snapshots',
// Every key App\Config reads is listed, defaults included. They all have `?? default`
// fallbacks, so an omitted key is silently absent rather than an error — an operator
// who never sees it here cannot know the knob exists.
'nonce_ttl' => 86400,
'timestamp_window' => 300,
'rate_limit_per_min' => 60,
'ingest_ip_rate_limit_per_min' => 300,
// PIPA retention: worker snapshots are purged after this many days (0 disables).
'snapshot_retention_days' => 7,
];

View file

@ -0,0 +1,165 @@
# 카페24 업로드 안내
이 폴더의 **내용물**을 카페24 FTP **최상위**에 그대로 올리면 됩니다.
고칠 파일은 **`config.php` 하나뿐**입니다.
---
## 1. 올리는 위치 (제일 중요)
**폴더 2개만** 올리면 됩니다. `move_detect_app` 과 `www`.
```
/ <- FTP 최상위
├── (기존 사이트 파일) <- 건드리지 않습니다
├── move_detect_app/ <- [올리기 1] 통째로. 웹에서 안 보입니다
│ ├── config.php 여기만 고칩니다
│ ├── src/
│ ├── vendor/
│ ├── storage/snapshots/ 사진 저장
│ └── schema.sql DB 넣고 나면 지웁니다
└── www/ <- 원래 있던 폴더 (다른 사이트가 씀)
├── (기존 사이트 파일) <- 건드리지 않습니다
└── move_detect/ <- [올리기 2] 이 폴더만 새로 생깁니다
├── index.php
├── .htaccess
├── _preflight.php
└── assets/
```
접속 주소는 **`https://내도메인/move_detect/`** 입니다.
`README-UPLOAD.md`(이 파일)는 올릴 필요 없습니다.
**왜 `move_detect_app` 을 `www` 밖에 두나:** `config.php` 에 DB 비밀번호가, `storage/snapshots/`
에 작업자 사진이 들어갑니다. `www` 안에 있으면 같은 `www` 를 쓰는 다른 사이트 주소로 접근될
수 있습니다. 한 폴더로 묶은 건 최상위에 있는 남의 파일(`vendor/`, `config.php` 같은 흔한
이름)과 겹쳐 덮어쓰는 사고를 막기 위해서입니다.
업로드가 끝나면 FTP 클라이언트에서 권한을 지정하세요. zip 과 FTP 는 유닉스 권한을
옮기지 못하므로, 패키지에 넣어둔 권한은 올리는 순간 사라집니다.
| 대상 | 권한 |
|---|---|
| `move_detect_app/config.php` | **600** (DB 비밀번호가 들어있습니다) |
| `move_detect_app/storage/snapshots/` | 750 |
`_preflight.php` 가 실제 권한과 노출 여부를 확인해줍니다.
---
## 2. `config.php` 고치기
DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
```php
'db_dsn' => 'mysql:host=localhost;dbname=여기에_DB이름;charset=utf8mb4',
'db_user' => '여기에_MySQL아이디',
'db_pass' => '여기에_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 켜기
카페24 관리자에서 **무료 SSL 인증서**를 켭니다.
선택이 아니라 필수입니다. 로그인 세션 쿠키가 `cookie_secure=true` 라서,
HTTP 로는 브라우저가 쿠키를 아예 안 보냅니다 → 로그인이 안 됩니다.
---
## 5. 점검하기
브라우저에서 엽니다:
```
https://내도메인/move_detect/_preflight.php
```
전부 **OK** 여야 합니다. 실패한 줄에 무엇을 하면 되는지 적혀 있습니다.
특히 이 둘은 카페24 요금제에 따라 없을 수 있고, 없으면 카페24에 문의해야 합니다:
| 항목 | 없으면 생기는 일 |
|---|---|
| **Argon2id** | 로그인 페이지가 즉시 죽습니다. 제일 흔한 실패 원인입니다. |
| **gd** | 이벤트는 저장되는데 **사진만 조용히 사라집니다.** 에러도 안 납니다. |
**확인이 끝나면 `www/move_detect/_preflight.php` 를 지우세요.**
---
## 6. 관리자 계정 만들기
앱에 회원가입 기능이 없습니다. 첫 계정은 직접 넣습니다.
비밀번호 해시를 만듭니다(로컬 PC 에서 실행 — 평문이 phpMyAdmin 기록에 남지 않게):
```bash
php -r "echo password_hash('여기에_진짜_비밀번호', PASSWORD_ARGON2ID), \"\n\";"
```
phpMyAdmin → SQL 탭:
```sql
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until)
VALUES ('admin', '여기에_해시_붙여넣기', 'admin', NULL, 0, 0);
```
---
## 7. 라즈베리파이(장비) 등록
장비마다 비밀키를 하나씩 만들어 DB 에 넣습니다.
```bash
php -r "echo bin2hex(random_bytes(32)), \"\n\";"
```
```sql
INSERT INTO move_devices (device_id, name, secret, config_version)
VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기', 1);
```
같은 값을 파이 쪽 `/etc/move-detect/edge.env` 의 `DEVICE_SECRET` 에 넣습니다.
`CAFE24_URL` 에는 `https://내도메인/move_detect` 를 넣습니다. (앱이 서브폴더에 있으므로
뒤의 `/move_detect` 까지 반드시 포함해야 합니다.)
비밀키는 메일/메신저로 보내지 마세요.
---
## 8. 마지막 확인
- [ ] `https://내도메인/move_detect/login` 이 열린다
- [ ] admin 으로 로그인되고 대시보드가 뜬다
- [ ] `schema.sql` 을 서버에서 지웠다
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)

View file

@ -0,0 +1,269 @@
#!/usr/bin/env bash
# Build the Cafe24 upload package: a folder whose contents are dragged into the hosting
# account's FTP root as-is.
#
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
# folder. The hosting account executes nothing — no shell, no composer, no installer: the
# operator uploads files over FTP and edits config.php. That is also why this lives under
# server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
# root on the target.
#
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
# server/deploy/build_cafe24_package.sh
#
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip
#
# dist/1_cafe24_upload/
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
# │ ├── config.php the ONLY file the operator edits
# │ ├── src/ vendor/ storage/snapshots/
# │ └── schema.sql import via phpMyAdmin, then DELETE from the host
# ├── www/
# │ └── move_detect/ -> UPLOAD into the existing www/. Reached at
# │ ├── index.php https://<domain>/move_detect/
# │ └── .htaccess _preflight.php assets/
# └── README-UPLOAD.md -> for the operator to read; not uploaded
#
# Two upload targets, and everything private is ONE folder. That matters on a shared account:
# this www/ already serves another site, so the FTP root may hold that site's files too —
# spraying src/, vendor/ and config.php across the root risks overwriting them (vendor/ and
# config.php are ordinary names in any PHP project).
#
# The app is mounted in a SUBDIRECTORY of www/ for the same reason: nothing secret may sit
# under a docroot the other site also serves. index.php finds move_detect_app/ by walking up
# rather than hardcoding '/..', and the URL prefix comes from SCRIPT_NAME, so this same
# package also works dropped straight into www/.
#
# APP_DIR= override the www/ subdirectory; empty mounts the app directly in www/.
# PRIVATE_DIR= override the private folder name (must match index.php's APP_PRIVATE_DIR).
set -euo pipefail
APP_DIR="${APP_DIR-move_detect}"
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SERVER="$REPO_ROOT/server"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/1_cafe24_upload"
ZIP="$OUT_DIR/move-detect-cafe24.zip"
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
echo ">> [1/6] clean stage: $STAGE"
rm -rf "$STAGE"
# WEB is where the app's own files land: www/move_detect by default, www/ when APP_DIR is
# empty. Everything else stays at the stage root = the FTP root = one level above www/.
WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"
# PRIV holds everything that must never be web-reachable, in ONE folder so it cannot
# collide with the co-hosted site's files at the FTP root.
PRIV="$STAGE/$PRIVATE_DIR"
mkdir -p "$WEB" "$PRIV/src" "$PRIV/storage/snapshots"
echo ">> [2/6] generating the no-dev autoloader"
# public/index.php hard-requires ../vendor/autoload.php, so vendor/ MUST ship — the app has
# no runtime composer *packages* (composer.lock's "packages" is empty), but it does need
# composer's generated autoloader to map App\ -> src/.
#
# --no-dev is load-bearing, not tidiness: the working tree's vendor/ carries PHPUnit and a
# dev autoload_files.php that eagerly requires tests/Support/InMemoryRepositories.php on
# every request — a file this package deliberately excludes. Shipping it fatals every hit.
#
# --classmap-authoritative because src/Auth/Session.php declares three symbols
# (SessionStore, ArraySessionStore, PhpSessionStore) under one filename matching none of
# them; that is why composer.json carries an explicit classmap entry for it. Plain PSR-4
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
# `new PhpSessionStore()` — the 4th statement of every request.
#
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/
# in place, so restore the dev autoloader on exit or local phpunit breaks.
restore_dev_autoloader() {
echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)"
( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
}
trap restore_dev_autoloader EXIT
( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
# Precondition for everything below: this package stages ONLY composer's generated
# autoloader, which is correct exactly while the app has no runtime packages. The day
# someone adds one to composer.json's `require`, that staging silently omits its code and
# the host fatals on a missing class. Fail here, on the commit that adds the dependency,
# rather than on the hosting account.
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
"$SERVER/composer.lock" || {
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
echo " autoloader, so their code would be missing from the package. Teach" >&2
echo " step [3/6] to stage the real vendor tree before shipping again." >&2
exit 1
}
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
mkdir -p "$PRIV/vendor/composer"
cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php"
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in
# phpunit/, sebastian/, myclabs/ and friends.
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f"
done
cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
# regenerate them — they are the package inventory written by the last `composer install`,
# so they still list every dev package (and its exact version, which is a free CVE shopping
# list for anyone who can read them). Nothing here needs them: autoload_real.php eagerly
# requires only platform_check.php, and Composer\InstalledVersions reads installed.php
# lazily, on a call this app never makes.
# Guard: the dev autoload_files.php requires tests/Support/InMemoryRepositories.php, which
# is not in this package. If it slipped through, every request 500s — fail the build loudly
# rather than ship it.
if [ -f "$PRIV/vendor/composer/autoload_files.php" ]; then
echo "ERROR: autoload_files.php present — that is the DEV autoloader (it requires tests/)." >&2
echo " composer dump-autoload --no-dev did not take effect." >&2
exit 1
fi
# Guard: no autoload map may point outside src/. Grep the maps that actually drive loading
# rather than the whole tree — InstalledVersions.php names PHPUnit in a docblock, which is
# harmless prose, and matching it would make this check cry wolf.
for map in autoload_classmap.php autoload_psr4.php autoload_static.php autoload_namespaces.php; do
if grep -qiE "phpunit|sebastian|myclabs|/tests/" "$PRIV/vendor/composer/$map"; then
echo "ERROR: $map references dev deps or tests/ — --no-dev did not take effect." >&2
exit 1
fi
done
# Guard: no dev package directory may exist in the staged vendor/.
if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -q .; then
echo "ERROR: unexpected package directories under the staged vendor/:" >&2
find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer >&2
exit 1
fi
echo ">> [4/6] staging app source"
cp -r "$SERVER/src/." "$PRIV/src/"
cp -r "$SERVER/public/." "$WEB/"
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \
-o -name '.pytest_cache' -o -name '.mypy_cache' -o -name '.DS_Store' \
-o -name '.phpunit.result.cache' -o -name 'Thumbs.db' \) -exec rm -rf {} + 2>/dev/null || true
# storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the
# failure is silent rather than loud: IngestController does
# `if (file_put_contents(...) === false) continue;`, so a missing dir means the snapshot is
# dropped while the API still answers 201 to the edge device. The event row lands in MySQL
# with no image, the dashboard shows an event with no photo, and with display_errors=Off
# (correct for production) not even a warning survives. That is permanent evidence loss.
touch "$PRIV/storage/snapshots/.gitkeep"
# Build-local only. Neither zip nor FTP carries unix modes, so these do NOT reach the host —
# they are correct for someone who rsyncs the stage directly, and a no-op otherwise. The
# host-side modes are the operator's job (README-UPLOAD.md) and _preflight.php verifies the
# live value rather than trusting these.
chmod 750 "$PRIV/storage/snapshots"
chmod 600 "$PRIV/config.php"
echo ">> [5/6] verifying the package resolves every class index.php touches"
# Prove the autoloader works against the STAGED layout, not the repo's.
php -r '
$stage = $argv[1];
require $stage . "/vendor/autoload.php";
$classes = ["App\\Config", "App\\Http\\Router", "App\\Http\\Request", "App\\Http\\Response",
"App\\Db\\Database", "App\\Auth\\PhpSessionStore", "App\\Auth\\AuthController",
"App\\Auth\\CsrfController", "App\\Auth\\Rbac", "App\\Ingest\\IngestController",
"App\\Ingest\\EventValidator", "App\\Ingest\\ImageProcessor", "App\\Api\\ConfigController",
"App\\Api\\HeartbeatController", "App\\Dashboard\\EventListController",
"App\\Dashboard\\EventDetailController", "App\\Dashboard\\SnapshotProxy",
"App\\Dashboard\\ZoneConfigController", "App\\Geometry\\Polygon",
"App\\Repository\\Pdo\\PdoDeviceRepository", "App\\Repository\\Pdo\\PdoNonceRepository",
"App\\Repository\\Pdo\\PdoRateLimiterRepository", "App\\Repository\\Pdo\\PdoEventRepository",
"App\\Repository\\Pdo\\PdoSnapshotRepository", "App\\Repository\\Pdo\\PdoUserRepository",
"App\\Repository\\Pdo\\PdoZoneRepository", "App\\Repository\\Pdo\\PdoThresholdRepository",
"App\\Repository\\Pdo\\PdoAuditRepository"];
$missing = array_values(array_filter($classes, fn($c) => !class_exists($c) && !interface_exists($c)));
if ($missing) { fwrite(STDERR, "ERROR: unresolvable in the package: " . implode(", ", $missing) . "\n"); exit(1); }
echo " -> " . count($classes) . " classes resolve through the packaged autoloader\n";
' "$PRIV"
# Prove index.php ACTUALLY finds $PRIV from where it is mounted, at the real depth, rather
# than trusting that the tree looks right. This is the check that would have caught a
# hardcoded '/..' — the walk-up must cross www/ and land on the private folder.
php -r '
[$_, $web, $priv] = $argv;
$found = null;
$isAppRoot = fn(string $d) => is_file("$d/config.php") && is_file("$d/vendor/autoload.php")
&& is_file("$d/src/Config.php");
for ($dir = dirname($web), $i = 0; $i < 4; $i++) {
foreach (["$dir/move_detect_app", $dir] as $c) { if ($isAppRoot($c)) { $found = $c; break 2; } }
if (dirname($dir) === $dir) break;
$dir = dirname($dir);
}
if ($found === null) { fwrite(STDERR, "ERROR: index.php could not resolve the app root from $web\n"); exit(1); }
if (realpath($found) !== realpath($priv)) {
fwrite(STDERR, "ERROR: app root resolved to $found, expected $priv\n"); exit(1);
}
echo " -> index.php resolves its app root across www/ to " . basename($found) . "/\n";
' "$WEB" "$PRIV"
# Every staged .php must parse under the build php. Catches a truncated copy.
while IFS= read -r -d '' f; do
php -l "$f" >/dev/null || { echo "ERROR: php -l failed: $f" >&2; exit 1; }
done < <(find "$STAGE" -name '*.php' -print0)
echo " -> every staged .php parses"
echo ">> [6/6] zipping"
rm -f "$ZIP"
if [ "${MAKE_ZIP:-0}" != "1" ]; then
# Default: leave the folder uncompressed. The operator uploads $STAGE/ over FTP as-is,
# which is also the only path that cannot mangle directory separators (see below).
echo " -> skipped (MAKE_ZIP=1 to also produce $ZIP)"
elif command -v zip >/dev/null 2>&1; then
( cd "$STAGE" && zip -rq "$ZIP" . )
elif command -v python3 >/dev/null 2>&1; then
# No `zip` (common on Windows/Git Bash). Use Python's zipfile with POSIX (forward-slash)
# arcnames. PowerShell's Compress-Archive writes BACKSLASH path separators, which Cafe24's
# extractor and some unzip tools do NOT normalise — the app tree then unpacks as flat files
# (move_detect_app\src\... as one filename) and the deploy silently breaks. Prefer python.
( cd "$STAGE" && python3 - "$ZIP" <<'PY'
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
for root, _dirs, files in os.walk("."):
for name in files:
full = os.path.join(root, name)
arc = os.path.relpath(full, ".").replace(os.sep, "/") # force forward slashes
z.write(full, arc)
PY
)
elif command -v powershell >/dev/null 2>&1; then
# Last resort. Compress-Archive uses backslash separators (see above) — the .htaccess/README
# note the operator to extract with a slash-aware tool, or prefer the python path.
win_stage="$(cygpath -w "$STAGE" 2>/dev/null || echo "$STAGE")"
win_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")"
powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force"
echo " WARN: archived via Compress-Archive (backslash paths). Verify the host unpacks the tree correctly."
else
echo " WARN: no zip/python3/powershell — skipping the archive; upload $STAGE/ directly."
fi
echo ""
echo "DONE."
echo " package : $STAGE"
[ -f "$ZIP" ] && echo " zip : $ZIP"
echo ""
echo "Upload: put the CONTENTS of $STAGE into the Cafe24 FTP root."
echo " www/* merges into the existing www/ (DocumentRoot)"
echo " src/ vendor/ storage/ config.php sit BESIDE www/, never inside it"
echo ""
echo "Then, on the host: edit config.php (DB name/user/password), import schema.sql via"
echo "phpMyAdmin and delete it, and open https://<domain>/_preflight.php to confirm the PHP"
echo "build is usable — then DELETE _preflight.php. See README-UPLOAD.md."

View file

@ -0,0 +1,31 @@
<?php
// ============================================================================
// move-detect — 설정 파일 (이 파일만 고치면 됩니다)
// 위치: 카페24 FTP 최상위 /config.php (www/ 안이 아닙니다!)
// 권한: 업로드 후 FTP 클라이언트에서 600 으로 지정하세요. zip/FTP 는 유닉스 권한을
// 옮기지 못하므로 여기서 미리 줄 수 없습니다. _preflight.php 가 확인해줍니다.
// ============================================================================
//
// 고칠 곳은 아래 "1. 데이터베이스" 세 줄뿐입니다. 값은 카페24 관리자 페이지의
// MySQL 정보에서 그대로 복사하세요. 나머지는 손대지 않아도 동작합니다.
//
return [
// ---- 1. 데이터베이스 (반드시 채우세요) ------------------------------
// CHANGEME_DB_NAME → 카페24에서 만든 DB 이름
'db_dsn' => 'mysql:host=localhost;dbname=CHANGEME_DB_NAME;charset=utf8mb4',
'db_user' => 'CHANGEME_DB_USER', // 카페24 MySQL 아이디
'db_pass' => 'CHANGEME_DB_PASSWORD', // 카페24 MySQL 비밀번호 — 비밀. 어디에도 붙여넣지 마세요.
// ---- 2. 저장 경로 (그대로 두세요) -----------------------------------
// 여기서 __DIR__ 은 이 config.php 가 있는 폴더(= FTP 최상위)입니다.
// src/Config.php 의 기본값은 '/../storage/snapshots' 이지만 그것은 src/ 기준이라
// '..' 이 하나 더 붙습니다. 둘 다 결국 같은 <최상위>/storage/snapshots 를 가리킵니다.
'snapshot_dir' => __DIR__ . '/storage/snapshots',
// ---- 3. 조절값 (기본값. 필요할 때만 바꾸세요) ------------------------
'nonce_ttl' => 86400, // 재전송 방지 nonce 보관 시간(초)
'timestamp_window' => 300, // HMAC 허용 시간 오차(초)
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성)
];

View file

@ -0,0 +1,64 @@
# move-detect cloud — belongs in the DocumentRoot (Cafe24: /home/hosting_users/<ID>/www/).
# Built into the upload package as www/.htaccess by build_cafe24_package.sh.
#
# mod_rewrite is confirmed on Cafe24 web hosting. mod_headers and mod_authz_core are not,
# so every dependent block is wrapped in <IfModule> and degrades instead of 500-ing.
<IfModule mod_rewrite.c>
RewriteEngine On
# 1) Force HTTPS. This is REQUIRED, not hardening: src/Auth/Session.php hard-codes
# 'cookie_secure' => true, so over plain HTTP the browser never sends the session
# cookie back — login appears to succeed, then silently loses the session on the very
# next request. All three conditions must hold before redirecting, so a TLS-terminating
# proxy that sets any one of them cannot trigger a redirect loop.
# R=302 on purpose. Promote to R=301 only after verifying in a real browser: a wrong
# 301 is cached by clients and is very hard to walk back.
RewriteCond %{HTTPS} !=on
RewriteCond %{SERVER_PORT} !^443$
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=302,L]
# 2) Front controller — every non-file, non-directory path goes to index.php.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
</IfModule>
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "no-referrer"
</IfModule>
# 3) No directory listings. Backstop against an unauthenticated listing of
# storage/snapshots/ (JPEGs of people at work) if it is ever misplaced under the docroot.
Options -Indexes
# 4) Deny dotfiles (.git, .env, .htaccess, .user.ini, .phpunit.result.cache, ...).
<FilesMatch "^\.">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# 5) Deny anything Apache would serve as PLAINTEXT for lack of a handler. schema.sql is the
# sharp one: it is full DDL, and unlike a .php file PHP would not swallow it.
<FilesMatch "\.(sql|md|json|lock|dist|ini|log|sh|ya?ml|cache)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# NOTE: there are deliberately no deny rules for src/ vendor/ storage/ config.php here.
# Those live OUTSIDE the DocumentRoot (siblings of www/), where no .htaccess under www/
# could reach them and none is needed. Placement is the control; keep them out of www/.

160
server/deploy/preflight.php Normal file
View file

@ -0,0 +1,160 @@
<?php
// Uploaded as www/_preflight.php. Open https://<domain>/_preflight.php once, confirm every
// row is OK, then DELETE THIS FILE.
//
// It exists because three host-level facts are unknowable from the repo and every one of
// them fails badly on a stock PHP build:
// * no argon2 -> src/Security/Password.php calls password_hash(..., PASSWORD_ARGON2ID)
// unguarded, and AuthController::DUMMY_HASH runs verify() on EVERY login
// attempt including unknown usernames -> the login page fatals for
// everyone, instantly.
// * no gd -> ImageProcessor::reencode() returns ok=false and IngestController's
// `continue` swallows it: events save with no snapshot, API still 201s.
// * no pdo_mysql -> every request 500s at Database::connect(). (mysqli being present does
// NOT imply pdo_mysql.)
//
// Deliberately prints booleans only — no phpinfo(), no paths, no config values — so the
// window between upload and deletion leaks nothing useful.
declare(strict_types=1);
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex, nofollow');
// Find the app root exactly the way public/index.php does — container first, then the bare
// directory, probing each level for any marker. Using ANY marker (not all three) is
// deliberate here and differs from index.php on purpose: this page exists to DIAGNOSE a
// half-finished upload, so it must still locate a root that is missing vendor/ or config.php
// and then report precisely which piece is absent.
const APP_PRIVATE_DIR = 'move_detect_app';
$root = null;
$hasAnyMarker = static fn (string $d): bool =>
is_file($d . '/config.php') || is_file($d . '/vendor/autoload.php') || is_file($d . '/src/Config.php');
for ($d = dirname(__DIR__), $i = 0; $i < 4; $i++) {
foreach ([$d . '/' . APP_PRIVATE_DIR, $d] as $cand) {
if ($hasAnyMarker($cand)) { $root = $cand; break 2; }
}
if (dirname($d) === $d) break;
$d = dirname($d);
}
// Nothing found anywhere IS the diagnosis: fall back to where the files SHOULD be, so every
// row below reports a truthful "missing" instead of crashing on null.
$root = $root ?? dirname(dirname(__DIR__)) . '/' . APP_PRIVATE_DIR;
$checks = [
'PHP >= 8.2' => [
PHP_VERSION_ID >= 80200,
'Cafe24 관리자에서 PHP 버전을 8.2 이상으로 올리세요. (주의: 카페24는 PHP 버전 변경 시 데이터와 DB가 초기화된다고 안내합니다. 반드시 업로드 전에 정하세요.)',
],
'pdo_mysql 확장' => [
in_array('mysql', PDO::getAvailableDrivers(), true),
'DB 연결이 전혀 안 됩니다. 카페24에 pdo_mysql 활성화를 요청하세요.',
],
'gd 확장' => [
function_exists('imagecreatefromstring') && function_exists('imagejpeg'),
'사진(스냅샷)이 조용히 사라집니다. 이벤트는 저장되는데 사진만 없어집니다. 카페24에 gd 활성화를 요청하세요.',
],
'Argon2id 암호 해시' => [
defined('PASSWORD_ARGON2ID') && in_array('argon2id', password_algos(), true),
'로그인 페이지가 즉시 죽습니다. 이게 가장 흔한 배포 실패 원인입니다. 카페24에 Argon2 포함 PHP 빌드를 요청하세요.',
],
'config.php 있음' => [
is_readable($root . '/config.php'),
'FTP 최상위(www 밖)에 config.php 를 올리세요.',
],
// Checks the parsed VALUES, not the file text. Grepping the raw file for 'CHANGEME'
// would also match the template's explanatory comments, which survive a correct edit —
// so a properly-filled config would report 실패 forever.
'config.php 를 수정했음' => (function () use ($root) {
$fix = 'config.php 안의 CHANGEME 를 실제 DB 이름/아이디/비밀번호로 바꾸세요.';
if (!is_readable($root . '/config.php')) {
return [false, $fix];
}
$cfg = @require $root . '/config.php';
if (!is_array($cfg)) {
return [false, 'config.php 가 PHP 배열을 return 하지 않습니다. 파일이 깨졌는지 확인하세요.'];
}
foreach (['db_dsn', 'db_user', 'db_pass'] as $k) {
if (str_contains((string)($cfg[$k] ?? ''), 'CHANGEME')) {
return [false, $fix];
}
}
return [true, ''];
})(),
'storage/snapshots 쓰기 가능' => [
is_dir($root . '/storage/snapshots') && is_writable($root . '/storage/snapshots'),
'사진이 조용히 사라집니다. 폴더를 만들고 권한 750 으로 주세요.',
],
'src/ 있음' => [
is_readable($root . '/src/Config.php'),
'src/ 폴더를 FTP 최상위(www 밖)에 올리세요.',
],
'vendor/ 오토로더 있음' => [
is_readable($root . '/vendor/autoload.php'),
'vendor/ 폴더를 FTP 최상위(www 밖)에 올리세요. 없으면 모든 페이지가 죽습니다.',
],
// These probe THIS directory (the one the web serves), not $root. If the secrets turned
// up here, they are downloadable.
'src/ 가 웹에 노출되지 않음' => [
!is_readable(__DIR__ . '/src/Config.php'),
'src/ 가 웹에서 접근 가능한 폴더 안에 있습니다. FTP 최상위(www 밖)로 옮기세요.',
],
'config.php 가 웹에 노출되지 않음' => [
!is_readable(__DIR__ . '/config.php'),
'config.php 가 웹에서 접근 가능한 폴더 안에 있습니다. DB 비밀번호가 노출됩니다. FTP 최상위(www 밖)로 옮기세요.',
],
// Reports the LIVE mode rather than trusting the build. The package's chmod 600 cannot
// survive a zip round-trip or an FTP upload (neither carries unix modes), so the only
// honest place to check this is here, on the host. 0077 — NOT 0o077, which is PHP 8.1+
// syntax and would make this file fail to parse on the very old build we want to warn about.
'config.php 권한이 600' => [
is_readable($root . '/config.php')
&& (fileperms($root . '/config.php') & 0077) === 0,
'FTP 클라이언트에서 config.php 권한을 600 으로 바꾸세요. DB 비밀번호가 들어있습니다.',
],
'HTTPS 로 접속됨' => [
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['SERVER_PORT'] ?? '') === '443'
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
'세션 쿠키가 cookie_secure=true 라서 HTTP 로는 로그인이 안 됩니다. 카페24에서 무료 SSL 을 켜세요.',
],
];
$failed = array_keys(array_filter($checks, fn($c) => !$c[0]));
?>
<!doctype html>
<meta charset="utf-8">
<title>move-detect 설치 점검</title>
<style>
body { font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
table { border-collapse: collapse; width: 100%; }
td { border-bottom: 1px solid #e5e5e5; padding: .5rem .4rem; vertical-align: top; }
td:first-child { white-space: nowrap; }
.ok { color: #0a7c2f; font-weight: 600; }
.no { color: #c02626; font-weight: 600; }
.fix { color: #666; font-size: 13px; }
.banner { padding: .75rem 1rem; border-radius: 6px; margin: 1rem 0; }
.good { background: #e8f6ec; }
.bad { background: #fdeaea; }
.warn { background: #fff6d8; border: 1px solid #e8cf7a; }
</style>
<h1>move-detect 설치 점검</h1>
<?php if ($failed): ?>
<p class="banner bad"><strong><?= count($failed) ?>개 항목 실패.</strong> 아래 빨간 줄의 안내를 먼저 해결하세요.</p>
<?php else: ?>
<p class="banner good"><strong>전부 통과.</strong> <a href="/login">/login</a> 으로 접속해 관리자 로그인을 확인하세요.</p>
<?php endif; ?>
<table>
<?php foreach ($checks as $name => [$ok, $fix]): ?>
<tr>
<td><?= htmlspecialchars($name, ENT_QUOTES) ?></td>
<td class="<?= $ok ? 'ok' : 'no' ?>"><?= $ok ? 'OK' : '실패' ?></td>
<td class="fix"><?= $ok ? '' : htmlspecialchars($fix, ENT_QUOTES) ?></td>
</tr>
<?php endforeach; ?>
</table>
<p class="banner warn">
<strong>확인이 끝나면 이 파일(<code>www/_preflight.php</code>)을 반드시 지우세요.</strong>
진단용이라 계속 열어둘 이유가 없습니다.
</p>

5
server/deploy/user.ini Normal file
View file

@ -0,0 +1,5 @@
; Shipped as www/move_detect/.user.ini (Cafe24 honors per-directory .user.ini).
; Backstops index.php's ini_set for errors raised before the script parses/runs, so a stack
; trace with the DB DSN/user can never reach the browser on a shared host that ships On.
display_errors = Off
log_errors = On

View file

@ -1,13 +1,13 @@
# Cafe24 Cloud Deploy Guide (P1-B)
# Cafe24 Server Deploy Guide (P1-B)
This document covers deploying the `cloud/` PHP application (ingest API + dashboard) to a
This document covers deploying the `server/` PHP application (ingest API + dashboard) to a
Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer
dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with
prepared statements — there is nothing to `composer install` on the production host.
## 1. Directory layout on the server
Only `cloud/public/` may be reachable from the web. Everything else must sit **outside**
Only `server/public/` may be reachable from the web. Everything else must sit **outside**
the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is),
or be blocked by hosting-level access rules if your plan cannot place files outside the
web root at all.
@ -15,38 +15,73 @@ web root at all.
```
/home/<account>/ <- NOT web-reachable
├── src/ <- App\ classes (App\Db, App\Repository\Pdo, ...)
├── vendor/ <- Composer's generated autoloader ONLY (see below)
├── storage/
│ └── snapshots/ <- re-encoded JPEGs; must be writable by the PHP process
├── config.php <- copied from config.example.php, secrets filled in (see §3)
├── schema.sql <- only needed transiently for the phpMyAdmin import (§4);
│ delete it from the server afterwards
└── public/ <- web root ("html" folder in Cafe24's control panel)
├── index.php
├── .htaccess
└── assets/
└── public/ <- web root. On Cafe24 it is named `www`; when that `www`
├── index.php already serves another site the app is mounted in a
├── .htaccess subdirectory (`www/move_detect/`) and the entries above
└── assets/ sit at the FTP root — see below.
```
`cloud/public/index.php` resolves its dependencies as `__DIR__ . '/../config.php'`,
`__DIR__ . '/../src/View/...'`, etc. — so `src/`, `config.php`, and `storage/` must be
exactly one directory above `public/`, matching the layout above.
`server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a
subdirectory of one — in the latter case the app root is two levels up, not one, because
nothing secret may sit under a docroot the other site also serves.
**Do not upload `vendor/`.** It contains only `phpunit` and its dev dependencies (see
`cloud/composer.json` — `require-dev` only, `require` has no packages beyond the `php`
version constraint). Shipping it would put a large, unused, occasionally-vulnerable
dependency tree on a production box for no benefit; leave it off the transfer entirely.
The URL prefix comes from `SCRIPT_NAME` (`App\Http\Request::baseFrom()`), which the web
server sets — never from client input. `Request::pathFor()` strips it before routing, and
views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without that, routes
declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
resolve against the co-hosted site.
If your Cafe24 plan cannot place folders outside the web root, upload `src/`, `storage/`,
and `config.php` into `public/` but add explicit deny rules for them in
`public/.htaccess` (e.g. `RewriteRule ^(src|storage)/ - [F]` and
`RewriteRule ^config\.php$ - [F]`, placed **before** the existing rewrite-to-index rule) —
placing them outside the web root is strongly preferred and should be the default choice.
`server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
(empty) to mount the app directly in `www/`.
**`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
`require __DIR__ . '/../vendor/autoload.php'`, so a transfer without `vendor/` fatals on
every request, including §10's own smoke check. There are no runtime Composer *packages*
(`composer.lock`'s `packages` is empty), but the app does need Composer's generated
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
```bash
cd server && composer dump-autoload --no-dev --classmap-authoritative --no-interaction
```
That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy
exactly those. Do **not** upload the working tree's `vendor/` as-is — it carries PHPUnit
and a dev `autoload_files.php` that eagerly `require`s
`tests/Support/InMemoryRepositories.php` on every request, a file this deployment
deliberately excludes.
`--classmap-authoritative` is not optional: `src/Auth/Session.php` declares three symbols
(`SessionStore`, `ArraySessionStore`, `PhpSessionStore`) under a filename matching none of
them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain
PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`.
`server/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the
tree below automatically — prefer it over assembling the transfer by hand.
If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is
unsupported, and the obvious workaround does not work.** `public/index.php` resolves
`../vendor/autoload.php` (line 3), `../config.php` (line 17), and `../src/View/` (line 43)
relative to itself, so moving those *into* `public/` puts them exactly where those three
`require`s do not look: every request fatals. Making it work needs source changes to
`index.php`'s path resolution plus deny rules for `src/`, `vendor/`, `storage/` **and**
`config.php` — not deny rules alone. Raise it before deploying rather than improvising:
`config.php` holds the DB password, and a half-applied version of this is how it ends up
web-readable.
## 2. Host prerequisites — verify before importing data
Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over
SSH if available) — do not assume, since PHP builds vary by hosting plan:
- [ ] **PHP version** matches `cloud/composer.json`'s `"php": ">=8.2"` (the app is written
- [ ] **PHP version** matches `server/composer.json`'s `"php": ">=8.2"` (the app is written
and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact
version Cafe24 provisions for your plan).
- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include
@ -77,7 +112,7 @@ SSH if available) — do not assume, since PHP builds vary by hosting plan:
## 3. `config.php`
Copy the example and fill in real values — **never commit `config.php`** (already covered
by `cloud/.gitignore`):
by `server/.gitignore`):
```bash
cp config.example.php config.php
@ -104,7 +139,7 @@ or version control.
## 4. Import the schema
`cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
@ -116,12 +151,19 @@ or version control.
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
attack surface for no benefit.
If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) will be parsed but silently not enforced —
harmless, since the application already whitelists these values before every insert
**Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression
default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a
hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an
older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1
blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel
(or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either
request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied
timestamp (the code already passes one on every insert).
Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) are parsed but silently not enforced — harmless,
since the application already whitelists these values before every insert
(`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`).
Everything else in the schema (tables, keys, indexes, `JSON` columns) requires only
MySQL 5.7+.
## 5. Storage directory
@ -153,14 +195,14 @@ php -r "echo bin2hex(random_bytes(32)), \"\n\";"
```
```sql
INSERT INTO devices (device_id, name, secret, config_version)
INSERT INTO move_devices (device_id, name, secret, config_version)
VALUES ('pi-01', 'Line 3 — Fall Cam', '<paste the generated secret here>', 1);
```
Deliver the secret to the device provisioning process out-of-band (not email/chat) and
configure it as the device's `HMAC` signing key for `X-Signature` on
`/api/events`, `/api/heartbeat`, and `/api/config`. Rotate a compromised secret by
`UPDATE devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the
`UPDATE move_devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the
old value stops working immediately since `secretFor()` always reads the current row.
## 7. First admin account
@ -180,7 +222,7 @@ php -r "echo password_hash('CHANGE-ME-to-a-real-passphrase', PASSWORD_ARGON2ID),
`PASSWORD_ARGON2ID`.)
```sql
INSERT INTO users (username, password_hash, role, site_scope, failed_attempts, locked_until)
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until)
VALUES ('admin', '<paste the argon2id hash here>', 'admin', NULL, 0, 0);
```

View file

@ -0,0 +1,98 @@
-- ===========================================================================
-- Migration: P1-9 (event dedup) + P3-2b (site-scoped access control)
-- + retention / rate-limit reap indexes
--
-- Apply ONCE to an EXISTING move_detect database. A FRESH install already has
-- all of this via schema.sql — do NOT run this on a database created from the
-- current schema.sql.
--
-- RUN IN THREE PARTS, IN ORDER. Part C (the events.site backfill) MUST run
-- AFTER Part B has set devices.site — otherwise it matches 0 rows and every
-- historical event stays site=NULL and DISAPPEARS from a scoped operator's
-- dashboard the moment you scope a user. Do not skip Part C.
--
-- How to run: phpMyAdmin -> your DB -> "SQL" tab. MySQL (unlike MariaDB) has no
-- "ADD COLUMN IF NOT EXISTS": if a column/index already exists, that ONE
-- statement errors — skip it and run the rest.
-- ===========================================================================
-- ===========================================================================
-- PART A — schema changes (safe to run as-is)
-- ===========================================================================
-- A1) devices.site — the access-control site each device belongs to.
ALTER TABLE move_devices
ADD COLUMN site VARCHAR(64) NULL AFTER name,
ADD KEY ix_devices_site (site);
-- A2) events.site — denormalized from move_devices.site at ingest, for the dashboard's
-- site-scoped event list.
ALTER TABLE move_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 move_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 move_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 move_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 move_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 move_devices SET site = 'factory-a' WHERE device_id = 'pi-1';
-- UPDATE move_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 move_events e
JOIN move_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 move_users SET site_scope = 'factory-a' WHERE username = 'operator-a';
-- ---- Verification (run these; each must confirm the object/state) -----------
-- SHOW COLUMNS FROM move_devices LIKE 'site';
-- SHOW COLUMNS FROM move_events LIKE 'site';
-- SHOW COLUMNS FROM move_events LIKE 'event_uuid';
-- SHOW INDEX FROM move_events WHERE Key_name = 'uq_events_uuid';
-- SHOW INDEX FROM move_event_snapshots WHERE Key_name = 'ix_event_snapshots_created';
-- SHOW INDEX FROM move_event_snapshots WHERE Key_name = 'uq_event_snapshots_event_hash';
-- SHOW INDEX FROM move_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 move_events e JOIN move_devices d ON d.device_id = e.device_id
-- WHERE e.site IS NULL AND d.site IS NOT NULL;

View file

@ -0,0 +1,23 @@
-- cloud/migrations/2026-07-20-move-table-prefix.sql
-- Rename every P1-B table to the `move_` prefix (shared-hosting DB co-tenancy).
-- Run ONCE in phpMyAdmin BEFORE deploying code that reads the prefixed names.
-- Fresh installs: skip this, cloud/schema.sql already creates prefixed tables.
--
-- RENAME TABLE is atomic across all pairs and preserves indexes, constraints
-- and FKs (fk_event_snapshots_event follows its parent automatically).
RENAME TABLE
users TO move_users,
devices TO move_devices,
cameras TO move_cameras,
zones TO move_zones,
events TO move_events,
event_snapshots TO move_event_snapshots,
thresholds TO move_thresholds,
nonces TO move_nonces,
rate_limits TO move_rate_limits,
audit_log TO move_audit_log;
-- Zone-editor background frame: SELECT ... FROM move_event_snapshots JOIN move_events
-- WHERE e.camera_id = ? would otherwise full-scan an unbounded table on an interactive page.
ALTER TABLE move_events ADD INDEX ix_events_camera (camera_id, event_id);

View file

@ -0,0 +1,321 @@
/* ==================================================================
AI SAFETY CCTV — operations console.
Light, dense, professional. A safety board is calm most of the time and
urgent rarely: near-monochrome chrome, indigo reserved for interaction,
colour spent only on the unacknowledged-critical alarm. Density carries the
"professional product" weight — a two-column console (device/camera rail +
detection stream) with a compact KPI strip and snapshot-forward event rows.
Accessibility (NF9): state is never colour alone — a dot/led always pairs
with a text label. Air-gapped (폐쇄망): system fonts, zero external assets.
================================================================== */
:root {
color-scheme: light;
/* Surfaces — content is light; chrome (top bar + rail) is a second neutral
layer, a hair cooler and elevated with hairline borders. */
--bg: #eef1f5; /* app canvas (cool light) */
--panel: #ffffff; /* top bar + rail surface */
--panel-2: #f7f9fc; /* rail inset / hover fill */
--card: #ffffff; /* content rows/cards */
--line: #e3e7ee; /* hairline border */
--line-2: #edf0f5; /* faint divider */
/* Ink ramp — contrast-checked on white. ink-2 ≈ 8:1, ink-3 ≈ 4.6:1. */
--ink: #131824;
--ink-2: #48505e;
--ink-3: #6b7382;
/* Brand + interaction (indigo — only where you act: nav, focus, selection, primary). */
--accent: #4f46e5;
--accent-ink: #ffffff;
--accent-wash: #eceafe;
--accent-line: #d6d2fb;
/* Live monitoring (calm green, never an alarm). */
--ok: #15a34a;
--ok-wash: #e7f6ec;
/* Severity — reserved, semantic; identity always carries a text label too. */
--crit:#dc2626; --crit-ink:#a51d1d; --crit-bg:#fdf2f2; --crit-line:#f2cccc;
--high:#e2560c; --high-bg:#fdf4ee; --high-line:#f5d9c4;
--warn:#b7820a; --warn-bg:#fbf6e6;
--info:#5b6473;
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
--mono:ui-monospace,"Cascadia Mono","Cascadia Code",Consolas,"D2Coding","Roboto Mono",monospace;
--shadow-sm:0 1px 2px rgba(19,24,36,.05);
--shadow: 0 1px 2px rgba(19,24,36,.04), 0 10px 26px -18px rgba(19,24,36,.42);
--radius:12px;
/* z-scale (semantic, not arbitrary) */
--z-sticky:100; --z-dropdown:200; --z-toast:400;
}
* { box-sizing:border-box; }
html { -webkit-text-size-adjust:100%; }
body {
margin:0;
font-family:var(--font);
color:var(--ink);
background:var(--bg);
line-height:1.45;
font-variant-numeric:tabular-nums;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
/* ---- Top bar (chrome, second neutral layer) ---------------------- */
.app-header {
position:sticky; top:0; z-index:var(--z-sticky);
display:flex; align-items:center; gap:1.1rem;
padding:.6rem 1.25rem;
background:var(--panel);
border-bottom:1px solid var(--line);
box-shadow:var(--shadow-sm);
}
.app-brand { display:flex; align-items:center; gap:.6rem; text-decoration:none; color:inherit; }
.brand-mark {
display:grid; place-items:center; width:34px; height:34px; flex:none;
border-radius:9px; color:var(--accent);
background:linear-gradient(180deg,#f3f2ff,#e9e7fe);
border:1px solid var(--accent-line);
}
.brand-mark svg { width:20px; height:20px; display:block; }
.brand-text { display:flex; flex-direction:column; line-height:1.12; }
.brand-name { font-weight:750; font-size:.92rem; letter-spacing:.02em; color:var(--ink); }
.brand-tag { font-size:.68rem; color:var(--ink-3); letter-spacing:.01em; }
/* legacy hook from the old header — kept harmless */
.live-dot { display:none; }
.app-nav { display:flex; gap:.15rem; margin-left:.6rem; margin-right:auto; }
.app-nav a {
position:relative; color:var(--ink-2); text-decoration:none;
font-size:.86rem; font-weight:550; padding:.42rem .7rem; border-radius:8px;
transition:background .15s, color .15s;
}
.app-nav a:hover, .app-nav a:focus-visible { background:var(--panel-2); color:var(--ink); }
.app-nav a.is-active { color:var(--accent); background:var(--accent-wash); }
.app-session { display:flex; align-items:center; gap:.7rem; font-size:.84rem; color:var(--ink-2); }
.app-session .who { color:var(--ink); font-weight:550; display:inline-flex; align-items:center; gap:.4rem; }
.app-session .who::before {
content:""; width:22px; height:22px; border-radius:50%; flex:none;
background:radial-gradient(circle at 50% 38%, #fff 0 34%, transparent 35%), var(--accent-wash);
border:1px solid var(--accent-line);
}
.app-session a { color:var(--accent); text-decoration:none; font-weight:550; }
.app-session a:hover { text-decoration:underline; text-underline-offset:3px; }
/* ---- Controls: buttons, fields ----------------------------------- */
button, .btn {
font:inherit; font-size:.82rem; font-weight:550; cursor:pointer;
padding:.4rem .78rem; color:var(--ink); background:var(--card);
border:1px solid var(--line); border-radius:9px;
transition:transform .1s, background .15s, border-color .15s, box-shadow .15s;
}
button:hover, .btn:hover { background:var(--panel-2); border-color:#d3d9e2; }
button:active, .btn:active { transform:translateY(.5px) scale(.99); }
button:disabled { cursor:default; }
:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
input, select {
font:inherit; font-size:.86rem; color:var(--ink);
padding:.44rem .6rem; background:var(--card);
border:1px solid var(--line); border-radius:9px;
transition:border-color .15s, box-shadow .15s;
}
input::placeholder { color:var(--ink-3); }
input:focus, select:focus {
outline:none; border-color:var(--accent);
box-shadow:0 0 0 3px var(--accent-wash);
}
label { color:var(--ink-2); font-size:.84rem; }
/* ---- Page shell -------------------------------------------------- */
.app-main { max-width:1380px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
/* ---- Console: device/camera rail + detection stream -------------- */
.console { display:grid; grid-template-columns:264px minmax(0,1fr); gap:1.25rem; align-items:start; }
.rail {
position:sticky; top:calc(46px + 1.5rem);
display:flex; flex-direction:column; gap:1.1rem;
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
padding:1rem .9rem; box-shadow:var(--shadow-sm);
}
.rail-block { display:flex; flex-direction:column; gap:.6rem; }
.rail-block + .rail-block { padding-top:1rem; border-top:1px solid var(--line-2); }
.rail-title {
margin:0; font-size:.7rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
color:var(--ink-3);
}
.rail-filters { display:flex; flex-direction:column; gap:.6rem; }
.rail-filters .field { display:flex; flex-direction:column; gap:.3rem; font-size:.74rem; font-weight:600; color:var(--ink-3); letter-spacing:.02em; text-transform:uppercase; }
.rail-filters select { text-transform:none; font-size:.85rem; font-weight:400; letter-spacing:0; color:var(--ink); }
.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.85rem; color:var(--ink-2); cursor:pointer; user-select:none; }
.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
/* ---- Stream (main) ----------------------------------------------- */
.stream { display:flex; flex-direction:column; min-width:0; }
.stream-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.9rem; }
.stream-title { display:flex; align-items:baseline; gap:.7rem; }
h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; }
.page-head { display:flex; align-items:baseline; gap:.8rem; margin:0 0 .25rem; flex-wrap:wrap; }
.page-sub { color:var(--ink-3); font-size:.86rem; margin:.15rem 0 1.4rem; }
.section-label { font-size:.72rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; margin:0 0 .7rem; }
/* Live watch pill — green when polling, muted when stale; dot breathes while live. */
.watch { font-size:.8rem; font-weight:650; color:var(--ok); display:inline-flex; align-items:center; gap:.4rem; }
.watch::before { content:""; width:7px; height:7px; border-radius:50%; background:currentColor; flex:none; box-shadow:0 0 0 0 currentColor; animation:watch-pulse 2.4s ease-out infinite; }
.watch.is-stale { color:var(--ink-3); }
.watch.is-stale::before { animation:none; }
@keyframes watch-pulse { 0%{ box-shadow:0 0 0 0 rgba(21,163,74,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,163,74,0);} }
/* ---- KPI strip: compact unacknowledged counts -------------------- */
/* Computed client-side from the /api/events window (no extra endpoint). A tight
inline strip of pills, not four hero cards — dense, glanceable. Zero dims, never hides. */
#events-status.kpi-strip { display:flex; gap:.5rem; flex-wrap:wrap; }
.stat-tile {
display:inline-flex; align-items:center; gap:.5rem;
padding:.4rem .7rem; background:var(--card);
border:1px solid var(--line); border-radius:999px; box-shadow:var(--shadow-sm);
}
.stat-tile .stat-label { display:inline-flex; align-items:center; gap:.4rem; font-size:.76rem; font-weight:600; color:var(--ink-2); }
.stat-tile .stat-label .led { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dot,#c4c9d0); }
.stat-tile .stat-label .icon { display:none; }
.stat-tile .stat-count { font-size:1rem; font-weight:750; letter-spacing:-.02em; line-height:1; color:var(--ink); }
.stat-tile.is-zero { opacity:.55; }
.stat-tile.is-zero .stat-count { color:var(--ink-3); }
.tile-critical{ --dot:var(--crit);} .tile-high{ --dot:var(--high);} .tile-warning{ --dot:var(--warn);} .tile-info{ --dot:var(--info);}
.tile-critical:not(.is-zero) { background:var(--crit-bg); border-color:var(--crit-line); }
.tile-critical:not(.is-zero) .stat-count, .tile-critical:not(.is-zero) .stat-label { color:var(--crit-ink); }
/* ---- Device / camera tree (rail) --------------------------------- */
/* Heartbeat telemetry the edge reports (online/offline, fps, temp, throttle, drift).
A dead camera is a safety blind spot → an offline row takes the alarm red, with the
"오프라인/응답 없음" text carrying state alongside the dot (NF9). */
.device-strip { display:flex; flex-direction:column; gap:.4rem; }
.device-strip.is-empty { color:var(--ink-3); font-size:.82rem; padding:.3rem 0; }
.device-card {
background:var(--panel-2); border:1px solid var(--line); border-radius:10px;
padding:.55rem .65rem; transition:border-color .15s, background .15s;
}
.device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.85rem; color:var(--ink); }
.device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; box-shadow:0 0 0 3px var(--ok-wash); }
.device-card .device-meta { margin-top:.28rem; padding-left:.02rem; font-family:var(--mono); font-size:.72rem; color:var(--ink-3); }
.device-card .device-warn { margin-top:.32rem; font-size:.7rem; font-weight:650; color:var(--warn); }
.device-card.is-offline { background:var(--crit-bg); border-color:var(--crit-line); }
.device-card.is-offline .device-name { color:var(--crit-ink); }
.device-card.is-offline .device-name .dot { background:var(--crit); box-shadow:0 0 0 3px var(--crit-bg); }
.device-strip.is-stale { opacity:.6; }
.device-stale-note { font-size:.7rem; color:var(--ink-3); padding-bottom:.2rem; }
/* ---- Detection stream: event rows -------------------------------- */
.event-list { display:flex; flex-direction:column; gap:.5rem; }
.event-list[aria-busy="true"] .stream-empty { color:var(--ink-3); }
.stream-empty { padding:2.5rem 1rem; text-align:center; color:var(--ink-3); font-size:.9rem; }
.event-row {
display:flex; align-items:center; gap:.9rem;
background:var(--card); border:1px solid var(--line); border-radius:11px;
padding:.6rem .8rem; box-shadow:var(--shadow-sm);
transition:border-color .15s, box-shadow .15s, transform .1s;
}
.event-row:hover { border-color:#d3d9e2; box-shadow:var(--shadow); }
/* The one row that takes colour: an unacknowledged critical — full tint + border + a soft
attention glow (state, not decoration). Never a bare colour: badge + "확인 필요" chip too. */
.event-row.is-unacknowledged.sev-row-critical {
background:var(--crit-bg); border-color:var(--crit-line);
animation:crit-attention 2.6s ease-out infinite;
}
.event-row.is-unacknowledged.sev-row-high { background:var(--high-bg); border-color:var(--high-line); }
@keyframes crit-attention { 0%{ box-shadow:0 0 0 0 rgba(220,38,38,.18);} 70%,100%{ box-shadow:0 0 0 5px rgba(220,38,38,0);} }
/* Evidence slot — a camera still on the left of every row (aligned, CCTV-grid feel). */
.event-thumb-slot { position:relative; width:74px; height:52px; flex:none; border-radius:8px; border:1px solid var(--line); background:#0e1116; overflow:hidden; }
.event-thumb { width:100%; height:100%; object-fit:cover; display:block; }
/* No snapshot (none captured, or purged by PIPA retention): an empty frame, not a black feed. */
.event-thumb-slot.is-empty { border-style:dashed; border-color:#cdd3dc; background:var(--panel-2); }
.event-thumb-slot.is-empty::after { content:"무영상"; position:absolute; inset:0; display:grid; place-items:center; font-size:.62rem; color:var(--ink-3); }
.severity-badge { display:inline-flex; align-items:center; gap:.42rem; flex:none; min-width:58px; font-size:.8rem; font-weight:650; white-space:nowrap; }
.severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; flex:none; }
.sev-critical{ color:var(--crit);} .sev-high{ color:var(--high);} .sev-warning{ color:var(--warn);} .sev-info{ color:var(--info);}
.event-meta { flex:1; min-width:0; }
.event-type { font-weight:650; font-size:.95rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; }
.event-time { margin-top:.15rem; font-family:var(--mono); font-size:.75rem; color:var(--ink-3); }
.event-detail-link { color:inherit; text-decoration:none; }
.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }
.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
.ack-btn { margin-left:auto; flex:none; }
.ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.76rem; }
/* "new events arrived while paging" reload affordance — honest about a possible gap. */
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .7rem; padding:.5rem .75rem;
font-size:.8rem; color:var(--ink-2); background:var(--accent-wash); border:1px solid var(--accent-line); border-radius:9px; }
.page-gap-hint[hidden] { display:none; }
.page-gap-hint button { margin-left:auto; }
.list-more { display:flex; justify-content:center; margin-top:.8rem; }
.list-more button[hidden] { display:none; }
/* legacy list toolbar hooks (filters moved into the rail) */
.list-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.7rem; }
.list-filters { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
/* ---- Login ------------------------------------------------------- */
.login-form {
display:flex; flex-direction:column; gap:.7rem; max-width:360px; margin:4rem auto 0;
background:var(--card); border:1px solid var(--line); border-radius:16px; padding:2rem 1.9rem;
box-shadow:var(--shadow);
}
.login-form h1 { margin:.1rem 0 .5rem; font-size:1.3rem; }
.login-form label { display:flex; flex-direction:column; gap:.35rem; font-size:.8rem; font-weight:600; color:var(--ink-2); }
.login-form button { margin-top:.4rem; padding:.6rem; font-size:.9rem; background:var(--accent); color:var(--accent-ink); border-color:var(--accent); }
.login-form button:hover { background:#4239d6; border-color:#4239d6; }
.form-error { color:var(--crit); font-size:.85rem; }
/* ---- Zone editor ------------------------------------------------- */
.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; }
.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; }
#zone-canvas {
border:1px solid var(--line); border-radius:12px;
background:#0e1116 center / contain no-repeat; cursor:crosshair; max-width:100%;
box-shadow:var(--shadow);
}
#zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; }
#zone-list li {
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
max-width:380px; padding:.5rem .75rem;
background:var(--card); border:1px solid var(--line); border-radius:10px;
font-size:.85rem; color:var(--ink-2);
}
#zone-status { font-family:var(--mono); font-size:.82rem; color:var(--ink-3); }
/* ---- Incident detail -------------------------------------------- */
.detail-row { margin:.35rem 0; }
.detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; }
#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; }
.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:12px; background:#fff; padding:4px; box-shadow:var(--shadow); }
/* ---- Responsive: rail collapses above the stream ----------------- */
@media (max-width:860px) {
.console { grid-template-columns:1fr; }
.rail { position:static; }
.rail-block { flex-direction:column; }
.rail-filters { flex-direction:row; flex-wrap:wrap; align-items:end; }
.device-strip { flex-direction:row; flex-wrap:wrap; }
.device-card { flex:1 1 12rem; }
}
@media (max-width:560px) {
.app-main { padding:1rem .9rem 2.5rem; }
.brand-tag { display:none; }
.event-thumb { width:60px; height:42px; }
}
/* ---- Reduced motion ---------------------------------------------- */
@media (prefers-reduced-motion:reduce) {
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
}

562
server/public/assets/app.js Normal file
View file

@ -0,0 +1,562 @@
/**
* Events dashboard: polls GET /api/events every 5s, renders the list with
* severity text+icon+color (never color alone, per NF9), pins unacknowledged
* events to the top, and wires the acknowledge button to POST /events/{id}/ack.
*
* XSS-safe: all dynamic values are set via textContent, never innerHTML.
*/
(function () {
'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME. Every URL below must
// go through it — a bare '/api/...' would address the other site instead.
var B = window.APP_BASE || '';
var POLL_INTERVAL_MS = 5000;
var SEVERITY_LABELS = {
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔
high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠
warning: { icon: '△', text: '경고', cls: 'sev-warning' }, // △
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } // ℹ
};
var root = document.getElementById('events-root');
if (!root) return;
// Live-monitoring pill: honest about poll health — green "실시간 감시 중" while events
// are flowing, muted "연결 지연" when a poll fails, so a stale board never looks live.
var watchEl = document.getElementById('watch-status');
function setWatch(ok) {
if (!watchEl) return;
watchEl.textContent = ok ? '실시간 감시 중' : '연결 지연';
watchEl.classList.toggle('is-stale', !ok);
}
var csrfToken = null;
function loadCsrfToken() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data && data.token) csrfToken = data.token; })
.catch(function () { /* ack will fail with 403 and re-enable the button */ });
}
function severityInfo(sev) {
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
}
function formatTime(occurredAt) {
if (!occurredAt && occurredAt !== 0) return '';
var d = new Date(Number(occurredAt) * 1000);
if (isNaN(d.getTime())) return '';
return d.toLocaleString('ko-KR');
}
function buildBadge(sev) {
var info = severityInfo(sev);
var badge = document.createElement('span');
badge.className = 'severity-badge ' + info.cls;
var icon = document.createElement('span');
icon.className = 'icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = info.icon;
var label = document.createElement('span');
label.className = 'label';
label.textContent = info.text;
badge.appendChild(icon);
badge.appendChild(label);
return badge;
}
// --- desk audio alert: repeating beep while any unacknowledged critical exists ---
// No binary asset (air-gapped friendly): a WebAudio oscillator tone. The browser
// blocks audio until a user gesture, so the context is created on first click.
var audioCtx = null;
var alarmTimer = null;
var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately
function unlockAudio() {
if (!audioCtx) {
var AC = window.AudioContext || window.webkitAudioContext;
if (AC) audioCtx = new AC();
}
document.removeEventListener('click', unlockAudio);
}
function beep() {
if (!audioCtx) return;
try {
var osc = audioCtx.createOscillator();
var gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.value = 0.2;
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.4);
} catch (e) {
// audio unavailable; the visual list still surfaces the critical event
}
}
function updateAlarm(events) {
var hasUnackedCritical = false;
events.forEach(function (ev) {
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
if (ev.severity === 'critical' && !acked) {
hasUnackedCritical = true;
if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); }
}
});
if (hasUnackedCritical && !alarmTimer) {
alarmTimer = setInterval(beep, 3000);
} else if (!hasUnackedCritical && alarmTimer) {
clearInterval(alarmTimer);
alarmTimer = null;
}
}
function buildRow(ev) {
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
// sev-row-* drives the severity-colored left rail (and the critical glow) on
// unacknowledged rows. Constrain to the known tiers so the class can't carry an
// arbitrary server value into the DOM.
var sevRow = SEVERITY_LABELS.hasOwnProperty(ev.severity) ? ev.severity : 'info';
var row = document.createElement('div');
row.className = 'event-row sev-row-' + sevRow + (isAcked ? '' : ' is-unacknowledged');
// Evidence slot — EVERY row is a camera slot so rows stay aligned and the stream reads as a
// CCTV grid. When a snapshot exists it fills the slot; otherwise (or after a PIPA-retention
// purge) the slot shows an empty "무영상" frame instead of collapsing the row. The <img> goes
// through /snap/{uuid}, which re-checks session + site scope, so the uuid grants nothing.
var slot = document.createElement('div');
slot.className = 'event-thumb-slot';
if (ev.thumb_uuid) {
var thumb = document.createElement('img');
thumb.className = 'event-thumb';
thumb.src = B + '/snap/' + encodeURIComponent(ev.thumb_uuid);
thumb.alt = '스냅샷';
thumb.loading = 'lazy';
thumb.onerror = function () { slot.classList.add('is-empty'); if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
slot.appendChild(thumb);
} else {
slot.classList.add('is-empty');
}
row.appendChild(slot);
row.appendChild(buildBadge(ev.severity));
var meta = document.createElement('div');
meta.className = 'event-meta';
var type = document.createElement('div');
type.className = 'event-type';
var typeLink = document.createElement('a');
typeLink.className = 'event-detail-link';
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
typeLink.textContent = String(ev.event_type || '');
type.appendChild(typeLink);
// The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip —
// secondary encoding alongside the red tint, so it never rides on color alone.
if (!isAcked && ev.severity === 'critical') {
var chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = '확인 필요';
type.appendChild(chip);
}
meta.appendChild(type);
var sub = document.createElement('div');
sub.className = 'event-time';
var cam = ev.camera_id ? String(ev.camera_id) : '';
var time = formatTime(ev.occurred_at);
sub.textContent = [cam, time].filter(Boolean).join(' · ');
meta.appendChild(sub);
row.appendChild(meta);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ack-btn';
if (isAcked) {
btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
btn.disabled = true;
} else {
btn.textContent = '확인';
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
}
row.appendChild(btn);
return row;
}
function acknowledge(eventId, btn) {
btn.disabled = true;
fetch(B + '/events/' + encodeURIComponent(eventId) + '/ack', {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
})
.then(function (res) {
if (!res.ok) throw new Error('ack_failed:' + res.status);
// Reflect the ack on a "더 보기"-loaded (older-window) row too. loadEvents() only refreshes
// the live window, so without this an event acked from the paged tail snaps straight back
// to unacknowledged on the next render — inviting a duplicate ack that overwrites the
// original acknowledged_by audit record.
patchOlderAck(eventId);
return loadEvents();
})
.catch(function () {
btn.disabled = false;
});
}
function patchOlderAck(eventId) {
for (var i = 0; i < olderEvents.length; i++) {
var ev = olderEvents[i];
if (ev && ev.event_id === eventId && !isAcked(ev)) {
// New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
olderEvents[i] = Object.assign({}, ev, { acknowledged_by: window.APP_USER || '확인' });
}
}
}
// Status strip: count UNACKNOWLEDGED events per severity from the same array the
// list renders. Stat tiles (not a chart): small integers a desk operator scans.
// Every tier tile is always drawn (zero -> muted) so the strip never reflows.
var statusRoot = document.getElementById('events-status');
var STATUS_TIERS = [
{ key: 'critical', cls: 'tile-critical' },
{ key: 'high', cls: 'tile-high' },
{ key: 'warning', cls: 'tile-warning' },
{ key: 'info', cls: 'tile-info' }
];
function isAcked(ev) {
return ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
}
function renderStatus(events) {
if (!statusRoot) return;
var counts = { critical: 0, high: 0, warning: 0, info: 0 };
events.forEach(function (ev) {
if (isAcked(ev)) return;
var sev = counts.hasOwnProperty(ev.severity) ? ev.severity : 'info';
counts[sev] += 1;
});
statusRoot.textContent = '';
var frag = document.createDocumentFragment();
STATUS_TIERS.forEach(function (tier) {
var info = severityInfo(tier.key);
var n = counts[tier.key];
var tile = document.createElement('div');
tile.className = 'stat-tile ' + tier.cls + (n === 0 ? ' is-zero' : '');
var label = document.createElement('span');
label.className = 'stat-label';
var led = document.createElement('span');
led.className = 'led';
led.setAttribute('aria-hidden', 'true');
var icon = document.createElement('span');
icon.className = 'icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = info.icon;
var text = document.createElement('span');
text.textContent = '미확인 ' + info.text;
label.appendChild(led);
label.appendChild(icon);
label.appendChild(text);
var count = document.createElement('span');
count.className = 'stat-count';
count.textContent = String(n);
tile.appendChild(label);
tile.appendChild(count);
frag.appendChild(tile);
});
statusRoot.appendChild(frag);
}
// --- filter + pagination state ---------------------------------------------------
// Filters are CLIENT-SIDE (they narrow only the displayed list); the status strip and the
// audible alarm keep considering the full live set, so filtering the view can never silence a
// real unacknowledged critical. Pagination is SERVER-SIDE (keyset cursor): "더 보기" pulls
// events older than the live window into an accumulated tail that the 5s poll does not wipe.
var liveEvents = []; // newest window from the poll (drives status + alarm)
var olderEvents = []; // events pulled in by "더 보기", older than the live window
var moreCursor = null; // cursor for the NEXT "더 보기" (null = no older page)
var pagedByUser = false; // once true, polls stop resetting moreCursor to the live boundary
var newestIdAtPaging = null; // newest live event_id at the moment paging began (gap detection)
var pageGapHint = null; // lazily-built "new events arrived while paging" reload affordance
var filters = { severity: '', camera: '', unackedOnly: false };
var cameraSel = document.getElementById('camera-filter');
var moreBtn = document.getElementById('load-more');
function matchesFilters(ev) {
if (filters.severity && ev.severity !== filters.severity) return false;
if (filters.camera && String(ev.camera_id || '') !== filters.camera) return false;
if (filters.unackedOnly && isAcked(ev)) return false;
return true;
}
function combinedEvents() {
var seen = {}, out = [];
liveEvents.concat(olderEvents).forEach(function (ev) {
if (seen[ev.event_id]) return; // dedup: a poll may overlap the paged tail by an event
seen[ev.event_id] = true;
out.push(ev);
});
return out;
}
// Populate the camera dropdown from the camera_ids actually present, rebuilding only when that
// set changes so a poll never resets the operator's current selection.
function refreshCameraOptions(events) {
if (!cameraSel) return;
var cams = {};
events.forEach(function (ev) { if (ev.camera_id) cams[String(ev.camera_id)] = true; });
var list = Object.keys(cams).sort();
var want = list.join('|');
if (cameraSel.getAttribute('data-opts') === want) return;
cameraSel.setAttribute('data-opts', want);
var current = cameraSel.value;
cameraSel.textContent = '';
var all = document.createElement('option');
all.value = ''; all.textContent = '전체 카메라';
cameraSel.appendChild(all);
list.forEach(function (c) {
var o = document.createElement('option');
o.value = c; o.textContent = c;
cameraSel.appendChild(o);
});
// Preserve the operator's active camera filter even if that camera has no event in the current
// window. Silently resetting to "전체 카메라" floods the list with other cameras' events, which
// reads as the filtered camera's activity. Keep the selection as an option so the filter stays
// applied (the list then honestly shows "none" for a quiet camera).
if (current && !cams[current]) {
var keep = document.createElement('option');
keep.value = current; keep.textContent = current + ' (최근 이벤트 없음)';
cameraSel.appendChild(keep);
}
cameraSel.value = current; // always preserve the selection ('' = 전체 카메라 is preserved too)
}
function renderList() {
renderStatus(liveEvents); // counts always from the full live window, never the filtered view
updateAlarm(liveEvents); // alarm too — before any early return
var all = combinedEvents();
refreshCameraOptions(all);
var shown = all.filter(matchesFilters).sort(function (a, b) {
var aA = isAcked(a), bA = isAcked(b);
if (aA !== bA) return aA ? 1 : -1; // unacknowledged first
var at = Number(a.occurred_at) || 0, bt = Number(b.occurred_at) || 0;
if (at !== bt) return bt - at; // then newest first
return (b.event_id || 0) - (a.event_id || 0);
});
root.textContent = '';
root.removeAttribute('aria-busy');
if (shown.length === 0) {
var anyFilter = filters.severity || filters.camera || filters.unackedOnly;
root.textContent = anyFilter ? '조건에 맞는 이벤트가 없습니다.' : '표시할 이벤트가 없습니다.';
} else {
var frag = document.createDocumentFragment();
shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
root.appendChild(frag);
}
if (moreBtn) moreBtn.hidden = (moreCursor === null);
updatePageGapHint();
}
function updatePageGapHint() {
// A gap exists once new events have arrived since the operator paged: some events have slid out
// of the live window into a band that was never fetched. Surface it honestly with a reload,
// rather than let the operator scroll a timeline with a silent hole in the middle.
var gap = pagedByUser && liveEvents.length > 0 && newestIdAtPaging !== null
&& (liveEvents[0].event_id || 0) > newestIdAtPaging;
if (!pageGapHint) {
pageGapHint = document.createElement('div');
pageGapHint.className = 'page-gap-hint';
var span = document.createElement('span');
span.textContent = '페이징 이후 새 이벤트가 도착해 목록 일부가 밀렸을 수 있습니다.';
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '새로고침';
btn.addEventListener('click', function () { window.location.reload(); });
pageGapHint.appendChild(span);
pageGapHint.appendChild(btn);
if (root.parentNode) root.parentNode.insertBefore(pageGapHint, root); // sibling above the list, survives root clears
}
pageGapHint.hidden = !gap;
}
function loadEvents() {
// Newest window (server MAX_LIMIT=200). The audible alarm and the "unacknowledged first"
// pinning only consider this set, so it stays the full cap — during an event burst a smaller
// page could push an unacknowledged critical off it and updateAlarm() would clear the alarm
// while a 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'; return null; }
if (!res.ok) throw new Error('list_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (data && Array.isArray(data.events)) {
liveEvents = data.events;
// Track the live boundary as the next-page cursor only until the operator starts paging;
// after that "더 보기" advances moreCursor itself and a poll must not reset it.
if (!pagedByUser) moreCursor = data.next_cursor || null;
renderList();
}
setWatch(true);
})
.catch(function () {
// Leave the last rendered state on transient poll failure, but flag the staleness.
setWatch(false);
});
}
function loadMore() {
if (moreCursor === null) return;
if (moreBtn) { moreBtn.disabled = true; moreBtn.textContent = '불러오는 중...'; }
fetch(B + '/api/events?limit=200&cursor=' + encodeURIComponent(moreCursor), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('more_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (data && Array.isArray(data.events)) {
// Capture the newest live id at the moment paging begins. If newer events arrive after
// this, the live window (newest 200) slides forward and events between its new bottom and
// the frozen paged boundary belong to neither array — combinedEvents() drops them
// silently. We cannot re-fetch that middle band cheaply, so we at least SURFACE it: the
// gap hint (renderList) tells the operator to reload for the complete timeline.
if (!pagedByUser) newestIdAtPaging = (liveEvents[0] && liveEvents[0].event_id) || 0;
pagedByUser = true;
olderEvents = olderEvents.concat(data.events);
moreCursor = data.next_cursor || null;
renderList();
}
})
.catch(function () { /* leave state; the button re-enables below */ })
.then(function () {
if (moreBtn) { moreBtn.disabled = false; moreBtn.textContent = '더 보기'; }
});
}
// Logout is wired in the shared layout footer (src/View/layout.php) so it works on every page,
// not just this one — app.js bails early on pages without #events-root.
// --- device / camera health panel -------------------------------------------------
// GET /api/devices surfaces the heartbeat telemetry the edge already reports (online/offline,
// fps, temp, throttle, config drift). A dead camera is a safety blind spot, so this is the
// panel that turns "the board looks calm" into "the board is actually watching".
var devicesRoot = document.getElementById('devices-root');
function fmtAgo(secs) {
if (secs === null || secs === undefined) return '응답 없음';
if (secs < 60) return secs + '초 전';
if (secs < 3600) return Math.floor(secs / 60) + '분 전';
return Math.floor(secs / 3600) + '시간 전';
}
function renderDevices(devices) {
if (!devicesRoot) return;
devicesRoot.textContent = '';
if (!devices || devices.length === 0) {
devicesRoot.className = 'device-strip is-empty';
devicesRoot.textContent = '등록된 장치가 없습니다.';
return;
}
devicesRoot.className = 'device-strip';
var frag = document.createDocumentFragment();
devices.forEach(function (d) {
var card = document.createElement('div');
card.className = 'device-card' + (d.online ? '' : ' is-offline');
var name = document.createElement('div');
name.className = 'device-name';
var dot = document.createElement('span');
dot.className = 'dot';
dot.setAttribute('aria-hidden', 'true');
var nm = document.createElement('span');
nm.textContent = d.name || d.device_id; // never color alone: the "오프라인" text below carries state too
name.appendChild(dot);
name.appendChild(nm);
card.appendChild(name);
var meta = document.createElement('div');
meta.className = 'device-meta';
var parts = [];
if (d.online) parts.push(fmtAgo(d.seconds_since));
else if (d.seconds_since === null) parts.push('응답 없음');
else parts.push('오프라인 · ' + fmtAgo(d.seconds_since));
if (d.fps !== null && d.fps !== undefined) parts.push((Math.round(d.fps * 10) / 10) + ' fps');
if (d.temp !== null && d.temp !== undefined && d.temp > 0) parts.push(Math.round(d.temp) + '°');
meta.textContent = parts.join(' · ');
card.appendChild(meta);
if (d.throttle || d.config_drift) {
var warn = document.createElement('div');
warn.className = 'device-warn';
var w = [];
if (d.throttle) w.push('과열 스로틀');
if (d.config_drift) w.push('설정 미적용');
warn.textContent = w.join(' · ');
card.appendChild(warn);
}
frag.appendChild(card);
});
devicesRoot.appendChild(frag);
}
function markDevicesStale() {
// The device panel's whole job is a trustworthy online/offline verdict, so a silently frozen
// panel (poll 500 / WAF-blocked while events still flow) is worse than an empty one: a camera
// that dies afterwards keeps showing "온라인". Flag the staleness instead of hiding it. Cleared
// automatically on the next successful poll (renderDevices resets the class + note).
if (!devicesRoot) return;
devicesRoot.classList.add('is-stale');
if (!devicesRoot.querySelector('.device-stale-note')) {
var note = document.createElement('div');
note.className = 'device-stale-note';
note.textContent = '상태 갱신 지연 — 아래는 마지막으로 확인된 상태입니다';
devicesRoot.insertBefore(note, devicesRoot.firstChild);
}
}
function loadDevices() {
return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('devices_failed:' + res.status);
return res.json();
})
.then(function (data) { if (data && Array.isArray(data.devices)) renderDevices(data.devices); })
.catch(function () { markDevicesStale(); }); // keep the last state, but say it is stale
}
document.addEventListener('click', unlockAudio);
// Filter bar (client-side) + "더 보기" (server-side cursor).
var sevSel = document.getElementById('severity-filter');
if (sevSel) sevSel.addEventListener('change', function () { filters.severity = sevSel.value; renderList(); });
if (cameraSel) cameraSel.addEventListener('change', function () { filters.camera = cameraSel.value; renderList(); });
var unackedChk = document.getElementById('unacked-only');
if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); });
if (moreBtn) moreBtn.addEventListener('click', loadMore);
loadCsrfToken();
loadEvents();
loadDevices();
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
})();

View file

@ -10,6 +10,10 @@
(function () {
'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
var B = window.APP_BASE || '';
var SEVERITY_LABELS = {
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' },
high: { icon: '⚠', text: '높음', cls: 'sev-high' },
@ -97,7 +101,7 @@
snaps.forEach(function (s) {
if (!s || !s.file_uuid) return;
var img = document.createElement('img');
img.src = '/snap/' + encodeURIComponent(s.file_uuid);
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
img.alt = '사고 스냅샷';
img.className = 'detail-snapshot';
img.loading = 'lazy';
@ -105,9 +109,9 @@
});
}
fetch('/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return null; }
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('detail_failed:' + res.status);
return res.json();
})

View file

@ -8,6 +8,10 @@
(function () {
'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
var B = window.APP_BASE || '';
var form = document.querySelector('.login-form');
if (!form) return;
@ -26,7 +30,7 @@
var formData = new FormData(form);
var body = new URLSearchParams(formData).toString();
fetch('/login', {
fetch(B + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
@ -34,7 +38,7 @@
.then(function (res) {
if (res.status === 200) {
// Success: redirect to dashboard
window.location.href = '/';
window.location.href = B + '/';
return null;
}
// Error: parse response and show error message

View file

@ -4,15 +4,22 @@
* - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones.
* - "Load" fetches existing zones for the camera via GET /api/zones/{camera}
* and redraws them; each has a Delete button -> POST /api/zones/{id}/delete.
* - If a `?snapshot={uuid}` query param is present, that image (served via
* the authenticated /snap/{uuid} proxy) is drawn as the canvas background;
* otherwise the canvas keeps its plain dark backdrop.
* - "현재 화면 불러오기" fetches the camera's newest snapshot uuid via
* GET /api/cameras/{camera}/latest-snapshot and draws it as the canvas
* background, so zones are drawn over what the camera actually sees.
* - A `?snapshot={uuid}` query param does the same for one specific image
* (the event-detail page links here that way); otherwise the canvas keeps
* its plain dark backdrop.
*
* XSS-safe: all dynamic text is set via textContent, never innerHTML.
*/
(function () {
'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
var B = window.APP_BASE || '';
var canvas = document.getElementById('zone-canvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
@ -21,6 +28,7 @@
var cameraInput = document.getElementById('camera-id');
var levelSelect = document.getElementById('zone-level');
var loadBtn = document.getElementById('zone-load-btn');
var frameBtn = document.getElementById('zone-frame-btn');
var clearBtn = document.getElementById('zone-clear-btn');
var saveBtn = document.getElementById('zone-save-btn');
var listEl = document.getElementById('zone-list');
@ -42,16 +50,23 @@
}
function loadCsrfToken() {
return fetch('/api/csrf', { headers: { Accept: 'application/json' } })
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data && data.token) csrfToken = data.token; })
.catch(function () { /* save/delete will fail with 403 if the token is missing */ });
}
// WIRE CONTRACT: zone polygons are stored NORMALIZED (0..1 of the image), so the edge can
// compare them against a foot point normalized by the camera's own resolution regardless of
// how big the camera is. On this canvas we draw in canvas pixels, so convert at the two wire
// boundaries only: denormalize on load (here), normalize on save (saveZone).
function parsePolygon(zone) {
try {
var pts = JSON.parse(zone.polygon_json || '[]');
return Array.isArray(pts) ? pts : [];
if (!Array.isArray(pts)) return [];
return pts.map(function (p) {
return [p[0] * canvas.width, p[1] * canvas.height];
});
} catch (e) {
return [];
}
@ -116,16 +131,32 @@
var img = new Image();
img.onload = function () { backgroundImage = img; redraw(); };
img.onerror = function () { backgroundImage = null; redraw(); };
img.src = '/snap/' + encodeURIComponent(uuid);
img.src = B + '/snap/' + encodeURIComponent(uuid);
}
// Load the camera's most recent frame as the drawing background. The uuid comes from the
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
function loadLatestFrame() {
var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
setStatus('현재 화면을 불러오는 중...');
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
.then(function (r) {
if (r.status === 404) throw new Error('이 카메라의 저장된 화면이 아직 없습니다. 이벤트가 한 번 발생해야 배경이 생깁니다.');
if (!r.ok) throw new Error('불러오기 실패 (' + r.status + ')');
return r.json();
})
.then(function (data) { loadBackground(data.uuid); setStatus('현재 화면을 배경으로 불러왔습니다.'); })
.catch(function (e) { setStatus(e.message); });
}
function loadZones() {
var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
setStatus('불러오는 중...');
fetch('/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return null; }
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('load_failed:' + res.status);
return res.json();
})
@ -148,18 +179,22 @@
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
setStatus('저장 중...');
fetch('/api/zones', {
fetch(B + '/api/zones', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
body: JSON.stringify({
device_id: device,
camera_id: camera,
level: levelSelect.value,
polygon: currentPoints
// Normalize canvas pixels -> 0..1 of the image (the edge un-normalizes with the
// camera's own resolution). See the parsePolygon contract note above.
polygon: currentPoints.map(function (p) {
return [p[0] / canvas.width, p[1] / canvas.height];
})
})
})
.then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return null; }
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('save_failed:' + res.status);
return res.json();
})
@ -173,12 +208,12 @@
}
function deleteZone(zoneId) {
fetch('/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
fetch(B + '/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
})
.then(function (res) {
if (res.status === 401) { window.location.href = '/login'; return; }
if (res.status === 401) { window.location.href = B + '/login'; return; }
if (!res.ok) throw new Error('delete_failed:' + res.status);
loadZones();
})
@ -197,6 +232,7 @@
levelSelect.addEventListener('change', redraw);
loadBtn.addEventListener('click', loadZones);
if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
saveBtn.addEventListener('click', saveZone);

126
server/public/index.php Normal file
View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
// The app root — the directory holding config.php, src/, vendor/ and storage/ — is found by
// walking up from this file rather than hardcoded as '/..'. Its depth genuinely varies:
// one level up when the docroot is the app's own directory, but two when the app is mounted
// in a subdirectory of a www/ that already serves another site, since nothing secret may live
// under a docroot that other site also serves. On shared hosting the private files are kept
// together in a single APP_PRIVATE_DIR beside www/, so that name is probed at every level.
//
// Two deliberate choices guard against binding to the CO-HOSTED site's files:
// * the container is probed BEFORE the bare directory, so our own root always wins;
// * all three markers must be present. config.php + vendor/ alone are ordinary names in any
// PHP project sharing this account — src/Config.php is specific to this app.
const APP_PRIVATE_DIR = 'move_detect_app';
(static function (): void {
$isAppRoot = static fn (string $d): bool =>
is_file($d . '/config.php')
&& is_file($d . '/vendor/autoload.php')
&& is_file($d . '/src/Config.php');
for ($dir = dirname(__DIR__), $i = 0; $i < 4; $i++) {
foreach ([$dir . '/' . APP_PRIVATE_DIR, $dir] as $candidate) {
if ($isAppRoot($candidate)) {
define('APP_ROOT', $candidate);
return;
}
}
if (dirname($dir) === $dir) break; // filesystem root; dirname() would spin here
$dir = dirname($dir);
}
http_response_code(500);
exit('config.php / vendor/autoload.php / src/Config.php not found above ' . __DIR__);
})();
require APP_ROOT . '/vendor/autoload.php';
use App\Config;
use App\Http\{Request, Router, Response};
use App\Db\Database;
use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
PdoThresholdRepository, PdoAuditRepository};
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
DeviceStatusController};
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
// and convert any uncaught Throwable into a generic 500 that is logged, not shown. (A .user.ini
// in the docroot backstops this for errors raised before this file even parses.)
ini_set('display_errors', '0');
ini_set('log_errors', '1');
set_exception_handler(static function (\Throwable $e): void {
error_log('move-detect: ' . $e);
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
}
echo '{"error":"internal"}';
});
// The URL prefix this app is mounted under: '' at the web root, '/move_detect' in a
// subdirectory. Server-derived (SCRIPT_NAME), never client input. Views and the JS bundles
// prefix every link/fetch with it; without it they'd address the other site sharing www/.
define('APP_BASE', Request::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? '')));
$cfg = Config::fromArray(require APP_ROOT . '/config.php');
$pdo = Database::connect($cfg);
// Scope the session cookie to the mount, so a co-hosted site under the same www/ is never
// sent this app's session id.
$sess = new PhpSessionStore(APP_BASE . '/');
$now = time();
$devices = new PdoDeviceRepository($pdo);
$nonces = new PdoNonceRepository($pdo);
$rate = new PdoRateLimiterRepository($pdo);
$events = new PdoEventRepository($pdo);
$snaps = new PdoSnapshotRepository($pdo, $cfg->snapshotDir());
$users = new PdoUserRepository($pdo);
$zones = new PdoZoneRepository($pdo);
$thr = new PdoThresholdRepository($pdo);
$audit = new PdoAuditRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate);
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
$auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController();
$list = new EventListController($events, $snaps, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
$deviceStatus = new DeviceStatusController($devices, $sess);
$render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
return Response::text((string)ob_get_clean());
};
$r = new Router();
$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess));
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
// Background frame for the zone editor: the newest snapshot this camera produced.
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
$r->add('GET', '/', fn() => $render('events'));
$r->add('GET', '/login', fn() => $render('login'));
$r->add('GET', '/zones', fn() => $render('zones'));
$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail'));
$r->dispatch(Request::fromGlobals())->send();

View file

@ -36,7 +36,7 @@ SET NAMES utf8mb4;
-- No PdoUserRepository::create() exists in P1-B -- the first admin account is
-- provisioned out-of-band via the CLI snippet in cloud/docs/cloud-deploy.md.
-- ---------------------------------------------------------------------------
CREATE TABLE users (
CREATE TABLE move_users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(190) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- Argon2id, see App\Security\Password::hash()
@ -55,9 +55,10 @@ CREATE TABLE users (
-- No PdoDeviceRepository::create() exists -- rows are provisioned by an admin
-- (per-device secret issuance) before the device is allowed to sync/ingest.
-- ---------------------------------------------------------------------------
CREATE TABLE devices (
CREATE TABLE move_devices (
device_id VARCHAR(64) NOT NULL,
name VARCHAR(190) NULL,
site VARCHAR(64) NULL, -- access-control scope; matches users.site_scope (NULL = unassigned)
secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log
config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config
last_fps DOUBLE NULL,
@ -66,7 +67,8 @@ CREATE TABLE devices (
reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat
last_heartbeat_at BIGINT UNSIGNED NULL,
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
PRIMARY KEY (device_id)
PRIMARY KEY (device_id),
KEY ix_devices_site (site)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
@ -75,7 +77,7 @@ CREATE TABLE devices (
-- directly as a free-form string. Kept for documentation/future per-camera
-- config and to avoid a breaking migration when that repository is added.
-- ---------------------------------------------------------------------------
CREATE TABLE cameras (
CREATE TABLE move_cameras (
camera_id VARCHAR(64) NOT NULL,
device_id VARCHAR(64) NOT NULL,
name VARCHAR(190) NULL,
@ -92,7 +94,7 @@ CREATE TABLE cameras (
-- `cameras` row exists for a given camera_id (no camera-registration flow in
-- P1-B), so a FK here would spuriously reject valid app writes.
-- ---------------------------------------------------------------------------
CREATE TABLE zones (
CREATE TABLE move_zones (
zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids
device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NOT NULL,
@ -110,9 +112,11 @@ CREATE TABLE zones (
-- App\Ingest\EventValidator before insert(); the CHECK constraints below are
-- defense-in-depth only, not the primary validation layer.
-- ---------------------------------------------------------------------------
CREATE TABLE events (
CREATE TABLE move_events (
event_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_uuid CHAR(32) NULL, -- edge-generated idempotency key (dedup a re-sent event); NULL for legacy rows
device_id VARCHAR(64) NOT NULL,
site VARCHAR(64) NULL, -- denormalized from move_devices.site at ingest; access-control scope
camera_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
@ -125,9 +129,15 @@ CREATE TABLE events (
acknowledged_at BIGINT UNSIGNED NULL,
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
PRIMARY KEY (event_id),
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
-- but the ACK timed out client-side) collide instead of duplicating. MySQL allows multiple
-- NULLs in a UNIQUE index, so legacy rows without a uuid do not conflict.
UNIQUE KEY uq_events_uuid (event_uuid),
KEY ix_events_occurred_at (occurred_at),
KEY ix_events_severity_occurred_at (severity, occurred_at),
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
KEY ix_events_device (device_id),
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault'
@ -141,7 +151,7 @@ CREATE TABLE events (
-- events is safe because IngestController always inserts the parent event row
-- before calling SnapshotRepository::add() for its snapshots.
-- ---------------------------------------------------------------------------
CREATE TABLE event_snapshots (
CREATE TABLE move_event_snapshots (
snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id INT UNSIGNED NOT NULL,
file_uuid CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)); on-disk filename = <uuid>.jpg
@ -149,8 +159,13 @@ 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),
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (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 move_events (event_id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -159,7 +174,7 @@ CREATE TABLE event_snapshots (
-- GET /api/config. No PdoThresholdRepository write method exists in P1-B --
-- rows are seeded/edited directly (phpMyAdmin) until a management UI lands.
-- ---------------------------------------------------------------------------
CREATE TABLE thresholds (
CREATE TABLE move_thresholds (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NULL,
@ -180,11 +195,11 @@ CREATE TABLE thresholds (
-- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard,
-- not the earlier seen() check).
-- ---------------------------------------------------------------------------
CREATE TABLE nonces (
CREATE TABLE move_nonces (
nonce VARCHAR(190) NOT NULL,
expires_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (nonce),
KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM nonces WHERE expires_at < ?` cleanup job
KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM move_nonces WHERE expires_at < ?` cleanup job
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
@ -193,11 +208,14 @@ CREATE TABLE nonces (
-- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in
-- PdoRateLimiterRepository::hit() -- no read-modify-write race window.
-- ---------------------------------------------------------------------------
CREATE TABLE rate_limits (
CREATE TABLE move_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;
-- ---------------------------------------------------------------------------
@ -205,7 +223,7 @@ CREATE TABLE rate_limits (
-- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the
-- Task 16 brief self-review -- this table is the plain append-only record only.
-- ---------------------------------------------------------------------------
CREATE TABLE audit_log (
CREATE TABLE move_audit_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
actor VARCHAR(190) NOT NULL,
action VARCHAR(64) NOT NULL,
@ -217,3 +235,25 @@ CREATE TABLE audit_log (
KEY ix_audit_log_ts (ts),
KEY ix_audit_log_actor (actor)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===========================================================================
-- 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
-- cloud/migrations/2026-07-20-move-table-prefix.sql (run this one LAST)
--
-- The second migration renames every table to the `move_` prefix used above and
-- by App\Repository\Pdo\* -- an existing DB must be renamed before deploy or all
-- queries fail with "table doesn't exist".
--
-- It adds devices.site / events.site / events.event_uuid, the retention and
-- rate-limit reap indexes, AND backfills events.site from move_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 move_devices SET site = 'factory-a' WHERE device_id = 'pi-1';
-- UPDATE move_users SET site_scope = 'factory-a' WHERE username = 'operator-a';
-- A user with site_scope = NULL is a global operator; '' is NOT global (matches
-- nothing — a blank scope fails closed).
-- ===========================================================================

View file

@ -5,17 +5,23 @@ namespace App\Api;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository};
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
final class ConfigController
{
public function __construct(
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
private NonceRepository $nonces, private Config $cfg,
private ?RateLimiterRepository $rateLimiter = null,
) {}
public function get(Request $req, int $now): Response
public function get(Request $req, int $now, string $clientIp = ''): Response
{
// Pre-auth per-IP anti-flood, like /api/events — HMAC + DB reads on every hit.
if ($this->rateLimiter !== null && $clientIp !== ''
&& $this->rateLimiter->hit('ip:' . $clientIp, intdiv($now, 60)) > $this->cfg->ingestIpRateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$deviceId = (string)$req->query('device_id');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Api;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository};
final class HeartbeatController
{
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces,
private ?RateLimiterRepository $rateLimiter = null) {}
public function post(Request $req, int $now, string $clientIp = ''): Response
{
// Pre-auth per-IP anti-flood, like /api/events — an unauthenticated attacker must not
// be able to hammer this endpoint (each hit does HMAC + a DB write) unthrottled.
if ($this->rateLimiter !== null && $clientIp !== ''
&& $this->rateLimiter->hit('ip:' . $clientIp, intdiv($now, 60)) > $this->cfg->ingestIpRateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$b = $req->json();
$deviceId = (string)($b['device_id'] ?? '');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';
if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401);
}
if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
return Response::json(['error' => 'stale'], 401);
}
if ($nonce === '' || $this->nonces->seen($nonce)) {
return Response::json(['error' => 'replay'], 401);
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
// config_version is null when the device did not report one (older edge builds). Storing a
// fake 0 made the dashboard's config-drift check (reported !== published) fire on EVERY
// device forever, since published versions start at 1. Keep it null until a real version
// arrives so drift only ever means genuine drift.
$reportedVersion = isset($b['config_version']) ? (int)$b['config_version'] : null;
$this->devices->touchHeartbeat($deviceId, [
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
'throttle' => (bool)($b['throttle'] ?? false),
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
'at' => $now,
]);
return Response::json(['ok' => true], 200);
}
}

View file

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Auth;
use App\Http\{Request, Response};
use App\Security\Password;
use App\Security\{Password, Csrf};
use App\Repository\{UserRepository, AuditRepository, RateLimiterRepository};
final class AuthController
@ -28,6 +28,14 @@ final class AuthController
return Response::json(['error' => 'rate_limited'], 429);
}
// Login CSRF: the GET /login render seeds $_SESSION['csrf'] and embeds it as a hidden
// field; the POST must echo it back. Without this an attacker can silently log a victim
// into the attacker's account (session fixation via forged login). Checked before the
// user lookup so a forged request never triggers the (expensive) Argon2id verify.
if (!Csrf::check((string)$sess->get('csrf'), (string)$req->post('csrf'))) {
return Response::json(['error' => 'csrf'], 403);
}
$username = (string)$req->post('username');
$password = (string)$req->post('password');
$u = $this->users->byUsername($username);
@ -67,8 +75,11 @@ final class AuthController
public function logout(SessionStore $sess): Response
{
$sess->clear();
// Rotate the id FIRST (invalidates the old cookie) while the session is still active,
// then wipe. The old order called session_regenerate_id() AFTER session_destroy(),
// which warns on an inactive session and leaves the old cookie unreplaced.
$sess->regenerate();
$sess->clear();
return Response::json(['ok' => true], 200);
}
}

View file

@ -18,9 +18,15 @@ final class ArraySessionStore implements SessionStore {
}
final class PhpSessionStore implements SessionStore {
public function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) session_start([
'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax',
]); }
/** @param string $cookiePath Scope the cookie to the mount ('/' at the web root,
* '/move_detect/' in a subdirectory) so a co-hosted site
* sharing the same www/ is never sent this session id. */
public function __construct(string $cookiePath = '/') {
if (session_status() !== PHP_SESSION_ACTIVE) session_start([
'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax',
'cookie_path' => $cookiePath,
]);
}
public function get(string $k): mixed { return $_SESSION[$k] ?? null; }
public function set(string $k, mixed $v): void { $_SESSION[$k] = $v; }
public function clear(): void { $_SESSION = []; session_destroy(); }

View file

@ -16,4 +16,5 @@ final class Config
public function timestampWindow(): int { return (int)($this->a['timestamp_window'] ?? 300); }
public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); }
public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); }
public function snapshotRetentionDays(): int { return (int)($this->a['snapshot_retention_days'] ?? 7); }
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\DeviceRepository;
/**
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
* reports fps/temp/throttle/config_version via POST /api/heartbeat (touchHeartbeat); this only
* surfaces what is already stored, so it adds no new data pipeline. Session-guarded and
* site-scoped exactly like the event list: a scoped operator sees only their site's devices.
*
* The one derived field is liveness: a device is "online" if it sent a heartbeat within
* OFFLINE_AFTER_S. That is computed server-side against the request clock so a client with a
* wrong local time cannot mislabel a live camera as down (or a dead one as up) — a dead camera
* is a safety blind spot, so this verdict has to be trustworthy.
*/
final class DeviceStatusController
{
// ~2 minutes without a heartbeat = offline. The edge beats on a fixed 30s WALL-CLOCK cadence
// (daemon.run_loop heartbeat_interval_s), not an iteration count, so this is a 4x margin: three
// beats can be dropped (server hiccup, a failed POST) before a healthy device flaps offline,
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
private const OFFLINE_AFTER_S = 120;
public function __construct(private DeviceRepository $devices, private SessionStore $sess) {}
public function json(Request $req, int $now): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
// Same scoping rule as EventListController: only a genuinely null scope is global; an
// empty-string scope is a misconfiguration that must match nothing (fail closed).
$scope = $this->sess->get('site_scope');
$site = is_string($scope) ? $scope : null;
$devices = array_map(function (array $d) use ($now): array {
$hb = $d['last_heartbeat_at'];
$secondsSince = $hb !== null ? max(0, $now - $hb) : null;
$online = $secondsSince !== null && $secondsSince <= self::OFFLINE_AFTER_S;
return [
'device_id' => $d['device_id'],
'name' => $d['name'],
'online' => $online,
'seconds_since'=> $secondsSince, // null = never reported
'fps' => $d['last_fps'],
'temp' => $d['last_temp'],
'throttle' => $d['last_throttle'],
// The device is running an older config than the server has published for it.
'config_drift' => $d['reported_config_version'] !== null
&& $d['reported_config_version'] !== $d['config_version'],
];
}, $this->devices->listStatus($site));
return Response::json(['devices' => $devices], 200);
}
}

View file

@ -4,7 +4,7 @@ namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\Csrf;
use App\Security\{Csrf, SiteScope};
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
final class EventDetailController
@ -14,12 +14,20 @@ final class EventDetailController
private AuditRepository $audit, private SessionStore $sess
) {}
// A scoped operator must not read — or acknowledge — an event outside their site. Return
// 404 (not 403) so a cross-site id is indistinguishable from a non-existent one (no leak).
private function outOfScope(array $event): bool
{
$scope = $this->sess->get('site_scope');
return !SiteScope::allows(is_string($scope) ? $scope : null, $event['site'] ?? null);
}
public function show(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$id = (int)($params['id'] ?? 0);
$event = $this->events->find($id);
if ($event === null) return Response::json(['error' => 'not_found'], 404);
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200);
}
@ -31,6 +39,10 @@ final class EventDetailController
return Response::json(['error' => 'csrf'], 403);
}
$id = (int)($params['id'] ?? 0);
// Scope BEFORE the write: without the find()+guard a scoped user could acknowledge another
// site's event by guessing its id.
$event = $this->events->find($id);
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
$user = (string)$this->sess->get('username');
if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append($user, 'acknowledge', "event:$id", [], []);

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\{EventRepository, SnapshotRepository};
final class EventListController
{
private const DEFAULT_LIMIT = 50;
private const MAX_LIMIT = 200;
public function __construct(private EventRepository $events, private SnapshotRepository $snaps, private SessionStore $sess) {}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$severity = $req->query('severity');
$limit = self::clampLimit($req->query('limit'));
[$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 : null;
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site);
// Attach a thumbnail uuid (newest snapshot) per event, batched in one query — the list
// shows evidence images inline instead of only on the detail page. The image itself still
// goes through /snap/{uuid}, which re-checks session + site scope, so exposing the uuid
// here grants nothing the row's owner could not already fetch.
if ($rows !== []) {
$thumbs = $this->snaps->thumbUuidsFor(array_map(static fn ($r) => (int)$r['event_id'], $rows));
foreach ($rows as &$row) { $row['thumb_uuid'] = $thumbs[(int)$row['event_id']] ?? null; }
unset($row);
}
// Advertise a next cursor only on a full page — a short page means we hit the end.
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
$next = null;
if (count($rows) === $limit && $rows !== []) {
$last = $rows[count($rows) - 1];
// 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);
}
private static function clampLimit(mixed $raw): int
{
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_LIMIT;
return max(1, min(self::MAX_LIMIT, (int)$raw));
}
/** @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];
// Keep occurred_at as its exact numeric STRING. Casting to float here would round it to
// PHP's ~14 significant digits (the `precision` ini) on the way back into the query,
// shifting the keyset boundary so the page-edge row is duplicated or skipped. MySQL parses
// the string to the same DOUBLE the column holds, so string end-to-end is lossless.
return [$ts, (int)$id];
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Security\SiteScope;
use App\Repository\{SnapshotRepository, EventRepository};
final class SnapshotProxy
{
public function __construct(
private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess
) {}
public function serve(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$uuid = (string)($params['uuid'] ?? '');
$path = $this->snaps->pathByUuid($uuid);
if ($path === null || !is_file($path)) return Response::json(['error' => 'not_found'], 404);
// Site-scope (P3-2b): a snapshot inherits its owning event's site; a scoped operator must
// not fetch another site's worker image (PIPA). 404, not 403, so it can't be used to probe
// which uuids exist. Global operators skip the lookup.
$scope = $this->sess->get('site_scope');
if (!SiteScope::isGlobal(is_string($scope) ? $scope : null)) {
$eventId = $this->snaps->eventIdByUuid($uuid);
$event = $eventId !== null ? $this->events->find($eventId) : null;
if ($event === null || !SiteScope::allows((string)$scope, $event['site'] ?? null)) {
return Response::json(['error' => 'not_found'], 404);
}
}
// A snapshot is immutable per uuid, so let the browser cache it. Without this the event
// list — which rebuilds every <img src=/snap/{uuid}> on each 5s poll — re-downloads every
// visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open
// dashboard, tens of GB/day that can trip Cafe24 shared-hosting traffic caps. `private` so
// a shared proxy never caches one operator's site's worker image for another (PIPA).
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200,
['Cache-Control' => 'private, max-age=86400, immutable']);
}
}

View file

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\{Csrf, SiteScope};
use App\Geometry\Polygon;
use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotRepository, ZoneOwnershipException};
final class ZoneConfigController
{
private const LEVELS = ['warning','restricted','pinch'];
public function __construct(
private ZoneRepository $zones, private DeviceRepository $devices,
private AuditRepository $audit, private SessionStore $sess,
private SnapshotRepository $snapshots
) {}
private function guard(string $perm): ?Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), $perm)) return Response::json(['error' => 'forbidden'], 403);
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
{
// 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);
}
/** GET /api/cameras/{camera}/latest-snapshot -> {"uuid": "..."} or 404.
* The zone editor loads /snap/{uuid} as its canvas background so zones are drawn over the
* camera's actual view. Returns only the uuid: the image itself still goes through
* SnapshotProxy, which re-checks the session and the site scope before serving bytes. */
public function latestSnapshot(Request $req, array $params): Response
{
if ($g = $this->guard('view_events')) return $g;
$shot = $this->snapshots->latestForCamera((string)($params['camera'] ?? ''));
// 404 for out-of-scope as well as missing -- a scoped operator must not be able to
// tell "another site's camera" apart from "no such camera" (mirrors list()).
if ($shot === null || !$this->inScope((string)($shot['device_id'] ?? ''))) {
return Response::json(['error' => 'not_found'], 404);
}
return Response::json(['uuid' => $shot['file_uuid']], 200);
}
public function save(Request $req): Response
{
if ($g = $this->guard('edit_zones')) return $g;
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$b = $req->json();
$level = (string)($b['level'] ?? '');
$polygon = $b['polygon'] ?? [];
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'] ?? '');
// The zone MUST name a real device: it is delivered to the edge only via
// GET /api/config -> zones->forDevice($deviceId), so a zone saved with a blank or mistyped
// device_id is enforced by nobody while the dashboard still shows it as active (a silent
// safety hole). A global (null-scope) operator's inScope() check below passes for an unknown
// device (siteFor()->null, allows(null,null) true), so it cannot catch this — check existence
// explicitly. 400: it is a client input error, not a hidden/cross-site resource.
if ($deviceId === '' || !$this->devices->exists($deviceId)) {
return Response::json(['error' => 'unknown_device'], 400);
}
// 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' => $deviceId,
'camera_id' => (string)($b['camera_id'] ?? ''),
'level' => $level,
'polygon_json' => json_encode($polygon),
]);
} 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 — 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]);
return Response::json(['zone_id' => $zoneId], 201);
}
public function delete(Request $req, array $params): Response
{
if ($g = $this->guard('edit_zones')) return $g;
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$id = (string)($params['id'] ?? '');
// Resolve the zone and scope-check its owning device BEFORE deleting, so a scoped operator
// cannot delete another site's zone by id. Unknown id and cross-site id both return 404.
$existing = $this->zones->find($id);
if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) {
return Response::json(['error' => 'not_found'], 404);
}
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
return Response::json(['ok' => true], 200);
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http;
final class Request
{
private array $headers;
public function __construct(
private string $method, private string $path, array $headers,
private string $body, private array $query = [], private array $post = []
) {
$this->headers = [];
foreach ($headers as $k => $v) $this->headers[strtolower($k)] = $v;
}
public function method(): string { return $this->method; }
public function path(): string { return $this->path; }
public function header(string $name): ?string { return $this->headers[strtolower($name)] ?? null; }
public function body(): string { return $this->body; }
public function json(): array { $d = json_decode($this->body, true); return is_array($d) ? $d : []; }
public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; }
public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; }
/**
* The URL prefix the app is mounted under, derived from SCRIPT_NAME:
* '/index.php' => '' (web root), '/move_detect/index.php' => '/move_detect'.
*
* SCRIPT_NAME is set by the web server, never by the caller. Deriving the mount point
* from client-controlled input (a query param, a header) would let anyone relocate the
* app and route around the prefix.
*/
public static function baseFrom(string $scriptName): string
{
if ($scriptName === '' || $scriptName[0] !== '/') return '';
$dir = str_replace('\\', '/', dirname($scriptName));
return $dir === '/' || $dir === '.' ? '' : rtrim($dir, '/');
}
/**
* The route path for a REQUEST_URI: query string dropped, mount prefix removed.
* Routes are declared as '/login', '/api/events', ... so without this every route 404s
* whenever the app is mounted anywhere but the web root.
*/
public static function pathFor(string $requestUri, string $base): string
{
// Collapse leading slashes FIRST: parse_url('//api/events') reads 'api' as a
// scheme-relative host and returns '/events', silently dropping the first segment.
$requestUri = '/' . ltrim($requestUri, '/');
$path = parse_url($requestUri, PHP_URL_PATH) ?: '/';
if ($base !== '' && ($path === $base || str_starts_with($path, $base . '/'))) {
// Match $base exactly or as a whole segment. A bare str_starts_with($path, $base)
// would also strip '/move_detected/x' and route it as 'ed/x'.
$path = substr($path, strlen($base));
}
return $path === '' ? '/' : $path;
}
public static function fromGlobals(): self
{
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
$base = self::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? ''));
return new self(
$_SERVER['REQUEST_METHOD'] ?? 'GET',
self::pathFor((string)($_SERVER['REQUEST_URI'] ?? '/'), $base),
$headers, file_get_contents('php://input') ?: '', $_GET, $_POST
);
}
}

View file

@ -12,8 +12,9 @@ final class Response
public static function text(string $s, int $status = 200): self {
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
}
public static function bytes(string $b, string $contentType, int $status = 200): self {
return new self($status, ['Content-Type' => $contentType], $b);
public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self {
// Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden.
return new self($status, ['Content-Type' => $contentType] + $headers, $b);
}
public static function redirect(string $to): self {
return new self(302, ['Location' => $to], '');

View file

@ -15,7 +15,11 @@ final class Router
foreach ($this->routes as [$method, $regex, $handler]) {
if ($req->method() !== $method) continue;
if (preg_match($regex, $req->path(), $m)) {
$params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
// Decode captured params: a camera_id/zone_id with a space or Korean text is
// %-encoded in the URL, so without this GET /api/zones/{camera} never matches
// what POST /api/zones stored.
$params = array_map('rawurldecode',
array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY));
return $handler($req, $params);
}
}

View file

@ -31,10 +31,24 @@ final class EventValidator
if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id';
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
// it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the
// snapshot-uuid convention); a malformed key is a bug, not a silent pass.
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) {
$errors[] = 'bad:event_uuid';
}
// Length caps matching the DB columns (device_id/camera_id VARCHAR(64), zone_id
// CHAR(32)). Without these an overlong id is a MySQL 'Data too long' 500 that the edge
// re-queues and retries forever; a 400 lets it drop the poison event instead.
foreach (['device_id' => 64, 'camera_id' => 64, 'zone_id' => 32] as $k => $max) {
if (isset($p[$k]) && is_string($p[$k]) && mb_strlen($p[$k]) > $max) $errors[] = "too_long:$k";
}
if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []];
$clean = [
'event_uuid' => isset($p['event_uuid']) ? (string)$p['event_uuid'] : null,
'device_id' => (string)$p['device_id'],
'camera_id' => (string)$p['camera_id'],
'event_type' => (string)$p['event_type'],

View file

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Ingest;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
EventRepository, SnapshotRepository};
final class IngestController
{
public function __construct(
private Config $cfg,
private DeviceRepository $devices,
private NonceRepository $nonces,
private RateLimiterRepository $rateLimiter,
private EventRepository $events,
private SnapshotRepository $snapshots,
// Opportunistic retention runs here because Cafe24 shared hosting has no cron, and the
// edge POSTs to this route 24/7 — so this is the one path that fires automatically. The
// gate keeps it rare (a purge query per ~1/200 ingests); tests inject `fn() => true`.
private ?\Closure $purgeGate = null,
) {
$this->purgeGate ??= static fn (): bool => mt_rand(1, 200) === 1;
}
public function handle(Request $req, int $now, string $clientIp): Response
{
$payload = $req->json();
$deviceId = (string)($payload['device_id'] ?? '');
$window = intdiv($now, 60);
// Pre-auth IP limit (cheap, anti-flood): guards the endpoint before any
// HMAC/device-secret work, so an unauthenticated attacker can't burn
// a real device's own quota (see per-device limit below, moved post-auth).
if ($clientIp !== '' && $this->rateLimiter->hit('ip:' . $clientIp, $window) > $this->cfg->ingestIpRateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$nonce = $req->header('X-Nonce') ?? '';
$ts = $req->header('X-Timestamp') ?? '';
$sig = $req->header('X-Signature') ?? '';
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
if ($secret === null || $nonce === '' || $ts === '' || $sig === '') {
return Response::json(['error' => 'unauthorized'], 401);
}
if (!Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401);
}
if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
return Response::json(['error' => 'unauthorized'], 401);
}
if ($this->nonces->seen($nonce)) {
return Response::json(['error' => 'unauthorized'], 401);
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
// Post-auth per-device limit: only authenticated (HMAC-verified) requests
// count toward a device's quota, so an attacker guessing device_id can no
// longer 429-lock a real camera.
if ($this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) {
return Response::json(['error' => 'rate_limited'], 429);
}
$v = EventValidator::validate($payload);
if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);
$clean = $v['clean'];
// Site denormalization (P3-2b): the dashboard filters events by site for access control,
// so stamp the DEVICE's site onto the event here. Server-derived from the authenticated
// device_id — a client-supplied site is never trusted.
$clean['site'] = $this->devices->siteFor($deviceId);
// Idempotency (P1-9): a POST can succeed server-side but time out client-side; the edge
// then re-queues and re-sends the SAME event_uuid. Return the already-stored event
// instead of inserting a duplicate alert.
$uuid = $clean['event_uuid'] ?? null;
if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) {
return $this->dedupResponse($existing, $payload);
}
try {
$eventId = $this->events->insert($clean);
} catch (\PDOException $e) {
// 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 $this->dedupResponse($existing, $payload);
}
throw $e;
}
foreach (($payload['snapshots'] ?? []) as $b64) {
$this->storeSnapshot($eventId, (string)$b64);
}
// PIPA retention: purge worker snapshots older than the configured window. Runs rarely
// (the gate) and off the critical path — the event is already stored.
$days = $this->cfg->snapshotRetentionDays();
if ($days > 0 && ($this->purgeGate)()) {
$this->snapshots->purgeOlderThan($now - $days * 86400);
}
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
* swallowed) and, critically, a DB-index failure AFTER the file lands deletes the orphan
* JPEG so disk never accumulates evidence with no index row. A snapshot problem never fails
* the ingest — the event itself is already stored and the 201 stands.
*/
private function storeSnapshot(int $eventId, string $b64): void
{
$img = ImageProcessor::reencode($b64);
if (!$img['ok']) {
error_log("ingest: snapshot re-encode failed for event $eventId (malformed or oversized)");
return;
}
$path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg';
$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 {
$this->snapshots->add($eventId, $img['uuid'], $img['hash']);
} catch (\Throwable $e) {
@unlink($path);
error_log("ingest: snapshot index failed for event $eventId, removed orphan {$img['uuid']}: " . $e->getMessage());
}
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface DeviceRepository
{
public function secretFor(string $deviceId): ?string;
/** True if a device row with this id is provisioned. Distinct from siteFor(), which returns
* null for BOTH an unknown device and a known-but-unassigned one — a zone save needs to tell
* those apart so it never persists a zone against a device_id no device will ever fetch. */
public function exists(string $deviceId): bool;
/** The device's access-control site (devices.site), or null if unassigned/unknown. */
public function siteFor(string $deviceId): ?string;
public function touchHeartbeat(string $deviceId, array $meta): void;
public function configVersion(string $deviceId): int;
/** All devices with their health telemetry, for the dashboard's device-status panel.
* Site-scoped: a non-null $site returns only that site's devices (a scoped operator);
* null returns every device (a global operator). Each row:
* [device_id, name, site, last_fps, last_temp, last_throttle, config_version,
* reported_config_version, last_heartbeat_at]. Read-only. */
public function listStatus(?string $site): array;
}

Some files were not shown because too many files have changed in this diff Show more