diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index 0d9e86b..8c5940c 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -25,7 +25,7 @@
{"id":"spaxel-51k","title":"OTA firmware update system","description":"## Background\n\nOnce nodes are deployed in a home, they need to be updated without physical access. ESP-IDF has a mature OTA (Over-The-Air) update mechanism: two OTA flash partitions (factory, ota_0, ota_1 as defined in firmware/partitions.csv), HTTP download to the inactive partition, cryptographic verification, set boot partition, reboot. The mothership serves firmware binaries and triggers the update via a WebSocket downstream command. Phase 1 laid the groundwork in the firmware; this bead completes the mothership side.\n\n## What Already Exists\n\nfirmware/main/websocket.c has OTA command handling and an ota_download_task that handles the HTTP download to the inactive OTA partition. The partition table in firmware/partitions.csv has factory + ota_0 + ota_1 slots. The firmware parses {type:\"ota\", url:\"...\", md5:\"...\", version:\"...\"} downstream commands and initiates the download. What is missing is:\n- The mothership HTTP server for firmware binary serving\n- The REST API for triggering OTA per-node or fleet-wide\n- The firmware manifest for version management\n- The rollback detection logic on the mothership side\n- The dashboard UI for OTA management\n\n## Mothership Firmware Serving\n\nGET /firmware/latest or GET /firmware/{version}: serves the compiled .bin file from the /firmware volume mount. The response must include:\n- Content-Length header (required by ESP-IDF OTA HTTP client for progress reporting)\n- ETag header (MD5 of the binary, for caching)\n- Content-Type: application/octet-stream\n\nFirmware binaries are placed in the /firmware volume mount (configured in docker-compose.yml or k8s volume mount). The mothership reads the firmware manifest on startup and re-reads it when a new file appears (inotify watch or periodic re-scan every 60s).\n\n## Firmware Manifest\n\nFile: /firmware/manifest.json — auto-generated by the CI build process, or manually created.\nFormat: list of objects, each with version (semver string), filename (basename within /firmware/), md5 (hex string of binary MD5), size (integer bytes), build_timestamp (ISO8601).\nThe mothership's \"latest\" version is determined by sorting manifest entries by semver and taking the highest.\n\n## OTA Trigger API\n\nPOST /api/nodes/{mac}/ota — trigger OTA for a specific node\nRequest body: {\"version\": \"0.2.0\"} (optional; defaults to latest if omitted)\nResponse: {\"job_id\": \"abc123\", \"node_mac\": \"aa:bb:cc:dd:ee:ff\", \"target_version\": \"0.2.0\", \"status\": \"initiated\"}\n\nThe mothership looks up the node's active WebSocket session in the connection registry and sends the OTA command:\n{\"type\":\"ota\",\"url\":\"http://{mothership_ip}:{port}/firmware/0.2.0\",\"md5\":\"{hex_md5}\",\"version\":\"0.2.0\"}\n\nThe firmware immediately begins the download in a background task, sends ota_status messages ({\"type\":\"ota_status\",\"progress\":45,\"status\":\"downloading\"}) which the mothership logs and broadcasts to the dashboard.\n\n## Fleet Rolling Update\n\nPOST /api/ota/fleet — trigger OTA for all connected nodes\nRequest body: {\"version\": \"0.2.0\", \"stagger_seconds\": 30} (default stagger: 30s)\n\nThe rolling update coordinator in the mothership triggers OTA for the first node, waits stagger_seconds, then the next, and so on. This ensures:\n- Not all nodes reboot simultaneously (avoids a coverage gap window)\n- If a node fails OTA, the remaining nodes can be halted before more disruption\n- Fleet update progress is visible in dashboard per-node\n\nThe fleet update job is stored in SQLite (ota_jobs table) and survives mothership restarts.\n\n## Rollback Detection\n\nESP-IDF automatically rolls back to the previous firmware if the new image does not call esp_ota_mark_app_valid_cancel_rollback() within a boot window (the firmware does this on successful WebSocket connection to the mothership). The mothership detects rollback by comparing the firmware_version field in the hello message after OTA against the requested target version. If they differ, the mothership logs an OTA rollback event and updates the node's status to \"rollback\".\n\n## Dashboard OTA UI\n\nAdd an OTA panel to the dashboard settings or fleet page:\n- Per-node: current firmware version, available version (if newer), \"Update\" button\n- Fleet: \"Update All\" button with stagger slider, progress per node (with percentage from ota_status messages), last updated time per node\n- Version history: per-node firmware version history in tooltip or expandable row\n- Rollback indicator: nodes that rolled back are highlighted with a warning and the reason (if known)\n\n## Files to Create or Modify\n\n- mothership/internal/ota/server.go: firmware file serving with Content-Length and ETag\n- mothership/internal/ota/manifest.go: manifest parsing and latest-version logic\n- mothership/internal/ota/jobs.go: OTA job creation, fleet rolling update coordinator, status tracking\n- mothership/internal/dashboard/routes.go: register OTA API routes\n- dashboard/js/fleet.js or dashboard/js/ota.js: OTA UI panel\n\n## Tests\n\n- Test OTA command JSON serialisation matches firmware's expected format exactly\n- Test rolling update stagger timing with a mock time source (use a clock interface for testability)\n- Test that firmware version in hello message is parsed and stored in the node registry\n- Test manifest parsing: valid manifest, empty manifest, malformed manifest\n- Test rollback detection when hello version does not match target version\n\n## Acceptance Criteria\n\n- OTA command reaches firmware and triggers download (verified via ota_status messages)\n- Rolling update staggers correctly with the configured delay between nodes\n- After successful OTA, node reconnects with new firmware version in hello message\n- Rollback is detectable via hello version mismatch and displayed in dashboard\n- MD5 verification failure in firmware logs an error and the old firmware remains running\n- Fleet update status visible per-node in dashboard\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:37:32.472078279Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.250035631Z","closed_at":"2026-03-28T05:36:39.249972673Z","close_reason":"Implemented: ota/manager.go + ota/server.go (fb69190) — HTTP firmware serving from /firmware volume, WebSocket-triggered OTA command, rolling update with 30s stagger, MD5 verification, firmware manifest.json, rollback detection via hello version mismatch","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-51k","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.874999678Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"spaxel-54i","title":"load-shedding: add per-iteration timing and rolling avg to ProcessorManager","description":"## Task\nAdd pipeline timing and a 5-iteration rolling average to `ProcessorManager` in `mothership/internal/signal/processor.go`.\n\n## Changes to ProcessorManager struct (lines 221-228)\nAdd these fields:\n```go\ntype ProcessorManager struct {\n // ... existing fields ...\n iterDurations [5]time.Duration // ring buffer for last 5 iteration times\n iterIdx int // next write index (mod 5)\n iterCount int // how many values filled (0-5)\n shedLevel int // current load shedding level (0-3)\n steadyCount int // consecutive iters below recovery threshold\n}\n```\n\n## Changes to LinkProcessor.Process() (line 54)\nWrap the entire Process body to time it. At the START of Process():\n```go\nt0 := time.Now()\n```\nAt the END of Process(), before return, update the manager's ring buffer. BUT — Process is on LinkProcessor, not ProcessorManager. So instead, add timing to ProcessorManager.Process() (line 252):\n\n```go\nfunc (pm *ProcessorManager) Process(linkID string, ...) (*ProcessResult, error) {\n t0 := time.Now()\n // ... existing lock + delegate to processor ...\n result, err := processor.Process(payload, rssiDBm, nSub, recvTime)\n pm.mu.Unlock() // already have write lock\n elapsed := time.Since(t0)\n pm.updateShedding(elapsed)\n return result, err\n}\n```\n\n## New method updateShedding(elapsed time.Duration)\n```go\nfunc (pm *ProcessorManager) updateShedding(elapsed time.Duration) {\n pm.iterDurations[pm.iterIdx%5] = elapsed\n pm.iterIdx++\n if pm.iterCount < 5 { pm.iterCount++ }\n\n // compute rolling avg\n var sum time.Duration\n for i := 0; i < pm.iterCount; i++ {\n sum += pm.iterDurations[i]\n }\n avg := sum / time.Duration(pm.iterCount)\n\n // level up\n if avg >= 95*time.Millisecond && pm.shedLevel < 3 {\n pm.shedLevel = 3; pm.steadyCount = 0\n } else if avg >= 90*time.Millisecond && pm.shedLevel < 2 {\n pm.shedLevel = 2; pm.steadyCount = 0\n } else if avg >= 80*time.Millisecond && pm.shedLevel < 1 {\n pm.shedLevel = 1; pm.steadyCount = 0\n }\n\n // recovery: step down one level when avg < 60ms for 10 consecutive iters\n if avg < 60*time.Millisecond {\n pm.steadyCount++\n if pm.steadyCount >= 10 && pm.shedLevel > 0 {\n pm.shedLevel--\n pm.steadyCount = 0\n }\n } else {\n pm.steadyCount = 0\n }\n}\n```\n\n## New getter\n```go\nfunc (pm *ProcessorManager) GetShedLevel() int {\n pm.mu.RLock()\n defer pm.mu.RUnlock()\n return pm.shedLevel\n}\n```\n\nNote: `updateShedding` must NOT hold `pm.mu` because it's called after unlock. The iterDurations ring buffer is only written from `Process` so it is already serialized by the caller's lock sequence. Add a separate `mu` for the shed state or call updateShedding while still holding pm.mu — simplest: call it BEFORE Unlock, while still holding the write lock.\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n```","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-07T06:33:03.697771676Z","created_by":"coding","updated_at":"2026-04-07T16:53:42.209613205Z","closed_at":"2026-04-07T16:53:42.209404722Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0}
{"id":"spaxel-5a3","title":"Add Fresnel zone visualization","description":"Implement wireframe ellipsoid overlay showing Fresnel zones between active links.\n\nAcceptance: 3D visualization shows ellipsoids between communicating nodes with proper scaling.","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T14:54:38.915673399Z","created_by":"coding","updated_at":"2026-04-09T16:37:30.545014159Z","closed_at":"2026-04-09T16:37:30.544898040Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]}
-{"id":"spaxel-5es","title":"Ambient dashboard mode","description":"## Background\n\nA wall-mounted tablet showing who is home is a genuinely useful home appliance — a digital replacement for the \"whiteboard on the fridge\" that families have used for decades. The ambient mode is designed for exactly this use case: always on, minimal information density, visually calm, and unobtrusive. It should run for weeks without user interaction, surviving screen timeouts, browser updates, and mothership restarts. When something important happens (fall, security alert), it breaks the calm decisively to get attention.\n\n## Route and Renderer\n\nNew route: /ambient (separate from /simple and expert mode)\nThe user can set a specific device as \"ambient display\" via a browser bookmark or home screen shortcut.\n\nCanvas 2D renderer (not Three.js): the ambient mode uses a dedicated Canvas 2D rendering engine rather than Three.js. Reasons:\n1. Three.js is designed for 3D; the ambient view is a 2D top-down floor plan\n2. Canvas 2D uses significantly less GPU memory and power — important for always-on tablet use\n3. Simpler code path means fewer failure modes for long-running display\n4. Lower battery drain on iPad and Android tablets\n\nRenderer: dashboard/js/ambient_renderer.js. Renders via requestAnimationFrame at 2 Hz (one frame every 500ms). The 2 Hz update rate is intentional: it's visually smooth enough for a presence display and uses minimal CPU.\n\n## Rendered Elements\n\nBackground: colour depends on time of day (see Time-of-Day Palette below).\n\nRoom outline: 2D rectangles for each zone's bounding box (projected to floor plane). White (#ffffff) with 1px stroke, no fill (transparent interior). Zone labels: zone name in white text at centroid, 14px medium font.\n\nPortal lines: thin lines (0.5px, #a855f7 purple) across doorways.\n\nNode positions: small filled circles (radius 4px, #6b7280 grey, subtle).\n\nPerson blobs: filled circles (radius 10-18px) in person.color. Name label above: first name only, 12px white. Blob radius proportional to identity confidence: full size if confident, slightly smaller if low confidence. If anonymous: a ghost/outline circle (stroke only, no fill) in light grey.\n\nSystem status indicator: top-left corner. Small circle (8px radius): green (#22c55e) if all nodes healthy, amber (#f59e0b) if any node degraded, red (#ef4444) if any node offline. No text — just the dot. Tooltip on hover (for the rare touch-and-hold interaction).\n\nTime display: top-right. Current time in large readable font. 28px, #ffffff, tabular-nums font variant for clean time display without layout shifts.\n\nPerson positions: lerp-interpolated between WebSocket updates for smooth movement at the 2 Hz render rate. On each WebSocket message received, update the target position. On each render frame, move each person blob 20% of the remaining distance to the target position (exponential approach = naturally decelerating animation).\n\n## Auto-Dim\n\nWhen no person is detected in the room where the ambient tablet is physically located (a zone that the user has configured as the \"ambient display zone\"), reduce the canvas brightness after 60 seconds of no detection:\n- After 60s: reduce canvas globalAlpha to 0.4 (40% brightness)\n- Restore immediately when presence detected in the ambient zone again\n\nImplementation: Use the CSS filter brightness() property on the canvas element, animated with a CSS transition. Set `canvas.style.filter = 'brightness(0.4)'` after timeout. This approach correctly dims including text labels.\n\nOptionally: if the device supports it (iOS WKWebView + Power Saving APIs), the ambient mode can request Screen Wake Lock API to prevent the tablet display from sleeping. window.navigator.wakeLock.request('screen'). Re-request on visibility change (the lock is released when the page is hidden).\n\n## Alert Mode\n\nWhen a fall or security alert fires (FallDetected or AnomalyDetected event received via WebSocket):\n1. The Canvas render loop detects the alert event\n2. Full canvas background fades to #dc2626 (urgent red) over 500ms\n3. Large white text in the centre: \"FALL DETECTED — Alice\" or \"ALERT — Motion while away\"\n4. Pulsing animation: canvas background alternates between #dc2626 and #991b1b at 1 Hz\n5. Acknowledge button rendered as a large white rectangle in the centre below the text\n6. Tap/click on the acknowledge button: POST /api/fall/{id}/acknowledge or /api/anomalies/{id}/acknowledge. Returns to normal ambient mode.\n\nThis alert mode must be clearly visible from across the room — the text should be at least 48px on a typical 10-inch tablet.\n\n## Morning Briefing Overlay\n\nFirst time presence is detected after 6am (configurable): the ambient display shows a brief overlay card for 15 seconds:\n- Overlaid on the normal floor plan (dark semi-transparent background)\n- Sleep summary (if available): \"Alice: 7h 23m, good sleep\"\n- Today's expected departures (from presence prediction, Phase 7): \"Alice likely leaves at 8:30am\"\n- System status: \"4 nodes healthy\"\n\nAfter 15 seconds: fade out and return to normal ambient view. Tapping the overlay dismisses it immediately.\n\n## Time-of-Day Palette\n\nThe ambient canvas background colour shifts with the time of day to be visually appropriate:\n- 06:00-12:00 (morning): light blue-grey (#f0f4f8, near white) — cool morning light feel\n- 12:00-18:00 (afternoon): neutral grey (#1e293b, dark) — reduces eye strain in bright rooms\n- 18:00-22:00 (evening): warm amber-grey (#1c1507, very dark warm) — matches evening lighting\n- 22:00-06:00 (night): near black (#040404) — OLED-friendly, minimal light\n\nTransitions: smooth CSS gradient transition over 30 minutes at each boundary (not instant). Implemented by pre-computing the target colour for the next 30 minutes and using CSS linear-gradient + keyframe animation.\n\n## Performance\n\nTarget: < 5% CPU usage on a 2016-era iPad (A9 chip). Achieved by:\n- 2 Hz render rate instead of 60 Hz (30x reduction in GPU/CPU work)\n- No Three.js, WebGL, or shader compilation overhead\n- Canvas 2D is hardware accelerated but lightweight\n- Blob count in a home is typically 1-4 — trivial to render\n\nMemory: the ambient page should have < 50MB JS heap. No large textures, no complex geometry.\n\n## Files to Create or Modify\n\n- dashboard/ambient.html: minimal HTML shell\n- dashboard/js/ambient.js: main ambient mode logic, WebSocket connection, alert handling\n- dashboard/js/ambient_renderer.js: Canvas 2D rendering engine\n- dashboard/js/ambient_briefing.js: morning briefing overlay\n- mothership/internal/dashboard/routes.go: /ambient route served statically\n\n## Tests\n\n- Test Canvas 2D renderer draws correct shapes: zone rectangle at (1,1)-(3,3) appears as a white rectangle at the correct pixel coordinates (given known canvas size and room dimensions)\n- Test auto-dim timer: mock 60s with no presence event, verify canvas brightness is reduced\n- Test auto-dim restore: presence event arrives, verify brightness returns to 100%\n- Test alert mode: inject FallDetected event, verify canvas background changes to red and text appears\n- Test acknowledge clears alert mode and returns to normal\n- Test morning briefing overlay appears only once after 6am (localStorage flag set)\n- Test lerp interpolation: person position updates from (1,1) to (3,3), after 5 render frames should be approximately (2.5, 2.5) (with 20% step lerp)\n\n## Acceptance Criteria\n\n- Ambient mode runs for 7 days without page reload (no memory leaks, no uncaught exceptions)\n- Auto-dim activates after 60 seconds of no presence in the display zone\n- Fall/anomaly alert mode clearly visible from 3 metres away on a 10-inch tablet\n- Acknowledge button works and returns to normal ambient\n- Morning briefing overlay appears once per day, dismisses after 15s\n- Canvas 2D rendering consumes < 5% CPU on a mid-range tablet\n- Time-of-day palette transitions are smooth (no hard cuts)\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:00:34.796733529Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.888767875Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-5es","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.888731706Z","created_by":"coding","metadata":"{}","thread_id":""}]}
+{"id":"spaxel-5es","title":"Ambient dashboard mode","description":"## Background\n\nA wall-mounted tablet showing who is home is a genuinely useful home appliance — a digital replacement for the \"whiteboard on the fridge\" that families have used for decades. The ambient mode is designed for exactly this use case: always on, minimal information density, visually calm, and unobtrusive. It should run for weeks without user interaction, surviving screen timeouts, browser updates, and mothership restarts. When something important happens (fall, security alert), it breaks the calm decisively to get attention.\n\n## Route and Renderer\n\nNew route: /ambient (separate from /simple and expert mode)\nThe user can set a specific device as \"ambient display\" via a browser bookmark or home screen shortcut.\n\nCanvas 2D renderer (not Three.js): the ambient mode uses a dedicated Canvas 2D rendering engine rather than Three.js. Reasons:\n1. Three.js is designed for 3D; the ambient view is a 2D top-down floor plan\n2. Canvas 2D uses significantly less GPU memory and power — important for always-on tablet use\n3. Simpler code path means fewer failure modes for long-running display\n4. Lower battery drain on iPad and Android tablets\n\nRenderer: dashboard/js/ambient_renderer.js. Renders via requestAnimationFrame at 2 Hz (one frame every 500ms). The 2 Hz update rate is intentional: it's visually smooth enough for a presence display and uses minimal CPU.\n\n## Rendered Elements\n\nBackground: colour depends on time of day (see Time-of-Day Palette below).\n\nRoom outline: 2D rectangles for each zone's bounding box (projected to floor plane). White (#ffffff) with 1px stroke, no fill (transparent interior). Zone labels: zone name in white text at centroid, 14px medium font.\n\nPortal lines: thin lines (0.5px, #a855f7 purple) across doorways.\n\nNode positions: small filled circles (radius 4px, #6b7280 grey, subtle).\n\nPerson blobs: filled circles (radius 10-18px) in person.color. Name label above: first name only, 12px white. Blob radius proportional to identity confidence: full size if confident, slightly smaller if low confidence. If anonymous: a ghost/outline circle (stroke only, no fill) in light grey.\n\nSystem status indicator: top-left corner. Small circle (8px radius): green (#22c55e) if all nodes healthy, amber (#f59e0b) if any node degraded, red (#ef4444) if any node offline. No text — just the dot. Tooltip on hover (for the rare touch-and-hold interaction).\n\nTime display: top-right. Current time in large readable font. 28px, #ffffff, tabular-nums font variant for clean time display without layout shifts.\n\nPerson positions: lerp-interpolated between WebSocket updates for smooth movement at the 2 Hz render rate. On each WebSocket message received, update the target position. On each render frame, move each person blob 20% of the remaining distance to the target position (exponential approach = naturally decelerating animation).\n\n## Auto-Dim\n\nWhen no person is detected in the room where the ambient tablet is physically located (a zone that the user has configured as the \"ambient display zone\"), reduce the canvas brightness after 60 seconds of no detection:\n- After 60s: reduce canvas globalAlpha to 0.4 (40% brightness)\n- Restore immediately when presence detected in the ambient zone again\n\nImplementation: Use the CSS filter brightness() property on the canvas element, animated with a CSS transition. Set `canvas.style.filter = 'brightness(0.4)'` after timeout. This approach correctly dims including text labels.\n\nOptionally: if the device supports it (iOS WKWebView + Power Saving APIs), the ambient mode can request Screen Wake Lock API to prevent the tablet display from sleeping. window.navigator.wakeLock.request('screen'). Re-request on visibility change (the lock is released when the page is hidden).\n\n## Alert Mode\n\nWhen a fall or security alert fires (FallDetected or AnomalyDetected event received via WebSocket):\n1. The Canvas render loop detects the alert event\n2. Full canvas background fades to #dc2626 (urgent red) over 500ms\n3. Large white text in the centre: \"FALL DETECTED — Alice\" or \"ALERT — Motion while away\"\n4. Pulsing animation: canvas background alternates between #dc2626 and #991b1b at 1 Hz\n5. Acknowledge button rendered as a large white rectangle in the centre below the text\n6. Tap/click on the acknowledge button: POST /api/fall/{id}/acknowledge or /api/anomalies/{id}/acknowledge. Returns to normal ambient mode.\n\nThis alert mode must be clearly visible from across the room — the text should be at least 48px on a typical 10-inch tablet.\n\n## Morning Briefing Overlay\n\nFirst time presence is detected after 6am (configurable): the ambient display shows a brief overlay card for 15 seconds:\n- Overlaid on the normal floor plan (dark semi-transparent background)\n- Sleep summary (if available): \"Alice: 7h 23m, good sleep\"\n- Today's expected departures (from presence prediction, Phase 7): \"Alice likely leaves at 8:30am\"\n- System status: \"4 nodes healthy\"\n\nAfter 15 seconds: fade out and return to normal ambient view. Tapping the overlay dismisses it immediately.\n\n## Time-of-Day Palette\n\nThe ambient canvas background colour shifts with the time of day to be visually appropriate:\n- 06:00-12:00 (morning): light blue-grey (#f0f4f8, near white) — cool morning light feel\n- 12:00-18:00 (afternoon): neutral grey (#1e293b, dark) — reduces eye strain in bright rooms\n- 18:00-22:00 (evening): warm amber-grey (#1c1507, very dark warm) — matches evening lighting\n- 22:00-06:00 (night): near black (#040404) — OLED-friendly, minimal light\n\nTransitions: smooth CSS gradient transition over 30 minutes at each boundary (not instant). Implemented by pre-computing the target colour for the next 30 minutes and using CSS linear-gradient + keyframe animation.\n\n## Performance\n\nTarget: < 5% CPU usage on a 2016-era iPad (A9 chip). Achieved by:\n- 2 Hz render rate instead of 60 Hz (30x reduction in GPU/CPU work)\n- No Three.js, WebGL, or shader compilation overhead\n- Canvas 2D is hardware accelerated but lightweight\n- Blob count in a home is typically 1-4 — trivial to render\n\nMemory: the ambient page should have < 50MB JS heap. No large textures, no complex geometry.\n\n## Files to Create or Modify\n\n- dashboard/ambient.html: minimal HTML shell\n- dashboard/js/ambient.js: main ambient mode logic, WebSocket connection, alert handling\n- dashboard/js/ambient_renderer.js: Canvas 2D rendering engine\n- dashboard/js/ambient_briefing.js: morning briefing overlay\n- mothership/internal/dashboard/routes.go: /ambient route served statically\n\n## Tests\n\n- Test Canvas 2D renderer draws correct shapes: zone rectangle at (1,1)-(3,3) appears as a white rectangle at the correct pixel coordinates (given known canvas size and room dimensions)\n- Test auto-dim timer: mock 60s with no presence event, verify canvas brightness is reduced\n- Test auto-dim restore: presence event arrives, verify brightness returns to 100%\n- Test alert mode: inject FallDetected event, verify canvas background changes to red and text appears\n- Test acknowledge clears alert mode and returns to normal\n- Test morning briefing overlay appears only once after 6am (localStorage flag set)\n- Test lerp interpolation: person position updates from (1,1) to (3,3), after 5 render frames should be approximately (2.5, 2.5) (with 20% step lerp)\n\n## Acceptance Criteria\n\n- Ambient mode runs for 7 days without page reload (no memory leaks, no uncaught exceptions)\n- Auto-dim activates after 60 seconds of no presence in the display zone\n- Fall/anomaly alert mode clearly visible from 3 metres away on a 10-inch tablet\n- Acknowledge button works and returns to normal ambient\n- Morning briefing overlay appears once per day, dismisses after 15s\n- Canvas 2D rendering consumes < 5% CPU on a mid-range tablet\n- Time-of-day palette transitions are smooth (no hard cuts)\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T02:00:34.796733529Z","created_by":"coding","updated_at":"2026-04-11T02:01:43.490210030Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-5es","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.888731706Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"spaxel-5lo","title":"Implement Zones CRUD REST endpoints with OpenAPI docs","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Include OpenAPI-style godoc comments. Acceptance: endpoints respond correctly to HTTP requests, godoc annotations present.","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:01:33.493352900Z","created_by":"coding","updated_at":"2026-04-07T18:13:38.639619498Z","closed_at":"2026-04-07T18:13:38.639505434Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-0ii"]}
{"id":"spaxel-5yq","title":"load-shedding: health endpoint + dashboard WS alert integration","description":"## Task\nExpose the load shedding level in the health endpoint and send a dashboard WS alert when Level 3 is triggered. Requires spaxel-54i to be complete (GetShedLevel() must exist).\n\n## 1. Health endpoint (mothership/cmd/mothership/main.go)\nFind the `/healthz` handler (around line 218). Change the JSON to include `shedding_level`:\n```go\nfmt.Fprintf(w, `{\"status\":\"ok\",\"version\":\"%s\",\"shedding_level\":%d}`, version, pm.GetShedLevel())\n```\n\n## 2. Dashboard WS alert on Level 3\nIn the fusion loop in main.go (the goroutine that calls `pm.Process()`), track the previous shed level and broadcast an alert when it changes to 3 or recovers:\n```go\n// after pm.Process() call:\nnewLevel := pm.GetShedLevel()\nif newLevel != prevShedLevel {\n if newLevel == 3 {\n msg := map[string]interface{}{\n \"type\": \"alert\",\n \"severity\": \"warning\",\n \"description\": \"System under load — CSI rate reduced to 10 Hz\",\n }\n data, _ := json.Marshal(msg)\n dashboardHub.Broadcast(data)\n }\n prevShedLevel = newLevel\n log.Printf(\"[INFO] Load shedding level changed: %d\", newLevel)\n}\n```\nDeclare `prevShedLevel int` before the fusion goroutine.\n\n## 3. Level 3 rate reduction push (best effort — log only if push mechanism not yet available)\nWhen `newLevel == 3`, log: `log.Printf(\"[INFO] Load shed level 3 — would push 10Hz cap to nodes\")`\nWhen `newLevel` recovers from 3, log: `log.Printf(\"[INFO] Load shed recovered — restoring prior node rate\")`\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n# curl http://localhost:8080/healthz should include shedding_level\n```\n\nRequires: spaxel-54i","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T06:33:19.278442007Z","created_by":"coding","updated_at":"2026-04-07T17:56:41.358181685Z","closed_at":"2026-04-07T17:56:41.358116981Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4"],"dependencies":[{"issue_id":"spaxel-5yq","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.206754212Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"spaxel-65k","title":"Dashboard: activity timeline view","description":"## Overview\n\nBuild the unified activity timeline — the primary event history UI, accessible via the #timeline route added in the dashboard framework bead.\n\n## What to build (dashboard/js/timeline.js + timeline.css)\n\n### Timeline sidebar\n- Scrollable chronological event list (newest at top)\n- Event types with distinct icons/colors:\n - Zone entry/exit (green/orange)\n - Portal crossing (blue arrow)\n - Anomaly / security alert (red pulse)\n - Learning milestone (purple star)\n - System event (grey gear)\n- Each event shows: timestamp, description, person name (if identified), zone name\n- Click event → jump to that moment in the 3D view (triggers replay seek to that timestamp)\n\n### Filter bar\n- Filter by: person, zone, event type, time range (today / last 7d / custom)\n- Search box with debounced text filter across event descriptions\n\n### Inline feedback\n- Thumbs up / thumbs down on presence detection events\n- POST /api/feedback with event_id and correct (bool)\n- System response toast: 'Thanks — threshold adjusted for kitchen link'\n\n### Data source\n- Initial load: GET /api/events?limit=200&since=24h\n- Live updates: 'event' messages from WebSocket feed (requires spaxel-9eg)\n\n## Acceptance\n\n- 200 events render within 200ms of page load\n- New events prepend without layout shift\n- Clicking an event in replay mode seeks the replay to ±5s around the event\n- Feedback buttons POST successfully and show toast confirmation","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:56:03.195915329Z","created_by":"coding","updated_at":"2026-04-06T16:01:48.118589901Z","closed_at":"2026-04-06T16:01:48.118470381Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]}
@@ -55,7 +55,7 @@
{"id":"spaxel-b6a","title":"Implement calibration POST endpoint","description":"## Task\nImplement POST /api/floorplan/calibrate endpoint.\n\n## Specification\n- Accept {ax,ay,bx,by,distance_m,rotation_deg}: two pixel coordinates and their real-world distance\n- Compute and persist pixel-to-meter transform to SQLite floorplan table\n\n## Acceptance\n- Calibration data persists to SQLite floorplan table","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:52.516620827Z","created_by":"coding","updated_at":"2026-04-07T18:53:06.721066Z","closed_at":"2026-04-07T18:53:06.720893263Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]}
{"id":"spaxel-bf5","title":"Build crowd flow visualization","description":"Create visualization tools for occupancy patterns and movement.\n\nDeliverables:\n- Trajectory accumulation over time\n- Directional flow map rendering\n- Dwell time hotspot visualization\n\nAcceptance: Dashboard shows accumulated movement patterns and hotspots.","status":"closed","priority":2,"issue_type":"task","assignee":"sp3","created_at":"2026-03-29T19:25:04.155117811Z","created_by":"coding","updated_at":"2026-03-29T19:39:12.378329742Z","closed_at":"2026-03-29T19:39:12.378228906Z","close_reason":"Implemented crowd flow visualization with three components:\n\nBackend (Go):\n- FlowAccumulator records trajectory segments and dwell time in SQLite\n- REST endpoints for flow map, dwell heatmap, and detected corridors\n- Bresenham rasterization, angular variance analysis, connected component labeling\n\nFrontend (JavaScript):\n- Pattern controls in dashboard sidebar (flows, dwell, corridors toggles)\n- Time filter dropdown (7d, 30d, all time)\n- 3D visualization with ArrowHelper, PlaneGeometry, pulsating animations\n\nFiles: dashboard/index.html, dashboard/js/app.js, mothership/internal/analytics/","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-i28"]}
{"id":"spaxel-bsek","title":"Add spatial quick actions","description":"Right-click context menus on 3D elements with follow camera functionality.","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-10T02:03:09.410752186Z","created_by":"coding","updated_at":"2026-04-10T02:54:38.754127232Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-17u"]}
-{"id":"spaxel-btj","title":"Pre-deployment simulator","description":"## Background\n\nA person considering buying 4+ ESP32-S3 nodes wants to know: will this work in my house? Where should I put the nodes? How accurate will it be with 4 nodes vs 6? The pre-deployment simulator lets users model their space, place virtual nodes, and run synthetic walkers to get an expected accuracy estimate — all before spending any money or touching hardware. It is also a powerful teaching tool for understanding how WiFi CSI sensing works.\n\n## Simulator Mode\n\nNew dashboard route /simulate. A separate UI state that reuses the 3D scene infrastructure but replaces all live data with simulated data.\n\nThe simulator does NOT connect to any hardware or the mothership's live pipeline. It runs entirely in the browser with synthetic CSI generation via a WebAssembly module or JavaScript implementation of the physics model.\n\n(Alternative: run the simulation on the mothership server side, with a separate \"simulation session\" API endpoint. This is more complex but allows reusing the Go signal processing code directly. Recommend the server-side approach for accuracy.)\n\n## Space Editor\n\nReuses the 3D editor from the node placement UI (spaxel-qq6). Additional tools for the simulator:\n- Wall tool: draw lines on the floor plane that represent walls. Walls are stored as line segments and affect the path loss model (each wall crossing reduces RSSI by ~3-6 dB, configurable).\n- Furniture tool: place box-shaped obstacle volumes. Obstacles block direct path and affect Fresnel zone intersection.\n- Room dimensions tool: define the room boundaries (already in node placement UI).\n\n## Virtual Node Placement\n\nSame node placement UI as the real dashboard. Virtual nodes have the same placement interface but no hardware connection. Node icons are greyed out to indicate virtual.\n\nThe placement UI renders the GDOP overlay (from Phase 3, spaxel-qq6) using the virtual node positions. This is the same GDOP computation as the real system.\n\n## Synthetic Walkers\n\n1-4 animated figures that move through the virtual space. Walker types:\n- Random walk: starts at a random position, takes random steps of 0.3-0.8m every 500ms, bounces off walls.\n- Path walk: user draws a polyline path in the 3D view. Walker follows the path and loops.\n- Zone walk: user selects a set of zones. Walker randomly transitions between zones using the zone transition portal geometry.\n\nWalker animation: rendered as the same humanoid mesh used for real blobs in the 3D view but with a distinctive \"ghost\" colour (semi-transparent white) to distinguish from real detections.\n\n## Simulated CSI Generation\n\nFor each virtual walker position at each 100ms timestep, compute the expected CSI frame for each virtual node pair:\n\n1. Path loss: RSSI = RSSI_at_1m - 20*log10(d) - n_walls * wall_attenuation_db\n where d = direct path distance, n_walls = number of walls crossed, wall_attenuation_db = 4 dB default.\n\n2. Fresnel zone contribution: for the walker at position P, compute the Fresnel zone overlap fraction for each link (TX, RX). This uses the same geometry as the FusionEngine (spaxel-m9a).\n\n3. deltaRMS simulation: expected_delta_rms = fresnel_overlap * signal_amplitude + gaussian_noise(sigma)\n signal_amplitude = 0.05 * exp(-distance_from_fresnel_centre / sigma_fresnel) where sigma_fresnel = 0.3m.\n gaussian_noise sigma is calibrated from real-world measurements (see docs/research/ for empirical noise floor).\n\n4. Generate binary CSI frame in the same format as real hardware (24-byte header + I/Q payload). Feed through the actual mothership signal processing pipeline via a simulation API endpoint.\n\n## Pipeline Integration\n\nThe mothership exposes a simulation API: POST /api/simulate/session creates a simulation session. Within a session:\n- Virtual nodes are registered as if they had connected via WebSocket\n- Synthetic frames are injected via POST /api/simulate/session/{id}/frames\n- The standard processing pipeline runs on the injected frames\n- Blob positions are returned in the response\n\nThe dashboard simulator mode polls this API to get blob positions for each simulation timestep.\n\n## Accuracy Estimation\n\nAfter running the simulation with N walkers for 30 seconds:\n- Collect all blob positions from the mothership pipeline\n- Compare to walker ground truth positions (known from the simulation)\n- Compute median position error: median(|blob_position - walker_position|) for matched pairs\n- Compute false positive rate: blob detections when no walker is in that area\n- Compute recall: fraction of walker positions that had a matched blob within 1m\n\nReport: \"With this layout, expected accuracy is ±{N}m median error, {M}% detection rate.\"\n\n## Recommendations Engine\n\nBased on the simulation results, generate actionable layout recommendations:\n- \"Adding a node near the hallway would reduce the east-side dead zone by ~30% GDOP improvement.\"\n- \"Node A and Node B are nearly collinear. Moving Node B 1.5m to the left would improve coverage.\"\n- \"With 4 nodes, you can achieve ±0.8m accuracy. Adding a 5th node would improve to ±0.5m.\"\n\nThese recommendations are generated by:\n1. Running the GDOP computation for the current layout\n2. Identifying zones with GDOP > 2.5 (poor coverage) — dead zone detection\n3. Trying a set of candidate additional-node positions and computing GDOP improvement\n\n## Shopping List\n\nBased on the virtual node count in the simulation:\n\"For this layout you need: {N} × ESP32-S3 Development Board, {N} × USB-C Power Supply (5V 1A), {N} × Adhesive Cable Clips for routing.\"\nInclude a pre-filled Amazon search URL template (not an affiliate link, just a query).\n\n## Files to Create or Modify\n\n- mothership/internal/simulator/session.go: SimulationSession, synthetic frame injection API\n- mothership/internal/simulator/physics.go: path loss model, Fresnel zone CSI generation\n- mothership/internal/simulator/accuracy.go: accuracy estimation, recommendation engine\n- dashboard/js/simulate.js: simulator UI, walker rendering, recommendations display\n- mothership/internal/dashboard/routes.go: POST/GET /api/simulate/ endpoints\n\n## Tests\n\n- Test Fresnel zone CSI simulation: walker at the midpoint of a TX-RX link should produce delta_rms > 0.03; walker at 2m off-axis should produce delta_rms < 0.01\n- Test path loss model: d=1m, n_walls=0 -> RSSI = RSSI_at_1m; d=2m, n_walls=1 -> RSSI = RSSI_at_1m - 6 - 4 = -10 dB relative\n- Test accuracy estimation: 1 walker at known position, simulation produces 1 blob within 0.5m -> accuracy report shows ≤ 0.5m error\n- Test recommendations engine: GDOP > 2.5 in east corner -> recommendation to add node near east corner\n\n## Acceptance Criteria\n\n- Simulator runs without any hardware (all computation in mothership API + browser)\n- GDOP overlay renders correctly for virtual node placements\n- Synthetic walkers produce blob detections via the real mothership pipeline\n- Accuracy estimate is produced after 30-second simulation run\n- Recommendation engine suggests at least one improvement for any layout with a dead zone\n- Shopping list rendered with correct node count\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:56:54.736166126Z","created_by":"coding","updated_at":"2026-04-09T23:28:13.602020044Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-btj","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.783856424Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-btj","depends_on_id":"spaxel-o0e","type":"blocks","created_at":"2026-03-28T01:58:40.668968235Z","created_by":"coding","metadata":"{}","thread_id":""}]}
+{"id":"spaxel-btj","title":"Pre-deployment simulator","description":"## Background\n\nA person considering buying 4+ ESP32-S3 nodes wants to know: will this work in my house? Where should I put the nodes? How accurate will it be with 4 nodes vs 6? The pre-deployment simulator lets users model their space, place virtual nodes, and run synthetic walkers to get an expected accuracy estimate — all before spending any money or touching hardware. It is also a powerful teaching tool for understanding how WiFi CSI sensing works.\n\n## Simulator Mode\n\nNew dashboard route /simulate. A separate UI state that reuses the 3D scene infrastructure but replaces all live data with simulated data.\n\nThe simulator does NOT connect to any hardware or the mothership's live pipeline. It runs entirely in the browser with synthetic CSI generation via a WebAssembly module or JavaScript implementation of the physics model.\n\n(Alternative: run the simulation on the mothership server side, with a separate \"simulation session\" API endpoint. This is more complex but allows reusing the Go signal processing code directly. Recommend the server-side approach for accuracy.)\n\n## Space Editor\n\nReuses the 3D editor from the node placement UI (spaxel-qq6). Additional tools for the simulator:\n- Wall tool: draw lines on the floor plane that represent walls. Walls are stored as line segments and affect the path loss model (each wall crossing reduces RSSI by ~3-6 dB, configurable).\n- Furniture tool: place box-shaped obstacle volumes. Obstacles block direct path and affect Fresnel zone intersection.\n- Room dimensions tool: define the room boundaries (already in node placement UI).\n\n## Virtual Node Placement\n\nSame node placement UI as the real dashboard. Virtual nodes have the same placement interface but no hardware connection. Node icons are greyed out to indicate virtual.\n\nThe placement UI renders the GDOP overlay (from Phase 3, spaxel-qq6) using the virtual node positions. This is the same GDOP computation as the real system.\n\n## Synthetic Walkers\n\n1-4 animated figures that move through the virtual space. Walker types:\n- Random walk: starts at a random position, takes random steps of 0.3-0.8m every 500ms, bounces off walls.\n- Path walk: user draws a polyline path in the 3D view. Walker follows the path and loops.\n- Zone walk: user selects a set of zones. Walker randomly transitions between zones using the zone transition portal geometry.\n\nWalker animation: rendered as the same humanoid mesh used for real blobs in the 3D view but with a distinctive \"ghost\" colour (semi-transparent white) to distinguish from real detections.\n\n## Simulated CSI Generation\n\nFor each virtual walker position at each 100ms timestep, compute the expected CSI frame for each virtual node pair:\n\n1. Path loss: RSSI = RSSI_at_1m - 20*log10(d) - n_walls * wall_attenuation_db\n where d = direct path distance, n_walls = number of walls crossed, wall_attenuation_db = 4 dB default.\n\n2. Fresnel zone contribution: for the walker at position P, compute the Fresnel zone overlap fraction for each link (TX, RX). This uses the same geometry as the FusionEngine (spaxel-m9a).\n\n3. deltaRMS simulation: expected_delta_rms = fresnel_overlap * signal_amplitude + gaussian_noise(sigma)\n signal_amplitude = 0.05 * exp(-distance_from_fresnel_centre / sigma_fresnel) where sigma_fresnel = 0.3m.\n gaussian_noise sigma is calibrated from real-world measurements (see docs/research/ for empirical noise floor).\n\n4. Generate binary CSI frame in the same format as real hardware (24-byte header + I/Q payload). Feed through the actual mothership signal processing pipeline via a simulation API endpoint.\n\n## Pipeline Integration\n\nThe mothership exposes a simulation API: POST /api/simulate/session creates a simulation session. Within a session:\n- Virtual nodes are registered as if they had connected via WebSocket\n- Synthetic frames are injected via POST /api/simulate/session/{id}/frames\n- The standard processing pipeline runs on the injected frames\n- Blob positions are returned in the response\n\nThe dashboard simulator mode polls this API to get blob positions for each simulation timestep.\n\n## Accuracy Estimation\n\nAfter running the simulation with N walkers for 30 seconds:\n- Collect all blob positions from the mothership pipeline\n- Compare to walker ground truth positions (known from the simulation)\n- Compute median position error: median(|blob_position - walker_position|) for matched pairs\n- Compute false positive rate: blob detections when no walker is in that area\n- Compute recall: fraction of walker positions that had a matched blob within 1m\n\nReport: \"With this layout, expected accuracy is ±{N}m median error, {M}% detection rate.\"\n\n## Recommendations Engine\n\nBased on the simulation results, generate actionable layout recommendations:\n- \"Adding a node near the hallway would reduce the east-side dead zone by ~30% GDOP improvement.\"\n- \"Node A and Node B are nearly collinear. Moving Node B 1.5m to the left would improve coverage.\"\n- \"With 4 nodes, you can achieve ±0.8m accuracy. Adding a 5th node would improve to ±0.5m.\"\n\nThese recommendations are generated by:\n1. Running the GDOP computation for the current layout\n2. Identifying zones with GDOP > 2.5 (poor coverage) — dead zone detection\n3. Trying a set of candidate additional-node positions and computing GDOP improvement\n\n## Shopping List\n\nBased on the virtual node count in the simulation:\n\"For this layout you need: {N} × ESP32-S3 Development Board, {N} × USB-C Power Supply (5V 1A), {N} × Adhesive Cable Clips for routing.\"\nInclude a pre-filled Amazon search URL template (not an affiliate link, just a query).\n\n## Files to Create or Modify\n\n- mothership/internal/simulator/session.go: SimulationSession, synthetic frame injection API\n- mothership/internal/simulator/physics.go: path loss model, Fresnel zone CSI generation\n- mothership/internal/simulator/accuracy.go: accuracy estimation, recommendation engine\n- dashboard/js/simulate.js: simulator UI, walker rendering, recommendations display\n- mothership/internal/dashboard/routes.go: POST/GET /api/simulate/ endpoints\n\n## Tests\n\n- Test Fresnel zone CSI simulation: walker at the midpoint of a TX-RX link should produce delta_rms > 0.03; walker at 2m off-axis should produce delta_rms < 0.01\n- Test path loss model: d=1m, n_walls=0 -> RSSI = RSSI_at_1m; d=2m, n_walls=1 -> RSSI = RSSI_at_1m - 6 - 4 = -10 dB relative\n- Test accuracy estimation: 1 walker at known position, simulation produces 1 blob within 0.5m -> accuracy report shows ≤ 0.5m error\n- Test recommendations engine: GDOP > 2.5 in east corner -> recommendation to add node near east corner\n\n## Acceptance Criteria\n\n- Simulator runs without any hardware (all computation in mothership API + browser)\n- GDOP overlay renders correctly for virtual node placements\n- Synthetic walkers produce blob detections via the real mothership pipeline\n- Accuracy estimate is produced after 30-second simulation run\n- Recommendation engine suggests at least one improvement for any layout with a dead zone\n- Shopping list rendered with correct node count\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:56:54.736166126Z","created_by":"coding","updated_at":"2026-04-11T02:05:31.657183335Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:5"],"dependencies":[{"issue_id":"spaxel-btj","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.783856424Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-btj","depends_on_id":"spaxel-o0e","type":"blocks","created_at":"2026-03-28T01:58:40.668968235Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"spaxel-c02","title":"Mothership: comprehensive /healthz endpoint","description":"## Overview\nExpand the /healthz endpoint from the current minimal response to the full spec required for Docker HEALTHCHECK and Traefik health routing.\n\n## Current state\nGET /healthz returns: {status:'ok', version:'...'} — missing required fields\n\n## Required response (plan lines 3507-3510):\nHealthy: HTTP 200 — {status:'ok', uptime_s:N, version:'...', nodes_online:N, db:'ok', load_level:0-3}\nDegraded: HTTP 503 — {status:'degraded', reason:'...', uptime_s:N, nodes_online:N, db:'ok'|'failing', load_level:0-3}\n\n## Implementation\n- Track start_time at mothership boot; compute uptime_s = int(time.Since(start).Seconds())\n- nodes_online: query ingestion server's connected node count (atomic counter)\n- db health: run 'SELECT 1' against SQLite with 100ms timeout; 'ok' or 'failing'\n- load_level: read from load shedding state (spaxel-zvb)\n- Degraded conditions: db='failing', or load_level=3 for >60s, or nodes_online=0 after 5min uptime\n- reason field: human-readable explanation of degradation\n\n## Docker integration\nUpdate Dockerfile HEALTHCHECK:\nHEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -qO- http://localhost:8080/healthz | grep -q '\"status\":\"ok\"' || exit 1\n\n## Acceptance\n- GET /healthz returns all required fields with correct types\n- HTTP 503 returned when SQLite unreachable (test by renaming DB file)\n- uptime_s increments correctly across multiple calls\n- Docker HEALTHCHECK transitions to 'healthy' within start-period","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:43:31.728532118Z","created_by":"coding","updated_at":"2026-04-07T15:14:36.243697798Z","closed_at":"2026-04-07T15:14:36.243595147Z","close_reason":"Comprehensive /healthz endpoint already implemented in commit e44dd34. All required fields present: status, uptime_s, version, nodes_online, db, load_level, reason. Degraded conditions: DB failing, load_level=3 >60s, no nodes after 5min. HTTP 200/503 codes. Docker HEALTHCHECK configured.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3"]}
{"id":"spaxel-c0q","title":"Phase 5: Reliability & Intelligence","description":"Goal: Production-quality detection for daily home use.\n\nDeliverables:\n- Diurnal adaptive baseline (24-slot hourly vectors, 7-day learning, crossfade, confidence indicator)\n- Stationary person detection (breathing band 0.1-0.5 Hz, long-dwell logic)\n- Ambient confidence score (per-link health: SNR, phase stability, packet rate, drift; composite gauge)\n- Self-healing fleet (auto role re-optimization on node loss/recovery, coverage comparison)\n- Link weather diagnostics (root-cause suggestions, weekly trends, repositioning advice)\n\nExit criteria: System runs unattended 7+ days with <5% false positive rate.","status":"closed","priority":3,"issue_type":"phase","assignee":"delta","created_at":"2026-03-27T01:55:24.131799292Z","created_by":"coding","updated_at":"2026-03-29T16:17:57.940335180Z","closed_at":"2026-03-29T16:17:57.940275703Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-c0q","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T01:33:43.508871095Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"spaxel-c1c","title":"Fall detection","description":"## Background\n\nFall detection is one of the highest-value safety features for homes with elderly residents or people with medical conditions. Approximately 1 in 4 adults over 65 fall each year, and a fall without prompt help can be fatal. A system that detects falls without requiring the person to press a button (wearable panic buttons are often not worn consistently) can be life-saving.\n\nA CSI-based fall detector works by analysing the track's Z-position history from the biomechanical blob tracker (spaxel-n9n). A fall is characterised by: rapid Z-axis descent (person goes from standing height ~1.7m to floor level ~0.2m in less than 1 second) followed by sustained stillness (no motion for > 30 seconds at floor level). False positives — sitting down quickly, a child lying on the floor, someone doing yoga — must be minimised through careful gating and the post-fall confirmation step.\n\n## Fall Detection Algorithm\n\nImplemented in mothership/internal/tracker/fall.go or as a method on TrackManager.\n\nState machine per track:\n- NORMAL: no fall detected\n- DESCENT_DETECTED: Z velocity trigger fired, monitoring for confirmation\n- FALL_CONFIRMED: post-fall stillness confirmed, alert active\n- ALERT_ACKNOWLEDGED: user acknowledged, monitoring continues\n\nStep 1 — Descent trigger:\nMonitor Z position history over a 200ms rolling window. Compute Z velocity (dZ/dt in m/s).\nTrigger condition: Z velocity < -1.5 m/s AND total Z drop in last 1s > 0.8m.\nOn trigger: transition to DESCENT_DETECTED, record trigger_time.\n\nStep 2 — Post-fall confirmation (the critical false-positive reduction step):\nAfter the descent trigger fires:\na. Check that current Z position is < 0.4m (person is at floor level, not just crouching)\nb. Check that motion (deltaRMS from the contributing links) is < 0.01 for > 30 consecutive seconds\nIf both conditions are met: transition to FALL_CONFIRMED, fire alert chain.\nIf Z rises above 0.4m within the 30-second window (person got up): cancel, return to NORMAL.\n\nStep 3 — False positive suppression:\nApply a higher threshold (drop > 1.2m instead of 0.8m) if the track has a \"sitting\" or \"lying\" posture classification in the last 10 minutes (from posture hints in spaxel-n9n). This reduces false alarms for people who sit down quickly or lie down intentionally.\nApply no fall detection in user-defined \"children's zones\" (zones with the is_children_zone flag) where floor-level activity is normal.\n\n## Alert Chain\n\n1. T+0s: FALL_CONFIRMED transition fires.\n Dashboard: full-width banner with high urgency styling. \"Possible fall detected — [person name] in [zone name]. Are you OK? [Acknowledge] [Call for help]\"\n Browser notification API (if permission granted): \"Spaxel: Possible fall detected — Alice in Hallway\"\n\n2. T+2 minutes (without user acknowledgement):\n Webhook/MQTT alert fires (via AutomationEngine with trigger type \"fall_detected\").\n Push notification via configured channel (ntfy/Pushover).\n\n3. T+5 minutes (without acknowledgement):\n Escalation: second webhook fires to any configured escalation URL (separate from primary webhook). Push notification with \"URGENT\" prefix. Dashboard banner pulses red.\n\nAcknowledgement: the [Acknowledge] button on the dashboard banner:\n- Transitions state to ALERT_ACKNOWLEDGED\n- Cancels the T+2min and T+5min timers\n- Logs the acknowledgement in the activity timeline with timestamp\n- Shows a brief form: \"How is [person name]?\" — [Fine, just fell] / [Needed help] / [False alarm]\n- Feedback stored for accuracy tracking (Phase 7)\n\n## Person Identification in Alerts\n\nIf the track has a confirmed BLE identity:\n- Alert: \"Possible fall detected — Alice in Hallway\"\n- Notification: includes person name and zone\n\nIf the track is anonymous:\n- Alert: \"Possible fall detected — unknown person in Hallway\"\n- Fall event still fires and escalates on the same timeline\n\n## Children's Zones\n\nAdd is_children_zone boolean flag to the zones table (see portals bead). A zone marked as children's zone suppresses fall detection for all tracks within it. Set via zone editor in the dashboard. Rationale: young children spend significant time at floor level (crawling, playing), and false fall alerts in a nursery or playroom are highly counterproductive.\n\n## Z Position Accuracy Dependency\n\nFall detection quality is directly tied to the accuracy of 3D position estimates from the tracker (spaxel-n9n). The UKF state includes Z position, but Z is the hardest axis to localise with WiFi CSI — the nodes are typically all at similar heights (wall-mounted or tabletop), so the vertical Fresnel zone geometry is poor. In practice:\n- Z accuracy is typically ±0.3m for direct LoS links\n- Fall detection requires a Z drop of > 0.8m, which should be detectable even with ±0.3m Z error\n- The 30-second stillness confirmation is the primary false-positive filter — it tolerates poor Z accuracy\n\nDocument this in the dashboard: \"Fall detection works best with nodes at different heights (e.g. one high, one low). Single-level node deployment may miss smaller Z changes.\"\n\n## Integration with Existing Systems\n\nFallDetector subscribes to TrackManager updates (the same 10 Hz update loop as identity matching). It receives the full TrackState including position history and posture hints.\n\nFallDetector emits FallEvent to the internal event bus on FALL_CONFIRMED. The event bus (Phase 8 activity timeline bead) logs it to the timeline. The AutomationEngine processes it for automation triggers. The spatial context notifications module (Phase 6) sends the push notification.\n\n## Files to Create or Modify\n\n- mothership/internal/tracker/fall.go: FallDetector struct, state machine, descent/stillness detection\n- mothership/internal/tracker/manager.go: integrate FallDetector in update loop\n- mothership/internal/events/events.go: FallEvent type\n- dashboard/js/fall.js: fall alert banner, acknowledgement handler, escalation countdown\n- mothership/internal/dashboard/routes.go: POST /api/fall/{event_id}/acknowledge\n\n## Tests\n\n- Test descent trigger: inject synthetic track with Z velocity = -2.0 m/s over 500ms and drop from 1.7m to 0.2m. Verify DESCENT_DETECTED transition.\n- Test post-fall confirmation: after descent trigger, inject 30 seconds of deltaRMS < 0.005 at Z = 0.2m. Verify FALL_CONFIRMED transition.\n- Test that a quick sit-down (Z drops from 1.7m to 0.5m at -1.0 m/s — below speed threshold) does NOT trigger DESCENT_DETECTED.\n- Test false positive suppression: track has \"sitting\" posture history, then fall of 0.9m fires — verify higher threshold (1.2m) is applied and no trigger for 0.9m drop.\n- Test alert chain timing: verify T+2min and T+5min timers fire without acknowledgement.\n- Test that acknowledgement cancels pending timers.\n- Test that children's zone flag suppresses fall detection.\n\n## Acceptance Criteria\n\n- Fall detector transitions to FALL_CONFIRMED on a simulated fall trajectory (2.0 m/s descent, 1.5m drop, 30s floor stillness)\n- Post-fall stillness confirmation fires after 30 consecutive seconds of stillness at Z < 0.4m\n- False positive suppression reduces alerts for quick sit-down actions (drop < 0.8m or speed < 1.5 m/s)\n- Alert chain fires at T+0, T+2min, T+5min without acknowledgement\n- Acknowledgement correctly cancels pending escalation timers\n- Person name included in alert when BLE identity is confirmed\n- Children's zone flag suppresses all fall detection for tracks within that zone\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:47:29.093424440Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.730764084Z","closed_at":"2026-03-29T18:07:39.730401434Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-c1c","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.322493912Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-c1c","depends_on_id":"spaxel-n9n","type":"blocks","created_at":"2026-03-28T01:47:33.774431809Z","created_by":"coding","metadata":"{}","thread_id":""}]}
@@ -89,7 +89,7 @@
{"id":"spaxel-iv3","title":"Dashboard: detection explainability overlay","description":"## Overview\n\nUsers need to understand why a blob is detected where it is. The 'Why is this here?' overlay is the primary explainability tool and a key trust-building feature.\n\n## What to build\n\n### Trigger\n- Right-click (or long-press) on a blob in the 3D view → context menu includes 'Why is this here?'\n- Also accessible from the blob's info panel\n\n### Overlay (dashboard/js/explainability.js)\n- Dim all scene elements except contributing links and the selected blob (THREE.js material opacity)\n- Highlight contributing links with a glowing yellow shader (MeshLineMaterial or emissive)\n- Render Fresnel zone ellipsoids for each contributing link at reduced opacity\n- Show intersection zones where Fresnel ellipsoids overlap (this is the detection hotspot)\n\n### Sidebar panel\n- Title: 'Detection confidence: 87%'\n- Per-link contribution table:\n | Link | deltaRMS | Zone # | Weight | Contributing? |\n |------|----------|--------|--------|---------------|\n | A:B | 0.041 | 1 | 0.34 | ✓ |\n- BLE match section (if applicable): 'Matched: Alice's iPhone (96% confidence)'\n- 'Close' button restores normal scene\n\n### Data source\n- GET /api/explain/{blob_id} — returns per-link contributions, confidence breakdown, BLE match\n- Must be implemented server-side (mothership/internal/fusion or localization)\n\n## Acceptance\n\n- Overlay renders within 300ms of user action\n- Non-contributing links are visually distinct from contributing ones\n- Fresnel ellipsoids visible and correctly scaled\n- Close button fully restores scene state","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:56:27.780919021Z","created_by":"coding","updated_at":"2026-04-06T17:31:34.658876657Z","closed_at":"2026-04-06T17:31:34.658774555Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:5"]}
{"id":"spaxel-jc4","title":"Self-healing fleet with role re-optimisation","description":"## Background\n\nIn a home deployment, nodes experience disruptions: power outages causing reboots, firmware crashes, being physically moved by a curious family member, or being temporarily unplugged. The fleet manager (spaxel-8u3) handles basic node connection management. This bead adds intelligence: when the set of available nodes changes, the system recomputes the optimal role assignment — which node should be TX, which should be RX, which should be passive radar — to maximise coverage over the occupied space.\n\nThis is \"self-healing\" in the sense that the system automatically recovers from topology changes without user intervention, maintaining the best possible detection coverage with whatever nodes are currently available.\n\n## Role Assignment Background\n\nA spaxel node can operate in one of four roles:\n- RX: passive receiver, listens for CSI from other nodes\n- TX: active transmitter, sends at configured rate\n- TX-RX: both transmits and receives (most flexible but uses more power)\n- Passive: uses existing WiFi traffic as CSI source (no active transmission)\n\nA sensing link requires one TX and one RX. With N nodes, the number of possible links is bounded by the assignment. Orthogonal links (non-collinear Fresnel zones) provide better coverage than parallel links. The optimal assignment maximises the number of orthogonally-placed links covering the occupied zones.\n\n## RoleOptimiser\n\nNew struct RoleOptimiser in mothership/internal/fleet/optimiser.go.\n\nInputs:\n- NodeList: current connected nodes with their 3D positions (from placement UI, spaxel-qq6)\n- NodeCapabilities: map from MAC to {canTX bool, canRX bool, hardwareType string} (from hello message capabilities field)\n- LinkHealthScores: current health scores from ambient confidence bead (spaxel-sbi)\n- RoomConfig: room dimensions and zone occupancy from config\n\nOutput:\n- RoleAssignment: map from MAC to Role\n\nOptimisation algorithm (greedy, O(n^2) which is fine for n <= 16 nodes):\n1. Start with all nodes unassigned\n2. For each pair of nodes (A, B) that both have adequate health and are within sensing range:\n - Compute the angle between their TX-RX axis and all other assigned TX-RX axes\n - Score = sum of min angular separation to each existing assigned link axis (penalise near-parallel links)\n3. Assign the pair with the highest score as TX-RX\n4. Repeat for remaining unassigned nodes until all capable nodes are assigned\n5. For nodes that cannot improve coverage (e.g. co-located with an existing node), assign as passive\n\nThe GDOP computation from Phase 3 (spaxel-qq6 coverage painting) provides a more principled score: compute GDOP for the candidate assignment and maximise the average GDOP over occupied zones. If the GDOP computation is available, use it; otherwise fall back to the angular separation heuristic.\n\n## Graceful Degradation on Node Loss\n\nWhen a node disconnects (WebSocket closes), the fleet manager immediately:\n\n1. Marks the node as OFFLINE in the node registry\n2. Identifies which sensing links have been lost (links where the offline node was TX or RX)\n3. Calls RoleOptimiser.Optimise(currentAvailableNodes) to get the new optimal assignment\n4. Compares old GDOP to new GDOP:\n - If new GDOP >= old GDOP * 0.9 (no significant coverage degradation): apply new assignment silently\n - If new GDOP < old GDOP * 0.9 (significant coverage degradation): apply new assignment AND show dashboard warning\n5. Broadcasts RoleChange commands to all affected surviving nodes via WebSocket\n6. Broadcasts fleet_change event to dashboard with before/after GDOP overlay data\n\nDashboard warning when coverage is significantly degraded:\n\"Detection accuracy reduced — Node [label] is offline. [Zone name] coverage dropped from [N]% to [M]%. [View impact] [Dismiss]\"\nThe \"View impact\" button shows a side-by-side 3D GDOP overlay comparison.\n\n## 5-Minute Reconnect Window\n\nIf the offline node reconnects within 5 minutes of going offline:\n- Do NOT run the optimiser again\n- Restore the node's previous role assignment from the node registry\n- Send the role push command to the reconnected node\n- Clear the coverage reduction warning in the dashboard\n- Log: \"Node [label] reconnected — restoring previous role\"\n\nThis prevents unnecessary churn when nodes experience brief power blips or firmware restarts. The 5-minute window is configurable via mothership config (fleet.reconnect_grace_period_seconds, default 300).\n\n## Before/After Coverage Comparison\n\nWhen re-optimisation occurs, compute GDOP for the old and new assignments and store both in the fleet_change event:\n- gdop_before: 2D array of GDOP values over the floor plan grid with old assignment\n- gdop_after: 2D array with new assignment\n- coverage_change_pct: percentage change in occupied-zone coverage\n\nThe dashboard 3D view can render these as two overlaid heat maps (before in red, after in green, with a blend slider).\n\n## Dashboard Fleet Health Panel\n\nAdd a \"Fleet Health\" section to the dashboard (sidebar panel or dedicated route):\n- Current role assignment: table showing each node, its role, and health score\n- Coverage quality: Detection Quality gauge (from ambient confidence bead) and GDOP overlay toggle\n- Re-optimisation history: last 5 optimisation events with timestamps, trigger reason, and GDOP change\n- \"Optimise Now\" button: manually triggers the optimiser regardless of coverage change threshold\n- \"Simulate node removal\" tool: shows predicted coverage impact if a specific node were removed (useful for planning maintenance)\n\n## Files to Create or Modify\n\n- mothership/internal/fleet/optimiser.go: RoleOptimiser struct and Optimise() method\n- mothership/internal/fleet/manager.go: extend with reconnect grace period, degradation detection\n- mothership/internal/fleet/manager.go: add fleet_change event emission\n- dashboard/js/fleet.js: fleet health panel, before/after GDOP comparison view\n- mothership/internal/dashboard/routes.go: GET /api/fleet/history, POST /api/fleet/optimise\n\n## Tests\n\n- Test that role optimiser selects the most orthogonal link pair from a set of 4 nodes at known positions\n- Test graceful degradation: when a node goes offline, the optimiser produces a valid assignment for the remaining nodes\n- Test 5-minute reconnect window: reconnecting node within 300s restores previous role without re-optimisation\n- Test that the reconnect window expires correctly at 300s and the optimised assignment is kept\n- Test GDOP comparison logic: when new GDOP >= old * 0.9, no dashboard warning; when < 0.9, warning fires\n- Test fleet_change event contains correct before/after GDOP data\n\n## Acceptance Criteria\n\n- Node loss triggers role re-optimisation within 10 seconds\n- Dashboard shows coverage impact for significant degradation (>10% GDOP change)\n- Reconnecting nodes within 5 minutes restore their previous role without unnecessary re-optimisation\n- Re-optimisation does not disrupt surviving links (surviving nodes receive new role commands without dropouts)\n- Coverage comparison overlay visible in dashboard when re-optimisation is triggered\n- \"Optimise Now\" manual trigger works\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-03-28T01:42:17.825002481Z","created_by":"coding","updated_at":"2026-04-09T13:18:43.622793328Z","closed_at":"2026-04-09T13:18:43.622647740Z","close_reason":"Implementation verified complete. All required functionality already exists in the codebase:\n\n- RoleOptimiser with GDOP-based greedy optimisation algorithm\n- SelfHealManager with 5-minute reconnect grace period and graceful degradation detection\n- FleetHandler REST API endpoints (/api/fleet/health, /api/fleet/history, /api/fleet/optimise, /api/fleet/simulate)\n- Dashboard fleet health panel with re-optimisation history and GDOP comparison overlay\n- Comprehensive test coverage across selfheal_test.go, healer_test.go, fleet_test.go\n\nNote: Go not installed on this system, so go test/go vet could not be run. Code review confirms all requirements are implemented.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:923"]}
{"id":"spaxel-jcc","title":"Reintegrate phase 6+ packages into default build","description":"## Problem\n\nmain_phase6.go uses a 'phase6' build tag which excludes all Phase 6+ code from the default binary. All backend work from Phase 6 onward is dead code that never runs.\n\n## What needs to change\n\n- Remove the 'phase6' build tag gating from main_phase6.go (or merge into cmd/mothership/main.go)\n- Ensure all packages under: automation/, events/, notify/, replay/, prediction/, learning/, analytics/, sleep/, tracker/, zones/, mqtt/ are wired into the server at startup\n- Run 'go build ./...' to confirm all packages compile cleanly\n- Run 'go test ./...' — all tests must pass\n\n## Files to check\n\n- mothership/cmd/mothership/main.go\n- mothership/cmd/mothership/main_phase6.go (if it exists)\n- Any file with '//go:build phase6'\n\n## Acceptance\n\n'go build -o /dev/null ./...' succeeds with no build tags. All routes, goroutines, and managers from phase 6 packages are initialized in main().","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:55:18.201589244Z","created_by":"coding","updated_at":"2026-04-07T06:29:44.917129307Z","closed_at":"2026-04-07T06:29:44.917067598Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","failure-count:82"],"dependencies":[{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:30:41.001290765Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:30:41.103813566Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:30:40.971412241Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:30:40.945729745Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:30:41.126328414Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:30:41.045136934Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-x59","type":"blocks","created_at":"2026-04-06T22:30:41.167499700Z","created_by":"coding","metadata":"{}","thread_id":""}]}
-{"id":"spaxel-jk0","title":"Simple mode with progressive disclosure","description":"## Background\n\nThe 3D expert interface is powerful but overwhelming for casual household members who just want to know \"is anyone home?\" or \"is the baby still asleep?\". Simple mode is a card-based, mobile-first UI that surfaces the most important information without any 3D scene. It should be usable by anyone who can read a smartphone screen, including elderly family members and non-technical partners who did not install the system.\n\nProgressive disclosure means: start with the simplest possible view, and let users reach more complexity when they want it — without being exposed to complexity they don't need.\n\n## Auto-Detection of Simple Mode\n\nSimple mode is automatically selected as the default view based on:\n1. Screen width < 768px (phones, small tablets in portrait)\n2. User-agent contains \"Mobile\" (additional phone detection signal)\n3. User has previously selected simple mode (localStorage \"spaxel_mode\" = \"simple\")\n\nExpert mode is the default for desktop browsers. A user can override the auto-detection and save their preference.\n\n## Room Occupancy Cards\n\nThe main view is a card grid (CSS Grid, 1 column on phones, 2 columns on small tablets). One card per defined zone.\n\nEach occupancy card shows:\n- Zone name (large, readable typography, min 20px font size)\n- Zone colour as a left border accent\n- Occupant count: large number\n- Named occupants: first names in a row (e.g. \"Alice, Bob\"). Anonymous tracks: \"1 person\"\n- Status icon: person silhouette if occupied, empty room icon if vacant\n- \"Last activity\" time: \"3 minutes ago\" (time since last zone transition event in this zone)\n\nAuto-update: cards update in real-time via WebSocket. When occupancy changes, the card animates briefly (a gentle pulse or highlight) to draw attention to the change.\n\nEmpty state (no zones defined): show a \"Get started\" prompt: \"Set up your rooms to see who's home. [Go to setup]\"\n\n## Activity Feed\n\nScrollable list below the zone cards (or as a separate tab). Shows the last 20 person-relevant events, filtered to exclude system noise (no node_connected, no weight_update events — only ZoneTransition, FallDetected, AnomalyDetected).\n\nEvent items: icon + one-line description + timestamp. Examples:\n- \"Alice walked into the Kitchen — 2 minutes ago\"\n- \"Bob left the house — 14 minutes ago\"\n- \"No one home since 8:32am\"\n- \"Possible fall: Alice in Hallway — 3 minutes ago [View] [Acknowledge]\"\n\nPlain English descriptions — no jargon. \"Possible fall\" not \"FallDetected event in zone_hallway_01.\"\n\nTap any event: shows a brief detail popup (not a full detail view). For zone transitions: \"Alice (via Living Room door) at 14:23\". For falls: the fall alert card with acknowledge button.\n\n## Alert Banner\n\nWhen an active unacknowledged alert exists (fall, anomaly, node offline), show a full-width banner at the top of the simple mode view:\n- Fall alert: red background, \"Possible fall — Alice in Hallway. [Acknowledge]\"\n- Anomaly (away mode): orange background, \"Movement detected while away. [View details]\"\n- Node offline: yellow background, \"Node Living Room went offline. [Help]\"\n\nAlerts are ordered by severity (fall > anomaly > node offline) if multiple are active. The [Help] button links to the troubleshooting flow (spaxel-r0l Phase 4 bead).\n\n## Sleep Summary Card\n\nA morning-only card, shown only between 6am and 11am on the day after a sleep session:\n- Position: above zone cards (highest priority in morning)\n- Content: \"Alice slept 7h 23m last night. 2 brief wake-ups. [View details]\"\n- If multiple people: stacked cards or a compact multi-person summary\n- Dismiss button: card hidden for today (localStorage flag \"spaxel_sleep_summary_{date}_shown\")\n- \"View details\" navigates to the Sleep panel in expert mode\n\n## Navigation\n\nBottom navigation bar (mobile-standard pattern): five tabs with icons + labels:\n1. Home (house icon) — occupancy cards (default tab)\n2. Activity (clock icon) — activity feed\n3. (empty centre spot for potential quick-action FAB in future)\n4. Alerts (bell icon) — active alerts and history. Badge with alert count.\n5. Settings (gear icon) — simplified settings: notification channel, person names, mode toggle\n\nThe Settings tab in simple mode shows only: display name for each person (from BLE registry), notification channel status (green = configured, grey = not set), and \"Switch to expert mode\" at the bottom.\n\n## Expert Mode Toggle\n\nA clearly labelled button: \"Expert Mode\" in the settings tab and as a persistent bottom-nav item. Tapping it:\n- If a PIN is configured (expert mode lock, settable in expert mode settings): shows PIN entry pad\n- If no PIN: immediately switches to expert mode\n- Saves preference to localStorage\n\nThe mode toggle is intentionally in settings/bottom-nav rather than prominently on the home screen — to reduce accidental switches for non-technical users.\n\n## Night Mode (OLED Dark)\n\nAuto-active during configured quiet hours (e.g. 10pm-7am). Uses CSS media query prefers-color-scheme: dark plus a manual override. OLED optimised: true black background (#000000), not just dark grey. This saves battery on OLED screens and reduces light disruption in bedrooms.\n\nAll card backgrounds in night mode: #0a0a0a (near-black). Text: #ffffff. Zone colour accents remain colourful.\n\n## Design System\n\nSimple mode uses a separate CSS file (dashboard/css/simple.css) that does NOT import from the expert mode styles. No Three.js canvas, no OrbitControls, no shader materials. Pure HTML + CSS + vanilla JS.\n\nFont size hierarchy: 14px minimum for secondary text, 16px for primary, 24px+ for zone names and counts. All interactive targets: minimum 44px height for WCAG AA touch target compliance.\n\n## Files to Create or Modify\n\n- dashboard/simple.html: simple mode HTML shell with bottom nav\n- dashboard/js/simple.js: card rendering, WebSocket updates, event feed\n- dashboard/js/simplemode.js: mode detection, localStorage mode preference\n- dashboard/css/simple.css: simple mode styles, night mode\n- mothership/internal/dashboard/routes.go: ensure /simple route is served\n\n## Tests\n\n- Test that room occupancy cards correctly reflect current zone state from WebSocket messages\n- Test activity feed filtering: inject a node_connected event and a zone_transition event; only the zone_transition should appear in simple mode feed\n- Test alert banner appears and dismisses correctly on acknowledge\n- Test mode toggle correctly switches between simple and expert mode routes\n- Test sleep summary card appears only between 6am-11am on the morning after a session\n- Test that occupancy card updates show the animated pulse on change\n- Test night mode activates based on quiet hours configuration\n\n## Acceptance Criteria\n\n- Simple mode loads correctly on a 320px wide screen (iPhone SE) without horizontal scrolling\n- Occupancy cards update in real-time as people move between zones\n- Activity feed shows only person-relevant events in plain English\n- Alert banner appears prominently and dismisses on acknowledge\n- Sleep summary card shown between 6am-11am after sleep session, dismissed when tapped\n- Mode toggle to expert mode works and saves preference\n- Night mode activates during configured quiet hours\n- All tap targets are at least 44px in height\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:59:32.690434334Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.851752276Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-jk0","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.851714754Z","created_by":"coding","metadata":"{}","thread_id":""}]}
+{"id":"spaxel-jk0","title":"Simple mode with progressive disclosure","description":"## Background\n\nThe 3D expert interface is powerful but overwhelming for casual household members who just want to know \"is anyone home?\" or \"is the baby still asleep?\". Simple mode is a card-based, mobile-first UI that surfaces the most important information without any 3D scene. It should be usable by anyone who can read a smartphone screen, including elderly family members and non-technical partners who did not install the system.\n\nProgressive disclosure means: start with the simplest possible view, and let users reach more complexity when they want it — without being exposed to complexity they don't need.\n\n## Auto-Detection of Simple Mode\n\nSimple mode is automatically selected as the default view based on:\n1. Screen width < 768px (phones, small tablets in portrait)\n2. User-agent contains \"Mobile\" (additional phone detection signal)\n3. User has previously selected simple mode (localStorage \"spaxel_mode\" = \"simple\")\n\nExpert mode is the default for desktop browsers. A user can override the auto-detection and save their preference.\n\n## Room Occupancy Cards\n\nThe main view is a card grid (CSS Grid, 1 column on phones, 2 columns on small tablets). One card per defined zone.\n\nEach occupancy card shows:\n- Zone name (large, readable typography, min 20px font size)\n- Zone colour as a left border accent\n- Occupant count: large number\n- Named occupants: first names in a row (e.g. \"Alice, Bob\"). Anonymous tracks: \"1 person\"\n- Status icon: person silhouette if occupied, empty room icon if vacant\n- \"Last activity\" time: \"3 minutes ago\" (time since last zone transition event in this zone)\n\nAuto-update: cards update in real-time via WebSocket. When occupancy changes, the card animates briefly (a gentle pulse or highlight) to draw attention to the change.\n\nEmpty state (no zones defined): show a \"Get started\" prompt: \"Set up your rooms to see who's home. [Go to setup]\"\n\n## Activity Feed\n\nScrollable list below the zone cards (or as a separate tab). Shows the last 20 person-relevant events, filtered to exclude system noise (no node_connected, no weight_update events — only ZoneTransition, FallDetected, AnomalyDetected).\n\nEvent items: icon + one-line description + timestamp. Examples:\n- \"Alice walked into the Kitchen — 2 minutes ago\"\n- \"Bob left the house — 14 minutes ago\"\n- \"No one home since 8:32am\"\n- \"Possible fall: Alice in Hallway — 3 minutes ago [View] [Acknowledge]\"\n\nPlain English descriptions — no jargon. \"Possible fall\" not \"FallDetected event in zone_hallway_01.\"\n\nTap any event: shows a brief detail popup (not a full detail view). For zone transitions: \"Alice (via Living Room door) at 14:23\". For falls: the fall alert card with acknowledge button.\n\n## Alert Banner\n\nWhen an active unacknowledged alert exists (fall, anomaly, node offline), show a full-width banner at the top of the simple mode view:\n- Fall alert: red background, \"Possible fall — Alice in Hallway. [Acknowledge]\"\n- Anomaly (away mode): orange background, \"Movement detected while away. [View details]\"\n- Node offline: yellow background, \"Node Living Room went offline. [Help]\"\n\nAlerts are ordered by severity (fall > anomaly > node offline) if multiple are active. The [Help] button links to the troubleshooting flow (spaxel-r0l Phase 4 bead).\n\n## Sleep Summary Card\n\nA morning-only card, shown only between 6am and 11am on the day after a sleep session:\n- Position: above zone cards (highest priority in morning)\n- Content: \"Alice slept 7h 23m last night. 2 brief wake-ups. [View details]\"\n- If multiple people: stacked cards or a compact multi-person summary\n- Dismiss button: card hidden for today (localStorage flag \"spaxel_sleep_summary_{date}_shown\")\n- \"View details\" navigates to the Sleep panel in expert mode\n\n## Navigation\n\nBottom navigation bar (mobile-standard pattern): five tabs with icons + labels:\n1. Home (house icon) — occupancy cards (default tab)\n2. Activity (clock icon) — activity feed\n3. (empty centre spot for potential quick-action FAB in future)\n4. Alerts (bell icon) — active alerts and history. Badge with alert count.\n5. Settings (gear icon) — simplified settings: notification channel, person names, mode toggle\n\nThe Settings tab in simple mode shows only: display name for each person (from BLE registry), notification channel status (green = configured, grey = not set), and \"Switch to expert mode\" at the bottom.\n\n## Expert Mode Toggle\n\nA clearly labelled button: \"Expert Mode\" in the settings tab and as a persistent bottom-nav item. Tapping it:\n- If a PIN is configured (expert mode lock, settable in expert mode settings): shows PIN entry pad\n- If no PIN: immediately switches to expert mode\n- Saves preference to localStorage\n\nThe mode toggle is intentionally in settings/bottom-nav rather than prominently on the home screen — to reduce accidental switches for non-technical users.\n\n## Night Mode (OLED Dark)\n\nAuto-active during configured quiet hours (e.g. 10pm-7am). Uses CSS media query prefers-color-scheme: dark plus a manual override. OLED optimised: true black background (#000000), not just dark grey. This saves battery on OLED screens and reduces light disruption in bedrooms.\n\nAll card backgrounds in night mode: #0a0a0a (near-black). Text: #ffffff. Zone colour accents remain colourful.\n\n## Design System\n\nSimple mode uses a separate CSS file (dashboard/css/simple.css) that does NOT import from the expert mode styles. No Three.js canvas, no OrbitControls, no shader materials. Pure HTML + CSS + vanilla JS.\n\nFont size hierarchy: 14px minimum for secondary text, 16px for primary, 24px+ for zone names and counts. All interactive targets: minimum 44px height for WCAG AA touch target compliance.\n\n## Files to Create or Modify\n\n- dashboard/simple.html: simple mode HTML shell with bottom nav\n- dashboard/js/simple.js: card rendering, WebSocket updates, event feed\n- dashboard/js/simplemode.js: mode detection, localStorage mode preference\n- dashboard/css/simple.css: simple mode styles, night mode\n- mothership/internal/dashboard/routes.go: ensure /simple route is served\n\n## Tests\n\n- Test that room occupancy cards correctly reflect current zone state from WebSocket messages\n- Test activity feed filtering: inject a node_connected event and a zone_transition event; only the zone_transition should appear in simple mode feed\n- Test alert banner appears and dismisses correctly on acknowledge\n- Test mode toggle correctly switches between simple and expert mode routes\n- Test sleep summary card appears only between 6am-11am on the morning after a session\n- Test that occupancy card updates show the animated pulse on change\n- Test night mode activates based on quiet hours configuration\n\n## Acceptance Criteria\n\n- Simple mode loads correctly on a 320px wide screen (iPhone SE) without horizontal scrolling\n- Occupancy cards update in real-time as people move between zones\n- Activity feed shows only person-relevant events in plain English\n- Alert banner appears prominently and dismisses on acknowledge\n- Sleep summary card shown between 6am-11am after sleep session, dismissed when tapped\n- Mode toggle to expert mode works and saves preference\n- Night mode activates during configured quiet hours\n- All tap targets are at least 44px in height\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T01:59:32.690434334Z","created_by":"coding","updated_at":"2026-04-11T01:42:15.956630697Z","closed_at":"2026-04-11T01:42:15.956422946Z","close_reason":"All acceptance criteria met. Simple mode with progressive disclosure is complete with:\n- Auto-detection based on screen width (<768px) and user-agent\n- Card-based mobile UI with room occupancy, activity feed, alerts\n- Night mode with OLED optimization (10pm-7am)\n- Sleep summary card (6am-11am display window)\n- Mode toggle with localStorage persistence\n- All 29 tests passing","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3"],"dependencies":[{"issue_id":"spaxel-jk0","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.851714754Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"spaxel-jkw","title":"Add Identify context menu to 3D view","description":"Add 'Identify (blink LED)' option to the right-click context menu in the 3D view that POSTs to /api/nodes/{mac}/identify.\n\n**Acceptance:**\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T11:11:50.047388206Z","created_by":"coding","updated_at":"2026-04-09T11:32:19.559003892Z","closed_at":"2026-04-09T11:32:19.558903935Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-h58"]}
{"id":"spaxel-jxru","title":"Build fleet status page","description":"Full table view with bulk actions and camera fly-to functionality.","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-10T02:03:09.725489218Z","created_by":"coding","updated_at":"2026-04-10T09:39:43.690167347Z","closed_at":"2026-04-10T09:39:43.690013432Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4","mitosis-child","mitosis-depth:1","parent-spaxel-17u"]}
{"id":"spaxel-jy4","title":"Crowd flow visualisation","description":"## Background\n\nOver days and weeks, the movement patterns of household members accumulate into meaningful flows: the main corridor between bedroom and bathroom, the typical path from the front door to the kitchen, habitual dwell spots (the favourite chair, the home office desk, the kitchen counter). Visualising these as directional flow maps and dwell hotspot heatmaps provides useful insight into how the space is actually used — and can inform furniture placement, automation placement, and even architectural decisions. It's also a compelling visual that demonstrates the system's accumulated knowledge.\n\n## FlowAccumulator\n\nNew package: mothership/internal/analytics/flow.go\n\nFlowAccumulator subscribes to TrackManager updates (10 Hz) and accumulates trajectory data.\n\nTrajectory sampling: for each track update, if the track has moved > 0.2m since the last recorded waypoint (for that track), record the movement:\n- from_xyz: last waypoint position\n- to_xyz: current position\n- speed: metres per second at this step\n- person_id: if identity is known\n- timestamp\n\nThis 0.2m threshold prevents accumulating thousands of micro-samples for stationary people.\n\nSQLite table: trajectory_segments (id TEXT PRIMARY KEY, person_id TEXT, from_x REAL, from_y REAL, from_z REAL, to_x REAL, to_y REAL, to_z REAL, speed REAL, timestamp DATETIME). Only store ground plane (from_z and to_z floor-projected: set to 0 for the flow map, since we render on the ground plane).\n\nTable growth management: the table accumulates indefinitely. Prune segments older than 90 days (configurable) with a daily background job. With 4 people at typical home movement rates, 90 days generates approximately 50,000 segments — manageable for SQLite.\n\n## Flow Map Computation\n\nQuery: for each 0.25m grid cell (same resolution as OccupancyGrid in FusionEngine), average the movement vectors of all trajectory segments that pass through that cell.\n\nSQL approach: for each segment, determine which grid cells it passes through (Bresenham's line algorithm on the grid). Accumulate vector components (to_x - from_x, to_y - from_y) into per-cell accumulators.\n\nIn practice: compute on demand when requested (not continuously). Cache the result for up to 5 minutes (or until a \"flow dirty\" flag is set by new trajectory data).\n\nOutput: FlowMap struct with per-cell vectors (x_component, y_component) and a cell count. Serialised to JSON for the dashboard.\n\n## Dwell Hotspot Heatmap\n\nQuery: for each track update where speed < 0.1 m/s (stationary or near-stationary), increment the dwell counter for the corresponding 0.25m grid cell.\n\nSQLite table: dwell_accumulator (grid_x INT, grid_y INT, person_id TEXT, count INT, last_updated DATETIME, PRIMARY KEY (grid_x, grid_y, person_id)). Aggregated at the person+cell level for person-filtered views.\n\nOutput: DwellHeatmap struct mapping (grid_x, grid_y) to count. Normalised to [0, 1] by dividing by the max count across all cells.\n\n## Corridor Detection\n\nIdentify grid cells with consistently high flow volume AND low angular variance in their flow vectors. These are likely corridors or pathways.\n\nAlgorithm:\n1. For each cell, compute the circular variance of the flow vector angles across all segments that contributed. Low variance = directional consistency = corridor.\n2. Threshold: cells with segment_count > 10 AND circular_variance < 0.3 are candidate corridor cells.\n3. Connected component analysis: group adjacent corridor cells into corridor regions.\n4. Each corridor region is represented by its dominant direction and a bounding box.\n\nCorridor regions are stored in SQLite: detected_corridors (id, centroid_xyz, dominant_direction_xy, length_m, width_m, cell_count, last_computed). Recomputed weekly.\n\n## Time and Person Filters\n\nThe dashboard allows filtering flow data by:\n- Time range: \"Today\", \"This week\", \"This month\", custom date range. Implemented as SQL WHERE timestamp >= ? filters on the trajectory_segments table.\n- Person: filter to show only trajectories attributed to a specific person_id (or \"All people\").\n\nFiltered queries are run on-demand with SQL indices on (timestamp, person_id).\n\n## Dashboard Visualisation\n\nAdd two toggle-able layers to the 3D scene (in addition to existing layers):\n\n1. \"Flow\" layer: render flow vectors as animated arrows on the ground plane. Each arrow is positioned at the cell centre, oriented in the cell's average flow direction, and sized proportional to the flow volume (segment count). Use Three.js ArrowHelper for rendering. Animate: cycle the arrow colour from 0% to 100% opacity (flowing effect) on a 2-second loop. Only render cells with > 5 segments.\n\n2. \"Dwell Hotspot\" layer: render a heatmap on the ground plane as coloured rectangle patches (Three.js PlaneGeometry with MeshBasicMaterial, colour mapped from blue (low dwell) through green to red (high dwell)). Opacity 0.4. Only render cells with > 10 dwell samples.\n\n3. Corridor highlighting: detected corridors rendered as slightly raised platform geometry (extruded rectangle, height 0.01m) with a pathway colour (warm grey, opacity 0.3). Toggle-able as sub-option of the \"Flow\" layer.\n\nLayer controls: new \"Patterns\" section in the 3D layer control panel. Three checkboxes: \"Movement flows\", \"Dwell hotspots\", \"Corridors\". Time filter dropdown: \"All time / Last 7 days / Last 30 days\". Person filter dropdown.\n\n## REST API\n\nGET /api/analytics/flow?person_id=&since=&until= — returns FlowMap JSON\nGET /api/analytics/dwell?person_id=&since=&until= — returns DwellHeatmap JSON\nGET /api/analytics/corridors — returns list of DetectedCorridor\n\n## Tests\n\n- Test trajectory sampling: track moves 0.25m -> segment recorded; track moves 0.05m -> no segment\n- Test flow vector averaging: 5 segments all pointing East -> cell vector = (1, 0); 5 East + 5 North -> cell vector ~= (0.5, 0.5)\n- Test dwell accumulation: 100 track updates at speed=0 in cell (5, 7) -> dwell_accumulator[5][7] count = 100\n- Test corridor detection: 20 aligned segments in adjacent cells with angular_variance < 0.3 -> corridor detected\n- Test time-range filtering: insert segments at T-1day and T-8days; query since T-7days -> only T-1day segment returned\n- Test 90-day pruning job removes old segments\n\n## Acceptance Criteria\n\n- Flow layer renders correctly in 3D view with animated arrows for rooms with > 7 days of data\n- Dwell hotspot heatmap visible and renders high-use spots (favourite chair, kitchen counter) correctly\n- Corridor overlay visible with detected high-traffic pathways\n- Time and person filter controls update the rendered layers\n- Layer toggles show/hide each layer cleanly without scene rebuild\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-04-09T23:28:13.618819456Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:325"]}
diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha
index fefc99a..790aed3 100644
--- a/.needle-predispatch-sha
+++ b/.needle-predispatch-sha
@@ -1 +1 @@
-5803bb790a995dc1ab91e8185a8bb5b08eb3faf7
+1d80c9ba368f11a64f99b64b4e0f052868fdb60d
diff --git a/dashboard/ambient.html b/dashboard/ambient.html
index af28b95..2e05a48 100644
--- a/dashboard/ambient.html
+++ b/dashboard/ambient.html
@@ -61,6 +61,8 @@
+
+
+
+
+
+
+
+
+
+
+