diff --git a/cloud/public/assets/app.css b/cloud/public/assets/app.css
index 733ec6d..1ed9ca5 100644
--- a/cloud/public/assets/app.css
+++ b/cloud/public/assets/app.css
@@ -84,3 +84,14 @@ button:hover { background: #f3f4f6; }
.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; }
diff --git a/cloud/public/assets/app.js b/cloud/public/assets/app.js
index f787fa9..ade6666 100644
--- a/cloud/public/assets/app.js
+++ b/cloud/public/assets/app.js
@@ -55,6 +55,55 @@
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 !== '';
@@ -68,7 +117,11 @@
var type = document.createElement('div');
type.className = 'event-type';
- type.textContent = String(ev.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');
@@ -118,6 +171,8 @@
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) {
@@ -154,6 +209,8 @@
});
}
+ document.addEventListener('click', unlockAudio);
+
loadCsrfToken();
loadEvents();
setInterval(loadEvents, POLL_INTERVAL_MS);
diff --git a/cloud/public/assets/detail.js b/cloud/public/assets/detail.js
new file mode 100644
index 0000000..58f5145
--- /dev/null
+++ b/cloud/public/assets/detail.js
@@ -0,0 +1,119 @@
+/**
+ * Incident detail view: parses the event id from /events/{id}/view, fetches
+ * GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
+ * plus each snapshot as an
.
+ *
+ * XSS-safe: every dynamic value is written via textContent or a validated URL
+ * attribute, never innerHTML. Severity is shown as icon + text (never color
+ * alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
+ */
+(function () {
+ 'use strict';
+
+ 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 meta = document.getElementById('event-detail-meta');
+ var snapBox = document.getElementById('event-detail-snapshots');
+ if (!meta) return;
+
+ var m = location.pathname.match(/\/events\/(\d+)\/view/);
+ if (!m) {
+ meta.textContent = '잘못된 주소입니다.';
+ meta.removeAttribute('aria-busy');
+ return;
+ }
+ var id = m[1];
+
+ 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;
+ }
+
+ function textNode(value) {
+ var span = document.createElement('span');
+ span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
+ return span;
+ }
+
+ function addRow(label, valueNode) {
+ var row = document.createElement('div');
+ row.className = 'detail-row';
+ var k = document.createElement('span');
+ k.className = 'detail-label';
+ k.textContent = label + ': ';
+ row.appendChild(k);
+ row.appendChild(valueNode);
+ meta.appendChild(row);
+ }
+
+ function render(data) {
+ var ev = data.event || {};
+ meta.textContent = '';
+ meta.removeAttribute('aria-busy');
+
+ addRow('심각도', buildBadge(ev.severity));
+ addRow('유형', textNode(ev.event_type));
+ addRow('카메라', textNode(ev.camera_id));
+ addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
+ addRow('지속(초)', textNode(ev.duration_s));
+ addRow('확인', textNode(ev.acknowledged_by || '미확인'));
+
+ if (!snapBox) return;
+ snapBox.textContent = '';
+ var snaps = data.snapshots || [];
+ if (snaps.length === 0) {
+ var none = document.createElement('p');
+ none.textContent = '스냅샷이 없습니다.';
+ snapBox.appendChild(none);
+ return;
+ }
+ snaps.forEach(function (s) {
+ if (!s || !s.file_uuid) return;
+ var img = document.createElement('img');
+ img.src = '/snap/' + encodeURIComponent(s.file_uuid);
+ img.alt = '사고 스냅샷';
+ img.className = 'detail-snapshot';
+ img.loading = 'lazy';
+ snapBox.appendChild(img);
+ });
+ }
+
+ fetch('/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
+ .then(function (res) {
+ if (res.status === 401) { window.location.href = '/login'; return null; }
+ if (!res.ok) throw new Error('detail_failed:' + res.status);
+ return res.json();
+ })
+ .then(function (data) { if (data) render(data); })
+ .catch(function () {
+ meta.textContent = '불러오기 실패';
+ meta.removeAttribute('aria-busy');
+ });
+})();
diff --git a/cloud/public/index.php b/cloud/public/index.php
index bf653ac..6e4157e 100644
--- a/cloud/public/index.php
+++ b/cloud/public/index.php
@@ -61,5 +61,6 @@ $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();
diff --git a/cloud/src/View/event_detail.php b/cloud/src/View/event_detail.php
index 952d4aa..864f5bb 100644
--- a/cloud/src/View/event_detail.php
+++ b/cloud/src/View/event_detail.php
@@ -3,6 +3,9 @@ require __DIR__ . '/layout.php';
render_header('이벤트 상세');
?>
이벤트 상세
-불러오는 중...
+불러오는 중...
+
+← 목록으로
+
assertSame(200, $r->dispatch(new Request('GET', '/events/9', [], '', [], []))->status());
$this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status());
$this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->status());
+
+ // The 3-segment view route must resolve to itself, NOT be swallowed by the
+ // 2-segment /events/{id} JSON route (they are registered in that order).
+ $twoSeg = $r->dispatch(new Request('GET', '/events/9', [], '', [], []));
+ $this->assertSame('/events/{id}', json_decode($twoSeg->body(), true)['route']);
+ $viewSeg = $r->dispatch(new Request('GET', '/events/9/view', [], '', [], []));
+ $this->assertSame(200, $viewSeg->status());
+ $this->assertSame('/events/{id}/view', json_decode($viewSeg->body(), true)['route']);
}
}
diff --git a/docs/pilot-runbook.md b/docs/pilot-runbook.md
new file mode 100644
index 0000000..b6c21cc
--- /dev/null
+++ b/docs/pilot-runbook.md
@@ -0,0 +1,112 @@
+# P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
+
+이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다.
+실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
+
+- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
+- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
+
+---
+
+## 1. 빌드 머신 준비 (모델 · onnxruntime)
+
+> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서
+> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다.
+
+```bash
+pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들)
+python -m edge.tools.fetch_movenet # MoveNet SinglePose Lightning ONNX -> models/movenet_lightning.onnx
+```
+
+`fetch_movenet.py`의 URL은 빌드 편의용이다. 실제 내려받은 모델의 입력 크기/자료형과 출력 레이아웃
+`[1,1,17,3]`(y,x,score)를 어댑터 기대값과 대조하라(다르면 `MoveNetPoseAdapter(input_size=...)` 조정;
+COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.py`(fake session)로 고정돼 있고,
+실모델 경로는 `tests/hardware/test_movenet_smoke.py`가 모델이 번들됐을 때만 게이트로 검증한다.
+
+---
+
+## 2. 클라우드 로컬 기동
+
+로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
+[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만.
+
+1. DB 생성 후 스키마 임포트: `mysql < cloud/schema.sql` (10개 테이블).
+2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
+ (`snapshot_dir`는 쓰기 가능 경로로).
+3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
+ CLI 스니펫과 SQL은 cloud-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`
+ - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
+ `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조.
+
+---
+
+## 3. 엣지 환경변수
+
+`edge/config.py`(`load_config`) + `edge/daemon.py`(`main`)가 읽는 실제 키:
+
+| env | 의미 | 예시 |
+|-----|------|------|
+| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` |
+| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
+| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` |
+| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
+| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
+| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
+| `TARGET_FPS` | 프레임 간격 = 1/fps | `12` |
+| `QUEUE_DB` | 오프라인 큐 sqlite 경로 | `queue.db` |
+
+감지 임계값(선택, 데모 단축용): `FALL_PRONE_S`(기본 3.0), `FALL_NO_MOTION_S`(10.0),
+`IMMOBILE_S`(120.0), `GHOST_REAPPEAR_S`(10.0), `ZONE_DEBOUNCE_S`(0.75), `COOLDOWN_S`(30.0).
+
+```bash
+export DEVICE_ID=pi-01 DEVICE_SECRET='<시크릿>' CAFE24_URL=http://localhost:8000 \
+ CAMERA_ID=cam-01 MOVENET_ONNX=models/movenet_lightning.onnx UVC_DEVICE=0 TARGET_FPS=12
+python -m edge # 또는 move-detect-edge
+```
+
+---
+
+## 4. 데모 시나리오
+
+1. **낙상:** 웹캠 앞에서 눕는다 → `FALL_PRONE_S`(기본 3초) 후 `fall`(high) 이벤트.
+2. **위급 상승:** 계속 안 움직이면 `FALL_NO_MOTION_S`(기본 10초) 후 `no_motion`(critical)
+ → 대시보드에서 **데스크 비프 반복**(첫 클릭으로 오디오 언락 필요).
+3. **가시화:** 목록의 이벤트 유형(링크) 클릭 → `/events/{id}/view` 상세 → **스냅샷 이미지** 확인.
+4. **응답:** 상세/목록에서 **확인** → 미확인 critical이 사라지면 비프 정지.
+5. **역루프(구역):** 대시보드 `구역 설정`에서 폴리곤 저장 → 발끝 진입 → `zone_*` 이벤트.
+ 구역 변경은 엣지의 **다음 config 폴링** 때 적용된다 — 기본 cadence `sync_every=150` 프레임
+ ≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것.
+6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 →
+ 복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송.
+7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명).
+
+> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로
+> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다.
+
+---
+
+## 5. 검증(테스트)
+
+```bash
+pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
+cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings)
+```
+
+모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —
+`tests/hardware/test_movenet_smoke.py`가 skip 해제돼 실행된다.
+
+---
+
+## 6. 파일럿 한계 (명시)
+
+- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속.
+- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2.
+- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요).
+- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save`
+ 패키징은 별도 후속 태스크.
+- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3).
diff --git a/edge/__main__.py b/edge/__main__.py
new file mode 100644
index 0000000..8744750
--- /dev/null
+++ b/edge/__main__.py
@@ -0,0 +1,4 @@
+from edge.daemon import main
+
+if __name__ == "__main__":
+ main()
diff --git a/edge/adapters/movenet_pose.py b/edge/adapters/movenet_pose.py
new file mode 100644
index 0000000..5e50f1e
--- /dev/null
+++ b/edge/adapters/movenet_pose.py
@@ -0,0 +1,34 @@
+import cv2
+import numpy as np
+
+from edge.models import Person, BBox, Keypoint
+
+
+class MoveNetPoseAdapter:
+ """MoveNet SinglePose Lightning (COCO-17) via onnxruntime. CPU dev/pilot PosePort.
+ 출력 [1,1,17,3] = (y, x, score) normalized 0..1."""
+
+ def __init__(self, onnx_path: str, input_size: int = 192, session=None,
+ min_person_score: float = 0.2):
+ self.input_size = input_size
+ self.min_person_score = min_person_score
+ if session is None:
+ import onnxruntime # gated: bundled on build machine
+ session = onnxruntime.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
+ self.session = session
+ self._input_name = session.get_inputs()[0].name
+
+ def infer(self, frame) -> list:
+ h, w = frame.shape[:2]
+ img = cv2.resize(frame, (self.input_size, self.input_size))
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.int32)[None, ...]
+ out = self.session.run(None, {self._input_name: img})[0] # [1,1,17,3]
+ kp = out[0, 0] # [17,3] (y,x,score)
+ keypoints = [Keypoint(float(kp[j, 1] * w), float(kp[j, 0] * h), float(kp[j, 2]))
+ for j in range(17)]
+ vis = [k for k in keypoints if k.conf >= self.min_person_score]
+ if not vis:
+ return []
+ xs = [k.x for k in vis]; ys = [k.y for k in vis]
+ bbox = BBox(min(xs), min(ys), max(xs), max(ys))
+ return [Person(bbox, keypoints)]
diff --git a/edge/alert.py b/edge/alert.py
index b2edb37..388f54d 100644
--- a/edge/alert.py
+++ b/edge/alert.py
@@ -6,7 +6,9 @@ import json
from edge.events import Event
-def sign(secret: str, nonce: str, ts: str, body: bytes) -> str:
+def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str:
+ if isinstance(body, str):
+ body = body.encode()
msg = nonce.encode() + ts.encode() + body
return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
diff --git a/edge/cloudclient.py b/edge/cloudclient.py
new file mode 100644
index 0000000..f982afa
--- /dev/null
+++ b/edge/cloudclient.py
@@ -0,0 +1,50 @@
+import json
+
+from edge.alert import sign
+
+
+class CloudClient:
+ 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("/")
+ self.device_id = device_id
+ self.secret = secret
+ self.nonce_fn = nonce_fn
+ self.now_fn = now_fn
+ self.timeout = timeout
+ if http_get is None or http_post is None:
+ import requests
+ http_get = http_get or requests.get
+ http_post = http_post or requests.post
+ self._get = http_get
+ self._post = http_post
+
+ def _headers(self, body: str) -> dict:
+ nonce = self.nonce_fn()
+ ts = str(self.now_fn())
+ return {"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)
+ except Exception:
+ return None
+ if 200 <= resp.status_code < 300:
+ try:
+ return resp.json()
+ except Exception:
+ return None
+ return None
+
+ def send_heartbeat(self, metrics: dict) -> bool:
+ payload = {"device_id": self.device_id, **metrics}
+ body = json.dumps(payload, sort_keys=True)
+ 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)
+ except Exception:
+ return False
+ return 200 <= resp.status_code < 300
diff --git a/edge/daemon.py b/edge/daemon.py
new file mode 100644
index 0000000..7bd04a3
--- /dev/null
+++ b/edge/daemon.py
@@ -0,0 +1,57 @@
+import os
+
+
+def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
+ sync_every=150, heartbeat_every=900, flush_every=300,
+ frame_interval=0.0):
+ counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
+ i = 0
+ while iterations is None or i < iterations:
+ i += 1
+ try:
+ runtime.step(clock())
+ counters["frames"] += 1
+ except Exception:
+ counters["errors"] += 1
+ try:
+ if sync_every and i % sync_every == 0:
+ cfg = client.fetch_config()
+ if cfg is not None:
+ from edge.zones import zones_from_config
+ runtime.apply_zones(zones_from_config(cfg))
+ counters["syncs"] += 1
+ if heartbeat_every and i % heartbeat_every == 0:
+ if client.send_heartbeat({"fps": 0.0, "frames": counters["frames"]}):
+ counters["beats"] += 1
+ if flush_every and i % flush_every == 0:
+ queue.flush()
+ counters["flushes"] += 1
+ except Exception:
+ counters["errors"] += 1
+ if frame_interval:
+ sleep(frame_interval)
+ return counters
+
+
+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.alert import PostClient
+ from edge.queue import OfflineQueue
+ from edge.runtime import PipelineRuntime
+ from edge.adapters.uvc_source import UvcSource
+ from edge.adapters.movenet_pose import MoveNetPoseAdapter
+
+ cfg = load_config(dict(os.environ))
+ nonce = lambda: os.urandom(8).hex()
+ now = 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)
+ source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
+ pose = MoveNetPoseAdapter(os.environ["MOVENET_ONNX"])
+ runtime = PipelineRuntime(cfg, source, pose, queue)
+ run_loop(runtime, client, queue, clock=now, sleep=time.sleep, iterations=None,
+ frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")))
diff --git a/edge/runtime.py b/edge/runtime.py
new file mode 100644
index 0000000..7b76a59
--- /dev/null
+++ b/edge/runtime.py
@@ -0,0 +1,37 @@
+from edge.app import run_once
+from edge.tracking import IouTracker
+from edge.smoothing import TrackSmootherPool
+from edge.detectors.fall import FallDetector
+from edge.detectors.immobility import ImmobilityWatchdog
+from edge.detectors.ghost import GhostIncidentDetector
+from edge.detectors.zone import ZoneIntrusionDetector
+from edge.engine import EventEngine
+from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
+
+
+class PipelineRuntime:
+ def __init__(self, config, source, pose, sink):
+ self.config = config
+ self.source = source
+ self.pose = pose
+ self.sink = sink
+ self.tracker = IouTracker()
+ self.pool = TrackSmootherPool()
+ self.zone_detector = ZoneIntrusionDetector([], config.zone)
+ self.engine = EventEngine(
+ FallDetector(config.fall),
+ ImmobilityWatchdog(config.immobility),
+ GhostIncidentDetector(config.ghost),
+ self.zone_detector,
+ config.engine,
+ )
+ self.ring = RingBuffer()
+ self.snapshotter = Snapshotter(FaceBlurrer())
+
+ 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)
+
+ def apply_zones(self, zones: list) -> None:
+ self.zone_detector.zones = list(zones)
diff --git a/edge/tools/__init__.py b/edge/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/edge/tools/fetch_movenet.py b/edge/tools/fetch_movenet.py
new file mode 100644
index 0000000..4b9c6e3
--- /dev/null
+++ b/edge/tools/fetch_movenet.py
@@ -0,0 +1,16 @@
+"""빌드머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0."""
+import os
+import urllib.request
+
+# MoveNet Lightning 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")
+
+def main():
+ os.makedirs(os.path.dirname(DEST), exist_ok=True)
+ print(f"downloading MoveNet ONNX -> {DEST}")
+ urllib.request.urlretrieve(URL, DEST)
+ print("done")
+
+if __name__ == "__main__":
+ main()
diff --git a/edge/zones.py b/edge/zones.py
new file mode 100644
index 0000000..23718ff
--- /dev/null
+++ b/edge/zones.py
@@ -0,0 +1,26 @@
+import json
+
+from edge.detectors.zone import Zone
+
+
+def zones_from_config(config: dict) -> list[Zone]:
+ out: list[Zone] = []
+ rows = config.get("zones", []) or []
+ if not isinstance(rows, list):
+ return []
+ for row in rows:
+ try:
+ raw = row["polygon_json"]
+ pts = json.loads(raw) if isinstance(raw, str) else raw
+ polygon = [(float(p[0]), float(p[1])) for p in pts]
+ if len(polygon) < 3:
+ continue
+ out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
+ except (KeyError, ValueError, TypeError, IndexError, json.JSONDecodeError):
+ continue
+ return out
+
+
+def thresholds_from_config(config: dict) -> dict:
+ t = config.get("thresholds")
+ return t if isinstance(t, dict) else {}
diff --git a/pyproject.toml b/pyproject.toml
index a75cd4d..510cccc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,6 +7,9 @@ dependencies = ["numpy", "opencv-python", "requests"]
[project.optional-dependencies]
dev = ["pytest"]
+[project.scripts]
+move-detect-edge = "edge.daemon:main"
+
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
diff --git a/tests/hardware/test_movenet_smoke.py b/tests/hardware/test_movenet_smoke.py
new file mode 100644
index 0000000..f7ec467
--- /dev/null
+++ b/tests/hardware/test_movenet_smoke.py
@@ -0,0 +1,28 @@
+"""Gated end-to-end smoke test for the real MoveNet ONNX model.
+
+Skipped unless the model is bundled AND onnxruntime is installed, so the default
+CI/dev suite (no model, no onnxruntime) stays green. On a build/pilot machine that
+ran ``python -m edge.tools.fetch_movenet``, this exercises the full adapter path:
+frame -> onnxruntime inference -> COCO-17 remap -> list[Person].
+"""
+import os
+
+import numpy as np
+import pytest
+
+MODEL = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
+
+
+@pytest.mark.skipif(not os.path.exists(MODEL), reason="MoveNet model not bundled")
+def test_real_movenet_runs():
+ pytest.importorskip("onnxruntime")
+ from edge.adapters.movenet_pose import MoveNetPoseAdapter
+
+ adapter = MoveNetPoseAdapter(MODEL)
+ persons = adapter.infer(np.full((480, 640, 3), 128, np.uint8))
+
+ # Runs end-to-end; a flat gray frame may yield 0 persons — the contract is
+ # only that it returns a list and, when non-empty, each Person has 17 keypoints.
+ assert isinstance(persons, list)
+ for person in persons:
+ assert len(person.keypoints) == 17
diff --git a/tests/test_cloudclient.py b/tests/test_cloudclient.py
new file mode 100644
index 0000000..d05b075
--- /dev/null
+++ b/tests/test_cloudclient.py
@@ -0,0 +1,32 @@
+import json
+from edge.cloudclient import CloudClient
+from edge.alert import sign
+
+class _Resp:
+ def __init__(self, code, body=""): self.status_code = code; self.text = body
+ def json(self): return json.loads(self.text)
+
+def test_fetch_config_signs_and_parses():
+ captured = {}
+ 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)
+ 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))
+ 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)
+ 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)
diff --git a/tests/test_daemon.py b/tests/test_daemon.py
new file mode 100644
index 0000000..2d01880
--- /dev/null
+++ b/tests/test_daemon.py
@@ -0,0 +1,56 @@
+from edge.daemon import run_loop
+
+
+class _Runtime:
+ def __init__(self): self.steps = 0; self.applied = None
+ def step(self, now): self.steps += 1; return []
+ def apply_zones(self, zones): self.applied = zones
+
+
+class _Client:
+ def __init__(self): self.configs = 0; self.beats = 0
+ def fetch_config(self): self.configs += 1; return {"zones": [], "thresholds": {}}
+ def send_heartbeat(self, m): self.beats += 1; return True
+
+
+class _Queue:
+ def __init__(self): self.flushes = 0
+ def flush(self): self.flushes += 1; return 0
+
+
+def test_loop_steps_and_periodics():
+ rt, cl, q = _Runtime(), _Client(), _Queue()
+ ticks = iter(range(1000))
+ counters = run_loop(rt, cl, q, clock=lambda: float(next(ticks)),
+ sleep=lambda s: None, iterations=10,
+ sync_every=5, heartbeat_every=5, flush_every=5)
+ assert rt.steps == 10
+ assert cl.configs == 2 and cl.beats == 2 and q.flushes == 2 # at frames 5 and 10
+ assert rt.applied == [] # zones applied from fetched config
+
+
+def test_loop_survives_step_exception():
+ class Boom(_Runtime):
+ def step(self, now):
+ self.steps += 1
+ if self.steps == 2: raise RuntimeError("frame glitch")
+ return []
+ rt = Boom()
+ counters = run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0,
+ sleep=lambda s: None, iterations=3,
+ sync_every=99, heartbeat_every=99, flush_every=99)
+ assert rt.steps == 3 # kept going past the exception
+ assert counters["errors"] == 1
+
+
+def test_loop_survives_flush_exception():
+ class BoomQueue(_Queue):
+ def flush(self):
+ self.flushes += 1
+ raise RuntimeError("db locked")
+ rt = _Runtime()
+ counters = run_loop(rt, _Client(), BoomQueue(), clock=lambda: 0.0,
+ sleep=lambda s: None, iterations=5,
+ sync_every=99, heartbeat_every=99, flush_every=2)
+ assert rt.steps == 5 # loop kept running past the flush exceptions
+ assert counters["errors"] >= 1
diff --git a/tests/test_movenet_pose.py b/tests/test_movenet_pose.py
new file mode 100644
index 0000000..0db0a8a
--- /dev/null
+++ b/tests/test_movenet_pose.py
@@ -0,0 +1,32 @@
+import numpy as np
+from edge.adapters.movenet_pose import MoveNetPoseAdapter
+
+class _FakeSession:
+ """MoveNet 출력 형태 [1,1,17,3] (y,x,score) 정규화."""
+ def get_inputs(self):
+ class I: name = "input"
+ return [I()]
+ def run(self, _out, feed):
+ kp = np.zeros((1, 1, 17, 3), dtype=np.float32)
+ for j in range(17):
+ kp[0, 0, j] = [0.5, 0.25, 0.9] # y=0.5, x=0.25, score=0.9
+ return [kp]
+
+def test_infer_maps_movenet_to_coco17_persons():
+ frame = np.zeros((400, 800, 3), dtype=np.uint8)
+ a = MoveNetPoseAdapter("unused.onnx", session=_FakeSession())
+ persons = a.infer(frame)
+ assert len(persons) == 1
+ p = persons[0]
+ assert len(p.keypoints) == 17
+ # x = 0.25 * 800 = 200, y = 0.5 * 400 = 200
+ assert abs(p.keypoints[0].x - 200) < 1 and abs(p.keypoints[0].y - 200) < 1
+ assert abs(p.keypoints[0].conf - 0.9) < 1e-6
+ assert p.bbox.x1 <= 200 <= p.bbox.x2
+
+def test_low_score_person_dropped():
+ class Low(_FakeSession):
+ def run(self, _o, feed):
+ return [np.zeros((1, 1, 17, 3), dtype=np.float32)] # all score 0
+ a = MoveNetPoseAdapter("unused.onnx", session=Low())
+ assert a.infer(np.zeros((10, 10, 3), np.uint8)) == []
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
new file mode 100644
index 0000000..85bd4b4
--- /dev/null
+++ b/tests/test_runtime.py
@@ -0,0 +1,43 @@
+import numpy as np
+from edge.config import load_config
+from edge.models import Person, BBox, Keypoint
+from edge.detectors.zone import Zone
+from edge.runtime import PipelineRuntime
+
+
+def _lying_person():
+ kps = [Keypoint(150, 300, 0.9) for _ in range(17)]
+ kps[5] = Keypoint(100, 300, 0.9); kps[6] = Keypoint(100, 310, 0.9)
+ kps[11] = Keypoint(200, 300, 0.9); kps[12] = Keypoint(200, 310, 0.9)
+ kps[0] = Keypoint(90, 320, 0.9)
+ return Person(BBox(90, 290, 210, 320), kps)
+
+
+class _Source:
+ def read(self): return True, np.full((360, 640, 3), 128, np.uint8)
+
+
+class _Pose:
+ def infer(self, frame): return [_lying_person()]
+
+
+class _Sink:
+ def __init__(self): self.events = []
+ def try_send(self, e, snaps): self.events.append(e); return True
+
+
+def test_step_runs_pipeline_and_emits_fall():
+ cfg = load_config({"FALL_PRONE_S": "0.5"})
+ sink = _Sink()
+ rt = PipelineRuntime(cfg, _Source(), _Pose(), sink)
+ rt.step(now=0.0)
+ rt.step(now=0.6)
+ from edge.events import EventType
+ assert any(e.event_type == EventType.FALL for e in sink.events)
+
+
+def test_apply_zones_hotswaps():
+ cfg = load_config({})
+ rt = PipelineRuntime(cfg, _Source(), _Pose(), _Sink())
+ rt.apply_zones([Zone("z1", "restricted", [(0, 0), (9, 0), (9, 9)])])
+ assert rt.zone_detector.zones[0].zone_id == "z1"
diff --git a/tests/test_zones.py b/tests/test_zones.py
new file mode 100644
index 0000000..1fcb342
--- /dev/null
+++ b/tests/test_zones.py
@@ -0,0 +1,33 @@
+from edge.zones import zones_from_config, thresholds_from_config
+
+def test_parses_zone_rows_json_string_polygon():
+ cfg = {"zones": [
+ {"zone_id": "z1", "camera_id": "cam-1", "level": "restricted",
+ "polygon_json": "[[0,0],[9,0],[9,9]]"},
+ ], "thresholds": {"fall_prone_s": 3.0}}
+ zones = zones_from_config(cfg)
+ assert len(zones) == 1
+ assert zones[0].zone_id == "z1"
+ assert zones[0].level == "restricted"
+ assert zones[0].polygon == [(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)]
+ assert thresholds_from_config(cfg)["fall_prone_s"] == 3.0
+
+def test_skips_malformed_row():
+ cfg = {"zones": [{"zone_id": "bad"}], "thresholds": {}}
+ assert zones_from_config(cfg) == []
+
+def test_empty_config():
+ assert zones_from_config({}) == []
+ assert thresholds_from_config({}) == {}
+
+def test_skips_short_point_without_crashing():
+ cfg = {"zones": [
+ {"zone_id": "bad", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9],[9,9]]"},
+ {"zone_id": "ok", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9,0],[9,9]]"},
+ ]}
+ zones = zones_from_config(cfg)
+ # bad row skipped, good row survives -- function did not crash
+ assert [z.zone_id for z in zones] == ["ok"]
+
+def test_non_iterable_zones_returns_empty():
+ assert zones_from_config({"zones": 5}) == []