diff --git a/docs/superpowers/specs/2026-04-03-foundation-plumbing-design.md b/docs/superpowers/specs/2026-04-03-foundation-plumbing-design.md new file mode 100644 index 0000000..0b8b1fc --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-foundation-plumbing-design.md @@ -0,0 +1,162 @@ +# Foundation Plumbing — Design Spec + +## Overview + +Complete four foundational features that are configured/stubbed but not yet implemented: notification delivery, recording playback, HLS.js library, and PIN verification. These are prerequisites for many other planned features. + +## F1: Notification Delivery + +### Web Push Sender + +The infrastructure is mostly in place: `pywebpush` is a dependency, push subscriptions are stored in the `push_subscriptions` table, `insert_alert_log` exists, and the service worker (`sw.js`) already handles incoming push events with notification display. + +**New file: `vigilar/alerts/sender.py`** + +`send_alert(engine, event_type, severity, source_id, payload)`: +1. Determine notification content from event type and payload (title, body, icon, URL) +2. Query all active push subscriptions from DB +3. For each subscription, call `pywebpush.webpush()` with VAPID credentials +4. Log each send attempt to `alert_log` table (channel=WEB_PUSH, status=SENT|FAILED, error if any) +5. Remove subscriptions that return 410 Gone (expired) + +VAPID keys: read from env var `VIGILAR_VAPID_PRIVATE_KEY` (primary), falling back to file at `StorageConfig.vapid_private_key_path` if env var is not set. + +**Notification content mapping:** + +| Event Type | Title | Body Example | +|-----------|-------|-------------| +| PERSON_DETECTED | Person Detected | "Person on Front Entrance" | +| PET_ESCAPE | Pet Alert | "Angel detected on Back Deck (exterior)" | +| UNKNOWN_ANIMAL | Unknown Animal | "Unknown cat on Front Entrance" | +| WILDLIFE_PREDATOR | Wildlife Alert | "Bear detected — Front Entrance" | +| WILDLIFE_NUISANCE | Wildlife | "Raccoon on Back Deck" | +| UNKNOWN_VEHICLE_DETECTED | Unknown Vehicle | "Unknown vehicle on Front Entrance" | +| POWER_LOSS | Power Alert | "UPS on battery" | +| LOW_BATTERY | Battery Critical | "UPS battery low" | + +**Integration point:** In `EventProcessor._execute_action`, when action is `alert_all` or `push_and_record`, call `send_alert()` instead of (or in addition to) just publishing to MQTT. The `push_adults` action filters subscriptions by a `role` field (future — for now, send to all subscriptions for any push action). + +### Audit Log + +Every alert-worthy event is already written to the `events` table. The `alert_log` table tracks notification delivery attempts. Additionally: + +- Configure a dedicated Python logger `vigilar.alerts` that writes to syslog via `logging.handlers.SysLogHandler` +- Log format: `[{severity}] {event_type} on {source_id}: {summary}` +- Logger is configured in `main.py` at startup, using the system's syslog socket (`/dev/log` on Linux) +- This is a logging config addition, not a new module + +## F2: Recording Playback & Download + +### Current State + +- Recordings are written as H.264 MP4 by FFmpeg via `AdaptiveRecorder` +- The `recordings` table has `encrypted` column (defaults to 1) and `file_path` +- `StorageConfig.encrypt_recordings` defaults to True, `key_file` points to `/etc/vigilar/secrets/storage.key` +- **No encryption is actually implemented yet** — files are written as plain MP4 despite the schema flag + +### Design + +Since encryption isn't implemented in the recorder yet, implement it end-to-end: + +**Encryption (recorder side):** +- After FFmpeg finishes writing an MP4 segment, encrypt the file using AES-256-CTR with the key from `VIGILAR_ENCRYPTION_KEY` env var +- Use `cryptography.hazmat.primitives.ciphers` (AES-CTR) for streaming-friendly encryption (no need to buffer entire file) +- Write encrypted file with `.vge` extension (Vigilar encrypted), delete the plain MP4 +- Store a random 16-byte IV as the first 16 bytes of the `.vge` file +- If `VIGILAR_ENCRYPTION_KEY` is not set and `encrypt_recordings` is True, log a warning and write plain MP4 (graceful degradation) + +**Key format:** 32-byte hex string in env var (64 hex characters). Decoded to 32 bytes for AES-256. + +**Decryption (playback side):** +- `GET /recordings//download` reads the `.vge` file, extracts the IV from the first 16 bytes, decrypts with AES-256-CTR, streams as `video/mp4` +- Response uses `Transfer-Encoding: chunked` to avoid buffering the entire file in memory +- If the file is plain MP4 (not encrypted or `encrypted=0` in DB), stream directly + +**Recording list API:** +- `GET /recordings/api/list` queries the `recordings` table with optional filters: `camera_id`, `date`, `detection_type`, `starred` +- Returns JSON array of recording metadata (id, camera_id, started_at, ended_at, duration_s, detection_type, starred, file_size) + +**Recording delete:** +- `DELETE /recordings/` removes the DB row and unlinks the file from disk +- Returns 404 if recording not found + +### Playback UI Integration + +The existing timeline component (`timeline.js`) renders clickable segments. Wire click events to load the recording in an HTML5 `