diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d358a5d..1fe89ed 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -82,7 +82,7 @@ {"id":"spaxel-dz5s","title":"Implement proactive quality prompts","description":"When ambient confidence score drops below 0.6 on any link for more than 5 consecutive minutes, show a non-blocking, dismissible prompt card in the dashboard. The card includes: link details, 'Diagnose' button that runs link weather diagnostics, 'Dismiss for today' option, and pulsing amber highlight on the 3D link line. Diagnostic results shown in plain English with possible causes and actions. Do NOT show prompt for temporary drops (< 5 minutes). Files: dashboard/js/proactive.js, mothership/internal/diagnostics/linkweather.go (add GetDiagnosticFor method). Acceptance: prompt appears within 5 minutes of sustained drop; no prompt for transient drops; 'Diagnose' shows root cause; dismissed prompts don't re-appear unless condition reoccurs after recovery.","status":"in_progress","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-11T03:34:48.934540862Z","created_by":"coding","updated_at":"2026-04-11T23:47:16.720368223Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:15","mitosis-child","mitosis-depth:1","parent-spaxel-tig"]} {"id":"spaxel-eelr","title":"Build guided troubleshooting","description":"Proactive contextual help and post-feedback explanations.","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-10T02:03:09.614829621Z","created_by":"coding","updated_at":"2026-04-10T08:26:46.161560106Z","closed_at":"2026-04-10T08:26:46.161384067Z","close_reason":"Implemented Component 36: Guided Troubleshooting with proactive contextual help.\n\nBackend:\n- FleetNotifier: Track node offline events with 2-hour threshold before triggering help\n- EditTracker: Monitor repeated settings changes (3+ within 60min triggers hint)\n- ZoneQualityTracker: Detect degraded detection quality (>24h below 60%)\n- DiscoveryTracker: First-time feature discovery tooltips (shown once per feature)\n- Manager: Coordinates all guided troubleshooting features with 5min periodic checks\n- REST API: /api/guided/* endpoints for issues, tooltips, feedback, calibration\n\nFrontend:\n- tooltip.js: Feature discovery tooltips with server-side coordination\n- tooltips.js: Sequential tooltip tour manager for first-time users \n- troubleshoot.js: Troubleshooting manager for quality/offline/calibration events\n- guided-help.js: Step-by-step guidance content\n\nTriggers:\n- Detection quality drops below 60% for >24 hours\n- Same setting key modified 3+ times in 60 minutes\n- Node offline for >2 hours\n- First-time feature access (tooltips)\n- After false positive feedback (explanation + adjustment offer)\n- After successful calibration (positive reinforcement)\n\nDesign principles:\n- Reactive, not proactive: Help appears only when something seems wrong\n- Dismissible in one tap: Never blocks the UI\n- Never repeats after dismissal (stored in localStorage + server)\n- Always explains what will happen next\n- Never condescending: Assumes the user is intelligent","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:10","mitosis-child","mitosis-depth:1","parent-spaxel-17u"]} {"id":"spaxel-esn","title":"Create synthetic walkers","description":"Implement synthetic walkers that move through the virtual space between nodes.\n\nAcceptance:\n- Walkers can traverse between virtual nodes\n- Movement patterns produce realistic synthetic data","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T16:11:25.513037845Z","created_by":"coding","updated_at":"2026-04-09T17:15:23.000870233Z","closed_at":"2026-04-09T17:15:23.000764431Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-d41"]} -{"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-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":"in_progress","priority":3,"issue_type":"task","assignee":"sp-needle-claude-anthropic-sonnet-sp-20260413201924-0","created_at":"2026-03-28T01:55:18.006377304Z","created_by":"coding","updated_at":"2026-04-13T23:51:07.958000284Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:10"],"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"]} @@ -156,9 +156,9 @@ {"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-s1ze","title":"Add iOS Safari safe area CSS support","description":"Add CSS environment variables for safe-area-inset to prevent content overlap with notch/home indicator on iOS devices.\n\n**Files:** dashboard/css/expert.css, dashboard/index.html (verify safe-area meta tag)\n\n**Acceptance Criteria:**\n- body has padding-top: env(safe-area-inset-top) and padding-bottom: env(safe-area-inset-bottom)\n- Hamburger menu respects env(safe-area-inset-bottom)\n- Safe-area meta tag present in index.html (viewport-fit=cover)","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-11T06:26:50.207276809Z","created_by":"coding","updated_at":"2026-04-11T07:02:52.862975215Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-kth"]} {"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-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-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":"closed","priority":3,"issue_type":"task","assignee":"sp-needle-claude-anthropic-sonnet-sp-20260413201924-0","created_at":"2026-03-28T01:54:31.341960586Z","created_by":"coding","updated_at":"2026-04-13T23:08:14.702152241Z","closed_at":"2026-04-13T23:08:14.701833767Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:11"],"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":"in_progress","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-09T17:50:35.030370233Z","created_by":"coding","updated_at":"2026-04-11T17:45:08.204329370Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-s70"]} +{"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":"closed","priority":2,"issue_type":"task","assignee":"sp-needle-claude-anthropic-sonnet-sp-20260413201924-0","created_at":"2026-04-09T17:50:35.030370233Z","created_by":"coding","updated_at":"2026-04-13T22:11:14.733752839Z","closed_at":"2026-04-13T22:11:14.733486118Z","close_reason":"Implemented sidebar timeline panel: reverse-chronological events, icons/descriptions for all 15 event types, thumbs-up/down feedback buttons delegating to Feedback module, virtualized rendering via IntersectionObserver for 10000+ events. All 19 Jest tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","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":"closed","priority":3,"issue_type":"phase","assignee":"golf","created_at":"2026-03-27T01:55:47.111916358Z","created_by":"coding","updated_at":"2026-04-10T01:37:01.689881858Z","closed_at":"2026-04-10T01:37:01.689692542Z","close_reason":"done","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":""}]} @@ -169,7 +169,7 @@ {"id":"spaxel-tr7","title":"3D spatial visualization","description":"Full Three.js 3D scene with humanoid figures, room bounds, and node meshes.\n\n## Deliverables\n- Room bounds visualization (walls, floor, ceiling from configured dimensions)\n- Floor plan texture support (uploaded image mapped to ground plane)\n- Humanoid figures using SkinnedMesh + AnimationMixer (standing/walking/seated/lying)\n- Vertical pillar anchors and footprint trails for tracked blobs\n- Node meshes at configured 3D positions with link lines between pairs\n- View presets (top-down, perspective, first-person follow)\n- WebSocket integration to receive blob positions from mothership\n\n## Acceptance Criteria\n- Humanoid figures animate smoothly between postures\n- User can orbit, pan, zoom with OrbitControls\n- Node positions and link lines update in real-time\n- Works with existing dashboard skeleton (dashboard/js/app.js)\n\n## References\n- Plan: docs/plan/plan.md item 17\n- Dashboard: dashboard/index.html, dashboard/js/app.js","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:57:04.504533558Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.148595152Z","closed_at":"2026-03-28T05:36:26.148493523Z","close_reason":"Implemented: dashboard/js/viz3d.js 566 lines (bcd19ad) — room bounds, humanoid SkinnedMesh 13-bone skeleton 4 postures, footprint trails, node meshes, link lines, 3 view presets","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-tr7","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.740185994Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-trsm","title":"Make expert mode mobile-responsive","description":"Touch orbit/pan/zoom functionality for mobile devices.","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-10T02:03:09.684731821Z","created_by":"coding","updated_at":"2026-04-10T08:27:17.474878224Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-17u"]} {"id":"spaxel-ts2","title":"Implement image upload endpoint","description":"## Task\nImplement POST /api/floorplan/image endpoint.\n\n## Specification\n- Multipart form handling\n- Accept PNG/JPG max 10 MB\n- Save to /data/floorplan/image.png\n- Reject > 10 MB upload with 413 error\n\n## Acceptance\n- Image upload saves file to /data/floorplan/image.png\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:50.419717078Z","created_by":"coding","updated_at":"2026-04-07T18:43:14.895237433Z","closed_at":"2026-04-07T18:43:14.895178829Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} -{"id":"spaxel-tvq","title":"Command palette","description":"## Background\n\nExpert users — system installers, power users, home automation enthusiasts — want to operate the dashboard entirely from the keyboard. Reaching for the mouse to navigate between nodes, zones, and features breaks focus. The command palette (Ctrl+K / Cmd+K) provides a keyboard-driven interface to every feature: navigate to any person, zone, or node by typing their name, execute commands like \"Start OTA\" or \"Toggle Fresnel overlay\", and even jump the 3D scene to specific timestamps using a time syntax.\n\n## Trigger and Appearance\n\nKeyboard shortcut: Ctrl+K (Windows/Linux) or Cmd+K (macOS). Also triggerable via a small search icon button in the dashboard header bar.\n\nAvailable in: expert mode only. Not available in simple mode or ambient mode (those are for non-technical users who don't benefit from a command palette).\n\nThe palette appears as a centred modal overlay:\n- Semi-transparent dark backdrop (opacity 0.5, backdrop-filter: blur(4px))\n- Centred container: max-width 600px, border-radius 12px, background #1e293b\n- Top: search input (auto-focused, placeholder \"Search people, zones, nodes, commands...\")\n- Below: results list (max 8 visible, scrollable)\n\nClose on: Escape key, click on backdrop, second Ctrl+K/Cmd+K.\n\n## Search Scope\n\nAll entities are cached client-side in the dashboard's state store (already maintained from WebSocket updates). No server round-trips for search — purely client-side matching.\n\nSearch categories (in priority order in results):\n1. People: names from the people registry. Icon: person silhouette. Action: jump 3D camera to person's current blob.\n2. Zones: zone names. Icon: location pin. Action: jump 3D camera to zone centroid, select zone.\n3. Nodes: node labels or MAC addresses. Icon: radio antenna. Action: jump 3D camera to node position, select node.\n4. Recent events: last 20 timeline events (titles). Icon: clock. Action: open event detail in timeline.\n5. Commands: static list of dashboard commands (see Commands below). Icon: lightning bolt. Action: execute command.\n6. Time navigation: if query starts with \"@\", attempt time parsing (see below). Icon: clock. Action: seek replay to that time.\n\n## Fuzzy Matching\n\nClient-side fuzzy matching for all categories. Use a simple Jaro-Winkler or Levenshtein-distance based scorer:\n- Exact prefix match: highest score. \"Kit\" -> \"Kitchen\" gets top score.\n- Subsequence match: \"kitch rm\" -> \"Kitchen\" matches (not adjacent characters but in order).\n- Typo tolerance: 1 character substitution tolerated for strings > 4 characters.\n\nImplementation: a custom 30-line fuzzy match function in JavaScript. No dependencies needed. The function returns a score in [0, 1]; results with score < 0.3 are excluded.\n\nSort: primary by category priority (commands highest if starting with \"/\"), secondary by match score.\n\n## Commands\n\nStatic list of commands accessible from the palette:\n\nNavigation commands:\n- \"Open settings\" -> navigate to /settings\n- \"Open fleet page\" -> navigate to /fleet\n- \"Open automations\" -> navigate to /automations\n- \"Open simulator\" -> navigate to /simulate\n\nView commands:\n- \"Toggle Fresnel overlay\" -> toggle the Fresnel zone debug layer\n- \"Toggle flow map\" -> toggle the flow map layer\n- \"Toggle dwell heatmap\" -> toggle the dwell heatmap layer\n- \"Toggle zone volumes\" -> toggle zone cuboid visibility\n- \"Reset camera\" -> fly camera back to default top-down position\n\nSystem commands:\n- \"Enter away mode\" -> POST /api/mode {\"mode\":\"away\"}\n- \"Enter home mode\" -> POST /api/mode {\"mode\":\"home\"}\n- \"Enter sleep mode\" -> POST /api/mode {\"mode\":\"sleep\"}\n- \"Trigger fleet OTA\" -> opens the fleet OTA dialog\n- \"Add a person\" -> opens the Add Person form in the People & Devices panel\n- \"Add a zone\" -> starts zone creation mode in the 3D view\n- \"Add a portal\" -> starts portal creation mode\n\nDebug commands (shown at bottom, lower priority):\n- \"Export all events CSV\" -> GET /api/events?format=csv and download\n- \"Show link health table\" -> opens the link health panel\n- \"Run diagnostics\" -> triggers a diagnostics pass and shows results\n- \"Check firmware updates\" -> fetches latest firmware version and compares to all nodes\n\n## Time Navigation\n\nIf the query starts with \"@\": attempt to parse as a time expression and offer a \"Jump to time\" result.\n\nSupported formats:\n- \"@3am\" -> today at 03:00\n- \"@3:15am\" -> today at 03:15\n- \"@yesterday 11pm\" -> yesterday at 23:00\n- \"@2026-03-27 14:23\" -> specific datetime\n- \"@-30min\" -> 30 minutes ago from now\n- \"@-2h\" -> 2 hours ago\n\nOn selection: triggers time-travel replay to the parsed timestamp (same as clicking a timeline event's tap-to-jump).\n\nShow parsing preview in the result item: \"Jump to 2026-03-27 03:00:00\" with a clock icon.\n\n## Result Item Rendering\n\nEach result item in the list:\n- Left: icon (category-appropriate SVG, 16px)\n- Centre-left: primary text (entity name or command label, 14px)\n- Centre-right: secondary text (grey, 12px): for zones: \"[N] people currently\", for nodes: \"[online/offline]\", for commands: keyboard shortcut hint if any\n- Right: arrow icon (shows the item is actionable)\n\nSelected item (keyboard navigation): blue #3b82f6 background highlight.\n\n## Keyboard Navigation\n\n- Arrow up/down: move selection through results\n- Enter: execute selected item\n- Tab: same as Enter (for keyboard-first users who use Tab to confirm)\n- Escape: close palette\n- Character keys: type to refine search (selection resets to first result on each keystroke)\n\n## Recent History\n\nWhen the palette opens with an empty query: show \"Recent\" header with the last 5 palette actions (stored in localStorage \"spaxel_palette_history\"). Format: same as search results but without scores. \"Recent\" category shown before search results.\n\nRecent history excludes time navigation entries (those are ephemeral).\n\n## Files to Create or Modify\n\n- dashboard/js/commandpalette.js: CommandPaletteManager, fuzzy match, time parsing, result rendering\n- dashboard/js/commandpalette.css: modal overlay, input, result list styles\n- dashboard/js/app.js: keyboard shortcut listener (Ctrl+K / Cmd+K), integrate CommandPaletteManager\n\n## Tests\n\n- Test fuzzy matching: \"kit\" -> \"Kitchen\" score > 0.7; \"livig rm\" -> \"Living Room\" score > 0.5; \"xyz\" -> \"Kitchen\" score < 0.3 (excluded)\n- Test time navigation parsing: \"@3am\" parses to today at 03:00; \"@-30min\" parses to 30 minutes ago; \"@2026-03-27 14:23\" parses correctly\n- Test that commands list is complete (all documented commands present in the registry)\n- Test keyboard navigation: arrow down moves selection, Enter executes, Escape closes\n- Test recent history: execute 5 actions, open palette with empty query -> 5 recent items shown\n- Test that palette does not activate in simple mode or ambient mode (keyboard listener absent)\n- Test that viewport reposition correctly positions the palette centred on the screen\n\n## Acceptance Criteria\n\n- Command palette opens with Ctrl+K (or Cmd+K on macOS) in < 50ms\n- Fuzzy search returns \"Kitchen\" for query \"kitch\", \"kit\", \"ktchn\"\n- Time navigation \"@3am\" correctly seeks replay to 03:00 today\n- All documented commands are accessible and execute correctly\n- Arrow key navigation works correctly through results\n- Recent history shows last 5 palette actions on empty query\n- Palette unavailable in simple and ambient modes\n- All features accessible in 3 keystrokes or fewer from palette open\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:02:28.058307267Z","created_by":"coding","updated_at":"2026-03-28T03:29:15.028133142Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-tvq","depends_on_id":"spaxel-s70","type":"blocks","created_at":"2026-03-28T02:02:31.595593278Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tvq","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:15.028093011Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-tvq","title":"Command palette","description":"## Background\n\nExpert users — system installers, power users, home automation enthusiasts — want to operate the dashboard entirely from the keyboard. Reaching for the mouse to navigate between nodes, zones, and features breaks focus. The command palette (Ctrl+K / Cmd+K) provides a keyboard-driven interface to every feature: navigate to any person, zone, or node by typing their name, execute commands like \"Start OTA\" or \"Toggle Fresnel overlay\", and even jump the 3D scene to specific timestamps using a time syntax.\n\n## Trigger and Appearance\n\nKeyboard shortcut: Ctrl+K (Windows/Linux) or Cmd+K (macOS). Also triggerable via a small search icon button in the dashboard header bar.\n\nAvailable in: expert mode only. Not available in simple mode or ambient mode (those are for non-technical users who don't benefit from a command palette).\n\nThe palette appears as a centred modal overlay:\n- Semi-transparent dark backdrop (opacity 0.5, backdrop-filter: blur(4px))\n- Centred container: max-width 600px, border-radius 12px, background #1e293b\n- Top: search input (auto-focused, placeholder \"Search people, zones, nodes, commands...\")\n- Below: results list (max 8 visible, scrollable)\n\nClose on: Escape key, click on backdrop, second Ctrl+K/Cmd+K.\n\n## Search Scope\n\nAll entities are cached client-side in the dashboard's state store (already maintained from WebSocket updates). No server round-trips for search — purely client-side matching.\n\nSearch categories (in priority order in results):\n1. People: names from the people registry. Icon: person silhouette. Action: jump 3D camera to person's current blob.\n2. Zones: zone names. Icon: location pin. Action: jump 3D camera to zone centroid, select zone.\n3. Nodes: node labels or MAC addresses. Icon: radio antenna. Action: jump 3D camera to node position, select node.\n4. Recent events: last 20 timeline events (titles). Icon: clock. Action: open event detail in timeline.\n5. Commands: static list of dashboard commands (see Commands below). Icon: lightning bolt. Action: execute command.\n6. Time navigation: if query starts with \"@\", attempt time parsing (see below). Icon: clock. Action: seek replay to that time.\n\n## Fuzzy Matching\n\nClient-side fuzzy matching for all categories. Use a simple Jaro-Winkler or Levenshtein-distance based scorer:\n- Exact prefix match: highest score. \"Kit\" -> \"Kitchen\" gets top score.\n- Subsequence match: \"kitch rm\" -> \"Kitchen\" matches (not adjacent characters but in order).\n- Typo tolerance: 1 character substitution tolerated for strings > 4 characters.\n\nImplementation: a custom 30-line fuzzy match function in JavaScript. No dependencies needed. The function returns a score in [0, 1]; results with score < 0.3 are excluded.\n\nSort: primary by category priority (commands highest if starting with \"/\"), secondary by match score.\n\n## Commands\n\nStatic list of commands accessible from the palette:\n\nNavigation commands:\n- \"Open settings\" -> navigate to /settings\n- \"Open fleet page\" -> navigate to /fleet\n- \"Open automations\" -> navigate to /automations\n- \"Open simulator\" -> navigate to /simulate\n\nView commands:\n- \"Toggle Fresnel overlay\" -> toggle the Fresnel zone debug layer\n- \"Toggle flow map\" -> toggle the flow map layer\n- \"Toggle dwell heatmap\" -> toggle the dwell heatmap layer\n- \"Toggle zone volumes\" -> toggle zone cuboid visibility\n- \"Reset camera\" -> fly camera back to default top-down position\n\nSystem commands:\n- \"Enter away mode\" -> POST /api/mode {\"mode\":\"away\"}\n- \"Enter home mode\" -> POST /api/mode {\"mode\":\"home\"}\n- \"Enter sleep mode\" -> POST /api/mode {\"mode\":\"sleep\"}\n- \"Trigger fleet OTA\" -> opens the fleet OTA dialog\n- \"Add a person\" -> opens the Add Person form in the People & Devices panel\n- \"Add a zone\" -> starts zone creation mode in the 3D view\n- \"Add a portal\" -> starts portal creation mode\n\nDebug commands (shown at bottom, lower priority):\n- \"Export all events CSV\" -> GET /api/events?format=csv and download\n- \"Show link health table\" -> opens the link health panel\n- \"Run diagnostics\" -> triggers a diagnostics pass and shows results\n- \"Check firmware updates\" -> fetches latest firmware version and compares to all nodes\n\n## Time Navigation\n\nIf the query starts with \"@\": attempt to parse as a time expression and offer a \"Jump to time\" result.\n\nSupported formats:\n- \"@3am\" -> today at 03:00\n- \"@3:15am\" -> today at 03:15\n- \"@yesterday 11pm\" -> yesterday at 23:00\n- \"@2026-03-27 14:23\" -> specific datetime\n- \"@-30min\" -> 30 minutes ago from now\n- \"@-2h\" -> 2 hours ago\n\nOn selection: triggers time-travel replay to the parsed timestamp (same as clicking a timeline event's tap-to-jump).\n\nShow parsing preview in the result item: \"Jump to 2026-03-27 03:00:00\" with a clock icon.\n\n## Result Item Rendering\n\nEach result item in the list:\n- Left: icon (category-appropriate SVG, 16px)\n- Centre-left: primary text (entity name or command label, 14px)\n- Centre-right: secondary text (grey, 12px): for zones: \"[N] people currently\", for nodes: \"[online/offline]\", for commands: keyboard shortcut hint if any\n- Right: arrow icon (shows the item is actionable)\n\nSelected item (keyboard navigation): blue #3b82f6 background highlight.\n\n## Keyboard Navigation\n\n- Arrow up/down: move selection through results\n- Enter: execute selected item\n- Tab: same as Enter (for keyboard-first users who use Tab to confirm)\n- Escape: close palette\n- Character keys: type to refine search (selection resets to first result on each keystroke)\n\n## Recent History\n\nWhen the palette opens with an empty query: show \"Recent\" header with the last 5 palette actions (stored in localStorage \"spaxel_palette_history\"). Format: same as search results but without scores. \"Recent\" category shown before search results.\n\nRecent history excludes time navigation entries (those are ephemeral).\n\n## Files to Create or Modify\n\n- dashboard/js/commandpalette.js: CommandPaletteManager, fuzzy match, time parsing, result rendering\n- dashboard/js/commandpalette.css: modal overlay, input, result list styles\n- dashboard/js/app.js: keyboard shortcut listener (Ctrl+K / Cmd+K), integrate CommandPaletteManager\n\n## Tests\n\n- Test fuzzy matching: \"kit\" -> \"Kitchen\" score > 0.7; \"livig rm\" -> \"Living Room\" score > 0.5; \"xyz\" -> \"Kitchen\" score < 0.3 (excluded)\n- Test time navigation parsing: \"@3am\" parses to today at 03:00; \"@-30min\" parses to 30 minutes ago; \"@2026-03-27 14:23\" parses correctly\n- Test that commands list is complete (all documented commands present in the registry)\n- Test keyboard navigation: arrow down moves selection, Enter executes, Escape closes\n- Test recent history: execute 5 actions, open palette with empty query -> 5 recent items shown\n- Test that palette does not activate in simple mode or ambient mode (keyboard listener absent)\n- Test that viewport reposition correctly positions the palette centred on the screen\n\n## Acceptance Criteria\n\n- Command palette opens with Ctrl+K (or Cmd+K on macOS) in < 50ms\n- Fuzzy search returns \"Kitchen\" for query \"kitch\", \"kit\", \"ktchn\"\n- Time navigation \"@3am\" correctly seeks replay to 03:00 today\n- All documented commands are accessible and execute correctly\n- Arrow key navigation works correctly through results\n- Recent history shows last 5 palette actions on empty query\n- Palette unavailable in simple and ambient modes\n- All features accessible in 3 keystrokes or fewer from palette open\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"sp-needle-claude-anthropic-sonnet-sp-20260413234000-0","created_at":"2026-03-28T02:02:28.058307267Z","created_by":"coding","updated_at":"2026-04-13T23:48:12.756812593Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-tvq","depends_on_id":"spaxel-s70","type":"blocks","created_at":"2026-03-28T02:02:31.595593278Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tvq","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:15.028093011Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"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":""}]} @@ -194,7 +194,7 @@ {"id":"spaxel-z2nn","title":"Add notification configuration UI to dashboard","description":"Modify mothership/internal/dashboard/routes.go. Add Settings -> Notifications tab with: delivery channel selector (None/ntfy/Pushover/Webhook), channel-specific credential fields (ntfy: server URL + topic + token, Pushover: API key, webhook: URL), test notification button, event type enable/disable toggles per type, quiet hours time picker from/to with day-of-week selector, smart batching toggle (default on), morning digest toggle (default on). API endpoints: GET/PUT /api/settings/notifications, POST /api/notifications/test.\n\nAcceptance Criteria:\n- Channel selector shows all options and saves to DB\n- Credential fields save securely to notifications_config\n- Test notification button fires immediately and verifies config\n- Event type toggles persist and filter notifications\n- Quiet hours picker saves time ranges and day bitmask\n- Smart batching and morning digest toggles persist","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-10T12:19:08.607186804Z","created_by":"coding","updated_at":"2026-04-11T22:16:30.866056794Z","closed_at":"2026-04-11T22:16:30.865726269Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:14","mitosis-child","mitosis-depth:1","parent-spaxel-zpt"]} {"id":"spaxel-z3k8","title":"Disable shadow maps on mobile","description":"On screens < 1024px width, disable shadow maps entirely or cap shadow map resolution at 512x512 in renderer initialization.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T06:59:34.187164922Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.187164922Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-gufk"]} {"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":""}]} +{"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":"closed","priority":3,"issue_type":"task","assignee":"sp-needle-claude-anthropic-sonnet-sp-20260413201924-0","created_at":"2026-03-28T01:48:19.528717849Z","created_by":"coding","updated_at":"2026-04-13T22:08:31.548378425Z","closed_at":"2026-04-13T22:08:31.548316544Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:6"],"dependencies":[{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-40tl","type":"blocks","created_at":"2026-04-10T12:19:08.664729324Z","created_by":"coding","metadata":"{}","thread_id":""},{"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-kbpt","type":"blocks","created_at":"2026-04-10T12:19:08.474051984Z","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":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-wgsz","type":"blocks","created_at":"2026-04-10T12:19:08.587569666Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-xo65","type":"blocks","created_at":"2026-04-10T12:19:08.541349556Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-z2nn","type":"blocks","created_at":"2026-04-10T12:19:08.626808416Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvb","title":"Mothership: adaptive load shedding & resource throttling","description":"## Overview\nImplement a 4-level load shedding system to keep the fusion pipeline responsive under CPU/memory pressure, especially for large fleets.\n\n## Pipeline instrumentation\n- Time each of the 8 fusion pipeline stages per iteration using time.Since()\n- Maintain 5-iteration rolling average of total iteration time (ring buffer of 5 durations)\n\n## Load shedding state machine\nLevel 0 (normal): rolling avg < 80 ms — full pipeline\nLevel 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation (~3 ms saved/iter)\nLevel 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes (~2 ms saved/iter)\nLevel 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; push rate reduction config to all nodes (10 Hz cap)\n\nRecovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level\n\n## Integration points\n- Health endpoint GET /healthz: include shedding_level (0-3) in response\n- Dashboard status bar: show 'System load: NOMINAL / LIGHT / MODERATE / HIGH'\n- WS alert when Level 3 triggered: {type: 'alert', severity: 'warning', description: 'System under load — CSI rate reduced to 10 Hz'}\n- Level 3 recovery: push config message to all nodes restoring their prior rate\n\n## Acceptance\n- Load shedding level changes logged at INFO\n- Level 3 triggers correctly when ingest channel >50% full\n- Node rate restoration confirmed after Level 3 recovery\n- Health endpoint reflects current level\n- No mutex contention from shedding logic itself (must be lock-free reads)","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-09T14:33:26.907490595Z","closed_at":"2026-04-09T14:33:26.907301564Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:228"],"dependencies":[{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.124863668Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-5yq","type":"blocks","created_at":"2026-04-07T06:33:23.159852888Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvo","title":"Interactive onboarding wizard","description":"## Background\n\nPhase 4's central goal is that a non-technical user can go from an unboxed ESP32-S3 to streaming CSI in under 5 minutes. The onboarding wizard is the centrepiece of this experience. It uses the Web Serial API (available in Chrome/Edge) to communicate with the ESP32 over USB — no driver installation needed, no CLI, no app download. The wizard is embedded in the existing mothership dashboard, accessible at /onboard.\n\n## Why Web Serial?\n\nThe alternative approaches — a dedicated mobile app, a WiFi provisioning AP, or a CLI tool — all have significant UX friction. Web Serial lets us flash firmware, provision WiFi credentials, and guide the user through calibration all in one browser session. The dashboard already knows the mothership IP/port. Chrome and Edge (95%+ of desktop browser market) support Web Serial natively since 2021. The only caveat is that Web Serial is not available in Firefox or Safari — this must be documented prominently at the start of the wizard.\n\n## Wizard Steps\n\n1. Browser check: Detect navigator.serial availability. If missing, show: \"Please use Google Chrome or Microsoft Edge to use the setup wizard. Firefox and Safari do not support USB device access.\"\n\n2. Connect device: Call navigator.serial.requestPort(). Guide the user to hold BOOT button while plugging in if the device does not appear. Show a SVG illustration of the ESP32-S3 board with the BOOT button highlighted.\n\n3. Flash firmware (if not already spaxel firmware): Use esp-web-tools (espressif/esp-web-tools). This open-source library handles the full ESP32 flashing pipeline via Web Serial, including ROM bootloader protocol, chip detection, and progress reporting. It needs a firmware manifest.json at GET /api/firmware/manifest describing binary addresses and offsets. Show a progress bar during flashing. Estimated time: 45-90 seconds.\n\n4. Provision WiFi: Show a form for SSID and password. Optional: mothership host/port override (for non-mDNS setups). Assemble the provisioning payload and send to the ESP32 over serial as JSON (see Provisioning Payload bead for format).\n\n5. Detect mothership: Once provisioned and rebooted, the ESP32 boots and discovers the mothership via mDNS (spaxel-mothership.local) or the configured host. Poll GET /api/nodes every 3s for up to 120s waiting for the new node to appear. Show animated \"Connecting...\" indicator. On timeout: show WiFi troubleshooting guidance (5GHz check, SSID typo check, distance check).\n\n6. Guided calibration: Show the CSI waveform for the new node's links as they come online. Steps:\n a. \"Walk around your space for 30 seconds\" — CSI amplitude should show activity. If flat: check node orientation.\n b. \"Stand still at the far end of the room\" — capture baseline. Show countdown. Green check when baseline is captured.\n c. \"Walk through the centre of the room\" — Fresnel zone lights up in 3D view, blob appears. \"The sensor can see you!\"\n\n7. Node placement guidance: Transition to the coverage painting UI (spaxel-qq6) for optimal node positioning. Show GDOP overlay for the current node placement. Suggest additional node positions if coverage is poor.\n\n## Files to Create or Modify\n\n- dashboard/js/onboard.js: wizard state machine, Web Serial API calls, step rendering\n- dashboard/index.html: add /onboard route and wizard container div, import esp-web-tools\n- mothership/internal/dashboard/ routes: add GET /api/firmware/manifest route\n- Firmware manifest JSON served at GET /api/firmware/manifest with chipFamily, parts array containing path and offset\n\n## esp-web-tools Integration\n\nThe library esp-web-tools is loaded from CDN as an ES module. A custom-element install-button is used for flashing. The manifest served by the mothership includes the firmware binary path (/firmware/latest) and flash offset (0x0). The library handles the bootloader handshake, erase, and write automatically.\n\n## Wizard State Machine\n\nStates: BROWSER_CHECK -> CONNECT_DEVICE -> FLASH_FIRMWARE -> PROVISION_WIFI -> DETECT_NODE -> CALIBRATE -> PLACEMENT -> COMPLETE\n\nEach state has: render() function, onEnter() side effects, onNext() transition, onBack() for revert, onError() for failure handling.\n\nPersisted in sessionStorage so a page refresh during onboarding resumes from the last step — critical for the reboot-then-detect step where the browser must survive the ESP32 reboot cycle.\n\n## Error Handling\n\nMap every known failure to a human-friendly message:\n- NotFoundError (no port selected) -> \"No device detected. Make sure the USB cable is connected and hold the BOOT button while plugging in.\"\n- NetworkError during flash -> \"The connection was interrupted. Check the USB cable is not loose and try again.\"\n- Node not appearing after 120s -> \"Your node connected to WiFi but cannot reach the mothership. Check: 1) Your router blocks device-to-device communication (AP isolation). 2) The mothership address is correct. 3) Your network uses a VLAN that separates devices.\"\n- Wrong SSID/password -> Node will fall into captive portal mode after 10 failures, triggering a \"Captive portal detected\" guidance flow.\n\nNever show stack traces, WebSocket error codes, or Go error strings to the user.\n\n## Tests\n\n- Mock navigator.serial API in Jest to test wizard state transitions without real hardware\n- Test that provisioning payload is correctly assembled and sent over the mocked serial port\n- Test that polling GET /api/nodes correctly detects node appearance and transitions to DETECT_NODE -> CALIBRATE\n- Test that BROWSER_CHECK step correctly detects missing serial API and shows the correct error\n- Test that sessionStorage correctly restores wizard state on page refresh at each step\n\n## Acceptance Criteria\n\n- Wizard completes in under 5 minutes on a fresh ESP32-S3 with a working WiFi network\n- User sees live CSI waveform during calibration step\n- Node appears in dashboard after wizard completion, with correct label\n- All known error conditions show human-friendly guidance, not technical errors\n- All existing dashboard tests pass\n- Wizard state is resumable after page refresh","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:36:08.928580604Z","created_by":"coding","updated_at":"2026-03-28T08:01:41.237288050Z","closed_at":"2026-03-28T08:01:41.237159218Z","close_reason":"Fixed 4 failing tests in the onboarding wizard test suite:\n\n1. WebSocket mock: Changed from constructor-prototype pattern to factory function so jest.resetAllMocks() doesn't break the mock. Fixed 'state.ws.close is not a function' errors during calibrate step cleanup.\n\n2. TextEncoderStream mock: Added functional readable/writable with pipeTo mock and data capture helpers (__getLastEncodedData/__clearLastEncodedData) to support provisioning serial send tests.\n\n3. flash_firmware test: Fixed assertion to check wizard-nav element for 'Skip Flashing' button instead of wizard-content (the nav button is rendered separately from step content).\n\n4. provisionAndSend 'no port' test: Changed getPorts mock from mockResolvedValueOnce to mockResolvedValue([]) so both the primary and fallback provisioning paths consistently fail when no port is available.\n\nAll 60 tests now pass. The onboarding wizard implementation (onboard.js, index.html, mothership firmware manifest route) was already complete from the previous commit.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvo","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.806490089Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvs","title":"Phase 6: Identity & Spatial Automation","description":"Goal: Named presence, actionable automations, safety features.\n\nDeliverables:\n- BLE device registry (People & Devices panel, auto-detected type, user labels/color)\n- BLE-to-blob identity matching (multi-node RSSI triangulation → nearest CSI blob)\n- Room transition portals (doorway planes, directional crossing, zone occupancy counters)\n- Spatial automation builder (3D trigger volumes, conditions, webhook/MQTT actions)\n- Fall detection (Z-axis descent + sustained stillness, alert chain, person-identified)\n- Spatial context notifications (push with mini floor-plan thumbnails, smart batching, quiet hours)\n- Home automation integration (optional MQTT for HA auto-discovery, webhooks)\n\nExit criteria: BLE-identified blobs show correct names. Fall detection fires on simulated falls <10% FP.","status":"closed","priority":3,"issue_type":"phase","assignee":"delta","created_at":"2026-03-27T01:55:32.553129034Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.888675543Z","closed_at":"2026-03-29T18:07:39.888615041Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvs","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T01:33:45.440982494Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/dashboard/css/commandpalette.css b/dashboard/css/commandpalette.css new file mode 100644 index 0000000..59206db --- /dev/null +++ b/dashboard/css/commandpalette.css @@ -0,0 +1,219 @@ +/** + * Spaxel Dashboard — Command Palette Styles (Component 34) + * + * Activated by Ctrl+K / Cmd+K in expert mode only. + */ + +/* ===== Overlay backdrop ===== */ +.cp-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 9000; +} + +.cp-overlay.cp-visible { + display: block; +} + +.cp-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + animation: cp-fade-in 0.12s ease-out; +} + +@keyframes cp-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ===== Container ===== */ +.cp-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90%; + max-width: 600px; + background: #1e293b; + border-radius: 12px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255,255,255,0.06); + overflow: hidden; + animation: cp-slide-in 0.14s ease-out; + display: flex; + flex-direction: column; +} + +@keyframes cp-slide-in { + from { + opacity: 0; + transform: translate(-50%, calc(-50% - 12px)); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } +} + +/* ===== Search row ===== */ +.cp-search-row { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.cp-search-icon { + font-size: 16px; + flex-shrink: 0; + opacity: 0.6; +} + +.cp-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: #f1f5f9; + font-size: 15px; + font-family: inherit; + line-height: 1.5; +} + +.cp-input::placeholder { + color: #64748b; +} + +.cp-esc-hint { + font-size: 11px; + color: #475569; + background: rgba(255, 255, 255, 0.07); + border-radius: 4px; + padding: 2px 7px; + flex-shrink: 0; + font-family: monospace; +} + +/* ===== Results list ===== */ +.cp-results { + list-style: none; + margin: 0; + padding: 6px 0; + max-height: 360px; /* ~8 items */ + overflow-y: auto; + overflow-x: hidden; +} + +.cp-results::-webkit-scrollbar { + width: 6px; +} + +.cp-results::-webkit-scrollbar-track { + background: transparent; +} + +.cp-results::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 3px; +} + +/* Group header */ +.cp-group-header { + padding: 6px 16px 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #475569; +} + +/* Empty state */ +.cp-empty { + padding: 32px 16px; + text-align: center; + color: #475569; + font-size: 14px; +} + +/* Result item */ +.cp-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + cursor: pointer; + transition: background 0.1s; +} + +.cp-item:hover { + background: rgba(255, 255, 255, 0.04); +} + +.cp-item-selected { + background: rgba(59, 130, 246, 0.18) !important; /* #3b82f6 at 18% */ +} + +.cp-item-icon { + font-size: 16px; + flex-shrink: 0; + width: 20px; + text-align: center; + line-height: 1; +} + +.cp-item-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.cp-item-label { + font-size: 14px; + font-weight: 500; + color: #f1f5f9; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cp-item-secondary { + font-size: 12px; + color: #64748b; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cp-item-arrow { + font-size: 18px; + color: #334155; + flex-shrink: 0; + line-height: 1; +} + +.cp-item-selected .cp-item-arrow { + color: #3b82f6; +} + +/* ===== Responsive ===== */ +@media (max-width: 640px) { + .cp-container { + width: 96%; + top: 12%; + transform: translateX(-50%); + } +} + +/* ===== Reduced motion ===== */ +@media (prefers-reduced-motion: reduce) { + .cp-backdrop, + .cp-container { + animation: none; + } +} diff --git a/dashboard/index.html b/dashboard/index.html index 4f3b1f7..a3ea52a 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -21,6 +21,7 @@ + @@ -3633,8 +3634,10 @@ - + + + diff --git a/dashboard/js/app.js b/dashboard/js/app.js index c0cf848..958a20f 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -2544,4 +2544,30 @@ rebuildFresnelDebugEllipsoids(); } }; + + // ============================================================ + // State exposure for Command Palette (Component 34) + // ============================================================ + /** + * Returns a snapshot of the current app state for use by the command palette + * and other modules. Read-only view — callers must not mutate returned objects. + */ + window.spaxelGetState = function () { + return { + nodes: Array.from(state.nodes.values()), + links: Array.from(state.links.values()), + bleDevices: Array.from(state.bleDevices.values()) + }; + }; + + // Ctrl+K / Cmd+K → Command Palette (expert mode only) + document.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + // CommandPaletteManager registers its own handler; let it run. + // This listener only acts as a fallback if the manager is not loaded. + if (!window.CommandPaletteManager) { + e.preventDefault(); + } + } + }); })(); diff --git a/dashboard/js/commandpalette.js b/dashboard/js/commandpalette.js new file mode 100644 index 0000000..61e9947 --- /dev/null +++ b/dashboard/js/commandpalette.js @@ -0,0 +1,978 @@ +/** + * Spaxel Dashboard — Command Palette (Component 34) + * + * Ctrl+K / Cmd+K: universal keyboard-driven interface for expert mode. + * Fuzzy search across zones, people, nodes, events, and commands. + * Time navigation via "@" prefix. + * + * Exposes: window.CommandPaletteManager + */ + +(function () { + 'use strict'; + + // ========================================================= + // Constants + // ========================================================= + var STORAGE_KEY = 'spaxel_palette_history'; + var MAX_RECENT = 5; + var MAX_RESULTS = 8; + + // Category priority (lower = higher in results) + var CAT_PRIORITY = { + command: 0, + time: 1, + person: 2, + zone: 3, + node: 4, + event: 5, + recent: -1 // shown only on empty query + }; + + // ========================================================= + // Levenshtein distance (compact) + // ========================================================= + function levenshteinDist(a, b) { + var m = a.length, n = b.length; + if (!m) return n; + if (!n) return m; + var prev = [], curr = []; + for (var j = 0; j <= n; j++) prev[j] = j; + for (var i = 1; i <= m; i++) { + curr[0] = i; + for (var k = 1; k <= n; k++) { + curr[k] = a[i - 1] === b[k - 1] + ? prev[k - 1] + : 1 + Math.min(prev[k], curr[k - 1], prev[k - 1]); + } + var tmp = prev; prev = curr; curr = tmp; + } + return prev[n]; + } + + // ========================================================= + // Fuzzy scorer [0, 1] + // ========================================================= + /** + * Returns a score in [0, 1] indicating how well `needle` matches `haystack`. + * Scores below 0.3 are considered non-matches and are excluded from results. + * + * Matching strategy (in priority order): + * 1. Exact prefix of full haystack → 0.90–1.00 + * 2. Exact substring of full haystack → 0.80 + * 3. Word-level matching (prefix / typo / subseq per word) + * 4. Character subsequence across full string → 0.30–0.40 + */ + function fuzzyScore(needle, haystack) { + if (!needle) return 1; + needle = needle.toLowerCase().trim(); + haystack = haystack.toLowerCase().trim(); + if (!needle) return 1; + if (needle === haystack) return 1; + + // 1. Full prefix + if (haystack.startsWith(needle)) { + return 0.90 + 0.10 * (needle.length / haystack.length); + } + + // 2. Exact substring + if (haystack.includes(needle)) { + return 0.80; + } + + // 3. Word-level matching + var needleWords = needle.split(/\s+/).filter(function (w) { return w.length > 0; }); + var haystackWords = haystack.split(/\s+/).filter(function (w) { return w.length > 0; }); + + if (needleWords.length > 0) { + var allMatch = true; + var totalScore = 0; + + for (var ni = 0; ni < needleWords.length; ni++) { + var nw = needleWords[ni]; + var bestWord = 0; + + for (var hi = 0; hi < haystackWords.length; hi++) { + var hw = haystackWords[hi]; + var ws = 0; + + if (hw.startsWith(nw)) { + ws = 0.90; + } else if (hw.includes(nw)) { + ws = 0.75; + } else if (nw.length > 2 && hw.length > 2) { + var dist = levenshteinDist(nw, hw); + if (dist === 1) { + ws = 0.70; + } else if (dist === 2 && nw.length > 4) { + ws = 0.50; + } + // Per-word subsequence (e.g. "rm" in "room") + if (ws === 0) { + var si = 0; + for (var ci = 0; ci < hw.length && si < nw.length; ci++) { + if (nw[si] === hw[ci]) si++; + } + if (si === nw.length) ws = 0.50; + } + } else if (nw.length <= 2) { + // Short needle: prefix or subsequence within each haystack word + if (hw.startsWith(nw)) { + ws = 0.75; + } else { + var si2 = 0; + for (var ci2 = 0; ci2 < hw.length && si2 < nw.length; ci2++) { + if (nw[si2] === hw[ci2]) si2++; + } + if (si2 === nw.length) ws = 0.50; + } + } + + if (ws > bestWord) bestWord = ws; + } + + if (bestWord === 0) { + allMatch = false; + break; + } + totalScore += bestWord; + } + + if (allMatch) { + return 0.40 + (totalScore / needleWords.length) * 0.30; + } + } + + // 4. Character subsequence across full string + var si3 = 0; + for (var ci3 = 0; ci3 < haystack.length && si3 < needle.length; ci3++) { + if (needle[si3] === haystack[ci3]) si3++; + } + if (si3 === needle.length) { + return 0.30 + 0.10 * (needle.length / haystack.length); + } + + return 0; + } + + // ========================================================= + // Time expression parser + // ========================================================= + /** + * Parse a "@..." time expression. + * @param {string} query - full query string starting with "@" + * @returns {Date|null} + */ + function parseTimeExpression(query) { + var s = query.replace(/^@/, '').trim(); + if (!s) return null; + var now = new Date(); + + // @-30min @-2h + var rel = s.match(/^-(\d+)(min|h)$/i); + if (rel) { + var amount = parseInt(rel[1], 10); + var unit = rel[2].toLowerCase(); + var d = new Date(now); + if (unit === 'min') d.setMinutes(d.getMinutes() - amount); + else d.setHours(d.getHours() - amount); + return d; + } + + // @2026-03-27 14:23 + var abs = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2})$/); + if (abs) { + var dt = new Date(abs[1] + 'T' + abs[2] + ':00'); + if (!isNaN(dt.getTime())) return dt; + } + + // @yesterday ... + var yest = s.match(/^yesterday\s+(.+)$/i); + if (yest) { + var base = new Date(now); + base.setDate(base.getDate() - 1); + return parseTimeOfDay(yest[1], base); + } + + // @3am @3:15pm @14:23 + return parseTimeOfDay(s, new Date(now)); + } + + function parseTimeOfDay(s, baseDate) { + // 12-hour: 3am, 3:15am, 11:30pm + var m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i); + if (m12) { + var h = parseInt(m12[1], 10); + var min = m12[2] ? parseInt(m12[2], 10) : 0; + var ampm = m12[3].toLowerCase(); + if (ampm === 'pm' && h !== 12) h += 12; + if (ampm === 'am' && h === 12) h = 0; + var r = new Date(baseDate); + r.setHours(h, min, 0, 0); + return r; + } + // 24-hour: 14:23 + var m24 = s.match(/^(\d{1,2}):(\d{2})$/); + if (m24) { + var r2 = new Date(baseDate); + r2.setHours(parseInt(m24[1], 10), parseInt(m24[2], 10), 0, 0); + return r2; + } + return null; + } + + // ========================================================= + // Command registry + // ========================================================= + var COMMANDS = [ + // ---- Navigation ---- + { + id: 'nav-settings', + label: 'Open settings', + category: 'command', + group: 'Navigation', + icon: '⚙', + hint: '', + action: function () { window.location.href = '/settings'; } + }, + { + id: 'nav-fleet', + label: 'Open fleet page', + category: 'command', + group: 'Navigation', + icon: '📡', + hint: '', + action: function () { window.location.href = '/fleet'; } + }, + { + id: 'nav-automations', + label: 'Open automations', + category: 'command', + group: 'Navigation', + icon: '⚡', + hint: '', + action: function () { window.location.href = '/automations'; } + }, + { + id: 'nav-simulator', + label: 'Open simulator', + category: 'command', + group: 'Navigation', + icon: '🔬', + hint: '', + action: function () { window.location.href = '/simulate'; } + }, + // ---- View ---- + { + id: 'view-fresnel', + label: 'Toggle Fresnel overlay', + category: 'command', + group: 'View', + icon: '◈', + hint: '', + action: function () { + if (window.toggleFresnelZones) window.toggleFresnelZones(); + } + }, + { + id: 'view-flowmap', + label: 'Toggle flow map', + category: 'command', + group: 'View', + icon: '🌊', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.toggleFlowLayer) window.Viz3D.toggleFlowLayer(); + } + }, + { + id: 'view-heatmap', + label: 'Toggle dwell heatmap', + category: 'command', + group: 'View', + icon: '🔥', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.toggleDwellLayer) window.Viz3D.toggleDwellLayer(); + } + }, + { + id: 'view-zones', + label: 'Toggle zone volumes', + category: 'command', + group: 'View', + icon: '📦', + hint: '', + action: function () { + if (window.ZoneEditor && window.ZoneEditor.toggleVolumes) window.ZoneEditor.toggleVolumes(); + else if (window.Viz3D && window.Viz3D.toggleZoneVolumes) window.Viz3D.toggleZoneVolumes(); + } + }, + { + id: 'view-reset-camera', + label: 'Reset camera', + category: 'command', + group: 'View', + icon: '🎥', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('topdown'); + } + }, + // ---- System ---- + { + id: 'mode-away', + label: 'Enter away mode', + category: 'command', + group: 'System', + icon: '🏠', + hint: '', + action: function () { + fetch('/api/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'away' }) + }); + } + }, + { + id: 'mode-home', + label: 'Enter home mode', + category: 'command', + group: 'System', + icon: '🏡', + hint: '', + action: function () { + fetch('/api/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'home' }) + }); + } + }, + { + id: 'mode-sleep', + label: 'Enter sleep mode', + category: 'command', + group: 'System', + icon: '🌙', + hint: '', + action: function () { + fetch('/api/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'sleep' }) + }); + } + }, + { + id: 'ota-fleet', + label: 'Trigger fleet OTA', + category: 'command', + group: 'System', + icon: '⬆', + hint: '', + action: function () { + if (window.SpaxelOTA && window.SpaxelOTA.openDialog) { + window.SpaxelOTA.openDialog(); + } else { + fetch('/api/nodes/update-all', { method: 'POST' }); + } + } + }, + { + id: 'add-person', + label: 'Add a person', + category: 'command', + group: 'System', + icon: '👤', + hint: '', + action: function () { + if (window.BLEPanel && window.BLEPanel.openAddPerson) window.BLEPanel.openAddPerson(); + } + }, + { + id: 'add-zone', + label: 'Add a zone', + category: 'command', + group: 'System', + icon: '📍', + hint: '', + action: function () { + if (window.ZoneEditor && window.ZoneEditor.startCreate) window.ZoneEditor.startCreate(); + } + }, + { + id: 'add-portal', + label: 'Add a portal', + category: 'command', + group: 'System', + icon: '🚪', + hint: '', + action: function () { + if (window.PortalEditor && window.PortalEditor.startCreate) window.PortalEditor.startCreate(); + } + }, + // ---- Debug ---- + { + id: 'debug-export-csv', + label: 'Export all events CSV', + category: 'command', + group: 'Debug', + icon: '📥', + hint: '', + action: function () { + var a = document.createElement('a'); + a.href = '/api/events?format=csv'; + a.download = 'spaxel-events.csv'; + a.click(); + } + }, + { + id: 'debug-link-health', + label: 'Show link health table', + category: 'command', + group: 'Debug', + icon: '📊', + hint: '', + action: function () { + if (window.LinkHealth && window.LinkHealth.openPanel) window.LinkHealth.openPanel(); + } + }, + { + id: 'debug-diagnostics', + label: 'Run diagnostics', + category: 'command', + group: 'Debug', + icon: '🔧', + hint: '', + action: function () { + fetch('/api/diagnostics', { method: 'POST' }).then(function (r) { + return r.json(); + }).then(function (data) { + if (window.showToast) window.showToast('Diagnostics: ' + (data.summary || 'done'), 'info'); + }).catch(function () { + if (window.showToast) window.showToast('Diagnostics triggered', 'info'); + }); + } + }, + { + id: 'debug-firmware-check', + label: 'Check firmware updates', + category: 'command', + group: 'Debug', + icon: '🔄', + hint: '', + action: function () { + fetch('/api/firmware').then(function (r) { return r.json(); }).then(function (data) { + var latest = data && data[0] ? data[0].version : '?'; + if (window.showToast) window.showToast('Latest firmware: v' + latest, 'info'); + }).catch(function () { + if (window.showToast) window.showToast('Could not fetch firmware info', 'warning'); + }); + } + } + ]; + + // ========================================================= + // Recent history (localStorage) + // ========================================================= + function loadHistory() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + } catch (e) { + return []; + } + } + + function saveHistory(items) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_RECENT))); + } catch (e) { + // quota error — ignore + } + } + + function addToHistory(item) { + // Exclude time navigation entries + if (item.category === 'time') return; + var hist = loadHistory().filter(function (h) { return h.id !== item.id; }); + hist.unshift({ id: item.id, label: item.label, category: item.category, icon: item.icon }); + saveHistory(hist); + } + + // ========================================================= + // Entity data source + // ========================================================= + /** + * Returns a snapshot of searchable entities from app state or cached API data. + * @returns {{ nodes: Array, zones: Array, people: Array, events: Array }} + */ + function getEntityData() { + var data = { nodes: [], zones: [], people: [], events: [] }; + + // Nodes: from app.js state exposure + if (window.spaxelGetState) { + var st = window.spaxelGetState(); + data.nodes = st.nodes || []; + } + + // Zones / people / events: use cached API snapshot if available + var cache = Manager._entityCache; + if (cache) { + data.zones = cache.zones || data.zones; + data.people = cache.people || data.people; + data.events = cache.events || data.events; + } + + return data; + } + + // ========================================================= + // Search + // ========================================================= + /** + * Search all categories with the given query. + * @param {string} query + * @returns {Array} sorted result items + */ + function search(query) { + var results = []; + var q = query.trim(); + + // Empty query: show recent history + if (!q) { + var hist = loadHistory(); + return hist.slice(0, MAX_RECENT).map(function (h) { + return { + id: h.id, + label: h.label, + category: 'recent', + icon: h.icon || '🕐', + secondary: 'Recent', + score: 1, + action: findCommandAction(h.id) + }; + }); + } + + // Time navigation + if (q.startsWith('@')) { + var dt = parseTimeExpression(q); + if (dt) { + var label = 'Jump to ' + dt.toLocaleString(); + results.push({ + id: 'time:' + q, + label: label, + category: 'time', + icon: '🕐', + secondary: dt.toISOString(), + score: 1, + action: function () { + if (window.SpaxelReplay && window.SpaxelReplay.seekTo) { + window.SpaxelReplay.seekTo(dt.getTime()); + } + } + }); + } + return results; + } + + var entities = getEntityData(); + + // Commands + COMMANDS.forEach(function (cmd) { + var s = Math.max( + fuzzyScore(q, cmd.label), + fuzzyScore(q, cmd.group || '') + ); + if (s >= 0.3) { + results.push({ + id: cmd.id, + label: cmd.label, + category: 'command', + icon: cmd.icon, + secondary: cmd.group || '', + score: s, + action: cmd.action + }); + } + }); + + // People + entities.people.forEach(function (p) { + var name = p.name || p.label || p.addr || ''; + var s = fuzzyScore(q, name); + if (s >= 0.3) { + results.push({ + id: 'person:' + name, + label: name, + category: 'person', + icon: '👤', + secondary: p.zone || '', + score: s, + action: function () { + if (window.Viz3D && window.Viz3D.flyToPerson) window.Viz3D.flyToPerson(name); + } + }); + } + }); + + // Zones + entities.zones.forEach(function (z) { + var name = z.name || ''; + var s = fuzzyScore(q, name); + if (s >= 0.3) { + var count = z.count != null ? z.count : (z.occupancy || 0); + results.push({ + id: 'zone:' + name, + label: name, + category: 'zone', + icon: '📍', + secondary: count + ' people currently', + score: s, + action: function () { + if (window.Viz3D && window.Viz3D.flyToZone) window.Viz3D.flyToZone(name); + } + }); + } + }); + + // Nodes + entities.nodes.forEach(function (n) { + var label = n.name || n.mac || ''; + var s = Math.max( + fuzzyScore(q, label), + n.mac ? fuzzyScore(q, n.mac) : 0 + ); + if (s >= 0.3) { + results.push({ + id: 'node:' + (n.mac || label), + label: label, + category: 'node', + icon: '📡', + secondary: n.status || '', + score: s, + action: function () { + if (window.Viz3D && window.Viz3D.flyToNode && n.mac) window.Viz3D.flyToNode(n.mac); + } + }); + } + }); + + // Recent events (last 20) + entities.events.forEach(function (evt) { + var title = evt.title || evt.type || ''; + var s = fuzzyScore(q, title); + if (s >= 0.3) { + results.push({ + id: 'event:' + (evt.id || title), + label: title, + category: 'event', + icon: '🕐', + secondary: evt.zone || '', + score: s, + action: function () { + if (window.SpaxelTimeline && window.SpaxelTimeline.openEvent) { + window.SpaxelTimeline.openEvent(evt.id); + } + } + }); + } + }); + + // Sort: commands first (if query starts with "/"), then by category priority, then score desc + results.sort(function (a, b) { + var pa = CAT_PRIORITY[a.category] != null ? CAT_PRIORITY[a.category] : 99; + var pb = CAT_PRIORITY[b.category] != null ? CAT_PRIORITY[b.category] : 99; + if (pa !== pb) return pa - pb; + return b.score - a.score; + }); + + return results.slice(0, MAX_RESULTS); + } + + function findCommandAction(id) { + for (var i = 0; i < COMMANDS.length; i++) { + if (COMMANDS[i].id === id) return COMMANDS[i].action; + } + return function () {}; + } + + // ========================================================= + // Mode detection + // ========================================================= + function isExpertMode() { + // Palette is unavailable in simple mode or ambient mode + if (document.body.classList.contains('simple-mode')) return false; + if (document.body.classList.contains('ambient-mode')) return false; + if (window.currentMode === 'simple' || window.currentMode === 'ambient') return false; + return true; + } + + // ========================================================= + // DOM creation + // ========================================================= + function createDOM() { + if (document.getElementById('cp-root')) return; + + var root = document.createElement('div'); + root.id = 'cp-root'; + root.className = 'cp-overlay'; + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + root.setAttribute('aria-label', 'Command palette'); + root.innerHTML = + '
' + + '
' + + '
' + + ' 🔍' + + ' ' + + ' ESC' + + '
' + + ' ' + + '
'; + + document.body.appendChild(root); + Manager.el = root; + } + + // ========================================================= + // Rendering + // ========================================================= + function renderResults(items) { + var list = document.getElementById('cp-listbox'); + if (!list) return; + + if (!items.length) { + list.innerHTML = '
  • No results
  • '; + return; + } + + var html = ''; + var lastCat = null; + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + + // Group header for "Recent" + if (item.category === 'recent' && lastCat !== 'recent') { + html += '
  • Recent
  • '; + } + + var selectedClass = (i === Manager.selectedIndex) ? ' cp-item-selected' : ''; + html += + '
  • ' + + ' ' + (item.icon || '•') + '' + + ' ' + + ' ' + escapeHtml(item.label) + '' + + ' ' + escapeHtml(item.secondary || '') + '' + + ' ' + + ' ' + + '
  • '; + + lastCat = item.category; + } + + list.innerHTML = html; + + // Click handlers + list.querySelectorAll('.cp-item').forEach(function (el) { + el.addEventListener('mousedown', function (e) { + e.preventDefault(); // prevent input blur + var idx = parseInt(el.getAttribute('data-index'), 10); + Manager.selectedIndex = idx; + Manager.execute(); + }); + }); + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ========================================================= + // Entity cache loader (one fetch per palette open) + // ========================================================= + function loadEntityCache() { + Manager._entityCache = Manager._entityCache || { zones: [], people: [], events: [] }; + + // Fetch zones + fetch('/api/zones').then(function (r) { return r.json(); }).then(function (data) { + Manager._entityCache.zones = Array.isArray(data) ? data : []; + }).catch(function () {}); + + // Fetch people (BLE devices of type "person") + fetch('/api/ble/devices?registered=true').then(function (r) { return r.json(); }).then(function (data) { + Manager._entityCache.people = (Array.isArray(data) ? data : []) + .filter(function (d) { return d.type === 'person'; }); + }).catch(function () {}); + + // Fetch recent events + fetch('/api/events?limit=20').then(function (r) { return r.json(); }).then(function (data) { + var arr = data && Array.isArray(data.events) ? data.events : (Array.isArray(data) ? data : []); + Manager._entityCache.events = arr.slice(0, 20).map(function (e) { + return { id: e.id, title: e.type || '', zone: e.zone || '', ts: e.timestamp_ms }; + }); + }).catch(function () {}); + } + + // ========================================================= + // Manager + // ========================================================= + var Manager = { + el: null, + isOpen: false, + selectedIndex: 0, + _items: [], + _entityCache: null, + + init: function () { + // Register Ctrl+K / Cmd+K globally + document.addEventListener('keydown', this._onKeydown.bind(this)); + }, + + open: function () { + if (!isExpertMode()) return; + + createDOM(); + + // Refresh entity cache (async, non-blocking) + loadEntityCache(); + + this.isOpen = true; + this.selectedIndex = 0; + this.el.classList.add('cp-visible'); + + var input = this.el.querySelector('.cp-input'); + if (input) { + input.value = ''; + setTimeout(function () { input.focus(); }, 10); + input.addEventListener('input', this._onInput.bind(this)); + input.addEventListener('keydown', this._onInputKeydown.bind(this)); + } + + var backdrop = this.el.querySelector('.cp-backdrop'); + if (backdrop) { + backdrop.addEventListener('click', this.close.bind(this)); + } + + this._showItems([]); + }, + + close: function () { + if (!this.isOpen) return; + this.isOpen = false; + + if (this.el) { + this.el.classList.remove('cp-visible'); + // Detach listeners by replacing input (simple) + var input = this.el.querySelector('.cp-input'); + if (input) { + var newInput = input.cloneNode(true); + input.parentNode.replaceChild(newInput, input); + } + } + }, + + toggle: function () { + if (this.isOpen) this.close(); + else this.open(); + }, + + execute: function () { + var item = this._items[this.selectedIndex]; + if (!item) return; + if (item.action) { + addToHistory(item); + item.action(); + } + this.close(); + }, + + _onKeydown: function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + if (!isExpertMode()) return; + this.toggle(); + } else if (e.key === 'Escape' && this.isOpen) { + e.preventDefault(); + this.close(); + } + }, + + _onInput: function (e) { + var q = e.target.value; + this.selectedIndex = 0; + var items = search(q); + this._showItems(items); + }, + + _onInputKeydown: function (e) { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.selectedIndex = Math.min(this.selectedIndex + 1, this._items.length - 1); + renderResults(this._items); + this._scrollToSelected(); + break; + case 'ArrowUp': + e.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + renderResults(this._items); + this._scrollToSelected(); + break; + case 'Enter': + case 'Tab': + e.preventDefault(); + this.execute(); + break; + case 'Escape': + this.close(); + break; + } + }, + + _showItems: function (items) { + this._items = items; + renderResults(items); + }, + + _scrollToSelected: function () { + var list = document.getElementById('cp-listbox'); + if (!list) return; + var sel = list.querySelector('.cp-item-selected'); + if (sel) sel.scrollIntoView({ block: 'nearest' }); + } + }; + + // ========================================================= + // Public API + // ========================================================= + window.CommandPaletteManager = Manager; + + // Expose internals for testing + Manager._fuzzyScore = fuzzyScore; + Manager._parseTimeExpression = parseTimeExpression; + Manager._parseTimeOfDay = parseTimeOfDay; + Manager._COMMANDS = COMMANDS; + Manager._loadHistory = loadHistory; + Manager._saveHistory = saveHistory; + Manager._addToHistory = addToHistory; + Manager._search = search; + Manager._isExpertMode = isExpertMode; + + // Auto-init when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Manager.init(); }); + } else { + Manager.init(); + } + +})(); diff --git a/dashboard/js/commandpalette.test.js b/dashboard/js/commandpalette.test.js new file mode 100644 index 0000000..b92e7b8 --- /dev/null +++ b/dashboard/js/commandpalette.test.js @@ -0,0 +1,544 @@ +/** + * Spaxel Dashboard — Command Palette Tests (Component 34) + * + * Tests for: + * - Fuzzy matching (fuzzyScore) + * - Time navigation parsing (parseTimeExpression) + * - Commands registry completeness + * - Keyboard navigation (arrow down, enter, escape) + * - Recent history (localStorage) + * - Expert-mode gating (palette unavailable in simple/ambient mode) + * - Viewport positioning (palette centred on screen) + */ + +describe('CommandPaletteManager', function () { + + // ── Setup ──────────────────────────────────────────────────────────────── + + beforeAll(function () { + // Mock localStorage + var _store = {}; + global.localStorage = { + getItem: function (k) { return _store[k] !== undefined ? _store[k] : null; }, + setItem: function (k, v) { _store[k] = String(v); }, + removeItem: function (k) { delete _store[k]; }, + clear: function () { _store = {}; } + }; + + // jsdom does not implement scrollIntoView — stub it + if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = function () {}; + } + + // Load the module (if not already loaded in this env) + if (typeof window.CommandPaletteManager === 'undefined') { + require('./commandpalette.js'); + } + }); + + beforeEach(function () { + // Reset body classes and history before each test + document.body.classList.remove('simple-mode', 'ambient-mode'); + localStorage.clear(); + + // Close palette if open + if (window.CommandPaletteManager && window.CommandPaletteManager.isOpen) { + window.CommandPaletteManager.close(); + } + + // Remove palette DOM if present + var root = document.getElementById('cp-root'); + if (root) root.parentNode.removeChild(root); + }); + + // ── Fuzzy matching ─────────────────────────────────────────────────────── + + describe('fuzzyScore', function () { + var fuzzyScore; + + beforeAll(function () { + fuzzyScore = window.CommandPaletteManager._fuzzyScore; + }); + + it('returns 1 for exact match', function () { + expect(fuzzyScore('Kitchen', 'Kitchen')).toBe(1); + }); + + it('"kit" → "Kitchen" scores > 0.7 (prefix)', function () { + expect(fuzzyScore('kit', 'Kitchen')).toBeGreaterThan(0.7); + }); + + it('"kitch" → "Kitchen" scores > 0.7 (prefix)', function () { + expect(fuzzyScore('kitch', 'Kitchen')).toBeGreaterThan(0.7); + }); + + it('"ktchn" → "Kitchen" scores >= 0.3 (subsequence)', function () { + expect(fuzzyScore('ktchn', 'Kitchen')).toBeGreaterThanOrEqual(0.3); + }); + + it('"livig rm" → "Living Room" scores > 0.5 (multi-word typo)', function () { + expect(fuzzyScore('livig rm', 'Living Room')).toBeGreaterThan(0.5); + }); + + it('"xyz" → "Kitchen" scores < 0.3 (excluded)', function () { + expect(fuzzyScore('xyz', 'Kitchen')).toBeLessThan(0.3); + }); + + it('"living room" → "Living Room" scores >= 0.8 (case insensitive substring)', function () { + expect(fuzzyScore('living room', 'Living Room')).toBeGreaterThanOrEqual(0.8); + }); + + it('empty needle returns 1 (matches everything)', function () { + expect(fuzzyScore('', 'Anything')).toBe(1); + }); + + it('"bedrm" → "Bedroom" scores >= 0.3', function () { + expect(fuzzyScore('bedrm', 'Bedroom')).toBeGreaterThanOrEqual(0.3); + }); + }); + + // ── Time parsing ───────────────────────────────────────────────────────── + + describe('parseTimeExpression', function () { + var parse; + + beforeAll(function () { + parse = window.CommandPaletteManager._parseTimeExpression; + }); + + it('@3am → today at 03:00', function () { + var d = parse('@3am'); + expect(d).not.toBeNull(); + expect(d.getHours()).toBe(3); + expect(d.getMinutes()).toBe(0); + }); + + it('@3:15am → today at 03:15', function () { + var d = parse('@3:15am'); + expect(d).not.toBeNull(); + expect(d.getHours()).toBe(3); + expect(d.getMinutes()).toBe(15); + }); + + it('@11pm → today at 23:00', function () { + var d = parse('@11pm'); + expect(d).not.toBeNull(); + expect(d.getHours()).toBe(23); + expect(d.getMinutes()).toBe(0); + }); + + it('@12am → today at 00:00 (midnight)', function () { + var d = parse('@12am'); + expect(d).not.toBeNull(); + expect(d.getHours()).toBe(0); + }); + + it('@12pm → today at 12:00 (noon)', function () { + var d = parse('@12pm'); + expect(d).not.toBeNull(); + expect(d.getHours()).toBe(12); + }); + + it('@-30min → 30 minutes ago', function () { + var before = new Date(); + var d = parse('@-30min'); + var after = new Date(); + expect(d).not.toBeNull(); + var expectedMin = before.getTime() - 30 * 60 * 1000; + var expectedMax = after.getTime() - 30 * 60 * 1000; + expect(d.getTime()).toBeGreaterThanOrEqual(expectedMin - 1000); + expect(d.getTime()).toBeLessThanOrEqual(expectedMax + 1000); + }); + + it('@-2h → 2 hours ago', function () { + var before = new Date(); + var d = parse('@-2h'); + var after = new Date(); + expect(d).not.toBeNull(); + var expectedMin = before.getTime() - 2 * 3600 * 1000; + var expectedMax = after.getTime() - 2 * 3600 * 1000; + expect(d.getTime()).toBeGreaterThanOrEqual(expectedMin - 1000); + expect(d.getTime()).toBeLessThanOrEqual(expectedMax + 1000); + }); + + it('@2026-03-27 14:23 → specific datetime', function () { + var d = parse('@2026-03-27 14:23'); + expect(d).not.toBeNull(); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(2); // March = 2 (0-indexed) + expect(d.getDate()).toBe(27); + expect(d.getHours()).toBe(14); + expect(d.getMinutes()).toBe(23); + }); + + it('@yesterday 11pm → yesterday at 23:00', function () { + var d = parse('@yesterday 11pm'); + expect(d).not.toBeNull(); + var yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + expect(d.getDate()).toBe(yesterday.getDate()); + expect(d.getHours()).toBe(23); + }); + + it('returns null for unparseable expression', function () { + var d = parse('@not-a-time'); + expect(d).toBeNull(); + }); + + it('returns null for empty @', function () { + var d = parse('@'); + expect(d).toBeNull(); + }); + }); + + // ── Commands registry completeness ─────────────────────────────────────── + + describe('Commands registry', function () { + var COMMANDS; + + beforeAll(function () { + COMMANDS = window.CommandPaletteManager._COMMANDS; + }); + + var REQUIRED_COMMANDS = [ + 'Open settings', + 'Open fleet page', + 'Open automations', + 'Open simulator', + 'Toggle Fresnel overlay', + 'Toggle flow map', + 'Toggle dwell heatmap', + 'Toggle zone volumes', + 'Reset camera', + 'Enter away mode', + 'Enter home mode', + 'Enter sleep mode', + 'Trigger fleet OTA', + 'Add a person', + 'Add a zone', + 'Add a portal', + 'Export all events CSV', + 'Show link health table', + 'Run diagnostics', + 'Check firmware updates' + ]; + + REQUIRED_COMMANDS.forEach(function (label) { + it('contains "' + label + '"', function () { + var found = COMMANDS.some(function (c) { return c.label === label; }); + expect(found).toBe(true); + }); + }); + + it('all commands have an id, label, category, and action', function () { + COMMANDS.forEach(function (cmd) { + expect(typeof cmd.id).toBe('string'); + expect(typeof cmd.label).toBe('string'); + expect(cmd.category).toBe('command'); + expect(typeof cmd.action).toBe('function'); + }); + }); + }); + + // ── Keyboard navigation ────────────────────────────────────────────────── + + describe('Keyboard navigation', function () { + + it('arrow down increments selectedIndex', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + + mgr.open(); + // Populate with some items via search + var items = mgr._search('open'); + mgr._showItems(items); + mgr.selectedIndex = 0; + + // Simulate ArrowDown keydown on the input + var input = document.querySelector('.cp-input'); + if (!input) return; + + var event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }); + input.dispatchEvent(event); + + expect(mgr.selectedIndex).toBe(1); + mgr.close(); + }); + + it('arrow up decrements selectedIndex (not below 0)', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + + mgr.open(); + var items = mgr._search('open'); + mgr._showItems(items); + mgr.selectedIndex = 0; + + var input = document.querySelector('.cp-input'); + if (!input) return; + + var event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }); + input.dispatchEvent(event); + + expect(mgr.selectedIndex).toBe(0); // clamped at 0 + mgr.close(); + }); + + it('Escape closes the palette', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + + mgr.open(); + expect(mgr.isOpen).toBe(true); + + var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + document.dispatchEvent(event); + + expect(mgr.isOpen).toBe(false); + }); + + it('Enter executes selected item and closes palette', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + + var executed = false; + mgr.open(); + + // Inject a synthetic item with a trackable action + mgr._showItems([{ + id: 'test-enter', + label: 'Test Action', + category: 'command', + icon: '•', + score: 1, + action: function () { executed = true; } + }]); + mgr.selectedIndex = 0; + + var input = document.querySelector('.cp-input'); + if (!input) return; + + var event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); + input.dispatchEvent(event); + + expect(executed).toBe(true); + expect(mgr.isOpen).toBe(false); + }); + }); + + // ── Recent history ─────────────────────────────────────────────────────── + + describe('Recent history', function () { + + it('addToHistory saves item to localStorage', function () { + var addToHistory = window.CommandPaletteManager._addToHistory; + var loadHistory = window.CommandPaletteManager._loadHistory; + if (!addToHistory || !loadHistory) return; + + addToHistory({ id: 'test-cmd', label: 'Test', category: 'command', icon: '⚙' }); + var hist = loadHistory(); + expect(hist.length).toBe(1); + expect(hist[0].id).toBe('test-cmd'); + }); + + it('addToHistory excludes time navigation entries', function () { + var addToHistory = window.CommandPaletteManager._addToHistory; + var loadHistory = window.CommandPaletteManager._loadHistory; + if (!addToHistory || !loadHistory) return; + + addToHistory({ id: 'time:@3am', label: 'Jump to 3am', category: 'time', icon: '🕐' }); + var hist = loadHistory(); + expect(hist.length).toBe(0); + }); + + it('stores at most 5 recent items', function () { + var addToHistory = window.CommandPaletteManager._addToHistory; + var loadHistory = window.CommandPaletteManager._loadHistory; + if (!addToHistory || !loadHistory) return; + + for (var i = 0; i < 7; i++) { + addToHistory({ id: 'cmd-' + i, label: 'Command ' + i, category: 'command', icon: '•' }); + } + var hist = loadHistory(); + expect(hist.length).toBeLessThanOrEqual(5); + }); + + it('most recently added item is first in history', function () { + var addToHistory = window.CommandPaletteManager._addToHistory; + var loadHistory = window.CommandPaletteManager._loadHistory; + if (!addToHistory || !loadHistory) return; + + addToHistory({ id: 'first', label: 'First', category: 'command', icon: '•' }); + addToHistory({ id: 'second', label: 'Second', category: 'command', icon: '•' }); + var hist = loadHistory(); + expect(hist[0].id).toBe('second'); + }); + + it('empty query shows recent items', function () { + var addToHistory = window.CommandPaletteManager._addToHistory; + var search = window.CommandPaletteManager._search; + if (!addToHistory || !search) return; + + addToHistory({ id: 'recent-a', label: 'Recent A', category: 'command', icon: '•' }); + addToHistory({ id: 'recent-b', label: 'Recent B', category: 'command', icon: '•' }); + + var results = search(''); + expect(results.length).toBeGreaterThan(0); + var cats = results.map(function (r) { return r.category; }); + expect(cats.every(function (c) { return c === 'recent'; })).toBe(true); + }); + + it('open palette with 5 prior actions shows 5 recent items on empty query', function () { + var addToHistory = window.CommandPaletteManager._addToHistory; + var search = window.CommandPaletteManager._search; + if (!addToHistory || !search) return; + + for (var i = 0; i < 5; i++) { + addToHistory({ id: 'hist-' + i, label: 'Hist ' + i, category: 'command', icon: '•' }); + } + var results = search(''); + expect(results.length).toBe(5); + }); + }); + + // ── Expert-mode gating ─────────────────────────────────────────────────── + + describe('Expert-mode gating', function () { + + it('isExpertMode() returns true by default (no class on body)', function () { + var isExpert = window.CommandPaletteManager._isExpertMode; + if (!isExpert) return; + document.body.classList.remove('simple-mode', 'ambient-mode'); + expect(isExpert()).toBe(true); + }); + + it('isExpertMode() returns false when body has simple-mode class', function () { + var isExpert = window.CommandPaletteManager._isExpertMode; + if (!isExpert) return; + document.body.classList.add('simple-mode'); + expect(isExpert()).toBe(false); + document.body.classList.remove('simple-mode'); + }); + + it('isExpertMode() returns false when body has ambient-mode class', function () { + var isExpert = window.CommandPaletteManager._isExpertMode; + if (!isExpert) return; + document.body.classList.add('ambient-mode'); + expect(isExpert()).toBe(false); + document.body.classList.remove('ambient-mode'); + }); + + it('open() does nothing in simple mode', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + document.body.classList.add('simple-mode'); + mgr.open(); + expect(mgr.isOpen).toBe(false); + document.body.classList.remove('simple-mode'); + }); + + it('open() does nothing in ambient mode (window.currentMode)', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + window.currentMode = 'ambient'; + mgr.open(); + expect(mgr.isOpen).toBe(false); + delete window.currentMode; + }); + + it('Ctrl+K does not open palette in simple mode', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + document.body.classList.add('simple-mode'); + var ev = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }); + document.dispatchEvent(ev); + expect(mgr.isOpen).toBe(false); + document.body.classList.remove('simple-mode'); + }); + }); + + // ── Viewport positioning ───────────────────────────────────────────────── + + describe('Viewport positioning', function () { + + it('palette container has position:absolute and transform:translate(-50%,-50%)', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + + // Open in expert mode to create DOM + mgr.open(); + expect(mgr.isOpen).toBe(true); + + var container = document.querySelector('.cp-container'); + expect(container).not.toBeNull(); + + // The CSS class sets the centering rules. + // In jsdom, computed styles aren't fully calculated, but we can + // verify the class is present on the element. + expect(container.className).toContain('cp-container'); + + mgr.close(); + }); + + it('overlay covers the viewport (cp-overlay present when open)', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + + mgr.open(); + var overlay = document.getElementById('cp-root'); + expect(overlay).not.toBeNull(); + expect(overlay.classList.contains('cp-visible')).toBe(true); + mgr.close(); + }); + + it('overlay is hidden when palette is closed', function () { + var mgr = window.CommandPaletteManager; + if (!mgr) return; + + mgr.open(); + mgr.close(); + var overlay = document.getElementById('cp-root'); + // After close, cp-visible should be removed + if (overlay) { + expect(overlay.classList.contains('cp-visible')).toBe(false); + } + }); + }); + + // ── Search results ─────────────────────────────────────────────────────── + + describe('Search', function () { + + it('search for "@3am" returns a time navigation result', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('@3am'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].category).toBe('time'); + }); + + it('search for "open" returns command results', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('open'); + expect(results.length).toBeGreaterThan(0); + var cmdResults = results.filter(function (r) { return r.category === 'command'; }); + expect(cmdResults.length).toBeGreaterThan(0); + }); + + it('results are capped at 8', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + // A broad query should still not return more than 8 + var results = search('a'); + expect(results.length).toBeLessThanOrEqual(8); + }); + + it('@unparseable returns empty array', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('@zzznottimestamp'); + expect(results.length).toBe(0); + }); + }); +}); diff --git a/dashboard/js/explainability.js b/dashboard/js/explainability.js index 36989eb..a763d05 100644 --- a/dashboard/js/explainability.js +++ b/dashboard/js/explainability.js @@ -130,6 +130,25 @@ const Explainability = (function () { html += ' ' + ' ' + ''; + + // Motion sparklines: 30-second deltaRMS history per contributing link + html += '
    ' + + '

    Signal History (30s)

    ' + + '
    '; + data.contributing_links.forEach(function (link) { + var safeID = 'sparkline-' + link.link_id.replace(/[^a-zA-Z0-9]/g, '_'); + html += + '
    ' + + ' ' + _shortenMAC(link.node_mac) + ':' + _shortenMAC(link.peer_mac) + '' + + ' ' + + ' ' + + '
    '; + }); + html += '
    ' + + '
    '; } // All links (including non-contributing) @@ -231,6 +250,129 @@ const Explainability = (function () { return colors[Math.min(zoneNumber - 1, colors.length - 1)] || '#999'; } + /** + * Draw a deltaRMS sparkline on a canvas element. + * The right edge represents the detection moment. + * A horizontal dashed line shows the motion threshold. + * + * @param {HTMLCanvasElement} canvas + * @param {number[]} points - deltaRMS values over 30 s (oldest first) + * @param {number} threshold - motion detection threshold (default 0.02) + */ + function _drawSparkline(canvas, points, threshold) { + var ctx = canvas.getContext('2d'); + var w = canvas.width; + var h = canvas.height; + threshold = threshold || 0.02; + + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, w, h); + + var maxVal = threshold * 2; + if (points && points.length > 0) { + for (var i = 0; i < points.length; i++) { + if (points[i] > maxVal) maxVal = points[i]; + } + } + maxVal = maxVal || 0.1; + + // Threshold dashed line + var threshY = h - (threshold / maxVal) * (h - 6) - 3; + ctx.save(); + ctx.setLineDash([3, 3]); + ctx.strokeStyle = '#ff6b6b'; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.7; + ctx.beginPath(); + ctx.moveTo(0, threshY); + ctx.lineTo(w, threshY); + ctx.stroke(); + ctx.restore(); + + if (!points || points.length < 2) { + // Single value: draw a flat line at current level + var curVal = (points && points.length === 1) ? points[0] : 0; + var flatY = h - (curVal / maxVal) * (h - 6) - 3; + ctx.strokeStyle = '#4FC3F7'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(0, flatY); + ctx.lineTo(w - 2, flatY); + ctx.stroke(); + } else { + var xStep = (w - 2) / (points.length - 1); + + // Fill area under sparkline + ctx.fillStyle = 'rgba(79, 195, 247, 0.12)'; + ctx.beginPath(); + for (var i = 0; i < points.length; i++) { + var x = i * xStep; + var y = h - (points[i] / maxVal) * (h - 6) - 3; + if (i === 0) ctx.moveTo(x, h); + ctx.lineTo(x, y); + } + ctx.lineTo((points.length - 1) * xStep, h); + ctx.closePath(); + ctx.fill(); + + // Sparkline + ctx.strokeStyle = '#4FC3F7'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (var i = 0; i < points.length; i++) { + var x = i * xStep; + var y = h - (points[i] / maxVal) * (h - 6) - 3; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + // Detection marker at right edge + ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(w - 1, 0); + ctx.lineTo(w - 1, h); + ctx.stroke(); + } + + /** + * Fetch 30-second deltaRMS history for each contributing link and draw sparklines. + * Falls back gracefully if the recordings API is unavailable. + * + * @param {Array} contributingLinks - Array of link contribution objects + */ + function _fetchAndDrawSparklines(contributingLinks) { + if (!contributingLinks || contributingLinks.length === 0) return; + + contributingLinks.forEach(function (link) { + var safeID = 'sparkline-' + link.link_id.replace(/[^a-zA-Z0-9]/g, '_'); + var canvas = document.getElementById(safeID); + if (!canvas) return; + + var deltaRMS = link.delta_rms || 0; + + // Try to fetch 30s history from the recordings API + fetch('/api/recordings/' + encodeURIComponent(link.link_id) + '/recent?seconds=30') + .then(function (resp) { + if (!resp.ok) throw new Error('no data'); + return resp.json(); + }) + .then(function (data) { + var points = Array.isArray(data.delta_rms) ? data.delta_rms : [deltaRMS]; + _drawSparkline(canvas, points, 0.02); + }) + .catch(function () { + // Fallback: render a flat line at the current deltaRMS value + _drawSparkline(canvas, [deltaRMS], 0.02); + }); + }); + } + function toggleSection(header) { var content = header.nextElementSibling; var icon = header.querySelector('.toggle-icon'); diff --git a/dashboard/js/explainability.test.js b/dashboard/js/explainability.test.js new file mode 100644 index 0000000..ae2de98 --- /dev/null +++ b/dashboard/js/explainability.test.js @@ -0,0 +1,426 @@ +/** + * Spaxel Dashboard - Explainability Module Tests + * + * Tests for: + * - Sidebar panel rendering with mock ExplainabilitySnapshot data + * - Scene state manipulation (dim/highlight) via scene state inspection + */ + +describe('Explainability Module', function () { + + // ── Mock state for scene inspection ────────────────────────────────────── + var _sceneObjects = []; + var _dimmedUUIDs = []; + var _highlightedLinks = []; + var _fresnelZonesAdded = []; + + // Build a minimal mock Viz3D that lets us inspect scene state. + function buildMockViz3D() { + return { + forEachRoomObject: function (cb) { + _sceneObjects.forEach(function (obj) { cb(obj); }); + }, + forEachLink: function (cb) { + _sceneObjects + .filter(function (o) { return o._isLink; }) + .forEach(function (obj) { cb(obj, obj._linkID); }); + }, + forEachBlob: function (cb) { + _sceneObjects + .filter(function (o) { return o._isBlob; }) + .forEach(function (obj) { cb(obj, obj._blobID); }); + }, + highlightLink: function (linkID, color, emissive, opacity) { + _highlightedLinks.push({ linkID: linkID, color: color, opacity: opacity }); + }, + addFresnelZone: function (cx, cy, cz, a, b, c, color, opacity) { + var mesh = { uuid: 'fz_' + _fresnelZonesAdded.length, userData: {} }; + _fresnelZonesAdded.push(mesh); + return mesh; + }, + removeFresnelZone: function (mesh) { + _fresnelZonesAdded = _fresnelZonesAdded.filter(function (m) { + return m !== mesh; + }); + }, + restoreObjectMaterial: function (uuid, state) { + // mark as restored for inspection + var obj = _sceneObjects.find(function (o) { return o.uuid === uuid; }); + if (obj && obj.material) { + obj.material.opacity = state.opacity; + obj.material.transparent = state.transparent; + } + } + }; + } + + function makeSceneObject(id, opts) { + return Object.assign({ + uuid: id, + material: { opacity: 1.0, transparent: false, emissive: { setHex: function () {}, getHex: function () { return 0; } }, needsUpdate: false }, + }, opts || {}); + } + + function makeContrib(overrides) { + return Object.assign({ + link_id: 'AA:BB:CC:DD:EE:01:AA:BB:CC:DD:EE:02', + node_mac: 'AA:BB:CC:DD:EE:01', + peer_mac: 'AA:BB:CC:DD:EE:02', + delta_rms: 0.12, + zone_number: 1, + weight: 1.0, + contributing: true, + contribution: 0.75 + }, overrides || {}); + } + + function makeMockExplainData(overrides) { + return Object.assign({ + blob_id: 1, + x: 3.2, y: 1.8, z: 1.0, + confidence: 0.87, + timestamp_ms: Date.now(), + contributing_links: [ + makeContrib({ delta_rms: 0.15, contribution: 0.60 }), + makeContrib({ + link_id: 'AA:BB:CC:DD:EE:02:AA:BB:CC:DD:EE:03', + node_mac: 'AA:BB:CC:DD:EE:02', + peer_mac: 'AA:BB:CC:DD:EE:03', + delta_rms: 0.08, contribution: 0.40 + }) + ], + all_links: [ + makeContrib({ delta_rms: 0.15, contribution: 0.60 }), + makeContrib({ + link_id: 'AA:BB:CC:DD:EE:02:AA:BB:CC:DD:EE:03', + node_mac: 'AA:BB:CC:DD:EE:02', + peer_mac: 'AA:BB:CC:DD:EE:03', + delta_rms: 0.08, contribution: 0.40 + }), + makeContrib({ + link_id: 'AA:BB:CC:DD:EE:03:AA:BB:CC:DD:EE:04', + node_mac: 'AA:BB:CC:DD:EE:03', + peer_mac: 'AA:BB:CC:DD:EE:04', + delta_rms: 0.005, contributing: false, contribution: 0.0 + }) + ], + fresnel_zones: [ + { + link_id: 'AA:BB:CC:DD:EE:01:AA:BB:CC:DD:EE:02', + center_pos: [3.2, 1.8, 1.0], + semi_axes: [2.015, 0.245, 0.245], + zone_number: 1 + } + ], + ble_match: null + }, overrides || {}); + } + + // Reset mocks before each test. + beforeEach(function () { + _sceneObjects = []; + _dimmedUUIDs = []; + _highlightedLinks = []; + _fresnelZonesAdded = []; + + // Ensure Explainability is loaded. + if (typeof window.Explainability === 'undefined') { + require('./explainability.js'); + } + + // Close any active explain state (leaves panel hidden but intact). + if (window.Explainability && window.Explainability.isActive()) { + window.Explainability.close(); + } + }); + + // ── Sidebar panel rendering ─────────────────────────────────────────────── + + describe('Sidebar panel rendering', function () { + + it('renders confidence gauge with correct percentage', function () { + if (!window.Explainability) { return; } + + // Trigger explain to create DOM. + // We stub fetchExplanation by overriding window.fetch. + var mockData = makeMockExplainData(); + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(mockData); } + }); + }; + + window.Explainability.explain(1); + + // Panel should exist and be visible. + var panel = document.getElementById('explainability-sidebar'); + expect(panel).not.toBeNull(); + }); + + it('renders the contributing links table when data contains contributing_links', function () { + if (!window.Explainability) { return; } + + var mockData = makeMockExplainData(); + + // Directly call the internal render path by closing and re-opening + // with a synthetic fetch. + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(mockData); } + }); + }; + + window.Explainability.explain(1); + + var panel = document.getElementById('explainability-sidebar'); + expect(panel).not.toBeNull(); + + // Content container should be present. + var content = document.getElementById('explainability-content'); + expect(content).not.toBeNull(); + }); + + it('shows "no explanation data" when data is null', function () { + if (!window.Explainability) { return; } + + global.fetch = function () { + return Promise.reject(new Error('network error')); + }; + + window.Explainability.explain(1); + + var content = document.getElementById('explainability-content'); + expect(content).not.toBeNull(); + }); + + it('isActive() returns false initially', function () { + if (!window.Explainability) { return; } + expect(window.Explainability.isActive()).toBe(false); + }); + + it('isActive() returns true after explain() is called', function () { + if (!window.Explainability) { return; } + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(makeMockExplainData()); } + }); + }; + + window.Explainability.explain(2); + expect(window.Explainability.isActive()).toBe(true); + }); + + it('isActive() returns false after close() is called', function () { + if (!window.Explainability) { return; } + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(makeMockExplainData()); } + }); + }; + + window.Explainability.explain(2); + window.Explainability.close(); + expect(window.Explainability.isActive()).toBe(false); + }); + + it('getCurrentBlobID() returns the blob ID passed to explain()', function () { + if (!window.Explainability) { return; } + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(makeMockExplainData()); } + }); + }; + + window.Explainability.explain(55); + expect(window.Explainability.getCurrentBlobID()).toBe(55); + }); + + it('getCurrentBlobID() returns null after close()', function () { + if (!window.Explainability) { return; } + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(makeMockExplainData()); } + }); + }; + + window.Explainability.explain(55); + window.Explainability.close(); + expect(window.Explainability.getCurrentBlobID()).toBeNull(); + }); + }); + + // ── 3D scene state inspection ───────────────────────────────────────────── + + describe('3D scene state manipulation', function () { + + it('dims all scene objects when explain mode is activated', function () { + if (!window.Explainability) { return; } + + // Populate mock scene objects. + _sceneObjects = [ + makeSceneObject('obj1'), + makeSceneObject('obj2'), + makeSceneObject('link1', { _isLink: true, _linkID: 'LINK_A' }), + ]; + window.Viz3D = buildMockViz3D(); + + var data = makeMockExplainData({ fresnel_zones: [] }); + // Invoke applyXRayOverlay via the module (internal function, not exposed). + // We call the public explain() path to trigger it, then verify scene state. + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(data); } + }); + }; + + window.Explainability.explain(1); + + // After explain() fires (synchronously for the setup portion): + // The panel should be visible. + expect(window.Explainability.isActive()).toBe(true); + }); + + it('highlights contributing links (contribution_pct > 2%) when explanation data arrives', function () { + if (!window.Explainability) { return; } + + _sceneObjects = [ + makeSceneObject('link_a', { _isLink: true, _linkID: 'LINK_A' }), + makeSceneObject('link_b', { _isLink: true, _linkID: 'LINK_B' }), + ]; + window.Viz3D = buildMockViz3D(); + + var mockData = makeMockExplainData({ + contributing_links: [ + makeContrib({ link_id: 'LINK_A', delta_rms: 0.20, contribution: 0.70 }), + makeContrib({ link_id: 'LINK_B', delta_rms: 0.05, contribution: 0.30, zone_number: 2 }) + ], + fresnel_zones: [] + }); + + // Capture whether Viz3D.highlightLink is invoked. + var highlighted = []; + window.Viz3D.highlightLink = function (linkID) { + highlighted.push(linkID); + }; + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(mockData); } + }); + }; + + window.Explainability.explain(1); + + // The overlay is applied synchronously in explain(), panel shows immediately. + expect(window.Explainability.isActive()).toBe(true); + }); + + it('restores scene state (all opacities to normal) after close()', function () { + if (!window.Explainability) { return; } + + var obj1 = makeSceneObject('obj1'); + obj1.material.opacity = 1.0; + _sceneObjects = [obj1]; + window.Viz3D = buildMockViz3D(); + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(makeMockExplainData({ fresnel_zones: [] })); } + }); + }; + + window.Explainability.explain(1); + window.Explainability.close(); + + // After close, isActive is false and the module has cleared state. + expect(window.Explainability.isActive()).toBe(false); + expect(window.Explainability.getData()).toBeNull(); + }); + + it('removes Fresnel zone meshes from scene on close()', function () { + if (!window.Explainability) { return; } + + window.Viz3D = buildMockViz3D(); + + var mockData = makeMockExplainData({ + fresnel_zones: [ + { link_id: 'L1', center_pos: [1, 0, 1], semi_axes: [2.0, 0.24, 0.24], zone_number: 1 } + ] + }); + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(mockData); } + }); + }; + + window.Explainability.explain(1); + // Before close, the module should have tracked the mesh. + // After close, removeFresnelZone should have been called. + window.Explainability.close(); + expect(window.Explainability.isActive()).toBe(false); + }); + }); + + // ── BLE match section ───────────────────────────────────────────────────── + + describe('BLE match rendering', function () { + + it('renders BLE match section when ble_match is present', function () { + if (!window.Explainability) { return; } + + var mockData = makeMockExplainData({ + ble_match: { + person_label: 'Alice', + person_color: '#4488ff', + device_addr: 'AA:BB:CC:DD:EE:FF', + confidence: 0.92, + match_method: 'ble_triangulation', + reported_by_nodes: ['kitchen-north'] + } + }); + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(mockData); } + }); + }; + + window.Explainability.explain(1); + + expect(window.Explainability.isActive()).toBe(true); + expect(window.Explainability.getCurrentBlobID()).toBe(1); + }); + + it('does not render BLE section when ble_match is null', function () { + if (!window.Explainability) { return; } + + var mockData = makeMockExplainData({ ble_match: null }); + + global.fetch = function () { + return Promise.resolve({ + ok: true, + json: function () { return Promise.resolve(mockData); } + }); + }; + + window.Explainability.explain(1); + expect(window.Explainability.isActive()).toBe(true); + }); + }); +}); diff --git a/mothership/internal/api/replay.go b/mothership/internal/api/replay.go index 3863804..7a0759d 100644 --- a/mothership/internal/api/replay.go +++ b/mothership/internal/api/replay.go @@ -654,9 +654,9 @@ func (h *ReplayHandler) GetSessions() []SessionInfo { return sessions } -// Seek moves the active replay session to the target timestamp. +// SeekTo moves the active replay session to the target timestamp. // Implements dashboard.ReplayHandler interface. -func (h *ReplayHandler) Seek(targetMS int64) error { +func (h *ReplayHandler) SeekTo(targetMS int64) error { h.mu.Lock() sessionID := h.activeSessionID h.mu.Unlock() diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index 7583bbe..d740779 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -46,6 +46,12 @@ type Hub struct { // Replay handler for time-travel debugging replayHandler ReplayHandler + + // pendingExplainBlobIDs holds blob IDs for which dashboard clients have + // requested an ExplainabilitySnapshot via "request_explain" WebSocket messages. + // The fusion loop checks this and generates the snapshot on the next tick. + pendingExplainMu sync.Mutex + pendingExplainBlobIDs map[int]bool } // snapshotCache holds serialised JSON bytes for each snapshot field, @@ -169,7 +175,7 @@ type BriefingProvider interface { // ReplayHandler is the interface for replay engine operations. type ReplayHandler interface { - Seek(targetMS int64) error + SeekTo(targetMS int64) error Play(speed float64) error Pause() error SetParams(params *replay.TunableParams) error @@ -202,13 +208,59 @@ type Client struct { // NewHub creates a new dashboard hub func NewHub() *Hub { return &Hub{ - clients: make(map[*Client]struct{}), - broadcast: make(chan []byte, 256), - register: make(chan *Client), - unregister: make(chan *Client), + clients: make(map[*Client]struct{}), + broadcast: make(chan []byte, 256), + register: make(chan *Client), + unregister: make(chan *Client), + pendingExplainBlobIDs: make(map[int]bool), } } +// RequestExplain records that a dashboard client wants an ExplainabilitySnapshot +// for the given blobID. The snapshot will be broadcast on the next fusion tick. +func (h *Hub) RequestExplain(blobID int) { + h.pendingExplainMu.Lock() + h.pendingExplainBlobIDs[blobID] = true + h.pendingExplainMu.Unlock() +} + +// ConsumeExplainRequests returns and clears the set of blob IDs pending explain +// snapshots. Called by the fusion loop each tick. +func (h *Hub) ConsumeExplainRequests() []int { + h.pendingExplainMu.Lock() + defer h.pendingExplainMu.Unlock() + if len(h.pendingExplainBlobIDs) == 0 { + return nil + } + ids := make([]int, 0, len(h.pendingExplainBlobIDs)) + for id := range h.pendingExplainBlobIDs { + ids = append(ids, id) + } + h.pendingExplainBlobIDs = make(map[int]bool) + return ids +} + +// BroadcastExplainSnapshot broadcasts a "blob_explain" WebSocket message +// containing the ExplainabilitySnapshot for a blob. +// +// The snapshot is serialised from the provided data map and broadcast to all +// connected dashboard clients. The message format is: +// +// {"type":"blob_explain","blob_id":N,"snapshot":{...}} +func (h *Hub) BroadcastExplainSnapshot(blobID int, snapshot interface{}) { + msg := map[string]interface{}{ + "type": "blob_explain", + "blob_id": blobID, + "snapshot": snapshot, + } + data, err := json.Marshal(msg) + if err != nil { + log.Printf("[WARN] Failed to marshal blob_explain message: %v", err) + return + } + h.Broadcast(data) +} + // SetIngestionState sets the ingestion state provider func (h *Hub) SetIngestionState(state IngestionState) { h.mu.Lock() diff --git a/mothership/internal/dashboard/hub_test.go b/mothership/internal/dashboard/hub_test.go index 82b67d8..2d9657e 100644 --- a/mothership/internal/dashboard/hub_test.go +++ b/mothership/internal/dashboard/hub_test.go @@ -967,3 +967,130 @@ func TestHub_BroadcastTriggerState(t *testing.T) { }) } } + +// ---- ExplainabilitySnapshot WebSocket flow tests ---- + +// TestHub_RequestExplain_ConsumeExplainRequests verifies that RequestExplain +// enqueues a blob ID and ConsumeExplainRequests drains the queue. +func TestHub_RequestExplain_ConsumeExplainRequests(t *testing.T) { + hub := NewHub() + + // Initially nothing pending. + if ids := hub.ConsumeExplainRequests(); len(ids) != 0 { + t.Fatalf("expected empty queue, got %v", ids) + } + + // Enqueue a request. + hub.RequestExplain(42) + ids := hub.ConsumeExplainRequests() + if len(ids) != 1 || ids[0] != 42 { + t.Fatalf("expected [42], got %v", ids) + } + + // Queue should be empty after consuming. + if ids2 := hub.ConsumeExplainRequests(); len(ids2) != 0 { + t.Fatalf("expected empty queue after consume, got %v", ids2) + } +} + +// TestHub_RequestExplain_Deduplicate verifies that duplicate requests for the same +// blob ID are deduplicated (the ID appears only once per consume cycle). +func TestHub_RequestExplain_Deduplicate(t *testing.T) { + hub := NewHub() + + hub.RequestExplain(7) + hub.RequestExplain(7) + hub.RequestExplain(7) + + ids := hub.ConsumeExplainRequests() + if len(ids) != 1 { + t.Fatalf("expected deduplication to 1 entry, got %d: %v", len(ids), ids) + } + if ids[0] != 7 { + t.Fatalf("expected id=7, got %d", ids[0]) + } +} + +// TestHub_BroadcastExplainSnapshot verifies that BroadcastExplainSnapshot sends a +// correctly structured "blob_explain" message to all connected clients. +func TestHub_BroadcastExplainSnapshot(t *testing.T) { + hub := NewHub() + go hub.Run() + + client := &Client{ + hub: hub, + send: make(chan []byte, 20), + } + hub.Register(client) + time.Sleep(10 * time.Millisecond) + drainSnapshot(t, client.send) // discard the initial snapshot + + snapshot := map[string]interface{}{ + "blob_id": 1, + "blob_position": [3]float64{3.2, 1.8, 1.0}, + "fusion_score": 0.87, + } + hub.BroadcastExplainSnapshot(1, snapshot) + + select { + case msg := <-client.send: + var parsed map[string]interface{} + if err := json.Unmarshal(msg, &parsed); err != nil { + t.Fatalf("failed to unmarshal blob_explain message: %v", err) + } + if parsed["type"] != "blob_explain" { + t.Errorf("expected type='blob_explain', got %v", parsed["type"]) + } + blobIDRaw, ok := parsed["blob_id"] + if !ok { + t.Fatal("missing blob_id field") + } + switch v := blobIDRaw.(type) { + case float64: + if int(v) != 1 { + t.Errorf("expected blob_id=1, got %v", v) + } + default: + t.Errorf("unexpected blob_id type %T: %v", blobIDRaw, blobIDRaw) + } + if _, ok := parsed["snapshot"]; !ok { + t.Error("missing snapshot field in blob_explain message") + } + case <-time.After(100 * time.Millisecond): + t.Error("expected to receive blob_explain broadcast") + } +} + +// TestServer_HandleRequestExplain verifies that a "request_explain" WebSocket +// command enqueues the blob ID in the hub for the next fusion tick. +func TestServer_HandleRequestExplain(t *testing.T) { + hub := NewHub() + server := NewServer(hub) + + cmd := []byte(`{"type":"request_explain","blob_id":99}`) + server.handleCommand(cmd, nil) + + ids := hub.ConsumeExplainRequests() + if len(ids) != 1 || ids[0] != 99 { + t.Fatalf("expected [99] after handleRequestExplain, got %v", ids) + } +} + +// TestServer_HandleRequestExplain_MissingBlobID verifies graceful handling of a +// "request_explain" command with a missing or invalid blob_id field. +func TestServer_HandleRequestExplain_MissingBlobID(t *testing.T) { + hub := NewHub() + server := NewServer(hub) + + // Missing blob_id — should be silently ignored. + server.handleCommand([]byte(`{"type":"request_explain"}`), nil) + if ids := hub.ConsumeExplainRequests(); len(ids) != 0 { + t.Fatalf("expected no enqueued IDs for missing blob_id, got %v", ids) + } + + // blob_id is a string — should also be silently ignored. + server.handleCommand([]byte(`{"type":"request_explain","blob_id":"not_a_number"}`), nil) + if ids := hub.ConsumeExplainRequests(); len(ids) != 0 { + t.Fatalf("expected no enqueued IDs for string blob_id, got %v", ids) + } +} diff --git a/mothership/internal/dashboard/server.go b/mothership/internal/dashboard/server.go index c3ee13f..f35d25d 100644 --- a/mothership/internal/dashboard/server.go +++ b/mothership/internal/dashboard/server.go @@ -140,6 +140,8 @@ func (s *Server) handleCommand(data []byte, client *Client) { s.handleReplayApplyToLive(cmd) case "replay_set_speed": s.handleReplaySetSpeed(cmd) + case "request_explain": + s.handleRequestExplain(cmd) default: // Unknown command type - ignore log.Printf("[DEBUG] Unknown WebSocket command type: %s", cmdType) @@ -162,7 +164,7 @@ func (s *Server) handleReplaySeek(cmd map[string]interface{}) { // Forward to replay handler if available if s.hub.replayHandler != nil { - s.hub.replayHandler.Seek(targetMS) + s.hub.replayHandler.SeekTo(targetMS) } } @@ -305,6 +307,24 @@ func (s *Server) writePump(conn *websocket.Conn, client *Client) { } } +// handleRequestExplain handles "request_explain" commands from the dashboard. +// The client sends {"type":"request_explain","blob_id":N} to request that the +// server emit a "blob_explain" message on the next fusion tick. +func (s *Server) handleRequestExplain(cmd map[string]interface{}) { + var blobID int + switch v := cmd["blob_id"].(type) { + case float64: + blobID = int(v) + case int: + blobID = v + default: + log.Printf("[WARN] request_explain: missing or invalid blob_id field") + return + } + s.hub.RequestExplain(blobID) + log.Printf("[DEBUG] request_explain queued for blob %d", blobID) +} + // Hub returns the server's hub for external use func (s *Server) Hub() *Hub { return s.hub diff --git a/mothership/internal/fusion/explain.go b/mothership/internal/fusion/explain.go new file mode 100644 index 0000000..4758f37 --- /dev/null +++ b/mothership/internal/fusion/explain.go @@ -0,0 +1,245 @@ +package fusion + +import ( + "math" + "time" +) + +// ExplainabilitySnapshot contains all data needed to explain why a specific +// blob appeared at a specific position. It is emitted alongside each BlobUpdate. +type ExplainabilitySnapshot struct { + // BlobID is the ID of the blob being explained. + BlobID int `json:"blob_id"` + // BlobPosition is the final estimated position [x, y, z] in metres. + BlobPosition [3]float64 `json:"blob_position"` + // PerLinkContributions describes how each link contributed to this detection. + PerLinkContributions []ExplainLinkContribution `json:"per_link_contributions"` + // BLEMatch is optional identity information if a BLE device matched. + BLEMatch *ExplainBLEMatch `json:"ble_match,omitempty"` + // FusionScore is the total occupancy grid score at blob position. + FusionScore float64 `json:"fusion_score"` + // Timestamp is when this snapshot was generated. + Timestamp time.Time `json:"timestamp"` +} + +// ExplainLinkContribution describes a single link's contribution to a blob. +type ExplainLinkContribution struct { + // LinkID is the canonical link identifier ("tx_mac:rx_mac"). + LinkID string `json:"link_id"` + // TXMAC is the transmitting node's MAC address. + TXMAC string `json:"tx_mac"` + // RXMAC is the receiving node's MAC address. + RXMAC string `json:"rx_mac"` + // Weight is the geometric Fresnel weight (health score) for this link. + Weight float64 `json:"weight"` + // LearnedWeight is the per-link learned spatial weight from the weight learner. + LearnedWeight float64 `json:"learned_weight"` + // CombinedWeight is Weight * LearnedWeight. + CombinedWeight float64 `json:"combined_weight"` + // DeltaRMS is the current deltaRMS for this link. + DeltaRMS float64 `json:"delta_rms"` + // ContributionPct is the percentage of total fusion score contributed by this link. + ContributionPct float64 `json:"contribution_pct"` + // FresnelIntersectionVolume is a proxy for how much this link "sees" the blob + // position — estimated from ellipsoid volume and zone decay. + FresnelIntersectionVolume float64 `json:"fresnel_intersection_volume"` + // ZoneNumber is the Fresnel zone number at the blob position (1 = highest sensitivity). + ZoneNumber int `json:"zone_number"` + // Contributing is true if this link actively contributed (motion above threshold). + Contributing bool `json:"contributing"` +} + +// ExplainBLEMatch holds optional BLE identity information for a blob. +type ExplainBLEMatch struct { + DeviceMAC string `json:"device_mac"` + PersonID string `json:"person_id"` + PersonLabel string `json:"person_label"` + BLEDistanceM float64 `json:"ble_distance_m"` + TriangulationConfidence float64 `json:"triangulation_confidence"` +} + +// GenerateExplainabilitySnapshot creates an ExplainabilitySnapshot for a single +// blob using the fusion result and per-link motion data. +// +// - result is the most recent fusion result. +// - blobIdx selects which blob in result.Blobs to explain. +// - blobID is the tracking ID assigned by the blob tracker. +// - links is the full list of links processed in the most recent fusion cycle. +// - nodePos maps node MAC addresses to their 3D positions. +// - learnedWeights maps canonical link IDs to their learned weight (1.0 default). +// - lambda is the WiFi wavelength in metres (0.125 for 2.4 GHz, 0.06 for 5 GHz). +// - cellSize is the fusion grid cell size in metres. +func GenerateExplainabilitySnapshot( + result *Result, + blobIdx int, + blobID int, + links []LinkMotion, + nodePos map[string]NodePosition, + learnedWeights map[string]float64, + lambda, cellSize float64, +) *ExplainabilitySnapshot { + if result == nil || blobIdx < 0 || blobIdx >= len(result.Blobs) { + return nil + } + if lambda <= 0 { + lambda = 0.125 + } + if cellSize <= 0 { + cellSize = 0.2 + } + + blob := result.Blobs[blobIdx] + snap := &ExplainabilitySnapshot{ + BlobID: blobID, + BlobPosition: [3]float64{blob.X, blob.Y, blob.Z}, + FusionScore: blob.Confidence, + Timestamp: result.Timestamp, + } + + type linkScore struct { + lm LinkMotion + posA NodePosition + posB NodePosition + weight float64 + learned float64 + combined float64 + zoneNum int + rawScore float64 + fiv float64 + } + + // Compute raw score for each link at the blob position. + scores := make([]linkScore, 0, len(links)) + totalScore := 0.0 + + for _, lm := range links { + posA, okA := nodePos[lm.NodeMAC] + posB, okB := nodePos[lm.PeerMAC] + if !okA || !okB { + continue + } + + linkID := lm.NodeMAC + ":" + lm.PeerMAC + learned := 1.0 + if lw, ok := learnedWeights[linkID]; ok && lw > 0 { + learned = lw + } + + // Geometric Fresnel weight comes from the HealthScore field; default to 1.0. + geoWeight := lm.HealthScore + if geoWeight <= 0 { + geoWeight = 1.0 + } + combined := geoWeight * learned + + // Fresnel zone number at blob position. + zoneNum := fresnelZoneAtPosition(posA, posB, blob.X, blob.Y, blob.Z) + + // Zone decay (decay_rate = 2.0 per plan.md). + zoneDecay := 1.0 / math.Pow(float64(zoneNum), 2.0) + + rawScore := lm.DeltaRMS * combined * zoneDecay + fiv := fresnelIntersectionVolume(posA, posB, blob.X, blob.Y, blob.Z, cellSize, lambda) + + totalScore += rawScore + scores = append(scores, linkScore{ + lm: lm, + posA: posA, + posB: posB, + weight: geoWeight, + learned: learned, + combined: combined, + zoneNum: zoneNum, + rawScore: rawScore, + fiv: fiv, + }) + } + + // Build ExplainLinkContribution for each link with normalised contribution_pct. + contribs := make([]ExplainLinkContribution, 0, len(scores)) + for _, s := range scores { + pct := 0.0 + if totalScore > 0 { + pct = (s.rawScore / totalScore) * 100.0 + } + linkID := s.lm.NodeMAC + ":" + s.lm.PeerMAC + contribs = append(contribs, ExplainLinkContribution{ + LinkID: linkID, + TXMAC: s.lm.NodeMAC, + RXMAC: s.lm.PeerMAC, + Weight: s.weight, + LearnedWeight: s.learned, + CombinedWeight: s.combined, + DeltaRMS: s.lm.DeltaRMS, + ContributionPct: pct, + FresnelIntersectionVolume: s.fiv, + ZoneNumber: s.zoneNum, + Contributing: s.lm.Motion && s.lm.DeltaRMS > 0.02, + }) + } + + snap.PerLinkContributions = contribs + return snap +} + +// fresnelIntersectionVolume estimates the volume of the first Fresnel zone ellipsoid +// that overlaps a voxel of the given cellSize centred on the blob position. +// +// This is a simplified proxy calculation: the actual ellipsoid/voxel intersection +// requires expensive numerical integration. Instead we estimate by scaling the +// full ellipsoid volume by the zone decay factor and capping at one voxel volume. +func fresnelIntersectionVolume(tx, rx NodePosition, px, py, pz, cellSize, lambda float64) float64 { + if lambda <= 0 { + lambda = 0.125 + } + + // Direct path distance for the link. + dxl := rx.X - tx.X + dyl := rx.Y - tx.Y + dzl := rx.Z - tx.Z + directDist := math.Sqrt(dxl*dxl + dyl*dyl + dzl*dzl) + if directDist < 1e-9 { + return 0 + } + + // First Fresnel zone ellipsoid semi-axes. + a := (directDist + lambda/2) / 2 + bAxis := math.Sqrt(math.Max(0, a*a-(directDist/2)*(directDist/2))) + ellipsoidVolume := (4.0 / 3.0) * math.Pi * a * bAxis * bAxis + + // Path length excess at the blob position. + dtx := math.Sqrt((px-tx.X)*(px-tx.X) + (py-tx.Y)*(py-tx.Y) + (pz-tx.Z)*(pz-tx.Z)) + dtr := math.Sqrt((rx.X-px)*(rx.X-px) + (rx.Y-py)*(rx.Y-py) + (rx.Z-pz)*(rx.Z-pz)) + excess := dtx + dtr - directDist + if excess < 0 { + excess = 0 + } + + // Zone number and decay. + zone := math.Ceil(excess / (lambda / 2)) + if zone < 1 { + zone = 1 + } + decay := 1.0 / (zone * zone) + + voxelVol := cellSize * cellSize * cellSize + return math.Min(ellipsoidVolume, voxelVol) * decay +} + +// ComputeFresnelEllipsoidAxes returns the semi-major axis (a), semi-minor axis (b), +// and link distance (d) for the first Fresnel zone ellipsoid of a link. +// +// TX and RX give the positions of the two endpoints; lambda is the wavelength in metres. +// Returns a, b, d. +func ComputeFresnelEllipsoidAxes(tx, rx NodePosition, lambda float64) (a, b, d float64) { + if lambda <= 0 { + lambda = 0.125 + } + dx := rx.X - tx.X + dy := rx.Y - tx.Y + dz := rx.Z - tx.Z + d = math.Sqrt(dx*dx + dy*dy + dz*dz) + a = (d + lambda/2) / 2 + b = math.Sqrt(math.Max(0, a*a-(d/2)*(d/2))) + return +} diff --git a/mothership/internal/fusion/fusion_test.go b/mothership/internal/fusion/fusion_test.go index 0a333ff..65bdd33 100644 --- a/mothership/internal/fusion/fusion_test.go +++ b/mothership/internal/fusion/fusion_test.go @@ -400,3 +400,150 @@ func TestEngine_PerformanceTwentyLinks(t *testing.T) { t.Errorf("fusion took %v per call (limit %v)", perFuse, limit) } } + +// ---- ExplainabilitySnapshot tests ---- + +// TestExplainabilitySnapshot_ThreeLinks verifies that GenerateExplainabilitySnapshot +// correctly computes per-link contributions for 3 known links with a blob at a +// known position. +func TestExplainabilitySnapshot_ThreeLinks(t *testing.T) { + nodePos := map[string]NodePosition{ + "AA:BB:CC:DD:EE:01": {MAC: "AA:BB:CC:DD:EE:01", X: 0, Y: 1, Z: 0}, + "AA:BB:CC:DD:EE:02": {MAC: "AA:BB:CC:DD:EE:02", X: 4, Y: 1, Z: 0}, + "AA:BB:CC:DD:EE:03": {MAC: "AA:BB:CC:DD:EE:03", X: 2, Y: 1, Z: 4}, + } + links := []LinkMotion{ + {NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:02", DeltaRMS: 0.10, Motion: true, HealthScore: 1.0}, + {NodeMAC: "AA:BB:CC:DD:EE:02", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.05, Motion: true, HealthScore: 1.0}, + {NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.08, Motion: true, HealthScore: 1.0}, + } + result := &Result{ + Blobs: []Blob{{X: 2, Y: 1, Z: 2, Confidence: 0.85}}, + Timestamp: time.Now(), + } + + snap := GenerateExplainabilitySnapshot(result, 0, 1, links, nodePos, nil, 0.125, 0.2) + if snap == nil { + t.Fatal("expected non-nil snapshot") + } + if snap.BlobID != 1 { + t.Errorf("blob_id: got %d, want 1", snap.BlobID) + } + if got := [3]float64{snap.BlobPosition[0], snap.BlobPosition[1], snap.BlobPosition[2]}; got != [3]float64{2, 1, 2} { + t.Errorf("blob_position: got %v, want [2 1 2]", got) + } + if len(snap.PerLinkContributions) != 3 { + t.Fatalf("expected 3 per-link contributions, got %d", len(snap.PerLinkContributions)) + } + // Verify each contribution has a positive deltaRMS and correct link IDs. + for _, c := range snap.PerLinkContributions { + if c.DeltaRMS <= 0 { + t.Errorf("link %s: DeltaRMS should be > 0, got %f", c.LinkID, c.DeltaRMS) + } + if c.ZoneNumber < 1 { + t.Errorf("link %s: ZoneNumber should be >= 1, got %d", c.LinkID, c.ZoneNumber) + } + if c.CombinedWeight <= 0 { + t.Errorf("link %s: CombinedWeight should be > 0, got %f", c.LinkID, c.CombinedWeight) + } + // Contributing flag: links with Motion=true and DeltaRMS > 0.02 + if !c.Contributing { + t.Errorf("link %s: Contributing should be true (DeltaRMS=%f, Motion=true)", c.LinkID, c.DeltaRMS) + } + } +} + +// TestExplainabilitySnapshot_ContributionPctSums verifies that the sum of +// ContributionPct across all links equals approximately 100%. +func TestExplainabilitySnapshot_ContributionPctSums(t *testing.T) { + nodePos := map[string]NodePosition{ + "AA:BB:CC:DD:EE:01": {MAC: "AA:BB:CC:DD:EE:01", X: 0, Y: 1, Z: 0}, + "AA:BB:CC:DD:EE:02": {MAC: "AA:BB:CC:DD:EE:02", X: 4, Y: 1, Z: 0}, + "AA:BB:CC:DD:EE:03": {MAC: "AA:BB:CC:DD:EE:03", X: 2, Y: 1, Z: 4}, + } + links := []LinkMotion{ + {NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:02", DeltaRMS: 0.15, Motion: true, HealthScore: 1.0}, + {NodeMAC: "AA:BB:CC:DD:EE:02", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.08, Motion: true, HealthScore: 1.0}, + {NodeMAC: "AA:BB:CC:DD:EE:01", PeerMAC: "AA:BB:CC:DD:EE:03", DeltaRMS: 0.12, Motion: true, HealthScore: 1.0}, + } + result := &Result{ + Blobs: []Blob{{X: 2, Y: 1, Z: 2, Confidence: 0.80}}, + Timestamp: time.Now(), + } + + snap := GenerateExplainabilitySnapshot(result, 0, 2, links, nodePos, nil, 0.125, 0.2) + if snap == nil { + t.Fatal("expected non-nil snapshot") + } + total := 0.0 + for _, c := range snap.PerLinkContributions { + total += c.ContributionPct + } + if math.Abs(total-100.0) > 0.01 { + t.Errorf("contribution_pct sum = %.4f, want ~100.0", total) + } +} + +// TestExplainabilitySnapshot_NilOnInvalidBlob verifies that nil is returned when +// the blob index is out of bounds. +func TestExplainabilitySnapshot_NilOnInvalidBlob(t *testing.T) { + result := &Result{Blobs: []Blob{{X: 1, Y: 1, Z: 1, Confidence: 0.5}}} + if snap := GenerateExplainabilitySnapshot(result, 5, 1, nil, nil, nil, 0.125, 0.2); snap != nil { + t.Error("expected nil for out-of-range blob index") + } + if snap := GenerateExplainabilitySnapshot(nil, 0, 1, nil, nil, nil, 0.125, 0.2); snap != nil { + t.Error("expected nil for nil result") + } +} + +// TestComputeFresnelEllipsoidAxes verifies the Fresnel ellipsoid geometry for a +// 4-metre link with 5 GHz WiFi (lambda = 0.06 m). +// +// Expected values: +// +// d = 4.0 m +// a = (d + lambda/2) / 2 = (4 + 0.03) / 2 = 2.015 m +// b = sqrt(a² − (d/2)²) = sqrt(2.015² − 4) = sqrt(0.060225) ≈ 0.245 m +func TestComputeFresnelEllipsoidAxes(t *testing.T) { + tx := NodePosition{X: 0, Y: 0, Z: 0} + rx := NodePosition{X: 4, Y: 0, Z: 0} + lambda := 0.06 // 5 GHz + + a, b, d := ComputeFresnelEllipsoidAxes(tx, rx, lambda) + + const tol = 0.001 + if math.Abs(d-4.0) > tol { + t.Errorf("d = %f, want 4.000 (±%f)", d, tol) + } + if math.Abs(a-2.015) > tol { + t.Errorf("a = %f, want 2.015 (±%f)", a, tol) + } + // b = sqrt(2.015^2 - 2^2) = sqrt(0.060225) ≈ 0.2454 + wantB := math.Sqrt(2.015*2.015 - 2.0*2.0) + if math.Abs(b-wantB) > tol { + t.Errorf("b = %f, want %f (±%f)", b, wantB, tol) + } +} + +// TestComputeFresnelEllipsoidAxes_2_4GHz verifies the geometry for 2.4 GHz WiFi +// (lambda = 0.125 m) with the same 4-metre link. +func TestComputeFresnelEllipsoidAxes_2_4GHz(t *testing.T) { + tx := NodePosition{X: 0, Y: 0, Z: 0} + rx := NodePosition{X: 4, Y: 0, Z: 0} + lambda := 0.125 + + a, b, d := ComputeFresnelEllipsoidAxes(tx, rx, lambda) + + const tol = 0.001 + if math.Abs(d-4.0) > tol { + t.Errorf("d = %f, want 4.000", d) + } + wantA := (4.0 + 0.125/2) / 2 + if math.Abs(a-wantA) > tol { + t.Errorf("a = %f, want %f", a, wantA) + } + wantB := math.Sqrt(wantA*wantA - 2.0*2.0) + if math.Abs(b-wantB) > tol { + t.Errorf("b = %f, want %f", b, wantB) + } +}