"""Confirmed-event side effects: annotated screenshot, JSONL log, sound, popup. Side effects run once per ``event_id``. Screenshot annotation and JSONL writing are Qt-free and unit-tested; sound and popup are injected sinks so the desktop adapters (Windows) stay thin. Artifact names use only the event ID and a date folder — never an RTSP address, credential, or client name. """ import json from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Callable, Dict, Optional, Sequence, Tuple import cv2 import numpy as np from v1.fall_state import FallEvent, FallState from v1.view_model import MonitorViewState, StatusColor # Semantic tokens as OpenCV BGR tuples (see docs/ui/silver-pose-ui-ux-spec.md). _COLOR_BGR: Dict[StatusColor, Tuple[int, int, int]] = { StatusColor.SUCCESS: (61, 128, 21), StatusColor.CAUTION: (9, 83, 180), StatusColor.CRITICAL: (40, 40, 198), StatusColor.OFFLINE: (139, 116, 100), } @dataclass(frozen=True) class AlertRecord: event: FallEvent screenshot_path: Path log_path: Path created_at_utc: str class AlertSink: """Injectable output for sound and popup. Default implementation is silent.""" def play_sound(self, event: FallEvent) -> None: # pragma: no cover - adapter pass def show_popup(self, record: AlertRecord) -> None: # pragma: no cover - adapter pass def annotate_frame(image: np.ndarray, view: MonitorViewState) -> np.ndarray: """Return a copy of ``image`` with boxes, skeleton, labels and a state border.""" canvas = image.copy() for overlay in view.people: color = _COLOR_BGR[overlay.color] left, top, right, bottom = (int(round(v)) for v in overlay.box_xyxy) cv2.rectangle(canvas, (left, top), (right, bottom), color, 2) for start, end in overlay.skeleton_segments: cv2.line( canvas, (int(round(start.x)), int(round(start.y))), (int(round(end.x)), int(round(end.y))), color, 2, ) for point in overlay.keypoints: if point is not None: cv2.circle(canvas, (int(round(point.x)), int(round(point.y))), 3, color, -1) cv2.putText( canvas, overlay.label, (left, max(0, top - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA, ) if view.highest_state is FallState.CONFIRMED: height, width = canvas.shape[:2] cv2.rectangle( canvas, (0, 0), (width - 1, height - 1), _COLOR_BGR[StatusColor.CRITICAL], 6 ) return canvas class EventArtifactWriter: """Persist one annotated screenshot and one JSONL line per confirmed event.""" def __init__( self, event_dir: Path, source_id: str, clock: Optional[Callable[[], datetime]] = None, ) -> None: self._event_dir = Path(event_dir) self._source_id = source_id self._clock = clock or (lambda: datetime.now(timezone.utc)) def write( self, event: FallEvent, image: np.ndarray, view: MonitorViewState ) -> AlertRecord: created = self._clock().astimezone(timezone.utc) day_dir = self._event_dir / created.strftime("%Y%m%d") day_dir.mkdir(parents=True, exist_ok=True) screenshot_path = day_dir / "{0}.png".format(event.event_id) annotated = annotate_frame(image, view) if not cv2.imwrite(str(screenshot_path), annotated): raise IOError("failed to save screenshot: {0}".format(screenshot_path)) log_path = day_dir / "events.jsonl" record = { "event_id": event.event_id, "track_id": event.track_id, "config_version": event.config_version, "source_id": self._source_id, "state": event.state.value, "confirmed_at_utc": created.isoformat(), "suspected_at_monotonic": event.suspected_at_monotonic, "confirmed_at_monotonic": event.confirmed_at_monotonic, "latency_seconds": event.latency_seconds, "screenshot": screenshot_path.relative_to(self._event_dir).as_posix(), } with log_path.open("a", encoding="utf-8") as stream: stream.write(json.dumps(record, ensure_ascii=False) + "\n") return AlertRecord( event=event, screenshot_path=screenshot_path, log_path=log_path, created_at_utc=record["confirmed_at_utc"], ) class AlertDispatcher: """De-duplicate confirmed events and fire each side effect exactly once.""" def __init__(self, writer: EventArtifactWriter, sink: Optional[AlertSink] = None) -> None: self._writer = writer self._sink = sink or AlertSink() self._seen_event_ids: set = set() def dispatch( self, events: Sequence[FallEvent], image: Optional[np.ndarray], view: MonitorViewState, ) -> Sequence[AlertRecord]: records = [] for event in events: if event.event_id in self._seen_event_ids or image is None: continue self._seen_event_ids.add(event.event_id) record = self._writer.write(event, image, view) self._sink.play_sound(event) self._sink.show_popup(record) records.append(record) return tuple(records)