From af8800caefa3fd68a1b43f17da6282483e3b8ace Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 9 Apr 2026 10:05:19 -0400 Subject: [PATCH] feat: add self-improving localization REST API Implement REST API endpoints for managing learned weights and tracking improvement in the self-improving localization system. - Add LocalizationHandler with endpoints for: - GET /api/localization/weights - get all learned link weights - GET /api/localization/weights/{linkID} - get specific link weight - POST /api/localization/weights/reset - reset all weights to default - GET /api/localization/spatial-weights - get spatial weights per zone - GET /api/localization/groundtruth/* - ground truth sample management - GET /api/localization/accuracy/* - position accuracy tracking - GET /api/localization/learning/* - learning progress and history - Integrate spatial weight learner into fusion engine: - Add AddLinkInfluenceWithSpatialWeights to grid.go for per-cell weight application - Update Fuse() in fusion.go to use spatial weight functions when available - Apply both sigma adjustments and spatial weights for Fresnel zone computation - Add comprehensive table-driven tests for all API endpoints Co-Authored-By: Claude Opus 4.6 --- .beads/issues.jsonl | 10 +- .needle-predispatch-sha | 2 +- dashboard/js/sleep.js | 9 +- mothership/internal/api/localization.go | 457 ++++++++++ mothership/internal/api/localization_test.go | 844 +++++++++++++++++++ mothership/internal/localization/fusion.go | 13 +- mothership/internal/localization/grid.go | 61 ++ 7 files changed, 1386 insertions(+), 10 deletions(-) create mode 100644 mothership/internal/api/localization.go create mode 100644 mothership/internal/api/localization_test.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 151083f..ad57d1b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -33,7 +33,7 @@ {"id":"spaxel-7nk","title":"fix: sleep/handler.go cannot index interface{} map value","description":"## Problem\n`internal/sleep/handler.go` lines 229 and 232 fail with: `cannot index result[\"metrics\"] (map index expression of type interface{})`\n\nThe `result` map is of type `map[string]interface{}`, so `result[\"metrics\"]` returns `interface{}`, which cannot be directly indexed.\n\n## Fix\nAdd a type assertion before indexing. Around lines 229-232 in `internal/sleep/handler.go`:\n```go\n// Before the two if-blocks, get a typed reference:\nif metricsMap, ok := result[\"metrics\"].(map[string]interface{}); ok {\n if !metrics.SleepStartTime.IsZero() {\n metricsMap[\"sleep_start_time\"] = metrics.SleepStartTime.Format(\"15:04\")\n }\n if !metrics.SleepEndTime.IsZero() {\n metricsMap[\"sleep_end_time\"] = metrics.SleepEndTime.Format(\"15:04\")\n }\n}\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/sleep/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:05.128489582Z","created_by":"coding","updated_at":"2026-04-06T22:40:47.430043249Z","closed_at":"2026-04-06T22:40:47.429779459Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-7qo","title":"Dashboard: WebSocket reconnection with exponential backoff and state management","description":"## Overview\nImplement robust client-side WebSocket reconnection with exponential backoff, jitter, and visual state transitions during disconnects.\n\n## Reconnection logic (dashboard/js/websocket.js or app.js)\n- Backoff sequence: 1s, 2s, 4s, 8s, max 10s; ±500ms random jitter on each attempt\n- Track disconnect_duration_ms from first disconnect event\n\n## Visual state transitions:\nDisconnect < 5s: silent (no UI change); blob positions extrapolated from last velocity\nDisconnect 5-30s: 3D scene dims to 50% opacity; 'Reconnecting...' spinner in status bar; user interaction disabled\nDisconnect > 30s: non-blocking modal: 'Connection lost — [Reload Page]'; allow viewing stale scene\n\n## Blob position extrapolation (<5s):\n- On disconnect: record last_position and last_velocity per blob\n- Each animation frame: position = last_position + last_velocity × elapsed_s (capped at 2s extrapolation)\n\n## On successful reconnect:\n- Clear all blob trails (path history lines)\n- Apply snapshot from first WebSocket message (spaxel-fll)\n- Restore scene opacity to 100%\n- Dismiss spinner and modal\n- Log 'Reconnected after Xs' to console\n\n## Acceptance\n- Disconnect for 3s: no visual change; blobs continue moving smoothly\n- Disconnect for 10s: scene dims, spinner shown\n- Disconnect for 35s: modal appears; scene still visible\n- Reconnect: modal dismissed, trails cleared, scene snaps to current state within 200ms\n- Requires: spaxel-fll (snapshot protocol), spaxel-896 (panel framework)","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-06T16:44:33.446200584Z","created_by":"coding","updated_at":"2026-04-07T16:45:31.143915724Z","closed_at":"2026-04-07T16:45:31.143823228Z","close_reason":"WebSocket reconnection with exponential backoff and visual state management was already fully implemented in commit ff3428f. All acceptance criteria met: backoff 1s-10s with ±500ms jitter, <5s silent extrapolation, 5-30s dimming with spinner, >30s modal with reload button, reconnect clears trails and restores scene from snapshot.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-7x2","title":"Wire anomaly detection & security mode API endpoints","description":"## Backend\n\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## Acceptance\n- Endpoints return correct JSON structure\n- Anomaly events push to WS feed as 'alert' messages\n- Arm/disarm state persists across server restarts","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T16:09:35.812256758Z","created_by":"coding","updated_at":"2026-04-07T19:17:55.756354374Z","closed_at":"2026-04-07T19:17:55.756259632Z","close_reason":"All acceptance criteria verified and already committed in b1c2218. AnomalyDetector initialized in main() with 6h periodic updates. Anomaly events broadcast to dashboard WS as alert messages. GET /api/anomalies?since=24h, POST /api/security/arm, POST /api/security/disarm, GET /api/security/status all wired and tested. Arm/disarm state persisted to learning_state table and restored on restart. All 14 related tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} -{"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-04-09T13:42:19.967763979Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:13"]} +{"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-04-09T13:49:09.454132371Z","closed_at":"2026-04-09T13:49:09.453996847Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:13"]} {"id":"spaxel-896","title":"Build dashboard panel/modal/sidebar UI framework","description":"## Problem\n\nThe dashboard is currently a single live 3D view with no panel system. All Phase 6-9 UI work (automation builder, timeline, explainability, settings, notifications, presence predictions) requires a panel/sidebar framework to hang on.\n\n## What to build\n\n### Panel System (dashboard/js/panels.js)\n- Slide-in sidebar (right, 360px) with close button and title\n- Modal overlay (centered, 600px wide) for forms and wizards\n- Toast notification stack (bottom-right)\n- Panel registry: panels can be opened by name from anywhere in the app\n\n### Route/Mode Navigation (dashboard/js/router.js)\n- Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay\n- Mode toggle bar in the header: Live | Timeline | Automations | Settings\n- Active mode preserved across page refresh (localStorage)\n\n### State Management (dashboard/js/state.js)\n- Central app state object (nodes, blobs, zones, links, alerts, events, ble_devices, triggers)\n- Subscribe/notify pattern for components to react to state changes\n- Separate from WebSocket message parsing\n\n### Settings Panel (dashboard/js/settings-panel.js)\n- Motion threshold slider\n- Sensing rate override\n- Notification channel config (Ntfy URL, Pushover token)\n- System info (version, uptime, node count)\n\n## Acceptance\n\n- Panel opens/closes smoothly with CSS transitions\n- Route changes update the active view without page reload\n- Settings panel reads from GET /api/settings and saves via POST /api/settings\n- All existing 3D live view functionality unaffected","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:55:28.903260636Z","created_by":"coding","updated_at":"2026-04-06T14:08:18.251230378Z","closed_at":"2026-04-06T14:08:18.250924137Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-8u3","title":"Fleet manager with SQLite persistence","description":"Node registry, role assignment engine, and self-healing.\n\n## Deliverables\n- New package: mothership/internal/fleet/\n- SQLite node registry (MAC, ID, role, last seen, health, position)\n- Role assignment engine (TX/RX/passive/TX-RX including passive radar virtual node)\n- Stagger scheduling for multi-node packet timing\n- Self-healing: auto role reassignment on node loss, graceful degradation warnings\n- REST API endpoints: GET /api/nodes, GET /api/nodes/:mac, POST /api/nodes/:mac/role\n\n## Acceptance Criteria\n- Node state persists across mothership restarts\n- Roles auto-reassign when a node goes offline\n- Stagger scheduling prevents packet collisions\n- Tests cover registration, role assignment, and failure recovery\n\n## References\n- Plan: docs/plan/plan.md items 14\n- SQLite: modernc.org/sqlite (pure Go, already in go.mod intent)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:38.835804826Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.132787526Z","closed_at":"2026-03-28T05:36:26.132727724Z","close_reason":"Implemented: fleet/manager.go + fleet/registry.go (fb69190) — SQLite node registry, role assignment engine, stagger scheduling, self-healing role reassignment on node loss","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-8u3","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.704767150Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-9eg","title":"Expand dashboard WebSocket feed: events, alerts, anomalies, triggers, BLE","description":"## Problem\n\nThe dashboard WebSocket (/ws/dashboard) currently only sends blob/node/zone/link/confidence/predictions/sleep/flow state. Events, alerts, anomalies, triggers, and BLE device data are never pushed to the dashboard, making Phase 6-9 UI impossible without a polling API.\n\n## What to add to the WS feed\n\nIn mothership/internal/dashboard/ (hub.go or server.go):\n\n### New message types to broadcast:\n\n**event** — presence transitions, zone entries/exits, portal crossings\n { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }\n\n**alert** — anomaly detections, security mode triggers\n { type: 'alert', alert: { id, ts, severity, description, acknowledged } }\n\n**ble_scan** — BLE device list updates (5s interval)\n { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }\n\n**trigger_state** — automation trigger state changes\n { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }\n\n**system_health** — periodic system stats (60s interval)\n { type: 'system_health', health: { uptime_s, node_count, bead_count, go_routines, mem_mb } }\n\n### In dashboard JS (app.js):\n- Handle each new message type in the WebSocket onmessage handler\n- Update app state for each type\n- Log unhandled types to console (for future debugging)\n\n## Acceptance\n\n- All 5 new message types appear in browser devtools WebSocket inspector\n- BLE device list updates every 5s when devices are present\n- Events appear within 1s of a zone transition\n- Existing blob/node/link messages unaffected","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T12:55:40.859267153Z","created_by":"coding","updated_at":"2026-04-07T15:20:38.722641396Z","closed_at":"2026-04-07T15:20:38.722538891Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-28k","type":"blocks","created_at":"2026-04-06T14:18:27.421346709Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-2ea","type":"blocks","created_at":"2026-04-06T14:18:27.498282335Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-fyi","type":"blocks","created_at":"2026-04-06T14:18:27.643320410Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-hf8","type":"blocks","created_at":"2026-04-06T14:18:27.581630865Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-ncw","type":"blocks","created_at":"2026-04-06T14:18:27.696083142Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -78,7 +78,7 @@ {"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-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":"in_progress","priority":3,"issue_type":"task","assignee":"echo","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-04-09T08:50:53.910236304Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:325"]} {"id":"spaxel-jza","title":"Dashboard: PIN change flow","description":"## Overview\nAllow authenticated users to change their dashboard PIN after first setup.\n\n## Backend\n- POST /api/auth/change-pin — requires valid session; body: {old_pin:'...', new_pin:'...'}\n- Verify old_pin against current bcrypt hash; return HTTP 403 if mismatch\n- Hash new_pin with bcrypt cost=12; update auth.pin_bcrypt\n- Existing sessions remain valid after PIN change (session tokens are independent of PIN)\n- Return {ok:true} on success\n\n## Dashboard\n- Settings panel: 'Security' section with 'Change PIN' button\n- Modal form: old PIN → new PIN → confirm new PIN → Submit\n- On 403: show 'Incorrect current PIN' error inline\n- On success: show 'PIN changed successfully' toast; close modal\n\n## Acceptance\n- Old PIN still works immediately after change attempt fails (403)\n- New PIN works on next login after successful change\n- Active session cookie remains valid after PIN change\n- Requires: spaxel-nk6 (PIN auth)","status":"closed","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:43:09.899017181Z","created_by":"coding","updated_at":"2026-04-09T12:10:28.896292868Z","closed_at":"2026-04-09T12:10:28.896154010Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} -{"id":"spaxel-klf","title":"Build self-improving localization","description":"Implement localization that learns from ground truth data.\n\nDeliverables:\n- BLE integration as ground truth source\n- Fresnel zone weight refinement algorithm\n- Continuous weight adjustment based on feedback\n\nAcceptance: Localization accuracy improves automatically as BLE ground truth data accumulates.","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-03-29T19:25:03.995110604Z","created_by":"coding","updated_at":"2026-04-02T01:19:06.575645095Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:924","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} +{"id":"spaxel-klf","title":"Build self-improving localization","description":"Implement localization that learns from ground truth data.\n\nDeliverables:\n- BLE integration as ground truth source\n- Fresnel zone weight refinement algorithm\n- Continuous weight adjustment based on feedback\n\nAcceptance: Localization accuracy improves automatically as BLE ground truth data accumulates.","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-03-29T19:25:03.995110604Z","created_by":"coding","updated_at":"2026-04-09T13:56:47.165254508Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:925","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-klk","title":"Add floor plan backend API and storage","description":"## Backend (mothership/internal/floorplan.go)\n- POST /api/floorplan/image — multipart form; accept PNG/JPG max 10 MB; save to /data/floorplan/image.png\n- GET /api/floorplan/image — serve the stored image (200 or 404 if none)\n- POST /api/floorplan/calibrate — accept {ax,ay,bx,by,distance_m,rotation_deg}: two pixel coordinates and their real-world distance; compute and persist pixel-to-meter transform\n- GET /api/floorplan/calibrate — return current calibration or 404 if none\n- SQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- Image upload saves file to /data/floorplan/image.png\n- Calibration data persists to SQLite\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T14:46:37.281038019Z","created_by":"coding","updated_at":"2026-04-07T19:03:01.027553189Z","closed_at":"2026-04-07T19:03:01.027363382Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-6hd"],"dependencies":[{"issue_id":"spaxel-klk","depends_on_id":"spaxel-05a","type":"blocks","created_at":"2026-04-07T17:55:53.393074362Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-b6a","type":"blocks","created_at":"2026-04-07T17:55:52.719854848Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-itf","type":"blocks","created_at":"2026-04-07T17:55:52.239848449Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-ts2","type":"blocks","created_at":"2026-04-07T17:55:50.722857752Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-xlo","type":"blocks","created_at":"2026-04-07T17:55:49.889540315Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-kth","title":"Mobile-responsive expert mode","description":"## Background\n\nThe expert mode 3D dashboard was built desktop-first: it assumes a large screen, mouse input, and keyboard shortcuts. On a tablet (10-inch iPad, Android tablet) or phone, the same interface needs adaptation: touch gestures instead of mouse, collapsible panels to preserve canvas space, responsive layout for portrait orientation, and appropriate touch target sizes. This bead systematically addresses all mobile-specific issues in the expert mode (simple mode and ambient mode already have their own mobile-optimised implementations).\n\n## Touch Controls for Three.js OrbitControls\n\nThree.js's OrbitControls already includes touch event handling:\n- Single-finger drag: orbit (rotate the camera around the scene centre)\n- Two-finger pinch: zoom (dollying)\n- Two-finger drag: pan (pan the camera laterally)\n\nHowever, several issues need to be resolved:\n\n1. Touch events from panel overlays propagating to the canvas: when a user touches a sidebar panel to scroll it, the touch event should not also orbit the scene. Fix: add touch event listeners on all panel elements with event.stopPropagation() to prevent bubbling to the canvas.\n\n2. iOS Safari passive event listener warning: OrbitControls uses non-passive touch listeners. iOS logs warnings about this. Fix: override event listener options in OrbitControls or configure the canvas touch-action CSS property: canvas { touch-action: none; }\n\n3. Double-tap to zoom conflict: iOS Safari intercepts double-taps as page zoom. Fix: meta viewport tag already has user-scalable=no (verify this is set in index.html). If not, add it.\n\n4. Pinch gesture accuracy: test on actual devices. If pinch feels imprecise, increase OrbitControls.zoomSpeed for touch input (separate from mouse zoomSpeed).\n\n5. Three-finger pan: useful on tablets. OrbitControls supports it but it may be disabled. Enable if not already active.\n\n## Hamburger Menu\n\nOn screens < 1024px width (tablets in portrait and all phones), replace the always-visible side panels with a hamburger menu:\n- Hamburger button: top-right of the header bar, next to the search icon. Three horizontal lines, 44px touch target.\n- Opening the menu: `transform: translateX(0)` CSS animation on the left sidebar panel. Duration: 200ms ease-out. Overlay backdrop: semi-transparent.\n- The menu contains: Node List, Link List, Presence Panel, Timeline (if visible), people and devices panel.\n- Active tab within the menu: the last-used panel opens first.\n- Close button inside the menu: top-right X, 44px. Also close on backdrop tap or Escape.\n\nCSS implementation: use `transform: translateX(-100%)` as the hidden state, `translateX(0)` as the shown state. Use CSS transitions (not JavaScript animation) for GPU-accelerated smoothness.\n\nMedia query breakpoints:\n- < 1024px: hamburger menu (single panel column replaces all sidebars)\n- < 768px: simple mode auto-activated by default (user can switch to expert)\n\n## Responsive Canvas\n\nThe Three.js canvas must fill the available space correctly at all screen sizes and orientations.\n\nOn orientation change:\n1. window.addEventListener('orientationchange', ...) — also listen to window.addEventListener('resize', ...)\n2. Update renderer.setSize(window.innerWidth, window.innerHeight) (or the canvas's container size)\n3. Update camera.aspect = window.innerWidth / window.innerHeight\n4. Call camera.updateProjectionMatrix()\n5. Trigger a re-render\n\niOS Safari specific: the visual viewport size can differ from window.innerWidth when the address bar is shown/hidden. Use visualViewport.width and visualViewport.height if available (iOS 13+), falling back to window.innerWidth/Height.\n\nBottom navigation bar (if simple mode is active): the Three.js canvas must not overlap the bottom nav. Use calc(100vh - 56px) as the canvas height (56px = nav bar height).\n\n## Touch-Friendly Targets\n\nAudit all interactive elements in the expert mode for touch target size compliance (WCAG 2.1 Success Criterion 2.5.5 Target Size: minimum 44x44px recommended):\n\nElements to resize:\n- Layer toggle checkboxes: increase clickable area with padding\n- Link list entries: ensure min 44px height\n- Panel close buttons: ensure 44px x 44px\n- Slider controls (baseline tau, threshold): ensure drag targets are at least 44px tall\n- Context menu items: min 44px height (should already be, verify)\n\nUse CSS padding to increase tap targets without changing visual size: add padding: 12px 8px to button elements, or use the :after pseudo-element trick for hitbox expansion.\n\n## Performance Optimisations for Mobile\n\nOn screens < 1024px width (treat as mobile/tablet):\n1. Cap devicePixelRatio at 2.0: `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2.0))`. This prevents 3x rendering on high-dpi displays which is unnecessary and expensive.\n2. Disable shadows: `renderer.shadowMap.enabled = false` on mobile. Shadow maps are expensive and home-scale scenes don't critically need them.\n3. Reduce maximum shadow map size to 512x512 if shadows remain enabled.\n4. Reduce antialias quality: use FXAA (Fast Approximate Anti-Aliasing as a post-process pass) instead of MSAA on mobile if needed.\n5. Cap frame rate at 30 fps on mobile (use `requestAnimationFrame` with a delta check) if the device is struggling.\n\n## iOS Safari Safe Area\n\nDevices with notches (iPhone X and later, newer iPads in landscape) have a \"safe area\" that content should not overlap. Use CSS environment variables:\n- body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }\n- The hamburger menu bottom should respect env(safe-area-inset-bottom) on iPhone home-button-less devices.\n\nThese CSS variables are zero on non-notched devices, so they are safe to apply universally.\n\nWebSocket behaviour: WebSocket works normally in iOS Safari, including when the app is backgrounded briefly (though connections may drop on long backgrounding — this is expected and the dashboard already has reconnection logic).\n\n## Files to Modify\n\n- dashboard/index.html: add meta viewport with user-scalable=no, verify safe-area meta tag\n- dashboard/css/expert.css: media queries for hamburger menu, responsive canvas, touch-friendly targets\n- dashboard/js/app.js: orientationchange and resize listeners, canvas resize handler\n- dashboard/js/controls.js (or wherever OrbitControls is initialised): touch event propagation fixes, canvas touch-action CSS\n\n## Tests\n\n- Test canvas resize handler: simulate a resize event with new width/height, verify renderer.setSize and camera.aspect are updated correctly\n- Test touch event propagation: touch event on a sidebar panel element does not reach the canvas (mock event bubbling)\n- Test hamburger menu open/close animation: mock CSS transition end event, verify panel reaches translateX(0) on open and translateX(-100%) on close\n- Test devicePixelRatio cap: mock window.devicePixelRatio = 3, verify renderer uses pixelRatio 2.0\n- Test safe-area CSS is applied: verify env() CSS variables are referenced in the stylesheet\n\n## Acceptance Criteria\n\n- 3D scene is navigable with touch gestures on iPad 10-inch and iPhone 15 (tested manually or via BrowserStack)\n- Pinch-to-zoom and single-finger orbit both work without conflicting with panel scrolling\n- All sidebar panels accessible via hamburger menu on screens < 1024px\n- Hamburger menu animation is smooth (CSS transform, not JavaScript)\n- Canvas responds correctly to orientation change (portrait <-> landscape) on both iOS and Android\n- No touch event propagation from panel overlays to the 3D scene\n- All interactive targets are at least 44px in their touch dimension\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:05:12.940221112Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.992514770Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-kth","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.992482460Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-kxf","title":"Implement Notifications REST endpoints","description":"Implement GET/POST /api/notifications/config to get/set delivery channel settings (Ntfy/Pushover/webhook). Add POST /api/notifications/test to send a test notification. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.428129819Z","created_by":"coding","updated_at":"2026-04-07T13:11:56.263459570Z","closed_at":"2026-04-07T13:11:56.263241649Z","close_reason":"Implemented Notifications REST endpoints:\n\n- GET /api/notifications/config: Returns all notification channel configurations with enabled status and type-specific settings\n- POST /api/notifications/config: Updates one or more notification channels with config validation per type\n- POST /api/notifications/test: Sends a test notification via the specified channel\n\nSupported channel types:\n- ntfy: requires 'url', optional 'token'\n- pushover: requires 'app_token', 'user_key'\n- gotify: requires 'url', 'token'\n- webhook: requires 'url', optional 'method', optional 'headers'\n- mqtt: no config required (uses global connection)\n\nIncludes table-driven tests covering all endpoints and validation scenarios.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} @@ -105,7 +105,7 @@ {"id":"spaxel-pv5","title":"Backup: SQLite Online Backup API streaming endpoint","description":"## Overview\nImplement GET /api/backup using SQLite's Online Backup API for consistent hot backups without downtime or temp files.\n\n## Implementation (mothership/internal/ — new backup.go)\n\n### Why Online Backup API:\n- Simple file copy misses in-flight WAL pages and produces inconsistent backups\n- sqlite3_backup_* copies page-by-page; readers/writers continue uninterrupted\n- No temp file needed: stream directly to HTTP response\n\n### Go implementation using go-sqlite3 (CGO) or modernc.org/sqlite:\nfunc StreamBackup(w http.ResponseWriter, src *sql.DB):\n 1. Open in-memory destination DB: sqlite3_open(':memory:', &pDest)\n 2. Init backup: pBackup = sqlite3_backup_init(pDest, 'main', pSrc, 'main')\n 3. Loop: sqlite3_backup_step(pBackup, 100) until SQLITE_DONE\n 4. sqlite3_backup_finish(pBackup)\n 5. Read all bytes from pDest and write to http.ResponseWriter\n\n### Response format:\n- Content-Type: application/zip\n- Content-Disposition: attachment; filename='spaxel-backup-.zip'\n- Zip contents:\n - spaxel.db (from backup)\n - floor_plan/ directory (if exists)\n - VERSION file\n\n### Endpoint:\nGET /api/backup — requires session auth; streams zip directly; no temp files written\n\n## Acceptance\n- Backup completes while mothership is actively processing CSI frames\n- Downloaded .db file opens cleanly in sqlite3 CLI: PRAGMA integrity_check returns 'ok'\n- Backup size reasonable (not 0 bytes, not gigabytes for fresh install)\n- Simultaneous write during backup does not produce corrupt backup (verify with PRAGMA integrity_check)","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:10:29.966455717Z","created_by":"coding","updated_at":"2026-04-07T10:17:12.858443123Z","closed_at":"2026-04-07T10:17:12.858299524Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:10"]} {"id":"spaxel-pvz","title":"Time-travel debugging and CSI replay","description":"## Background\n\nThe CSI recording buffer (Phase 2, spaxel-tqj) stores 48 hours of raw CSI frames on disk. Time-travel debugging lets you pause the live 3D view, scrub a timeline to any point in that 48-hour window, and replay the 3D scene exactly as it was at that moment. This is the most powerful debugging tool in spaxel: if a false alert fired at 3am, you can replay those 10 minutes and see exactly which links fired, what the blob positions were, and why the alert triggered. Parameter tuning without hardware becomes possible: change the motion threshold slider and immediately see how different the replay result would have been.\n\n## ReplayEngine\n\nNew package: mothership/internal/replay/engine.go\n\nReplayEngine manages the replay lifecycle:\n- state: LIVE, PAUSED, REPLAYING, SEEKING\n- replay_position: current replay timestamp\n- replay_speed: float64 (1.0 = real-time, 5.0 = 5x speed, 0.0 = paused)\n- linked_session_id: the WebSocket session ID of the client requesting replay (each dashboard session has its own replay state)\n\nReplayEngine.Seek(t time.Time): reads the recording buffer to the specified timestamp. Uses the segment file structure from spaxel-tqj: finds the correct segment file for time t, scans forward to the exact frame at t. Target: seek time < 1 second.\n\nReplayEngine.Play(speed float64): starts reading frames from the buffer at the specified speed and feeding them through the signal processing pipeline.\n\n## Replay Processing Pipeline\n\nThe replay pipeline is a copy of the live processing pipeline but with all outputs redirected to \"replay\" namespaced WebSocket messages:\n- \"replay_blob_update\" instead of \"blob_update\"\n- \"replay_track_update\" instead of \"track_update\"\n- \"replay_link_health\" instead of \"link_health\"\n\nThe replay pipeline uses a separate instance of:\n- SignalProcessor (with possibly modified parameters from the tuning sliders)\n- FusionEngine\n- TrackManager\n\nThese are cloned from the live instances at replay start so they inherit the current configuration, then modified by slider values.\n\nThe replay pipeline is self-contained: it does not affect the live pipeline in any way. Live detection continues while replay is active.\n\n## Parameter Tuning During Replay\n\nWhile in replay mode, the dashboard shows a \"Tuning\" panel with sliders for key signal processing parameters:\n- Motion threshold: deltaRMS threshold for motion detection (default from config, range 0.001 to 0.1)\n- Baseline tau: EMA time constant in seconds (default 30s, range 5s to 300s)\n- Fresnel weight sigma: Gaussian sigma for Fresnel zone contribution (default 0.1m, range 0.01m to 0.5m)\n- Minimum confidence for detection: composite minimum confidence before blob is reported (default 0.3)\n\nChanging any slider: the replay engine discards the current replay pipeline state and re-processes from the current replay_position with the new parameters. This takes at most 1-2 seconds for a typical segment (the CSI frames are already on disk; it's fast CPU processing).\n\n\"Apply to Live\" button: copies the currently-active replay parameters to the live configuration and persists them to the mothership config file. The live pipeline picks up the new values within one processing cycle. Requires confirmation modal: \"This will change the live detection configuration. Continue?\"\n\n## Dashboard Controls\n\nEntering replay mode: clicking the \"Pause\" button (or pressing Space) on the live dashboard:\n1. Pauses the live 3D view (3D scene stops updating)\n2. Shows the timeline scrubber: a horizontal bar spanning the 48-hour recording window\n3. Event markers appear on the scrubber at the timestamps of activity timeline events (zone transitions, alerts, etc.)\n4. \"Live\" chip in the dashboard header changes to \"Replay\" chip\n\nTimeline scrubber:\n- Click to seek to any position in the 48-hour window\n- Drag for continuous scrubbing\n- Event markers: small coloured ticks on the scrubber. Clicking a marker seeks to that event and jumps the activity timeline selection to that event row.\n- The current replay position is shown as a draggable thumb with a timestamp tooltip (\"2026-03-27 03:14:22\")\n\nPlayback controls:\n- Play/Pause button (Space key shortcut)\n- Speed selector: 1x, 5x, 10x\n- Step-forward button: advances replay by 1 second\n- \"Back to Live\" button: exits replay mode and resumes live updates\n\nThe 3D scene in replay mode: shows a \"REPLAY\" watermark badge in the top-left corner (so it's clear the view is not live). All live blob and track updates are suppressed while in replay mode (only replay_ prefixed messages update the scene).\n\n## Seek Performance\n\nThe recording buffer (spaxel-tqj) uses 1-hour segment files. To seek to timestamp T:\n1. Identify the correct segment file: {linkID}-{year}-{month}-{day}-{hour}.csi\n2. Binary search within the file: CSI frames are variable-length but each has a 24-byte header with timestamp_us. Scan forward from start of file to the frame nearest T. O(n) but files are ≤ 1 hour = at most 180,000 frames at 50 Hz. At 64-byte average header read, this is < 10MB scan and typically completes in < 200ms.\n3. Buffer a few seconds of frames ahead of T for smooth playback start.\n\nFor all active links: seek all link segment files in parallel (goroutines). Total seek time < 1s.\n\n## Files to Create or Modify\n\n- mothership/internal/replay/engine.go: ReplayEngine, state machine, seek, play, parameter injection\n- mothership/internal/replay/pipeline.go: replay signal processing pipeline (cloned from live)\n- mothership/internal/recording/ (spaxel-tqj): add SeekToTimestamp(t time.Time) method\n- mothership/internal/dashboard/hub.go: replay_ namespaced WebSocket message routing\n- dashboard/js/replay.js: timeline scrubber UI, playback controls, tuning panel\n- mothership/internal/dashboard/routes.go: WebSocket commands for replay control (type: \"replay_seek\", \"replay_play\", \"replay_pause\", \"replay_set_params\")\n\n## Tests\n\n- Test seek: create a mock recording buffer with known frames at known timestamps. Seek to an arbitrary timestamp, verify the returned frame is the closest one to the target.\n- Test that replay pipeline processes frames identically to live pipeline for the same input (regression test with saved CSI data and known expected output blobs)\n- Test parameter slider: change motion_threshold via replay command, verify the replay pipeline uses the new threshold on subsequent frames\n- Test \"Apply to Live\" correctly writes parameter changes to the live config\n- Test that live pipeline output is unaffected while replay is active (isolation test)\n- Test seek performance: 1-hour segment file with 180,000 frames, seek to timestamp in the middle, complete in < 500ms\n\n## Acceptance Criteria\n\n- Seek to any point in 48-hour window completes in < 1 second for all active links\n- Replay produces identical blob positions to original live processing for the same CSI input\n- Parameter sliders re-process the current replay position in < 3 seconds\n- \"Apply to Live\" copies parameters correctly and live detection immediately uses new values\n- Timeline scrubber event markers correctly align with activity timeline events\n- \"Back to Live\" correctly resumes live detection without any stale state\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:56:04.674847447Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.698778779Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-pvz","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.698749622Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-pvz","depends_on_id":"spaxel-tqj","type":"blocks","created_at":"2026-03-28T01:56:07.776160379Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-pwf","title":"Self-improving localisation with BLE ground truth","description":"## Background\n\nThe Fresnel zone fusion engine (spaxel-m9a) computes localisation by weighting each link's deltaRMS contribution according to the geometric intersection of candidate voxels with the Fresnel zone ellipsoid. These weights are currently uniform and based purely on geometry. In practice, some links are better at detecting motion in specific parts of the room than others — due to reflection geometry, multipath, furniture layout, and antenna orientation. By using BLE RSSI positions as continuous ground truth (when a person's labelled phone or wearable is visible), we can refine the per-link, per-zone weights to match observed physical reality.\n\n## Self-Improving Mechanism\n\nNew package: mothership/internal/learning/weights.go\n\nWeightLearner runs as a background goroutine. It operates on ground truth samples collected during normal operation.\n\nA ground truth sample is collected when BOTH:\n1. A confident BLE triangulated position is available for a known person (confidence > 0.7 from identity matching bead spaxel-nqh)\n2. A CSI blob position is within 0.5m of the BLE position (confirming the blob corresponds to that person)\n\nSample structure: {timestamp, person_id, ble_position Vec3, blob_position Vec3, per_link_delta_rms map[linkID]float64, per_link_health map[linkID]float64}\n\nThese samples are stored in SQLite: ground_truth_samples (id, timestamp, person_id, position_xyz, per_link_deltas_json, per_link_health_json). The table is capped at 10,000 samples per person (oldest first out) to prevent unbounded growth.\n\n## Online Weight Learning\n\nAfter accumulating 100+ samples for a given spatial zone (the room is divided into zones of 0.5m x 0.5m grid cells for this purpose), run incremental linear regression:\n\nPrediction model: position_estimate = sum_i (w_i * delta_rms_i) / sum_i w_i, where w_i are the learnable per-link weights.\n\nThe objective is to minimise the mean squared error between the position estimate from the weighted fusion and the ground truth BLE positions, over all samples in the zone.\n\nUpdate rule (stochastic gradient descent, online):\nFor each new ground truth sample:\n- Compute current position estimate using current weights\n- Compute error = ground_truth_position - estimated_position\n- For each link i: w_i += learning_rate * error * delta_rms_i / |delta_rms_vector|\n- learning_rate = 0.001 (small to prevent overfitting to transient environmental changes)\n- Apply L2 regularisation: w_i *= (1 - regularisation * learning_rate) where regularisation = 0.01\n\nClip weights to [0, 5] to prevent divergence. Normalise weight vector to unit sum after each update.\n\n## Validation Gate\n\nTo prevent the learned weights from degrading accuracy (overfitting, transient environmental changes, sensor noise):\n\nHold out 20% of samples as a validation set (random selection). After each batch of 50 weight updates, compute the mean position error on the validation set using the updated weights vs. the original (geometric) weights.\n\nOnly persist the updated weights if: validation_error_new < validation_error_original * 0.95 (at least 5% improvement on the validation set).\n\nIf the validation check fails, discard the weight update and log: \"Weight update rejected: no improvement on validation set. Keeping current weights.\"\n\nThis is a conservative gate. The threshold is configurable (fleet.weight_improvement_threshold, default 0.05).\n\n## Weight Storage\n\nSQLite table: link_weights (link_id TEXT, zone_grid_x INT, zone_grid_y INT, weight REAL, sample_count INT, last_updated DATETIME, validation_improvement REAL, PRIMARY KEY (link_id, zone_grid_x, zone_grid_y)).\n\nZone grid: floor is divided into 0.5m cells. zone_grid_x = floor(x / 0.5), zone_grid_y = floor(y / 0.5). This allows position-dependent weights — a link might be excellent for localisation in one area and poor in another.\n\nOn FusionEngine update: instead of using geometric Fresnel zone weights alone, multiply by the learned spatial weight for the voxel being evaluated (bilinear interpolation between grid cells for smooth transitions).\n\nFallback: if no learned weight exists for a grid cell (insufficient samples), use the geometric weight (learned weight = 1.0). This ensures correctness during the learning period.\n\n## Accuracy Trend in Dashboard\n\nThe accuracy improvement from learning should be visible to users. In the \"Accuracy\" dashboard panel (Phase 7 feedback loop bead):\n\nAdd \"Position accuracy\" subsection:\n- Median position error (m): computed weekly from ground truth samples. median(|ble_position - blob_position|) over all weekly samples.\n- Week-over-week trend: sparkline of weekly median position error. Arrow indicating direction (improving/degrading).\n- Sample count: \"Based on N position measurements from M people this week\"\n- \"Accuracy improving\" badge when position error has decreased by > 10% vs previous week.\n\n## Files to Create or Modify\n\n- mothership/internal/learning/weights.go: WeightLearner, SGD update, validation gate\n- mothership/internal/learning/samples.go: ground truth sample collection, SQLite storage\n- mothership/internal/fusion/engine.go (spaxel-m9a): integrate learned weights in FusionEngine\n- mothership/internal/dashboard/routes.go: GET /api/accuracy/weights (debug endpoint showing current weight map)\n- dashboard/js/accuracy.js: position accuracy trend chart\n\n## Tests\n\n- Test ground truth sample collection gates correctly: confidence > 0.7 AND BLE-blob distance < 0.5m -> sample collected; confidence = 0.6 -> no sample\n- Test SGD weight update: after 100 samples with known ground truth, verify weights move in the direction that reduces error\n- Test validation gate: inject a batch of adversarial samples that would degrade accuracy, verify gate rejects the update\n- Test bilinear interpolation between adjacent grid cells produces smooth weight values\n- Test weight fallback: FusionEngine correctly uses geometric weight=1.0 when no learned weight exists for a grid cell\n- Test SQLite cap: inserting 10,001 samples removes the oldest one, maintaining the 10,000 cap\n\n## Acceptance Criteria\n\n- Position error decreases measurably over 2+ weeks of operation with BLE ground truth data (target: from initial ~1.2m to < 0.8m median error)\n- Validation gate prevents weight regressions (mock adversarial samples do not degrade fusion accuracy)\n- Weight updates persist across mothership restarts\n- Position accuracy trend visible in dashboard Accuracy panel\n- Sample collection rate visible (samples per day per person) in dashboard\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T01:50:34.214065492Z","created_by":"coding","updated_at":"2026-03-30T00:12:00.715207673Z","closed_at":"2026-03-30T00:12:00.715088959Z","close_reason":"Implemented self-improving localization with BLE ground truth. Created spatial weight learner with SGD, validation gate, bilinear interpolation. Added position accuracy visualization to dashboard. All tests implemented.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-3ps","type":"blocks","created_at":"2026-03-28T01:50:36.699492024Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.574878149Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-qfp","title":"Sleep quality monitoring","description":"## Background\n\nThe breathing analysis feature (Phase 5, spaxel-r37) detects the micro-motion of breathing in stationary people. Run continuously in bedroom zones overnight, it can compute sleep quality metrics without any wearable device. Chest displacement during breathing at 15 breaths/minute produces a detectable 0.25 Hz signal in CSI. By tracking this overnight, combined with motion events (wake episodes) and the timing of presence in the bedroom zone, we can produce a sleep summary that rivals basic commercial sleep trackers — without the user wearing anything.\n\n## Sleep Session Detection\n\nSleepMonitor in mothership/internal/sleep/monitor.go.\n\nSession onset detection (all conditions must hold):\n1. Person is in a bedroom zone (zone with is_bedroom flag = true, set in zone editor)\n2. Stationary detection fires (STATIONARY_DETECTED state from breathing analysis bead)\n3. BLE device shows reduced activity (optional enhancement: phone advertising rate drops when screen is off; this is a bonus signal, not required)\nTentative onset: all conditions met. Confirmed onset: conditions hold for 15 consecutive minutes.\n\nSession end detection:\n1. Person leaves bedroom zone (zone transition event fires)\n2. OR: motion detection fires for > 2 minutes (sustained motion = getting up)\n3. OR: stationary detection drops and does not return for > 30 minutes (person left room without portal crossing — reconciliation path)\n\nSession record stored in SQLite:\nCREATE TABLE sleep_sessions (\n id TEXT PRIMARY KEY,\n person_id TEXT NOT NULL,\n zone_id TEXT NOT NULL, -- bedroom zone\n session_date DATE NOT NULL, -- the date this sleep night belongs to (typically today-1 for morning reports)\n sleep_onset DATETIME, -- time tentative detection was confirmed\n wake_time DATETIME,\n time_in_bed_minutes REAL,\n sleep_latency_minutes REAL, -- time from entering bedroom to sleep onset\n wake_episode_count INTEGER DEFAULT 0,\n wake_after_sleep_onset_minutes REAL, -- total time awake after first sleep onset\n breathing_rate_mean REAL,\n breathing_rate_stddev REAL,\n breathing_anomaly_count INTEGER DEFAULT 0, -- breathing < 8 or > 25 per minute\n sleep_efficiency REAL -- (time_in_bed - waso) / time_in_bed * 100\n);\n\nCREATE TABLE sleep_wake_episodes (\n id TEXT PRIMARY KEY,\n session_id TEXT,\n episode_start DATETIME,\n episode_end DATETIME,\n duration_seconds REAL\n);\n\n## Sleep Metrics Computation\n\nDuring the sleep session, SleepMonitor subscribes to:\n- Breathing data: periodic sample of breathing_freq_hz from BreathingDetector (spaxel-r37). Store in a rolling buffer.\n- Motion events: MOTION_DETECTED state transitions from LinkProcessor. Each motion event during a confirmed sleep session is a potential wake episode.\n\nWake episode classification:\n- If deltaRMS > threshold for > 3 seconds: wake episode starts\n- If deltaRMS returns below threshold and breathing signal resumes: wake episode ends\n- Store episode start/end in sleep_wake_episodes\n\nBreathing analysis during sleep:\n- Mean breathing rate (bpm): mean(breathing_freq_hz * 60) over all samples in session\n- Breathing rate standard deviation: indicates sleep stage variability (higher variance may indicate REM activity)\n- Breathing anomaly: if breathing_freq_hz * 60 < 8 or > 25 for > 3 consecutive minutes: log anomaly. This is a proxy for potential sleep apnoea or hyperventilation.\n\nSleep efficiency: (time_in_bed_minutes - wake_after_sleep_onset_minutes) / time_in_bed_minutes * 100. A value above 85% is considered good sleep efficiency.\n\n## Morning Summary Card\n\nOn first WebSocket connection from the dashboard after 6am AND after a sleep session has ended (wake_time is set):\n- Mothership pushes a \"morning_summary\" WebSocket message with the completed session data\n- Dashboard renders a dismissible card in simple mode (full width at top) and as a floating panel in expert mode\n\nCard content:\n- \"Last night: [sleep_duration] h [mm] min\"\n- Colored efficiency indicator: green (>85%), amber (70-85%), red (<70%)\n- Wake episodes: \"2 wake episodes, [total waso] min awake after sleep onset\"\n- Breathing: \"Average breathing: [N] breaths/min\"\n- Anomaly note (if applicable): \"Unusual breathing detected at [time]. [View details]\"\n- \"View full sleep report\" link (opens detailed timeline view in expert mode)\n\n## Weekly Trends\n\nDashboard \"Sleep\" panel:\n- 7-day sparkline of sleep duration per night\n- 7-day sparkline of sleep efficiency per night\n- Average breathing rate over the week\n- Week-over-week comparison: \"This week you slept 6h 48m on average (vs. 7h 12m last week)\"\n\n## Per-Person Tracking\n\nSleep monitoring is person-specific and requires BLE identity (so the system knows whose bedroom this is). Multiple people sharing a bedroom: each person has their own sleep session if their BLE devices can be distinguished. If both people are in bed simultaneously, the breathing detector may pick up a blend of two breathing rates — acknowledge this limitation in documentation.\n\nFor anonymous tracks (no BLE identity): detect in-bedroom stationary presence only (no per-person sleep report). Log \"Unidentified person in bedroom zone\" for 8+ hour periods.\n\n## Zone Configuration\n\nThe zone editor (portals bead, spaxel-qlh) is extended with a zone type selector:\n- Normal zone (default)\n- Bedroom (enables sleep monitoring)\n- Kitchen (no special behavior)\n- Children's zone (suppresses fall detection)\n\nThis is stored as zone_type in the zones table.\n\n## Files to Create or Modify\n\n- mothership/internal/sleep/monitor.go: SleepMonitor, session detection, metric computation\n- mothership/internal/sleep/report.go: morning summary generation, weekly trend aggregation\n- mothership/internal/signal/breathing.go (spaxel-r37): add tick-based sample reporting for sleep monitor\n- dashboard/js/sleep.js: morning summary card, Sleep panel\n- mothership/internal/events/events.go: SleepSessionStartEvent, SleepSessionEndEvent\n\n## Tests\n\n- Test sleep session onset: stationary detection fires, person in bedroom, 15 minutes -> session confirmed\n- Test that stationary detection < 15 minutes does not create a session (avoids brief naps misclassified)\n- Test wake episode counting: 3 MOTION_DETECTED events > 3s each during a session -> wake_episode_count = 3\n- Test wake after sleep onset calculation: 3 episodes of 5 minutes each -> waso = 15 minutes\n- Test sleep efficiency calculation: 480 minutes in bed, 45 minutes waso -> efficiency = 90.6%\n- Test breathing anomaly detection: inject 4 minutes of breathing_freq_hz = 0.1 (6 bpm) -> anomaly logged\n- Test morning summary trigger fires only on first connection after 6am AND after session end\n\n## Acceptance Criteria\n\n- Sleep session detected within 15 minutes of confirmed onset (stationary in bedroom zone)\n- Wake episodes counted correctly (tested with synthetic motion event injection)\n- Morning summary card appears on first dashboard open after wake time (6am by default, configurable)\n- Weekly trends sparkline shows 7 nights of data after 7 days\n- Sleep session data persists in SQLite across mothership restarts\n- Breathing anomaly flag fires correctly for rate < 8 or > 25 bpm\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"hotel","created_at":"2026-03-28T01:52:06.457208929Z","created_by":"coding","updated_at":"2026-04-09T13:42:11.433119110Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:76"]} +{"id":"spaxel-qfp","title":"Sleep quality monitoring","description":"## Background\n\nThe breathing analysis feature (Phase 5, spaxel-r37) detects the micro-motion of breathing in stationary people. Run continuously in bedroom zones overnight, it can compute sleep quality metrics without any wearable device. Chest displacement during breathing at 15 breaths/minute produces a detectable 0.25 Hz signal in CSI. By tracking this overnight, combined with motion events (wake episodes) and the timing of presence in the bedroom zone, we can produce a sleep summary that rivals basic commercial sleep trackers — without the user wearing anything.\n\n## Sleep Session Detection\n\nSleepMonitor in mothership/internal/sleep/monitor.go.\n\nSession onset detection (all conditions must hold):\n1. Person is in a bedroom zone (zone with is_bedroom flag = true, set in zone editor)\n2. Stationary detection fires (STATIONARY_DETECTED state from breathing analysis bead)\n3. BLE device shows reduced activity (optional enhancement: phone advertising rate drops when screen is off; this is a bonus signal, not required)\nTentative onset: all conditions met. Confirmed onset: conditions hold for 15 consecutive minutes.\n\nSession end detection:\n1. Person leaves bedroom zone (zone transition event fires)\n2. OR: motion detection fires for > 2 minutes (sustained motion = getting up)\n3. OR: stationary detection drops and does not return for > 30 minutes (person left room without portal crossing — reconciliation path)\n\nSession record stored in SQLite:\nCREATE TABLE sleep_sessions (\n id TEXT PRIMARY KEY,\n person_id TEXT NOT NULL,\n zone_id TEXT NOT NULL, -- bedroom zone\n session_date DATE NOT NULL, -- the date this sleep night belongs to (typically today-1 for morning reports)\n sleep_onset DATETIME, -- time tentative detection was confirmed\n wake_time DATETIME,\n time_in_bed_minutes REAL,\n sleep_latency_minutes REAL, -- time from entering bedroom to sleep onset\n wake_episode_count INTEGER DEFAULT 0,\n wake_after_sleep_onset_minutes REAL, -- total time awake after first sleep onset\n breathing_rate_mean REAL,\n breathing_rate_stddev REAL,\n breathing_anomaly_count INTEGER DEFAULT 0, -- breathing < 8 or > 25 per minute\n sleep_efficiency REAL -- (time_in_bed - waso) / time_in_bed * 100\n);\n\nCREATE TABLE sleep_wake_episodes (\n id TEXT PRIMARY KEY,\n session_id TEXT,\n episode_start DATETIME,\n episode_end DATETIME,\n duration_seconds REAL\n);\n\n## Sleep Metrics Computation\n\nDuring the sleep session, SleepMonitor subscribes to:\n- Breathing data: periodic sample of breathing_freq_hz from BreathingDetector (spaxel-r37). Store in a rolling buffer.\n- Motion events: MOTION_DETECTED state transitions from LinkProcessor. Each motion event during a confirmed sleep session is a potential wake episode.\n\nWake episode classification:\n- If deltaRMS > threshold for > 3 seconds: wake episode starts\n- If deltaRMS returns below threshold and breathing signal resumes: wake episode ends\n- Store episode start/end in sleep_wake_episodes\n\nBreathing analysis during sleep:\n- Mean breathing rate (bpm): mean(breathing_freq_hz * 60) over all samples in session\n- Breathing rate standard deviation: indicates sleep stage variability (higher variance may indicate REM activity)\n- Breathing anomaly: if breathing_freq_hz * 60 < 8 or > 25 for > 3 consecutive minutes: log anomaly. This is a proxy for potential sleep apnoea or hyperventilation.\n\nSleep efficiency: (time_in_bed_minutes - wake_after_sleep_onset_minutes) / time_in_bed_minutes * 100. A value above 85% is considered good sleep efficiency.\n\n## Morning Summary Card\n\nOn first WebSocket connection from the dashboard after 6am AND after a sleep session has ended (wake_time is set):\n- Mothership pushes a \"morning_summary\" WebSocket message with the completed session data\n- Dashboard renders a dismissible card in simple mode (full width at top) and as a floating panel in expert mode\n\nCard content:\n- \"Last night: [sleep_duration] h [mm] min\"\n- Colored efficiency indicator: green (>85%), amber (70-85%), red (<70%)\n- Wake episodes: \"2 wake episodes, [total waso] min awake after sleep onset\"\n- Breathing: \"Average breathing: [N] breaths/min\"\n- Anomaly note (if applicable): \"Unusual breathing detected at [time]. [View details]\"\n- \"View full sleep report\" link (opens detailed timeline view in expert mode)\n\n## Weekly Trends\n\nDashboard \"Sleep\" panel:\n- 7-day sparkline of sleep duration per night\n- 7-day sparkline of sleep efficiency per night\n- Average breathing rate over the week\n- Week-over-week comparison: \"This week you slept 6h 48m on average (vs. 7h 12m last week)\"\n\n## Per-Person Tracking\n\nSleep monitoring is person-specific and requires BLE identity (so the system knows whose bedroom this is). Multiple people sharing a bedroom: each person has their own sleep session if their BLE devices can be distinguished. If both people are in bed simultaneously, the breathing detector may pick up a blend of two breathing rates — acknowledge this limitation in documentation.\n\nFor anonymous tracks (no BLE identity): detect in-bedroom stationary presence only (no per-person sleep report). Log \"Unidentified person in bedroom zone\" for 8+ hour periods.\n\n## Zone Configuration\n\nThe zone editor (portals bead, spaxel-qlh) is extended with a zone type selector:\n- Normal zone (default)\n- Bedroom (enables sleep monitoring)\n- Kitchen (no special behavior)\n- Children's zone (suppresses fall detection)\n\nThis is stored as zone_type in the zones table.\n\n## Files to Create or Modify\n\n- mothership/internal/sleep/monitor.go: SleepMonitor, session detection, metric computation\n- mothership/internal/sleep/report.go: morning summary generation, weekly trend aggregation\n- mothership/internal/signal/breathing.go (spaxel-r37): add tick-based sample reporting for sleep monitor\n- dashboard/js/sleep.js: morning summary card, Sleep panel\n- mothership/internal/events/events.go: SleepSessionStartEvent, SleepSessionEndEvent\n\n## Tests\n\n- Test sleep session onset: stationary detection fires, person in bedroom, 15 minutes -> session confirmed\n- Test that stationary detection < 15 minutes does not create a session (avoids brief naps misclassified)\n- Test wake episode counting: 3 MOTION_DETECTED events > 3s each during a session -> wake_episode_count = 3\n- Test wake after sleep onset calculation: 3 episodes of 5 minutes each -> waso = 15 minutes\n- Test sleep efficiency calculation: 480 minutes in bed, 45 minutes waso -> efficiency = 90.6%\n- Test breathing anomaly detection: inject 4 minutes of breathing_freq_hz = 0.1 (6 bpm) -> anomaly logged\n- Test morning summary trigger fires only on first connection after 6am AND after session end\n\n## Acceptance Criteria\n\n- Sleep session detected within 15 minutes of confirmed onset (stationary in bedroom zone)\n- Wake episodes counted correctly (tested with synthetic motion event injection)\n- Morning summary card appears on first dashboard open after wake time (6am by default, configurable)\n- Weekly trends sparkline shows 7 nights of data after 7 days\n- Sleep session data persists in SQLite across mothership restarts\n- Breathing anomaly flag fires correctly for rate < 8 or > 25 bpm\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:52:06.457208929Z","created_by":"coding","updated_at":"2026-04-09T13:50:59.536299142Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:77"]} {"id":"spaxel-qgj","title":"Implement NTP client in ESP32 firmware","description":"Add NTP synchronization to firmware/main/wifi.c or ntp.c:\n- Call esp_sntp_setservername(0, ntp_server) before esp_sntp_init() on boot\n- ntp_server read from NVS 'ntp_server' key (default: 'pool.ntp.org')\n- Attempt sync for up to 10 seconds after WiFi connect; log WARN if sync fails\n- On sync failure: proceed without stagger (rely on CSMA/CA)\n- Resync every 10 minutes via esp_timer periodic callback\n- Include ntp_synced status in health JSON message\n\nAcceptance: Node health messages show ntp_synced: true when pool is reachable; ntp_synced: false when NTP blocked — node still operates normally; resync occurs every ~600s (verified via UART logs)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-07T14:37:00.302557793Z","created_by":"coding","updated_at":"2026-04-07T17:32:57.896842167Z","closed_at":"2026-04-07T17:32:57.896693758Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-u7y"]} {"id":"spaxel-qlh","title":"Room transition portals and zone occupancy","description":"## Background\n\nKnowing a blob is at coordinates (3.2m, 1.8m, 1.0m) is useful to the algorithm, but \"Alice is in the Kitchen\" is useful to a person. Room transition portals define doorway planes between named zones. When a track's trajectory intersects a portal plane, the zone occupancy counts update and a transition event fires. This is the foundation for natural language presence display (\"Alice is in the Kitchen\"), automation triggers (\"when Alice enters the bedroom\"), and the activity timeline (\"Alice moved from Living Room to Kitchen at 14:23\").\n\n## Zone Definitions\n\nZones are named 3D volumes represented as axis-aligned bounding boxes (AABB) for simplicity. Each zone has: id (uuid), name (\"Kitchen\"), bounds_min (Vec3), bounds_max (Vec3), color (hex string for 3D overlay), created_at.\n\nSQLite schema:\nCREATE TABLE zones (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n bounds_min_x REAL, bounds_min_y REAL, bounds_min_z REAL,\n bounds_max_x REAL, bounds_max_y REAL, bounds_max_z REAL,\n color TEXT DEFAULT '#3b82f6',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nContainment test: a position P is in zone Z if bounds_min_x <= P.x <= bounds_max_x AND bounds_min_y <= P.y <= bounds_max_y. The Z bounds are typically 0 to ceiling height (usually 2.5m) since we track floor-plane position.\n\n## Portal Definitions\n\nA portal is a vertical plane segment spanning a doorway. It divides two zones and detects crossings.\n\nPortal schema:\nCREATE TABLE portals (\n id TEXT PRIMARY KEY,\n name TEXT, -- e.g. \"Kitchen Door\"\n zone_a_id TEXT, -- zone on one side\n zone_b_id TEXT, -- zone on other side\n plane_point Vec3, -- a point on the portal plane (e.g. centre of doorway)\n plane_normal Vec3, -- unit normal vector of the portal plane\n width REAL, -- width of the doorway in metres\n height REAL, -- height of the doorway (default: 2.1m)\n created_at DATETIME\n);\n\nA portal normal points from zone_a toward zone_b. A crossing from zone_a to zone_b has dot(velocity, normal) > 0. A crossing from zone_b to zone_a has dot(velocity, normal) < 0.\n\n## Portal Editor (3D Dashboard)\n\nExtend the node placement UI (spaxel-qq6) with portal editing:\n1. User clicks \"Add Portal\" button\n2. A vertical plane appears in the 3D scene at the camera's focal point\n3. User drags the plane using TransformControls (from Three.js addons) to position it across a doorway\n4. User adjusts width and assigns zone names on each side (dropdown of existing zones or \"Create new zone\")\n5. User clicks \"Save\" — portal is stored in SQLite and rendered as a semi-transparent divider plane in the 3D scene\n\nPortal rendering: thin coloured plane (opacity 0.3, colour #a855f7 purple) with a label at the top edge showing the portal name. When a track crosses the portal, the plane briefly flashes brighter (animated opacity increase then decay back to 0.3).\n\nZone rendering: semi-transparent coloured cuboid volumes (opacity 0.1, colour from zone.color). Zone name displayed as a floating text label at the zone centroid (using THREE.Sprite). A \"Zones\" layer toggle in the 3D view hides/shows all zones simultaneously.\n\n## Crossing Detection\n\nCrossingDetector runs as part of the TrackManager update loop (10 Hz). For each track update:\n\n1. For each active portal, test if the track crossed the portal plane in the last update step:\n - Previous position P_prev, current position P_curr\n - Check if the line segment P_prev -> P_curr intersects the portal plane within the portal's rectangular bounds (width x height centered on plane_point)\n - Intersection test: t = dot(plane_point - P_prev, normal) / dot(P_curr - P_prev, normal). If 0 <= t <= 1, compute intersection point P_int = P_prev + t*(P_curr - P_prev), then check if P_int is within the doorway rectangle.\n - Crossing direction: if dot(P_curr - P_prev, normal) > 0, direction is A_to_B; otherwise B_to_A.\n\n2. On crossing detected: update occupancy counts, emit ZoneCrossingEvent.\n\nZoneCrossingEvent: {portal_id, track_id, person_id, person_label, from_zone_id, from_zone_name, to_zone_id, to_zone_name, direction, timestamp}.\n\nThis event is:\n- Published to the internal event bus\n- Broadcast via WebSocket to dashboard as type \"zone_transition\"\n- Appended to activity timeline (Phase 8)\n- Processed by automation engine (Phase 6)\n\n## Occupancy Counter\n\nOccupancyManager maintains a per-zone current occupant list (map[zoneID][]TrackID).\n\nUpdates from two sources:\n1. CrossingDetector portal events: when a track crosses from zone A to B, move its entry in the occupancy map from A to B.\n2. Direct containment check: run every 30 seconds as a reconciliation pass. For each active track, check if it is within any zone's bounding box. If the track is in zone C but the occupancy map says it is in zone A (e.g. track was created inside a zone without crossing a portal), update accordingly.\nThe containment check prevents \"teleportation\" inconsistencies when tracks are created or resume from coasting state.\n\n## WebSocket Broadcast\n\nOn each zone occupancy change, the mothership broadcasts:\n{\"type\":\"zone_occupancy\",\"zones\":[{\"id\":\"zone-kitchen\",\"name\":\"Kitchen\",\"occupants\":[{\"track_id\":\"track-1\",\"person_id\":\"uuid-alice\",\"person_label\":\"Alice\"}]},{\"id\":\"zone-living\",\"name\":\"Living Room\",\"occupants\":[]}]}\n\nAnd specifically on crossings:\n{\"type\":\"zone_transition\",\"portal_id\":\"...\",\"person_label\":\"Alice\",\"from_zone\":\"Kitchen\",\"to_zone\":\"Living Room\",\"timestamp\":\"2026-03-27T14:23:00Z\"}\n\n## REST API\n\nGET /api/zones: list all zones with current occupancy\nPOST /api/zones: create zone\nPUT /api/zones/{id}: update zone bounds/name/color\nDELETE /api/zones/{id}: delete zone (removes from all occupancy tracking)\n\nGET /api/portals: list all portals\nPOST /api/portals: create portal\nPUT /api/portals/{id}: update portal\nDELETE /api/portals/{id}: delete portal\n\nGET /api/zones/{id}/history?since=2026-03-27T00:00:00Z: get crossing history for zone (list of ZoneCrossingEvent)\n\n## Tests\n\n- Test portal crossing detection with a track path that passes through the portal plane: verify crossing event fires with correct direction\n- Test that a track path that runs parallel to a portal plane but within 0.1m does not fire a false crossing\n- Test that a track path outside the portal's width bounds does not fire a crossing\n- Test occupancy count updates: zone Kitchen starts with 1 occupant, track crosses portal to Living Room, Kitchen count = 0, Living Room count = 1\n- Test the 30-second reconciliation pass: track that appears inside a zone without crossing a portal is correctly assigned to that zone\n- Test zone containment with a position exactly on the bounds_min edge (inclusive boundary)\n- Test that zone_transition WebSocket message is broadcast with correct from_zone and to_zone names\n\n## Acceptance Criteria\n\n- Portal editor allows placing vertical plane portals across doorways in the 3D scene\n- Zone bounding boxes are editable and render as semi-transparent volumes in 3D view\n- Zone labels update in real-time as people move between zones (\"Kitchen: Alice, Bob\")\n- Zone transition events fire within one track update cycle (100ms) of the crossing occurring\n- Reconciliation pass correctly handles tracks that appear inside zones without portal crossings\n- Zone and portal data persists across mothership restarts via SQLite\n- WebSocket broadcasts zone_occupancy after every occupancy change\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:45:41.668543362Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.268105795Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.268078719Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-03-28T01:45:44.642770328Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-qob","title":"Webhook action firing & fault tolerance for automations","description":"## Overview\nReliable webhook delivery for automation trigger actions with error handling and dashboard feedback.\n\n## Backend (mothership/automation/)\n- HTTP client: POST to configured URL with 5s timeout; fire-and-forget (no retry)\n- Payload schema: {trigger_id, trigger_name, condition, blob_id, person, position:{x,y,z}, zone, dwell_s, timestamp_ms}\n- Error handling:\n - 4xx response: disable trigger + set trigger.error_message; push WS alert to dashboard\n - 5xx / timeout: log warning + increment trigger.error_count; do NOT disable\n - error_count resets on first 2xx response\n- Test endpoint: POST /api/triggers/{id}/test — fires webhook once with synthetic payload, returns {status, response_ms, error}\n- Audit log: webhook_log table (trigger_id, fired_at_ms, url, status_code, latency_ms, error)\n\n## Dashboard\n- Error badge on trigger card when disabled due to 4xx\n- 'Test Webhook' button in trigger edit panel — shows response in real time\n- Last N firings visible in trigger detail view (from webhook_log)\n- 'Re-enable' button to clear error state and retry\n\n## Acceptance\n- 5xx failures do not disable triggers\n- 4xx disables trigger and shows dashboard warning within 2s\n- Test endpoint returns response within timeout + 500ms overhead\n- Requires: spaxel-6ha (REST API), spaxel-vuw (trigger volumes), spaxel-9eg (WS alerts)","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T13:01:53.677999018Z","created_by":"coding","updated_at":"2026-04-07T04:16:09.129273227Z","closed_at":"2026-04-07T04:16:09.129061569Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:8"]} @@ -115,7 +115,7 @@ {"id":"spaxel-r0l","title":"Guided troubleshooting and first-time UX","description":"## Background\n\nThe onboarding wizard (spaxel-zvo) handles the happy path from unboxed ESP32 to streaming CSI. This bead handles what happens when things go wrong — during onboarding and during normal operation — and provides first-time feature discovery for users after successful setup. The design principle is that a non-technical household member should never need to read documentation, open a terminal, or contact support for any common problem. Every failure mode has a recovery path visible in the UI.\n\n## Scope\n\nThis Phase 4 bead covers foundational troubleshooting infrastructure. A Phase 9 bead (Guided troubleshooting enhanced for production) extends this with proactive quality prompts and production-polish improvements.\n\n## 1. Onboarding Failure Guidance\n\nAt each step of the onboarding wizard, intercept known failure modes and render human-readable recovery steps instead of technical errors. Each wizard step has an error state with specific guidance:\n\nBrowser check errors:\n- navigator.serial unavailable -> \"Please use Google Chrome or Microsoft Edge. Firefox and Safari do not support USB device communication.\"\n\nDevice connection errors:\n- No device selected -> \"Did you hold the BOOT button while plugging in? Try again: hold BOOT, then plug in the USB cable.\"\n- Permission denied -> \"Browser blocked USB access. Check your browser's site permissions for this address.\"\n- Access denied (device in use) -> \"Another application is using this USB port. Close Arduino IDE, esptool, or any other serial monitor and try again.\"\n\nWiFi provisioning errors:\n- Node not appearing after 120s -> Stepped guidance: (1) Check SSID/password are exactly correct. (2) ESP32-S3 only supports 2.4GHz — check your router's 2.4GHz band is enabled. (3) Check if your router has AP isolation / client isolation enabled (blocks device-to-device communication). (4) Try moving the node closer to the router.\n- Node appears but no CSI data -> \"The node connected but is not sensing yet. Check the antenna orientation — the PCB antenna should face away from walls.\"\n\n## 2. Node Offline Troubleshooting\n\nWhen a node goes offline (detected via heartbeat timeout in mothership), the dashboard shows a troubleshooting timeline card:\n\n\"Node [Living Room] went offline at [time].\"\nTimeline of suggested actions:\n1. Check the node's power LED is on (solid green = powered and connected, blinking = attempting WiFi)\n2. If blinking: move the node closer to your WiFi router temporarily\n3. If the LED is blinking rapidly after 5 minutes: the node has lost its WiFi configuration. Connect to 'spaxel-{last4mac}' WiFi network to reconfigure. [Link to captive portal guide]\n4. If the LED is off: check the power supply and USB cable\n5. Still stuck? [Reset to factory defaults] (button that sends a \"reboot\" downstream command if node reconnects, or shows instructions for manual factory reset via BOOT button hold)\n\nThe card remains visible until the node reconnects. It includes last-known position on the floor plan (greyed out) and how long it has been offline.\n\n## 3. First-Time Feature Tooltips\n\nOn the first dashboard open after a node is successfully added (tracked by a localStorage key \"spaxel_tooltips_shown\"), show contextual tooltips pointing to key features:\n- Point at the CSI amplitude chart: \"This is your live signal. Motion causes the waves to change.\"\n- Point at the 3D view (if available): \"This 3D space updates as people move around.\"\n- Point at the presence indicator: \"Green = no one detected. Red = motion detected.\"\n- Point at the link list: \"Each line between two nodes is a sensing link.\"\n\nTooltips: auto-dismiss after 8 seconds, dismiss-all button, never re-appear (localStorage flag per tooltip ID). Rendered as floating HTML divs with an arrow pointing to the target element, positioned via getBoundingClientRect().\n\n## 4. Post-Calibration Reinforcement\n\nAfter the guided calibration walk in the onboarding wizard completes (blob detected successfully), show a \"You're all set\" card:\n- Summary: \"[Node A] calibrated. 1 sensing link active. Motion detection: Ready.\"\n- What to expect: \"You'll see the CSI waveform react when someone walks through the room. The system learns your space over the next few hours and becomes more accurate.\"\n- Next step prompt: \"Want to add another node for more precise location tracking? [Add another node] [I'm done for now]\"\n\n## 5. Detection Quality Prompts (Phase 4 basic version)\n\nWhen a link's packet rate drops below 50% of the configured rate for more than 60 seconds (basic quality issue detectable without Phase 5 full confidence scoring):\n- Show a non-blocking banner: \"Node A is having trouble communicating. Check that it is powered on and within WiFi range.\"\n- Auto-dismiss when packet rate recovers.\n\n## Implementation\n\nTroubleshooting logic lives in dashboard/js/troubleshoot.js:\n- TroubleshootManager class subscribes to WebSocket events: node_offline, node_online, low_packet_rate, calibration_complete\n- For each event type, renders the appropriate UI component\n- Uses a simple state machine per issue: DETECTED -> NOTIFIED -> RESOLVED/DISMISSED\n- Issue state persisted in memory (cleared on page refresh — issues re-fire on next event)\n\nTooltip system: dashboard/js/tooltips.js\n- TooltipManager class: show(tooltipId, targetSelector, text, direction)\n- Checks localStorage \"spaxel_tooltip_{id}_shown\" before showing\n- Sets localStorage flag on dismiss\n- All tooltips in a manifest array, shown in sequence on first visit\n\nA dedicated troubleshoot.css for the offline card and tooltip styles.\n\n## Design Principles\n\n- Guidance must be actionable, not diagnostic: \"Move the node closer to your router\" not \"WiFi RSSI is -78 dBm\"\n- Never condescending: \"Your node went offline\" not \"Error: WebSocket connection closed with code 1006\"\n- Never blocks normal operation: every troubleshooting element is dismissible\n- Avoid information overload: show one most-likely cause first, with \"More options\" expander for alternatives\n- Use progressive disclosure: simple guidance first, technical details behind \"Advanced\" toggle\n\n## Tests\n\n- Test that node_offline WebSocket event triggers the troubleshooting panel render with correct node label\n- Test that tooltip TooltipManager correctly checks localStorage before showing each tooltip\n- Test that tooltips set the localStorage flag on dismiss and do not re-appear on subsequent show() calls\n- Test that node_online event after node_offline dismisses the offline card\n- Test that low_packet_rate event below 50% threshold triggers the quality banner\n- Test that the post-calibration card renders with correct link count and node label\n\n## Acceptance Criteria\n\n- All common onboarding failure modes have human-readable recovery paths in the wizard\n- Node offline card appears in the dashboard within 30 seconds of disconnection\n- Offline card includes actionable steps and the captive portal AP SSID\n- First-time tooltips appear exactly once on first dashboard open after node addition\n- Tooltips never re-appear after dismissal (localStorage persistence)\n- Post-calibration card shows correct summary after wizard completion\n- Detection quality banner fires when packet rate drops below 50% threshold\n- All UI elements are dismissible without blocking normal dashboard use\n- Tests pass","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:39:04.748866600Z","created_by":"coding","updated_at":"2026-03-28T08:19:35.525106448Z","closed_at":"2026-03-28T08:19:35.525040672Z","close_reason":"Implemented guided troubleshooting and first-time UX:\n\n1. Onboarding failure guidance - human-readable recovery paths at each wizard step\n2. Node offline troubleshooting - timeline cards with progressive disclosure, factory reset modal, captive portal AP SSID\n3. First-time feature tooltips - 4 contextual tooltips with localStorage persistence, auto-dismiss, sequential tour\n4. Post-calibration reinforcement card with summary and next steps\n5. Detection quality prompts - client-side link health check with auto-recovery\n6. 30 new tests, all 96 tests pass","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-r0l","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.926926473Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-r0l","depends_on_id":"spaxel-zvo","type":"blocks","created_at":"2026-03-28T01:39:10.975223706Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-r37","title":"Stationary person detection via breathing analysis","description":"## Background\n\nStandard deltaRMS motion detection fires when someone physically moves. But a stationary person — reading, sleeping, watching TV — produces micro-motion from breathing: chest displacement of approximately 5mm at 0.1-0.5 Hz. This is well below the standard motion threshold but is detectable with careful bandpass filtering of the CSI signal. This is one of the hardest and most valuable features in the system. It transforms spaxel from a \"motion detector\" into a true \"presence detector\" that can tell you a sleeping baby is still breathing.\n\n## Physics of Breathing Detection in CSI\n\nWhen a person breathes, their chest moves approximately 5-10mm. This tiny displacement changes the path length of reflected wireless signals by up to 20mm (round trip). At 5 GHz (lambda ~= 0.06m), a 20mm path length change corresponds to a phase shift of 2*pi*0.02/0.06 ~= 2.1 radians — measurable in phase-sensitive CSI. In amplitude (IQ magnitude), the change is typically ~0.1% of the total amplitude — tiny but consistent.\n\nThe key is that breathing is periodic. A person breathes at 12-20 times per minute (0.2-0.33 Hz at rest, up to 0.5 Hz during mild activity). The CSI signal shows a weak periodic oscillation at this frequency. By taking the FFT of a 30-second window of deltaRMS samples, a sharp peak at the breathing frequency emerges from the noise floor.\n\nThe phase change cycles at TWICE the physical breathing rate because path length change cycles twice per breath cycle (chest goes out and comes back in, changing path length from +d to -d and back to +d). So a 15-breath/minute breathing rate (0.25 Hz) produces a CSI phase oscillation at 0.5 Hz, and we look for peaks at 0.2-1.0 Hz in the FFT spectrum.\n\n## BandpassDetector Implementation\n\nNew file: mothership/internal/signal/breathing.go\n\nBreathingDetector struct:\n- rollingBuffer []float64: circular buffer of deltaRMS samples, default 60 samples (30s at 2Hz adaptive rate)\n- bufferSize int: configurable, default 60\n- windowFn []float64: precomputed Hann window coefficients (reduces spectral leakage)\n- sampleRateHz float64: current sample rate (adaptive, from Phase 2 adaptive sensing rate bead)\n- minFreqHz float64: low end of breathing band (default 0.2 Hz)\n- maxFreqHz float64: high end of breathing band (default 1.0 Hz)\n- snrThreshold float64: minimum peak-to-noise ratio in dB to declare breathing (default 3 dB)\n\nMethods:\n- AddSample(deltaRMS float64): append to rolling buffer, overwrite oldest when full\n- Detect() BreathingResult: run FFT, find peak in breathing band, compute SNR, return result\n- BreathingResult: {IsBreathing bool, FrequencyHz float64, Confidence float64, PeakSNRdB float64}\n\nFFT implementation: use gonum.org/v1/gonum/dsp/fourier (already a project dependency from the UKF). Apply Hann window to buffer before FFT to reduce spectral leakage. FFT output is complex64 array; take abs to get amplitude spectrum. Bin resolution = sampleRateHz / bufferSize. For 2Hz * 60 samples: resolution = 0.033 Hz, which gives good separation of breathing harmonics.\n\nSNR computation: peak amplitude in [0.2, 1.0] Hz band divided by median amplitude of the full spectrum (median is robust to other peaks). SNR in dB = 20 * log10(peak/median). If SNR > snrThreshold, return IsBreathing=true.\n\n## Long-Dwell Logic\n\nEven without a breathing signal, a person who was detected in motion and then becomes still is likely still present for some time. Add a DwellTracker per link in the LinkProcessor (mothership/internal/signal/processor.go):\n\nDwell states:\n- CLEAR: no recent motion, no breathing signal\n- MOTION_DETECTED: current deltaRMS > motion threshold\n- POSSIBLY_PRESENT: was MOTION_DETECTED within last 10 seconds, now below threshold. Report as \"possibly present\" to fusion engine (lower weight).\n- STATIONARY_DETECTED: BreathingDetector reports IsBreathing=true. Report as \"stationary person\" with the breathing frequency.\n\nTransitions:\n- CLEAR -> MOTION_DETECTED: deltaRMS > motion threshold\n- MOTION_DETECTED -> POSSIBLY_PRESENT: deltaRMS < threshold for > 0.5s (debounce)\n- POSSIBLY_PRESENT -> MOTION_DETECTED: deltaRMS > threshold again\n- POSSIBLY_PRESENT -> STATIONARY_DETECTED: BreathingDetector fires\n- POSSIBLY_PRESENT -> CLEAR: 60 seconds without motion or breathing signal\n- STATIONARY_DETECTED -> POSSIBLY_PRESENT: BreathingDetector no longer fires\n- STATIONARY_DETECTED -> CLEAR: 120 seconds without motion or breathing signal (longer timeout because breathing detection is highly confident)\n\nThe dwell timer prevents premature \"CLEAR\" declarations for people sitting quietly, which is a common and highly frustrating false-negative.\n\n## Sensitivity Constraints\n\nBreathing detection only works reliably under these conditions:\n1. Direct line-of-sight (LoS) or single-reflection path between TX and RX — through-wall detection is too noisy\n2. The person is within the first Fresnel zone of the TX-RX link (see fusion bead)\n3. Link health score (ambient confidence bead) > 0.7 — low-confidence links produce too much noise\n4. No other people moving in the scene (other motion dominates the signal)\n5. Minimum duration: 15s of data before the first detection can fire (half the FFT window)\n\nThe system should gate breathing detection using the link health score from the ambient confidence bead. If health_score < 0.7, set BreathingDetector.enabled = false for that link.\n\n## Dashboard Integration\n\nAdd a \"Stationary person\" indicator to the dashboard link presence panel (distinct from the motion indicator):\n- Slow-pulsing blue dot (not the motion red/green) when STATIONARY_DETECTED state\n- Tooltip showing estimated breathing rate in breaths-per-minute (=frequencyHz * 60)\n- Timeline event logged: \"Stationary person detected on [link] at [time] — breathing at {N} bpm\"\n\nAdd to the link health WebSocket message (\"link_health\" type): breathing_state (\"CLEAR\"/\"POSSIBLY_PRESENT\"/\"MOTION_DETECTED\"/\"STATIONARY_DETECTED\"), breathing_freq_hz (null if not detected).\n\n## Tests\n\n- Test FFT output with synthetic breathing waveform: inject 60 samples of sin(2*pi*0.3*t) + noise (sigma=0.001) into BreathingDetector.AddSample(), verify Detect() returns IsBreathing=true, FrequencyHz ~= 0.3, SNR > 3 dB\n- Test that uniform random noise (no periodic component) does not trigger breathing detection (false positive rate < 5% across 1000 trials with sigma=0.001)\n- Test long-dwell timer transitions: MOTION_DETECTED -> POSSIBLY_PRESENT after 0.5s quiescence, POSSIBLY_PRESENT -> CLEAR after 60s, STATIONARY_DETECTED -> CLEAR after 120s\n- Test that BreathingDetector is disabled when health_score < 0.7\n- Test Hann window application produces expected output for a known input\n- Test that a breathing frequency outside the [0.2, 1.0] Hz band is not reported\n\n## Acceptance Criteria\n\n- Breathing detection fires for a stationary person in direct LoS with good link quality (health_score > 0.7) at SNR > 15 dB\n- False positive rate < 5% on an empty room with a high-quality link\n- Breathing frequency displayed in dashboard in breaths-per-minute (converted from FFT peak Hz)\n- Long-dwell logic prevents premature \"CLEAR\" declaration for a stationary person for at least 60 seconds after last motion\n- Breathing detection correctly gated off on low-health links\n- Dwell state transitions logged in activity timeline\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"charlie","created_at":"2026-03-28T01:40:45.831647006Z","created_by":"coding","updated_at":"2026-03-30T00:25:45.034604864Z","closed_at":"2026-03-30T00:25:45.034248272Z","close_reason":"Implemented stationary person detection via FFT-based breathing analysis. FFTBreathingDetector with 30s rolling buffer, DwellTracker state machine, health gating, dashboard integration with pulsing blue indicator, timeline event logging. All tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-r37","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:14.054454703Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-r37","depends_on_id":"spaxel-v9z","type":"blocks","created_at":"2026-03-28T01:40:48.996634547Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-r7t","title":"BLE address rotation detection & identity continuity","description":"## Overview\nHandle MAC address rotation in BLE devices (phones rotate every 15-30 min) to maintain continuous identity tracking.\n\n## Backend (mothership/ble/)\n- Rotation heuristics: manufacturer data fingerprinting, time+RSSI proximity, position continuity, merge confirmation\n- ble_device_aliases table: addr, canonical_addr, confidence, first_seen, last_seen\n- Alias matching in blob-to-device scoring: resolve rotated address to canonical identity\n- Graceful fallback: 5-min window before clearing identity when rotation is unresolved\n\n## Dashboard UI\n- Rotation icon indicator in BLE device registry\n- Manual merge/split UI: 'These look like the same device. Merge?' confirmation\n- Alias history expandable in device detail panel\n\n## Acceptance\n- Identity continuity across address rotation with >90% precision in test scenarios\n- No duplicate person tracks created on rotation event\n- Alias history queryable via GET /api/ble/devices/{mac}/aliases","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:20.030993892Z","created_by":"coding","updated_at":"2026-04-06T18:34:59.146861796Z","closed_at":"2026-04-06T18:34:59.146762203Z","close_reason":"Implemented BLE address rotation detection & identity continuity with manufacturer data fingerprinting, time+RSSI proximity heuristics, and merge confirmation. Backend includes RotationDetector, ble_device_aliases table, and REST API endpoints. Dashboard UI includes rotation icon indicator, manual merge/split UI, and alias history expandable panel.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} -{"id":"spaxel-s60","title":"Implement presence prediction","description":"Build predictive presence modeling for Home Assistant integration.\n\nDeliverables:\n- Per-person transition probability tracking\n- Per-zone occupancy patterns\n- Time-slot based predictions\n- HA sensor exposure for predicted states\n\nAcceptance: Predictions achieve >75% accuracy at 15-minute horizon.","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-03-29T19:25:04.052115700Z","created_by":"coding","updated_at":"2026-04-02T01:20:12.179026061Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:924","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} +{"id":"spaxel-s60","title":"Implement presence prediction","description":"Build predictive presence modeling for Home Assistant integration.\n\nDeliverables:\n- Per-person transition probability tracking\n- Per-zone occupancy patterns\n- Time-slot based predictions\n- HA sensor exposure for predicted states\n\nAcceptance: Predictions achieve >75% accuracy at 15-minute horizon.","status":"in_progress","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-03-29T19:25:04.052115700Z","created_by":"coding","updated_at":"2026-04-09T14:00:30.471428303Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:925","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-s70","title":"Activity timeline","description":"## Background\n\nSpaxel generates a continuous stream of events: presence detections, zone transitions, alerts, system events, learning milestones, health changes. Without a structured event stream, debugging is difficult, history is lost, and the system appears as a black box. The activity timeline is the universal event log — a chronological record of everything the system has seen. It doubles as the primary debugging interface and enables time-travel replay (an engineer can tap any timeline event and the 3D view jumps back to that moment).\n\n## Internal Event Bus\n\nNew package: mothership/internal/events/bus.go\n\nEventBus provides a typed publish-subscribe mechanism for all internal events. All subsystems publish to the bus; the timeline, automation engine, and notification module subscribe.\n\nImplementation: a simple channel-based pub/sub. Publisher side: bus.Publish(EventType, EventPayload). Subscriber side: bus.Subscribe(EventType) returns a channel. Multiple subscribers per event type are supported (fan-out).\n\nEventType enum:\n- MotionDetected, MotionCleared\n- ZoneTransition (a person crossed a portal)\n- ZoneOccupancyChanged (any occupancy change, including by anonymous tracks)\n- FallDetected, FallAcknowledged\n- NodeConnected, NodeDisconnected, NodeOTAComplete\n- BLEDeviceFirstSeen, BLEIdentityAssigned\n- WeightUpdate, DiurnalBaselineActivated\n- AnomalyDetected, AnomalyAcknowledged\n- SleepSessionStart, SleepSessionEnd\n- FeedbackSubmitted\n\nEventPayload is a typed interface. Each event type has its own concrete struct.\n\n## Timeline Storage\n\nSQLite table:\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n type TEXT NOT NULL,\n timestamp DATETIME NOT NULL,\n person_id TEXT,\n zone_id TEXT,\n data_json TEXT NOT NULL, -- full event payload as JSON\n feedback_type TEXT, -- populated by feedback loop (Phase 7)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX idx_events_timestamp ON events (timestamp DESC);\nCREATE INDEX idx_events_person ON events (person_id, timestamp DESC);\nCREATE INDEX idx_events_zone ON events (zone_id, timestamp DESC);\nCREATE INDEX idx_events_type ON events (type, timestamp DESC);\n\nTimeline subscriber: a goroutine that reads from the bus and writes to SQLite. Buffered with a 1000-event queue to avoid blocking publishers. If the queue fills: log a warning and drop the oldest (the bus is lossy for the storage subscriber, but this should never happen at normal event rates).\n\n## Dashboard Timeline Panel\n\nSidebar panel showing events in reverse-chronological order.\n\nEvent visual rendering per type:\n- MotionDetected / ZoneTransition: person avatar (coloured circle with initial) + description + timestamp + thumbs\n- FallDetected: red shield icon + \"Possible fall: [person] in [zone]\" + Acknowledge button\n- NodeConnected / NodeDisconnected: grey dot icon + \"Node [label] connected/disconnected\"\n- WeightUpdate / DiurnalBaselineActivated: green brain icon + \"Detection accuracy improved\" / \"Daily patterns activated\"\n- AnomalyDetected: orange warning icon + \"Anomaly: [description]\"\n- SleepSessionStart/End: moon icon + \"Alice went to sleep\" / \"Alice woke up\"\n\nEvent description templates (plain English, no jargon):\n- ZoneTransition: \"{person_name} walked from {from_zone} to {to_zone}\"\n- MotionDetected: \"Motion detected in {zone_name}\" (if no identity)\n- NodeDisconnected: \"Node {label} went offline — {duration} downtime\"\n- DiurnalBaselineActivated: \"System has learned {person_name}'s daily patterns. Detection accuracy improved.\"\n\nVirtualized rendering: use a virtual scroll list (render only visible items) since the timeline can have thousands of events. Implement using IntersectionObserver API for lazy loading of off-screen items.\n\nThumbs-up/down on each event: delegates to the feedback module (spaxel-3ps). Rendered as small icon buttons on the right side of each event row.\n\n## Search and Filter\n\nFilter bar above timeline:\n- Type filter: checkboxes for event categories (Presence, Zones, Alerts, System, Learning). Default: all.\n- Person filter: dropdown \"All people / Alice / Bob / Unknown\"\n- Zone filter: dropdown \"All zones / Kitchen / Bedroom / etc.\"\n- Date range: \"Today / Last 7 days / Last 30 days / Custom\"\n- Text search: fuzzy match on event description text (client-side filtering on loaded events; server-side for date-range queries)\n\nFiltered queries use the indexed columns in the events table. Return at most 500 events per page; \"Load more\" button for pagination.\n\n## Expert vs Simple Mode\n\nExpert mode: all event types visible. System events (node health, weight updates) shown as secondary (smaller text, greyed color).\n\nSimple mode: only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd (morning summary). System events hidden. This prevents \"terminal-style\" log noise from confusing non-technical users.\n\nMode is set by the current dashboard mode (expert vs simple) and passed as ?mode=expert or ?mode=simple to the API.\n\n## Tap-to-Jump (Time-Travel Coordination)\n\nWhen a timeline event is clicked (in expert mode), the dashboard emits a \"jump_to_time\" command with the event's timestamp. The time-travel replay module (Phase 8, separate bead) listens for this command and:\n1. Pauses live playback\n2. Seeks the CSI recording buffer to the event timestamp\n3. Begins replay from that point\n4. The 3D scene shows the \"replay\" state at that timestamp\n\nClicking the event also highlights it in the timeline (selected state) and shows a \"Now replaying\" chip in the timeline header.\n\n## REST API\n\nGET /api/events?since=&until=&type=&person_id=&zone_id=&limit=&mode=expert|simple\nReturns: paginated list of Event objects with all fields.\n\nGET /api/events/{id}: single event detail\nPOST /api/events/{id}/feedback: submit feedback for an event (delegates to feedback module)\n\n## Tests\n\n- Test EventBus pub/sub: publish event, verify subscriber channel receives it within 10ms\n- Test that multiple subscribers all receive the same event\n- Test timeline storage: publish 10 events of different types, verify all appear in SQLite with correct fields\n- Test search and filter: insert events for two people and two zones, query by person -> correct subset returned\n- Test time-range filtering: insert events at T-1h and T-25h; query since T-24h -> only T-1h event\n- Test virtualized rendering handles 1000+ events without layout jank (performance test in browser)\n- Test tap-to-jump emits correct timestamp to time-travel player\n- Test expert vs simple mode filter: system events excluded in simple mode\n\n## Acceptance Criteria\n\n- All event types appear in the timeline within 1 second of firing\n- Search and filter queries return correct subsets\n- Tap-to-jump coordinates with time-travel player (3D scene seeks to correct timestamp)\n- Simple mode hides system events while showing person-relevant events\n- Feedback buttons appear on each event and invoke the feedback module correctly\n- Timeline handles 10,000+ events without UI slowdown via virtualised rendering\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:54:31.341960586Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.636974843Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-s70","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.636944347Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-sbi","title":"Ambient confidence score and link health","description":"## Background\n\nNot all sensing links are equal. A link where a wall bisects the Fresnel zone produces consistently noisy detections. A link experiencing WiFi congestion from neighbour networks drops packets and has unreliable amplitude measurements. A link near a microwave oven sees periodic interference bursts. Without a quality metric, the fusion engine treats all links equally, and poor-quality links introduce noise that degrades overall localisation accuracy.\n\nThe ambient confidence score is a per-link quality metric that: (1) gates and weights detection algorithms so poor links contribute less, (2) surfaces actionable quality information to the user, and (3) powers the link weather diagnostics feature. A composite system-wide Detection Quality gauge summarises overall system health.\n\n## Per-Link Health Metrics\n\nNew module: mothership/internal/health/linkhealth.go\n\nLinkHealthScorer computes five sub-metrics per link, each in [0, 1]:\n\n1. SNR Estimate (weight 40%)\n Ratio of motion-period delta to quiet-period delta, expressed as a quality score.\n During known-quiet periods (determined by extended absence of motion, minimum 60s), record the ambient deltaRMS variance (sigma_quiet). During motion-active periods, record deltaRMS peaks (signal level). SNR_ratio = signal_level / sigma_quiet. Map to [0,1] via: score = min(1.0, log10(SNR_ratio) / log10(100)) where SNR=100:1 -> score=1.0, SNR=10:1 -> score=0.5.\n\n2. Phase Stability (weight 30%)\n During known-quiet periods, compute the variance of the phase offset across subcarriers. Low variance indicates stable hardware clock synchronisation between TX and RX, which is a prerequisite for reliable phase-based detection. High variance (>0.5 radians) suggests temperature drift or near-field metal interference.\n score = max(0, 1 - phase_variance / 0.5)\n\n3. Packet Rate Health (weight 20%)\n actual_pps / configured_rate. If configured at 50 Hz and receiving 40 Hz: score = 0.8.\n Rolling average over 10-second window.\n\n4. Baseline Drift (weight 10%)\n Rate of change of the EMA baseline over a 1-hour sliding window. High drift indicates an unstable environment (e.g. gradual temperature change, something blocking or unblocking the Fresnel zone). Computed as: drift_rate = |B_t - B_{t-1h}| / |B_{t-1h}| (normalised L2 change per hour).\n score = max(0, 1 - drift_rate / 0.1) where 10% per hour -> score=0.0.\n\n5. Composite Score\n composite = 0.4 * snr + 0.3 * phase_stability + 0.2 * packet_rate + 0.1 * (1 - baseline_drift_normalized)\n Clamped to [0, 1]. Updated every 10 seconds.\n\n## Dashboard Visualisation\n\nPer-link health is surfaced in multiple places:\n\nIn the 3D view (Phase 3 node placement UI, spaxel-qq6):\n- Link line thickness: 2px (health > 0.7), 1px (health 0.4-0.7), 0.5px (health < 0.4)\n- Link line colour: green (#22c55e at health=1.0) through yellow (#eab308 at health=0.5) through red (#ef4444 at health=0)\n\nIn the Link Health panel (sidebar, shown on link click):\n- Per-metric breakdown: four sub-score gauges (SNR, Phase Stability, Packet Rate, Baseline Drift) with label, value, and interpretation\n- Sparkline chart: composite health score over last 24 hours\n- \"Why is this low?\" contextual hint based on which sub-metric is lowest\n\nSystem-wide Detection Quality gauge (dashboard header):\n- Single number: weighted average of all active link composite scores\n- Rendered as a circular gauge (0-100%) with colour gradient\n- Tooltip: \"Based on N active links. Weakest link: [link name] at [score%]\"\n\n## API\n\nGET /api/links returns:\n[{\n \"link_id\": \"aabbccddee:ff:00:11:22:33\",\n \"tx_mac\": \"aa:bb:cc:dd:ee:ff\",\n \"rx_mac\": \"00:11:22:33:44:55\",\n \"health_score\": 0.83,\n \"health_details\": {\n \"snr\": 0.91,\n \"phase_stability\": 0.78,\n \"packet_rate\": 0.97,\n \"baseline_drift\": 0.62\n },\n \"last_updated\": \"2026-03-27T14:23:45Z\"\n}]\n\n## Gating Effects\n\nThe health score gates and weights two downstream systems:\n1. BreathingDetector (stationary person detection, spaxel-r37): disabled when composite health_score < 0.7\n2. FusionEngine (spaxel-m9a): each link's contribution to the 3D occupancy grid is multiplied by its health_score. A link with score=0.3 contributes only 30% as much as a link with score=1.0. This prevents degraded links from producing noisy phantom blobs.\n\nThe gating thresholds (0.7 for breathing, any value for weighted fusion) are configurable via mothership config.\n\n## Integration with Existing Code\n\nLinkHealthScorer is instantiated in mothership/internal/ingestion/server.go alongside the existing signal processors. It receives:\n- Packet arrival timestamps (to compute actual PPS vs configured)\n- deltaRMS values from the signal processor (for SNR computation)\n- Phase values from the signal processor (for phase stability)\n- Baseline vectors from BaselineManager (for drift computation)\n\nThe health scores are updated in background via a goroutine that fires every 10 seconds. Results are published on the internal event bus as LinkHealthUpdate events, which the dashboard hub broadcasts as \"link_health\" WebSocket messages.\n\n## Tests\n\n- Test composite score computation with mock inputs: all 1.0 -> 1.0, packet_rate=0.5 others 1.0 -> weighted result\n- Test SNR sub-score mapping: SNR_ratio=1 -> score=0, SNR_ratio=10 -> score=0.5, SNR_ratio=100 -> score=1.0\n- Test phase stability: variance=0 -> score=1.0, variance=0.5 -> score=0.0, variance=0.25 -> score=0.5\n- Test that breathing detection gating fires correctly when score drops below 0.7\n- Test FusionEngine link weight reflects health score (inspect internal state after injection)\n- Test API response format matches documented schema\n- Test that health score updates are published to the event bus\n\n## Acceptance Criteria\n\n- Per-link health scores computed and visible in dashboard for all active links\n- 3D link line thickness and colour reflect health score in real-time\n- Detection Quality gauge shows system-wide average health, updates every 10 seconds\n- BreathingDetector correctly gated off when link health < 0.7\n- FusionEngine link weights reflect health scores (verified via test)\n- Per-metric breakdown visible in Link Health panel on link click\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:41:30.452621121Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.806481028Z","closed_at":"2026-03-29T18:07:39.806256783Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-sbi","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:13.992381357Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-she","title":"fix: mqtt/client.go API mismatches for paho v1.5.0","description":"## Problem\n`internal/mqtt/client.go` uses methods that don't exist in paho.mqtt.golang v1.5.0 (the version in go.mod):\n\n1. **Line 120**: `opts.SetCleanOnConnect(true)` — method doesn't exist. In paho v1.5.0 the method is `opts.SetCleanSession(true)`\n2. **Line 147**: `opts.OnDisconnect = func(...)` — field doesn't exist. In paho v1.5.0 the callback is set via `opts.SetConnectionLostHandler(func(...))`\n3. **Lines 402, 404**: Redundant type assertions inside a type switch:\n - `case string: data = []byte(v.(string))` — `v` is already typed as `string` in this case branch; change to `data = []byte(v)`\n - `case []byte: data = v.([]byte)` — `v` is already `[]byte`; change to `data = v`\n\n## Fixes\n1. Line 120: `opts.SetCleanOnConnect(true)` → `opts.SetCleanSession(true)`\n2. Lines 147-160 (OnDisconnect assignment block): Replace with `opts.SetConnectionLostHandler(func(client mqtt.Client, err error) { ... })`\n3. Lines 402, 404: Remove the redundant type assertions\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/mqtt/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:21.813369312Z","created_by":"coding","updated_at":"2026-04-06T22:47:51.175731416Z","closed_at":"2026-04-06T22:47:51.175482589Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} @@ -143,6 +143,6 @@ {"id":"spaxel-xpk","title":"Diurnal adaptive baseline: 24-hour slot learning","description":"## Overview\nExtend the EMA baseline system with per-hour-of-day slots to eliminate false positives caused by daily environmental cycles (sunlight, HVAC, temperature changes).\n\n## Backend (mothership/signal/baseline.go extension)\n- Data structure: 24 hourly slots per link per subcarrier; each slot stores amplitude blob and sample_count\n- Learning phase (7 days): accumulate motion-free CSI into hourly slots; require >=300 samples/slot to mark ready\n- Steady state: on each fusion tick, select active baseline = weighted blend of diurnal slot (if ready) + EMA fallback\n- Crossfade: over first 15 min of each hour, linearly blend from EMA to diurnal slot; after 15 min use diurnal exclusively\n- Motion-gated updates: EMA updates continue during the hourly window, improving diurnal slot over time\n- Outlier protection: skip update if deltaRMS > motion threshold (don't train on motion frames)\n- SQLite diurnal_baselines table: link_id, hour_of_day (0-23), n_sub INT, amplitude BLOB, sample_count INT, confidence REAL, updated_at INT\n\n## Dashboard visualization\n- Per-link detail panel: 24-hour polar chart (or horizontal bar chart) showing baseline amplitude variance by hour\n- 'Diurnal learning' progress indicator: 'Learning hour 14... 6/7 days'\n- Confidence color per hour: green (ready), amber (partial), red (no data)\n\n## Acceptance\n- Baseline correctly crossfades at hour boundaries (±60s)\n- Motion events during learning do not corrupt slots (outlier protection confirmed by test)\n- Polar chart renders for links with >=1 ready slot\n- No performance regression: baseline lookup remains O(1)\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-09T13:05:47.358547333Z","closed_at":"2026-04-09T13:05:47.358191247Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:138"],"dependencies":[{"issue_id":"spaxel-xpk","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T22:30:46.133690574Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-yxr","title":"Ingestion: CSI frame validation with malformed counter and auto-close","description":"## Overview\nImplement strict CSI binary frame validation with per-connection malformed frame counters and automatic connection closure on persistent malformed input.\n\n## Validation rules (plan lines 303-324):\n- Minimum frame length: 24 bytes (header only, zero subcarriers valid)\n- Maximum frame length: 280 bytes (24 header + 128 subcarriers × 2 bytes I/Q)\n- n_sub field: must be ≤128\n- Payload length: must equal n_sub × 2 bytes exactly\n- channel: must be in [1,14] for 2.4 GHz; drop if 0 or >14\n- rssi: int8; 0 treated as invalid/missing (not an error, but log at DEBUG)\n- timestamp_us: any uint64 value accepted\n\n## Per-connection malformed counter (sliding 60-second window):\n- Track malformed_count and window_start_ms per WebSocket connection\n- On each validation failure: increment malformed_count; log at DEBUG\n- Every 60s: check counts → if malformed_count > 100: log WARN 'Node {mac} sent {N} malformed frames in 60s'\n- If malformed_count > 1000 within 60s: close WebSocket with message 'Excessive malformed frames — possible firmware bug'\n- Reset counter every 60s\n\n## Acceptance\n- Valid frame: passes all checks in <1 μs\n- Frame with n_sub=200: rejected (n_sub > 128)\n- Frame with len=10: rejected (< 24 bytes)\n- Frame with channel=0: dropped silently\n- 1001 malformed frames in 60s: connection closed with correct message\n- 101 malformed frames: WARN logged, connection kept open","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-06T16:44:21.981852269Z","created_by":"coding","updated_at":"2026-04-07T16:23:24.731432820Z","closed_at":"2026-04-07T16:23:24.731370070Z","close_reason":"Implemented CSI frame validation with DEBUG logging and performance benchmark.\n\nAll validation rules from plan lines 303-324 implemented:\n- Minimum frame length: 24 bytes ✓\n- Maximum frame length: 280 bytes ✓ \n- n_sub ≤ 128 ✓\n- Payload length = n_sub × 2 bytes ✓\n- Channel in [1,14] for 2.4 GHz ✓\n- RSSI=0 logged at DEBUG (allowed) ✓\n- timestamp_us any value ✓\n\nPer-connection malformed counter (60s sliding window):\n- DEBUG log on each validation failure ✓\n- WARN log when count > 100 ✓\n- Auto-close when count > 1000 ✓\n- Counter resets every 60s ✓\n\nAdded benchmark tests to verify <1 μs validation performance for valid frames.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} {"id":"spaxel-zpt","title":"Spatial context notifications with floor-plan thumbnails","description":"## Background\n\nPush notifications without context are ignored or disabled. \"Motion detected\" tells you nothing useful. \"Alice walked into the Kitchen — Bob is already there\" is genuinely interesting. \"Possible fall: Alice in Hallway — unacknowledged for 3 minutes\" demands immediate attention. The plan specifies server-side rendering of mini floor-plan thumbnails attached to notifications to provide instant spatial context without opening the app.\n\n## Server-Side Floor-Plan Renderer\n\nNew package: mothership/internal/render/floorplan.go\n\nThe renderer produces a top-down 2D PNG (300x300 pixels) showing:\n- Room outline: outer boundary of all zones as white rectangles on dark background\n- Zone fills: each zone as a semi-transparent coloured fill (zone.color at 20% opacity)\n- Zone labels: zone name in small white text at zone centroid\n- Node positions: small white circle dots\n- Person blobs: coloured circles (person.color) at their last-known position, diameter proportional to detection confidence (min 10px, max 20px)\n- Name labels: person name in white text above each blob circle, if identity is known\n- Portal planes: thin lines in purple (#a855f7)\n- Event highlight: the zone where the event occurred rendered with brighter fill and a white border\n\nRendering library: use github.com/fogleman/gg (a pure-Go 2D graphics library). Alternative: standard image/draw + image/png for maximum portability. The fogleman/gg approach is recommended for its higher-level drawing API (bezier curves, text, etc.).\n\nThe PNG must be generated within 200ms to not delay notification delivery. At 300x300 with simple geometry, this should be easily achievable.\n\nThe rendered PNG is stored as a []byte and passed to the notification delivery function. It is base64-encoded for attachment in webhook payloads or passed as a file to ntfy/Pushover APIs.\n\n## Notification Types and Triggers\n\n1. zone_enter: \"{{person_name}} entered {{zone_name}}\" — LOW priority unless security mode is active\n2. zone_leave: \"{{person_name}} left {{zone_name}}\" — LOW priority\n3. zone_vacant: \"{{zone_name}} is now empty\" — LOW priority\n4. fall_detected: \"Possible fall: {{person_name}} in {{zone_name}}\" — URGENT, always immediate\n5. fall_escalation: \"URGENT: Fall unacknowledged for 5 minutes — {{person_name}} in {{zone_name}}\" — URGENT\n6. anomaly_alert: \"Unexpected presence: {{zone_name}}\" — HIGH priority (breaks quiet hours)\n7. node_offline: \"Node {{node_label}} has gone offline\" — MEDIUM priority\n8. sleep_summary: \"Last night: {{sleep_duration}}\" — LOW priority, morning delivery\n\n## Smart Batching\n\nIf multiple LOW or MEDIUM priority events fire within a 30-second window, batch them into a single notification:\n- \"Alice entered Kitchen. Bob left Living Room.\"\n- \"2 presence events in the last 30 seconds.\"\n\nBatching rules:\n- Batch only events of the same priority level\n- Never batch URGENT events — those are always immediate\n- Never batch events involving different notification types if the combination is confusing\n- Batch counter: if more than 5 events in 30s, summarise as \"N presence events in the last minute\"\n\nBatching implementation: a 30-second window timer per notification channel. When the first LOW event fires, start the 30s timer. Accumulate events. On timer expiry: merge into one notification and deliver.\n\n## Quiet Hours\n\nUser-configurable quiet hours: from_time, to_time (e.g. \"22:00\" to \"07:00\"). Stored in SQLite notifications_config (channel, quiet_from, quiet_to, quiet_days_bitmask).\n\nDuring quiet hours:\n- LOW priority notifications are queued\n- MEDIUM priority notifications are queued\n- HIGH and URGENT notifications are delivered immediately regardless of quiet hours\n\nAt the end of quiet hours (07:00 on non-override days): deliver all queued notifications as a morning digest bundle: \"While you were asleep: [summary of queued events]\"\n\n## Delivery Channels\n\nntfy:\n- POST to https://ntfy.sh/{topic} (or self-hosted server URL)\n- Headers: Authorization: Bearer {token} (if configured), Priority: urgent/high/default/low/min\n- Body: the notification text\n- Headers: Attach: {base64_encoded_png_url} — for ntfy, attach the floor-plan as a URL if mothership is publicly accessible, or send as base64 data URL for local deployments\n\nPushover:\n- POST to https://api.pushover.net/1/messages.json\n- Fields: token, user, message, title, priority, attachment (PNG as multipart form upload)\n\nGeneric webhook:\n- POST to user-configured URL\n- Body: {\"event_type\":\"...\", \"message\":\"...\", \"person_id\":\"...\", \"zone_id\":\"...\", \"timestamp\":\"...\", \"floorplan_png_base64\":\"...\"}\n\n## Configuration UI\n\nDashboard Settings panel -> \"Notifications\" tab:\n- Delivery channel selector: None / ntfy / Pushover / Webhook\n- Channel-specific credential fields (ntfy server URL + topic + token, Pushover API key, webhook URL)\n- Test notification button: sends a test notification to verify configuration\n- Event type enable/disable toggles: per event type, can disable e.g. \"zone_enter\" while keeping \"fall_detected\" enabled\n- Quiet hours: time picker from/to, day-of-week selector\n- Smart batching toggle (default on)\n- \"Morning digest\" toggle (default on — delivers batched quiet-hours events at wake time)\n\n## Files to Create or Modify\n\n- mothership/internal/render/floorplan.go: floor-plan PNG renderer\n- mothership/internal/notifications/manager.go: NotificationManager, batching, quiet hours logic\n- mothership/internal/notifications/ntfy.go: ntfy delivery client\n- mothership/internal/notifications/pushover.go: Pushover delivery client\n- mothership/internal/notifications/webhook.go: generic webhook delivery\n- mothership/internal/dashboard/routes.go: GET/PUT /api/settings/notifications, POST /api/notifications/test\n\n## Tests\n\n- Test floor-plan renderer produces a 300x300 PNG with correct dimensions\n- Test that zone boundaries appear in the rendered PNG at correct coordinates (check pixel colors at known positions)\n- Test batching: 3 LOW events within 10s -> 1 notification; 1 URGENT event -> immediate even if batching timer is active\n- Test quiet hours gate: LOW event at 23:00 with quiet hours 22:00-07:00 -> queued; URGENT event at 23:00 -> delivered immediately\n- Test morning digest delivery: queued events are bundled and delivered at quiet_hours_end\n- Test ntfy delivery with mock HTTP server: verify correct headers and body format\n- Test webhook delivery with mock HTTP server: verify correct JSON body and base64 PNG field\n- Test test-notification endpoint fires correctly\n\n## Acceptance Criteria\n\n- Notification received via ntfy within 5 seconds of trigger event for URGENT priority\n- Floor-plan PNG correctly shows zone boundaries and person positions in the notification\n- Smart batching prevents more than one notification per 30-second window for LOW events\n- Quiet hours suppress LOW/MEDIUM notifications and queue them for morning digest\n- Fall detection and anomaly alerts always bypass quiet hours\n- Morning digest delivered correctly at quiet hours end\n- Test notification button correctly verifies channel configuration\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:48:19.528717849Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.371730406Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.371640840Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:48:23.948107860Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:48:23.975916991Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-zvb","title":"Mothership: adaptive load shedding & resource throttling","description":"## Overview\nImplement a 4-level load shedding system to keep the fusion pipeline responsive under CPU/memory pressure, especially for large fleets.\n\n## Pipeline instrumentation\n- Time each of the 8 fusion pipeline stages per iteration using time.Since()\n- Maintain 5-iteration rolling average of total iteration time (ring buffer of 5 durations)\n\n## Load shedding state machine\nLevel 0 (normal): rolling avg < 80 ms — full pipeline\nLevel 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation (~3 ms saved/iter)\nLevel 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes (~2 ms saved/iter)\nLevel 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; push rate reduction config to all nodes (10 Hz cap)\n\nRecovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level\n\n## Integration points\n- Health endpoint GET /healthz: include shedding_level (0-3) in response\n- Dashboard status bar: show 'System load: NOMINAL / LIGHT / MODERATE / HIGH'\n- WS alert when Level 3 triggered: {type: 'alert', severity: 'warning', description: 'System under load — CSI rate reduced to 10 Hz'}\n- Level 3 recovery: push config message to all nodes restoring their prior rate\n\n## Acceptance\n- Load shedding level changes logged at INFO\n- Level 3 triggers correctly when ingest channel >50% full\n- Node rate restoration confirmed after Level 3 recovery\n- Health endpoint reflects current level\n- No mutex contention from shedding logic itself (must be lock-free reads)","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-07T20:49:19.853741601Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:228"],"dependencies":[{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.124863668Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-5yq","type":"blocks","created_at":"2026-04-07T06:33:23.159852888Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-zvb","title":"Mothership: adaptive load shedding & resource throttling","description":"## Overview\nImplement a 4-level load shedding system to keep the fusion pipeline responsive under CPU/memory pressure, especially for large fleets.\n\n## Pipeline instrumentation\n- Time each of the 8 fusion pipeline stages per iteration using time.Since()\n- Maintain 5-iteration rolling average of total iteration time (ring buffer of 5 durations)\n\n## Load shedding state machine\nLevel 0 (normal): rolling avg < 80 ms — full pipeline\nLevel 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation (~3 ms saved/iter)\nLevel 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes (~2 ms saved/iter)\nLevel 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; push rate reduction config to all nodes (10 Hz cap)\n\nRecovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level\n\n## Integration points\n- Health endpoint GET /healthz: include shedding_level (0-3) in response\n- Dashboard status bar: show 'System load: NOMINAL / LIGHT / MODERATE / HIGH'\n- WS alert when Level 3 triggered: {type: 'alert', severity: 'warning', description: 'System under load — CSI rate reduced to 10 Hz'}\n- Level 3 recovery: push config message to all nodes restoring their prior rate\n\n## Acceptance\n- Load shedding level changes logged at INFO\n- Level 3 triggers correctly when ingest channel >50% full\n- Node rate restoration confirmed after Level 3 recovery\n- Health endpoint reflects current level\n- No mutex contention from shedding logic itself (must be lock-free reads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-09T13:49:26.228902385Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:228"],"dependencies":[{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.124863668Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-5yq","type":"blocks","created_at":"2026-04-07T06:33:23.159852888Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvo","title":"Interactive onboarding wizard","description":"## Background\n\nPhase 4's central goal is that a non-technical user can go from an unboxed ESP32-S3 to streaming CSI in under 5 minutes. The onboarding wizard is the centrepiece of this experience. It uses the Web Serial API (available in Chrome/Edge) to communicate with the ESP32 over USB — no driver installation needed, no CLI, no app download. The wizard is embedded in the existing mothership dashboard, accessible at /onboard.\n\n## Why Web Serial?\n\nThe alternative approaches — a dedicated mobile app, a WiFi provisioning AP, or a CLI tool — all have significant UX friction. Web Serial lets us flash firmware, provision WiFi credentials, and guide the user through calibration all in one browser session. The dashboard already knows the mothership IP/port. Chrome and Edge (95%+ of desktop browser market) support Web Serial natively since 2021. The only caveat is that Web Serial is not available in Firefox or Safari — this must be documented prominently at the start of the wizard.\n\n## Wizard Steps\n\n1. Browser check: Detect navigator.serial availability. If missing, show: \"Please use Google Chrome or Microsoft Edge to use the setup wizard. Firefox and Safari do not support USB device access.\"\n\n2. Connect device: Call navigator.serial.requestPort(). Guide the user to hold BOOT button while plugging in if the device does not appear. Show a SVG illustration of the ESP32-S3 board with the BOOT button highlighted.\n\n3. Flash firmware (if not already spaxel firmware): Use esp-web-tools (espressif/esp-web-tools). This open-source library handles the full ESP32 flashing pipeline via Web Serial, including ROM bootloader protocol, chip detection, and progress reporting. It needs a firmware manifest.json at GET /api/firmware/manifest describing binary addresses and offsets. Show a progress bar during flashing. Estimated time: 45-90 seconds.\n\n4. Provision WiFi: Show a form for SSID and password. Optional: mothership host/port override (for non-mDNS setups). Assemble the provisioning payload and send to the ESP32 over serial as JSON (see Provisioning Payload bead for format).\n\n5. Detect mothership: Once provisioned and rebooted, the ESP32 boots and discovers the mothership via mDNS (spaxel-mothership.local) or the configured host. Poll GET /api/nodes every 3s for up to 120s waiting for the new node to appear. Show animated \"Connecting...\" indicator. On timeout: show WiFi troubleshooting guidance (5GHz check, SSID typo check, distance check).\n\n6. Guided calibration: Show the CSI waveform for the new node's links as they come online. Steps:\n a. \"Walk around your space for 30 seconds\" — CSI amplitude should show activity. If flat: check node orientation.\n b. \"Stand still at the far end of the room\" — capture baseline. Show countdown. Green check when baseline is captured.\n c. \"Walk through the centre of the room\" — Fresnel zone lights up in 3D view, blob appears. \"The sensor can see you!\"\n\n7. Node placement guidance: Transition to the coverage painting UI (spaxel-qq6) for optimal node positioning. Show GDOP overlay for the current node placement. Suggest additional node positions if coverage is poor.\n\n## Files to Create or Modify\n\n- dashboard/js/onboard.js: wizard state machine, Web Serial API calls, step rendering\n- dashboard/index.html: add /onboard route and wizard container div, import esp-web-tools\n- mothership/internal/dashboard/ routes: add GET /api/firmware/manifest route\n- Firmware manifest JSON served at GET /api/firmware/manifest with chipFamily, parts array containing path and offset\n\n## esp-web-tools Integration\n\nThe library esp-web-tools is loaded from CDN as an ES module. A custom-element install-button is used for flashing. The manifest served by the mothership includes the firmware binary path (/firmware/latest) and flash offset (0x0). The library handles the bootloader handshake, erase, and write automatically.\n\n## Wizard State Machine\n\nStates: BROWSER_CHECK -> CONNECT_DEVICE -> FLASH_FIRMWARE -> PROVISION_WIFI -> DETECT_NODE -> CALIBRATE -> PLACEMENT -> COMPLETE\n\nEach state has: render() function, onEnter() side effects, onNext() transition, onBack() for revert, onError() for failure handling.\n\nPersisted in sessionStorage so a page refresh during onboarding resumes from the last step — critical for the reboot-then-detect step where the browser must survive the ESP32 reboot cycle.\n\n## Error Handling\n\nMap every known failure to a human-friendly message:\n- NotFoundError (no port selected) -> \"No device detected. Make sure the USB cable is connected and hold the BOOT button while plugging in.\"\n- NetworkError during flash -> \"The connection was interrupted. Check the USB cable is not loose and try again.\"\n- Node not appearing after 120s -> \"Your node connected to WiFi but cannot reach the mothership. Check: 1) Your router blocks device-to-device communication (AP isolation). 2) The mothership address is correct. 3) Your network uses a VLAN that separates devices.\"\n- Wrong SSID/password -> Node will fall into captive portal mode after 10 failures, triggering a \"Captive portal detected\" guidance flow.\n\nNever show stack traces, WebSocket error codes, or Go error strings to the user.\n\n## Tests\n\n- Mock navigator.serial API in Jest to test wizard state transitions without real hardware\n- Test that provisioning payload is correctly assembled and sent over the mocked serial port\n- Test that polling GET /api/nodes correctly detects node appearance and transitions to DETECT_NODE -> CALIBRATE\n- Test that BROWSER_CHECK step correctly detects missing serial API and shows the correct error\n- Test that sessionStorage correctly restores wizard state on page refresh at each step\n\n## Acceptance Criteria\n\n- Wizard completes in under 5 minutes on a fresh ESP32-S3 with a working WiFi network\n- User sees live CSI waveform during calibration step\n- Node appears in dashboard after wizard completion, with correct label\n- All known error conditions show human-friendly guidance, not technical errors\n- All existing dashboard tests pass\n- Wizard state is resumable after page refresh","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:36:08.928580604Z","created_by":"coding","updated_at":"2026-03-28T08:01:41.237288050Z","closed_at":"2026-03-28T08:01:41.237159218Z","close_reason":"Fixed 4 failing tests in the onboarding wizard test suite:\n\n1. WebSocket mock: Changed from constructor-prototype pattern to factory function so jest.resetAllMocks() doesn't break the mock. Fixed 'state.ws.close is not a function' errors during calibrate step cleanup.\n\n2. TextEncoderStream mock: Added functional readable/writable with pipeTo mock and data capture helpers (__getLastEncodedData/__clearLastEncodedData) to support provisioning serial send tests.\n\n3. flash_firmware test: Fixed assertion to check wizard-nav element for 'Skip Flashing' button instead of wizard-content (the nav button is rendered separately from step content).\n\n4. provisionAndSend 'no port' test: Changed getPorts mock from mockResolvedValueOnce to mockResolvedValue([]) so both the primary and fallback provisioning paths consistently fail when no port is available.\n\nAll 60 tests now pass. The onboarding wizard implementation (onboard.js, index.html, mothership firmware manifest route) was already complete from the previous commit.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvo","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.806490089Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvs","title":"Phase 6: Identity & Spatial Automation","description":"Goal: Named presence, actionable automations, safety features.\n\nDeliverables:\n- BLE device registry (People & Devices panel, auto-detected type, user labels/color)\n- BLE-to-blob identity matching (multi-node RSSI triangulation → nearest CSI blob)\n- Room transition portals (doorway planes, directional crossing, zone occupancy counters)\n- Spatial automation builder (3D trigger volumes, conditions, webhook/MQTT actions)\n- Fall detection (Z-axis descent + sustained stillness, alert chain, person-identified)\n- Spatial context notifications (push with mini floor-plan thumbnails, smart batching, quiet hours)\n- Home automation integration (optional MQTT for HA auto-discovery, webhooks)\n\nExit criteria: BLE-identified blobs show correct names. Fall detection fires on simulated falls <10% FP.","status":"closed","priority":3,"issue_type":"phase","assignee":"delta","created_at":"2026-03-27T01:55:32.553129034Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.888675543Z","closed_at":"2026-03-29T18:07:39.888615041Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvs","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T01:33:45.440982494Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index f475d9a..664bce7 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -6705826f922b604e805c080c39d3d567ff68caf2 +ef75a823fcf137f4e836d4bf11011b77ca27cb60 diff --git a/dashboard/js/sleep.js b/dashboard/js/sleep.js index 19459b5..552a950 100644 --- a/dashboard/js/sleep.js +++ b/dashboard/js/sleep.js @@ -459,8 +459,10 @@ * @param {Object} msg - { type: 'morning_summary', report: { ... } } */ function handleMorningSummary(msg) { - if (msg.report) { - showMorningSummary(msg.report); + // Backend sends "sleep" field (from BroadcastMorningSummary in hub.go) + var report = msg.report || msg.sleep; + if (report) { + showMorningSummary(report); } } @@ -495,4 +497,7 @@ handleSleepStatus: handleSleepStatus, fetchSleepData: fetchSleepData }; + + // Auto-initialize on load + init(); })(); diff --git a/mothership/internal/api/localization.go b/mothership/internal/api/localization.go new file mode 100644 index 0000000..20f94e5 --- /dev/null +++ b/mothership/internal/api/localization.go @@ -0,0 +1,457 @@ +// Package api provides REST API handlers for self-improving localization. +package api + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/spaxel/mothership/internal/localization" +) + +// LocalizationHandler manages self-improving localization API endpoints. +type LocalizationHandler struct { + groundTruthStore *localization.GroundTruthStore + spatialWeightLearner *localization.SpatialWeightLearner + weightLearner *localization.WeightLearner + weightStore *localization.WeightStore + selfImprovingLocalizer *localization.SelfImprovingLocalizer +} + +// NewLocalizationHandler creates a new localization API handler. +func NewLocalizationHandler( + gtStore *localization.GroundTruthStore, + swLearner *localization.SpatialWeightLearner, + wLearner *localization.WeightLearner, + wStore *localization.WeightStore, + sil *localization.SelfImprovingLocalizer, +) *LocalizationHandler { + return &LocalizationHandler{ + groundTruthStore: gtStore, + spatialWeightLearner: swLearner, + weightLearner: wLearner, + weightStore: wStore, + selfImprovingLocalizer: sil, + } +} + +// RegisterRoutes registers localization endpoints. +func (h *LocalizationHandler) RegisterRoutes(r chi.Router) { + // Learned weights endpoints + r.Get("/api/localization/weights", h.getWeights) + r.Get("/api/localization/weights/{linkID}", h.getLinkWeight) + r.Post("/api/localization/weights/reset", h.resetWeights) + r.Get("/api/localization/weights/stats", h.getWeightStats) + + // Spatial weights endpoints + r.Get("/api/localization/spatial-weights", h.getSpatialWeights) + r.Get("/api/localization/spatial-weights/stats", h.getSpatialWeightStats) + r.Get("/api/localization/spatial-weights/zone/{zoneX}/{zoneY}", h.getSpatialWeightsForZone) + + // Ground truth endpoints + r.Get("/api/localization/groundtruth/samples", h.getGroundTruthSamples) + r.Get("/api/localization/groundtruth/stats", h.getGroundTruthStats) + r.Post("/api/localization/groundtruth/compute-accuracy", h.computeWeeklyAccuracy) + + // Accuracy and improvement endpoints + r.Get("/api/localization/accuracy/history", h.getAccuracyHistory) + r.Get("/api/localization/accuracy/current", h.getCurrentAccuracy) + r.Get("/api/localization/accuracy/improvement", h.getImprovementStats) + + // Learning progress endpoints + r.Get("/api/localization/learning/progress", h.getLearningProgress) + r.Get("/api/localization/learning/history", h.getImprovementHistory) + + // Self-improving localizer endpoints + r.Get("/api/localization/self-improving/status", h.getSelfImprovingStatus) + r.Post("/api/localization/self-improving/process", h.processLearning) +} + +// getWeights handles GET /api/localization/weights +func (h *LocalizationHandler) getWeights(w http.ResponseWriter, r *http.Request) { + if h.weightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available") + return + } + + weights := h.weightLearner.GetLearnedWeights().GetAllWeights() + stats := h.weightLearner.GetAllStats() + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "weights": weights, + "stats": stats, + }) +} + +// getLinkWeight handles GET /api/localization/weights/{linkID} +func (h *LocalizationHandler) getLinkWeight(w http.ResponseWriter, r *http.Request) { + if h.weightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available") + return + } + + linkID := chi.URLParam(r, "linkID") + weights := h.weightLearner.GetLearnedWeights() + stats := h.weightLearner.GetLinkStats(linkID) + + result := map[string]interface{}{ + "link_id": linkID, + "weight": weights.GetLinkWeight(linkID), + "sigma": weights.GetLinkSigma(linkID), + } + + if stats != nil { + result["stats"] = stats + } + + writeJSON(w, http.StatusOK, result) +} + +// resetWeights handles POST /api/localization/weights/reset +func (h *LocalizationHandler) resetWeights(w http.ResponseWriter, r *http.Request) { + if h.weightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available") + return + } + + // Reset all weights to default + weights := h.weightLearner.GetLearnedWeights() + weights.mu.Lock() + weights.linkWeights = make(map[string]float64) + weights.linkSigmas = make(map[string]float64) + weights.linkStats = make(map[string]*localization.LinkLearningStats) + weights.lastUpdate = time.Now() + weights.mu.Unlock() + + // Persist reset + if h.weightStore != nil { + if err := h.weightStore.SaveWeights(weights); err != nil { + log.Printf("[WARN] Failed to save reset weights: %v", err) + } + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "weights_reset"}) +} + +// getWeightStats handles GET /api/localization/weights/stats +func (h *LocalizationHandler) getWeightStats(w http.ResponseWriter, r *http.Request) { + if h.weightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available") + return + } + + stats := h.weightLearner.GetAllStats() + progress := h.weightLearner.GetLearningProgress() + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "stats": stats, + "progress": progress, + }) +} + +// getSpatialWeights handles GET /api/localization/spatial-weights +func (h *LocalizationHandler) getSpatialWeights(w http.ResponseWriter, r *http.Request) { + if h.spatialWeightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available") + return + } + + weights := h.spatialWeightLearner.GetAllWeights() + stats := h.spatialWeightLearner.GetWeightStats() + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "spatial_weights": weights, + "stats": stats, + }) +} + +// getSpatialWeightStats handles GET /api/localization/spatial-weights/stats +func (h *LocalizationHandler) getSpatialWeightStats(w http.ResponseWriter, r *http.Request) { + if h.spatialWeightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available") + return + } + + stats := h.spatialWeightLearner.GetWeightStats() + + writeJSON(w, http.StatusOK, stats) +} + +// getSpatialWeightsForZone handles GET /api/localization/spatial-weights/zone/{zoneX}/{zoneY} +func (h *LocalizationHandler) getSpatialWeightsForZone(w http.ResponseWriter, r *http.Request) { + if h.spatialWeightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available") + return + } + + zoneX, err := strconv.Atoi(chi.URLParam(r, "zoneX")) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid zoneX") + return + } + + zoneY, err := strconv.Atoi(chi.URLParam(r, "zoneY")) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid zoneY") + return + } + + weights := h.spatialWeightLearner.GetWeightsForZone(zoneX, zoneY) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "zone_x": zoneX, + "zone_y": zoneY, + "weights": weights, + }) +} + +// getGroundTruthSamples handles GET /api/localization/groundtruth/samples +func (h *LocalizationHandler) getGroundTruthSamples(w http.ResponseWriter, r *http.Request) { + if h.groundTruthStore == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available") + return + } + + // Parse query parameters + personID := r.URL.Query().Get("person") + limitStr := r.URL.Query().Get("limit") + limit := 100 + if limitStr != "" { + if n, err := strconv.Atoi(limitStr); err == nil && n > 0 { + limit = n + } + } + + var samples []localization.GroundTruthSample + var err error + + if personID != "" { + // Get samples for specific person + samples, err = h.groundTruthStore.GetSamplesInTimeRange( + time.Now().Add(-24*time.Hour), time.Now()) + // Filter by person + filtered := make([]localization.GroundTruthSample, 0) + for _, s := range samples { + if s.PersonID == personID { + filtered = append(filtered, s) + } + } + samples = filtered + } else { + samples, err = h.groundTruthStore.GetRecentSamples(limit) + } + + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "samples": samples, + "count": len(samples), + }) +} + +// getGroundTruthStats handles GET /api/localization/groundtruth/stats +func (h *LocalizationHandler) getGroundTruthStats(w http.ResponseWriter, r *http.Request) { + if h.groundTruthStore == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available") + return + } + + total, err := h.groundTruthStore.GetTotalSampleCount() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + byPerson, err := h.groundTruthStore.GetSampleCountByPerson() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + today, err := h.groundTruthStore.GetSamplesTodayCount() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + zoneCounts, err := h.groundTruthStore.GetZoneSampleCounts() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "total_samples": total, + "today_samples": today, + "by_person": byPerson, + "zone_counts": zoneCounts, + }) +} + +// computeWeeklyAccuracy handles POST /api/localization/groundtruth/compute-accuracy +func (h *LocalizationHandler) computeWeeklyAccuracy(w http.ResponseWriter, r *http.Request) { + if h.groundTruthStore == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available") + return + } + + // Get current week + week := localization.GetWeekString(time.Now()) + + if err := h.groundTruthStore.ComputeWeeklyAccuracy(week); err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "accuracy_computed", + "week": week, + }) +} + +// getAccuracyHistory handles GET /api/localization/accuracy/history +func (h *LocalizationHandler) getAccuracyHistory(w http.ResponseWriter, r *http.Request) { + if h.groundTruthStore == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available") + return + } + + weeksStr := r.URL.Query().Get("weeks") + weeks := 12 // Default 12 weeks + if weeksStr != "" { + if n, err := strconv.Atoi(weeksStr); err == nil && n > 0 { + weeks = n + } + } + + records, err := h.groundTruthStore.GetPositionAccuracyHistory(weeks) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "records": records, + "weeks": weeks, + }) +} + +// getCurrentAccuracy handles GET /api/localization/accuracy/current +func (h *LocalizationHandler) getCurrentAccuracy(w http.ResponseWriter, r *http.Request) { + if h.groundTruthStore == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available") + return + } + + current, err := h.groundTruthStore.GetCurrentPositionAccuracy() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + if current == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "message": "no accuracy data for current week", + }) + return + } + + writeJSON(w, http.StatusOK, current) +} + +// getImprovementStats handles GET /api/localization/accuracy/improvement +func (h *LocalizationHandler) getImprovementStats(w http.ResponseWriter, r *http.Request) { + if h.groundTruthStore == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available") + return + } + + stats, err := h.groundTruthStore.GetPositionImprovementStats() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, stats) +} + +// getLearningProgress handles GET /api/localization/learning/progress +func (h *LocalizationHandler) getLearningProgress(w http.ResponseWriter, r *http.Request) { + if h.weightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available") + return + } + + progress := h.weightLearner.GetLearningProgress() + stats := h.weightLearner.GetAllStats() + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "progress": progress, + "stats": stats, + }) +} + +// getImprovementHistory handles GET /api/localization/learning/history +func (h *LocalizationHandler) getImprovementHistory(w http.ResponseWriter, r *http.Request) { + if h.weightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available") + return + } + + history := h.weightLearner.GetImprovementHistory() + stats := h.weightLearner.GetImprovementStats() + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "history": history, + "stats": stats, + }) +} + +// getSelfImprovingStatus handles GET /api/localization/self-improving/status +func (h *LocalizationHandler) getSelfImprovingStatus(w http.ResponseWriter, r *http.Request) { + if h.selfImprovingLocalizer == nil { + writeJSONError(w, http.StatusServiceUnavailable, "self-improving localizer not available") + return + } + + progress := h.selfImprovingLocalizer.GetLearningProgress() + weights := h.selfImprovingLocalizer.GetLearnedWeights() + improvementStats := h.selfImprovingLocalizer.GetImprovementStats() + improvementHistory := h.selfImprovingLocalizer.GetImprovementHistory() + gtStats, _ := h.selfImprovingLocalizer.GetGroundTruthProvider().GetObservationCount() + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "learning_progress": progress, + "learned_weights": weights, + "improvement_stats": improvementStats, + "improvement_history": improvementHistory, + "ble_observations_count": gtStats, + }) +} + +// processLearning handles POST /api/localization/self-improving/process +func (h *LocalizationHandler) processLearning(w http.ResponseWriter, r *http.Request) { + if h.weightLearner == nil { + writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available") + return + } + + if err := h.weightLearner.ProcessLearning(); err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Record error snapshot for improvement tracking + h.weightLearner.RecordErrorSnapshot() + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "learning_processed", + "timestamp": time.Now().Format(time.RFC3339), + }) +} diff --git a/mothership/internal/api/localization_test.go b/mothership/internal/api/localization_test.go new file mode 100644 index 0000000..7c59095 --- /dev/null +++ b/mothership/internal/api/localization_test.go @@ -0,0 +1,844 @@ +// Package api provides REST API tests for self-improving localization. +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/spaxel/mothership/internal/localization" +) + +func TestLocalizationHandler_getWeights(t *testing.T) { + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create components + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/weights + req := httptest.NewRequest("GET", "/api/localization/weights", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check fields + if _, ok := result["weights"]; !ok { + t.Error("Missing weights field") + } + if _, ok := result["stats"]; !ok { + t.Error("Missing stats field") + } +} + +func TestLocalizationHandler_getLinkWeight(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/weights/test-link-1 + req := httptest.NewRequest("GET", "/api/localization/weights/test-link-1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check fields + if result["link_id"] != "test-link-1" { + t.Errorf("Expected link_id test-link-1, got %v", result["link_id"]) + } + if _, ok := result["weight"]; !ok { + t.Error("Missing weight field") + } + if _, ok := result["sigma"]; !ok { + t.Error("Missing sigma field") + } +} + +func TestLocalizationHandler_resetWeights(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + // Set some weights first + weights := sil.GetWeightLearner().GetLearnedWeights() + weights.SetWeights("test-link", 1.5, 0.5) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test POST /api/localization/weights/reset + req := httptest.NewRequest("POST", "/api/localization/weights/reset", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if result["status"] != "weights_reset" { + t.Errorf("Expected status weights_reset, got %v", result["status"]) + } + + // Verify weights were reset + weight := sil.GetWeightLearner().GetLearnedWeights().GetLinkWeight("test-link") + if weight != 1.0 { + t.Errorf("Expected weight to be reset to 1.0, got %v", weight) + } +} + +func TestLocalizationHandler_getSpatialWeights(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/spatial-weights + req := httptest.NewRequest("GET", "/api/localization/spatial-weights", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check fields + if _, ok := result["spatial_weights"]; !ok { + t.Error("Missing spatial_weights field") + } + if _, ok := result["stats"]; !ok { + t.Error("Missing stats field") + } +} + +func TestLocalizationHandler_getSpatialWeightsForZone(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + // Set some weights for testing + swLearner.mu.Lock() + swLearner.setWeightLocked("link1", 0, 0, 1.5) + swLearner.setWeightLocked("link2", 0, 0, 0.8) + swLearner.mu.Unlock() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/spatial-weights/zone/0/0 + req := httptest.NewRequest("GET", "/api/localization/spatial-weights/zone/0/0", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check fields + if result["zone_x"] != 0 { + t.Errorf("Expected zone_x 0, got %v", result["zone_x"]) + } + if result["zone_y"] != 0 { + t.Errorf("Expected zone_y 0, got %v", result["zone_y"]) + } + if _, ok := result["weights"]; !ok { + t.Error("Missing weights field") + } + + // Verify weights + weights, ok := result["weights"].(map[string]interface{}) + if !ok { + t.Fatal("weights is not a map") + } + + // Check that our test weights are present + if link1Weight, ok := weights["link1"].(float64); !ok || link1Weight != 1.5 { + t.Errorf("Expected link1 weight 1.5, got %v", weights["link1"]) + } + if link2Weight, ok := weights["link2"].(float64); !ok || link2Weight != 0.8 { + t.Errorf("Expected link2 weight 0.8, got %v", weights["link2"]) + } +} + +func TestLocalizationHandler_getGroundTruthSamples(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + // Add some test samples + for i := 0; i < 5; i++ { + sample := localization.GroundTruthSample{ + Timestamp: time.Now().Add(-time.Duration(i) * time.Minute), + PersonID: "test-person", + BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0}, + BlobPosition: localization.Vec3{X: 1.0 + float64(i)*0.1, Y: 0.0, Z: 1.0}, + PositionError: float64(i) * 0.1, + PerLinkDeltas: map[string]float64{"link1": 0.5}, + PerLinkHealth: map[string]float64{"link1": 0.9}, + BLEConfidence: 0.8, + ZoneGridX: 0, + ZoneGridY: 0, + } + if err := gtStore.AddSample(sample); err != nil { + t.Fatalf("Failed to add sample: %v", err) + } + } + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/groundtruth/samples + req := httptest.NewRequest("GET", "/api/localization/groundtruth/samples?limit=10", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check fields + if _, ok := result["samples"]; !ok { + t.Error("Missing samples field") + } + if _, ok := result["count"]; !ok { + t.Error("Missing count field") + } + + // Verify we got samples + count, ok := result["count"].(int) + if !ok || count != 5 { + t.Errorf("Expected 5 samples, got %v", result["count"]) + } +} + +func TestLocalizationHandler_getGroundTruthStats(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + // Add test samples + sample := localization.GroundTruthSample{ + Timestamp: time.Now(), + PersonID: "test-person", + BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0}, + BlobPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0}, + PositionError: 0.1, + PerLinkDeltas: map[string]float64{"link1": 0.5}, + PerLinkHealth: map[string]float64{"link1": 0.9}, + BLEConfidence: 0.8, + ZoneGridX: 0, + ZoneGridY: 0, + } + if err := gtStore.AddSample(sample); err != nil { + t.Fatalf("Failed to add sample: %v", err) + } + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/groundtruth/stats + req := httptest.NewRequest("GET", "/api/localization/groundtruth/stats", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check required fields + requiredFields := []string{"total_samples", "today_samples", "by_person", "zone_counts"} + for _, field := range requiredFields { + if _, ok := result[field]; !ok { + t.Errorf("Missing required field: %s", field) + } + } + + // Verify total samples + total, ok := result["total_samples"].(int) + if !ok || total != 1 { + t.Errorf("Expected 1 total sample, got %v", result["total_samples"]) + } +} + +func TestLocalizationHandler_getAccuracyHistory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/accuracy/history + req := httptest.NewRequest("GET", "/api/localization/accuracy/history?weeks=4", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check fields + if _, ok := result["records"]; !ok { + t.Error("Missing records field") + } + if _, ok := result["weeks"]; !ok { + t.Error("Missing weeks field") + } +} + +func TestLocalizationHandler_getLearningProgress(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/learning/progress + req := httptest.NewRequest("GET", "/api/localization/learning/progress", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check required fields + requiredFields := []string{"progress", "stats"} + for _, field := range requiredFields { + if _, ok := result[field]; !ok { + t.Errorf("Missing required field: %s", field) + } + } +} + +func TestLocalizationHandler_getSelfImprovingStatus(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/self-improving/status + req := httptest.NewRequest("GET", "/api/localization/self-improving/status", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check required fields + requiredFields := []string{ + "learning_progress", "learned_weights", "improvement_stats", + "improvement_history", "ble_observations_count", + } + for _, field := range requiredFields { + if _, ok := result[field]; !ok { + t.Errorf("Missing required field: %s", field) + } + } +} + +func TestLocalizationHandler_processLearning(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test POST /api/localization/self-improving/process + req := httptest.NewRequest("POST", "/api/localization/self-improving/process", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check fields + if result["status"] != "learning_processed" { + t.Errorf("Expected status learning_processed, got %v", result["status"]) + } + if _, ok := result["timestamp"]; !ok { + t.Error("Missing timestamp field") + } +} + +func TestLocalizationHandler_getImprovementHistory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "localization_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gtStore, err := localization.NewGroundTruthStore( + filepath.Join(tmpDir, "groundtruth.db"), + localization.DefaultGroundTruthStoreConfig(), + ) + if err != nil { + t.Fatalf("Failed to create ground truth store: %v", err) + } + defer gtStore.Close() + + swLearner, err := localization.NewSpatialWeightLearner( + filepath.Join(tmpDir, "spatial_weights.db"), + localization.DefaultSpatialWeightLearnerConfig(), + ) + if err != nil { + t.Fatalf("Failed to create spatial weight learner: %v", err) + } + defer swLearner.Close() + + wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db")) + if err != nil { + t.Fatalf("Failed to create weight store: %v", err) + } + defer wStore.Close() + + config := localization.DefaultSelfImprovingConfig() + sil := localization.NewSelfImprovingLocalizer(config) + + handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil) + + r := chi.NewRouter() + handler.RegisterRoutes(r) + + // Test GET /api/localization/learning/history + req := httptest.NewRequest("GET", "/api/localization/learning/history", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var result map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check required fields + if _, ok := result["history"]; !ok { + t.Error("Missing history field") + } + if _, ok := result["stats"]; !ok { + t.Error("Missing stats field") + } +} diff --git a/mothership/internal/localization/fusion.go b/mothership/internal/localization/fusion.go index e97d79f..5f757c9 100644 --- a/mothership/internal/localization/fusion.go +++ b/mothership/internal/localization/fusion.go @@ -145,8 +145,17 @@ func (e *Engine) Fuse(links []LinkMotion) *FusionResult { sigmaMultiplier = learnedWeights.GetLinkSigma(linkID) } - // Use the sigma-aware version if we have learned sigma - if sigmaMultiplier != 0 { + // Use the sigma-aware version if we have learned sigma, and apply spatial weights if available + if e.spatialWeightLearner != nil { + // Create spatial weight function for this link + linkID := lm.NodeMAC + "-" + lm.PeerMAC + spatialWeightFunc := func(x, z float64) float64 { + return e.spatialWeightLearner.GetSpatialWeight(linkID, x, z) + } + + // Apply both sigma and spatial weights + e.grid.AddLinkInfluenceWithSpatialWeights(posA.X, posA.Z, posB.X, posB.Z, weight, sigmaMultiplier, spatialWeightFunc) + } else if sigmaMultiplier != 0 { e.grid.AddLinkInfluenceWithSigma(posA.X, posA.Z, posB.X, posB.Z, weight, sigmaMultiplier) } else { e.grid.AddLinkInfluence(posA.X, posA.Z, posB.X, posB.Z, weight) diff --git a/mothership/internal/localization/grid.go b/mothership/internal/localization/grid.go index f9e245b..40f50ef 100644 --- a/mothership/internal/localization/grid.go +++ b/mothership/internal/localization/grid.go @@ -118,6 +118,67 @@ func (g *Grid) AddLinkInfluenceWithSigma(ax, az, bx, bz, weight, sigmaMultiplier } } +// AddLinkInfluenceWithSpatialWeights paints Fresnel-zone influence with per-cell spatial weights. +// spatialWeightFunc is a function that takes (x, z) position and returns a weight multiplier for this link. +// This enables Fresnel zone weight refinement based on learned spatial patterns. +func (g *Grid) AddLinkInfluenceWithSpatialWeights(ax, az, bx, bz, weight, sigmaMultiplier float64, spatialWeightFunc func(x, z float64) float64) { + if weight <= 0 { + return + } + + ab := math.Sqrt((bx-ax)*(bx-ax) + (bz-az)*(bz-az)) + if ab < 0.1 { + return // degenerate link + } + + // σ is chosen so the first Fresnel zone (excess = λ/2 ≈ 0.062m at 2.4GHz) + // maps to ~1σ, giving comfortable spatial spread. In practice a wider + // sigma (0.5m) gives better localisation for indoor multipath. + baseSigma := math.Max(ab*0.25, 0.5) + + // Apply learned sigma multiplier + sigma := baseSigma + if sigmaMultiplier > 0 { + sigma = baseSigma * sigmaMultiplier + // Clamp to reasonable range + if sigma < 0.2 { + sigma = 0.2 + } + if sigma > 2.0 { + sigma = 2.0 + } + } + + twoSigSq := 2 * sigma * sigma + + g.mu.Lock() + defer g.mu.Unlock() + + for row := 0; row < g.rows; row++ { + pz := g.originZ + (float64(row)+0.5)*g.cellSize + for col := 0; col < g.cols; col++ { + px := g.originX + (float64(col)+0.5)*g.cellSize + + dAP := math.Sqrt((px-ax)*(px-ax) + (pz-az)*(pz-az)) + dPB := math.Sqrt((px-bx)*(px-bx) + (pz-bz)*(pz-bz)) + excess := dAP + dPB - ab + + if excess < 0 { + excess = 0 + } + + // Apply spatial weight for this cell position + cellWeight := weight + if spatialWeightFunc != nil { + cellWeight = weight * spatialWeightFunc(px, pz) + } + + influence := cellWeight * math.Exp(-(excess * excess) / twoSigSq) + g.cells[row*g.cols+col] += influence + } + } +} + // Normalize scales the grid so the maximum cell value is 1.0. // Returns false if the grid is all zero. func (g *Grid) Normalize() bool {