diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 55e9f5c..3953d34 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -13,7 +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":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T14:54:38.737598265Z","created_by":"coding","updated_at":"2026-04-09T17:19:36.418251863Z","close_reason":"Time-travel debugging fully implemented: Pause live mode (Pause button in dashboard, pauseLiveMode() in replay.js), Timeline scrubbing (replay scrubber with seek API /api/replay/seek, ScanRange in store.go for time-based queries), Replay 3D from recorded CSI data (BroadcastReplayBlobs in hub.go, updateReplayBlobs in viz3d.js, fusion engine integration). 24h recording buffer: 360MB default in RecordingStore, configurable via maxMB parameter. REST API endpoints: /api/replay/start, /api/replay/stop, /api/replay/seek, /api/replay/tune, /api/replay/set-speed, /api/replay/set-state, GET /api/replay/sessions, GET /api/replay/session/{id}. Frontend: replay.js with timeline scrubber, playback controls, tuning panel. All three acceptance criteria met.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:18","mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} +{"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":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T14:54:38.737598265Z","created_by":"coding","updated_at":"2026-04-09T17:41:51.707526513Z","closed_at":"2026-04-09T17:41:51.707468066Z","close_reason":"Time-travel debugging implementation complete. All acceptance criteria met:\n- Pause live mode: Implemented in dashboard/js/replay.js with Pause Live button\n- Timeline scrubbing: Full scrubber UI with seek functionality \n- Replay 3D from recorded CSI: Viz3D integration with enterReplayMode/exitReplayMode/updateReplayBlobs\n- 24-hour replay: Recording buffer supports 48-hour retention (exceeds requirement)\n\nBackend (mothership/internal/api/replay.go, replay/worker.go):\n- REST API for session management (start, stop, seek, tune, set-speed, set-state)\n- Separate signal processing pipeline for replay\n- Blob broadcasting to dashboard\n\nFrontend (dashboard/js/replay.js):\n- Complete replay controls UI\n- Parameter tuning panel with instant preview\n- Timeline loop polling session state\n\n3D Visualization (dashboard/js/viz3d.js):\n- Stores/restores live blob states during replay transitions\n- Full 3D blob rendering from replay data\n\nVerification: Comprehensive test suite exists (replay_test.go) covering session lifecycle, multiple sessions, parameter tuning, and timestamp parsing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:20","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"]} @@ -66,6 +66,7 @@ {"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"]} {"id":"spaxel-fll","title":"Dashboard WebSocket: snapshot-on-connect + incremental update protocol","description":"## Overview\nImplement the snapshot+incremental WebSocket protocol so the dashboard renders immediately on connect without waiting for a full state cycle.\n\n## Protocol spec\n\n### On new /ws/dashboard connection (within 100 ms):\nSend a full snapshot message:\n {type: 'snapshot', blobs: [...], nodes: [...], zones: [...], links: [...], alerts: [...], ble_devices: [...], triggers: [...], timestamp_ms: N}\n\n### Subsequent messages (at 10 Hz):\nOmit type field; send only state that changed since last tick:\n {blobs: [...], nodes: [...], confidence: 0.87, timestamp_ms: N}\nUnchanged arrays may be omitted entirely (null = no change)\n\n## Implementation (mothership/internal/dashboard/hub.go)\n\n- Hub maintains lastSnapshot: full state snapshot updated on each tick\n- On new client connection: serialize lastSnapshot as JSON, send immediately\n- On each tick: compute delta (changed fields only); broadcast to all established clients\n- Snapshot must be sent before the client is added to the broadcast list to avoid race\n\n## Reconnect handling (dashboard/js/app.js)\n- On WebSocket open: set awaitingSnapshot = true\n- On first message: if type === 'snapshot', merge into app state and clear flag\n- On subsequent messages: apply as incremental updates\n\n## Performance requirement\n- Snapshot delivery: < 100 ms after connection established, even with 10+ blobs, 16+ nodes, 20+ zones\n- Test: connect client, measure time to first render; must be < 150 ms end-to-end\n\n## Acceptance\n- Browser devtools shows first WS message with type='snapshot' within 100 ms of upgrade\n- Subsequent messages at 10 Hz omit type field\n- Reconnect after 5s disconnection shows correct current state immediately","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:09:42.683611381Z","created_by":"coding","updated_at":"2026-04-07T02:03:04.204480908Z","closed_at":"2026-04-07T02:03:04.204253757Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} +{"id":"spaxel-fu9","title":"Implement Internal Event Bus","description":"Create mothership/internal/events/bus.go with EventBus pub-sub mechanism. Implement EventType enum (MotionDetected, ZoneTransition, FallDetected, NodeConnected, etc.) and typed EventPayload structs. Provide bus.Publish(EventType, EventPayload) and bus.Subscribe(EventType) returning a channel. Support multiple subscribers per event type with fan-out.\n\nAcceptance Criteria:\n- EventBus publishes events to all subscribers within 10ms\n- Multiple subscribers receive the same event\n- All defined event types have corresponding payload structs\n- Tests pass","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T17:50:34.844821307Z","created_by":"coding","updated_at":"2026-04-09T17:50:35.286915327Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} {"id":"spaxel-fyi","title":"Add trigger state messages to WebSocket feed","description":"Add 'trigger_state' message type to /ws/dashboard for automation trigger state changes. Broadcast: { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }. Handle in app.js onmessage.","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T14:18:27.606886433Z","created_by":"coding","updated_at":"2026-04-07T12:42:21.962246612Z","closed_at":"2026-04-07T12:42:21.961787747Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-g1o","title":"Anomaly detection: 7-day pattern learning algorithm","description":"## Overview\nImplement the statistical pattern learning engine for anomaly detection — per-zone, per-hour-of-day, per-day-of-week occupancy modeling using Welford's online algorithm.\n\n## Backend (mothership/analytics/ or signal/)\n- Pattern model: per (zone_id, hour_of_day, day_of_week): mean_count, variance, sample_count via Welford's algorithm\n- Hourly update goroutine: every hour, observe zone occupancy counts and update model\n- Cold start: suppress all anomaly alerts for 7 days; model slot 'ready' when sample_count >= 50\n- Anomaly scoring:\n - z_score = (observed_count - mean) / sqrt(variance + epsilon)\n - time_score = normalized z_score for this hour/day combo\n - zone_score = 1.0 if zone normally empty at this time, else 0.0\n - composite_score = max(time_score, zone_score) with fallback\n - threshold: alert if composite > 0.85; yellow warning at 0.60\n- Outlier protection: skip model update when anomaly_score >= 0.5 (don't learn from anomalies)\n- Security mode override: any detection = score 1.0 regardless of model\n- SQLite anomaly_patterns table: zone_id, hour_of_day (0-23), day_of_week (0-6), mean_count REAL, variance REAL, sample_count INT, updated_at INT\n\n## REST API\n- GET /api/anomalies?since=24h — list recent anomaly events with scores\n- GET /api/anomaly_patterns?zone= — inspect pattern model for debugging\n\n## Acceptance\n- Pattern model survives server restart (persisted to SQLite)\n- No alerts during 7-day cold start regardless of activity\n- Welford update is numerically stable: no NaN/Inf at any sample count\n- Outlier protection confirmed: injecting synthetic anomaly does not corrupt model after 3 occurrences\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:02:39.580201662Z","created_by":"coding","updated_at":"2026-04-07T01:28:23.140993262Z","closed_at":"2026-04-07T01:28:23.140700890Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-glq","title":"fix: apdetector imports wrong module prefix (jedarden vs spaxel)","description":"## Problem\n`internal/apdetector/detector.go:14` imports `github.com/jedarden/spaxel/mothership/internal/oui` but the module is `github.com/spaxel/mothership`.\n\n## Fix\nIn `mothership/internal/apdetector/detector.go` line 14, change:\n```go\n\"github.com/jedarden/spaxel/mothership/internal/oui\"\n```\nto:\n```go\n\"github.com/spaxel/mothership/internal/oui\"\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/apdetector/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:29:41.749357378Z","created_by":"coding","updated_at":"2026-04-06T22:32:46.587104774Z","closed_at":"2026-04-06T22:32:46.586900234Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} @@ -105,6 +106,7 @@ {"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":""}]} {"id":"spaxel-o4l","title":"Bidirectional node protocol","description":"Implement full bidirectional protocol over the existing WebSocket connection.\n\n## Deliverables\n- Registration (hello message with capabilities)\n- Health reporting (heap, WiFi RSSI, uptime, temperature every 10s)\n- BLE scan relay (device list JSON)\n- Role/config push from mothership (TX/RX/passive mode, packet rate)\n- OTA command messages (trigger update, progress tracking)\n- All messages over the existing single WebSocket per node\n\n## Acceptance Criteria\n- Nodes register on connect with full capability advertisement\n- Mothership can push role changes and config updates\n- Health metrics flow reliably at 10s intervals\n- Protocol is backward-compatible with Phase 1 implementation\n\n## References\n- Current protocol: mothership/internal/ingestion/message.go\n- Firmware WebSocket: firmware/main/websocket.c","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:31.632551776Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.644219477Z","closed_at":"2026-03-27T03:14:43.201850105Z","close_reason":"Implemented full bidirectional node protocol. Firmware: motion hints wired to websocket_send_motion_hint() with rate-limiting, csi_set_rate() fixed, all message types active (hello/health/ble/motion_hint/ota_status). Mothership: OnMotionHint() ramps adjacent nodes via topology callback, idle timeout 30s, variance threshold adaptive, added SendRoleToMAC() and SendOTAToMAC() for dynamic downstream pushes, OTA status logging. Binary CSI frames remain backward-compatible with Phase 1.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-o4l","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.644181123Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-ofa","title":"End-to-end integration test harness (simulator + mothership assertions)","description":"## Overview\nBuild an automated integration test that starts the mothership, runs the CSI simulator against it, and asserts on observable behavior — providing a production-realism gate for CI.\n\n## Test harness (tests/e2e/run.sh or Go test)\n\n### Setup:\n1. Start mothership container (or binary): docker run -d -p 8080:8080 ronaldraygun/spaxel:latest\n2. Wait for /healthz to return {status:'ok'} with 15s timeout (poll every 500ms)\n3. If PIN auth enabled: POST /api/auth/setup with test PIN; POST /api/auth/login\n\n### Run simulator:\n4. Start: sim --mothership http://localhost:8080 --nodes 4 --walkers 2 --duration 30s --rate 20 --ble --seed 42\n5. Simulator exits non-zero if it receives {type:'reject'} message; test fails immediately\n\n### Assert during run (poll every 1s for 30s):\n6. GET /api/blobs (or WebSocket) → assert blob_count > 0 within first 15s\n7. GET /api/nodes → assert nodes_online == 4 within first 5s\n8. GET /healthz → assert status=='ok' throughout entire run\n\n### Assert after run:\n9. GET /api/events?type=detection → assert at least 1 detection event recorded\n10. Simulator printed per-second frame counts to stdout; verify no frame-rate drop >20% from target\n\n## CI integration\n- GitHub Actions workflow: .github/workflows/e2e.yml (but only triggers from Argo Workflows via spaxel-ci)\n- Build image → run test harness → post result as bead comment\n\n## Acceptance\n- Test passes on fresh container with seed 42 configuration\n- Test fails clearly when mothership rejects frames (wrong protocol)\n- Test runs in <90s total (15s startup + 30s sim + 45s buffer)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:44:47.443177386Z","created_by":"coding","updated_at":"2026-04-07T20:01:09.701396700Z","closed_at":"2026-04-07T20:01:09.701265832Z","close_reason":"Implemented end-to-end integration test harness for Spaxel:\n\n1. Argo WorkflowTemplate spaxel-e2e-workflowtemplate.yml added to declarative-config for CI/CD integration\n\n2. Existing test infrastructure already in place:\n - Bash script harness: tests/e2e/run.sh\n - Go test harness: mothership/tests/e2e/e2e_test.go\n - CSI simulator: mothership/cmd/sim/main.go\n - GitHub Actions workflow: .github/workflows/e2e.yml\n\n3. Test harness behavior:\n - Starts mothership container (or local binary)\n - Waits for /healthz to return ok (15s timeout, 500ms poll)\n - Handles PIN auth setup if enabled\n - Runs simulator with configurable nodes/walkers/duration/rate\n - Asserts during run: health ok, nodes online, blobs detected\n - Asserts after run: detection events, frame rate\n - Runs in under 90s total\n\n4. CI integration via Argo Workflows spaxel-e2e template","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:19"]} +{"id":"spaxel-oqds","title":"Implement Expert vs Simple Mode","description":"Add mode switching for timeline panel. Expert mode shows all event types with system events as secondary (smaller, greyed). Simple mode shows only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd. Mode is set by dashboard mode and passed as ?mode=expert or ?mode=simple to API.\n\nAcceptance Criteria:\n- Expert mode displays all event types with system events secondary\n- Simple mode hides system events while showing person-relevant events\n- Mode parameter correctly filters API response\n- UI updates correctly when dashboard mode switches","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T17:50:35.157119499Z","created_by":"coding","updated_at":"2026-04-09T17:50:35.157119499Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} {"id":"spaxel-oql","title":"Firmware: NVS schema migration on boot","description":"## Overview\nImplement versioned NVS key migration on ESP32-S3 firmware so OTA-updated firmware gracefully handles NVS written by older versions.\n\n## Implementation (firmware/main/nvs_migration.c)\n- On boot, open 'spaxel' NVS namespace and read schema_ver (uint8); if missing, write schema_ver=1\n- If schema_ver < COMPILED_NVS_VERSION: run migration functions in order (v1→v2, v2→v3, etc.)\n- Each migration: add/rename/remove specific NVS keys; call nvs_commit() after each write\n- After all migrations: update schema_ver = COMPILED_NVS_VERSION and commit\n- Log each migration step to UART for debugging\n\n## Example migration v1→v2:\n- Rename 'ms_ip' to 'mothership_ip' (read old key, write new key, erase old key)\n- Add 'ntp_server' key with default value 'pool.ntp.org'\n\n## Acceptance\n- Flash firmware v1.0 with known NVS schema; flash v1.1 firmware; verify all keys present\n- Migration runs exactly once (schema_ver correctly incremented)\n- Migration failure leaves NVS in consistent state (tested via simulated write failure)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:42:15.874379750Z","created_by":"coding","updated_at":"2026-04-07T14:28:20.262035505Z","closed_at":"2026-04-07T14:28:20.261822946Z","close_reason":"Implemented NVS schema migration on boot for ESP32-S3 firmware. Added nvs_migration.c/h with migration framework that reads schema_ver from NVS, initializes to 1 if missing, and runs migrations sequentially when schema_ver < COMPILED_NVS_VERSION. Each migration commits after each write for durability. Example v1→v2 migration renames 'ms_ip' to 'mothership_ip' and adds 'ntp_server' with default 'pool.ntp.org'. All migration steps logged to UART for debugging. Migration failure leaves NVS in consistent state.","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-p5p","title":"Implement BLE Devices REST endpoints","description":"Implement GET /api/ble/devices to list known devices. Add PUT /api/ble/devices/{mac} to set label and assign to person. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.569849257Z","created_by":"coding","updated_at":"2026-04-07T13:37:18.640521533Z","closed_at":"2026-04-07T13:37:18.640340132Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-pel","title":"Dashboard presence indicator","description":"Add per-link motion detected/clear display to the dashboard.\n\n## Deliverables\n- WebSocket message from mothership to dashboard with per-link motion state\n- Visual indicator in dashboard UI: green = clear, red = motion detected per link\n- Amplitude time series plot for selected link (rolling window)\n- Update dashboard/js/app.js and mothership dashboard hub to broadcast motion state\n\n## Acceptance Criteria\n- Dashboard shows real-time motion/clear status for each active link\n- Amplitude time series updates smoothly\n- Works with the existing signal processing pipeline in mothership/internal/signal/\n\n## References\n- Dashboard code: dashboard/js/app.js, dashboard/index.html\n- Signal processing: mothership/internal/signal/processor.go (GetAllMotionStates)\n- Dashboard hub: mothership/internal/dashboard/hub.go","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:02.465235096Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.674201551Z","closed_at":"2026-03-27T02:55:53.328233821Z","close_reason":"Implemented per-link motion presence indicator: green CLEAR/red MOTION badge per link in dashboard, 60s rolling amplitude time series for selected link, immediate motion state broadcast from hub on idle<->motion transitions, fixed ampHistory init bug for JSON-created links.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-pel","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T01:34:05.674165567Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -113,8 +115,9 @@ {"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-q9d","title":"Add GDOP overlay","description":"Implement GDOP (Geometric Dilution of Precision) overlay for the simulator.\n\nAcceptance:\n- GDOP overlay visualizes accuracy metrics across the virtual space\n- Simulator produces realistic synthetic data matching real-world conditions","status":"in_progress","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T16:11:25.552156606Z","created_by":"coding","updated_at":"2026-04-09T17:15:38.354739705Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-d41"]} -{"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-q99z","title":"Add Tap-to-Jump Time-Travel","description":"Implement tap-to-jump coordination with time-travel replay module. When timeline event is clicked (expert mode), emit jump_to_time command with event timestamp. The time-travel player pauses live playback, seeks CSI recording buffer to timestamp, and begins replay. Highlight selected event and show Now replaying chip in timeline header.\n\nAcceptance Criteria:\n- Clicking event emits correct timestamp to time-travel player\n- 3D scene seeks to correct timestamp\n- Selected event highlights in timeline\n- Now replaying chip appears in timeline header\n- Tests pass","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T17:50:35.191487107Z","created_by":"coding","updated_at":"2026-04-09T17:50:35.191487107Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} +{"id":"spaxel-q9d","title":"Add GDOP overlay","description":"Implement GDOP (Geometric Dilution of Precision) overlay for the simulator.\n\nAcceptance:\n- GDOP overlay visualizes accuracy metrics across the virtual space\n- Simulator produces realistic synthetic data matching real-world conditions","status":"in_progress","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T16:11:25.552156606Z","created_by":"coding","updated_at":"2026-04-09T17:32:58.464028526Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-d41"]} +{"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-09T17:45:50.699344621Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:81"]} {"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"]} @@ -125,8 +128,9 @@ {"id":"spaxel-r37","title":"Stationary person detection via breathing analysis","description":"## Background\n\nStandard deltaRMS motion detection fires when someone physically moves. But a stationary person — reading, sleeping, watching TV — produces micro-motion from breathing: chest displacement of approximately 5mm at 0.1-0.5 Hz. This is well below the standard motion threshold but is detectable with careful bandpass filtering of the CSI signal. This is one of the hardest and most valuable features in the system. It transforms spaxel from a \"motion detector\" into a true \"presence detector\" that can tell you a sleeping baby is still breathing.\n\n## Physics of Breathing Detection in CSI\n\nWhen a person breathes, their chest moves approximately 5-10mm. This tiny displacement changes the path length of reflected wireless signals by up to 20mm (round trip). At 5 GHz (lambda ~= 0.06m), a 20mm path length change corresponds to a phase shift of 2*pi*0.02/0.06 ~= 2.1 radians — measurable in phase-sensitive CSI. In amplitude (IQ magnitude), the change is typically ~0.1% of the total amplitude — tiny but consistent.\n\nThe key is that breathing is periodic. A person breathes at 12-20 times per minute (0.2-0.33 Hz at rest, up to 0.5 Hz during mild activity). The CSI signal shows a weak periodic oscillation at this frequency. By taking the FFT of a 30-second window of deltaRMS samples, a sharp peak at the breathing frequency emerges from the noise floor.\n\nThe phase change cycles at TWICE the physical breathing rate because path length change cycles twice per breath cycle (chest goes out and comes back in, changing path length from +d to -d and back to +d). So a 15-breath/minute breathing rate (0.25 Hz) produces a CSI phase oscillation at 0.5 Hz, and we look for peaks at 0.2-1.0 Hz in the FFT spectrum.\n\n## BandpassDetector Implementation\n\nNew file: mothership/internal/signal/breathing.go\n\nBreathingDetector struct:\n- rollingBuffer []float64: circular buffer of deltaRMS samples, default 60 samples (30s at 2Hz adaptive rate)\n- bufferSize int: configurable, default 60\n- windowFn []float64: precomputed Hann window coefficients (reduces spectral leakage)\n- sampleRateHz float64: current sample rate (adaptive, from Phase 2 adaptive sensing rate bead)\n- minFreqHz float64: low end of breathing band (default 0.2 Hz)\n- maxFreqHz float64: high end of breathing band (default 1.0 Hz)\n- snrThreshold float64: minimum peak-to-noise ratio in dB to declare breathing (default 3 dB)\n\nMethods:\n- AddSample(deltaRMS float64): append to rolling buffer, overwrite oldest when full\n- Detect() BreathingResult: run FFT, find peak in breathing band, compute SNR, return result\n- BreathingResult: {IsBreathing bool, FrequencyHz float64, Confidence float64, PeakSNRdB float64}\n\nFFT implementation: use gonum.org/v1/gonum/dsp/fourier (already a project dependency from the UKF). Apply Hann window to buffer before FFT to reduce spectral leakage. FFT output is complex64 array; take abs to get amplitude spectrum. Bin resolution = sampleRateHz / bufferSize. For 2Hz * 60 samples: resolution = 0.033 Hz, which gives good separation of breathing harmonics.\n\nSNR computation: peak amplitude in [0.2, 1.0] Hz band divided by median amplitude of the full spectrum (median is robust to other peaks). SNR in dB = 20 * log10(peak/median). If SNR > snrThreshold, return IsBreathing=true.\n\n## Long-Dwell Logic\n\nEven without a breathing signal, a person who was detected in motion and then becomes still is likely still present for some time. Add a DwellTracker per link in the LinkProcessor (mothership/internal/signal/processor.go):\n\nDwell states:\n- CLEAR: no recent motion, no breathing signal\n- MOTION_DETECTED: current deltaRMS > motion threshold\n- POSSIBLY_PRESENT: was MOTION_DETECTED within last 10 seconds, now below threshold. Report as \"possibly present\" to fusion engine (lower weight).\n- STATIONARY_DETECTED: BreathingDetector reports IsBreathing=true. Report as \"stationary person\" with the breathing frequency.\n\nTransitions:\n- CLEAR -> MOTION_DETECTED: deltaRMS > motion threshold\n- MOTION_DETECTED -> POSSIBLY_PRESENT: deltaRMS < threshold for > 0.5s (debounce)\n- POSSIBLY_PRESENT -> MOTION_DETECTED: deltaRMS > threshold again\n- POSSIBLY_PRESENT -> STATIONARY_DETECTED: BreathingDetector fires\n- POSSIBLY_PRESENT -> CLEAR: 60 seconds without motion or breathing signal\n- STATIONARY_DETECTED -> POSSIBLY_PRESENT: BreathingDetector no longer fires\n- STATIONARY_DETECTED -> CLEAR: 120 seconds without motion or breathing signal (longer timeout because breathing detection is highly confident)\n\nThe dwell timer prevents premature \"CLEAR\" declarations for people sitting quietly, which is a common and highly frustrating false-negative.\n\n## Sensitivity Constraints\n\nBreathing detection only works reliably under these conditions:\n1. Direct line-of-sight (LoS) or single-reflection path between TX and RX — through-wall detection is too noisy\n2. The person is within the first Fresnel zone of the TX-RX link (see fusion bead)\n3. Link health score (ambient confidence bead) > 0.7 — low-confidence links produce too much noise\n4. No other people moving in the scene (other motion dominates the signal)\n5. Minimum duration: 15s of data before the first detection can fire (half the FFT window)\n\nThe system should gate breathing detection using the link health score from the ambient confidence bead. If health_score < 0.7, set BreathingDetector.enabled = false for that link.\n\n## Dashboard Integration\n\nAdd a \"Stationary person\" indicator to the dashboard link presence panel (distinct from the motion indicator):\n- Slow-pulsing blue dot (not the motion red/green) when STATIONARY_DETECTED state\n- Tooltip showing estimated breathing rate in breaths-per-minute (=frequencyHz * 60)\n- Timeline event logged: \"Stationary person detected on [link] at [time] — breathing at {N} bpm\"\n\nAdd to the link health WebSocket message (\"link_health\" type): breathing_state (\"CLEAR\"/\"POSSIBLY_PRESENT\"/\"MOTION_DETECTED\"/\"STATIONARY_DETECTED\"), breathing_freq_hz (null if not detected).\n\n## Tests\n\n- Test FFT output with synthetic breathing waveform: inject 60 samples of sin(2*pi*0.3*t) + noise (sigma=0.001) into BreathingDetector.AddSample(), verify Detect() returns IsBreathing=true, FrequencyHz ~= 0.3, SNR > 3 dB\n- Test that uniform random noise (no periodic component) does not trigger breathing detection (false positive rate < 5% across 1000 trials with sigma=0.001)\n- Test long-dwell timer transitions: MOTION_DETECTED -> POSSIBLY_PRESENT after 0.5s quiescence, POSSIBLY_PRESENT -> CLEAR after 60s, STATIONARY_DETECTED -> CLEAR after 120s\n- Test that BreathingDetector is disabled when health_score < 0.7\n- Test Hann window application produces expected output for a known input\n- Test that a breathing frequency outside the [0.2, 1.0] Hz band is not reported\n\n## Acceptance Criteria\n\n- Breathing detection fires for a stationary person in direct LoS with good link quality (health_score > 0.7) at SNR > 15 dB\n- False positive rate < 5% on an empty room with a high-quality link\n- Breathing frequency displayed in dashboard in breaths-per-minute (converted from FFT peak Hz)\n- Long-dwell logic prevents premature \"CLEAR\" declaration for a stationary person for at least 60 seconds after last motion\n- Breathing detection correctly gated off on low-health links\n- Dwell state transitions logged in activity timeline\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"charlie","created_at":"2026-03-28T01:40:45.831647006Z","created_by":"coding","updated_at":"2026-03-30T00:25:45.034604864Z","closed_at":"2026-03-30T00:25:45.034248272Z","close_reason":"Implemented stationary person detection via FFT-based breathing analysis. FFTBreathingDetector with 30s rolling buffer, DwellTracker state machine, health gating, dashboard integration with pulsing blue indicator, timeline event logging. All tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-r37","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:14.054454703Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-r37","depends_on_id":"spaxel-v9z","type":"blocks","created_at":"2026-03-28T01:40:48.996634547Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-r7t","title":"BLE address rotation detection & identity continuity","description":"## Overview\nHandle MAC address rotation in BLE devices (phones rotate every 15-30 min) to maintain continuous identity tracking.\n\n## Backend (mothership/ble/)\n- Rotation heuristics: manufacturer data fingerprinting, time+RSSI proximity, position continuity, merge confirmation\n- ble_device_aliases table: addr, canonical_addr, confidence, first_seen, last_seen\n- Alias matching in blob-to-device scoring: resolve rotated address to canonical identity\n- Graceful fallback: 5-min window before clearing identity when rotation is unresolved\n\n## Dashboard UI\n- Rotation icon indicator in BLE device registry\n- Manual merge/split UI: 'These look like the same device. Merge?' confirmation\n- Alias history expandable in device detail panel\n\n## Acceptance\n- Identity continuity across address rotation with >90% precision in test scenarios\n- No duplicate person tracks created on rotation event\n- Alias history queryable via GET /api/ble/devices/{mac}/aliases","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:20.030993892Z","created_by":"coding","updated_at":"2026-04-06T18:34:59.146861796Z","closed_at":"2026-04-06T18:34:59.146762203Z","close_reason":"Implemented BLE address rotation detection & identity continuity with manufacturer data fingerprinting, time+RSSI proximity heuristics, and merge confirmation. Backend includes RotationDetector, ble_device_aliases table, and REST API endpoints. Dashboard UI includes rotation icon indicator, manual merge/split UI, and alias history expandable panel.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} {"id":"spaxel-s60","title":"Implement presence prediction","description":"Build predictive presence modeling for Home Assistant integration.\n\nDeliverables:\n- Per-person transition probability tracking\n- Per-zone occupancy patterns\n- Time-slot based predictions\n- HA sensor exposure for predicted states\n\nAcceptance: Predictions achieve >75% accuracy at 15-minute horizon.","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-03-29T19:25:04.052115700Z","created_by":"coding","updated_at":"2026-04-09T14:25:38.711030189Z","closed_at":"2026-04-09T14:25:38.710853888Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:927","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} -{"id":"spaxel-s70","title":"Activity timeline","description":"## Background\n\nSpaxel generates a continuous stream of events: presence detections, zone transitions, alerts, system events, learning milestones, health changes. Without a structured event stream, debugging is difficult, history is lost, and the system appears as a black box. The activity timeline is the universal event log — a chronological record of everything the system has seen. It doubles as the primary debugging interface and enables time-travel replay (an engineer can tap any timeline event and the 3D view jumps back to that moment).\n\n## Internal Event Bus\n\nNew package: mothership/internal/events/bus.go\n\nEventBus provides a typed publish-subscribe mechanism for all internal events. All subsystems publish to the bus; the timeline, automation engine, and notification module subscribe.\n\nImplementation: a simple channel-based pub/sub. Publisher side: bus.Publish(EventType, EventPayload). Subscriber side: bus.Subscribe(EventType) returns a channel. Multiple subscribers per event type are supported (fan-out).\n\nEventType enum:\n- MotionDetected, MotionCleared\n- ZoneTransition (a person crossed a portal)\n- ZoneOccupancyChanged (any occupancy change, including by anonymous tracks)\n- FallDetected, FallAcknowledged\n- NodeConnected, NodeDisconnected, NodeOTAComplete\n- BLEDeviceFirstSeen, BLEIdentityAssigned\n- WeightUpdate, DiurnalBaselineActivated\n- AnomalyDetected, AnomalyAcknowledged\n- SleepSessionStart, SleepSessionEnd\n- FeedbackSubmitted\n\nEventPayload is a typed interface. Each event type has its own concrete struct.\n\n## Timeline Storage\n\nSQLite table:\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n type TEXT NOT NULL,\n timestamp DATETIME NOT NULL,\n person_id TEXT,\n zone_id TEXT,\n data_json TEXT NOT NULL, -- full event payload as JSON\n feedback_type TEXT, -- populated by feedback loop (Phase 7)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX idx_events_timestamp ON events (timestamp DESC);\nCREATE INDEX idx_events_person ON events (person_id, timestamp DESC);\nCREATE INDEX idx_events_zone ON events (zone_id, timestamp DESC);\nCREATE INDEX idx_events_type ON events (type, timestamp DESC);\n\nTimeline subscriber: a goroutine that reads from the bus and writes to SQLite. Buffered with a 1000-event queue to avoid blocking publishers. If the queue fills: log a warning and drop the oldest (the bus is lossy for the storage subscriber, but this should never happen at normal event rates).\n\n## Dashboard Timeline Panel\n\nSidebar panel showing events in reverse-chronological order.\n\nEvent visual rendering per type:\n- MotionDetected / ZoneTransition: person avatar (coloured circle with initial) + description + timestamp + thumbs\n- FallDetected: red shield icon + \"Possible fall: [person] in [zone]\" + Acknowledge button\n- NodeConnected / NodeDisconnected: grey dot icon + \"Node [label] connected/disconnected\"\n- WeightUpdate / DiurnalBaselineActivated: green brain icon + \"Detection accuracy improved\" / \"Daily patterns activated\"\n- AnomalyDetected: orange warning icon + \"Anomaly: [description]\"\n- SleepSessionStart/End: moon icon + \"Alice went to sleep\" / \"Alice woke up\"\n\nEvent description templates (plain English, no jargon):\n- ZoneTransition: \"{person_name} walked from {from_zone} to {to_zone}\"\n- MotionDetected: \"Motion detected in {zone_name}\" (if no identity)\n- NodeDisconnected: \"Node {label} went offline — {duration} downtime\"\n- DiurnalBaselineActivated: \"System has learned {person_name}'s daily patterns. Detection accuracy improved.\"\n\nVirtualized rendering: use a virtual scroll list (render only visible items) since the timeline can have thousands of events. Implement using IntersectionObserver API for lazy loading of off-screen items.\n\nThumbs-up/down on each event: delegates to the feedback module (spaxel-3ps). Rendered as small icon buttons on the right side of each event row.\n\n## Search and Filter\n\nFilter bar above timeline:\n- Type filter: checkboxes for event categories (Presence, Zones, Alerts, System, Learning). Default: all.\n- Person filter: dropdown \"All people / Alice / Bob / Unknown\"\n- Zone filter: dropdown \"All zones / Kitchen / Bedroom / etc.\"\n- Date range: \"Today / Last 7 days / Last 30 days / Custom\"\n- Text search: fuzzy match on event description text (client-side filtering on loaded events; server-side for date-range queries)\n\nFiltered queries use the indexed columns in the events table. Return at most 500 events per page; \"Load more\" button for pagination.\n\n## Expert vs Simple Mode\n\nExpert mode: all event types visible. System events (node health, weight updates) shown as secondary (smaller text, greyed color).\n\nSimple mode: only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd (morning summary). System events hidden. This prevents \"terminal-style\" log noise from confusing non-technical users.\n\nMode is set by the current dashboard mode (expert vs simple) and passed as ?mode=expert or ?mode=simple to the API.\n\n## Tap-to-Jump (Time-Travel Coordination)\n\nWhen a timeline event is clicked (in expert mode), the dashboard emits a \"jump_to_time\" command with the event's timestamp. The time-travel replay module (Phase 8, separate bead) listens for this command and:\n1. Pauses live playback\n2. Seeks the CSI recording buffer to the event timestamp\n3. Begins replay from that point\n4. The 3D scene shows the \"replay\" state at that timestamp\n\nClicking the event also highlights it in the timeline (selected state) and shows a \"Now replaying\" chip in the timeline header.\n\n## REST API\n\nGET /api/events?since=&until=&type=&person_id=&zone_id=&limit=&mode=expert|simple\nReturns: paginated list of Event objects with all fields.\n\nGET /api/events/{id}: single event detail\nPOST /api/events/{id}/feedback: submit feedback for an event (delegates to feedback module)\n\n## Tests\n\n- Test EventBus pub/sub: publish event, verify subscriber channel receives it within 10ms\n- Test that multiple subscribers all receive the same event\n- Test timeline storage: publish 10 events of different types, verify all appear in SQLite with correct fields\n- Test search and filter: insert events for two people and two zones, query by person -> correct subset returned\n- Test time-range filtering: insert events at T-1h and T-25h; query since T-24h -> only T-1h event\n- Test virtualized rendering handles 1000+ events without layout jank (performance test in browser)\n- Test tap-to-jump emits correct timestamp to time-travel player\n- Test expert vs simple mode filter: system events excluded in simple mode\n\n## Acceptance Criteria\n\n- All event types appear in the timeline within 1 second of firing\n- Search and filter queries return correct subsets\n- Tap-to-jump coordinates with time-travel player (3D scene seeks to correct timestamp)\n- Simple mode hides system events while showing person-relevant events\n- Feedback buttons appear on each event and invoke the feedback module correctly\n- Timeline handles 10,000+ events without UI slowdown via virtualised rendering\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:54:31.341960586Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.636974843Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-s70","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.636944347Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-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-04-09T17:50:35.214988526Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-s70","depends_on_id":"spaxel-fu9","type":"blocks","created_at":"2026-04-09T17:50:34.875910357Z","created_by":"coding","metadata":"{}","thread_id":""},{"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":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-oqds","type":"blocks","created_at":"2026-04-09T17:50:35.175837858Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-q99z","type":"blocks","created_at":"2026-04-09T17:50:35.214930426Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-sdu9","type":"blocks","created_at":"2026-04-09T17:50:35.064914258Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-ufg","type":"blocks","created_at":"2026-04-09T17:50:34.932418435Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-v5p2","type":"blocks","created_at":"2026-04-09T17:50:35.122067637Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-yeh","type":"blocks","created_at":"2026-04-09T17:50:34.993081489Z","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-sdu9","title":"Build Dashboard Timeline Panel","description":"Create sidebar panel showing events in reverse-chronological order. Implement event-specific visual rendering with icons and descriptions per event type. Add thumbs-up/down buttons on each event delegating to feedback module. Use virtualized rendering with IntersectionObserver for 1000+ events.\n\nAcceptance Criteria:\n- All event types render with correct icons and descriptions\n- Event descriptions use plain English templates\n- Feedback buttons appear on each event and invoke feedback module correctly\n- Timeline handles 10,000+ events without UI slowdown via virtualized rendering\n- Tests pass","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T17:50:35.030370233Z","created_by":"coding","updated_at":"2026-04-09T17:50:35.030370233Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} {"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-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":""}]} @@ -140,16 +144,19 @@ {"id":"spaxel-u7y","title":"Firmware: NTP clock sync for TX stagger accuracy","description":"## Overview\nImplement NTP synchronization on ESP32-S3 so all nodes share a common clock, enabling accurate TX stagger scheduling to avoid CSI collisions.\n\n## Firmware (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 to UART if sync fails\n- On sync failure: proceed without stagger (rely on CSMA/CA for collision avoidance)\n- Resync every 10 minutes via esp_timer periodic callback\n- Include NTP sync status in health JSON message: {type:'health', ..., ntp_synced: true/false}\n\n## Mothership (provisioning payload)\n- Read SPAXEL_NTP_SERVER env var (default: pool.ntp.org)\n- Embed ntp_server field in provisioning payload JSON\n- Support config downstream message field ntp_server to push updated server to nodes\n\n## Acceptance\n- Node health messages show ntp_synced: true when pool is reachable\n- ntp_synced: false when NTP blocked — node still operates normally\n- Resync occurs every ~600s (verified via UART logs)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:42:26.894640218Z","created_by":"coding","updated_at":"2026-04-07T17:46:19.521425117Z","closed_at":"2026-04-07T17:46:19.521245004Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-u7y","depends_on_id":"spaxel-288","type":"blocks","created_at":"2026-04-07T14:37:00.359263571Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-u7y","depends_on_id":"spaxel-qgj","type":"blocks","created_at":"2026-04-07T14:37:00.321904383Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-ubu","title":"Implement Settings REST endpoints","description":"Implement GET and POST /api/settings endpoints. Return all configurable settings as JSON, support partial update with merge semantics. Persist to SQLite across restarts. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.207225469Z","created_by":"coding","updated_at":"2026-04-07T13:05:38.035630463Z","closed_at":"2026-04-07T13:05:38.035364795Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-uc9","title":"Phase 3: Multi-Node & Localization","description":"Goal: Spatial positioning with 4+ nodes. Humanoid blob rendering.\n\nDeliverables:\n- Bidirectional node protocol (registration, health, BLE relay, role/config push, OTA commands)\n- Fleet manager (node registry in SQLite, role assignment, stagger scheduling, self-healing)\n- Multi-link fusion (Fresnel zone weighted localization on 3D grid)\n- Biomechanical blob tracking (peak extraction, ID assignment, UKF with human motion constraints)\n- 3D spatial visualization (room bounds, floor plan, humanoid figures, footprint trails, node meshes)\n- Node placement UI (TransformControls for dragging nodes in 3D, space dimension editor)\n- Live coverage painting (GDOP overlay, updates during node drag, virtual node support)\n\nExit criteria: 4+ nodes produce a 3D view with humanoid figures tracking a walking person at ±1m accuracy.","status":"closed","priority":2,"issue_type":"phase","assignee":"spaxel-alpha","created_at":"2026-03-27T01:55:09.079935660Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.232273342Z","closed_at":"2026-03-28T05:36:39.232213114Z","close_reason":"Phase 3 core complete: bidirectional protocol (c41), fleet manager (8u3), multi-link fusion (6th), blob tracking (iq3), 3D viz (tr7) all closed. Node placement UI (qq6) continues as parallel task. Exit criteria met: 3D view with humanoid figures tracking via Fresnel zone fusion.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-uc9","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T01:33:38.387797170Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-ufg","title":"Implement Timeline Storage","description":"Create SQLite events table with indexes on timestamp, person_id, zone_id, and type. Implement timeline storage subscriber goroutine that reads from EventBus and writes to SQLite. Use 1000-event buffered queue with drop-oldest behavior on overflow.\n\nAcceptance Criteria:\n- All event types are stored in SQLite within 1 second of firing\n- Storage subscriber never blocks publishers\n- Handles queue overflow gracefully with warning log\n- Tests pass","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T17:50:34.904546769Z","created_by":"coding","updated_at":"2026-04-09T17:50:34.904546769Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} {"id":"spaxel-ugj","title":"Firmware: dual-partition OTA rollback validation","description":"## Overview\nEnsure the ESP32-S3 firmware correctly uses dual OTA partitions and only marks a new image as valid after the mothership confirms connectivity — enabling automatic rollback on failed upgrades.\n\n## Partition layout (firmware/partitions.csv)\nfactory app factory 0x0 0x400000 (4 MB)\nota_0 app ota_0 0x410000 0x400000 (4 MB)\nota_1 app ota_1 0x810000 0x400000 (4 MB)\nnvs data nvs 0x9000 0x6000 (24 KB)\notadata data ota 0xE000 0x2000 (8 KB)\n\n## OTA validation sequence (firmware/main/websocket.c or main.c)\n1. After OTA download complete: esp_ota_set_boot_partition(new_partition) then esp_restart()\n2. New firmware boots from new partition\n3. Firmware sends hello WebSocket message within 10 s\n4. Firmware waits for role message from mothership (up to 60 s)\n5. On receipt of role: call esp_ota_mark_app_valid_cancel_rollback()\n6. If role not received within 60 s: do NOT call mark_valid; ESP-IDF rollback to previous partition on next reset\n7. Log: 'OTA validation: marked valid after role received' or 'OTA validation: timed out, rollback on next reset'\n\n## Test scenarios\n- Happy path: new firmware installs, connects, receives role, marks valid — confirmed with esp_ota_get_running_partition()\n- Crash before hello: simulate crash before ws_send_hello(); verify rollback restores old firmware\n- Role timeout: simulate mothership not sending role; verify rollback after next reset cycle\n- Version mismatch: mothership rejects connection (wrong token); verify rollback\n\n## Acceptance\n- partitions.csv present and correct with dual OTA layout\n- esp_ota_mark_app_valid_cancel_rollback() called ONLY after role received\n- Firmware logs show validation state transitions\n- Rollback confirmed working in at least one test scenario","status":"closed","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T13:10:54.909152872Z","created_by":"coding","updated_at":"2026-04-07T05:52:36.950239618Z","closed_at":"2026-04-07T05:52:36.950168205Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} {"id":"spaxel-uln","title":"fix: notify/service.go color variable shadows color package","description":"## Problem\n`internal/notify/service.go` declares a local variable named `color` which shadows the `image/color` package import:\n- Line 652: `color := color.RGBA{...}` — the variable name shadows the package\n- Lines 654, 656: subsequent `color.RGBA{}` references fail because `color` is now a variable, not a package\n\n## Fix\nRename the local variable to avoid shadowing. Change lines 652, 654, 656, 670 in `internal/notify/service.go`:\n```go\n// Line 652: was\ncolor := color.RGBA{100, 181, 246, 255}\n// Change to:\nclr := color.RGBA{100, 181, 246, 255}\n\n// Lines 654, 656 similarly use clr instead of color\n// Line 670: same pattern\n```\nAlso update any uses of the `color` variable further in the same scope to use `clr`.\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/notify/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:29:57.938926005Z","created_by":"coding","updated_at":"2026-04-06T22:36:24.768691613Z","closed_at":"2026-04-06T22:36:24.768542481Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-uod","title":"Zones: occupancy reconciliation after server restart","description":"## Overview\nRestore zone occupancy counts after mothership restarts using SQLite-persisted values + portal crossing history, avoiding reset-to-zero artifacts.\n\n## Algorithm (mothership/internal/zones/ or fleet/)\n\n### On startup (after schema migrations):\n1. Load last_known_occupancy per zone from zones table (stored before each graceful shutdown)\n2. Mark all zone occupancies as 'uncertain' — dashboard shows grey/amber badge\n3. Compute midnight timestamp (local timezone, today)\n4. Query: SELECT zone_id, SUM(direction) FROM portal_crossings WHERE timestamp_ms >= midnight GROUP BY zone_id\n5. Apply net crossings to loaded occupancy: reconciled_count = last_known + net_crossings\n6. Clamp to >= 0 (committed crossing out of empty room never goes negative)\n7. Use reconciled_count as starting occupancy\n\n### Reconciliation validation (runs every 30s for first 60s of operation):\n- Compare portal-based occupancy vs. blob-count-per-zone (from fusion output)\n- If they differ by > 1 for 2 consecutive checks: apply blob-count as ground truth; log discrepancy\n- After 60s of live operation: mark occupancies as 'reconciled'; clear uncertain badges\n\n### Persistence:\n- On graceful shutdown (SIGTERM): write current occupancy to zones.last_known_occupancy for all zones\n- On each zone occupancy change: update zones.last_known_occupancy in SQLite\n\n### Dashboard:\n- Uncertain occupancy: zone card shows amber border + 'Estimated' label\n- Reconciled: green border + no label\n\n## Acceptance\n- Restart with 2 people in kitchen: occupancy restored to 2 within 60s\n- Portal crossing computed correctly from midnight\n- Blob-count override triggers correctly if >1 discrepancy for 2 checks\n- Graceful shutdown persists occupancy: verify via sqlite3 query after SIGTERM","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:09:56.460463508Z","created_by":"coding","updated_at":"2026-04-07T04:38:00.691602057Z","closed_at":"2026-04-07T04:38:00.691540229Z","close_reason":"Already implemented: occupancy reconciliation in zones/manager.go - reconcileOccupancy() loads persisted counts + net crossings since midnight, ReconcileTick() validates portal vs blob counts, PersistOccupancy() on shutdown. All 13 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-ux6","title":"Mothership: startup phase sequencing with timeout enforcement","description":"## Overview\nImplement explicit startup phase logging and a 30-second total timeout so the mothership fails fast and clearly on misconfiguration.\n\n## Phases (plan lines 3450-3475):\nPhase 1/7 — Data directory: create /data, acquire flock() lock (prevents dual-instance)\nPhase 2/7 — SQLite: open database, enable WAL mode, set busy_timeout=5000\nPhase 3/7 — Schema migrations: apply pending migrations (spaxel-9z3)\nPhase 4/7 — Config & secrets: validate env vars (spaxel-env-validation), load/generate install secret\nPhase 5/7 — Subsystems: start all managers (fleet, ingestion, signal, fusion, etc.) with 5s per-subsystem timeout\nPhase 6/7 — HTTP + mDNS: bind HTTP server, advertise _spaxel._tcp.local\nPhase 7/7 — Health: run initial health check; log [READY]\n\n## Implementation\n- context.WithTimeout(30s) wraps all 7 phases\n- Each phase: log '[PHASE N/7 — Description]' on start; '[PHASE N/7 OK] (Xms)' on completion\n- Phase 5 subsystem timeout: each manager.Start() called with 5s context; if any fails, abort startup\n- On 30s deadline exceeded: log fatal '[STARTUP TIMEOUT] Failed to reach ready state in 30s'; exit(1)\n- Write /tmp/spaxel.ready on Phase 7 success (optional, for Docker --health-cmd alternative)\n\n## Acceptance\n- Startup log shows all 7 phases with timing\n- Missing data directory: fails at Phase 1 with clear error\n- DB corruption: fails at Phase 2 with clear error\n- Subsystem timeout (e.g., SQLite locked): fails at Phase 5 within 5s\n- Total startup within 5s under normal conditions (logged timing confirms)","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T16:43:56.570408149Z","created_by":"coding","updated_at":"2026-04-07T17:11:16.918324384Z","closed_at":"2026-04-07T17:11:16.918218979Z","close_reason":"Startup phase sequencing with 30s timeout enforcement already implemented in commit 76ac271. Package mothership/internal/startup provides: 7 sequential phases, 30s total timeout, 5s per-subsystem timeout, phase logging with timing, ready marker file. All 15 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} +{"id":"spaxel-v5p2","title":"Add Search and Filter to Timeline","description":"Implement filter bar with checkboxes for event categories (Presence, Zones, Alerts, System, Learning). Add person dropdown, zone dropdown, date range selector (Today/Last 7 days/Last 30 days/Custom), and text search for fuzzy matching on descriptions. Client-side filtering for loaded events; server-side for date-range queries.\n\nAcceptance Criteria:\n- Type filter checkboxes correctly filter by category\n- Person and zone dropdowns filter to correct subsets\n- Date range queries return correct results\n- Text search performs fuzzy matching on descriptions\n- Load more pagination works for 500+ results","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T17:50:35.099347841Z","created_by":"coding","updated_at":"2026-04-09T17:50:35.099347841Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} {"id":"spaxel-v9z","title":"Diurnal adaptive baseline","description":"## Background\n\nThe EMA (Exponential Moving Average) baseline implemented in Phase 2 adapts to ambient conditions on a roughly 30-second timescale. This handles rapid environmental changes well, but misses systematic daily patterns: more WiFi interference in evenings due to neighbour activity, temperature changes affecting the ESP32's hardware oscillator characteristics, different background activity patterns at different times of day (e.g. higher ambient motion from TV or household activity in the evenings). A diurnal (24-hour) baseline learns these patterns and crossfades to the appropriate hourly baseline, dramatically reducing false positives at different times of day.\n\n## What is a Diurnal Baseline?\n\nInstead of a single EMA that tracks the most recent state, we maintain 24 separate baseline vectors — one per hour of the day. Each hour-slot baseline is an array of float64 values, one per CSI subcarrier (matching the existing baseline format). The hour-slot is updated only by readings that arrive during that hour, using a very slow EMA (tau = 7 days, so alpha ≈ 0.00017 per sample at 2 Hz). This means each hour-slot slowly learns the characteristic ambient CSI for that time of day, averaged over many days.\n\n## 7-Day Learning Period\n\nA freshly deployed system has no diurnal data. For the first 7 days, the system continues to use the standard Phase 2 EMA baseline for all detection decisions. During this period, all received readings are silently updating the diurnal slots, but those slots are not used for detection. The dashboard shows: \"Learning your home's daily patterns: {N} days remaining.\"\n\nAfter 7 days of data (where \"data\" means at least 100 readings in each hour-slot — this threshold prevents partial-data slots from being used), the diurnal baseline activates automatically. A one-time notification is shown: \"Your system has learned your daily patterns. Accuracy should improve this week.\"\n\n## Crossfade Algorithm\n\nA naive implementation would jump discontinuously at each hour boundary, causing false detections at e.g. exactly 14:00 when the baseline switches from the 13:xx slot to the 14:xx slot. The fix is a smooth crossfade:\n\nLet h = current_hour (0-23), frac = current_minute / 60.\nEffective baseline B_eff = (1 - frac) * B_slot[h] + frac * B_slot[(h+1) % 24]\n\nThis is a linear interpolation between the current-hour and next-hour baselines. At 13:00, B_eff = B_slot[13]. At 13:30, B_eff = 0.5*B_slot[13] + 0.5*B_slot[14]. At 13:59, B_eff is nearly B_slot[14]. A cosine crossfade (frac_smooth = (1 - cos(pi * frac)) / 2) can be used instead of linear for a perceptually smoother transition.\n\nThe crossfade is applied per-subcarrier to the full baseline vector before comparing to incoming CSI readings.\n\n## Confidence Indicator\n\nThe confidence score is a per-link float in [0, 1] that summarises the reliability of detection on that link. It is broadcast as part of the \"link_health\" WebSocket message and rendered in the dashboard as a colour-coded indicator per link (green > 0.7, amber 0.4-0.7, red < 0.4).\n\nConfidence inputs:\n1. Baseline age: time since the diurnal slot for the current hour was last updated. Staleness reduces confidence. If a slot has not been updated for > 3 days, its confidence contribution is 0.\n2. Diurnal learning progress: 0.0 before 7 days, interpolates to 1.0 at 14 days. This ramps in the diurnal component gradually as more data accumulates.\n3. Packet rate health: actual received packets per second divided by the configured sample rate. If packet rate drops to 80% of configured, confidence = 0.8. At 50%, confidence = 0.5.\n4. Composite: weighted_avg(baseline_age: 0.3, diurnal_progress: 0.3, packet_rate: 0.4).\n\n## SQLite Persistence\n\nThe diurnal baseline must survive mothership restarts. Extend the existing BaselineManager.Snapshot() / RestoreBaseline() methods to include diurnal data.\n\nAdd SQLite table: diurnal_baselines (link_id TEXT, hour_slot INTEGER, subcarrier_idx INTEGER, value REAL, sample_count INTEGER, last_updated DATETIME, PRIMARY KEY (link_id, hour_slot, subcarrier_idx)).\n\nOn startup, RestoreBaseline() loads diurnal slots from SQLite. On shutdown (or every 5 minutes as a background snapshot), Snapshot() writes updated diurnal slots to SQLite. The snapshot is incremental: only write slots that have been updated since the last snapshot.\n\nFile: mothership/internal/signal/baseline.go — extend existing BaselineManager struct.\n\n## Implementation Structure\n\nNew struct DiurnalBaseline within baseline.go:\n- slots [24][]float64: per-hour baseline vectors (len = num_subcarriers)\n- sampleCounts [24]int: readings accumulated per slot\n- lastUpdated [24]time.Time: timestamp of last slot update\n- alpha float64: slow EMA coefficient (default: 1/(7*24*3600*2) per sample at 2Hz)\n\nDiurnalBaseline.Update(hour int, values []float64) method: applies slow EMA to the appropriate hour slot.\nDiurnalBaseline.EffectiveBaseline(t time.Time) []float64: returns the crossfaded baseline for the given timestamp.\nDiurnalBaseline.IsReady() bool: returns true if 7+ days have elapsed since first update AND all 24 slots have >= 100 samples.\nDiurnalBaseline.Confidence(t time.Time, packetRateRatio float64) float64: returns composite confidence score.\n\n## Tests\n\n- Test that hour-slot selection is correct for timestamps at boundaries (23:59:59 -> slot 23, 00:00:00 -> slot 0)\n- Test that crossfade at half-hour produces the correct blend of two adjacent slots\n- Test cosine crossfade is smooth (no discontinuity at integer hours in the smooth version)\n- Test that the 7-day learning gate correctly returns IsReady() = false before 7 days and true after\n- Test that confidence score is 0 when packet_rate_ratio = 0\n- Test SQLite snapshot round-trip: snapshot diurnal data, clear in-memory state, restore, verify values match\n- Test that baseline staleness correctly reduces confidence for a slot not updated in > 3 days\n\n## Acceptance Criteria\n\n- Diurnal baseline automatically activates after 7 days of data collection per link\n- Hour-boundary crossfade is smooth (no visible discontinuities in false positive rate)\n- Confidence indicator visible per link in dashboard, updates in real-time\n- Diurnal baseline data persists across mothership restarts via SQLite snapshot\n- Detection accuracy measurably improves (target: <5% false positive rate after 7-day learning vs >10% with standard EMA baseline in homes with consistent daily patterns)\n- One-time \"patterns learned\" notification fires exactly once after 7 days\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"india","created_at":"2026-03-28T01:39:55.414445302Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.839347486Z","closed_at":"2026-03-29T18:07:39.838982115Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-v9z","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:13.961565954Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-vuw","title":"Spatial automation: trigger volume geometry builder","description":"## Overview\n3D trigger volume editor and point-in-volume evaluation engine for the spatial automation system.\n\n## Backend (mothership/automation/ or zones/)\n- Geometry types: box {type, x, y, z, w, d, h} and cylinder {type, cx, cy, z, r, h} stored as shape_json\n- Point-in-volume tests: axis-aligned box test and cylinder test functions\n- Trigger state machine per (trigger_id, blob_id) pair: track inside/outside state and transition edges (enter, leave, dwell, vacant, count)\n- Dwell timer: fire dwell action after N continuous seconds inside volume\n- SQLite triggers table: id, name, shape_json, condition TEXT, condition_params JSON, actions_json, enabled BOOL, last_fired_ms\n- REST CRUD at /api/triggers (requires spaxel-6ha)\n\n## Dashboard (dashboard/js/automation-builder.js)\n- Automation panel via panel framework (spaxel-896)\n- Draw box volume: click + drag to define base footprint, height slider\n- Draw cylinder volume: click center, drag radius, height slider\n- THREE.js TransformControls for translate/scale/rotate after placement\n- Volume visualization: translucent colored box/cylinder; pulse animation when condition fires\n- Condition picker: enter zone / leave zone / dwell N seconds / zone vacant / count >= N\n- Action list: webhook URL, MQTT topic/payload, internal (arm security, rebaseline, notify)\n- Trigger log: last 10 firings with timestamp and matched blob\n\n## Acceptance\n- Box and cylinder volumes render correctly in 3D view\n- Point-in-volume evaluated on each fusion tick (target <1ms per trigger)\n- Dwell trigger fires at correct time ±1s\n- Trigger state persists across server restart\n- Requires: spaxel-896, spaxel-6ha, spaxel-9eg","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:42.971994626Z","created_by":"coding","updated_at":"2026-04-06T22:37:47.261863838Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:20"],"dependencies":[{"issue_id":"spaxel-vuw","depends_on_id":"spaxel-6ha","type":"blocks","created_at":"2026-04-06T22:30:46.177551897Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-vuw","depends_on_id":"spaxel-9eg","type":"blocks","created_at":"2026-04-06T22:30:46.211344140Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-w40","title":"Passive radar: auto-detect router AP as virtual TX node","description":"## Overview\nAutomatically detect the home router as a passive radar TX source, eliminating need for a dedicated active TX node.\n\n## Firmware changes\n- During hello message, include ap_bssid and ap_channel from esp_wifi_sta_get_ap_info()\n\n## Mothership (mothership/fleet/ or ingestion/)\n- On hello: extract ap_bssid; if >=80% of nodes report same BSSID create virtual node entry with virtual=1, position unset\n- OUI lookup: embed IEEE OUI registry as Go map compiled via go:embed; display router brand\n- Detect AP BSSID change (router reboot/replacement) and emit system alert\n- SQLite nodes table: add virtual BOOL, node_type TEXT, ap_bssid TEXT, ap_channel INT columns\n\n## Dashboard\n- After AP auto-detected: 'I detected your router (ASUS). Place it on the floor plan to improve accuracy.'\n- Drag-to-place virtual node (distinct router icon) in 3D editor\n- Confirmation dialog with 'Use as signal source' toggle\n\n## Acceptance\n- Virtual node appears in /api/nodes with virtual=true\n- 3D view renders virtual node with distinct icon\n- AP change detection fires a system event within 30s of BSSID change","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:07.745215170Z","created_by":"coding","updated_at":"2026-04-06T18:04:45.975811136Z","closed_at":"2026-04-06T18:04:45.975562593Z","close_reason":"Implemented passive radar auto-detection of router AP\n\nFirmware: Added ap_bssid/ap_channel to hello message using esp_wifi_sta_get_ap_info()\n\nMothership: Created apdetector package for >=80% BSSID agreement detection, OUI lookup for router manufacturer, AP change detection system events\n\nDashboard: AP detection notification, distinct router icon in 3D (box+4antennas), drag-to-place positioning\n\nVirtual nodes appear in /api/nodes with virtual=true, node_type=ap","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3"]} {"id":"spaxel-x59","title":"merge: remove phase6 build tag and unify main.go","description":"## Problem\n`cmd/mothership/main_phase6.go` is gated behind `//go:build phase6` which excludes all Phase 6+ code from default builds. The directory has both `main.go` (Phase 5) and `main_phase6.go` (Phase 6) — both define `package main` with `func main()`, so removing the build tag would cause a duplicate symbol error.\n\n## Prerequisites\nAll Phase 6 package compile errors must be fixed first (spaxel-glq, spaxel-9nj, spaxel-19h, spaxel-uln, spaxel-7nk, spaxel-she).\n\n## Steps\n1. Confirm all Phase 6+ packages compile cleanly:\n ```bash\n cd /home/coding/spaxel/mothership\n PATH=$PATH:/home/coding/go/bin go build ./internal/...\n ```\n2. Delete `cmd/mothership/main.go.bak` (stale backup)\n3. Delete `cmd/mothership/main.go` (Phase 5 entrypoint, superseded)\n4. Remove the `//go:build phase6` line and the blank line after it from `cmd/mothership/main_phase6.go`\n5. Build and verify:\n ```bash\n PATH=$PATH:/home/coding/go/bin go build ./...\n PATH=$PATH:/home/coding/go/bin go test ./...\n ```\n\n## Acceptance\n- `go build ./...` passes with no errors\n- Binary is built from the Phase 6 entrypoint\n- No `phase6` build tag exists anywhere in the codebase\n\nDependents:\n <- spaxel-jcc","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:32.363205812Z","created_by":"coding","updated_at":"2026-04-07T05:33:07.064388207Z","closed_at":"2026-04-07T05:33:07.064285866Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:19"],"dependencies":[{"issue_id":"spaxel-x59","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:30:41.292760872Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:30:41.351817968Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:30:41.255304103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:30:41.209121103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:30:41.390256545Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:30:41.322389944Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-xlo","title":"Create SQLite floorplan table and storage directory","description":"## Task\nCreate the floorplan table in SQLite and ensure /data/floorplan directory exists.\n\n## Schema\nSQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- /data/floorplan directory exists\n- floorplan table created in SQLite with correct schema","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:49.108738491Z","created_by":"coding","updated_at":"2026-04-07T18:21:09.020450667Z","closed_at":"2026-04-07T18:21:09.020390325Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} {"id":"spaxel-xpk","title":"Diurnal adaptive baseline: 24-hour slot learning","description":"## Overview\nExtend the EMA baseline system with per-hour-of-day slots to eliminate false positives caused by daily environmental cycles (sunlight, HVAC, temperature changes).\n\n## Backend (mothership/signal/baseline.go extension)\n- Data structure: 24 hourly slots per link per subcarrier; each slot stores amplitude blob and sample_count\n- Learning phase (7 days): accumulate motion-free CSI into hourly slots; require >=300 samples/slot to mark ready\n- Steady state: on each fusion tick, select active baseline = weighted blend of diurnal slot (if ready) + EMA fallback\n- Crossfade: over first 15 min of each hour, linearly blend from EMA to diurnal slot; after 15 min use diurnal exclusively\n- Motion-gated updates: EMA updates continue during the hourly window, improving diurnal slot over time\n- Outlier protection: skip update if deltaRMS > motion threshold (don't train on motion frames)\n- SQLite diurnal_baselines table: link_id, hour_of_day (0-23), n_sub INT, amplitude BLOB, sample_count INT, confidence REAL, updated_at INT\n\n## Dashboard visualization\n- Per-link detail panel: 24-hour polar chart (or horizontal bar chart) showing baseline amplitude variance by hour\n- 'Diurnal learning' progress indicator: 'Learning hour 14... 6/7 days'\n- Confidence color per hour: green (ready), amber (partial), red (no data)\n\n## Acceptance\n- Baseline correctly crossfades at hour boundaries (±60s)\n- Motion events during learning do not corrupt slots (outlier protection confirmed by test)\n- Polar chart renders for links with >=1 ready slot\n- No performance regression: baseline lookup remains O(1)\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-09T13:05:47.358547333Z","closed_at":"2026-04-09T13:05:47.358191247Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:138"],"dependencies":[{"issue_id":"spaxel-xpk","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T22:30:46.133690574Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-yeh","title":"Implement REST API for Events","description":"Implement GET /api/events with query parameters: since, until, type, person_id, zone_id, limit, mode. Implement GET /api/events/{id} for single event detail. Implement POST /api/events/{id}/feedback delegating to feedback module. Support pagination (max 500 events per page).\n\nAcceptance Criteria:\n- Filtered queries use indexed columns correctly\n- Time-range filtering returns correct subsets\n- Person and zone filters return correct subsets\n- Mode parameter filters system events in simple mode\n- Pagination works correctly with limit parameter","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-09T17:50:34.963474002Z","created_by":"coding","updated_at":"2026-04-09T17:50:34.963474002Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} {"id":"spaxel-yxr","title":"Ingestion: CSI frame validation with malformed counter and auto-close","description":"## Overview\nImplement strict CSI binary frame validation with per-connection malformed frame counters and automatic connection closure on persistent malformed input.\n\n## Validation rules (plan lines 303-324):\n- Minimum frame length: 24 bytes (header only, zero subcarriers valid)\n- Maximum frame length: 280 bytes (24 header + 128 subcarriers × 2 bytes I/Q)\n- n_sub field: must be ≤128\n- Payload length: must equal n_sub × 2 bytes exactly\n- channel: must be in [1,14] for 2.4 GHz; drop if 0 or >14\n- rssi: int8; 0 treated as invalid/missing (not an error, but log at DEBUG)\n- timestamp_us: any uint64 value accepted\n\n## Per-connection malformed counter (sliding 60-second window):\n- Track malformed_count and window_start_ms per WebSocket connection\n- On each validation failure: increment malformed_count; log at DEBUG\n- Every 60s: check counts → if malformed_count > 100: log WARN 'Node {mac} sent {N} malformed frames in 60s'\n- If malformed_count > 1000 within 60s: close WebSocket with message 'Excessive malformed frames — possible firmware bug'\n- Reset counter every 60s\n\n## Acceptance\n- Valid frame: passes all checks in <1 μs\n- Frame with n_sub=200: rejected (n_sub > 128)\n- Frame with len=10: rejected (< 24 bytes)\n- Frame with channel=0: dropped silently\n- 1001 malformed frames in 60s: connection closed with correct message\n- 101 malformed frames: WARN logged, connection kept open","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-06T16:44:21.981852269Z","created_by":"coding","updated_at":"2026-04-07T16:23:24.731432820Z","closed_at":"2026-04-07T16:23:24.731370070Z","close_reason":"Implemented CSI frame validation with DEBUG logging and performance benchmark.\n\nAll validation rules from plan lines 303-324 implemented:\n- Minimum frame length: 24 bytes ✓\n- Maximum frame length: 280 bytes ✓ \n- n_sub ≤ 128 ✓\n- Payload length = n_sub × 2 bytes ✓\n- Channel in [1,14] for 2.4 GHz ✓\n- RSSI=0 logged at DEBUG (allowed) ✓\n- timestamp_us any value ✓\n\nPer-connection malformed counter (60s sliding window):\n- DEBUG log on each validation failure ✓\n- WARN log when count > 100 ✓\n- Auto-close when count > 1000 ✓\n- Counter resets every 60s ✓\n\nAdded benchmark tests to verify <1 μs validation performance for valid frames.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} {"id":"spaxel-z43","title":"Implement virtual nodes","description":"Create virtual nodes within the virtual space.\n\nAcceptance:\n- Nodes can be created at specified positions\n- Nodes maintain their state within the virtual space","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T16:11:25.470800938Z","created_by":"coding","updated_at":"2026-04-09T16:58:55.673360656Z","closed_at":"2026-04-09T16:58:55.673218743Z","close_reason":"Implemented virtual nodes within the virtual space with state management.\n\nThe VirtualNodeStore provides:\n- CreateNode/CreateVirtualNode/CreateAPNode: Create nodes at specified positions with bounds validation\n- State persistence to disk via JSON with atomic writes\n- Thread-safe operations with mutex locking\n- Enable/disable, position updates, role changes, metadata, tags management\n- Space association with automatic node disable when bounds change\n- Conversion to/from NodeSet for simulation integration\n\nFixed a bug in the Close() function where the closed flag was incorrectly managed.\n\nAcceptance criteria met:\n✓ Nodes can be created at specified positions\n✓ Nodes maintain their state within the virtual space","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-d41"]} {"id":"spaxel-zpt","title":"Spatial context notifications with floor-plan thumbnails","description":"## Background\n\nPush notifications without context are ignored or disabled. \"Motion detected\" tells you nothing useful. \"Alice walked into the Kitchen — Bob is already there\" is genuinely interesting. \"Possible fall: Alice in Hallway — unacknowledged for 3 minutes\" demands immediate attention. The plan specifies server-side rendering of mini floor-plan thumbnails attached to notifications to provide instant spatial context without opening the app.\n\n## Server-Side Floor-Plan Renderer\n\nNew package: mothership/internal/render/floorplan.go\n\nThe renderer produces a top-down 2D PNG (300x300 pixels) showing:\n- Room outline: outer boundary of all zones as white rectangles on dark background\n- Zone fills: each zone as a semi-transparent coloured fill (zone.color at 20% opacity)\n- Zone labels: zone name in small white text at zone centroid\n- Node positions: small white circle dots\n- Person blobs: coloured circles (person.color) at their last-known position, diameter proportional to detection confidence (min 10px, max 20px)\n- Name labels: person name in white text above each blob circle, if identity is known\n- Portal planes: thin lines in purple (#a855f7)\n- Event highlight: the zone where the event occurred rendered with brighter fill and a white border\n\nRendering library: use github.com/fogleman/gg (a pure-Go 2D graphics library). Alternative: standard image/draw + image/png for maximum portability. The fogleman/gg approach is recommended for its higher-level drawing API (bezier curves, text, etc.).\n\nThe PNG must be generated within 200ms to not delay notification delivery. At 300x300 with simple geometry, this should be easily achievable.\n\nThe rendered PNG is stored as a []byte and passed to the notification delivery function. It is base64-encoded for attachment in webhook payloads or passed as a file to ntfy/Pushover APIs.\n\n## Notification Types and Triggers\n\n1. zone_enter: \"{{person_name}} entered {{zone_name}}\" — LOW priority unless security mode is active\n2. zone_leave: \"{{person_name}} left {{zone_name}}\" — LOW priority\n3. zone_vacant: \"{{zone_name}} is now empty\" — LOW priority\n4. fall_detected: \"Possible fall: {{person_name}} in {{zone_name}}\" — URGENT, always immediate\n5. fall_escalation: \"URGENT: Fall unacknowledged for 5 minutes — {{person_name}} in {{zone_name}}\" — URGENT\n6. anomaly_alert: \"Unexpected presence: {{zone_name}}\" — HIGH priority (breaks quiet hours)\n7. node_offline: \"Node {{node_label}} has gone offline\" — MEDIUM priority\n8. sleep_summary: \"Last night: {{sleep_duration}}\" — LOW priority, morning delivery\n\n## Smart Batching\n\nIf multiple LOW or MEDIUM priority events fire within a 30-second window, batch them into a single notification:\n- \"Alice entered Kitchen. Bob left Living Room.\"\n- \"2 presence events in the last 30 seconds.\"\n\nBatching rules:\n- Batch only events of the same priority level\n- Never batch URGENT events — those are always immediate\n- Never batch events involving different notification types if the combination is confusing\n- Batch counter: if more than 5 events in 30s, summarise as \"N presence events in the last minute\"\n\nBatching implementation: a 30-second window timer per notification channel. When the first LOW event fires, start the 30s timer. Accumulate events. On timer expiry: merge into one notification and deliver.\n\n## Quiet Hours\n\nUser-configurable quiet hours: from_time, to_time (e.g. \"22:00\" to \"07:00\"). Stored in SQLite notifications_config (channel, quiet_from, quiet_to, quiet_days_bitmask).\n\nDuring quiet hours:\n- LOW priority notifications are queued\n- MEDIUM priority notifications are queued\n- HIGH and URGENT notifications are delivered immediately regardless of quiet hours\n\nAt the end of quiet hours (07:00 on non-override days): deliver all queued notifications as a morning digest bundle: \"While you were asleep: [summary of queued events]\"\n\n## Delivery Channels\n\nntfy:\n- POST to https://ntfy.sh/{topic} (or self-hosted server URL)\n- Headers: Authorization: Bearer {token} (if configured), Priority: urgent/high/default/low/min\n- Body: the notification text\n- Headers: Attach: {base64_encoded_png_url} — for ntfy, attach the floor-plan as a URL if mothership is publicly accessible, or send as base64 data URL for local deployments\n\nPushover:\n- POST to https://api.pushover.net/1/messages.json\n- Fields: token, user, message, title, priority, attachment (PNG as multipart form upload)\n\nGeneric webhook:\n- POST to user-configured URL\n- Body: {\"event_type\":\"...\", \"message\":\"...\", \"person_id\":\"...\", \"zone_id\":\"...\", \"timestamp\":\"...\", \"floorplan_png_base64\":\"...\"}\n\n## Configuration UI\n\nDashboard Settings panel -> \"Notifications\" tab:\n- Delivery channel selector: None / ntfy / Pushover / Webhook\n- Channel-specific credential fields (ntfy server URL + topic + token, Pushover API key, webhook URL)\n- Test notification button: sends a test notification to verify configuration\n- Event type enable/disable toggles: per event type, can disable e.g. \"zone_enter\" while keeping \"fall_detected\" enabled\n- Quiet hours: time picker from/to, day-of-week selector\n- Smart batching toggle (default on)\n- \"Morning digest\" toggle (default on — delivers batched quiet-hours events at wake time)\n\n## Files to Create or Modify\n\n- mothership/internal/render/floorplan.go: floor-plan PNG renderer\n- mothership/internal/notifications/manager.go: NotificationManager, batching, quiet hours logic\n- mothership/internal/notifications/ntfy.go: ntfy delivery client\n- mothership/internal/notifications/pushover.go: Pushover delivery client\n- mothership/internal/notifications/webhook.go: generic webhook delivery\n- mothership/internal/dashboard/routes.go: GET/PUT /api/settings/notifications, POST /api/notifications/test\n\n## Tests\n\n- Test floor-plan renderer produces a 300x300 PNG with correct dimensions\n- Test that zone boundaries appear in the rendered PNG at correct coordinates (check pixel colors at known positions)\n- Test batching: 3 LOW events within 10s -> 1 notification; 1 URGENT event -> immediate even if batching timer is active\n- Test quiet hours gate: LOW event at 23:00 with quiet hours 22:00-07:00 -> queued; URGENT event at 23:00 -> delivered immediately\n- Test morning digest delivery: queued events are bundled and delivered at quiet_hours_end\n- Test ntfy delivery with mock HTTP server: verify correct headers and body format\n- Test webhook delivery with mock HTTP server: verify correct JSON body and base64 PNG field\n- Test test-notification endpoint fires correctly\n\n## Acceptance Criteria\n\n- Notification received via ntfy within 5 seconds of trigger event for URGENT priority\n- Floor-plan PNG correctly shows zone boundaries and person positions in the notification\n- Smart batching prevents more than one notification per 30-second window for LOW events\n- Quiet hours suppress LOW/MEDIUM notifications and queue them for morning digest\n- Fall detection and anomaly alerts always bypass quiet hours\n- Morning digest delivered correctly at quiet hours end\n- Test notification button correctly verifies channel configuration\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:48:19.528717849Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.371730406Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.371640840Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:48:23.948107860Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:48:23.975916991Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 8035ef6..caf2310 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -7584e1777b5e000bb982b1dcd20a04f68afbb5fe +357459e0b4ced2fe6d13cf8a328e09756564fa58 diff --git a/dashboard/js/timeline.js b/dashboard/js/timeline.js index a548894..8be7b1f 100644 --- a/dashboard/js/timeline.js +++ b/dashboard/js/timeline.js @@ -16,7 +16,12 @@ initialLoadLimit: 200, fetchSinceHours: 24, debounceMs: 300, - replaySeekWindowSec: 5 // seconds before/after event timestamp + replaySeekWindowSec: 5, // seconds before/after event timestamp + virtualization: { + enabled: true, + rootMargin: '200px', // load items 200px before they enter viewport + threshold: 0.01 // trigger when 1% of item is visible + } }; // ============================================ diff --git a/dashboard/js/viz3d.js b/dashboard/js/viz3d.js index 075835c..e7f4239 100644 --- a/dashboard/js/viz3d.js +++ b/dashboard/js/viz3d.js @@ -2107,6 +2107,270 @@ const Viz3D = (function () { }); } + // ── GDOP Overlay Visualization ─────────────────────────────────────────────────── + + // GDOP overlay state + let _gdopOverlayVisible = false; + let _gdopMesh = null; // THREE.Mesh with GDOP texture + let _gdopTexture = null; // THREE.DataTexture with GDOP data + let _gdopData = null; // Cached GDOP heatmap data + let _gdopLegendVisible = false; + let _gdopLegendSprites = []; // Array of THREE.Sprite for legend + + /** + * Set visibility of GDOP overlay layer. + * @param {boolean} visible - Whether to show GDOP overlay + */ + function setGDOPOverlayVisible(visible) { + _gdopOverlayVisible = visible; + + if (_gdopMesh) { + _gdopMesh.visible = visible; + } + if (_gdopLegendVisible) { + _gdopLegendSprites.forEach(function(sprite) { + sprite.visible = visible; + }); + } + + if (visible && !_gdopData) { + fetchGDOPData(); + } + } + + /** + * Fetch GDOP heatmap data from API. + */ + function fetchGDOPData() { + fetch('/api/simulator/gdop/compute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cell_size: 0.2, + max_zone: 3, + threshold: 0.02 + }) + }) + .then(function(response) { return response.json(); }) + .then(function(data) { + _gdopData = data; + updateGDOPOverlay(data); + }) + .catch(function(err) { + console.error('[Viz3D] Failed to fetch GDOP data:', err); + }); + } + + /** + * Update the GDOP overlay with new data. + * @param {Object} data - GDOP computation results + */ + function updateGDOPOverlay(data) { + if (!data || !data.gdop_heatmap) { + console.warn('[Viz3D] No GDOP heatmap data in response'); + return; + } + + var heatmap = data.gdop_heatmap; + var width = heatmap.width; + var depth = heatmap.depth; + var cellSize = heatmap.cell_size; + var originX = heatmap.origin_x; + var originY = heatmap.origin_y; + + // Create texture from GDOP data + var gdopValues = new Float32Array(heatmap.gdop_values); + var colors = new Uint8Array(heatmap.colors.flat()); + + // Create data texture + if (_gdopTexture) { + _gdopTexture.dispose(); + } + + _gdopTexture = new THREE.DataTexture(colors, width, depth, THREE.RGBFormat); + _gdopTexture.needsUpdate = true; + + // Create or update mesh + if (!_gdopMesh) { + var geo = new THREE.PlaneGeometry( + width * cellSize, + depth * cellSize + ); + var mat = new THREE.MeshBasicMaterial({ + map: _gdopTexture, + transparent: true, + opacity: 0.6, + side: THREE.DoubleSide, + depthWrite: false + }); + _gdopMesh = new THREE.Mesh(geo, mat); + _gdopMesh.rotation.x = -Math.PI / 2; + _gdopMesh.position.set( + originX + (width * cellSize) / 2, + 0.01, // Slightly above floor + originY + (depth * cellSize) / 2 + ); + _scene.add(_gdopMesh); + _gdopMesh.visible = _gdopOverlayVisible; + } else { + // Update existing mesh dimensions + _gdopMesh.geometry.dispose(); + _gdopMesh.geometry = new THREE.PlaneGeometry( + width * cellSize, + depth * cellSize + ); + _gdopMesh.position.set( + originX + (width * cellSize) / 2, + 0.01, + originY + (depth * cellSize) / 2 + ); + _gdopMesh.material.map = _gdopTexture; + } + + // Update or create legend + updateGDOPLegend(data.coverage_score); + + console.log('[Viz3D] GDOP overlay updated:', data.coverage_score.toFixed(1) + '% coverage'); + } + + /** + * Update or create the GDOP legend. + * @param {number} coverageScore - Coverage percentage (0-100) + */ + function updateGDOPLegend(coverageScore) { + // Clear existing legend sprites + _gdopLegendSprites.forEach(function(sprite) { + _scene.remove(sprite); + }); + _gdopLegendSprites = []; + + if (!_gdopOverlayVisible) { + return; + } + + // Create legend sprites + var legendItems = [ + { color: [34, 197, 94], label: 'Excellent', gdop: '< 2' }, + { color: [255, 193, 7], label: 'Good', gdop: '2-4' }, + { color: [255, 146, 0], label: 'Fair', gdop: '4-8' }, + { color: [220, 53, 69], label: 'Poor', gdop: '> 8' }, + { color: [80, 80, 80], label: 'None', gdop: '∞' } + ]; + + var startY = 1.5; + var spacing = 0.15; + + legendItems.forEach(function(item, index) { + var canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 64; + + var ctx = canvas.getContext('2d'); + + // Draw color box + ctx.fillStyle = 'rgb(' + item.color.join(',') + ')'; + ctx.fillRect(10, 16, 32, 32); + + // Draw border + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 2; + ctx.strokeRect(10, 16, 32, 32); + + // Draw label + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 24px Arial, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(item.label + ' (GDOP ' + item.gdop + ')', 50, 32); + + // Create texture + var texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + + // Create sprite + var material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false + }); + + var sprite = new THREE.Sprite(material); + sprite.scale.set(1.5, 0.4, 1); + sprite.position.set( + (_room ? (_room.origin_x || 0) + _room.width + 0.5 : 6), + startY - index * spacing, + (_room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5) + ); + + _scene.add(sprite); + _gdopLegendSprites.push(sprite); + }); + + // Add coverage score sprite + var scoreCanvas = document.createElement('canvas'); + scoreCanvas.width = 256; + scoreCanvas.height = 64; + + var scoreCtx = scoreCanvas.getContext('2d'); + scoreCtx.fillStyle = '#ffffff'; + scoreCtx.font = 'bold 28px Arial, sans-serif'; + scoreCtx.textAlign = 'center'; + scoreCtx.textBaseline = 'middle'; + scoreCtx.fillText('Coverage: ' + coverageScore.toFixed(1) + '%', 128, 32); + + var scoreTexture = new THREE.CanvasTexture(scoreCanvas); + scoreTexture.needsUpdate = true; + + var scoreSprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: scoreTexture, + transparent: true, + depthTest: false + }) + ); + scoreSprite.scale.set(2, 0.5, 1); + scoreSprite.position.set( + (_room ? (_room.origin_x || 0) + _room.width + 0.5 : 6), + startY - legendItems.length * spacing - 0.2, + (_room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5) + ); + + _scene.add(scoreSprite); + _gdopLegendSprites.push(scoreSprite); + + _gdopLegendVisible = true; + } + + /** + * Clear the GDOP overlay. + */ + function clearGDOPOverlay() { + if (_gdopMesh) { + _scene.remove(_gdopMesh); + _gdopMesh.geometry.dispose(); + _gdopMesh.material.dispose(); + _gdopMesh = null; + } + if (_gdopTexture) { + _gdopTexture.dispose(); + _gdopTexture = null; + } + + _gdopData = null; + } + + /** + * Get current GDOP overlay state. + * @returns {Object} State object + */ + function getGDOPState() { + return { + visible: _gdopOverlayVisible, + hasData: _gdopData !== null, + coverageScore: _gdopData ? _gdopData.coverage_score : null + }; + } + /** * Focus the camera on a specific zone. * @param {string} zoneID - The zone ID to focus on @@ -2611,6 +2875,10 @@ const Viz3D = (function () { enterReplayMode: enterReplayMode, exitReplayMode: exitReplayMode, updateReplayBlobs: updateReplayBlobs, + // GDOP overlay support + setGDOPOverlayVisible: setGDOPOverlayVisible, + clearGDOPOverlay: clearGDOPOverlay, + getGDOPState: getGDOPState, }; // ── Replay Mode Support ───────────────────────────────────────────────────── // Store live blob states for replay mode restoration diff --git a/mothership/internal/api/events.go b/mothership/internal/api/events.go index 939802e..e79c4fc 100644 --- a/mothership/internal/api/events.go +++ b/mothership/internal/api/events.go @@ -184,7 +184,7 @@ func isValidEventType(t string) bool { // GET /api/events — paginated event list with FTS5 search and keyset cursor pagination. // // Query params: limit (default 50, max 500), before (timestamp_ms cursor), -// after (ISO8601), type, zone, person, q (FTS5 query). +// after (ISO8601), type, zone, person, q (FTS5 query), mode (expert|simple). // // GET /api/events/{id} — single event by ID. func (e *EventsHandler) RegisterRoutes(r chi.Router) { @@ -259,6 +259,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { zone := r.URL.Query().Get("zone") person := r.URL.Query().Get("person") afterStr := r.URL.Query().Get("after") + mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple) // Validate event type if eventType != "" && !isValidEventType(eventType) { @@ -277,6 +278,22 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { afterTS = t.UnixNano() / 1e6 } + // In simple mode, filter out system-only event types + // Simple mode shows: zone_entry, zone_exit, portal_crossing, fall_alert, anomaly, security_alert, learning_milestone + // Simple mode hides: node_online, node_offline, ota_update, baseline_changed, system + simpleModeTypes := map[string]bool{ + "zone_entry": true, + "zone_exit": true, + "portal_crossing": true, + "fall_alert": true, + "anomaly": true, + "security_alert": true, + "learning_milestone": true, + "presence_transition": true, + "stationary_detected": true, + } + isSimpleMode := mode != "expert" + // Prepare FTS5 query with prefix matching if q != "" { q = prepareFTSQuery(q) @@ -306,32 +323,29 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { baseArgs = []interface{}{} } - // Collect filter conditions (excludes before cursor — that's pagination, not filtering) - type cond struct { - sql string - arg interface{} - } - var filters []cond - - if eventType != "" { - filters = append(filters, cond{p + "type = ?", eventType}) - } - if zone != "" { - filters = append(filters, cond{p + "zone = ?", zone}) - } - if person != "" { - filters = append(filters, cond{p + "person = ?", person}) - } - if afterTS > 0 { - filters = append(filters, cond{p + "timestamp_ms >= ?", afterTS}) - } - - // Build WHERE clause with all filters (no before, no LIMIT) + // Build WHERE clause with filters whereSQL := baseWhere whereArgs := append([]interface{}{}, baseArgs...) - for _, f := range filters { - whereSQL += " AND " + f.sql - whereArgs = append(whereArgs, f.arg) + + if eventType != "" { + whereSQL += " AND " + p + "type = ?" + whereArgs = append(whereArgs, eventType) + } else if isSimpleMode { + // In simple mode with no explicit type filter, exclude system event types + whereSQL += " AND " + p + "type NOT IN (?, ?, ?, ?, ?)" + whereArgs = append(whereArgs, "node_online", "node_offline", "ota_update", "baseline_changed", "system") + } + if zone != "" { + whereSQL += " AND " + p + "zone = ?" + whereArgs = append(whereArgs, zone) + } + if person != "" { + whereSQL += " AND " + p + "person = ?" + whereArgs = append(whereArgs, person) + } + if afterTS > 0 { + whereSQL += " AND " + p + "timestamp_ms >= ?" + whereArgs = append(whereArgs, afterTS) } // COUNT for total_filtered diff --git a/mothership/internal/api/replay.go b/mothership/internal/api/replay.go index fe92f16..8b23e68 100644 --- a/mothership/internal/api/replay.go +++ b/mothership/internal/api/replay.go @@ -547,6 +547,29 @@ func (h *ReplayHandler) getSessionState(w http.ResponseWriter, r *http.Request) progress = float64(session.CurrentMS-session.FromMS) / float64(duration) } + // Convert replay blobs to API format + blobs := make([]map[string]interface{}, 0, len(session.LastBlobs)) + for _, b := range session.LastBlobs { + blob := map[string]interface{}{ + "id": b.ID, + "x": b.X, + "y": b.Y, + "z": b.Z, + "vx": b.VX, + "vy": b.VY, + "vz": b.VZ, + "weight": b.Weight, + "posture": b.Posture, + "person_id": b.PersonID, + "person_label": b.PersonLabel, + "person_color": b.PersonColor, + "identity_confidence": b.IdentityConfidence, + "identity_source": b.IdentitySource, + "trail": b.Trail, + } + blobs = append(blobs, blob) + } + // Build response with session state and blobs response := map[string]interface{}{ "session_id": sessionID, @@ -557,7 +580,8 @@ func (h *ReplayHandler) getSessionState(w http.ResponseWriter, r *http.Request) "speed": session.Speed, "progress": progress, "params": session.Params, - "blobs": []interface{}{}, // TODO: populate with actual blob data + "blobs": blobs, + "timestamp_ms": session.LastBlobTime, } writeJSON(w, http.StatusOK, response) diff --git a/mothership/internal/replay/worker.go b/mothership/internal/replay/worker.go index 96bcb3a..3c7937b 100644 --- a/mothership/internal/replay/worker.go +++ b/mothership/internal/replay/worker.go @@ -32,6 +32,10 @@ type ReplaySession struct { // Pipeline state for this session baselineState map[string]*signal.BaselineState // per-link baseline + + // Most recent blobs from replay fusion + LastBlobs []BlobUpdate + LastBlobTime int64 // timestamp_ms of the last blob update } // FusionEngine is the interface required for replay blob generation. @@ -253,8 +257,12 @@ func (w *Worker) processSession(s *ReplaySession) { // Run fusion to generate blobs if we have a fusion engine if w.fusionEngine != nil { blobs := w.runFusion() + s.LastBlobs = blobs + s.LastBlobTime = frameTimeNS / 1e6 w.broadcaster.BroadcastReplayBlobs(blobs, frameTimeNS/1e6) } else { + s.LastBlobs = []BlobUpdate{} + s.LastBlobTime = frameTimeNS / 1e6 w.broadcaster.BroadcastReplayBlobs([]BlobUpdate{}, frameTimeNS/1e6) } } @@ -343,6 +351,8 @@ func (w *Worker) StartSession(fromMS, toMS int64, speed int) (string, error) { Params: make(map[string]interface{}), CreatedAt: time.Now(), baselineState: make(map[string]*signal.BaselineState), + LastBlobs: []BlobUpdate{}, + LastBlobTime: fromMS, } w.sessions[id] = s