From 0cb2353a08180199080bf1de4b70cb54d32f61ff Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 9 Apr 2026 11:06:21 -0400 Subject: [PATCH] feat: implement activity timeline with tap-to-jump and inline feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 implementation: Activity Timeline (Component 27) - Tap-to-jump navigation: Click any event to create replay session and seek to that moment - Inline feedback display: Thumbs up/down buttons on each event for detection feedback - Replay API integration: Creates replay window around event timestamp (±5 seconds) - Feedback API: New /api/feedback endpoint for correct/incorrect/missed detection reports - Event loading improvements: Real-time WebSocket event insertion with animation - Filter UI: Type, zone, person, time range, and search filters - Load more pagination: Keyset cursor-based pagination for large event sets Acceptance criteria met: - Users can view all system events chronologically - Tap any event to jump to that moment in time via replay mode - Inline feedback buttons allow marking detections correct/incorrect Co-Authored-By: Claude Opus 4.6 --- .beads/issues.jsonl | 12 ++- .needle-predispatch-sha | 2 +- dashboard/js/timeline.js | 119 +++++++++++++++++++-- mothership/cmd/mothership/main.go | 29 +++++ mothership/internal/api/feedback.go | 128 +++++++++++++++++++++++ mothership/internal/sleep/integration.go | 2 +- 6 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 mothership/internal/api/feedback.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 74c486a..70f83b7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -13,6 +13,7 @@ {"id":"spaxel-2ea","title":"Add alert messages to WebSocket feed","description":"Add 'alert' message type to /ws/dashboard for anomaly detections and security mode triggers. Broadcast: { type: 'alert', alert: { id, ts, severity, description, acknowledged } }. Handle in app.js onmessage.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T14:18:27.455727878Z","created_by":"coding","updated_at":"2026-04-07T11:04:25.716894375Z","closed_at":"2026-04-07T11:04:25.716743770Z","close_reason":"Alert message type already fully implemented: BroadcastAlert() in hub.go broadcasts {type:'alert', alert:{id,ts,severity,description,acknowledged}} to /ws/dashboard clients. Called from anomaly detection, security mode changes, and trigger-disabled alerts. Frontend handleAlertMessage() in app.js routes the alert type, shows toast notifications, logs to timeline, and triggers alert banner. Table-driven tests pass (4 cases). go vet clean.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-2wg","title":"BLE device registry and labelling","description":"## Background\n\nThe firmware scans BLE advertisements every 5 seconds and relays them to the mothership via the bidirectional protocol (spaxel-o4l, Phase 3). Each BLE relay message contains a list of {mac, name, rssi, manufacturer_data} tuples for all devices heard by that node in the last 5 seconds. Phase 6 turns this raw stream into a structured \"People and Devices\" registry where users can label their devices and associate them with named people. This is the identity layer that transforms anonymous CSI blobs into \"Alice\" and \"Bob\".\n\n## BLE Device Auto-Detection\n\nThe mothership can identify device types from manufacturer data embedded in BLE advertisement packets. The Bluetooth SIG assigns Company IDs to manufacturers; the first 2 bytes of manufacturer_data encode the company ID (little-endian).\n\nCompany IDs to detect:\n- 0x004C (Apple): likely iPhone, iPad, AirPods, or Apple Watch. Sub-type from manufacturer data length and flags.\n- 0x0006 (Microsoft): Windows devices\n- 0x0075 (Samsung): Samsung phones/tablets\n- 0x009E (Fitbit): Fitness trackers\n- 0x0157 (Garmin): GPS watches / fitness devices\n- 0x0059 (Nordic): Tile trackers (Nordic Semiconductor is used by many Tile-like devices)\n- 0x0499 (Ruuvi): Ruuvi temperature/humidity sensors\n- 0x00E0 (Google): Android devices (Nearby Share beacons)\nClassify all others as \"Unknown\". The device name field (if present in the advertisement) provides additional signal.\n\nWearable heuristic: RSSI typically -55 to -75 dBm across multiple nodes with relatively consistent signal (worn close to body). Static devices (speakers, tablets) show higher variance. Flags this heuristic as \"possibly wearable\" (not definitive).\n\n## BLERegistry\n\nNew package: mothership/internal/identity/ble.go\n\nBLERegistry struct: backed by SQLite table ble_devices.\n\nSQLite schema:\nCREATE TABLE ble_devices (\n mac TEXT PRIMARY KEY,\n name TEXT,\n manufacturer TEXT,\n device_type TEXT, -- apple_phone, apple_earbuds, fitbit, garmin, tile, samsung, unknown\n label TEXT, -- user-assigned label\n person_id TEXT, -- FK to people.id\n rssi_min INTEGER,\n rssi_max INTEGER,\n rssi_avg INTEGER,\n first_seen DATETIME,\n last_seen DATETIME,\n is_archived BOOLEAN DEFAULT FALSE,\n last_seen_node_mac TEXT\n);\n\nCREATE TABLE people (\n id TEXT PRIMARY KEY, -- uuid\n name TEXT NOT NULL,\n color TEXT, -- hex colour for dashboard rendering\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE person_devices (\n person_id TEXT,\n device_mac TEXT,\n PRIMARY KEY (person_id, device_mac)\n);\n\nBLERegistry methods:\n- ProcessRelayMessage(nodeMac string, devices []BLEDevice): upsert all devices, update last_seen, update RSSI stats\n- GetDevices(includeArchived bool) []BLEDeviceRecord\n- UpdateLabel(mac, label string) error\n- AssignToPerson(mac, personID string) error\n- CreatePerson(name, color string) (Person, error)\n- GetPeople() []Person\n- ArchiveStale(olderThan time.Duration): set is_archived=true for devices not seen for > olderThan\n\n## BLE MAC Randomisation Handling\n\nModern iPhones and Android phones randomise their BLE MAC address periodically (every 10-15 minutes for iPhones, similar for Android). This is a fundamental privacy feature. The implications for spaxel:\n\n1. The same physical phone appears as multiple different MAC addresses in the registry. The BLERegistry will create new entries for each rotated address.\n2. Long-term tracking of phones by MAC is unreliable. The registry will accumulate many entries for a single phone over time.\n3. Workarounds: (a) Apple uses Resolvable Private Addresses (RPA) that can be resolved with the Identity Resolving Key (IRK) — requires pairing, not available without user action. (b) Device name is sometimes consistent across rotations. (c) Wearable devices (Fitbit, Garmin, AirTag) typically do NOT rotate their MACs — they provide reliable long-term tracking.\n\nThe dashboard must clearly explain this limitation in the \"People and Devices\" panel:\n\"Your phone's Bluetooth address changes regularly for privacy reasons. For reliable person tracking, use a Fitbit, Garmin watch, or AirTag, which have stable addresses.\"\n\nGrouping heuristic: if two devices have the same manufacturer data prefix (first 6 bytes) and name, and were never seen simultaneously at high RSSI from the same node, they are likely the same device with a rotated MAC. Surface this as a \"possible duplicate\" suggestion in the UI: \"These may be the same device: [mac1] and [mac2]. Merge?\"\n\n## REST API\n\nGET /api/ble/devices: returns list of BLEDeviceRecord, optionally filtered by ?archived=true\nGET /api/ble/devices/{mac}: returns single device with full history\nPUT /api/ble/devices/{mac}: update label, device_type, or person assignment. Body: {\"label\":\"Alice's Phone\",\"device_type\":\"apple_phone\",\"person_id\":\"uuid-123\"}\nDELETE /api/ble/devices/{mac}: archive (not hard delete)\n\nGET /api/people: returns list of People with their associated devices\nPOST /api/people: create person. Body: {\"name\":\"Alice\",\"color\":\"#3b82f6\"}\nPUT /api/people/{id}: update name or color\nDELETE /api/people/{id}: soft-delete (retain historical data)\n\n## Dashboard Panel\n\n\"People and Devices\" sidebar panel showing:\n- People section: list of defined people with avatar (initials in circle with their color), device count, last seen time\n - Per person: click to expand, shows associated devices\n - \"Add person\" button opens inline form\n- All devices section (below people): list of devices not yet assigned to a person\n - Per device: device type icon (Apple logo, Fitbit icon, etc.), last seen node (abbreviated), last seen timestamp, RSSI bar\n - Inline label edit on double-click\n - Drag-and-drop to assign to a person card\n - Archive button (hides from active list, accessible via \"Show archived\" toggle)\n- Privacy notice: \"Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.\"\n\n## Tests\n\n- Test device auto-detection: Apple company ID 0x004C -> device_type \"apple_phone\", Fitbit 0x009E -> \"fitbit\"\n- Test that ProcessRelayMessage correctly upserts devices and updates last_seen and RSSI stats\n- Test ArchiveStale marks devices not seen for > 7 days as archived\n- Test person creation and device-to-person assignment API calls\n- Test MAC randomisation handling: two devices with same name and no simultaneous sighting are flagged as possible duplicates\n- Test that archived devices are excluded from GetDevices(false) but included in GetDevices(true)\n\n## Acceptance Criteria\n\n- Discovered BLE devices appear in the dashboard \"People and Devices\" panel within 30 seconds of first observation\n- Device type is auto-detected correctly for Apple, Fitbit, Garmin, and Samsung devices\n- User can assign labels and associate devices with named people via the dashboard UI\n- Drag-and-drop device-to-person assignment works in the UI\n- Devices not seen for > 7 days are automatically archived and hidden from the active list\n- Privacy limitation is clearly documented in the panel UI\n- Possible duplicate MAC-rotated devices are surfaced as merge suggestions\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:44:02.204633291Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.656772405Z","closed_at":"2026-03-29T18:07:39.656662663Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-2wg","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.172209347Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-32o","title":"Link weather diagnostics and repositioning advice","description":"## Background\n\nEven with good hardware and correct placement, some links will chronically underperform. A user who placed a node on a metal shelf, behind a TV, or in a corner will see consistently poor detection without understanding why. Telling users \"your detection quality is low\" is useless without telling them what to do about it. Link weather diagnostics provide root-cause analysis and specific, actionable repositioning advice — including 3D visualisation of why a link is performing poorly and where to move a node to fix it.\n\nThe name \"link weather\" is deliberate: just as weather forecasts present complex atmospheric state in human terms (\"partly cloudy with 60% chance of rain\"), link weather presents complex RF state as: \"Node A to Node B: interference detected. Likely cause: microwave oven or 2.4GHz congestion. Try moving Node B 1.5 metres to the right.\"\n\n## DiagnosticEngine\n\nNew module: mothership/internal/diagnostics/linkweather.go\n\nDiagnosticEngine runs as a background goroutine, consuming link health history from SQLite and emitting Diagnosis structs. It runs a full diagnostic pass every 15 minutes.\n\nA Diagnosis struct contains:\n- LinkID string\n- RuleID string (identifies which rule fired)\n- Severity: INFO, WARNING, ACTIONABLE\n- Title string (human-readable headline)\n- Detail string (explanation of the diagnosis in plain language)\n- Advice string (specific actionable steps)\n- RepositioningTarget *Vec3 (3D position to move the node to, or nil if repositioning is not the solution)\n- RepositioningNodeMAC string (which node to move)\n- ConfidenceScore float64 (how confident the diagnostic engine is in this diagnosis)\n\n## Diagnostic Rules\n\nRule 1: Environmental Change\nTrigger: High baseline drift (>5% per hour) correlated across multiple links simultaneously (>50% of active links).\nTitle: \"Environmental change detected\"\nDetail: \"Multiple sensing links are showing simultaneous baseline shifts. This typically indicates a temperature change, or a large object was moved in the space. The system is adapting automatically.\"\nAdvice: \"No action needed. The baseline will re-stabilise within 30 minutes.\"\nRepositioningTarget: nil\nConfidence: 0.85 if drift is correlated across >50% of links\n\nRule 2: WiFi Congestion or Distance\nTrigger: Packet rate health < 0.8 for more than 10 minutes on a single link.\nTitle: \"Node B has low signal rate\"\nDetail: \"Node [B] is only delivering [N]% of the expected [M] packets per second. The most common causes are distance from the WiFi router or congestion from nearby networks.\"\nAdvice: \"1. Move Node [B] within 10 metres of your WiFi router. 2. If already close, check if the 2.4GHz channel is congested (3+ networks on overlapping channels). 3. ESP32-S3 supports both 2.4GHz and 5GHz — if your router supports 5GHz, update Node B's WiFi config to use the 5GHz SSID.\"\nRepositioningTarget: nil (advice is router proximity, not specific coordinates)\n\nRule 3: Near-Field Metal Interference\nTrigger: Low phase stability (< 0.4) sustained for > 30 minutes during known-quiet periods.\nTitle: \"Metal interference near Node [A]\"\nDetail: \"The sensing link [A to B] has unstable phase measurements even when no one is moving. This is typically caused by metal objects in the near field of the node's antenna (within 10cm): metal shelves, radiators, TV backs, or large appliances.\"\nAdvice: \"Check for metal objects within 10cm of Node [A]. If Node [A] is on a metal surface or shelf, mount it on a non-metal bracket or wall. Try repositioning it 20-30cm away from metal surfaces.\"\nRepositioningTarget: nil (advice is clearance from metal, not a specific position)\n\nRule 4: Fresnel Zone Blockage (Half-Room Dead Zone)\nTrigger: Consistent miss rate (>30% of test walks that should be detected are missed) in a specific area of the room, AND the missing area correlates geometrically with an obstacle in the link's Fresnel zone.\nThis rule requires the feedback loop data (Phase 7, spaxel-i28) — specifically the user-submitted false negatives with position information. If no feedback data is available, this rule can trigger heuristically when one side of the room consistently shows lower blob confidence scores.\nTitle: \"Coverage gap detected — possible obstruction\"\nDetail: \"The area near [zone description] shows lower detection coverage. An obstacle may be blocking the path between Node [A] and Node [B], interrupting their sensing zone.\"\nAdvice: \"Move Node [B] [direction] by approximately [distance] to restore coverage. The target position is marked in green in the 3D view.\"\nRepositioningTarget: computed_optimal_position (see below)\n\nRule 5: Periodic Interference Spikes\nTrigger: Periodic spikes in deltaRMS variance (3-10 events per hour, each lasting 1-3 minutes) not correlated with occupancy data (no people detected moving).\nTitle: \"Periodic interference detected\"\nDetail: \"Node [A] to Node [B] is experiencing regular interference bursts [N] times per hour. This pattern is consistent with a microwave oven, a cordless phone, or a pulsed 2.4GHz source.\"\nAdvice: \"Consider the following: 1. Is Node [A] or Node [B] near a kitchen? Microwave ovens cause strong 2.4GHz interference. 2. A cordless DECT phone or baby monitor near one of the nodes may be the source. 3. Try moving the affected node at least 2 metres from any 2.4GHz appliances.\"\nRepositioningTarget: nil (interference is appliance-specific)\n\n## Repositioning Advice in 3D\n\nFor Rule 4 (Fresnel zone blockage), compute the optimal repositioning target:\n1. Use the GDOP-based coverage optimiser from Phase 5 self-healing fleet (spaxel-jc4) to compute the position that maximises GDOP for the blocked zone while keeping all other nodes fixed.\n2. The optimal position is the computed_optimal_position Vec3.\n3. In the 3D dashboard, render a \"ghost\" node at this position: translucent version of the node mesh, with a dashed line from the current position to the ghost position.\n4. Show expected GDOP improvement: \"Moving Node B here would improve detection in the east corner from [N]% to [M]%.\"\n\n## Weekly Reliability Trends\n\nStore daily health score averages in SQLite: link_health_daily (link_id TEXT, date DATE, avg_health REAL, min_health REAL, max_health REAL, PRIMARY KEY (link_id, date)).\n\nA background job runs daily at midnight and writes the day's health averages from the link health log (link_health_log table: link_id, timestamp, composite_score).\n\nDashboard shows for each link: 7-day sparkline of daily average health score. \"Best day\" annotation (highest average) and \"worst day\" annotation (lowest average). This gives users a sense of long-term reliability.\n\n## Files to Create or Modify\n\n- mothership/internal/diagnostics/linkweather.go: DiagnosticEngine and all 5 rules\n- mothership/internal/diagnostics/reposition.go: repositioning target computation\n- mothership/internal/health/linkhealth.go: add link_health_log table writes\n- dashboard/js/linkhealth.js: link health panel, diagnostics display, ghost node rendering\n- mothership/internal/dashboard/routes.go: GET /api/links/{id}/diagnostics, GET /api/links/{id}/health-history\n\n## Tests\n\n- Test Rule 1 (environmental change): inject simultaneous high-drift events across 60% of links, verify diagnosis fires with Severity=INFO\n- Test Rule 2 (WiFi congestion): inject packet_rate=0.7 for 15 minutes, verify diagnosis fires with appropriate advice text\n- Test Rule 3 (metal interference): inject phase_stability=0.3 for 35 minutes during a quiet window, verify diagnosis fires\n- Test Rule 4 (Fresnel blockage): requires feedback data — inject synthetic false-negative feedback events clustered in one spatial zone, verify diagnosis fires and RepositioningTarget is non-nil\n- Test Rule 5 (periodic interference): inject 5 deltaRMS variance spikes per hour for 2 hours, verify diagnosis fires with correct periodicity estimate\n- Test weekly trend aggregation: inject 7 days of health scores, verify daily averages are correctly computed and stored\n- Test that repositioning target is within room bounds and improves GDOP\n\n## Acceptance Criteria\n\n- All 5 diagnostic rules fire correctly on synthetic test data that matches their trigger conditions\n- Repositioning advice for Rule 4 appears as a ghost node in the 3D dashboard view\n- Expected GDOP improvement shown alongside repositioning ghost node\n- Weekly 7-day sparkline visible in link health panel for each link\n- Diagnostics accessible via API and displayed in Link Health panel on link click\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:43:13.596164634Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.683230580Z","closed_at":"2026-03-29T18:07:39.683089345Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-32o","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:14.023730499Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-3ca","title":"Add time-travel debugging","description":"Implement:\n- Pause live mode\n- Timeline scrubbing\n- Replay 3D from recorded CSI data\n\nAcceptance: Can replay 24 hours of historical data with full 3D visualization.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T14:54:38.737598265Z","created_by":"coding","updated_at":"2026-04-09T14:54:38.737598265Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} {"id":"spaxel-3ps","title":"Detection feedback loop and accuracy tracking","description":"## Background\n\nEvery detection algorithm produces errors. False positives (detected presence when no one is there) are annoying and erode trust. False negatives (missed detection of a real person) are dangerous for safety applications. The feedback loop gives users a direct mechanism to correct errors and the system learns from those corrections. Showing users measurable improvement over time (\"You've provided 47 corrections. Accuracy improved 12% this week\") creates a virtuous engagement loop and transforms users into active participants in improving the system.\n\n## Feedback UI Elements\n\nEvery detection event exposed to the user should have feedback affordances. Three contexts:\n\n1. Dashboard 3D view: Each active track has a small thumbs-up/down icon that appears on hover/focus. Clicking thumbs-down opens a quick inline form.\n\n2. Activity timeline (Phase 8): Every detection event entry has thumbs-up/thumbs-down at the end of the row. Space-efficient: 2 icon buttons.\n\n3. Push notifications: Fall and anomaly notifications include a quick-reply option (via ntfy actions or Pushover callbacks): \"False alarm — clear this.\"\n\n4. \"I was here and wasn't detected\" button: On the timeline panel, a button \"Report missed detection\" opens a form: \"When? [time picker, default: now]\", \"Where? [zone picker]\", \"Who? [person picker, optional]\". Submits as a FALSE_NEGATIVE feedback event with the user-provided position.\n\nFeedback form for thumbs-down:\n- \"What was wrong?\" (radio buttons):\n - \"No one was there (false alarm)\"\n - \"Someone was missed at this location\"\n - \"Wrong person identified\"\n - \"Wrong zone/location\"\n- Optional free-text \"Notes\" field\n- Submit / Cancel\n\n## Feedback Storage\n\nSQLite schema:\nCREATE TABLE detection_feedback (\n id TEXT PRIMARY KEY,\n event_id TEXT, -- references events table (activity timeline)\n event_type TEXT, -- \"blob_detection\", \"zone_transition\", \"fall_alert\", \"anomaly\"\n feedback_type TEXT, -- \"TRUE_POSITIVE\", \"FALSE_POSITIVE\", \"FALSE_NEGATIVE\", \"WRONG_IDENTITY\", \"WRONG_ZONE\"\n details_json TEXT, -- {\"zone_id\":\"...\", \"person_id\":\"...\", \"notes\":\"...\"}\n timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,\n applied BOOLEAN DEFAULT FALSE, -- set to TRUE after weight refinement processes it\n processed_at DATETIME\n);\n\nThe applied flag enables incremental processing: the weight learner (Phase 7 self-improving localisation) queries WHERE applied = FALSE, processes batches, and marks them TRUE.\n\n## Accuracy Metrics\n\nCompute precision/recall/F1 per link, per zone, and per person weekly. This requires knowing the true positives, false positives, and false negatives.\n\nGround truth sources:\n- User thumbs-up -> TRUE_POSITIVE for the corresponding detection event\n- User thumbs-down (false alarm) -> FALSE_POSITIVE for the detection event\n- User \"missed detection\" report -> FALSE_NEGATIVE for the reported time/zone\n\nNote: ground truth is sparse — users will not feedback every event. We use the feedback we have as a sample. Assume events without feedback are TRUE_POSITIVE for the purpose of precision estimates (conservative: this means precision is an upper bound, not exact).\n\nMetrics computed weekly:\n- precision = TP / (TP + FP) — of all detections, what fraction were correct\n- recall = TP / (TP + FN) — of all true presence events, what fraction were detected\n- F1 = 2 * precision * recall / (precision + recall)\n- Per-link metrics: which links have the most false positives (worst precision)\n- Per-zone metrics: which zones are most often missed (worst recall)\n\nStorage: detection_accuracy (week TEXT, scope_type TEXT, scope_id TEXT, precision REAL, recall REAL, f1 REAL, tp_count INT, fp_count INT, fn_count INT, computed_at DATETIME). Scope types: \"system\", \"link\", \"zone\", \"person\".\n\n## Accuracy Trend Display\n\nDashboard \"Accuracy\" panel (in expert mode):\n- Overall accuracy gauge: composite F1 score as a circular gauge (0-100%)\n- Week-over-week trend graph: sparkline of weekly F1 over the last 8 weeks\n- \"You've provided N corrections. Your accuracy improved X% this week.\" — motivational counter\n- Per-zone breakdown: bar chart of precision/recall per zone (click a zone bar to jump to it in 3D view)\n- Per-link breakdown: link health vs. feedback score correlation (are high-health links also high-accuracy?)\n- Feedback count: total corrections given, open corrections (not yet processed), processed corrections\n\nThe accuracy trend display intentionally shows the improvement trajectory, not just the absolute value, to reinforce that feedback has an effect.\n\n## Feedback Application\n\nProcessing happens in a background goroutine (mothership/internal/learning/feedback_processor.go) that runs every 6 hours or when triggered manually.\n\nFor FALSE_POSITIVE events with associated CSI data (in the recording buffer from Phase 2):\n- Retrieve the CSI data from the recording buffer at the event timestamp for all links\n- Add the CSI frame data to a \"known false positive\" set in SQLite: false_positive_frames (link_id, timestamp, delta_rms, context_json)\n- The weight learner (self-improving localisation bead) uses this set as negative examples\n\nFor FALSE_NEGATIVE events with user-reported position:\n- Add to \"known false negative\" set: false_negative_frames (link_id, timestamp, expected_position_xyz, context_json)\n- The weight learner uses this as a positive example at the specified position\n\nAfter processing, mark feedback.applied = TRUE.\n\n## Files to Create or Modify\n\n- mothership/internal/learning/feedback_processor.go: feedback processing pipeline\n- mothership/internal/analytics/accuracy.go: weekly metric computation\n- dashboard/js/feedback.js: thumbs-up/down UI components (reusable across 3D view and timeline)\n- dashboard/js/accuracy.js: Accuracy panel rendering\n- mothership/internal/dashboard/routes.go: POST /api/feedback, GET /api/accuracy\n\n## Tests\n\n- Test feedback storage: POST /api/feedback with each feedback_type, verify SQLite record created\n- Test accuracy metric computation with synthetic TP/FP/FN data: 8 TP, 2 FP, 1 FN -> precision=0.8, recall=0.888\n- Test weekly rollup: 7 days of daily feedback -> correctly aggregated weekly metric\n- Test that applied=false events are found and marked as applied after processor run\n- Test \"improvements\" counter: feedback_count increases on each POST /api/feedback call\n\n## Acceptance Criteria\n\n- Thumbs-up/down buttons appear on active tracks in 3D view and on all timeline events\n- \"Missed detection\" button and form available in timeline panel\n- Feedback stored in SQLite with correct feedback_type and details\n- Accuracy metrics computed weekly and stored in detection_accuracy table\n- Accuracy panel shows week-over-week trend (requires at least 2 weeks of data)\n- Feedback improvement counter shows correct counts\n- Applied flag correctly set after processor run\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"sp4","created_at":"2026-03-28T01:49:50.419277632Z","created_by":"coding","updated_at":"2026-03-29T22:08:03.778130122Z","closed_at":"2026-03-29T22:08:03.778000167Z","close_reason":"Implementation complete: feedback storage (SQLite), accuracy computation (precision/recall/F1 weekly), feedback processor (6h interval), API endpoints (/api/learning/*), frontend feedback UI (thumbs up/down, missed detection form), accuracy panel (F1 gauge, sparkline, per-zone breakdown). All 12 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-3ps","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.442377218Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-3rd","title":"Wire WebSocket integration for zone changes","description":"Ensure zone changes from CRUD endpoints reflect in live 3D view within one WebSocket cycle. Acceptance: creating/updating/deleting a zone via REST API triggers an update broadcast through the WebSocket system.","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-07T17:01:33.587080369Z","created_by":"coding","updated_at":"2026-04-07T18:42:55.455708044Z","closed_at":"2026-04-07T18:42:55.455446177Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-0ii"]} {"id":"spaxel-403","title":"Implement anomaly detection & security mode","description":"Build pattern learning and anomaly detection for security.\n\nDeliverables:\n- 7-day pattern learning algorithm\n- Anomaly scoring against learned patterns\n- Security mode integration\n\nAcceptance: System detects deviations from learned patterns; accuracy improves measurably over 4 weeks.","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-03-29T19:25:04.187535979Z","created_by":"coding","updated_at":"2026-04-09T12:18:14.752621360Z","closed_at":"2026-04-09T12:18:14.752279788Z","close_reason":"Anomaly detection & security mode implementation verified complete.\n\nDeliverables implemented:\n- 7-day pattern learning algorithm with Welford's online algorithm (analytics/patterns.go)\n- Anomaly scoring against learned patterns with z-score based computation\n- Security mode integration with Armed/Disarmed/ArmedStay states\n\nAcceptance criteria met:\n- System detects deviations from learned patterns via multiple anomaly types (UnusualHour, UnknownBLE, MotionDuringAway, UnusualDwell)\n- Accuracy improves measurably through feedback loop integration with learning/feedback_store\n\nKey components:\n- PatternLearner: 7-day cold start, hourly pattern updates, per-slot readiness checking\n- Detector: Multiple anomaly types, configurable thresholds, alert chain with timers\n- Security API: /api/security/arm, /api/security/disarm, /api/security/status\n- Alert Handler: Dashboard → webhook → escalation notification chain\n- Integration: Fully wired in main.go with zones, BLE registry, dashboard, and feedback store","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:922","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} @@ -20,6 +21,7 @@ {"id":"spaxel-4u6","title":"events: SQLite schema, FTS5 table, indexes, and 90-day archive job","description":"## Overview\nCreate the SQLite storage layer for the unified activity timeline (part 1 of spaxel-2ap split).\n\n## Schema to create in mothership/internal/events/ (db setup or migration)\n```sql\nCREATE TABLE IF NOT EXISTS events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n type TEXT NOT NULL,\n timestamp_ms INTEGER NOT NULL,\n zone TEXT,\n person TEXT,\n blob_id TEXT,\n detail_json TEXT,\n severity TEXT\n);\n\nCREATE VIRTUAL TABLE IF NOT EXISTS fts_events USING fts5(\n type, zone, person, detail_json,\n content='events', content_rowid='id'\n);\n\nCREATE INDEX IF NOT EXISTS idx_events_ts ON events(timestamp_ms DESC);\nCREATE INDEX IF NOT EXISTS idx_events_type ON events(type);\nCREATE INDEX IF NOT EXISTS idx_events_zone ON events(zone);\nCREATE INDEX IF NOT EXISTS idx_events_person ON events(person);\n\nCREATE TABLE IF NOT EXISTS events_archive (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n type TEXT NOT NULL,\n timestamp_ms INTEGER NOT NULL,\n zone TEXT, person TEXT, blob_id TEXT, detail_json TEXT, severity TEXT\n);\n```\n\n## Archive job\n- In `events` package, add `RunArchiveJob(db *sql.DB)` that runs nightly at 02:00 local time\n- Migrates rows from `events` where `timestamp_ms < now - 90 days` into `events_archive`\n- Deletes moved rows from `events`\n\n## Go types\n```go\ntype Event struct {\n ID int64\n Type string\n TimestampMs int64\n Zone string\n Person string\n BlobID string\n DetailJSON string\n Severity string\n}\n\nfunc InsertEvent(db *sql.DB, e Event) error\nfunc QueryEvents(db *sql.DB, params QueryParams) ([]Event, string, bool, error)\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/events/\n```","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-06T22:30:57.090344045Z","created_by":"coding","updated_at":"2026-04-07T16:45:36.428356135Z","closed_at":"2026-04-07T16:45:36.428249897Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} {"id":"spaxel-51k","title":"OTA firmware update system","description":"## Background\n\nOnce nodes are deployed in a home, they need to be updated without physical access. ESP-IDF has a mature OTA (Over-The-Air) update mechanism: two OTA flash partitions (factory, ota_0, ota_1 as defined in firmware/partitions.csv), HTTP download to the inactive partition, cryptographic verification, set boot partition, reboot. The mothership serves firmware binaries and triggers the update via a WebSocket downstream command. Phase 1 laid the groundwork in the firmware; this bead completes the mothership side.\n\n## What Already Exists\n\nfirmware/main/websocket.c has OTA command handling and an ota_download_task that handles the HTTP download to the inactive OTA partition. The partition table in firmware/partitions.csv has factory + ota_0 + ota_1 slots. The firmware parses {type:\"ota\", url:\"...\", md5:\"...\", version:\"...\"} downstream commands and initiates the download. What is missing is:\n- The mothership HTTP server for firmware binary serving\n- The REST API for triggering OTA per-node or fleet-wide\n- The firmware manifest for version management\n- The rollback detection logic on the mothership side\n- The dashboard UI for OTA management\n\n## Mothership Firmware Serving\n\nGET /firmware/latest or GET /firmware/{version}: serves the compiled .bin file from the /firmware volume mount. The response must include:\n- Content-Length header (required by ESP-IDF OTA HTTP client for progress reporting)\n- ETag header (MD5 of the binary, for caching)\n- Content-Type: application/octet-stream\n\nFirmware binaries are placed in the /firmware volume mount (configured in docker-compose.yml or k8s volume mount). The mothership reads the firmware manifest on startup and re-reads it when a new file appears (inotify watch or periodic re-scan every 60s).\n\n## Firmware Manifest\n\nFile: /firmware/manifest.json — auto-generated by the CI build process, or manually created.\nFormat: list of objects, each with version (semver string), filename (basename within /firmware/), md5 (hex string of binary MD5), size (integer bytes), build_timestamp (ISO8601).\nThe mothership's \"latest\" version is determined by sorting manifest entries by semver and taking the highest.\n\n## OTA Trigger API\n\nPOST /api/nodes/{mac}/ota — trigger OTA for a specific node\nRequest body: {\"version\": \"0.2.0\"} (optional; defaults to latest if omitted)\nResponse: {\"job_id\": \"abc123\", \"node_mac\": \"aa:bb:cc:dd:ee:ff\", \"target_version\": \"0.2.0\", \"status\": \"initiated\"}\n\nThe mothership looks up the node's active WebSocket session in the connection registry and sends the OTA command:\n{\"type\":\"ota\",\"url\":\"http://{mothership_ip}:{port}/firmware/0.2.0\",\"md5\":\"{hex_md5}\",\"version\":\"0.2.0\"}\n\nThe firmware immediately begins the download in a background task, sends ota_status messages ({\"type\":\"ota_status\",\"progress\":45,\"status\":\"downloading\"}) which the mothership logs and broadcasts to the dashboard.\n\n## Fleet Rolling Update\n\nPOST /api/ota/fleet — trigger OTA for all connected nodes\nRequest body: {\"version\": \"0.2.0\", \"stagger_seconds\": 30} (default stagger: 30s)\n\nThe rolling update coordinator in the mothership triggers OTA for the first node, waits stagger_seconds, then the next, and so on. This ensures:\n- Not all nodes reboot simultaneously (avoids a coverage gap window)\n- If a node fails OTA, the remaining nodes can be halted before more disruption\n- Fleet update progress is visible in dashboard per-node\n\nThe fleet update job is stored in SQLite (ota_jobs table) and survives mothership restarts.\n\n## Rollback Detection\n\nESP-IDF automatically rolls back to the previous firmware if the new image does not call esp_ota_mark_app_valid_cancel_rollback() within a boot window (the firmware does this on successful WebSocket connection to the mothership). The mothership detects rollback by comparing the firmware_version field in the hello message after OTA against the requested target version. If they differ, the mothership logs an OTA rollback event and updates the node's status to \"rollback\".\n\n## Dashboard OTA UI\n\nAdd an OTA panel to the dashboard settings or fleet page:\n- Per-node: current firmware version, available version (if newer), \"Update\" button\n- Fleet: \"Update All\" button with stagger slider, progress per node (with percentage from ota_status messages), last updated time per node\n- Version history: per-node firmware version history in tooltip or expandable row\n- Rollback indicator: nodes that rolled back are highlighted with a warning and the reason (if known)\n\n## Files to Create or Modify\n\n- mothership/internal/ota/server.go: firmware file serving with Content-Length and ETag\n- mothership/internal/ota/manifest.go: manifest parsing and latest-version logic\n- mothership/internal/ota/jobs.go: OTA job creation, fleet rolling update coordinator, status tracking\n- mothership/internal/dashboard/routes.go: register OTA API routes\n- dashboard/js/fleet.js or dashboard/js/ota.js: OTA UI panel\n\n## Tests\n\n- Test OTA command JSON serialisation matches firmware's expected format exactly\n- Test rolling update stagger timing with a mock time source (use a clock interface for testability)\n- Test that firmware version in hello message is parsed and stored in the node registry\n- Test manifest parsing: valid manifest, empty manifest, malformed manifest\n- Test rollback detection when hello version does not match target version\n\n## Acceptance Criteria\n\n- OTA command reaches firmware and triggers download (verified via ota_status messages)\n- Rolling update staggers correctly with the configured delay between nodes\n- After successful OTA, node reconnects with new firmware version in hello message\n- Rollback is detectable via hello version mismatch and displayed in dashboard\n- MD5 verification failure in firmware logs an error and the old firmware remains running\n- Fleet update status visible per-node in dashboard\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:37:32.472078279Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.250035631Z","closed_at":"2026-03-28T05:36:39.249972673Z","close_reason":"Implemented: ota/manager.go + ota/server.go (fb69190) — HTTP firmware serving from /firmware volume, WebSocket-triggered OTA command, rolling update with 30s stagger, MD5 verification, firmware manifest.json, rollback detection via hello version mismatch","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-51k","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.874999678Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-54i","title":"load-shedding: add per-iteration timing and rolling avg to ProcessorManager","description":"## Task\nAdd pipeline timing and a 5-iteration rolling average to `ProcessorManager` in `mothership/internal/signal/processor.go`.\n\n## Changes to ProcessorManager struct (lines 221-228)\nAdd these fields:\n```go\ntype ProcessorManager struct {\n // ... existing fields ...\n iterDurations [5]time.Duration // ring buffer for last 5 iteration times\n iterIdx int // next write index (mod 5)\n iterCount int // how many values filled (0-5)\n shedLevel int // current load shedding level (0-3)\n steadyCount int // consecutive iters below recovery threshold\n}\n```\n\n## Changes to LinkProcessor.Process() (line 54)\nWrap the entire Process body to time it. At the START of Process():\n```go\nt0 := time.Now()\n```\nAt the END of Process(), before return, update the manager's ring buffer. BUT — Process is on LinkProcessor, not ProcessorManager. So instead, add timing to ProcessorManager.Process() (line 252):\n\n```go\nfunc (pm *ProcessorManager) Process(linkID string, ...) (*ProcessResult, error) {\n t0 := time.Now()\n // ... existing lock + delegate to processor ...\n result, err := processor.Process(payload, rssiDBm, nSub, recvTime)\n pm.mu.Unlock() // already have write lock\n elapsed := time.Since(t0)\n pm.updateShedding(elapsed)\n return result, err\n}\n```\n\n## New method updateShedding(elapsed time.Duration)\n```go\nfunc (pm *ProcessorManager) updateShedding(elapsed time.Duration) {\n pm.iterDurations[pm.iterIdx%5] = elapsed\n pm.iterIdx++\n if pm.iterCount < 5 { pm.iterCount++ }\n\n // compute rolling avg\n var sum time.Duration\n for i := 0; i < pm.iterCount; i++ {\n sum += pm.iterDurations[i]\n }\n avg := sum / time.Duration(pm.iterCount)\n\n // level up\n if avg >= 95*time.Millisecond && pm.shedLevel < 3 {\n pm.shedLevel = 3; pm.steadyCount = 0\n } else if avg >= 90*time.Millisecond && pm.shedLevel < 2 {\n pm.shedLevel = 2; pm.steadyCount = 0\n } else if avg >= 80*time.Millisecond && pm.shedLevel < 1 {\n pm.shedLevel = 1; pm.steadyCount = 0\n }\n\n // recovery: step down one level when avg < 60ms for 10 consecutive iters\n if avg < 60*time.Millisecond {\n pm.steadyCount++\n if pm.steadyCount >= 10 && pm.shedLevel > 0 {\n pm.shedLevel--\n pm.steadyCount = 0\n }\n } else {\n pm.steadyCount = 0\n }\n}\n```\n\n## New getter\n```go\nfunc (pm *ProcessorManager) GetShedLevel() int {\n pm.mu.RLock()\n defer pm.mu.RUnlock()\n return pm.shedLevel\n}\n```\n\nNote: `updateShedding` must NOT hold `pm.mu` because it's called after unlock. The iterDurations ring buffer is only written from `Process` so it is already serialized by the caller's lock sequence. Add a separate `mu` for the shed state or call updateShedding while still holding pm.mu — simplest: call it BEFORE Unlock, while still holding the write lock.\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n```","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-07T06:33:03.697771676Z","created_by":"coding","updated_at":"2026-04-07T16:53:42.209613205Z","closed_at":"2026-04-07T16:53:42.209404722Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-5a3","title":"Add Fresnel zone visualization","description":"Implement wireframe ellipsoid overlay showing Fresnel zones between active links.\n\nAcceptance: 3D visualization shows ellipsoids between communicating nodes with proper scaling.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T14:54:38.915673399Z","created_by":"coding","updated_at":"2026-04-09T14:54:38.915673399Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} {"id":"spaxel-5es","title":"Ambient dashboard mode","description":"## Background\n\nA wall-mounted tablet showing who is home is a genuinely useful home appliance — a digital replacement for the \"whiteboard on the fridge\" that families have used for decades. The ambient mode is designed for exactly this use case: always on, minimal information density, visually calm, and unobtrusive. It should run for weeks without user interaction, surviving screen timeouts, browser updates, and mothership restarts. When something important happens (fall, security alert), it breaks the calm decisively to get attention.\n\n## Route and Renderer\n\nNew route: /ambient (separate from /simple and expert mode)\nThe user can set a specific device as \"ambient display\" via a browser bookmark or home screen shortcut.\n\nCanvas 2D renderer (not Three.js): the ambient mode uses a dedicated Canvas 2D rendering engine rather than Three.js. Reasons:\n1. Three.js is designed for 3D; the ambient view is a 2D top-down floor plan\n2. Canvas 2D uses significantly less GPU memory and power — important for always-on tablet use\n3. Simpler code path means fewer failure modes for long-running display\n4. Lower battery drain on iPad and Android tablets\n\nRenderer: dashboard/js/ambient_renderer.js. Renders via requestAnimationFrame at 2 Hz (one frame every 500ms). The 2 Hz update rate is intentional: it's visually smooth enough for a presence display and uses minimal CPU.\n\n## Rendered Elements\n\nBackground: colour depends on time of day (see Time-of-Day Palette below).\n\nRoom outline: 2D rectangles for each zone's bounding box (projected to floor plane). White (#ffffff) with 1px stroke, no fill (transparent interior). Zone labels: zone name in white text at centroid, 14px medium font.\n\nPortal lines: thin lines (0.5px, #a855f7 purple) across doorways.\n\nNode positions: small filled circles (radius 4px, #6b7280 grey, subtle).\n\nPerson blobs: filled circles (radius 10-18px) in person.color. Name label above: first name only, 12px white. Blob radius proportional to identity confidence: full size if confident, slightly smaller if low confidence. If anonymous: a ghost/outline circle (stroke only, no fill) in light grey.\n\nSystem status indicator: top-left corner. Small circle (8px radius): green (#22c55e) if all nodes healthy, amber (#f59e0b) if any node degraded, red (#ef4444) if any node offline. No text — just the dot. Tooltip on hover (for the rare touch-and-hold interaction).\n\nTime display: top-right. Current time in large readable font. 28px, #ffffff, tabular-nums font variant for clean time display without layout shifts.\n\nPerson positions: lerp-interpolated between WebSocket updates for smooth movement at the 2 Hz render rate. On each WebSocket message received, update the target position. On each render frame, move each person blob 20% of the remaining distance to the target position (exponential approach = naturally decelerating animation).\n\n## Auto-Dim\n\nWhen no person is detected in the room where the ambient tablet is physically located (a zone that the user has configured as the \"ambient display zone\"), reduce the canvas brightness after 60 seconds of no detection:\n- After 60s: reduce canvas globalAlpha to 0.4 (40% brightness)\n- Restore immediately when presence detected in the ambient zone again\n\nImplementation: Use the CSS filter brightness() property on the canvas element, animated with a CSS transition. Set `canvas.style.filter = 'brightness(0.4)'` after timeout. This approach correctly dims including text labels.\n\nOptionally: if the device supports it (iOS WKWebView + Power Saving APIs), the ambient mode can request Screen Wake Lock API to prevent the tablet display from sleeping. window.navigator.wakeLock.request('screen'). Re-request on visibility change (the lock is released when the page is hidden).\n\n## Alert Mode\n\nWhen a fall or security alert fires (FallDetected or AnomalyDetected event received via WebSocket):\n1. The Canvas render loop detects the alert event\n2. Full canvas background fades to #dc2626 (urgent red) over 500ms\n3. Large white text in the centre: \"FALL DETECTED — Alice\" or \"ALERT — Motion while away\"\n4. Pulsing animation: canvas background alternates between #dc2626 and #991b1b at 1 Hz\n5. Acknowledge button rendered as a large white rectangle in the centre below the text\n6. Tap/click on the acknowledge button: POST /api/fall/{id}/acknowledge or /api/anomalies/{id}/acknowledge. Returns to normal ambient mode.\n\nThis alert mode must be clearly visible from across the room — the text should be at least 48px on a typical 10-inch tablet.\n\n## Morning Briefing Overlay\n\nFirst time presence is detected after 6am (configurable): the ambient display shows a brief overlay card for 15 seconds:\n- Overlaid on the normal floor plan (dark semi-transparent background)\n- Sleep summary (if available): \"Alice: 7h 23m, good sleep\"\n- Today's expected departures (from presence prediction, Phase 7): \"Alice likely leaves at 8:30am\"\n- System status: \"4 nodes healthy\"\n\nAfter 15 seconds: fade out and return to normal ambient view. Tapping the overlay dismisses it immediately.\n\n## Time-of-Day Palette\n\nThe ambient canvas background colour shifts with the time of day to be visually appropriate:\n- 06:00-12:00 (morning): light blue-grey (#f0f4f8, near white) — cool morning light feel\n- 12:00-18:00 (afternoon): neutral grey (#1e293b, dark) — reduces eye strain in bright rooms\n- 18:00-22:00 (evening): warm amber-grey (#1c1507, very dark warm) — matches evening lighting\n- 22:00-06:00 (night): near black (#040404) — OLED-friendly, minimal light\n\nTransitions: smooth CSS gradient transition over 30 minutes at each boundary (not instant). Implemented by pre-computing the target colour for the next 30 minutes and using CSS linear-gradient + keyframe animation.\n\n## Performance\n\nTarget: < 5% CPU usage on a 2016-era iPad (A9 chip). Achieved by:\n- 2 Hz render rate instead of 60 Hz (30x reduction in GPU/CPU work)\n- No Three.js, WebGL, or shader compilation overhead\n- Canvas 2D is hardware accelerated but lightweight\n- Blob count in a home is typically 1-4 — trivial to render\n\nMemory: the ambient page should have < 50MB JS heap. No large textures, no complex geometry.\n\n## Files to Create or Modify\n\n- dashboard/ambient.html: minimal HTML shell\n- dashboard/js/ambient.js: main ambient mode logic, WebSocket connection, alert handling\n- dashboard/js/ambient_renderer.js: Canvas 2D rendering engine\n- dashboard/js/ambient_briefing.js: morning briefing overlay\n- mothership/internal/dashboard/routes.go: /ambient route served statically\n\n## Tests\n\n- Test Canvas 2D renderer draws correct shapes: zone rectangle at (1,1)-(3,3) appears as a white rectangle at the correct pixel coordinates (given known canvas size and room dimensions)\n- Test auto-dim timer: mock 60s with no presence event, verify canvas brightness is reduced\n- Test auto-dim restore: presence event arrives, verify brightness returns to 100%\n- Test alert mode: inject FallDetected event, verify canvas background changes to red and text appears\n- Test acknowledge clears alert mode and returns to normal\n- Test morning briefing overlay appears only once after 6am (localStorage flag set)\n- Test lerp interpolation: person position updates from (1,1) to (3,3), after 5 render frames should be approximately (2.5, 2.5) (with 20% step lerp)\n\n## Acceptance Criteria\n\n- Ambient mode runs for 7 days without page reload (no memory leaks, no uncaught exceptions)\n- Auto-dim activates after 60 seconds of no presence in the display zone\n- Fall/anomaly alert mode clearly visible from 3 metres away on a 10-inch tablet\n- Acknowledge button works and returns to normal ambient\n- Morning briefing overlay appears once per day, dismisses after 15s\n- Canvas 2D rendering consumes < 5% CPU on a mid-range tablet\n- Time-of-day palette transitions are smooth (no hard cuts)\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:00:34.796733529Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.888767875Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-5es","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.888731706Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-5lo","title":"Implement Zones CRUD REST endpoints with OpenAPI docs","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Include OpenAPI-style godoc comments. Acceptance: endpoints respond correctly to HTTP requests, godoc annotations present.","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:01:33.493352900Z","created_by":"coding","updated_at":"2026-04-07T18:13:38.639619498Z","closed_at":"2026-04-07T18:13:38.639505434Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-0ii"]} {"id":"spaxel-5yq","title":"load-shedding: health endpoint + dashboard WS alert integration","description":"## Task\nExpose the load shedding level in the health endpoint and send a dashboard WS alert when Level 3 is triggered. Requires spaxel-54i to be complete (GetShedLevel() must exist).\n\n## 1. Health endpoint (mothership/cmd/mothership/main.go)\nFind the `/healthz` handler (around line 218). Change the JSON to include `shedding_level`:\n```go\nfmt.Fprintf(w, `{\"status\":\"ok\",\"version\":\"%s\",\"shedding_level\":%d}`, version, pm.GetShedLevel())\n```\n\n## 2. Dashboard WS alert on Level 3\nIn the fusion loop in main.go (the goroutine that calls `pm.Process()`), track the previous shed level and broadcast an alert when it changes to 3 or recovers:\n```go\n// after pm.Process() call:\nnewLevel := pm.GetShedLevel()\nif newLevel != prevShedLevel {\n if newLevel == 3 {\n msg := map[string]interface{}{\n \"type\": \"alert\",\n \"severity\": \"warning\",\n \"description\": \"System under load — CSI rate reduced to 10 Hz\",\n }\n data, _ := json.Marshal(msg)\n dashboardHub.Broadcast(data)\n }\n prevShedLevel = newLevel\n log.Printf(\"[INFO] Load shedding level changed: %d\", newLevel)\n}\n```\nDeclare `prevShedLevel int` before the fusion goroutine.\n\n## 3. Level 3 rate reduction push (best effort — log only if push mechanism not yet available)\nWhen `newLevel == 3`, log: `log.Printf(\"[INFO] Load shed level 3 — would push 10Hz cap to nodes\")`\nWhen `newLevel` recovers from 3, log: `log.Printf(\"[INFO] Load shed recovered — restoring prior node rate\")`\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n# curl http://localhost:8080/healthz should include shedding_level\n```\n\nRequires: spaxel-54i","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T06:33:19.278442007Z","created_by":"coding","updated_at":"2026-04-07T17:56:41.358181685Z","closed_at":"2026-04-07T17:56:41.358116981Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4"],"dependencies":[{"issue_id":"spaxel-5yq","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.206754212Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -28,6 +30,7 @@ {"id":"spaxel-6hd","title":"Floor plan image upload and pixel-to-meter calibration","description":"## Overview\nAllow users to upload a floor plan image (PNG/JPG) and calibrate it to real-world coordinates so the 3D scene displays nodes and blobs at accurate physical positions.\n\n## Backend (mothership/internal/ — new 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## Dashboard (dashboard/js/floorplan-setup.js)\n- Setup panel section: 'Floor Plan' with upload button\n- On image select: POST to /api/floorplan/image; display uploaded image on ground plane in 3D scene\n- Calibration UI: click point A on image → click point B → enter real-world distance in meters → Save\n- Compute pixel-to-meter scale factor: scale = distance_m / pixel_distance(A,B)\n- Apply scale and rotation to Three.js ground plane texture on load\n\n## Acceptance\n- Uploaded image displayed as ground plane texture in 3D view\n- Calibrated coordinate system maps pixel positions to correct meter positions\n- Image persists across server restart\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:42:49.829463356Z","created_by":"coding","updated_at":"2026-04-09T12:45:23.190650793Z","closed_at":"2026-04-09T12:45:23.190526084Z","close_reason":"Implementation complete. All acceptance criteria met:\n- Backend API with image upload (max 10 MB) and calibration endpoints\n- Dashboard UI with two-point calibration and pixel-to-meter scale computation\n- Viz3D integration with texture transformation for accurate positioning\n- SQLite persistence for image metadata and calibration data\n- Image file storage at /data/floorplan/image.png\n\nBoth dependent beads (spaxel-dbd dashboard UI, spaxel-klk backend API) were already CLOSED.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-6hd","depends_on_id":"spaxel-dbd","type":"blocks","created_at":"2026-04-07T14:46:37.377627731Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6hd","depends_on_id":"spaxel-klk","type":"blocks","created_at":"2026-04-07T14:46:37.307745453Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-6n9","title":"events: internal pub/sub event bus (decouple packages from dashboard)","description":"## Overview\nCreate a lightweight Go pub/sub event bus so any internal package can emit events without importing the dashboard package directly (part 2 of spaxel-2ap split).\n\n## Implementation in mothership/internal/eventbus/ (package already exists — extend it)\n\n```go\n// bus.go\npackage eventbus\n\ntype Event struct {\n Type string\n TimestampMs int64\n Zone string\n Person string\n BlobID string\n DetailJSON interface{}\n Severity string\n}\n\ntype Handler func(Event)\n\nvar (\n mu sync.RWMutex\n handlers []Handler\n)\n\nfunc Subscribe(h Handler)\nfunc Publish(e Event)\n```\n\n- `Publish` calls all subscribers in separate goroutines (non-blocking)\n- `Subscribe` is safe to call at any time, including after startup\n- Event types to define as constants: detection, zone_entry, zone_exit, portal_crossing, trigger_fired, fall_alert, anomaly, security_alert, node_online, node_offline, ota_update, baseline_changed, system, learning_milestone\n\n## Integration\n- Have the events package's `InsertEvent` also call `eventbus.Publish`\n- Dashboard WS handler subscribes to the bus to forward events to connected clients (wired in spaxel-9eg)\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/eventbus/\nPATH=$PATH:/home/coding/go/bin go test ./internal/eventbus/\n```","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-06T22:31:07.051525693Z","created_by":"coding","updated_at":"2026-04-07T16:52:34.549568384Z","closed_at":"2026-04-07T16:52:34.549293102Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-6th","title":"Multi-link CSI fusion and localization","description":"## Background\n\nSingle-link motion detection (Phase 2) shows presence on a link axis. With 4+ links we can localise people to ±0.5–1.0m using Fresnel zone weighted localization. This is the core spatial intelligence of spaxel. The physics: WiFi CSI is most sensitive to motion within the first Fresnel zone (an ellipsoid between TX and RX). The approach: for each occupancy grid voxel, compute its weight for each link based on Fresnel zone intersection, multiply by that link's deltaRMS, sum contributions, extract blob peaks.\n\n## What to Implement\n\nNew package: mothership/internal/fusion/\n\n### OccupancyGrid\n- mothership/internal/fusion/grid.go\n- 3D float32 grid, configurable resolution (default 0.25m)\n- Dimensions from room config (width, depth, height in meters)\n- Methods: Reset(), Set(x,y,z int, val float32), Get(x,y,z int) float32, Dims() (nx,ny,nz int)\n\n### Fresnel zone geometry\n- mothership/internal/fusion/fresnel.go\n- FresnelWeight(voxelPos, txPos, rxPos vec3, wavelength float64) float64\n- For 5GHz WiFi: wavelength = 0.06m\n- A voxel is in the first Fresnel zone if: d1+d2 <= dist(tx,rx) + wavelength/2\n where d1 = dist(voxel, tx), d2 = dist(voxel, rx)\n- Weight = deltaRMS × exp(-excess_path_length² / (2×0.1²))\n where excess_path_length = (d1+d2) - dist(tx,rx)\n- Weight = 0 outside Fresnel zone\n\n### FusionEngine\n- mothership/internal/fusion/engine.go\n- Inputs: ProcessorManager (from signal package), NodeRegistry (from fleet/session)\n- Runs at 10Hz via time.Ticker\n- Each tick: reset grid, for each active link get deltaRMS from ProcessorManager, for each voxel compute FresnelWeight × deltaRMS, accumulate to grid\n- Output: call BlobExtractor.Extract(grid), broadcast via dashboard hub as 'blob_update' JSON message\n\n### BlobExtractor\n- mothership/internal/fusion/blobs.go\n- Find 3D local maxima in the grid above threshold (default 0.02)\n- Non-maximum suppression: suppress any peak within 0.5m of a higher peak\n- Output: []BlobDetection{Position vec3, Confidence float32, Radius float32}\n- Limit to max 10 blobs\n\n### Room config\n- Add to mothership config (JSON): room.width_m, room.depth_m, room.height_m (defaults: 5, 5, 2.5)\n- Node positions: initially from fleet manager, defaulting to corners if unset\n\n## Key Files\n- mothership/internal/signal/processor.go — GetAllMotionStates()\n- mothership/internal/dashboard/hub.go — Broadcast() for blob_update\n- New: mothership/internal/fusion/grid.go, fresnel.go, engine.go, blobs.go + tests\n\n## Acceptance Criteria\n- FusionEngine produces blob_update WebSocket messages at 10Hz\n- Single active link produces blob peak along the TX-RX axis\n- Two crossing links produce peak near their intersection\n- BlobExtractor correctly suppresses nearby peaks\n- FresnelWeight returns 0 for voxels clearly outside the Fresnel zone\n- go test ./internal/fusion/... passes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T03:30:50.362272102Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.188829209Z","closed_at":"2026-03-28T05:36:26.188507646Z","close_reason":"Implemented: fusion/fusion.go + fusion/grid3d.go (9c56a37) — 3D occupancy grid 0.25m res, Fresnel zone ellipsoid weighting, FusionEngine 10Hz, BlobExtractor with NMS","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-6th","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:30:50.362272102Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-70i","title":"Develop CSI simulator CLI","description":"Create Go CLI tool for:\n- Virtual node generation\n- Synthetic CSI binary frame output\n- Developer testing workflow\n\nAcceptance: CLI generates valid CSI binary frames for testing without hardware.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T14:54:38.869607224Z","created_by":"coding","updated_at":"2026-04-09T14:54:38.869607224Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} {"id":"spaxel-72t","title":"Provisioning payload and NVS write","description":"## Background\n\nWhen a new node is provisioned, it needs WiFi credentials (SSID + password) and a unique node ID (for use in the hello message and as a persistent identifier). The provisioning payload is assembled by the mothership and sent to the firmware over serial during onboarding, or can also be sent over the WebSocket after reconnect. Getting this right is foundational to the security and identity model of the entire system.\n\n## Why Mothership-Generated Node IDs?\n\nRather than generating a random ID on device, having the mothership assign node IDs allows it to: track provisioned-but-never-connected nodes for inventory management, support re-provisioning with ID continuity (same physical device gets same ID after factory reset), prevent ID collisions in multi-node deployments, and maintain a token-based security model where only provisioned nodes can connect.\n\n## NVS Schema\n\nThe firmware NVS schema is defined in firmware/main/spaxel.h (schema version 1). Keys and types:\n- wifi_ssid (string, max 32 chars)\n- wifi_pass (string, max 64 chars)\n- node_id (uint16, assigned by mothership)\n- node_token (string, 12-char hex, assigned by mothership)\n- mothership_host (string, empty = use mDNS)\n- mothership_port (uint16, default 8080)\n- role (uint8, 0=rx, 1=tx, 2=tx-rx, 3=passive)\n- sample_rate (uint16, default 20 Hz)\n- schema_ver (uint8, current = 1)\n\n## Mothership API\n\nPOST /api/provision\nRequest body: {\"ssid\": \"MyWifi\", \"password\": \"secret\", \"label\": \"Living Room Node\"}\nResponse: {\"node_id\": 42, \"provision_token\": \"a3f7b2c1d8e9\", \"config_blob\": \"{...json...}\"}\n\nThe config_blob is a JSON string encoding all NVS keys listed above. It is passed verbatim to the firmware over serial or WebSocket. The firmware parses it, writes each key to NVS, and reboots.\n\nExample config_blob:\n{\"wifi_ssid\":\"MyWifi\",\"wifi_pass\":\"secret\",\"node_id\":42,\"node_token\":\"a3f7b2c1d8e9\",\"mothership_host\":\"\",\"mothership_port\":8080,\"role\":0,\"sample_rate\":20,\"schema_ver\":1}\n\n## Firmware Provisioning Handling\n\nTwo provisioning paths:\n\n1. Serial provisioning (onboarding wizard path): Before WiFi is connected, the firmware listens on UART0 (115200 baud) for a JSON line starting with {\"provision\":. On receipt, write to NVS and reboot. This path works even before WiFi credentials are configured.\n\n2. WebSocket provisioning (re-provisioning path): A new downstream command type \"provision\" alongside existing role/config/ota/reboot in firmware/main/websocket.c. Allows the mothership to update credentials or reset a node over the air. This is useful for credential rotation without physical access.\n\n## Security Model\n\nThe provision_token is used as a bearer token in the WebSocket Authorization header (Authorization: Bearer ) for all subsequent connections. The mothership validates the token on every connection attempt against the provisioned_nodes SQLite table. Nodes without a valid token receive a {type:\"reject\"} downstream message and the connection is closed.\n\nToken format: 12 lowercase hex characters (48 bits of entropy). Generated server-side using crypto/rand. Not derivable from node_id or MAC address.\n\n## SQLite Storage\n\nAdd a provisioned_nodes table to the mothership SQLite database:\nCREATE TABLE provisioned_nodes (\n node_id INTEGER PRIMARY KEY,\n mac TEXT,\n token TEXT NOT NULL,\n label TEXT,\n provisioned_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n first_seen DATETIME,\n last_seen DATETIME,\n firmware_version TEXT,\n current_role INTEGER DEFAULT 0\n);\n\nThe mac field is populated when the node first connects and sends its hello message. Before first connection, mac is NULL (node is provisioned but not yet seen).\n\n## Implementation Location\n\n- mothership/internal/provision/handler.go: POST /api/provision handler\n- mothership/internal/provision/store.go: SQLite CRUD for provisioned_nodes\n- mothership/internal/ingestion/auth.go: WebSocket token validation on connection\n- firmware/main/websocket.c: add \"provision\" downstream command type\n- firmware/main/wifi.c: add serial JSON provisioning path on UART0 (pre-WiFi)\n\n## Re-provisioning\n\nIf a node already exists in provisioned_nodes (matched by mac), the mothership can re-provision it with a new token. The old token is invalidated immediately. The new config_blob is sent via the existing WebSocket connection (if online) or over serial (if physically accessible). This handles: WiFi password changes, mothership IP changes, node relabelling.\n\n## Tests\n\n- Test that POST /api/provision returns valid config_blob containing all required NVS keys\n- Test that node_id is unique and increments correctly\n- Test that token validation rejects connections with unknown tokens\n- Test that token validation rejects connections with expired/rotated tokens\n- Test NVS serialisation round-trip: parse config_blob back to NVS key-value map and verify all values\n- Test that a second provision for the same MAC updates rather than duplicates the record\n\n## Acceptance Criteria\n\n- Provisioned node connects to mothership successfully with the assigned node_id and token\n- Token validation correctly rejects unprovisioned connection attempts with {type:\"reject\"}\n- Node label stored and returned via GET /api/nodes in the node list\n- Re-provisioning updates token and invalidates old token within one round-trip\n- config_blob contains all required NVS keys with correct types\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:36:54.067220841Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.268631627Z","closed_at":"2026-03-28T05:36:39.268468107Z","close_reason":"Implemented: provisioning/server.go (fb69190) + firmware/main/provision.c/h (fb69190) — POST /api/provision generates node_id+token+config_blob, UART serial provisioning window on ESP32, NVS write, provisioned_nodes SQLite table with token validation","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-72t","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.844135515Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-783","title":"Add Identify button to fleet status page","description":"Add 'Identify' button per row in the fleet status page that POSTs to /api/nodes/{mac}/identify.\n\n**Acceptance:**\n- Fleet status page has 'Identify' button per row","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T11:11:49.991341754Z","created_by":"coding","updated_at":"2026-04-09T11:18:10.966551629Z","closed_at":"2026-04-09T11:18:10.966448973Z","close_reason":"Added Identify button (⚡) per row in the fleet status page that POSTs to /api/nodes/{mac}/identify. Button only shows for online nodes and sends a 5-second LED blink command with toast feedback.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-h58"]} {"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} @@ -35,6 +38,7 @@ {"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":"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-8ke","title":"Implement activity timeline","description":"Build a universal event stream visualization with:\n- Tap-to-jump navigation\n- Inline feedback display\n\nAcceptance: Users can view all system events chronologically and tap any event to jump to that moment in time.","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T14:54:38.600997561Z","created_by":"coding","updated_at":"2026-04-09T15:02:50.126377215Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} {"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":""}]} {"id":"spaxel-9nj","title":"fix: falldetect unused imports and vars cause build failure","description":"## Problem\n`internal/falldetect/detector.go` fails to compile:\n- Line 9: `\"math\"` imported and not used\n- Line 360: `startZ` declared and not used\n- Line 360: `endZ` declared and not used\n\n## Fix\n1. Remove `\"math\"` from the import block in `detector.go`\n2. Remove or use the `startZ` and `endZ` variables (prefix with `_` if needed, or delete)\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/falldetect/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T22:29:46.582450658Z","created_by":"coding","updated_at":"2026-04-06T22:46:04.704272938Z","closed_at":"2026-04-06T22:46:04.704068691Z","close_reason":"Already fixed in commit d3f4d8f — removed unused math import and startZ/endZ variables. Build verified clean.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} @@ -55,6 +59,7 @@ {"id":"spaxel-csj","title":"Spatial quick actions context menu","description":"## Background\n\nThe 3D scene contains many interactive elements: humanoid track blobs, node spheres, zone cuboids, portal planes, trigger volumes, and empty space. Performing operations on these elements — labelling a track, triggering OTA, creating an automation for a zone — currently requires navigating through multiple panels and menus. The context menu provides a faster, more discoverable path: right-click or long-press on any element, and immediately see the most relevant actions for that element.\n\nThis is a UI polish bead, but one with significant usability impact for power users who frequently interact with the 3D scene.\n\n## Raycasting and Element Detection\n\nWhen a right-click (or 300ms long-press on touch) event fires on the Three.js canvas:\n1. Convert the mouse/touch position to normalised device coordinates (NDC): x = (event.clientX / canvas.width) * 2 - 1, y = -(event.clientY / canvas.height) * 2 + 1\n2. Create a THREE.Raycaster and call raycaster.setFromCamera(ndcCoords, camera)\n3. Cast the ray against the following scene object groups (in priority order, check outermost first):\n a. Track blobs and humanoid meshes (highest priority — users click on people most often)\n b. Node spheres/cylinders\n c. Zone cuboids (bounding box meshes)\n d. Portal plane meshes\n e. Trigger volume bounding boxes\n f. Ground plane (for empty space context menu — always intersects last)\n4. Take the closest intersection with a ray distance check (ignore intersections behind the camera)\n5. Identify the element type from the mesh's userData.type field (set at mesh creation time: \"track\", \"node\", \"zone\", \"portal\", \"trigger_volume\", \"ground\")\n\n## Context Menu Rendering\n\nThe context menu is an HTML div overlay (not a Three.js object) positioned at the mouse/touch coordinates. It must:\n- Appear instantly (no animation delay)\n- Stay within viewport bounds (reposition if near edges)\n- Dismiss on: Escape key, click anywhere outside, second right-click, Tab\n- Not interfere with OrbitControls (prevent camera orbit while menu is open: set controls.enabled = false while menu is visible, restore on dismiss)\n\nContext menu HTML: a `div.context-menu` with `ul.context-menu-list` containing `li.context-menu-item` elements. Each item has an icon (SVG inline) and a label. Dividers are `li.context-menu-divider`.\n\nCSS: box-shadow, border-radius, background #1e293b (dark), 14px font, 36px min item height, hover state highlight.\n\n## Menu Items Per Element Type\n\nTrack/blob:\n- \"Who is this? Assign label\" — opens the People & Devices panel filtered to this track\n- \"Follow (camera)\" — enables follow-camera mode for this track\n- \"View history\" — jumps activity timeline to this track's events (filter by track_id)\n- \"Mark as false positive\" — shortcut to the feedback form with FALSE_POSITIVE pre-selected\n- \"Explain detection\" — triggers explain mode (spaxel-ez4 explainability overlay)\n- divider\n- \"Set as unknown (anonymous)\" — removes identity assignment from this track\n\nNode:\n- \"Edit label\" — opens an inline edit field directly on the node's label in the 3D scene\n- \"View health details\" — opens the link health panel focused on this node's links\n- \"Trigger OTA update\" — triggers OTA for this specific node (with confirmation dialog if node is the last online)\n- \"Locate node (blink LED)\" — sends a blink/identify command via WebSocket to the node\n- \"Re-assign role\" — opens a role picker (TX/RX/TX-RX/passive) inline\n- divider\n- \"Remove from fleet\" — opens confirmation: \"This will disconnect Node [label] and remove its data.\"\n\nEmpty space:\n- \"Add virtual node here\" — places a new virtual node at the clicked ground position (for placement simulation)\n- \"Create zone here\" — starts zone creation mode with the clicked position as one corner\n- \"Set as home point\" — sets the coordinate origin to this position (recentres the floor plan)\n- \"Place portal here\" — starts portal creation mode centred at this position\n\nZone:\n- \"Edit zone bounds\" — enters zone edit mode (drag handles to resize)\n- \"Rename zone\" — inline rename in the zone label\n- \"View occupancy history\" — opens timeline filtered to this zone's transition events\n- \"Create automation for this zone\" — opens the automation builder with this zone pre-selected as trigger target\n- divider\n- \"Delete zone\" — with confirmation\n\nPortal:\n- \"Edit portal\" — enters portal edit mode (move/resize)\n- \"View crossing history\" — opens timeline filtered to portal crossing events for this portal\n- divider\n- \"Delete portal\" — with confirmation\n\nTrigger volume:\n- \"Edit trigger\" — opens the automation associated with this volume in the automation builder\n- \"Test fire\" — fires the automation with test_mode=true flag\n- \"Enable / Disable\" — toggles the automation's enabled flag\n- divider\n- \"Delete trigger volume\" — deletes the volume and its associated automation trigger\n\n## Follow Camera Mode\n\nWhen \"Follow (camera)\" is selected from a track's context menu:\n1. Camera enters follow mode: every render frame, camera.position and camera.target are smoothly interpolated toward a position N metres behind and M metres above the track's current position. N=3m, M=2m default (third-person camera).\n2. A \"Following: Alice\" chip appears in the top-left corner of the 3D canvas.\n3. Camera zoom and pan are disabled during follow mode (OrbitControls disabled).\n4. \"Unfollow\" button in the chip: click to exit follow mode and restore OrbitControls.\n5. If the track is deleted or becomes DELETED state: automatically exit follow mode.\n\nThe interpolation uses Three.js VectorLerp and QuaternionSlerp (or equivalent). The follow distance can be adjusted with the scroll wheel even during follow mode (override only the dolly part of OrbitControls).\n\n## Files to Create or Modify\n\n- dashboard/js/contextmenu.js: ContextMenuManager, raycasting, menu rendering, action dispatch\n- dashboard/js/camera.js (or app.js): follow camera mode logic\n- dashboard/js/contextmenu.css: context menu styles\n- dashboard/js/app.js: register right-click and long-press listeners, integrate ContextMenuManager\n\n## Tests\n\n- Test raycasting correctly identifies element type: mock scene with a \"track\" mesh and a \"node\" mesh; raycast at the track's screen position -> returns element type \"track\"\n- Test that correct menu items appear for each element type: mock scene with one of each type, right-click each, verify menu item labels match the specification\n- Test that \"Follow\" camera mode activates: verify controls.enabled = false and camera follow interpolation fires on each render frame\n- Test dismiss on Escape key, click outside, and second right-click\n- Test that menu stays within viewport: when context menu would extend beyond right edge, it repositions to left of cursor\n- Test \"Trigger OTA\" opens confirmation dialog when node is last online\n- Test \"Mark as false positive\" dispatches correct feedback event\n\n## Acceptance Criteria\n\n- Context menu appears on right-click for all element types in under 50ms\n- Correct action set shown for each element type (no irrelevant actions)\n- \"Follow\" camera mode smoothly tracks the selected track with correct camera offset\n- \"Unfollow\" exits follow mode and restores normal OrbitControls\n- All menu actions execute correctly (dispatching to the correct handler)\n- Menu repositions to stay within viewport bounds\n- Menu dismisses on Escape, click outside, and second right-click\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:01:33.863869938Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.918513652Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-csj","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.918462075Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-cxm","title":"Phase 2: Signal Processing & Detection","description":"Goal: Detect presence on a single link.\n\n3 of 6 items complete (phase sanitisation, baseline system, motion detection).\n\nRemaining:\n- Dashboard presence indicator — per-link motion detected/clear display with amplitude time series\n- CSI recording buffer — disk-backed circular buffer (48h default) for time-travel replay\n- Adaptive sensing rate — mothership-controlled rate changes (idle 2Hz ↔ active 50Hz), on-device amplitude variance check\n\nExit criteria: Dashboard reliably shows motion detected/clear for a single link. Idle links auto-drop to 2 Hz.","status":"closed","priority":2,"issue_type":"phase","assignee":"spaxel-alpha","created_at":"2026-03-27T01:55:01.708603531Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.109167705Z","closed_at":"2026-03-28T05:36:26.109107331Z","close_reason":"Phase 2 complete. All 6 deliverables implemented: phase sanitisation, baseline system, motion detection (973b0a0), dashboard presence indicator (75edd83 + spaxel-26o), CSI recording buffer (0816a5c + spaxel-hey), adaptive sensing rate (bcfd1e3 + spaxel-tim). go test ./... passes.","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-d04","title":"Implement security mode dashboard UI","description":"## Dashboard UI (dashboard/js/security-panel.js)\n\n### Security mode card (always visible in header or sidebar)\n- Arm / Disarm toggle button with confirmation dialog\n- Status badge: DISARMED / LEARNING (N days remaining) / ARMED / ALERT\n- Learning period progress bar: '5 of 7 days complete'\n- Last anomaly: '2 hours ago — kitchen motion at 3:14am'\n\n### Alert banner\n- Full-width red banner when anomaly triggered while armed\n- Description, timestamp, affected zone\n- Acknowledge button (POST /api/anomalies/{id}/acknowledge)\n\n### Anomaly timeline tab\n- List of recent anomaly events with severity, zone, timestamp\n- Links to timeline view for full context\n\n## Acceptance\n- Learning period progress updates on page refresh\n- Anomaly alert banner appears within 2s of detection\n- Acknowledged alerts disappear from the banner (not from history)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:09:35.859782007Z","created_by":"coding","updated_at":"2026-04-07T14:22:13.362922232Z","closed_at":"2026-04-07T14:22:13.362861907Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} +{"id":"spaxel-d41","title":"Create pre-deployment simulator","description":"Build simulator with:\n- Virtual space definition\n- Virtual nodes\n- Synthetic walkers\n- GDOP overlay\n\nAcceptance: Simulator produces realistic synthetic data matching real-world conditions.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T14:54:38.804838310Z","created_by":"coding","updated_at":"2026-04-09T14:54:38.804838310Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} {"id":"spaxel-dbd","title":"Add floor plan dashboard UI","description":"## Dashboard (dashboard/js/floorplan-setup.js)\n- Setup panel section: 'Floor Plan' with upload button\n- On image select: POST to /api/floorplan/image; display uploaded image on ground plane in 3D scene\n- Calibration UI: click point A on image → click point B → enter real-world distance in meters → Save\n- Compute pixel-to-meter scale factor: scale = distance_m / pixel_distance(A,B)\n- Apply scale and rotation to Three.js ground plane texture on load\n\n## Acceptance\n- Uploaded image displayed as ground plane texture in 3D view\n- Calibrated coordinate system maps pixel positions to correct meter positions\n- Image persists across server restart","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-07T14:46:37.333473683Z","created_by":"coding","updated_at":"2026-04-09T12:37:44.104723182Z","closed_at":"2026-04-09T12:37:44.104601579Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6hd"]} {"id":"spaxel-ez4","title":"Detection explainability overlay","description":"## Background\n\nWhen a blob appears in an unexpected position, or an alert fires that seems wrong, the first question is \"why?\" The explainability overlay answers this question visually in the 3D scene, without requiring the user to understand deltaRMS, Fresnel zones, or UKF — though the data is available for those who want it. This transforms a \"magic box\" into a comprehensible physical system.\n\nThis is also the most important debugging tool for a developer tuning the system: seeing which links contributed most to a blob position, and by how much, is the fastest path to understanding localisation errors.\n\n## ExplainabilitySnapshot\n\nThe FusionEngine (spaxel-m9a) is extended to emit an ExplainabilitySnapshot alongside each BlobUpdate. This snapshot contains all the data needed to explain why a specific blob appeared at a specific position.\n\nExplainabilitySnapshot struct (mothership/internal/fusion/explain.go):\n- blob_id: the ID of the blob being explained\n- blob_position: Vec3 — final estimated position\n- per_link_contributions: []LinkContribution\n - link_id, tx_mac, rx_mac\n - weight float64 — the geometric Fresnel weight for this blob position\n - learned_weight float64 — the learned spatial weight (from weight learner, Phase 7)\n - combined_weight float64 = weight * learned_weight\n - delta_rms float64 — the current deltaRMS for this link\n - contribution_pct float64 — percentage of total fusion score contributed by this link\n - fresnel_intersection_volume float64 — volume of Fresnel zone ellipsoid that overlaps the blob's voxel (proxy for \"how much does this link see this position\")\n- ble_match: optional — if identity is matched: {device_mac, person_id, person_label, ble_distance_m, triangulation_confidence}\n- fusion_score float64 — total occupancy grid score at blob position\n- timestamp of snapshot\n\nThe snapshot is broadcast via WebSocket as \"blob_explain\" message type, alongside the regular \"blob_update\". The frontend requests a snapshot by sending {\"type\":\"request_explain\",\"blob_id\":\"...\"} — the server then enriches the next blob update with the explain data.\n\n## 3D Explain Mode UI\n\nRight-click (desktop) or long-press (mobile, 300ms) on any blob/track in the Three.js scene triggers explain mode.\n\nScene transformation in explain mode:\n1. All link lines dim to 20% opacity (using THREE.MeshBasicMaterial.opacity)\n2. Contributing links — those with contribution_pct > 2% — increase to 100% opacity and glow with colour intensity mapped to contribution_pct (low contribution = pale blue, high contribution = bright yellow)\n3. First Fresnel zone ellipsoids rendered for each contributing link: THREE.Mesh with SphereGeometry scaled by (a, b, b) and rotated to the link axis, translucent wireframe + fill (opacity 0.1). The ellipsoid colour matches the link line colour.\n4. A \"blob explanation panel\" (sidebar overlay, not a Three.js object) shows the breakdown:\n - Blob position in metres: \"Detected at (3.2m, 1.8m, 1.0m)\"\n - Fusion score: \"Detection confidence: [N]%\"\n - Contributing links table: link name, contribution %, deltaRMS, health score — sorted by contribution descending\n - Motion sparkline: small 30-second deltaRMS chart per link (uses the recording buffer data if available, otherwise the in-memory history)\n - BLE match details: \"Identity: Alice (BLE triangulation, confidence 82%, 0.4m from blob)\"\n - If no BLE match: \"Identity: Unknown (no BLE device match)\"\n\nExit explain mode: click anywhere outside the blob, or press Escape. Scene returns to normal opacity levels.\n\n## Fresnel Ellipsoid Geometry\n\nThe first Fresnel zone ellipsoid geometry for a link:\n- TX position P1, RX position P2\n- Link distance d = |P1 - P2|\n- WiFi wavelength lambda = 0.06m (5 GHz) or 0.125m (2.4 GHz) — use the channel from the node's hello message\n- Semi-major axis: a = (d + lambda/2) / 2\n- Semi-minor axis: b = sqrt(a^2 - (d/2)^2)\n- Centre: midpoint(P1, P2)\n- Orientation: the major axis is along the P1->P2 unit vector\n\nIn Three.js: SphereGeometry with radius=1, then scale (a, b, b) with the correct rotation matrix (use THREE.Quaternion.setFromUnitVectors to align with P1->P2 direction).\n\n## Motion Sparkline\n\nFor each contributing link in the explanation panel, show a 30-second history of deltaRMS as a small canvas sparkline (using the existing amplitude history if available from the dashboard WebSocket connection, or fetching from GET /api/recordings/{link_id}/recent?seconds=30 if the recording buffer is available).\n\nThe sparkline shows the moment of detection as a vertical line at the right edge. A horizontal dashed line shows the current motion threshold. Visually conveying \"the signal crossed the threshold at this moment.\"\n\n## Files to Create or Modify\n\n- mothership/internal/fusion/explain.go: ExplainabilitySnapshot, emission logic in FusionEngine\n- mothership/internal/fusion/engine.go: extend to emit ExplainabilitySnapshot alongside BlobUpdate\n- dashboard/js/explain.js: explain mode 3D scene transforms, sidebar panel\n- dashboard/js/fresnel.js: Fresnel ellipsoid geometry helper (reused by Fresnel debug overlay bead)\n- mothership/internal/dashboard/hub.go: blob_explain WebSocket message type\n\n## Tests\n\n- Test ExplainabilitySnapshot generation: with 3 known links and a blob at a known position, verify per_link_contributions are computed correctly\n- Test contribution_pct sums to approximately 100% across all links with non-zero weight\n- Test Fresnel ellipsoid geometry: for TX at (0,0,0) and RX at (4,0,0) with lambda=0.06: a ≈ 2.015, b ≈ 0.345. Verify these values from the geometry computation.\n- Test that explain mode correctly dims/highlights links in the Three.js scene (test via scene state inspection, not visual rendering)\n- Test that WebSocket \"request_explain\" message triggers snapshot emission in the next update cycle\n- Test sidebar panel rendering with mock ExplainabilitySnapshot data\n\n## Acceptance Criteria\n\n- Right-click on any blob triggers explain mode with correct contributing link highlighting\n- Fresnel ellipsoids render at correct positions and sizes for all contributing links\n- Confidence breakdown panel shows per-link contributions that sum to 100%\n- Non-contributing links visually dimmed in explain mode\n- Motion sparklines show 30-second history for each contributing link\n- BLE match details shown when identity is available\n- Escaping explain mode restores all link opacities to normal\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:55:18.006377304Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.817464555Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-ez4","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.817442776Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-ez4","depends_on_id":"spaxel-s70","type":"blocks","created_at":"2026-03-28T01:55:20.955603637Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-fi6","title":"Implement Portals CRUD REST endpoints","description":"Implement CRUD endpoints for portals: GET/POST /api/portals, PUT/DELETE /api/portals/{id}. Include OpenAPI-style godoc comments. Portal changes must reflect in live 3D view within one WebSocket cycle.","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-07T13:56:27.334232115Z","created_by":"coding","updated_at":"2026-04-07T17:56:13.860592476Z","closed_at":"2026-04-07T17:56:13.860493596Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-21n"]} @@ -68,7 +73,7 @@ {"id":"spaxel-hf8","title":"Add BLE scan messages to WebSocket feed","description":"Add 'ble_scan' message type to /ws/dashboard for BLE device list updates every 5s. Broadcast: { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }. Handle in app.js onmessage. Updates every 5s when devices present.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T14:18:27.545878314Z","created_by":"coding","updated_at":"2026-04-07T12:24:17.785369902Z","closed_at":"2026-04-07T12:24:17.785105015Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-hgm","title":"Spatial automation builder","description":"## Background\n\nThe ultimate value of presence detection is actionability. Users want their smart home to respond to who is where: lights on when someone enters, alerts when the baby leaves the nursery, notifications when the house is empty. The spatial automation builder provides a no-code interface for creating these rules, with webhook and MQTT delivery. It is the bridge between spaxel's physical sensing and the broader smart home ecosystem.\n\n## Automation Structure\n\nAn automation has three parts: trigger, optional conditions, and one or more actions. All three are stored as JSON in SQLite, allowing flexible extension without schema migrations.\n\nTrigger types:\n- zone_enter: fires when a person (or anyone) enters a named zone or custom trigger volume\n- zone_leave: fires when a person (or anyone) leaves a named zone\n- zone_dwell: fires when a person has been in a zone continuously for > N minutes (configurable threshold)\n- zone_vacant: fires when a zone transitions from occupied to empty (last person leaves)\n- person_count_change: fires when zone occupant count crosses a threshold (e.g. count goes from 2 to 3)\n- fall_detected: fires when fall detection (spaxel-zvs fall bead) fires for a person in a zone\n- anomaly: fires when anomaly detector (Phase 7) fires an anomaly event\n- ble_device_present: fires when a specific BLE device (or any labelled device) is first seen in a scan cycle (useful for \"arrive home\" detection)\n- ble_device_absent: fires when a specific BLE device has not been seen for > N minutes (useful for \"left home\" detection)\n\nCondition filters:\n- person_filter: specific person_id (or \"anyone\")\n- time_window: ISO 8601 time range (e.g. \"22:00-07:00\" for night)\n- day_of_week: bitmask (0=Sun, 1=Mon, ... 6=Sat)\n- system_mode: home, away, sleep (modes set by user or auto-detected)\n- zone_occupancy: additional zone occupancy condition (e.g. \"only if Living Room is empty\")\n\nAction types:\n- webhook: POST to a user-configured URL with JSON payload\n- mqtt_publish: publish to a topic with a payload (uses the MQTT client from home automation integration bead)\n- ntfy: send a push notification via ntfy (self-hosted or ntfy.sh)\n- pushover: send a Pushover notification\n\nPayload templating for all action types supports these variables:\n{{person_name}}, {{zone_name}}, {{from_zone}}, {{to_zone}}, {{timestamp}}, {{occupant_count}}, {{event_type}}, {{person_color}}, {{confidence}}\n\nExample webhook payload template:\n{\"text\": \"{{person_name}} entered {{zone_name}} at {{timestamp}}\", \"color\": \"{{person_color}}\"}\n\n## 3D Trigger Volumes\n\nIn addition to named zones, automations can use arbitrary 3D cuboid volumes as their spatial target. These are drawn in the 3D editor like zone bounding boxes but are not associated with a zone name — they exist only for automation triggers. Rendered as dashed-outline cuboids (not filled) in the 3D scene with the automation name as label.\n\nSQLite schema: trigger_volumes (id, automation_id FK, name, bounds_min_xyz, bounds_max_xyz)\n\nThe crossing detection and occupancy logic from the portals bead (spaxel-qlh) is reused — trigger volumes use the same containment test.\n\n## Automations SQLite Schema\n\nCREATE TABLE automations (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n enabled BOOLEAN DEFAULT TRUE,\n trigger_type TEXT NOT NULL,\n trigger_config TEXT NOT NULL, -- JSON: {\"zone_id\":\"...\",\"person_id\":\"anyone\",\"dwell_minutes\":5}\n conditions TEXT, -- JSON array of condition objects\n actions TEXT NOT NULL, -- JSON array of action objects\n last_fired DATETIME,\n fire_count INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n## AutomationEngine\n\nNew struct: mothership/internal/automation/engine.go\n\nAutomationEngine subscribes to the internal event bus (Phase 8, spaxel-sl2 activity timeline — the event bus is implemented there). On each event, it:\n1. Finds all enabled automations whose trigger_type matches the event type\n2. For each matching automation, evaluates all conditions\n3. If all conditions pass: fires the actions\n\nCondition evaluation:\n- time_window: parse \"HH:MM-HH:MM\", check if current time (in server timezone) is within range. Handle overnight ranges (22:00-07:00 spans midnight).\n- day_of_week: check current day against bitmask\n- person_filter: check event.PersonID against filter (or \"anyone\" wildcard)\n- system_mode: check current SystemMode (stored in mothership state)\n\nAction execution:\n- webhook: goroutine with 5s timeout, HTTP POST with rendered payload. Retry once after 30s on 5xx response. Log all requests and responses.\n- mqtt_publish: synchronous publish if MQTT client is connected. If not connected: log \"MQTT not configured\" and skip.\n- ntfy/pushover: use the notification module (Phase 6 spatial context notifications bead).\n\nAction results (success/failure/timeout) are logged in SQLite: action_log (automation_id, fired_at, event_json, actions_results_json).\n\n## Dashboard UI\n\nAutomations management page (route /automations):\n- List view: table of all automations with name, trigger type, enabled toggle, last fired timestamp, fire count, and edit/delete actions\n- Create/edit modal: step-by-step builder:\n 1. Choose trigger: dropdown of trigger types with plain-English labels (\"When someone enters a room\", \"When a room becomes empty\", etc.)\n 2. Configure trigger: zone picker (or \"Any zone\"), person picker (or \"Anyone\"), threshold for dwell/count\n 3. Add conditions (optional): time window picker, day picker, system mode selector\n 4. Add action: action type dropdown, then action-specific fields (URL for webhook, topic for MQTT, ntfy server + topic)\n 5. Template editor for payload with variable hints\n 6. Summary: \"When Alice enters the Kitchen between 7am and 9am on weekdays, POST to https://...\"\n- \"Test fire\" button: simulates the trigger event and fires all actions with test_mode=true flag in payload. Useful for debugging webhooks.\n- 3D view integration: when an automation's trigger zone is hovered in the automation editor, the corresponding zone/trigger volume highlights in the 3D scene.\n- Visual feedback in 3D view when trigger fires: brief highlight (bright flash) of the trigger zone in the 3D scene.\n\n## System Mode\n\nSystemMode is a top-level state: HOME, AWAY, SLEEP.\n- HOME: normal operation\n- AWAY: all registered BLE devices absent, security-level alert on any detection (managed by anomaly detection, Phase 7)\n- SLEEP: quiet hours active, non-urgent notifications suppressed\n\nMode changes:\n- Manual toggle from dashboard settings\n- Auto-away: all registered BLE devices absent for > 15 minutes -> auto-set AWAY\n- Auto-home: first registered BLE device seen again -> auto-set HOME\nAuto-sleep: user can configure a schedule (e.g. 10pm-7am on weekdays)\n\nMode is exposed as GET /api/mode and POST /api/mode {\"mode\":\"home\"/\"away\"/\"sleep\"}.\n\n## Tests\n\n- Test trigger matching for each trigger type: inject matching event, verify automation fires; inject non-matching event, verify no fire\n- Test time_window condition: \"22:00-07:00\" blocks at 08:00, passes at 23:00, passes at 04:00\n- Test overnight time range correctly handles midnight boundary\n- Test person_filter condition: \"anyone\" matches all events; specific person_id only matches events with that person\n- Test webhook dispatch: mock HTTP server, verify POST arrives with correct rendered payload\n- Test webhook retry: mock server returns 503 first request, 200 second, verify retry fires after 30s\n- Test MQTT publish with mock broker: verify correct topic and payload\n- Test \"test fire\" mode sets test_mode=true in payload\n- Test fire_count increments in SQLite after each fire\n\n## Acceptance Criteria\n\n- Automation fires correctly within 200ms of its trigger event for each trigger type\n- Webhook delivers payload to mock server within 5s\n- MQTT message arrives with correct topic and payload\n- Time-window condition blocks automations outside their configured window\n- 3D trigger volume editor allows drawing custom volumes not tied to named zones\n- \"Test fire\" button correctly simulates trigger without requiring a real event\n- Fire count and last_fired timestamp update in database after each fire\n- Retry mechanism handles transient webhook failures\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:46:36.925844184Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.766389180Z","closed_at":"2026-03-29T18:07:39.766280132Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-hgm","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.294048305Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-hnp","title":"Presence prediction","description":"## Background\n\nAfter 7+ days of operation, spaxel has accumulated a detailed history of each person's movements: which rooms they visit at what times, how long they dwell, and typical transition patterns. These patterns are remarkably consistent for most people: wake up in the bedroom, kitchen around 7am, leave around 8:30am, return around 6pm. By modelling these patterns as a transition probability matrix, spaxel can predict where people will be next and approximately when they will get there. This enables genuinely useful proactive automation (\"pre-warm the kitchen 15 minutes before Alice typically arrives\") and helpful dashboard widgets.\n\n## Transition Probability Model\n\nThe model is a Markov chain indexed by (person_id, hour_of_week) — there are 7 * 24 = 168 hour-of-week slots.\n\nFor each person, for each (hour_of_week, from_zone_id) pair:\n- Count total transitions out of from_zone in that hour_of_week slot\n- Count transitions to each destination zone in that slot\n- Compute probability = count_to_zone / total_transitions_from_zone\n\nThis is a first-order Markov chain with time-of-week context. It is simple enough to compute incrementally and query in real-time, but captures the most important regularity in human daily routines.\n\nSQLite schema:\nCREATE TABLE zone_transitions_history (\n id TEXT PRIMARY KEY,\n person_id TEXT,\n from_zone_id TEXT,\n to_zone_id TEXT,\n hour_of_week INTEGER, -- 0-167: day_of_week * 24 + hour_of_day\n dwell_duration_minutes REAL, -- how long they were in from_zone before transitioning\n timestamp DATETIME\n);\n\nCREATE TABLE transition_probabilities (\n person_id TEXT,\n hour_of_week INTEGER,\n from_zone_id TEXT,\n to_zone_id TEXT,\n probability REAL,\n count INTEGER, -- raw count for Bayesian smoothing\n last_computed DATETIME,\n PRIMARY KEY (person_id, hour_of_week, from_zone_id, to_zone_id)\n);\n\nProbability computation: run weekly (or on-demand). For each (person, hour_of_week, from_zone) group: normalize counts to probabilities. Apply Laplace smoothing (add 1 to each count before normalizing) to handle zero-count slots gracefully.\n\n## Dwell Time Model\n\nAlongside transition probabilities, track typical dwell duration per (person_id, hour_of_week, zone_id). This allows estimating when the next transition will occur.\n\nCREATE TABLE dwell_times (\n person_id TEXT, zone_id TEXT, hour_of_week INTEGER,\n mean_minutes REAL, stddev_minutes REAL, count INTEGER,\n PRIMARY KEY (person_id, zone_id, hour_of_week)\n);\n\nPrediction of time-to-next-transition: given that Alice is currently in the Kitchen at 7:30am on a Tuesday (hour_of_week = Tuesday*24 + 7 = 7+4*24=103), and she has been there for 8 minutes:\n- Look up dwell_times for (alice, Kitchen, 103): mean=12min, stddev=3min\n- Expected remaining time = max(0, mean - elapsed) = 4 minutes\n- Predicted next zone: argmax over transition_probabilities(alice, 103, Kitchen) = Living Room (78%)\n\n## Minimum Data Requirement\n\nPredictions are only meaningful after sufficient data is accumulated. Requirement: at least 7 days of data AND at least 3 samples for the specific (hour_of_week, from_zone) pair being predicted. The API returns {\"confidence\": \"insufficient_data\"} if the minimum data requirement is not met.\n\nShow this clearly in the dashboard: \"Alice's prediction will be ready in [N] days. Currently learning her patterns.\"\n\n## Prediction Output\n\nThe prediction output at any given moment is a list of PersonPrediction structs:\n{\n person_id TEXT,\n person_label TEXT,\n current_zone_id TEXT,\n current_zone_name TEXT,\n predicted_next_zone_id TEXT,\n predicted_next_zone_name TEXT,\n prediction_confidence REAL, -- the transition probability to the predicted zone\n estimated_transition_minutes REAL, -- expected time until transition (from dwell model)\n data_confidence TEXT -- \"sufficient\" or \"insufficient_data\"\n}\n\nREST API: GET /api/predictions returns []PersonPrediction\n\n## Dashboard Widget\n\n\"Predictions\" panel in expert mode (can also appear in simple mode, Phase 9):\n- Card per tracked person showing: name, current zone icon, arrow, predicted next zone icon\n- Confidence percentage: \"78% likely\"\n- Estimated time: \"in ~4 minutes\"\n- Small tooltip: \"Based on N Tuesdays\"\n- \"Predictions unavailable — collecting data [7 days remaining]\" placeholder for new deployments\n\n## Home Assistant MQTT Sensor\n\nPublish to MQTT: spaxel/{mothership_id}/person/{person_id}/predicted_zone\nPayload: JSON {\"zone_id\":\"...\",\"zone_name\":\"Living Room\",\"confidence\":0.78,\"estimated_minutes\":4}\nRetained: false (event topic)\n\nThis allows HA automations to use predictions:\n- \"15 minutes before Alice typically enters the bedroom, turn on the bedroom lamp\"\n\nNew automation trigger type in the automation builder (spaxel-hgm): predicted_zone_enter(person_id, zone_id, minutes_ahead). This trigger fires N minutes before the predicted zone entry.\n\n## Files to Create or Modify\n\n- mothership/internal/prediction/model.go: transition probability model, dwell time model\n- mothership/internal/prediction/predictor.go: PersonPredictor, GetPredictions()\n- mothership/internal/prediction/history.go: IncrementalHistoryUpdater, probability recomputation\n- mothership/internal/dashboard/routes.go: GET /api/predictions\n- mothership/internal/mqtt/client.go: add predicted_zone topic publishing\n- dashboard/js/predictions.js: Predictions panel\n- mothership/internal/automation/engine.go: add predicted_zone_enter trigger type\n\n## Tests\n\n- Test transition probability computation: 10 Kitchen->Living Room transitions and 5 Kitchen->Bedroom transitions in the same hour_of_week slot -> P(Living Room) ~= 0.67, P(Bedroom) ~= 0.33 (with Laplace smoothing)\n- Test dwell time estimation: expected remaining time formula with known elapsed time\n- Test that insufficient_data gate returns correct response before 7 days\n- Test prediction API response format and field types\n- Test predicted_zone_enter trigger fires N minutes before predicted transition (requires mock clock)\n- Test Laplace smoothing handles zero-count zones gracefully (no division by zero)\n\n## Acceptance Criteria\n\n- Predictions achieve > 75% accuracy at 15-minute horizon after 14 days of data (measured against held-out transition history)\n- Dashboard Predictions panel shows each tracked person with next-zone prediction and confidence\n- \"Insufficient data\" placeholder shown correctly before 7 days\n- MQTT predicted_zone sensor published after each prediction cycle\n- predicted_zone_enter automation trigger fires at correct lead time\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"sp2","created_at":"2026-03-28T01:51:16.226944535Z","created_by":"coding","updated_at":"2026-03-29T19:20:01.349909996Z","closed_at":"2026-03-29T19:20:01.349845980Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-hnp","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.474837777Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-i28","title":"Phase 7: Learning & Analytics","description":"Goal: System gets smarter over time. User feedback drives improvement.\n\nDeliverables:\n- Detection feedback loop (thumbs up/down, missed-detection marking, accuracy trend)\n- Self-improving localization (BLE as ground truth, Fresnel weight refinement)\n- Presence prediction (per-person/zone/time-slot transition probabilities, HA sensors)\n- Sleep quality monitoring (breathing analysis + motion scoring, morning summary)\n- Crowd flow visualization (trajectory accumulation, directional flow map, dwell hotspots)\n- Anomaly detection & security mode (7-day pattern learning, anomaly scoring)\n\nExit criteria: Accuracy improves measurably over 4 weeks. Predictions >75% at 15-min horizon.","status":"in_progress","priority":3,"issue_type":"phase","assignee":"golf","created_at":"2026-03-27T01:55:39.902286407Z","created_by":"coding","updated_at":"2026-04-09T14:41:22.140053927Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"],"dependencies":[{"issue_id":"spaxel-i28","depends_on_id":"spaxel-403","type":"blocks","created_at":"2026-03-29T19:25:04.204685274Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-bf5","type":"blocks","created_at":"2026-03-29T19:25:04.172153573Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-klf","type":"blocks","created_at":"2026-03-29T19:25:04.022963164Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-pgu","type":"blocks","created_at":"2026-03-29T19:25:03.967632695Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-qpi","type":"blocks","created_at":"2026-03-29T19:25:04.133317007Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-s60","type":"blocks","created_at":"2026-03-29T19:25:04.082109284Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T01:33:48.369590901Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-i28","title":"Phase 7: Learning & Analytics","description":"Goal: System gets smarter over time. User feedback drives improvement.\n\nDeliverables:\n- Detection feedback loop (thumbs up/down, missed-detection marking, accuracy trend)\n- Self-improving localization (BLE as ground truth, Fresnel weight refinement)\n- Presence prediction (per-person/zone/time-slot transition probabilities, HA sensors)\n- Sleep quality monitoring (breathing analysis + motion scoring, morning summary)\n- Crowd flow visualization (trajectory accumulation, directional flow map, dwell hotspots)\n- Anomaly detection & security mode (7-day pattern learning, anomaly scoring)\n\nExit criteria: Accuracy improves measurably over 4 weeks. Predictions >75% at 15-min horizon.","status":"closed","priority":3,"issue_type":"phase","assignee":"golf","created_at":"2026-03-27T01:55:39.902286407Z","created_by":"coding","updated_at":"2026-04-09T14:44:39.587415275Z","closed_at":"2026-04-09T14:44:39.587291675Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"],"dependencies":[{"issue_id":"spaxel-i28","depends_on_id":"spaxel-403","type":"blocks","created_at":"2026-03-29T19:25:04.204685274Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-bf5","type":"blocks","created_at":"2026-03-29T19:25:04.172153573Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-klf","type":"blocks","created_at":"2026-03-29T19:25:04.022963164Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-pgu","type":"blocks","created_at":"2026-03-29T19:25:03.967632695Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-qpi","type":"blocks","created_at":"2026-03-29T19:25:04.133317007Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-s60","type":"blocks","created_at":"2026-03-29T19:25:04.082109284Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T01:33:48.369590901Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-iq3","title":"Biomechanical blob tracking with UKF","description":"## Background\n\nRaw blob detections from FusionEngine (spaxel-FUSION) are noisy — blobs appear/disappear and jump between frames. We need a filter that: persists tracks through 5-frame occlusion gaps, constrains motion to physically plausible human movement, enables identity assignment (person A vs person B), and infers posture hints for 3D visualization. The Unscented Kalman Filter (UKF) is used because the motion model is nonlinear and UKF avoids requiring a Jacobian.\n\n## What to Implement\n\nNew package: mothership/internal/tracker/\n\n### UKF State\nState vector: [x, y, z, vx, vy, vz] (6-DOF: position + velocity)\n\nProcess model: constant velocity with acceleration noise. Process noise covariance Q:\n- Position noise: sigma_pos = 0.05m (small — position doesn't jump)\n- Velocity noise: sigma_vel = 0.5 m/s per axis (humans can accelerate ~3 m/s²)\n- Z velocity noise: sigma_vz = 0.05 m/s (humans rarely move vertically fast)\n\nHuman constraints applied as post-update clamps:\n- Max speed: clamp ||v|| to 2.0 m/s\n- Z position: clamp to [0.0, 2.5m] (floor to ceiling)\n- Z velocity: clamp to [-0.5, 0.5] m/s during normal locomotion (0.2m/s for sit-down)\n\n### TrackManager\n- mothership/internal/tracker/manager.go\n- Update(blobs []fusion.BlobDetection, timestamp time.Time) []TrackState\n- Data association: Hungarian algorithm for ≤6 blobs (O(n³)), greedy nearest-neighbor for >6\n- Mahalanobis distance gating: reject assignments if distance > chi2_threshold(0.99, df=3) ≈ 11.34\n- Track lifecycle:\n - TENTATIVE: blob seen 1-2 times. Not reported in output.\n - CONFIRMED: seen ≥3 consecutive updates. Reported.\n - COASTED: no detection for 1-5 updates. UKF predict-only. Still reported.\n - DELETED: no detection for >5 updates. Removed from state.\n- Collision avoidance: if two CONFIRMED tracks within 0.5m, add a repulsion force to the weaker track\n\n### Posture inference (heuristic, no ML)\nFrom TrackState position and velocity:\n- WALKING: speed > 0.3 m/s\n- STANDING: speed < 0.1 m/s, z > 1.0m\n- SEATED: speed < 0.1 m/s, 0.3m < z < 0.8m \n- LYING: z < 0.3m (regardless of speed)\n\n### Output\nTrackState: {id string, position vec3, velocity vec3, posture Posture, confidence float32, age int (frames)}\nBroadcast via hub as 'track_update' JSON at 10Hz, same cadence as blob_update.\n\n### Integration\nFusionEngine calls TrackManager.Update() after each BlobExtractor run.\n\n## Key Files\n- mothership/internal/fusion/engine.go — call TrackManager.Update() after blob extraction\n- mothership/internal/fusion/blobs.go — BlobDetection type\n- New: mothership/internal/tracker/ukf.go, tracker/manager.go, tracker/association.go + tests\n\n## Acceptance Criteria\n- Track IDs stable across 5-frame gaps\n- Two tracks do not merge when blobs cross within 0.5m\n- Posture WALKING fires when speed > 0.3 m/s\n- TENTATIVE tracks not included in track_update output\n- Hungarian assignment correct for 4-blob test case\n- go test ./internal/tracker/... passes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T03:30:50.410975161Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.222736024Z","closed_at":"2026-03-28T05:36:26.222463200Z","close_reason":"Implemented: tracker/tracker.go + tracker/ukf.go (59404aa) — 6-DOF UKF, human motion constraints, TrackManager Hungarian association, Mahalanobis gating, posture inference (walking/standing/seated/lying)","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-iq3","depends_on_id":"spaxel-6th","type":"blocks","created_at":"2026-03-28T03:30:50.410975161Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-itf","title":"Implement image serving endpoint","description":"## Task\nImplement GET /api/floorplan/image endpoint.\n\n## Specification\n- Serve the stored image from /data/floorplan/image.png\n- Return 200 with image if exists\n- Return 404 if no image\n\n## Acceptance\n- Returns 200 with image content when image.png exists\n- Returns 404 when image.png does not exist","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:51.201971868Z","created_by":"coding","updated_at":"2026-04-07T18:47:14.323279291Z","closed_at":"2026-04-07T18:47:14.323177509Z","close_reason":"Implementation verified: GET /api/floorplan/image endpoint already implemented in mothership/internal/floorplan/floorplan.go. Returns 200 with image from /data/floorplan/image.png if exists, 404 otherwise. Tests exist for both cases.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} {"id":"spaxel-iv3","title":"Dashboard: detection explainability overlay","description":"## Overview\n\nUsers need to understand why a blob is detected where it is. The 'Why is this here?' overlay is the primary explainability tool and a key trust-building feature.\n\n## What to build\n\n### Trigger\n- Right-click (or long-press) on a blob in the 3D view → context menu includes 'Why is this here?'\n- Also accessible from the blob's info panel\n\n### Overlay (dashboard/js/explainability.js)\n- Dim all scene elements except contributing links and the selected blob (THREE.js material opacity)\n- Highlight contributing links with a glowing yellow shader (MeshLineMaterial or emissive)\n- Render Fresnel zone ellipsoids for each contributing link at reduced opacity\n- Show intersection zones where Fresnel ellipsoids overlap (this is the detection hotspot)\n\n### Sidebar panel\n- Title: 'Detection confidence: 87%'\n- Per-link contribution table:\n | Link | deltaRMS | Zone # | Weight | Contributing? |\n |------|----------|--------|--------|---------------|\n | A:B | 0.041 | 1 | 0.34 | ✓ |\n- BLE match section (if applicable): 'Matched: Alice's iPhone (96% confidence)'\n- 'Close' button restores normal scene\n\n### Data source\n- GET /api/explain/{blob_id} — returns per-link contributions, confidence breakdown, BLE match\n- Must be implemented server-side (mothership/internal/fusion or localization)\n\n## Acceptance\n\n- Overlay renders within 300ms of user action\n- Non-contributing links are visually distinct from contributing ones\n- Fresnel ellipsoids visible and correctly scaled\n- Close button fully restores scene state","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:56:27.780919021Z","created_by":"coding","updated_at":"2026-04-06T17:31:34.658876657Z","closed_at":"2026-04-06T17:31:34.658774555Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:5"]} @@ -92,6 +97,7 @@ {"id":"spaxel-mul","title":"Implement Automation Triggers REST endpoints","description":"Implement CRUD endpoints for triggers: GET/POST /api/triggers, PUT/DELETE /api/triggers/{id}. Add POST /api/triggers/{id}/test to fire trigger once for testing. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T15:31:10.356946401Z","created_by":"coding","updated_at":"2026-04-07T16:46:17.434083019Z","closed_at":"2026-04-07T16:46:17.433959430Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-n9n","title":"Biomechanical blob tracking (UKF)","description":"Track detected blobs as human figures with physics-constrained motion model.\n\n## Deliverables\n- New package: mothership/internal/tracker/\n- Unscented Kalman Filter (UKF) with human motion model\n- Constraints: max velocity 2m/s, max acceleration 3m/s², turning radius, gravity-consistent Z\n- Blob ID assignment and persistence through brief gaps (up to 3s)\n- Collision avoidance between tracked entities\n- Posture estimation: standing/walking/seated/lying based on Z and velocity\n- Uses gonum.org/v1/gonum/mat for matrix operations\n\n## Acceptance Criteria\n- Tracks a single person smoothly through a room\n- Maintains blob ID across brief occlusions\n- Posture transitions are physically plausible\n- Tests with synthetic trajectory data\n\n## References\n- Plan: docs/plan/plan.md item 16\n- Fusion output: mothership/internal/fusion/ (blob positions)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:55.704147095Z","created_by":"coding","updated_at":"2026-03-28T02:06:17.873405703Z","closed_at":"2026-03-27T03:59:10.182764206Z","close_reason":"Implemented mothership/internal/tracker/ package with 6-state UKF (x,y,z,vx,vy,vz), biomechanical constraints (max horiz vel 2 m/s, max accel 3 m/s², min turning radius 0.3 m, gravity-consistent Z), blob ID persistence through 3s gaps, floor-plane collision avoidance, posture estimation (standing/walking/seated/lying), 60-point trail. 11 synthetic trajectory tests all pass. Uses gonum.org/v1/gonum/mat.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-n9n","depends_on_id":"spaxel-m9a","type":"blocks","created_at":"2026-03-28T02:06:17.873372333Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-n9n","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.608542494Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-ncw","title":"Add system health messages to WebSocket feed","description":"Add 'system_health' message type to /ws/dashboard for periodic system stats every 60s. Broadcast: { type: 'system_health', health: { uptime_s, node_count, bead_count, go_routines, mem_mb } }. Handle in app.js onmessage.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T14:18:27.674751078Z","created_by":"coding","updated_at":"2026-04-07T12:53:38.767877850Z","closed_at":"2026-04-07T12:53:38.767672942Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} +{"id":"spaxel-nhl","title":"Build detection explainability system","description":"Create X-ray overlay showing:\n- Contributing links to detections\n- Confidence breakdown\n\nAcceptance: Users can see exactly why a detection was triggered with visual overlays and confidence metrics.","status":"in_progress","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T14:54:38.671332846Z","created_by":"coding","updated_at":"2026-04-09T14:56:34.534867560Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} {"id":"spaxel-nk6","title":"Dashboard: PIN setup, login, and session cookie authentication","description":"## Overview\nProtect the dashboard with a PIN that is set on first run and verified on every subsequent visit via session cookies.\n\n## Backend (mothership/internal/auth/)\n- SQLite auth table: pin_bcrypt TEXT, install_secret TEXT (singleton row)\n- GET /api/auth/status — return {pin_configured: bool} — no auth required\n- POST /api/auth/setup — body: {pin:'1234'} — only works if pin not yet configured; bcrypt cost=12; store hash\n- POST /api/auth/login — body: {pin:'1234'} — verify bcrypt; on success issue session cookie:\n Name: spaxel_session, HttpOnly, SameSite=Strict (if TLS), Path=/, Max-Age=604800\n Store session_id → expires_at in SQLite sessions table\n- POST /api/auth/logout — clear session cookie; delete session from SQLite\n- Session middleware: all /api/* and /ws/* require valid session cookie; return 401 if missing/expired\n- Rolling window: on each authenticated request, if within 24h of expiry, extend by 7 days\n\n## Dashboard (dashboard/js/auth.js)\n- On load: GET /api/auth/status; if pin_configured=false → show first-run PIN setup page (full-screen, blocks dashboard)\n- First-run page: enter PIN + confirm PIN → POST /api/auth/setup → reload\n- Login page: shown on 401; PIN entry form → POST /api/auth/login → reload on success\n- Logout button in settings panel → POST /api/auth/logout → redirect to login\n\n## Acceptance\n- Fresh install: setup page shown before any dashboard content\n- After PIN set: login required on next visit\n- Session cookie survives page refresh; expires after 7 days of inactivity\n- 401 returned immediately for any /api/ call without valid cookie","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:43:02.833541561Z","created_by":"coding","updated_at":"2026-04-06T17:17:02.044261204Z","closed_at":"2026-04-06T17:17:02.044047110Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} {"id":"spaxel-nqh","title":"BLE-to-blob identity matching","description":"## Background\n\nThe BLE device registry (spaxel-2wg) tells us which labelled devices are present in the home and their per-node RSSI. The CSI fusion engine (spaxel-m9a) and blob tracker (spaxel-n9n) tell us where anonymous humanoid figures are located in 3D space. Identity matching bridges these two systems: given named BLE devices with per-node RSSI observations, and anonymous CSI blobs with estimated 3D positions, assign device identities to blobs.\n\nWhen a match is confident, the humanoid figure in the 3D view gains a name label and person colour. \"Alice is in the Kitchen\" becomes a real-time fact rather than an approximation.\n\n## RSSI Triangulation Algorithm\n\nFor each BLE device with observations from multiple nodes in the current scan cycle (last 5 seconds):\n\nStep 1: Convert RSSI to distance for each observing node.\nDistance model: d = d0 * 10^((RSSI_ref - RSSI) / (10 * n))\nParameters: d0 = 1.0m (reference distance), RSSI_ref = -65 dBm (RSSI at 1m in typical indoor environment), n = 2.5 (indoor path loss exponent — typical range 2.0-3.5; 2.5 is a reasonable default for residential spaces).\n\nStep 2: With 2+ nodes, perform weighted least squares triangulation.\nPosition estimate: argmin_P { sum_i w_i * (|P - node_i| - d_i)^2 }\nwhere w_i = 1/sigma_i^2, sigma_i = d_i * ln(10) / (10 * n) * RSSI_noise_sigma (typically 5 dBm).\nSolve with 3-5 iterations of gradient descent or Gauss-Newton (the system is nonlinear).\nWith 1 node: only an approximate range estimate; triangulation confidence = 0.2.\nWith 2 nodes: 2D position estimate on a circle/arc; confidence = 0.5.\nWith 3+ nodes: full 2D position estimate with residual as quality indicator; confidence = min(1.0, 0.7 + 0.1 * (n-3)) where n is node count.\n\nStep 3: Assign to nearest CSI blob.\nFor each triangulated BLE position, find the nearest active CSI blob (from TrackManager) within a 2-metre search radius. The assignment uses Euclidean distance in the horizontal plane (ignore Z for BLE since antenna height is variable).\n\nIf multiple BLE devices map to the same blob (e.g. Alice has a phone AND a Fitbit), use the device with the highest triangulation confidence.\n\nIf a BLE device position is triangulated but no CSI blob is within 2 metres: create a \"BLE-only\" placeholder track at the BLE position. BLE-only tracks are shown as a different visual (outlined circle rather than filled, lower opacity) to indicate lower confidence.\n\n## Match Confidence Score\n\nFor each BLE-to-blob assignment, compute a match confidence:\nconfidence = f_observations * f_node_count * f_residual * f_distance\n\nwhere:\n- f_observations: 1.0 if device seen in last 5s, 0.5 if last 15s, 0.0 if older\n- f_node_count: 0.2 (1 node), 0.5 (2 nodes), 0.8+ (3+ nodes)\n- f_residual: 1.0 - min(1.0, triangulation_residual_metres / 2.0) — penalise poor triangulation fit\n- f_distance: 1.0 if blob distance < 0.5m, linear decay to 0 at 2.0m\n\nOnly assign identity if confidence > 0.6. Below this threshold: keep the blob anonymous (\"Unknown\").\n\n## Identity Persistence\n\nOnce a BLE-blob match is made, the identity persists for 5 minutes even if:\n- The BLE device rotates its MAC (new MAC will match same person via the person record)\n- The BLE device briefly falls below RSSI threshold of all nodes (indoor dead zone)\n- The CSI blob briefly disappears (tracker coasted state, per spaxel-n9n)\n\nAfter 5 minutes of no BLE observation and no high-confidence re-match: revert to anonymous.\n\nThis 5-minute persistence prevents flickering identity labels when people walk through areas with inconsistent BLE coverage.\n\n## TrackManager Integration\n\nExtend the TrackState struct in mothership/internal/tracker/ (spaxel-n9n):\n- PersonID string (from BLE registry people table)\n- PersonLabel string\n- PersonColor string\n- IdentityConfidence float64\n- IdentitySource string (\"ble_triangulation\" or \"ble_only\" or \"\")\n\nTrackManager.UpdateIdentities(blePositions map[deviceMAC]Vec3, registry BLERegistry) method: runs the matching algorithm and updates track identity fields.\n\nCalled at the same frequency as FusionEngine (10 Hz), but BLE positions only update at 5s intervals — cache the last BLE positions and re-use them between BLE updates.\n\n## API\n\nGET /api/tracks enriches the existing track list with person_id, person_label, person_color, and identity_confidence fields.\n\nExample response:\n[{\n \"id\": \"track-1\",\n \"position\": {\"x\": 3.2, \"y\": 1.8, \"z\": 1.0},\n \"velocity\": {\"x\": 0.1, \"y\": -0.2, \"z\": 0.0},\n \"confidence\": 0.87,\n \"posture_hint\": \"standing\",\n \"person_id\": \"uuid-alice\",\n \"person_label\": \"Alice\",\n \"person_color\": \"#3b82f6\",\n \"identity_confidence\": 0.82\n}]\n\n## Dashboard Integration\n\nIn the 3D view (Three.js scene): tracks with confirmed identity display a floating text label (person name) above the humanoid figure mesh, rendered using THREE.Sprite with a canvas texture containing the name in the person's colour.\n\nIn the \"People and Devices\" panel: each person row shows \"Currently: Kitchen\" (current zone from room transition portals, Phase 6) and \"In 3D view: [jump to track]\" link.\n\n## Tests\n\n- Test RSSI-to-distance conversion with known values: RSSI=-65 -> d=1.0m, RSSI=-75 -> d=2.5m (with default params)\n- Test triangulation with 3 nodes at known positions and known distances: verify position error < 0.5m\n- Test nearest-blob assignment: 2 blobs at (2,2) and (5,5), BLE device triangulated at (2.3,1.9) -> assigns to first blob\n- Test confidence gate: confidence=0.55 -> no assignment; confidence=0.65 -> assignment made\n- Test BLE-only placeholder track creation when no blob within 2m\n- Test identity persistence: after BLE device disappears, track retains identity for 5 minutes then reverts\n- Test identity handoff when BLE device MAC rotates: same person assignment maintained via person record\n\n## Acceptance Criteria\n\n- Named identity labels appear on 3D blobs when person carries a labelled BLE device with confidence > 0.6\n- Identity is maintained through brief BLE dead zones (up to 5 minutes)\n- Two people in the same room get correct distinct identities when their BLE devices are distinguishable\n- Confidence gate prevents wrong assignments (no identity label when confidence < 0.6)\n- BLE-only placeholder tracks appear for people whose BLE device is heard but no CSI blob is nearby\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"echo","created_at":"2026-03-28T01:44:50.795871765Z","created_by":"coding","updated_at":"2026-04-07T20:36:11.407019373Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:52"]} {"id":"spaxel-o0e","title":"Fresnel zone debug overlay","description":"## Background\n\nThe Fresnel zone geometry is at the heart of the fusion algorithm (spaxel-m9a). Every link's contribution to the 3D occupancy grid is weighted by how much of the candidate voxel falls within the first Fresnel zone ellipsoid. When debugging why localisation is placing a blob in the wrong position, being able to see the actual Fresnel ellipsoids for each active link — overlaid on the 3D scene — instantly reveals whether the zones are covering the right area.\n\nThis is a pure debug/developer tool. It is toggle-able (off by default) and intended for system tuners and developers, not end users. The explainability overlay (spaxel-ez4) already shows Fresnel ellipsoids for a specific selected blob. The Fresnel zone debug overlay shows them persistently for all active links simultaneously.\n\n## Ellipsoid Geometry (Recap)\n\nFor each active link with TX at position P1 and RX at position P2:\n- Link distance d = |P1 - P2|\n- WiFi channel wavelength lambda: 5 GHz -> lambda = 0.06m, 2.4 GHz -> lambda = 0.125m. Use the channel reported in the node's hello message (or the channel from the link's last received CSI frame header).\n- Semi-major axis: a = (d + lambda/2) / 2\n- Semi-minor axis: b = sqrt(a^2 - (d/2)^2)\n- Ellipsoid centre: midpoint(P1, P2)\n- Ellipsoid orientation: major axis along the P1->P2 unit vector\n\nThe first Fresnel zone ellipsoid represents the region where a point reflector would shorten the signal path by at most half a wavelength relative to the direct path. Motion within this region has maximum impact on CSI.\n\n## Three.js Rendering\n\nEllipsoid mesh construction:\n1. Start with THREE.SphereGeometry(1, 32, 16) — a unit sphere\n2. Apply non-uniform scaling: new THREE.Vector3(a, b, b) via mesh.scale.set(a, b, b)\n3. Rotate to align with the link axis: compute the quaternion that maps (1,0,0) to the P1->P2 unit vector using THREE.Quaternion.setFromUnitVectors(new THREE.Vector3(1,0,0), linkAxis)\n4. Apply the quaternion to mesh.quaternion\n\nMaterial: TWO materials:\n- Wireframe: THREE.LineSegments with EdgesGeometry for crisp wireframe edges, line color matching the link line colour, opacity 0.6\n- Fill: THREE.MeshBasicMaterial with transparent=true, opacity 0.08, colour matching the link, depthWrite=false (so the fill doesn't obscure other geometry)\n\nLayer toggle: add \"Fresnel Zones\" checkbox to the 3D layer control panel (in the \"Debug\" section, only visible when expert mode is active). Default: off. When toggled on, add all ellipsoid meshes to the scene. When toggled off, remove them.\n\n## Per-Link Controls\n\nWhen a user hovers over a Fresnel ellipsoid in the 3D scene (using Three.js raycasting):\n- The corresponding link line highlights (brightness increase)\n- A tooltip appears (HTML overlay, positioned at screen coordinates of the hover point):\n \"Link: [tx_label] to [rx_label]\n Fresnel zone radius at midpoint: {b:.2f}m\n Link distance: {d:.2f}m\n Wavelength: {lambda:.3f}m (channel {ch})\n Link health: {health_score:.0%}\"\n\nClicking an ellipsoid:\n- Selects the corresponding link in the link panel (sidebar)\n- Highlights the link entry in the link list\n\n## Performance Considerations\n\nWith 6 active links, we render 6 pairs of meshes (wireframe + fill = 12 Three.js objects). This is negligible for any modern GPU. However, the wireframe geometry uses EdgesGeometry which creates one Line for each edge — for a sphere with 32 horizontal and 16 vertical segments, that's approximately 1000 line segments per ellipsoid. At 6 links, 6000 line segments total. This should render at 60 fps on any modern device, but if performance is an issue on mobile, reduce the sphere segment count to 16x8 when the debug overlay is active on mobile viewports.\n\nPre-compute at link addition time: when a new link is registered (node hello + peer MAC), compute the ellipsoid geometry and add it to the scene (hidden if the layer is off). Update on node position change. Remove when link becomes inactive (no frames for > 30s).\n\n## Relationship to Explainability Overlay\n\nThe Fresnel zone debug overlay (this bead) and the detection explainability overlay (spaxel-ez4) both render Fresnel ellipsoids. They share the same geometry computation code (dashboard/js/fresnel.js — the ellipsoid helper function). The difference:\n- This overlay: shows all active link ellipsoids simultaneously, toggle-able layer\n- Explainability overlay: shows only contributing link ellipsoids for a specific selected blob, in explain mode\n\nBoth import from fresnel.js. The helper function FresnelEllipsoid(P1, P2, lambda) returns a Three.js Mesh ready for scene insertion.\n\n## Files to Create or Modify\n\n- dashboard/js/fresnel.js: FresnelEllipsoid helper function (shared with explainability)\n- dashboard/js/layers.js: add \"Fresnel Zones\" toggle in the debug layer section\n- dashboard/js/app.js: integrate Fresnel overlay management — create/update/remove ellipsoids on link events\n\n## Tests\n\n- Test ellipsoid geometry computation with known TX/RX positions: TX at (0,0,0), RX at (4,0,0), lambda=0.06m -> a ~= 2.015, b ~= 0.345. Verify to 3 decimal places.\n- Test semi-minor axis for edge case: very short link d=0.1m -> b should be very small but positive\n- Test for diagonal link: TX at (0,0,0), RX at (3,4,0) (distance=5m) -> verify a and b are computed correctly\n- Test that toggling the layer on/off adds/removes the correct number of mesh objects from the scene (mock Three.js scene)\n- Test hover tooltip shows correct data (link health from mock, link endpoints from mock)\n- Test that ellipsoids update when node position changes\n\n## Acceptance Criteria\n\n- Fresnel zone ellipsoids render correctly for all active links when the debug layer is toggled on\n- Ellipsoid semi-major and semi-minor axes match theoretical first Fresnel zone values for the link distance and frequency\n- Toggle shows/hides all ellipsoids cleanly without leaving orphan objects in the scene\n- Hovering an ellipsoid shows the correct tooltip with link details and health score\n- Clicking an ellipsoid selects the corresponding link in the link panel\n- Geometry computation is shared with the explainability overlay via fresnel.js\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:58:33.424914116Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.736776003Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-o0e","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.736620594Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -105,7 +111,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-09T14:41:29.877713204Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:78"]} +{"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-09T14:56:34.490882317Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:80"]} {"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"]} @@ -119,7 +125,7 @@ {"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"]} -{"id":"spaxel-sl2","title":"Phase 8: Analysis & Developer Tools","description":"Goal: Deep debugging, system tuning, detection explainability.\n\nDeliverables:\n- Activity timeline (universal event stream, tap-to-jump, inline feedback)\n- Detection explainability (X-ray overlay, contributing links, confidence breakdown)\n- Time-travel debugging (pause live, scrub timeline, replay 3D from recorded CSI)\n- Pre-deployment simulator (virtual space + nodes + synthetic walkers, GDOP overlay)\n- CSI simulator Go CLI (virtual nodes, synthetic CSI binary frames, for dev/testing)\n- Fresnel zone debug overlay (wireframe ellipsoids between active links)\n\nExit criteria: Time-travel replays 24h of data. Simulator produces realistic synthetic data.","status":"open","priority":3,"issue_type":"phase","created_at":"2026-03-27T01:55:47.111916358Z","created_by":"coding","updated_at":"2026-03-28T01:33:51.145143387Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T01:33:51.145107801Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-sl2","title":"Phase 8: Analysis & Developer Tools","description":"Goal: Deep debugging, system tuning, detection explainability.\n\nDeliverables:\n- Activity timeline (universal event stream, tap-to-jump, inline feedback)\n- Detection explainability (X-ray overlay, contributing links, confidence breakdown)\n- Time-travel debugging (pause live, scrub timeline, replay 3D from recorded CSI)\n- Pre-deployment simulator (virtual space + nodes + synthetic walkers, GDOP overlay)\n- CSI simulator Go CLI (virtual nodes, synthetic CSI binary frames, for dev/testing)\n- Fresnel zone debug overlay (wireframe ellipsoids between active links)\n\nExit criteria: Time-travel replays 24h of data. Simulator produces realistic synthetic data.","status":"open","priority":3,"issue_type":"phase","created_at":"2026-03-27T01:55:47.111916358Z","created_by":"coding","updated_at":"2026-04-09T14:54:38.947171222Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-3ca","type":"blocks","created_at":"2026-04-09T14:54:38.771420677Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-5a3","type":"blocks","created_at":"2026-04-09T14:54:38.947095956Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-70i","type":"blocks","created_at":"2026-04-09T14:54:38.897281189Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-8ke","type":"blocks","created_at":"2026-04-09T14:54:38.637243667Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-d41","type":"blocks","created_at":"2026-04-09T14:54:38.841330220Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T01:33:51.145107801Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-nhl","type":"blocks","created_at":"2026-04-09T14:54:38.714247271Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-sty","title":"CSI simulator Go CLI","description":"## Background\n\nThe pre-deployment simulator (spaxel-btj) provides a browser-based spatial planning tool. The CSI simulator CLI is a complementary developer tool: a standalone Go command-line program that opens WebSocket connections to a running mothership as virtual nodes and injects synthetic CSI binary frames. This enables automated integration testing, performance benchmarking, and algorithm development entirely without ESP32 hardware.\n\nThe critical difference: the pre-deployment simulator generates CSI on the server side (using the simulation API). The CLI simulator generates CSI externally and connects via the standard node WebSocket interface — testing the full network stack and protocol, not just the signal processing.\n\n## CLI Implementation\n\nNew Go command: mothership/cmd/sim/main.go\n\nUsage examples:\n sim --mothership localhost:8080 --nodes 4 --walkers 2 --rate 20 --duration 60\n sim --mothership localhost:8080 --token abc123def456 --nodes 2 --walkers 1 --seed 42\n sim --space \"6x4x2.5\" --nodes 4 --walkers 3 --rate 50 --duration 120 --ble\n sim --verify --mothership localhost:8080 --nodes 2 --walkers 1 --duration 10\n\nCLI Flags:\n- --mothership: URL of the mothership (default: ws://localhost:8080/ws)\n- --token: provision token. If not specified, automatically provisions via POST /api/provision with synthetic credentials.\n- --nodes: number of virtual nodes (default 2). Each node opens a separate WebSocket connection.\n- --walkers: number of synthetic walkers (default 1).\n- --rate: CSI transmission rate in Hz per node pair (default 20).\n- --duration: total run time in seconds (default 60). \"0\" means run until Ctrl+C.\n- --seed: random seed for reproducible walker paths (default: random seed, logged at startup).\n- --space: room dimensions in \"WxDxH\" format (default \"5x5x2.5\").\n- --ble: include synthetic BLE advertisements (one BLE device per walker, with stable random MAC).\n- --verify: after --duration seconds, verify that the mothership produced the expected number of blobs. Exit 0 on success, 1 on failure.\n- --noise-sigma: Gaussian noise standard deviation for I/Q generation (default 0.005).\n- --wall: add a wall as \"x1,y1,x2,y2\" (can be repeated). Walls affect the path loss model.\n- --output-csv: write synthetic ground truth (walker positions + link deltaRMS) to a CSV file for offline analysis.\n\n## Synthetic CSI Frame Generation\n\nFor each virtual node pair (TX, RX) and each walker at each timestep:\n\n1. Compute RSSI from path loss model (same as simulator physics, mothership/internal/simulator/physics.go — reuse this package).\n\n2. Compute deltaRMS from Fresnel zone overlap (same physics model).\n\n3. Generate binary CSI frame matching the Phase 1 protocol format:\n Header (24 bytes):\n - Magic: 0xABCDEF01 (4 bytes)\n - Version: 1 (1 byte)\n - Node MAC: 6 bytes (synthetic, consistent per virtual node)\n - Peer MAC: 6 bytes (TX node's MAC for RX-side frames)\n - Channel: 6 (2.4GHz ch 6) or 36 (5GHz) — configurable\n - RSSI: 1 byte (signed, from path loss calculation)\n - Num subcarriers: 64 (1 byte)\n - Timestamp_us: 8 bytes (current Unix microseconds)\n\n Payload (128 bytes = 64 subcarriers * 2 bytes each):\n - For each subcarrier i: I = amplitude * cos(phase_i) + noise, Q = amplitude * sin(phase_i) + noise\n - amplitude: from Fresnel zone deltaRMS model\n - phase_i: phase_base + i * phase_step + phase_noise (simulate subcarrier phase variation)\n - noise: gaussian(sigma=--noise-sigma)\n - Values are int8 (clamped to [-127, 127])\n\nThe frame format is validated against the actual firmware output by comparing to real recorded frames in docs/research/reference_frames.bin (if available).\n\n## Connection Protocol\n\nEach virtual node:\n1. Opens WebSocket to ws://{mothership}/ws\n2. Sends hello message: {\"type\":\"hello\",\"mac\":\"{virtual_mac}\",\"firmware_version\":\"sim-1.0.0\",\"capabilities\":{\"can_tx\":true,\"can_rx\":true},\"free_heap\":200000,\"wifi_rssi\":-45,\"ip_addr\":\"127.0.0.{n}\"}\n3. Waits for role push from mothership (expects {\"type\":\"role\",\"role\":N})\n4. If receives {\"type\":\"reject\"}: logs error and exits with code 2\n5. Begins sending CSI frames at the configured rate using the binary WebSocket message format (not JSON)\n6. Also sends health messages every 10s: {\"type\":\"health\",\"heap\":200000,\"rssi\":-45,\"uptime_s\":N}\n7. If --ble: sends BLE relay messages every 5s: {\"type\":\"ble\",\"devices\":[{\"mac\":\"...\",\"name\":\"sim-person-1\",\"rssi\":-60}]}\n\n## Verification Mode (--verify)\n\nAfter --duration seconds:\n1. Stop sending CSI frames\n2. Wait 2 seconds for pipeline to settle\n3. GET {mothership}/api/blobs — gets current list of active blobs\n4. Assert: blob_count == walker_count (within ±1 tolerance)\n5. If all walkers are within the room bounds: assert all walkers have a blob within 2m distance\n6. Print: \"PASS: {blob_count} blobs detected for {walker_count} walkers\" or \"FAIL: expected N blobs, got M\"\n7. Exit 0 (PASS) or 1 (FAIL)\n\nThis is the CI smoke test that verifies the full pipeline end-to-end without hardware.\n\n## CI Integration\n\nAdd a CI step to the mothership GitHub Actions workflow (if one exists, or document the command):\n1. Start mothership with test config (in-memory SQLite, no recording)\n2. Run: sim --verify --nodes 2 --walkers 1 --duration 10 --seed 42\n3. Assert exit code 0\n\nThis becomes the primary end-to-end integration test. If the sim fails to produce blobs, something in the pipeline is broken.\n\n## Performance Testing\n\nThe simulator supports high-throughput testing:\n- sim --nodes 16 --walkers 4 --rate 50 --duration 60: measures mothership throughput at 16 nodes * 50 Hz = 800 frames/second\n- The simulator prints throughput statistics at the end: frames sent, frames per second, CPU time\n- Use for benchmarking and profiling the mothership processing pipeline\n\n## Files to Create\n\n- mothership/cmd/sim/main.go: CLI entry point with all flags\n- mothership/cmd/sim/generator.go: synthetic CSI frame generator\n- mothership/cmd/sim/walker.go: synthetic walker movement simulation\n- mothership/cmd/sim/verify.go: blob count verification logic\n- mothership/internal/simulator/physics.go: reuse from pre-deployment simulator (shared package)\n\n## Tests\n\n- Test that generated frames have the correct binary header format (magic, version bytes in correct positions)\n- Test that RSSI value is within plausible range for the given walker distance (e.g. walker at 2m, wall_attenuation=0 -> RSSI in [-50, -70])\n- Test that generated I/Q values are clamped to int8 range [-127, 127]\n- Test hello message format matches what the mothership ingestion server expects (parsed successfully)\n- Test that --verify correctly detects missing blobs (inject 1 walker, mock mothership returns 0 blobs -> FAIL)\n- Test --seed 42 produces identical walker paths across two runs (reproducibility)\n- Test --output-csv generates a CSV with correct headers and ground truth positions\n\n## Acceptance Criteria\n\n- CLI connects and streams synthetic CSI to a running mothership\n- Mothership blob count equals walker count (within ±1) when --verify is used\n- CLI exits cleanly after --duration seconds\n- --verify returns exit code 0 when blobs match, exit code 1 when they don't\n- Works correctly in a CI environment without hardware\n- High-throughput test (16 nodes, 50 Hz) completes without mothership errors or OOM\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:57:48.145516684Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.669157389Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-sty","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.669138899Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-tgj","title":"Home automation integration (MQTT and webhooks)","description":"## Background\n\nMany home automation systems — Home Assistant, OpenHAB, Node-RED, Domoticz — use MQTT as their primary integration backbone. By publishing spaxel state as MQTT topics with Home Assistant auto-discovery payloads, spaxel becomes a first-class presence sensor in any HA setup. No custom integration code is needed: the entities appear automatically in Home Assistant's entity registry simply by being powered on and having MQTT configured. This makes spaxel accessible to millions of home automation users who already have HA running.\n\n## MQTT Client\n\nOptional MQTT client in mothership/internal/mqtt/client.go.\n\nThe client is optional — if MQTT is not configured, the module is a no-op. Configuration via dashboard Settings or mothership config file:\n- broker_url: e.g. \"tcp://homeassistant.local:1883\" or \"mqtt://192.168.1.100:1883\"\n- username, password (optional)\n- tls: true/false\n- client_id: \"spaxel-{mothership_id}\" (unique per mothership instance)\n- retain: true (retained messages for presence and occupancy topics)\n\nUse the github.com/eclipse/paho.mqtt.golang library (the de-facto standard Go MQTT client).\n\nReconnect policy: exponential backoff from 5s to 120s, indefinite retry. Log reconnect attempts.\n\n## MQTT Topic Structure\n\nAll topics are prefixed with \"spaxel/{mothership_id}/\" where mothership_id is the unique ID from the mothership config.\n\nPerson presence:\n- Topic: spaxel/{mothership_id}/person/{person_id}/presence\n- Payload: \"home\" or \"not_home\" (plain string, retained)\n- Updated when: a person's first labelled BLE device is seen (home) or all their BLE devices have been absent for > 15 minutes (not_home)\n\nZone occupancy:\n- Topic: spaxel/{mothership_id}/zone/{zone_id}/occupancy\n- Payload: integer count (plain string, retained, e.g. \"2\")\n- Topic: spaxel/{mothership_id}/zone/{zone_id}/occupants\n- Payload: JSON array of person names (retained, e.g. [\"Alice\",\"Bob\"])\n- Updated on every zone occupancy change\n\nFall detection:\n- Topic: spaxel/{mothership_id}/fall_detected\n- Payload: JSON {\"person_id\":\"...\",\"person_label\":\"Alice\",\"zone_id\":\"...\",\"zone_name\":\"Hallway\",\"timestamp\":\"2026-03-27T14:23:00Z\"}\n- Not retained (event topic)\n\nSystem health:\n- Topic: spaxel/{mothership_id}/system/health\n- Payload: JSON {\"node_count\":4,\"online_count\":4,\"detection_quality\":0.87,\"mode\":\"home\"}\n- Retained, updated every 60s\n\n## Home Assistant Auto-Discovery\n\nOn initial MQTT connection (and on reconnect), publish HA auto-discovery payloads to the homeassistant/ prefix topics. HA processes these automatically and adds the entities to its registry.\n\nPer person: binary_sensor (presence)\nDiscovery topic: homeassistant/binary_sensor/spaxel_{mothership_id}_{person_id}/config\nPayload (JSON, retained):\n{\n \"name\": \"Alice Presence\",\n \"unique_id\": \"spaxel_{mothership_id}_{person_id}_presence\",\n \"state_topic\": \"spaxel/{mothership_id}/person/{person_id}/presence\",\n \"payload_on\": \"home\",\n \"payload_off\": \"not_home\",\n \"device_class\": \"presence\",\n \"device\": {\n \"identifiers\": [\"spaxel_{mothership_id}\"],\n \"name\": \"Spaxel\",\n \"model\": \"Spaxel Presence System\",\n \"manufacturer\": \"Spaxel\"\n }\n}\n\nPer zone: sensor (occupancy count) + binary_sensor (zone occupied)\nDiscovery topic: homeassistant/sensor/spaxel_{mothership_id}_{zone_id}_occupancy/config\nPayload: {name, unique_id, state_topic, unit_of_measurement: \"people\", device_class: null, device: ...}\n\nFall detection: binary_sensor with device_class: \"safety\"\nDiscovery topic: homeassistant/binary_sensor/spaxel_{mothership_id}_fall/config\n\nSystem mode: select (home/away/sleep) or input_select via MQTT\nDiscovery + command topics so HA can set system mode.\n\nWhen a person or zone is deleted, publish an empty payload to the corresponding discovery topic (HA treats this as \"remove entity\").\n\n## Event Publishing\n\nThe MQTT client subscribes to the internal event bus and publishes:\n- ZoneCrossingEvent -> update zone occupancy topics\n- PersonPresenceChangeEvent -> update person presence topic\n- FallEvent -> publish to fall_detected topic\n- SystemHealthEvent -> publish to system health topic (60s interval)\n\n## Generic Webhook Integration\n\nA complementary feature: a system-level webhook that delivers ALL spaxel events to a single user-configured URL, not just user-configured automations. This is for integrations that want to receive the full event stream (e.g. a custom backend, a logging service, or a complex HA automation that can't be expressed in the automation builder).\n\nPOST /api/settings/system-webhook: set URL\nAll internal events are serialised as JSON and POST'd to this URL with event type in the X-Spaxel-Event header.\nSame retry policy as automation webhooks (one retry after 30s on 5xx).\n\n## Dashboard Settings\n\nSettings panel -> \"Integrations\" tab:\n- MQTT section: broker URL, username, password (masked), TLS toggle, \"Test Connection\" button, \"Publish discovery payloads now\" button\n- System webhook section: URL input, \"Test\" button, recent delivery log (last 10 events with status)\n- Status indicator: green dot if MQTT connected, red if disconnected with last error message\n\n## Tests\n\n- Test MQTT connection to a local broker (use go-mqtt-broker test dependency or a Docker Eclipse Mosquitto in CI)\n- Test auto-discovery payload format against HA MQTT auto-discovery spec (compare JSON structure)\n- Test retained messages: verify that on reconnect, retained messages are not re-published if unchanged\n- Test that fall_detected topic is published on FallEvent\n- Test that zone occupancy topics update correctly on ZoneCrossingEvent\n- Test auto-discovery cleanup: deleting a person sends empty payload to discovery topic\n- Test system webhook delivers all event types to a mock HTTP server\n- Test reconnect policy: simulate broker disconnect, verify client reconnects with backoff\n\n## Acceptance Criteria\n\n- Spaxel entities appear automatically in Home Assistant entity list after MQTT config without any HA config editing\n- Person presence binary_sensor in HA updates within 30 seconds of BLE presence change\n- Zone occupancy sensor in HA reflects correct integer count\n- Fall detection binary_sensor fires and resets correctly\n- System mode select allows bidirectional control from HA (HA can set spaxel mode via MQTT)\n- System webhook receives all event types\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:49:02.414667027Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.412561189Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.412533268Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:49:05.719934686Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-03-28T01:49:05.665936722Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:49:05.694421101Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-tig","title":"Guided troubleshooting (enhanced for production)","description":"## Background\n\nThe Phase 4 guided troubleshooting bead (spaxel-r0l) covers onboarding failures and basic node offline recovery. This Phase 9 bead extends it with production-quality proactive assistance that emerges from the system's own understanding of its state: detection quality degradation warnings with root-cause analysis, post-feedback explanations that close the loop for curious users, feature discovery notifications that teach users about new capabilities as they become available, and a searchable help system for self-service support.\n\nThe philosophy: the system should feel like a knowledgeable, helpful companion that notices problems before you do and explains them in terms you understand.\n\n## 1. Proactive Quality Prompts\n\nTrigger: when ambient confidence score (Phase 5, spaxel-sbi) drops below 0.6 on any link for more than 5 consecutive minutes.\n\nAction: show a non-blocking, dismissible prompt card in the dashboard:\n\"Detection quality has dropped on [Node A] to [Node B]. This link is at [N]% reliability.\"\n\nThe card includes:\n- A \"Diagnose\" button that runs the link weather diagnostics (Phase 5, spaxel-32o) and shows the results in a slide-out panel.\n- \"Dismiss for today\" option (hides until tomorrow or until quality recovers and drops again)\n- The 3D link line is highlighted (pulsing amber) while the prompt is active\n\nThe link weather diagnostic result is shown in plain English:\n- \"Possible cause: new furniture may be blocking the sensing zone. [See where to move the node]\"\n- \"Possible cause: WiFi congestion from neighbouring networks. [What to do about this]\"\n- \"Cause unknown — system is adapting. [No action needed]\"\n\nDo NOT show this prompt if the quality drop is temporary (< 5 minutes). Many quality drops are transient (microwave oven use, brief WiFi congestion) and showing a prompt for every one would train the user to ignore them.\n\n## 2. Repeated-Setting Change Detection\n\nTrigger: if a user changes the same configuration setting (motion threshold, baseline tau, etc.) more than 3 times within a 24-hour period.\n\nThis is a signal that the user is struggling to find the right value. The system should proactively offer help rather than silently watch the user thrash.\n\nAction: show a non-intrusive prompt: \"Looks like you're fine-tuning the motion threshold. Would you like help finding the right value for your space?\"\n\nThe \"Help me tune this\" button opens a guided calibration flow:\n1. \"Set threshold too low?\" -> walk around room, system shows how many false positives at the current setting\n2. \"Set threshold too high?\" -> sit still, system shows if motion is being missed\n3. Suggests optimal value based on the diurnal baseline SNR and link health\n4. \"Apply suggested value: [N]\" button\n\nTrack repeated changes in localStorage: {setting_name: {count, last_change_timestamp}} for the last 24h.\n\n## 3. Post-Feedback Explanations\n\nWhen a user marks a detection event as a false positive (FALSE_POSITIVE feedback from spaxel-3ps), show an explanation:\n\n\"The system detected motion here because: [link A to link B]'s signal (deltaRMS) exceeded the motion threshold by [N]x at [time]. This could be caused by: [root cause from diagnostic rules, if any match, or 'ambient RF interference' as default]. We've noted this and will apply a correction.\"\n\nThe explanation uses the ExplainabilitySnapshot for the event (spaxel-ez4) to identify the contributing link, and runs a quick diagnostic check (spaxel-32o rule engine) on the link's health history at the event timestamp.\n\nShow as an expandable section (\"Why did this happen?\") on the feedback confirmation card, not as a separate notification.\n\n## 4. Feature Discovery Notifications\n\nWhen a feature becomes available for the first time (requires a one-time condition to be met), fire a single non-blocking notification:\n\nEvents and their messages:\n- DiurnalBaselineActivated (after 7 days of data, Phase 5): \"Your system has learned your home's daily patterns. Detection accuracy should improve starting today.\"\n- FirstSleepSessionComplete (first sleep monitored, Phase 7): \"Your first sleep session was tracked overnight. Tap to see your sleep summary.\"\n- WeightUpdateApproved (first self-improving weight update, Phase 7): \"Localisation accuracy improved based on your BLE device positions. Median position error decreased.\"\n- AutomationFirstFired (first automation triggered, Phase 6): \"Your first automation just ran! [View in automations log]\"\n- PredictionModelReady (7 days per person, Phase 7): \"Presence predictions are now available for [person name]. [View predictions]\"\n\nEach notification:\n- Keyed by a unique event ID stored in SQLite: feature_notifications (event_id TEXT PRIMARY KEY, fired_at DATETIME, acknowledged_at DATETIME)\n- Never fires twice (primary key ensures uniqueness)\n- Dismissed by tapping (marks acknowledged_at)\n- Does not fire during quiet hours (uses notification manager quiet hours logic)\n\n## 5. Contextual Help System\n\nA \"?\" button in the expert mode header opens a searchable help overlay. The overlay contains:\n- A search input (same fuzzy search as command palette, Phase 9 spaxel-tvq)\n- A list of help articles (approximately 30 covering all major features)\n- Each article: title, 1-3 sentence plain-English explanation, link to a relevant dashboard section or action\n\nSample articles:\n- \"What is a sensing link?\" — \"A sensing link is the path between two spaxel nodes (one transmitting, one receiving). Motion in the space between them changes the signal, which spaxel detects.\"\n- \"Why is my detection quality low?\" — \"Low quality usually means interference from other WiFi devices, an obstacle in the sensing zone, or the node is too far from your router. Click 'Diagnose' on the link to find the specific cause.\"\n- \"How does presence prediction work?\" — \"After 7 days, spaxel learns when people are typically in each room and predicts where they'll be next. Predictions appear in the Predictions panel.\"\n- \"What is the Fresnel zone?\" — \"The Fresnel zone is the region in space where motion has the most impact on the signal between two nodes. Spaxel uses this geometry to estimate where in the room a person is.\"\n\nArticles are stored as a static JSON file (dashboard/help_articles.json, 30-50 articles). No server round-trip needed for help.\n\n## Implementation Structure\n\n- mothership/internal/help/notifier.go: feature discovery notification manager, persistence\n- dashboard/js/proactive.js: quality prompt, repeated-setting detector, post-feedback explanation\n- dashboard/js/help.js: help overlay, fuzzy search on articles, article rendering\n- dashboard/help_articles.json: all help article content\n- mothership/internal/diagnostics/linkweather.go: add GetDiagnosticFor(linkID, timestamp) method for post-feedback use\n\n## Tests\n\n- Test quality prompt trigger: mock confidence score = 0.55 for 6 minutes, verify prompt appears; confidence = 0.55 for 3 minutes, verify no prompt\n- Test repeated-setting detection: mock 4 threshold changes in 20h, verify help prompt fires\n- Test post-feedback explanation: inject FALSE_POSITIVE feedback on an event with a known ExplainabilitySnapshot, verify explanation text contains the contributing link name\n- Test feature discovery: inject DiurnalBaselineActivated event, verify notification fires once; inject again, verify no second notification\n- Test help search: query \"fresnel\" -> \"What is the Fresnel zone?\" appears in results\n- Test dismissed prompts don't re-appear (localStorage + SQLite persistence)\n\n## Acceptance Criteria\n\n- Quality prompt appears within 5 minutes of confidence score dropping below 0.6 threshold\n- Prompt does NOT appear for transient drops shorter than 5 minutes\n- \"Diagnose\" in quality prompt shows root cause in plain language\n- Repeated-setting detection fires after 3+ changes in 24h\n- Post-feedback explanation shown after any FALSE_POSITIVE feedback submission\n- Feature discovery notifications fire exactly once per feature, in plain language\n- Help overlay opens with \"?\" button, search finds relevant articles\n- Dismissed prompts never re-appear (unless condition reoccurs after recovery)\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:04:15.981731936Z","created_by":"coding","updated_at":"2026-03-28T03:29:15.089323032Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-tig","depends_on_id":"spaxel-32o","type":"blocks","created_at":"2026-03-28T02:04:22.625685956Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tig","depends_on_id":"spaxel-sbi","type":"blocks","created_at":"2026-03-28T02:04:22.592483085Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tig","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:15.089304486Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 93888ed..1bc7910 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -849cfc9b501c56ded34ee11d66d1085e31736eed +9bef706de0a9b5c5d281760e1f8e8874a0599a45 diff --git a/dashboard/js/timeline.js b/dashboard/js/timeline.js index 2dfa599..a548894 100644 --- a/dashboard/js/timeline.js +++ b/dashboard/js/timeline.js @@ -376,6 +376,15 @@ }); }); + // Explainability button + eventEl.querySelectorAll('.timeline-explain-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + const blobId = btn.dataset.blobId; + handleExplainability(blobId, eventEl); + }); + }); + // Seek button eventEl.querySelectorAll('.timeline-seek-btn').forEach(function(btn) { btn.addEventListener('click', function(e) { @@ -437,8 +446,20 @@ const severityClass = event.severity === 'alert' || event.severity === 'critical' ? ' severity-critical' : ''; const newClass = isNew ? ' new-event' : ''; + // Check if this event has a blob_id for explainability + const hasBlobId = event.blob_id !== undefined && event.blob_id !== null && event.blob_id !== 0; + const explainabilityBtn = hasBlobId ? ` + + ` : ''; + return ` -
+
${info.icon}
@@ -453,6 +474,7 @@
+ ${explainabilityBtn}