Commit graph

115 commits

Author SHA1 Message Date
유창욱
b0d1241da1 feat(cloud): dashboard rows link to detail + desk Web Audio on critical 2026-07-12 11:49:07 +09:00
유창욱
55d99602ce feat(cloud): incident detail view renders snapshot images 2026-07-12 11:48:55 +09:00
유창욱
d67513fc0e feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap) 2026-07-12 11:13:28 +09:00
유창욱
581f337021 fix(edge): daemon periodic ops (sync/heartbeat/flush) can't crash the loop
Wrap sync_config, send_heartbeat, and queue.flush operations in separate
try/except to ensure daemon survives periodic operation exceptions. Critical
for unattended 24/7 pilot devices where a locked database or disk error would
otherwise kill the entire loop. Per-frame step() handling unchanged.
2026-07-12 11:07:27 +09:00
유창욱
6f54706476 feat(edge): runnable daemon loop (step + periodic sync/heartbeat/flush) 2026-07-12 11:00:08 +09:00
유창욱
92eb97c078 feat(edge): PipelineRuntime assembles components + hot-swaps zones 2026-07-12 10:54:16 +09:00
유창욱
2eb8d81e81 fix(edge): zones_from_config skips malformed rows without raising (IndexError/non-list guard) 2026-07-12 10:49:46 +09:00
유창욱
3cf702e440 feat(edge): parse cloud config zones/thresholds into Zone objects 2026-07-12 10:45:11 +09:00
유창욱
316d80e696 feat(edge): CloudClient for config poll + heartbeat (HMAC-signed) 2026-07-12 10:38:54 +09:00
유창욱
e1f9f91110 docs: P1 pilot integration plan (wire edge daemon + CPU pose + detail view)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-12 10:35:21 +09:00
유창욱
171ef3e1c5 fix(cloud): add nonce-replay + timestamp-window checks to config/heartbeat (M2)
ConfigController::get and HeartbeatController::post verified HMAC but never
checked nonce replay or timestamp freshness, so a captured signed request
(e.g. a replayed heartbeat) could be replayed forever, masking a dead
device. Inject NonceRepository (+Config) into both and add the same
timestamp-window + nonce-remember checks IngestController already applies,
after HMAC verification and before the response/side effect.
2026-07-12 09:19:32 +09:00
유창욱
3405c1e2cc fix(cloud): enforce CSRF on authenticated state-changing POSTs (M1)
Only SameSite=Lax protected the acknowledge and zone save/delete endpoints,
even though a Csrf helper and mintable token already existed. Add
GET /api/csrf (CsrfController::token) to mint/return a per-session token,
check it (auth -> rbac -> csrf -> mutate) in EventDetailController::acknowledge
and ZoneConfigController::save/delete, and wire app.js/zone-draw.js to fetch
the token on load and send it as X-Csrf-Token on every POST.
2026-07-12 09:16:38 +09:00
유창욱
238b932c86 fix(cloud): add per-IP rate limit to /login before Argon2id verify (H2)
Every login attempt, including unknown users (via DUMMY_HASH), forces a full
Argon2id verify with no rate limit, letting a flood take down the login
endpoint / shared host via hashing-cost amplification. Add a pre-auth
per-IP limiter (RateLimiterRepository, default 20/min) as the first check
in AuthController::login, before any user lookup or password hashing.
2026-07-12 09:11:47 +09:00
유창욱
7828f9c419 fix(cloud): move ingest per-device rate limit post-auth, add pre-auth IP limit (H1)
An unauthenticated attacker could guess a real device_id and 429-lock that
camera by hammering /api/events before HMAC verification ran, dropping its
accident events. Add a cheap pre-auth per-IP limiter (Config::ingestIpRateLimitPerMin,
default 300/min) that fires before any device-secret lookup, and move the
existing per-device limiter to run only after HMAC + timestamp + nonce checks
succeed, so only authenticated requests consume a device's own quota.
2026-07-12 09:09:31 +09:00
유창욱
c1fb22ee16 fix(cloud): deterministic event ordering + zone concurrent-delete detection
Self-review (Task 16) follow-up fixes:
- PdoEventRepository::recent(): add event_id DESC as a secondary sort key
  after occurred_at DESC so ties (equal device-reported timestamps) resolve
  deterministically, matching InMemoryEventRepository's insertion-order
  tie-break.
- PdoZoneRepository::updateOwned(): after the ownership-scoped UPDATE,
  disambiguate a 0-rowCount() result (MySQL reports "changed" rows, not
  "matched" rows) with a targeted existence check, so a genuine no-op
  resubmit (identical level/polygon) still succeeds while a zone deleted
  concurrently between the ownership check and the UPDATE now throws
  instead of save() silently reporting success for a zone that no longer
  exists.

Full suite still green: 59 tests, 114 assertions, 4 skipped, 0 failures,
0 warnings/deprecations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-12 02:06:32 +09:00
유창욱
9aa7f44ea4 feat(cloud): PDO repositories + schema + Cafe24 deploy doc
Implements Task 16 (final, P1-B): App\Db\Database::connect(Config): PDO
factory and the 9 App\Repository\Pdo\Pdo*Repository classes backing the
repository interfaces consumed by controllers since T1-T15, all-prepared
statements, plus cloud/schema.sql (MySQL 8 DDL) and cloud/docs/cloud-deploy.md.

Security hardening incorporated per accumulated task reviews:
- PdoNonceRepository: nonces.nonce is PK; duplicate remember() throws instead
  of racing seen() (closes T7 TOCTOU).
- PdoRateLimiterRepository::hit(): atomic INSERT ... ON DUPLICATE KEY UPDATE
  upsert on (rate_key, window_start), no read-modify-write race.
- PdoSnapshotRepository::pathByUuid(): validates 32-hex uuid before building
  any filesystem path (closes T12 path-traversal risk).
- PdoZoneRepository::save(): update path is ownership-scoped by
  (zone_id, device_id, camera_id); throws on mismatch instead of silently
  overwriting an unrelated zone (closes T13 cross-tenant overwrite risk).
- Database::connect(): ERRMODE_EXCEPTION, EMULATE_PREPARES=false, FETCH_ASSOC.

cloud/tests/PdoIntegrationTest.php round-trips Nonce/Event/Zone against a
real sqlite :memory: DB, gated via markTestSkipped when pdo_sqlite is
unavailable (this host has pdo_mysql only, no pdo_sqlite) -- suite stays
green with 4 tests skipped.

Full suite: 59 tests, 114 assertions, 4 skipped, 0 failures, 0
warnings/deprecations. php -l passes on all new files and on
cloud/public/index.php (T15), which references all new FQCNs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-12 01:58:05 +09:00
유창욱
81eae5d045 fix(cloud): login form submits via fetch (redirect on success, show error) 2026-07-12 01:39:06 +09:00
유창욱
81e8e81b72 feat(cloud): front controller wiring + views + zone-draw UI 2026-07-12 01:24:42 +09:00
유창욱
5135c08e18 feat(cloud): device config + heartbeat sync API (HMAC) 2026-07-12 01:13:02 +09:00
유창욱
92c630bf58 feat(cloud): polygon validation + zone config controller (RBAC) 2026-07-12 01:02:37 +09:00
유창욱
90d3016b17 feat(cloud): dashboard list/detail/ack + auth-gated snapshot proxy 2026-07-12 00:56:43 +09:00
유창욱
430758c3b7 feat(cloud): path-param router + dispatch 2026-07-12 00:49:52 +09:00
유창욱
f7a0a3740e test(cloud): cover unknown-username 401 parity + logout session clear 2026-07-12 00:46:39 +09:00
유창욱
902bbe93c8 fix(cloud): reset lockout on window expiry + constant-time unknown-user path (anti-enumeration) 2026-07-12 00:39:19 +09:00
유창욱
450c88978f fix(cloud): session-id regeneration on login (fixation) + pristine test output 2026-07-12 00:35:41 +09:00
유창욱
b345a89788 feat(cloud): session store + auth controller with lockout 2026-07-12 00:28:18 +09:00
유창욱
9983bc8fe3 feat(cloud): RBAC permission matrix 2026-07-12 00:19:47 +09:00
유창욱
76ea1b4ecf feat(cloud): argon2id password + CSRF token helpers 2026-07-12 00:11:20 +09:00
유창욱
185629a4c4 fix(cloud): unify ingest auth errors (anti-enumeration) + guard snapshot write 2026-07-12 00:07:42 +09:00
유창욱
57cfc64548 feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+persist) 2026-07-12 00:01:05 +09:00
유창욱
a0db1e06ca feat(cloud): Http Request/Response primitives 2026-07-11 23:54:58 +09:00
유창욱
7246e12c4a feat(cloud): gd image re-encode (strip payload, uuid, hash) 2026-07-11 23:50:08 +09:00
유창욱
2f88355071 fix(cloud): type-guard EventValidator fields against malformed input 2026-07-11 23:46:38 +09:00
유창욱
b5388b2a68 feat(cloud): event payload validator with enum whitelist 2026-07-11 23:40:53 +09:00
유창욱
4bbd28f95f fix(cloud): centralize test-double autoloading via composer files
Move InMemoryRepositories.php from require_once workaround to composer
files-autoload. Enables PSR-4 autoloading of test-double classes and
scales for 5+ future test files. All 8 tests pass with require_once removed.
2026-07-11 23:38:01 +09:00
유창욱
67b9999a31 feat(cloud): repository interfaces + in-memory test doubles 2026-07-11 23:32:16 +09:00
유창욱
6fbe4015dc test(cloud): golden HMAC vectors proving P1-A Python interop 2026-07-11 23:25:15 +09:00
유창욱
8e62304471 feat(cloud): HMAC sign/verify matching P1-A edge contract 2026-07-11 23:21:00 +09:00
유창욱
50cb2cfe82 feat(cloud): P1-B scaffold — composer autoload + Config 2026-07-11 23:16:13 +09:00
유창욱
65344f6ea6 docs: P1-B cloud ingest + dashboard implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-11 23:12:04 +09:00
유창욱
e81ea8961c refactor(edge): DRY displacement helper into geometry.track_displacement (FIX 6)
_displacement (mean per-keypoint delta between the last two track frames,
normalized by bbox height) was duplicated verbatim in
edge/detectors/fall.py and edge/detectors/immobility.py.

Move it to edge/geometry.py as track_displacement(track) and have both
detectors call the shared helper instead of their own private copy.
Pure refactor — behavior is identical (byte-identical formula), no
config or public API changes.

Tests: tests/test_geometry.py adds direct coverage for the new public
helper (fewer-than-two-frames default, and the normalized-by-height
calculation). Existing fall/immobility tests are unchanged and stay green,
confirming behavior parity.
2026-07-11 22:21:27 +09:00
유창욱
752aabaff1 fix(edge): forget per-track state on lost tracks (FIX 5)
fall._states, immobility._still_since/_fired, zone._inside_since/_fired,
and engine._last_emit were never pruned when a track was lost (ghost
already pruned via on_lost). On a 24/7 edge device this is unbounded
growth as track IDs keep incrementing.

Add forget(track_id) to FallDetector, ImmobilityWatchdog, and
ZoneIntrusionDetector (drops that track's per-track keys/sets).
EventEngine.forget(track_id) calls each sub-detector's forget and removes
_last_emit cooldown keys whose tuple's second element is that track_id
(covers FALL/NO_MOTION/IMMOBILITY/zone/GHOST — GHOST keys are
(GHOST, track_id) after FIX 4). EventEngine.process now calls
self.forget(tr.track_id) for every track in lost_tracks after emitting,
since ghost.on_lost already consumed that track's ghost-relevant state at
the top of process().

app.py run_once already calls pool.forget(tr.track_id) for lost tracks as
part of FIX 1, completing the cleanup chain (smoother pool + detector
state + dedup cache).

Tests: tests/test_engine.py adds test_forget_prunes_state_on_lost —
fires a FALL event, then passes the track as lost, and asserts
engine._last_emit no longer has that track's keys and fall._states no
longer has the track id.
2026-07-11 22:19:39 +09:00
유창욱
f7266f5980 fix(edge): identity-aware GHOST dedup keyed on track_id (FIX 4)
edge/engine.py keyed the ghost cooldown as
(GHOST, round(x), round(y)) — purely spatial, ignoring track identity.
Two distinct workers collapsing at ~the same pixel within the cooldown
window would suppress each other's ghost event, potentially masking a
second CRITICAL incident.

edge/detectors/ghost.py GhostIncidentDetector.update now includes the
candidate's track_id in each emitted dict. edge/engine.py keys the
cooldown as (EventType.GHOST, track_id) and sets the emitted Event's
track_id accordingly, matching how FALL/NO_MOTION/IMMOBILITY/zone events
are already keyed per-track.

Tests: tests/test_ghost.py asserts ghosts[0]["track_id"] == 1 on the
existing reappear-timeout test.
2026-07-11 22:17:48 +09:00
유창욱
f46ed40088 fix(edge): populate Event.duration_s from detector metrics (FIX 3)
edge/engine.py _mk hardcoded duration_s=0.0 for every event. The §7.3
wire contract / P1-B dashboard expects the real incident duration for
no_motion, immobility, and ghost events.

_mk now derives duration_s from the metrics dict: immobility_s (fall
no_motion, immobility watchdog) or waited_s (ghost), falling back to 0.0
for instantaneous confirms (fall, zone).

Tests: tests/test_engine.py adds test_no_motion_event_carries_duration,
driving a track to NO_MOTION and asserting the emitted event's duration_s
is > 0 and equals metrics["immobility_s"].
2026-07-11 22:16:44 +09:00
유창욱
3240428380 fix(edge): require sustained non-prone before FallDetector recover (FIX 2)
edge/detectors/fall.py update(): a single frame where prone was false while
state was in {FALLING, FALLEN, NO_MOTION} immediately emitted "recover" and
reset _State. One jittery frame (a momentary pose-estimation miss) could
wipe an in-progress CRITICAL no_motion incident.

Add FallConfig.recover_grace_s (default 1.0s) and _State.not_prone_since.
On the first non-prone frame while in an incident state, start the grace
timer instead of recovering immediately; only reset to STANDING once
recover_grace_s of sustained non-prone has elapsed. A re-prone frame while
in an incident state clears the pending grace timer (cancels the recover
attempt). The non-incident STANDING path is unchanged.

BEHAVIOR CHANGE: recover now requires recover_grace_s of sustained
non-prone instead of firing on a single frame.

Tests: test_recovery_clears_state (tests/test_fall.py) strengthened per
review brief to assert a single standing frame does NOT recover but
sustained standing across the grace window does. Added
test_single_noisy_nonprone_frame_does_not_cancel_no_motion covering the
life-safety scenario: one noisy non-prone frame during NO_MOTION must not
cancel the incident.
2026-07-11 22:15:47 +09:00
유창욱
01c7f4636e fix(edge): smooth keypoints per-track after tracking, not before (FIX 1)
edge/app.py run_once used ONE shared KeypointSmoother across all persons
BEFORE tracking assigned IDs. The smoother holds per-joint-index One-Euro
filter state, so with >1 person in frame the filter state was shared and
corrupted across different people, and same-timestamp calls within a frame
would pass through unsmoothed while still clobbering the shared state.
Multi-person scenes could get corrupted torso angles -> missed/false falls.

Add TrackSmootherPool (edge/smoothing.py) that lazily allocates one
KeypointSmoother per track_id. run_once now: tracks raw keypoints first,
then smooths each track's latest frame in place using its own pooled
smoother, and forgets a track's smoother when the track is lost (ties into
FIX 5 lost-track cleanup).

BEHAVIOR CHANGE: keypoints are now smoothed after tracking instead of
before. Single-person pipelines are unaffected (still one smoother, one
identity). Multi-person pipelines get correct, non-cross-contaminated
smoothing.

run_once signature: smoother param replaced with pool: TrackSmootherPool.
build_pipeline now constructs "pool" instead of "smoother".

Tests: tests/test_smoothing.py adds TrackSmootherPool identity/forget
coverage; tests/test_app.py adds test_two_persons_get_independent_smoothing
demonstrating each track's smoothed keypoints stay on its own side instead
of being pulled toward the other person.
2026-07-11 22:14:03 +09:00
유창욱
2ac26d4091 feat(edge): hardware adapter stubs + Pi bring-up/benchmark doc 2026-07-11 21:54:24 +09:00
유창욱
b621627863 feat(edge): main-loop wiring (run_once) + pipeline factory 2026-07-11 21:46:43 +09:00
유창욱
e74cc7f898 test(edge): cover ghost/zone env overrides and config defaults 2026-07-11 21:43:35 +09:00
유창욱
a63a5918cc feat(edge): env-based Config for detectors and transport 2026-07-11 21:40:06 +09:00