diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 763028a..582d236 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -14,12 +14,14 @@ {"id":"spaxel-6hd","title":"Floor plan image upload and pixel-to-meter calibration","description":"## Overview\nAllow users to upload a floor plan image (PNG/JPG) and calibrate it to real-world coordinates so the 3D scene displays nodes and blobs at accurate physical positions.\n\n## Backend (mothership/internal/ — new floorplan.go)\n- POST /api/floorplan/image — multipart form; accept PNG/JPG max 10 MB; save to /data/floorplan/image.png\n- GET /api/floorplan/image — serve the stored image (200 or 404 if none)\n- POST /api/floorplan/calibrate — accept {ax,ay,bx,by,distance_m,rotation_deg}: two pixel coordinates and their real-world distance; compute and persist pixel-to-meter transform\n- GET /api/floorplan/calibrate — return current calibration or 404 if none\n- SQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Dashboard (dashboard/js/floorplan-setup.js)\n- Setup panel section: 'Floor Plan' with upload button\n- On image select: POST to /api/floorplan/image; display uploaded image on ground plane in 3D scene\n- Calibration UI: click point A on image → click point B → enter real-world distance in meters → Save\n- Compute pixel-to-meter scale factor: scale = distance_m / pixel_distance(A,B)\n- Apply scale and rotation to Three.js ground plane texture on load\n\n## Acceptance\n- Uploaded image displayed as ground plane texture in 3D view\n- Calibrated coordinate system maps pixel positions to correct meter positions\n- Image persists across server restart\n- > 10 MB upload rejected with 413 error","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:42:49.829463356Z","created_by":"coding","updated_at":"2026-04-06T16:42:49.829463356Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-6th","title":"Multi-link CSI fusion and localization","description":"## Background\n\nSingle-link motion detection (Phase 2) shows presence on a link axis. With 4+ links we can localise people to ±0.5–1.0m using Fresnel zone weighted localization. This is the core spatial intelligence of spaxel. The physics: WiFi CSI is most sensitive to motion within the first Fresnel zone (an ellipsoid between TX and RX). The approach: for each occupancy grid voxel, compute its weight for each link based on Fresnel zone intersection, multiply by that link's deltaRMS, sum contributions, extract blob peaks.\n\n## What to Implement\n\nNew package: mothership/internal/fusion/\n\n### OccupancyGrid\n- mothership/internal/fusion/grid.go\n- 3D float32 grid, configurable resolution (default 0.25m)\n- Dimensions from room config (width, depth, height in meters)\n- Methods: Reset(), Set(x,y,z int, val float32), Get(x,y,z int) float32, Dims() (nx,ny,nz int)\n\n### Fresnel zone geometry\n- mothership/internal/fusion/fresnel.go\n- FresnelWeight(voxelPos, txPos, rxPos vec3, wavelength float64) float64\n- For 5GHz WiFi: wavelength = 0.06m\n- A voxel is in the first Fresnel zone if: d1+d2 <= dist(tx,rx) + wavelength/2\n where d1 = dist(voxel, tx), d2 = dist(voxel, rx)\n- Weight = deltaRMS × exp(-excess_path_length² / (2×0.1²))\n where excess_path_length = (d1+d2) - dist(tx,rx)\n- Weight = 0 outside Fresnel zone\n\n### FusionEngine\n- mothership/internal/fusion/engine.go\n- Inputs: ProcessorManager (from signal package), NodeRegistry (from fleet/session)\n- Runs at 10Hz via time.Ticker\n- Each tick: reset grid, for each active link get deltaRMS from ProcessorManager, for each voxel compute FresnelWeight × deltaRMS, accumulate to grid\n- Output: call BlobExtractor.Extract(grid), broadcast via dashboard hub as 'blob_update' JSON message\n\n### BlobExtractor\n- mothership/internal/fusion/blobs.go\n- Find 3D local maxima in the grid above threshold (default 0.02)\n- Non-maximum suppression: suppress any peak within 0.5m of a higher peak\n- Output: []BlobDetection{Position vec3, Confidence float32, Radius float32}\n- Limit to max 10 blobs\n\n### Room config\n- Add to mothership config (JSON): room.width_m, room.depth_m, room.height_m (defaults: 5, 5, 2.5)\n- Node positions: initially from fleet manager, defaulting to corners if unset\n\n## Key Files\n- mothership/internal/signal/processor.go — GetAllMotionStates()\n- mothership/internal/dashboard/hub.go — Broadcast() for blob_update\n- New: mothership/internal/fusion/grid.go, fresnel.go, engine.go, blobs.go + tests\n\n## Acceptance Criteria\n- FusionEngine produces blob_update WebSocket messages at 10Hz\n- Single active link produces blob peak along the TX-RX axis\n- Two crossing links produce peak near their intersection\n- BlobExtractor correctly suppresses nearby peaks\n- FresnelWeight returns 0 for voxels clearly outside the Fresnel zone\n- go test ./internal/fusion/... passes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T03:30:50.362272102Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.188829209Z","closed_at":"2026-03-28T05:36:26.188507646Z","close_reason":"Implemented: fusion/fusion.go + fusion/grid3d.go (9c56a37) — 3D occupancy grid 0.25m res, Fresnel zone ellipsoid weighting, FusionEngine 10Hz, BlobExtractor with NMS","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-6th","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:30:50.362272102Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-72t","title":"Provisioning payload and NVS write","description":"## Background\n\nWhen a new node is provisioned, it needs WiFi credentials (SSID + password) and a unique node ID (for use in the hello message and as a persistent identifier). The provisioning payload is assembled by the mothership and sent to the firmware over serial during onboarding, or can also be sent over the WebSocket after reconnect. Getting this right is foundational to the security and identity model of the entire system.\n\n## Why Mothership-Generated Node IDs?\n\nRather than generating a random ID on device, having the mothership assign node IDs allows it to: track provisioned-but-never-connected nodes for inventory management, support re-provisioning with ID continuity (same physical device gets same ID after factory reset), prevent ID collisions in multi-node deployments, and maintain a token-based security model where only provisioned nodes can connect.\n\n## NVS Schema\n\nThe firmware NVS schema is defined in firmware/main/spaxel.h (schema version 1). Keys and types:\n- wifi_ssid (string, max 32 chars)\n- wifi_pass (string, max 64 chars)\n- node_id (uint16, assigned by mothership)\n- node_token (string, 12-char hex, assigned by mothership)\n- mothership_host (string, empty = use mDNS)\n- mothership_port (uint16, default 8080)\n- role (uint8, 0=rx, 1=tx, 2=tx-rx, 3=passive)\n- sample_rate (uint16, default 20 Hz)\n- schema_ver (uint8, current = 1)\n\n## Mothership API\n\nPOST /api/provision\nRequest body: {\"ssid\": \"MyWifi\", \"password\": \"secret\", \"label\": \"Living Room Node\"}\nResponse: {\"node_id\": 42, \"provision_token\": \"a3f7b2c1d8e9\", \"config_blob\": \"{...json...}\"}\n\nThe config_blob is a JSON string encoding all NVS keys listed above. It is passed verbatim to the firmware over serial or WebSocket. The firmware parses it, writes each key to NVS, and reboots.\n\nExample config_blob:\n{\"wifi_ssid\":\"MyWifi\",\"wifi_pass\":\"secret\",\"node_id\":42,\"node_token\":\"a3f7b2c1d8e9\",\"mothership_host\":\"\",\"mothership_port\":8080,\"role\":0,\"sample_rate\":20,\"schema_ver\":1}\n\n## Firmware Provisioning Handling\n\nTwo provisioning paths:\n\n1. Serial provisioning (onboarding wizard path): Before WiFi is connected, the firmware listens on UART0 (115200 baud) for a JSON line starting with {\"provision\":. On receipt, write to NVS and reboot. This path works even before WiFi credentials are configured.\n\n2. WebSocket provisioning (re-provisioning path): A new downstream command type \"provision\" alongside existing role/config/ota/reboot in firmware/main/websocket.c. Allows the mothership to update credentials or reset a node over the air. This is useful for credential rotation without physical access.\n\n## Security Model\n\nThe provision_token is used as a bearer token in the WebSocket Authorization header (Authorization: Bearer ) for all subsequent connections. The mothership validates the token on every connection attempt against the provisioned_nodes SQLite table. Nodes without a valid token receive a {type:\"reject\"} downstream message and the connection is closed.\n\nToken format: 12 lowercase hex characters (48 bits of entropy). Generated server-side using crypto/rand. Not derivable from node_id or MAC address.\n\n## SQLite Storage\n\nAdd a provisioned_nodes table to the mothership SQLite database:\nCREATE TABLE provisioned_nodes (\n node_id INTEGER PRIMARY KEY,\n mac TEXT,\n token TEXT NOT NULL,\n label TEXT,\n provisioned_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n first_seen DATETIME,\n last_seen DATETIME,\n firmware_version TEXT,\n current_role INTEGER DEFAULT 0\n);\n\nThe mac field is populated when the node first connects and sends its hello message. Before first connection, mac is NULL (node is provisioned but not yet seen).\n\n## Implementation Location\n\n- mothership/internal/provision/handler.go: POST /api/provision handler\n- mothership/internal/provision/store.go: SQLite CRUD for provisioned_nodes\n- mothership/internal/ingestion/auth.go: WebSocket token validation on connection\n- firmware/main/websocket.c: add \"provision\" downstream command type\n- firmware/main/wifi.c: add serial JSON provisioning path on UART0 (pre-WiFi)\n\n## Re-provisioning\n\nIf a node already exists in provisioned_nodes (matched by mac), the mothership can re-provision it with a new token. The old token is invalidated immediately. The new config_blob is sent via the existing WebSocket connection (if online) or over serial (if physically accessible). This handles: WiFi password changes, mothership IP changes, node relabelling.\n\n## Tests\n\n- Test that POST /api/provision returns valid config_blob containing all required NVS keys\n- Test that node_id is unique and increments correctly\n- Test that token validation rejects connections with unknown tokens\n- Test that token validation rejects connections with expired/rotated tokens\n- Test NVS serialisation round-trip: parse config_blob back to NVS key-value map and verify all values\n- Test that a second provision for the same MAC updates rather than duplicates the record\n\n## Acceptance Criteria\n\n- Provisioned node connects to mothership successfully with the assigned node_id and token\n- Token validation correctly rejects unprovisioned connection attempts with {type:\"reject\"}\n- Node label stored and returned via GET /api/nodes in the node list\n- Re-provisioning updates token and invalidates old token within one round-trip\n- config_blob contains all required NVS keys with correct types\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:36:54.067220841Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.268631627Z","closed_at":"2026-03-28T05:36:39.268468107Z","close_reason":"Implemented: provisioning/server.go (fb69190) + firmware/main/provision.c/h (fb69190) — POST /api/provision generates node_id+token+config_blob, UART serial provisioning window on ESP32, NVS write, provisioned_nodes SQLite table with token validation","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-72t","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.844135515Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-7nk","title":"fix: sleep/handler.go cannot index interface{} map value","description":"## Problem\n`internal/sleep/handler.go` lines 229 and 232 fail with: `cannot index result[\"metrics\"] (map index expression of type interface{})`\n\nThe `result` map is of type `map[string]interface{}`, so `result[\"metrics\"]` returns `interface{}`, which cannot be directly indexed.\n\n## Fix\nAdd a type assertion before indexing. Around lines 229-232 in `internal/sleep/handler.go`:\n```go\n// Before the two if-blocks, get a typed reference:\nif metricsMap, ok := result[\"metrics\"].(map[string]interface{}); ok {\n if !metrics.SleepStartTime.IsZero() {\n metricsMap[\"sleep_start_time\"] = metrics.SleepStartTime.Format(\"15:04\")\n }\n if !metrics.SleepEndTime.IsZero() {\n metricsMap[\"sleep_end_time\"] = metrics.SleepEndTime.Format(\"15:04\")\n }\n}\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/sleep/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:05.128489582Z","created_by":"coding","updated_at":"2026-04-06T22:40:47.430043249Z","closed_at":"2026-04-06T22:40:47.429779459Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-7qo","title":"Dashboard: WebSocket reconnection with exponential backoff and state management","description":"## Overview\nImplement robust client-side WebSocket reconnection with exponential backoff, jitter, and visual state transitions during disconnects.\n\n## Reconnection logic (dashboard/js/websocket.js or app.js)\n- Backoff sequence: 1s, 2s, 4s, 8s, max 10s; ±500ms random jitter on each attempt\n- Track disconnect_duration_ms from first disconnect event\n\n## Visual state transitions:\nDisconnect < 5s: silent (no UI change); blob positions extrapolated from last velocity\nDisconnect 5-30s: 3D scene dims to 50% opacity; 'Reconnecting...' spinner in status bar; user interaction disabled\nDisconnect > 30s: non-blocking modal: 'Connection lost — [Reload Page]'; allow viewing stale scene\n\n## Blob position extrapolation (<5s):\n- On disconnect: record last_position and last_velocity per blob\n- Each animation frame: position = last_position + last_velocity × elapsed_s (capped at 2s extrapolation)\n\n## On successful reconnect:\n- Clear all blob trails (path history lines)\n- Apply snapshot from first WebSocket message (spaxel-fll)\n- Restore scene opacity to 100%\n- Dismiss spinner and modal\n- Log 'Reconnected after Xs' to console\n\n## Acceptance\n- Disconnect for 3s: no visual change; blobs continue moving smoothly\n- Disconnect for 10s: scene dims, spinner shown\n- Disconnect for 35s: modal appears; scene still visible\n- Reconnect: modal dismissed, trails cleared, scene snaps to current state within 200ms\n- Requires: spaxel-fll (snapshot protocol), spaxel-896 (panel framework)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:44:33.446200584Z","created_by":"coding","updated_at":"2026-04-06T16:44:33.446200584Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-7x2","title":"Wire anomaly detection & security mode API endpoints","description":"## Backend\n\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## Acceptance\n- Endpoints return correct JSON structure\n- Anomaly events push to WS feed as 'alert' messages\n- Arm/disarm state persists across server restarts","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:09:35.812256758Z","created_by":"coding","updated_at":"2026-04-06T16:09:35.812256758Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} {"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-03-30T16:27:42.698511Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-896","title":"Build dashboard panel/modal/sidebar UI framework","description":"## Problem\n\nThe dashboard is currently a single live 3D view with no panel system. All Phase 6-9 UI work (automation builder, timeline, explainability, settings, notifications, presence predictions) requires a panel/sidebar framework to hang on.\n\n## What to build\n\n### Panel System (dashboard/js/panels.js)\n- Slide-in sidebar (right, 360px) with close button and title\n- Modal overlay (centered, 600px wide) for forms and wizards\n- Toast notification stack (bottom-right)\n- Panel registry: panels can be opened by name from anywhere in the app\n\n### Route/Mode Navigation (dashboard/js/router.js)\n- Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay\n- Mode toggle bar in the header: Live | Timeline | Automations | Settings\n- Active mode preserved across page refresh (localStorage)\n\n### State Management (dashboard/js/state.js)\n- Central app state object (nodes, blobs, zones, links, alerts, events, ble_devices, triggers)\n- Subscribe/notify pattern for components to react to state changes\n- Separate from WebSocket message parsing\n\n### Settings Panel (dashboard/js/settings-panel.js)\n- Motion threshold slider\n- Sensing rate override\n- Notification channel config (Ntfy URL, Pushover token)\n- System info (version, uptime, node count)\n\n## Acceptance\n\n- Panel opens/closes smoothly with CSS transitions\n- Route changes update the active view without page reload\n- Settings panel reads from GET /api/settings and saves via POST /api/settings\n- All existing 3D live view functionality unaffected","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-06T12:55:28.903260636Z","created_by":"coding","updated_at":"2026-04-06T12:55:28.903260636Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-8u3","title":"Fleet manager with SQLite persistence","description":"Node registry, role assignment engine, and self-healing.\n\n## Deliverables\n- New package: mothership/internal/fleet/\n- SQLite node registry (MAC, ID, role, last seen, health, position)\n- Role assignment engine (TX/RX/passive/TX-RX including passive radar virtual node)\n- Stagger scheduling for multi-node packet timing\n- Self-healing: auto role reassignment on node loss, graceful degradation warnings\n- REST API endpoints: GET /api/nodes, GET /api/nodes/:mac, POST /api/nodes/:mac/role\n\n## Acceptance Criteria\n- Node state persists across mothership restarts\n- Roles auto-reassign when a node goes offline\n- Stagger scheduling prevents packet collisions\n- Tests cover registration, role assignment, and failure recovery\n\n## References\n- Plan: docs/plan/plan.md items 14\n- SQLite: modernc.org/sqlite (pure Go, already in go.mod intent)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:38.835804826Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.132787526Z","closed_at":"2026-03-28T05:36:26.132727724Z","close_reason":"Implemented: fleet/manager.go + fleet/registry.go (fb69190) — SQLite node registry, role assignment engine, stagger scheduling, self-healing role reassignment on node loss","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-8u3","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.704767150Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-9eg","title":"Expand dashboard WebSocket feed: events, alerts, anomalies, triggers, BLE","description":"## Problem\n\nThe dashboard WebSocket (/ws/dashboard) currently only sends blob/node/zone/link/confidence/predictions/sleep/flow state. Events, alerts, anomalies, triggers, and BLE device data are never pushed to the dashboard, making Phase 6-9 UI impossible without a polling API.\n\n## What to add to the WS feed\n\nIn mothership/internal/dashboard/ (hub.go or server.go):\n\n### New message types to broadcast:\n\n**event** — presence transitions, zone entries/exits, portal crossings\n { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }\n\n**alert** — anomaly detections, security mode triggers\n { type: 'alert', alert: { id, ts, severity, description, acknowledged } }\n\n**ble_scan** — BLE device list updates (5s interval)\n { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }\n\n**trigger_state** — automation trigger state changes\n { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }\n\n**system_health** — periodic system stats (60s interval)\n { type: 'system_health', health: { uptime_s, node_count, bead_count, go_routines, mem_mb } }\n\n### In dashboard JS (app.js):\n- Handle each new message type in the WebSocket onmessage handler\n- Update app state for each type\n- Log unhandled types to console (for future debugging)\n\n## Acceptance\n\n- All 5 new message types appear in browser devtools WebSocket inspector\n- BLE device list updates every 5s when devices are present\n- Events appear within 1s of a zone transition\n- Existing blob/node/link messages unaffected","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-06T12:55:40.859267153Z","created_by":"coding","updated_at":"2026-04-06T12:55:40.859267153Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-9nj","title":"fix: falldetect unused imports and vars cause build failure","description":"## Problem\n`internal/falldetect/detector.go` fails to compile:\n- Line 9: `\"math\"` imported and not used\n- Line 360: `startZ` declared and not used\n- Line 360: `endZ` declared and not used\n\n## Fix\n1. Remove `\"math\"` from the import block in `detector.go`\n2. Remove or use the `startZ` and `endZ` variables (prefix with `_` if needed, or delete)\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/falldetect/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T22:29:46.582450658Z","created_by":"coding","updated_at":"2026-04-06T22:46:04.704272938Z","closed_at":"2026-04-06T22:46:04.704068691Z","close_reason":"Already fixed in commit d3f4d8f — removed unused math import and startZ/endZ variables. Build verified clean.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} {"id":"spaxel-9z3","title":"Infrastructure: database schema migration engine","description":"## Overview\nImplement a versioned schema migration framework so the mothership can safely upgrade its SQLite schema across releases without data loss.\n\n## Implementation (mothership/internal/db/ or cmd/mothership/migrate.go)\n\n- schema_migrations table: version INT PK, applied_at INT, description TEXT\n- Migration registry: slice of Migration structs {Version int, Description string, Up func(*sql.Tx) error}\n- Startup phase (before any other subsystem): read current schema_ver; identify pending migrations\n- Run each pending migration in its own transaction; commit on success, rollback on failure\n- On failure: log error, preserve pre-migration backup, exit non-zero\n- Startup shutdown on error: never serve traffic with a partially migrated schema\n\n## Pre-migration safety\n- Before first migration: use SQLite Online Backup API to copy DB to /data/backups/pre-upgrade-v-to-v-.sqlite\n- Create /data/backups/ if not exists\n- Backups older than 90 days: pruned daily at 02:00 local time (or on startup)\n\n## Initial migrations\n- migration_001: initial schema (nodes, links, baselines, events, zones, portals, sessions, etc.)\n- migration_002: add diurnal_baselines table\n- migration_003: add anomaly_patterns table\n- migration_004: add prediction_models table\n- migration_005: add ble_device_aliases table\n\n## Acceptance\n- 'go test ./internal/db/...' passes including migration test from v1 to current\n- Pre-migration backup created before any schema change\n- Failed migration exits cleanly; DB unchanged; backup preserved\n- Idempotent: running migrations on already-migrated DB is a no-op","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-06T13:09:15.158103223Z","created_by":"coding","updated_at":"2026-04-06T13:09:15.158103223Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-9zs","title":"Mothership: SIGTERM graceful shutdown sequence","description":"## Overview\nImplement the full 10-step ordered shutdown sequence so the mothership drains cleanly without data loss on SIGTERM (Docker stop, Kubernetes termination).\n\n## Sequence (plan lines 3480-3494) — 30s hard deadline:\n1. Set shutting_down=true; ingestion server returns HTTP 503 to new WebSocket upgrade requests\n2. Broadcast {type:'shutdown', reconnect_in_ms:30000} to all dashboard WebSocket clients\n3. Cancel fusion loop context (stops fusion goroutine)\n4. Drain signal processing pipeline: wait for in-flight CSI frames (max 2s)\n5. Flush in-memory baselines to SQLite in a single transaction\n6. Sync CSI recording buffer to disk (close writer, fsync)\n7. Close all node WebSocket connections with normal close frame (1000)\n8. Write {type:'system', description:'Mothership stopped'} event to events table\n9. PRAGMA wal_checkpoint(FULL) to collapse WAL into main DB file\n10. sqlite3.Close()\n\n## Implementation\n- context.WithTimeout(30s) wraps entire shutdown\n- Each step gets its own log line: '[SHUTDOWN] Step N/10 — ...'\n- Steps that fail log ERROR but do not abort remaining steps\n- Exit code 0 if all steps completed within deadline; exit code 1 if deadline exceeded\n\n## Acceptance\n- docker stop (SIGTERM) completes within 35s (30s shutdown + 5s buffer)\n- No WAL file remains after clean shutdown (verified with ls -la /data/)\n- system_stopped event present in events table after restart\n- In-flight CSI frames processed (not dropped) during drain step","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:43:43.743509570Z","created_by":"coding","updated_at":"2026-04-06T16:43:43.743509570Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-a1f","title":"Morning briefing","description":"## Background\n\nThe morning briefing is a daily summary delivered when the first household member opens the dashboard (or wakes up, detected by the presence system) after the quiet hours / sleep period. It collects the most relevant information from the previous night and the upcoming day into a single concise card, so the user starts the day informed about their home without any active effort. A good morning briefing is like a trusted home assistant quietly summarising overnight events.\n\n## BriefingGenerator\n\nNew package: mothership/internal/briefing/generator.go\n\nBriefingGenerator runs as a scheduled goroutine, generating a DailyBriefing at a configurable time (default 06:00 server-local time). The briefing is stored in SQLite (daily_briefings table) and pushed to dashboard clients on their first connection after the briefing time.\n\nDailyBriefing struct:\n- id TEXT (UUID)\n- date DATE (the date this briefing covers — typically \"today\", covering the previous night)\n- generated_at DATETIME\n- sections []BriefingSection\n- delivered BOOLEAN (set true after first push to any dashboard client)\n- acknowledged BOOLEAN (set true when user dismisses the card)\n\nEach BriefingSection has a type and content:\n- SectionType: \"sleep\", \"overnight_events\", \"system_health\", \"predictions\", \"weather\" (optional)\n\n## Section: Sleep Report\n\nFor each person with a completed sleep session from the previous night:\nGenerated from the sleep_sessions table (Phase 7, spaxel-qfp).\nContent example: \"Alice slept 7h 23m. 2 wake-ups, 91% efficiency. Avg breathing 14/min.\"\nIf no sleep session: \"No sleep data for Alice last night.\"\nIf sleep session is still in progress (woke up after 6am): \"Alice is still asleep.\"\n\nFormatting rules:\n- Good sleep (efficiency > 85%, duration > 7h): green indicator\n- Fair sleep (efficiency 70-85% or duration 6-7h): amber indicator\n- Poor sleep (efficiency < 70% or duration < 6h): red indicator\n- Anomaly (breathing rate anomaly flagged): include note: \"Unusual breathing pattern detected at 02:14.\"\n\n## Section: Overnight Events\n\nSummary of activity timeline events that occurred during the quiet hours period (e.g. 10pm-6am).\nFilter: FallDetected, AnomalyDetected, NodeDisconnected events only.\nIf no events: \"No incidents overnight.\" (Reassuring — users should see this most mornings.)\nIf events: \"Node Living Room went offline at 02:15 and reconnected at 02:47.\" or \"Anomaly detected at 03:30 in Kitchen (acknowledged).\"\nFall events: always prominently listed, even if acknowledged: \"Possible fall detected at 04:12 for Alice in Bedroom (acknowledged).\"\nLimit: maximum 5 events summarised. If more than 5: \"...and 3 more events. [View all]\"\n\n## Section: System Health\n\nQuick summary of current system health.\nContent: \"4 nodes healthy.\" or \"3 nodes healthy. Node Hallway has been offline since 02:15.\"\nIf a node has been offline > 1 hour: include the duration.\nLink health average: \"Detection quality: 92%.\" (from ambient confidence score, Phase 5).\n\n## Section: Predictions\n\nFor each tracked person, their predicted first activity today.\nGenerated from the presence prediction model (Phase 7, spaxel-hnp).\nContent: \"Alice typically leaves at 8:30am on Tuesdays (78% confidence).\"\nOnly included if prediction model has sufficient data (> 7 days per person).\nIf prediction confidence < 60%: omit (not useful at low confidence).\n\n## Section: Weather (Optional)\n\nIf a weather API URL is configured in settings (e.g. OpenWeatherMap, wttr.in):\nFetch current outdoor temperature and conditions.\nContent: \"Outside: 14°C, partly cloudy.\"\nThis is a nice-to-have context note for heating decisions. The API call is optional and fails gracefully (omit the section if the API is unavailable).\n\nImplementation: GET https://wttr.in/{location}?format=%t+%C (plain text format). Cache for 30 minutes.\n\n## Dashboard Delivery\n\nOn the first WebSocket connection after 06:00 from any dashboard client:\n1. Check if today's DailyBriefing exists and has not been delivered yet\n2. If yes: push {\"type\":\"morning_briefing\",\"briefing\":BriefingJSON} to that client\n3. Mark briefing.delivered = true\n\nDashboard rendering:\n- Simple mode: full-width dismissible card at the very top of the home view. Large enough to read at a glance. Dismiss button (X) in top-right corner.\n- Expert mode: a floating panel overlay (300px wide, top-right of screen), auto-dismisses after 30 seconds of inactivity (the user sees it then goes back to work)\n- Ambient mode: the morning briefing overlay already handled by the ambient mode bead (spaxel-5es) — this briefing generator feeds that overlay.\n\nAcknowledgement: the dismiss button sends POST /api/briefings/{id}/acknowledge. Sets briefing.acknowledged = true.\n\n## Push Notification Delivery\n\nThe briefing is also pushed as a notification at 06:00 (via the notification module, Phase 6 spaxel-zpt), even if no dashboard is open. This ensures users get their morning summary even if they have not opened the dashboard.\n\nThe notification includes a floor-plan thumbnail (from the floor-plan renderer, spaxel-zpt) showing the current home state at 06:00.\n\nNotification title: \"Good morning\" (or \"Good morning, Alice\" if single person)\nNotification body: condensed version of the briefing: the most important item from each section.\n\n## Files to Create or Modify\n\n- mothership/internal/briefing/generator.go: BriefingGenerator, DailyBriefing, section generation\n- mothership/internal/briefing/scheduler.go: daily schedule (6am goroutine)\n- mothership/internal/dashboard/hub.go: morning_briefing push on first connection\n- dashboard/js/briefing.js: morning briefing card rendering in simple and expert modes\n- mothership/internal/dashboard/routes.go: POST /api/briefings/{id}/acknowledge, GET /api/briefings/today\n\n## Tests\n\n- Test briefing generation at 06:00: mock time at 06:00, verify BriefingGenerator creates a DailyBriefing\n- Test sleep section: inject a completed sleep session, verify section content is correct\n- Test overnight events: inject a NodeDisconnected event at 03:00, verify it appears in the overnight section\n- Test system health section: inject node_offline state, verify it appears correctly with duration\n- Test that briefing.delivered is set to true after the first push\n- Test that the briefing is pushed only once (second connection after delivery does not re-push)\n- Test push notification is sent at 06:00 (with mock time and mock notification module)\n\n## Acceptance Criteria\n\n- Morning briefing card appears on the first dashboard open after 06:00 (configurable)\n- All sections (sleep, overnight events, system health, predictions) are correctly populated\n- Briefing is pushed only once per day (not re-pushed on second dashboard open)\n- Push notification delivered at 06:00 with condensed briefing summary\n- Dismiss button correctly acknowledges the briefing and hides the card\n- \"No incidents overnight\" message appears correctly on quiet nights\n- Weather section appears when weather API is configured\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:03:19.830232092Z","created_by":"coding","updated_at":"2026-03-28T03:29:15.060297756Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-a1f","depends_on_id":"spaxel-qfp","type":"blocks","created_at":"2026-03-28T02:03:23.803412940Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-a1f","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:15.060252766Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -37,8 +39,8 @@ {"id":"spaxel-cxm","title":"Phase 2: Signal Processing & Detection","description":"Goal: Detect presence on a single link.\n\n3 of 6 items complete (phase sanitisation, baseline system, motion detection).\n\nRemaining:\n- Dashboard presence indicator — per-link motion detected/clear display with amplitude time series\n- CSI recording buffer — disk-backed circular buffer (48h default) for time-travel replay\n- Adaptive sensing rate — mothership-controlled rate changes (idle 2Hz ↔ active 50Hz), on-device amplitude variance check\n\nExit criteria: Dashboard reliably shows motion detected/clear for a single link. Idle links auto-drop to 2 Hz.","status":"closed","priority":2,"issue_type":"phase","assignee":"spaxel-alpha","created_at":"2026-03-27T01:55:01.708603531Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.109167705Z","closed_at":"2026-03-28T05:36:26.109107331Z","close_reason":"Phase 2 complete. All 6 deliverables implemented: phase sanitisation, baseline system, motion detection (973b0a0), dashboard presence indicator (75edd83 + spaxel-26o), CSI recording buffer (0816a5c + spaxel-hey), adaptive sensing rate (bcfd1e3 + spaxel-tim). go test ./... passes.","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-d04","title":"Implement security mode dashboard UI","description":"## Dashboard UI (dashboard/js/security-panel.js)\n\n### Security mode card (always visible in header or sidebar)\n- Arm / Disarm toggle button with confirmation dialog\n- Status badge: DISARMED / LEARNING (N days remaining) / ARMED / ALERT\n- Learning period progress bar: '5 of 7 days complete'\n- Last anomaly: '2 hours ago — kitchen motion at 3:14am'\n\n### Alert banner\n- Full-width red banner when anomaly triggered while armed\n- Description, timestamp, affected zone\n- Acknowledge button (POST /api/anomalies/{id}/acknowledge)\n\n### Anomaly timeline tab\n- List of recent anomaly events with severity, zone, timestamp\n- Links to timeline view for full context\n\n## Acceptance\n- Learning period progress updates on page refresh\n- Anomaly alert banner appears within 2s of detection\n- Acknowledged alerts disappear from the banner (not from history)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:09:35.859782007Z","created_by":"coding","updated_at":"2026-04-06T16:09:35.859782007Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} {"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-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":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:09:42.683611381Z","created_by":"coding","updated_at":"2026-04-06T13:09:42.683611381Z","source_repo":".","compaction_level":0,"original_size":0} -{"id":"spaxel-g1o","title":"Anomaly detection: 7-day pattern learning algorithm","description":"## Overview\nImplement the statistical pattern learning engine for anomaly detection — per-zone, per-hour-of-day, per-day-of-week occupancy modeling using Welford's online algorithm.\n\n## Backend (mothership/analytics/ or signal/)\n- Pattern model: per (zone_id, hour_of_day, day_of_week): mean_count, variance, sample_count via Welford's algorithm\n- Hourly update goroutine: every hour, observe zone occupancy counts and update model\n- Cold start: suppress all anomaly alerts for 7 days; model slot 'ready' when sample_count >= 50\n- Anomaly scoring:\n - z_score = (observed_count - mean) / sqrt(variance + epsilon)\n - time_score = normalized z_score for this hour/day combo\n - zone_score = 1.0 if zone normally empty at this time, else 0.0\n - composite_score = max(time_score, zone_score) with fallback\n - threshold: alert if composite > 0.85; yellow warning at 0.60\n- Outlier protection: skip model update when anomaly_score >= 0.5 (don't learn from anomalies)\n- Security mode override: any detection = score 1.0 regardless of model\n- SQLite anomaly_patterns table: zone_id, hour_of_day (0-23), day_of_week (0-6), mean_count REAL, variance REAL, sample_count INT, updated_at INT\n\n## REST API\n- GET /api/anomalies?since=24h — list recent anomaly events with scores\n- GET /api/anomaly_patterns?zone= — inspect pattern model for debugging\n\n## Acceptance\n- Pattern model survives server restart (persisted to SQLite)\n- No alerts during 7-day cold start regardless of activity\n- Welford update is numerically stable: no NaN/Inf at any sample count\n- Outlier protection confirmed: injecting synthetic anomaly does not corrupt model after 3 occurrences\n- Requires: spaxel-jcc (phase 6 integration)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:02:39.580201662Z","created_by":"coding","updated_at":"2026-04-06T13:02:39.580201662Z","source_repo":".","compaction_level":0,"original_size":0} +{"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":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:09:42.683611381Z","created_by":"coding","updated_at":"2026-04-07T01:29:29.566185778Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-g1o","title":"Anomaly detection: 7-day pattern learning algorithm","description":"## Overview\nImplement the statistical pattern learning engine for anomaly detection — per-zone, per-hour-of-day, per-day-of-week occupancy modeling using Welford's online algorithm.\n\n## Backend (mothership/analytics/ or signal/)\n- Pattern model: per (zone_id, hour_of_day, day_of_week): mean_count, variance, sample_count via Welford's algorithm\n- Hourly update goroutine: every hour, observe zone occupancy counts and update model\n- Cold start: suppress all anomaly alerts for 7 days; model slot 'ready' when sample_count >= 50\n- Anomaly scoring:\n - z_score = (observed_count - mean) / sqrt(variance + epsilon)\n - time_score = normalized z_score for this hour/day combo\n - zone_score = 1.0 if zone normally empty at this time, else 0.0\n - composite_score = max(time_score, zone_score) with fallback\n - threshold: alert if composite > 0.85; yellow warning at 0.60\n- Outlier protection: skip model update when anomaly_score >= 0.5 (don't learn from anomalies)\n- Security mode override: any detection = score 1.0 regardless of model\n- SQLite anomaly_patterns table: zone_id, hour_of_day (0-23), day_of_week (0-6), mean_count REAL, variance REAL, sample_count INT, updated_at INT\n\n## REST API\n- GET /api/anomalies?since=24h — list recent anomaly events with scores\n- GET /api/anomaly_patterns?zone= — inspect pattern model for debugging\n\n## Acceptance\n- Pattern model survives server restart (persisted to SQLite)\n- No alerts during 7-day cold start regardless of activity\n- Welford update is numerically stable: no NaN/Inf at any sample count\n- Outlier protection confirmed: injecting synthetic anomaly does not corrupt model after 3 occurrences\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:02:39.580201662Z","created_by":"coding","updated_at":"2026-04-07T01:28:23.140993262Z","closed_at":"2026-04-07T01:28:23.140700890Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-goc","title":"BLE device discovery & registration dashboard panel","description":"## Overview\nPeople & Devices panel for discovering, registering, and labeling BLE devices seen by the fleet.\n\n## Dashboard (dashboard/js/ble-panel.js)\n- 'People & Devices' panel (via spaxel-896 panel framework)\n- Discovered devices list: sorted by sighting frequency; shows MAC (truncated), name, RSSI, last seen, type icon\n- Registration UI: click device → assign label, type (person/pet/object), color\n- Auto-type hints from manufacturer data: iPhone, Apple Watch, Fitbit, Tile, AirTag\n- Manual pre-registration by address (for tracker tags not yet seen)\n- Unregistered count badge on panel toggle button\n\n## Backend\n- SQLite ble_devices table: addr, label, type, color, icon, first_seen, last_seen, last_rssi, sighting_count\n- GET /api/ble/devices?registered=true|false — filter registered vs discovered\n- PUT /api/ble/devices/{mac} — set label, type, color, assign to person\n- GET /api/ble/devices/{mac}/history — sighting timeline\n\n## Acceptance\n- Panel shows all devices seen in last 24h by default\n- Label assignment persists across server restart\n- Registered devices show up with name in 3D blob labels and timeline events\n- Requires: spaxel-896 (panel framework), spaxel-9eg (BLE WS feed)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:29.882665390Z","created_by":"coding","updated_at":"2026-04-06T13:01:29.882665390Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-hey","title":"CSI recording buffer","description":"## Background\n\nThe CSI recording buffer is the foundation for time-travel debugging (Phase 8, spaxel-pvz). Every CSI binary frame received from a node should be persisted to disk in real-time. The plan specifies 48-hour retention. This bead implements the recording infrastructure and wires it into the ingestion server. Starting data collection in Phase 2 means real CSI data accumulates from day one, invaluable for debugging Phase 3+ algorithms offline.\n\n## What to Implement\n\nNew package: mothership/internal/recorder/\n\n### Segment file design\nUse 1-hour segment files per link: data/{nodeMAC}-{peerMAC}/{YYYYMMDD-HH}.csi. Each file is append-only. Frame format: [4-byte big-endian length][raw CSI binary frame bytes]. Background goroutine deletes segment files older than 48h (configurable via RecorderConfig.RetentionHours).\n\n### API\n- recorder.Manager — manages per-link recorders, one goroutine per link\n- recorder.Manager.Write(linkID string, frame []byte) — called from ingestion server per frame\n- recorder.Manager.ReadFrom(linkID string, since time.Time) <-chan []byte — returns channel of frames in chronological order from 'since' timestamp; closes channel when caught up to current time\n- recorder.Manager.AvailableRange(linkID string) (start, end time.Time, err error) — oldest and newest frame timestamps\n- recorder.Manager.Close() — graceful shutdown\n\n### Storage estimate\nAt 2Hz idle: ~176 bytes/frame × 2/s × 3600s × 48h = ~60MB/link/48h. At 50Hz active for 1h/day: add ~30MB. Total for 4 links × 48h ≈ 360MB–720MB. Configure via RecorderConfig.MaxBytesPerLink (default 1GB) as a secondary guard.\n\n### Wire-up\nIn mothership/internal/ingestion/server.go, after parsing a valid binary CSI frame (in the existing frame parsing path), call recorder.Manager.Write(linkID, rawFrameBytes). The recorder must not block the ingestion goroutine — use a buffered channel (capacity 1000 frames) per link.\n\n## Key Files\n- mothership/internal/ingestion/server.go — add recorder.Write call after frame parse\n- mothership/internal/ingestion/frame.go — frame parsing reference\n- New: mothership/internal/recorder/manager.go, recorder/segment.go, recorder/segment_test.go, recorder/manager_test.go\n\n## Acceptance Criteria\n- CSI frames written to segment files in real-time (< 10ms write latency)\n- ReadFrom correctly replays frames in timestamp order\n- Segment files older than RetentionHours deleted automatically\n- Write does not block ingestion goroutine (buffered channel, drops with warning if full)\n- go test ./internal/recorder/... passes","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T03:29:50.502500283Z","created_by":"coding","updated_at":"2026-03-28T04:28:24.975258849Z","closed_at":"2026-03-28T04:28:24.974961809Z","close_reason":"CSI recording buffer already implemented in commit 0816a5c. All components complete: recorder/segment.go (append-only 1-hour segment files), recorder/manager.go (per-link buffered recording with Write/ReadFrom/AvailableRange/Close, 48h retention, 1GB/link limit), full test coverage (20 tests passing), wired into ingestion server.go and main.go.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-hgm","title":"Spatial automation builder","description":"## Background\n\nThe ultimate value of presence detection is actionability. Users want their smart home to respond to who is where: lights on when someone enters, alerts when the baby leaves the nursery, notifications when the house is empty. The spatial automation builder provides a no-code interface for creating these rules, with webhook and MQTT delivery. It is the bridge between spaxel's physical sensing and the broader smart home ecosystem.\n\n## Automation Structure\n\nAn automation has three parts: trigger, optional conditions, and one or more actions. All three are stored as JSON in SQLite, allowing flexible extension without schema migrations.\n\nTrigger types:\n- zone_enter: fires when a person (or anyone) enters a named zone or custom trigger volume\n- zone_leave: fires when a person (or anyone) leaves a named zone\n- zone_dwell: fires when a person has been in a zone continuously for > N minutes (configurable threshold)\n- zone_vacant: fires when a zone transitions from occupied to empty (last person leaves)\n- person_count_change: fires when zone occupant count crosses a threshold (e.g. count goes from 2 to 3)\n- fall_detected: fires when fall detection (spaxel-zvs fall bead) fires for a person in a zone\n- anomaly: fires when anomaly detector (Phase 7) fires an anomaly event\n- ble_device_present: fires when a specific BLE device (or any labelled device) is first seen in a scan cycle (useful for \"arrive home\" detection)\n- ble_device_absent: fires when a specific BLE device has not been seen for > N minutes (useful for \"left home\" detection)\n\nCondition filters:\n- person_filter: specific person_id (or \"anyone\")\n- time_window: ISO 8601 time range (e.g. \"22:00-07:00\" for night)\n- day_of_week: bitmask (0=Sun, 1=Mon, ... 6=Sat)\n- system_mode: home, away, sleep (modes set by user or auto-detected)\n- zone_occupancy: additional zone occupancy condition (e.g. \"only if Living Room is empty\")\n\nAction types:\n- webhook: POST to a user-configured URL with JSON payload\n- mqtt_publish: publish to a topic with a payload (uses the MQTT client from home automation integration bead)\n- ntfy: send a push notification via ntfy (self-hosted or ntfy.sh)\n- pushover: send a Pushover notification\n\nPayload templating for all action types supports these variables:\n{{person_name}}, {{zone_name}}, {{from_zone}}, {{to_zone}}, {{timestamp}}, {{occupant_count}}, {{event_type}}, {{person_color}}, {{confidence}}\n\nExample webhook payload template:\n{\"text\": \"{{person_name}} entered {{zone_name}} at {{timestamp}}\", \"color\": \"{{person_color}}\"}\n\n## 3D Trigger Volumes\n\nIn addition to named zones, automations can use arbitrary 3D cuboid volumes as their spatial target. These are drawn in the 3D editor like zone bounding boxes but are not associated with a zone name — they exist only for automation triggers. Rendered as dashed-outline cuboids (not filled) in the 3D scene with the automation name as label.\n\nSQLite schema: trigger_volumes (id, automation_id FK, name, bounds_min_xyz, bounds_max_xyz)\n\nThe crossing detection and occupancy logic from the portals bead (spaxel-qlh) is reused — trigger volumes use the same containment test.\n\n## Automations SQLite Schema\n\nCREATE TABLE automations (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n enabled BOOLEAN DEFAULT TRUE,\n trigger_type TEXT NOT NULL,\n trigger_config TEXT NOT NULL, -- JSON: {\"zone_id\":\"...\",\"person_id\":\"anyone\",\"dwell_minutes\":5}\n conditions TEXT, -- JSON array of condition objects\n actions TEXT NOT NULL, -- JSON array of action objects\n last_fired DATETIME,\n fire_count INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n## AutomationEngine\n\nNew struct: mothership/internal/automation/engine.go\n\nAutomationEngine subscribes to the internal event bus (Phase 8, spaxel-sl2 activity timeline — the event bus is implemented there). On each event, it:\n1. Finds all enabled automations whose trigger_type matches the event type\n2. For each matching automation, evaluates all conditions\n3. If all conditions pass: fires the actions\n\nCondition evaluation:\n- time_window: parse \"HH:MM-HH:MM\", check if current time (in server timezone) is within range. Handle overnight ranges (22:00-07:00 spans midnight).\n- day_of_week: check current day against bitmask\n- person_filter: check event.PersonID against filter (or \"anyone\" wildcard)\n- system_mode: check current SystemMode (stored in mothership state)\n\nAction execution:\n- webhook: goroutine with 5s timeout, HTTP POST with rendered payload. Retry once after 30s on 5xx response. Log all requests and responses.\n- mqtt_publish: synchronous publish if MQTT client is connected. If not connected: log \"MQTT not configured\" and skip.\n- ntfy/pushover: use the notification module (Phase 6 spatial context notifications bead).\n\nAction results (success/failure/timeout) are logged in SQLite: action_log (automation_id, fired_at, event_json, actions_results_json).\n\n## Dashboard UI\n\nAutomations management page (route /automations):\n- List view: table of all automations with name, trigger type, enabled toggle, last fired timestamp, fire count, and edit/delete actions\n- Create/edit modal: step-by-step builder:\n 1. Choose trigger: dropdown of trigger types with plain-English labels (\"When someone enters a room\", \"When a room becomes empty\", etc.)\n 2. Configure trigger: zone picker (or \"Any zone\"), person picker (or \"Anyone\"), threshold for dwell/count\n 3. Add conditions (optional): time window picker, day picker, system mode selector\n 4. Add action: action type dropdown, then action-specific fields (URL for webhook, topic for MQTT, ntfy server + topic)\n 5. Template editor for payload with variable hints\n 6. Summary: \"When Alice enters the Kitchen between 7am and 9am on weekdays, POST to https://...\"\n- \"Test fire\" button: simulates the trigger event and fires all actions with test_mode=true flag in payload. Useful for debugging webhooks.\n- 3D view integration: when an automation's trigger zone is hovered in the automation editor, the corresponding zone/trigger volume highlights in the 3D scene.\n- Visual feedback in 3D view when trigger fires: brief highlight (bright flash) of the trigger zone in the 3D scene.\n\n## System Mode\n\nSystemMode is a top-level state: HOME, AWAY, SLEEP.\n- HOME: normal operation\n- AWAY: all registered BLE devices absent, security-level alert on any detection (managed by anomaly detection, Phase 7)\n- SLEEP: quiet hours active, non-urgent notifications suppressed\n\nMode changes:\n- Manual toggle from dashboard settings\n- Auto-away: all registered BLE devices absent for > 15 minutes -> auto-set AWAY\n- Auto-home: first registered BLE device seen again -> auto-set HOME\nAuto-sleep: user can configure a schedule (e.g. 10pm-7am on weekdays)\n\nMode is exposed as GET /api/mode and POST /api/mode {\"mode\":\"home\"/\"away\"/\"sleep\"}.\n\n## Tests\n\n- Test trigger matching for each trigger type: inject matching event, verify automation fires; inject non-matching event, verify no fire\n- Test time_window condition: \"22:00-07:00\" blocks at 08:00, passes at 23:00, passes at 04:00\n- Test overnight time range correctly handles midnight boundary\n- Test person_filter condition: \"anyone\" matches all events; specific person_id only matches events with that person\n- Test webhook dispatch: mock HTTP server, verify POST arrives with correct rendered payload\n- Test webhook retry: mock server returns 503 first request, 200 second, verify retry fires after 30s\n- Test MQTT publish with mock broker: verify correct topic and payload\n- Test \"test fire\" mode sets test_mode=true in payload\n- Test fire_count increments in SQLite after each fire\n\n## Acceptance Criteria\n\n- Automation fires correctly within 200ms of its trigger event for each trigger type\n- Webhook delivers payload to mock server within 5s\n- MQTT message arrives with correct topic and payload\n- Time-window condition blocks automations outside their configured window\n- 3D trigger volume editor allows drawing custom volumes not tied to named zones\n- \"Test fire\" button correctly simulates trigger without requiring a real event\n- Fire count and last_fired timestamp update in database after each fire\n- Retry mechanism handles transient webhook failures\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:46:36.925844184Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.766389180Z","closed_at":"2026-03-29T18:07:39.766280132Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-hgm","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.294048305Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -74,7 +76,7 @@ {"id":"spaxel-pwf","title":"Self-improving localisation with BLE ground truth","description":"## Background\n\nThe Fresnel zone fusion engine (spaxel-m9a) computes localisation by weighting each link's deltaRMS contribution according to the geometric intersection of candidate voxels with the Fresnel zone ellipsoid. These weights are currently uniform and based purely on geometry. In practice, some links are better at detecting motion in specific parts of the room than others — due to reflection geometry, multipath, furniture layout, and antenna orientation. By using BLE RSSI positions as continuous ground truth (when a person's labelled phone or wearable is visible), we can refine the per-link, per-zone weights to match observed physical reality.\n\n## Self-Improving Mechanism\n\nNew package: mothership/internal/learning/weights.go\n\nWeightLearner runs as a background goroutine. It operates on ground truth samples collected during normal operation.\n\nA ground truth sample is collected when BOTH:\n1. A confident BLE triangulated position is available for a known person (confidence > 0.7 from identity matching bead spaxel-nqh)\n2. A CSI blob position is within 0.5m of the BLE position (confirming the blob corresponds to that person)\n\nSample structure: {timestamp, person_id, ble_position Vec3, blob_position Vec3, per_link_delta_rms map[linkID]float64, per_link_health map[linkID]float64}\n\nThese samples are stored in SQLite: ground_truth_samples (id, timestamp, person_id, position_xyz, per_link_deltas_json, per_link_health_json). The table is capped at 10,000 samples per person (oldest first out) to prevent unbounded growth.\n\n## Online Weight Learning\n\nAfter accumulating 100+ samples for a given spatial zone (the room is divided into zones of 0.5m x 0.5m grid cells for this purpose), run incremental linear regression:\n\nPrediction model: position_estimate = sum_i (w_i * delta_rms_i) / sum_i w_i, where w_i are the learnable per-link weights.\n\nThe objective is to minimise the mean squared error between the position estimate from the weighted fusion and the ground truth BLE positions, over all samples in the zone.\n\nUpdate rule (stochastic gradient descent, online):\nFor each new ground truth sample:\n- Compute current position estimate using current weights\n- Compute error = ground_truth_position - estimated_position\n- For each link i: w_i += learning_rate * error * delta_rms_i / |delta_rms_vector|\n- learning_rate = 0.001 (small to prevent overfitting to transient environmental changes)\n- Apply L2 regularisation: w_i *= (1 - regularisation * learning_rate) where regularisation = 0.01\n\nClip weights to [0, 5] to prevent divergence. Normalise weight vector to unit sum after each update.\n\n## Validation Gate\n\nTo prevent the learned weights from degrading accuracy (overfitting, transient environmental changes, sensor noise):\n\nHold out 20% of samples as a validation set (random selection). After each batch of 50 weight updates, compute the mean position error on the validation set using the updated weights vs. the original (geometric) weights.\n\nOnly persist the updated weights if: validation_error_new < validation_error_original * 0.95 (at least 5% improvement on the validation set).\n\nIf the validation check fails, discard the weight update and log: \"Weight update rejected: no improvement on validation set. Keeping current weights.\"\n\nThis is a conservative gate. The threshold is configurable (fleet.weight_improvement_threshold, default 0.05).\n\n## Weight Storage\n\nSQLite table: link_weights (link_id TEXT, zone_grid_x INT, zone_grid_y INT, weight REAL, sample_count INT, last_updated DATETIME, validation_improvement REAL, PRIMARY KEY (link_id, zone_grid_x, zone_grid_y)).\n\nZone grid: floor is divided into 0.5m cells. zone_grid_x = floor(x / 0.5), zone_grid_y = floor(y / 0.5). This allows position-dependent weights — a link might be excellent for localisation in one area and poor in another.\n\nOn FusionEngine update: instead of using geometric Fresnel zone weights alone, multiply by the learned spatial weight for the voxel being evaluated (bilinear interpolation between grid cells for smooth transitions).\n\nFallback: if no learned weight exists for a grid cell (insufficient samples), use the geometric weight (learned weight = 1.0). This ensures correctness during the learning period.\n\n## Accuracy Trend in Dashboard\n\nThe accuracy improvement from learning should be visible to users. In the \"Accuracy\" dashboard panel (Phase 7 feedback loop bead):\n\nAdd \"Position accuracy\" subsection:\n- Median position error (m): computed weekly from ground truth samples. median(|ble_position - blob_position|) over all weekly samples.\n- Week-over-week trend: sparkline of weekly median position error. Arrow indicating direction (improving/degrading).\n- Sample count: \"Based on N position measurements from M people this week\"\n- \"Accuracy improving\" badge when position error has decreased by > 10% vs previous week.\n\n## Files to Create or Modify\n\n- mothership/internal/learning/weights.go: WeightLearner, SGD update, validation gate\n- mothership/internal/learning/samples.go: ground truth sample collection, SQLite storage\n- mothership/internal/fusion/engine.go (spaxel-m9a): integrate learned weights in FusionEngine\n- mothership/internal/dashboard/routes.go: GET /api/accuracy/weights (debug endpoint showing current weight map)\n- dashboard/js/accuracy.js: position accuracy trend chart\n\n## Tests\n\n- Test ground truth sample collection gates correctly: confidence > 0.7 AND BLE-blob distance < 0.5m -> sample collected; confidence = 0.6 -> no sample\n- Test SGD weight update: after 100 samples with known ground truth, verify weights move in the direction that reduces error\n- Test validation gate: inject a batch of adversarial samples that would degrade accuracy, verify gate rejects the update\n- Test bilinear interpolation between adjacent grid cells produces smooth weight values\n- Test weight fallback: FusionEngine correctly uses geometric weight=1.0 when no learned weight exists for a grid cell\n- Test SQLite cap: inserting 10,001 samples removes the oldest one, maintaining the 10,000 cap\n\n## Acceptance Criteria\n\n- Position error decreases measurably over 2+ weeks of operation with BLE ground truth data (target: from initial ~1.2m to < 0.8m median error)\n- Validation gate prevents weight regressions (mock adversarial samples do not degrade fusion accuracy)\n- Weight updates persist across mothership restarts\n- Position accuracy trend visible in dashboard Accuracy panel\n- Sample collection rate visible (samples per day per person) in dashboard\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T01:50:34.214065492Z","created_by":"coding","updated_at":"2026-03-30T00:12:00.715207673Z","closed_at":"2026-03-30T00:12:00.715088959Z","close_reason":"Implemented self-improving localization with BLE ground truth. Created spatial weight learner with SGD, validation gate, bilinear interpolation. Added position accuracy visualization to dashboard. All tests implemented.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-3ps","type":"blocks","created_at":"2026-03-28T01:50:36.699492024Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.574878149Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-qfp","title":"Sleep quality monitoring","description":"## Background\n\nThe breathing analysis feature (Phase 5, spaxel-r37) detects the micro-motion of breathing in stationary people. Run continuously in bedroom zones overnight, it can compute sleep quality metrics without any wearable device. Chest displacement during breathing at 15 breaths/minute produces a detectable 0.25 Hz signal in CSI. By tracking this overnight, combined with motion events (wake episodes) and the timing of presence in the bedroom zone, we can produce a sleep summary that rivals basic commercial sleep trackers — without the user wearing anything.\n\n## Sleep Session Detection\n\nSleepMonitor in mothership/internal/sleep/monitor.go.\n\nSession onset detection (all conditions must hold):\n1. Person is in a bedroom zone (zone with is_bedroom flag = true, set in zone editor)\n2. Stationary detection fires (STATIONARY_DETECTED state from breathing analysis bead)\n3. BLE device shows reduced activity (optional enhancement: phone advertising rate drops when screen is off; this is a bonus signal, not required)\nTentative onset: all conditions met. Confirmed onset: conditions hold for 15 consecutive minutes.\n\nSession end detection:\n1. Person leaves bedroom zone (zone transition event fires)\n2. OR: motion detection fires for > 2 minutes (sustained motion = getting up)\n3. OR: stationary detection drops and does not return for > 30 minutes (person left room without portal crossing — reconciliation path)\n\nSession record stored in SQLite:\nCREATE TABLE sleep_sessions (\n id TEXT PRIMARY KEY,\n person_id TEXT NOT NULL,\n zone_id TEXT NOT NULL, -- bedroom zone\n session_date DATE NOT NULL, -- the date this sleep night belongs to (typically today-1 for morning reports)\n sleep_onset DATETIME, -- time tentative detection was confirmed\n wake_time DATETIME,\n time_in_bed_minutes REAL,\n sleep_latency_minutes REAL, -- time from entering bedroom to sleep onset\n wake_episode_count INTEGER DEFAULT 0,\n wake_after_sleep_onset_minutes REAL, -- total time awake after first sleep onset\n breathing_rate_mean REAL,\n breathing_rate_stddev REAL,\n breathing_anomaly_count INTEGER DEFAULT 0, -- breathing < 8 or > 25 per minute\n sleep_efficiency REAL -- (time_in_bed - waso) / time_in_bed * 100\n);\n\nCREATE TABLE sleep_wake_episodes (\n id TEXT PRIMARY KEY,\n session_id TEXT,\n episode_start DATETIME,\n episode_end DATETIME,\n duration_seconds REAL\n);\n\n## Sleep Metrics Computation\n\nDuring the sleep session, SleepMonitor subscribes to:\n- Breathing data: periodic sample of breathing_freq_hz from BreathingDetector (spaxel-r37). Store in a rolling buffer.\n- Motion events: MOTION_DETECTED state transitions from LinkProcessor. Each motion event during a confirmed sleep session is a potential wake episode.\n\nWake episode classification:\n- If deltaRMS > threshold for > 3 seconds: wake episode starts\n- If deltaRMS returns below threshold and breathing signal resumes: wake episode ends\n- Store episode start/end in sleep_wake_episodes\n\nBreathing analysis during sleep:\n- Mean breathing rate (bpm): mean(breathing_freq_hz * 60) over all samples in session\n- Breathing rate standard deviation: indicates sleep stage variability (higher variance may indicate REM activity)\n- Breathing anomaly: if breathing_freq_hz * 60 < 8 or > 25 for > 3 consecutive minutes: log anomaly. This is a proxy for potential sleep apnoea or hyperventilation.\n\nSleep efficiency: (time_in_bed_minutes - wake_after_sleep_onset_minutes) / time_in_bed_minutes * 100. A value above 85% is considered good sleep efficiency.\n\n## Morning Summary Card\n\nOn first WebSocket connection from the dashboard after 6am AND after a sleep session has ended (wake_time is set):\n- Mothership pushes a \"morning_summary\" WebSocket message with the completed session data\n- Dashboard renders a dismissible card in simple mode (full width at top) and as a floating panel in expert mode\n\nCard content:\n- \"Last night: [sleep_duration] h [mm] min\"\n- Colored efficiency indicator: green (>85%), amber (70-85%), red (<70%)\n- Wake episodes: \"2 wake episodes, [total waso] min awake after sleep onset\"\n- Breathing: \"Average breathing: [N] breaths/min\"\n- Anomaly note (if applicable): \"Unusual breathing detected at [time]. [View details]\"\n- \"View full sleep report\" link (opens detailed timeline view in expert mode)\n\n## Weekly Trends\n\nDashboard \"Sleep\" panel:\n- 7-day sparkline of sleep duration per night\n- 7-day sparkline of sleep efficiency per night\n- Average breathing rate over the week\n- Week-over-week comparison: \"This week you slept 6h 48m on average (vs. 7h 12m last week)\"\n\n## Per-Person Tracking\n\nSleep monitoring is person-specific and requires BLE identity (so the system knows whose bedroom this is). Multiple people sharing a bedroom: each person has their own sleep session if their BLE devices can be distinguished. If both people are in bed simultaneously, the breathing detector may pick up a blend of two breathing rates — acknowledge this limitation in documentation.\n\nFor anonymous tracks (no BLE identity): detect in-bedroom stationary presence only (no per-person sleep report). Log \"Unidentified person in bedroom zone\" for 8+ hour periods.\n\n## Zone Configuration\n\nThe zone editor (portals bead, spaxel-qlh) is extended with a zone type selector:\n- Normal zone (default)\n- Bedroom (enables sleep monitoring)\n- Kitchen (no special behavior)\n- Children's zone (suppresses fall detection)\n\nThis is stored as zone_type in the zones table.\n\n## Files to Create or Modify\n\n- mothership/internal/sleep/monitor.go: SleepMonitor, session detection, metric computation\n- mothership/internal/sleep/report.go: morning summary generation, weekly trend aggregation\n- mothership/internal/signal/breathing.go (spaxel-r37): add tick-based sample reporting for sleep monitor\n- dashboard/js/sleep.js: morning summary card, Sleep panel\n- mothership/internal/events/events.go: SleepSessionStartEvent, SleepSessionEndEvent\n\n## Tests\n\n- Test sleep session onset: stationary detection fires, person in bedroom, 15 minutes -> session confirmed\n- Test that stationary detection < 15 minutes does not create a session (avoids brief naps misclassified)\n- Test wake episode counting: 3 MOTION_DETECTED events > 3s each during a session -> wake_episode_count = 3\n- Test wake after sleep onset calculation: 3 episodes of 5 minutes each -> waso = 15 minutes\n- Test sleep efficiency calculation: 480 minutes in bed, 45 minutes waso -> efficiency = 90.6%\n- Test breathing anomaly detection: inject 4 minutes of breathing_freq_hz = 0.1 (6 bpm) -> anomaly logged\n- Test morning summary trigger fires only on first connection after 6am AND after session end\n\n## Acceptance Criteria\n\n- Sleep session detected within 15 minutes of confirmed onset (stationary in bedroom zone)\n- Wake episodes counted correctly (tested with synthetic motion event injection)\n- Morning summary card appears on first dashboard open after wake time (6am by default, configurable)\n- Weekly trends sparkline shows 7 nights of data after 7 days\n- Sleep session data persists in SQLite across mothership restarts\n- Breathing anomaly flag fires correctly for rate < 8 or > 25 bpm\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:52:06.457208929Z","created_by":"coding","updated_at":"2026-03-30T16:27:42.734713Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-qlh","title":"Room transition portals and zone occupancy","description":"## Background\n\nKnowing a blob is at coordinates (3.2m, 1.8m, 1.0m) is useful to the algorithm, but \"Alice is in the Kitchen\" is useful to a person. Room transition portals define doorway planes between named zones. When a track's trajectory intersects a portal plane, the zone occupancy counts update and a transition event fires. This is the foundation for natural language presence display (\"Alice is in the Kitchen\"), automation triggers (\"when Alice enters the bedroom\"), and the activity timeline (\"Alice moved from Living Room to Kitchen at 14:23\").\n\n## Zone Definitions\n\nZones are named 3D volumes represented as axis-aligned bounding boxes (AABB) for simplicity. Each zone has: id (uuid), name (\"Kitchen\"), bounds_min (Vec3), bounds_max (Vec3), color (hex string for 3D overlay), created_at.\n\nSQLite schema:\nCREATE TABLE zones (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n bounds_min_x REAL, bounds_min_y REAL, bounds_min_z REAL,\n bounds_max_x REAL, bounds_max_y REAL, bounds_max_z REAL,\n color TEXT DEFAULT '#3b82f6',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nContainment test: a position P is in zone Z if bounds_min_x <= P.x <= bounds_max_x AND bounds_min_y <= P.y <= bounds_max_y. The Z bounds are typically 0 to ceiling height (usually 2.5m) since we track floor-plane position.\n\n## Portal Definitions\n\nA portal is a vertical plane segment spanning a doorway. It divides two zones and detects crossings.\n\nPortal schema:\nCREATE TABLE portals (\n id TEXT PRIMARY KEY,\n name TEXT, -- e.g. \"Kitchen Door\"\n zone_a_id TEXT, -- zone on one side\n zone_b_id TEXT, -- zone on other side\n plane_point Vec3, -- a point on the portal plane (e.g. centre of doorway)\n plane_normal Vec3, -- unit normal vector of the portal plane\n width REAL, -- width of the doorway in metres\n height REAL, -- height of the doorway (default: 2.1m)\n created_at DATETIME\n);\n\nA portal normal points from zone_a toward zone_b. A crossing from zone_a to zone_b has dot(velocity, normal) > 0. A crossing from zone_b to zone_a has dot(velocity, normal) < 0.\n\n## Portal Editor (3D Dashboard)\n\nExtend the node placement UI (spaxel-qq6) with portal editing:\n1. User clicks \"Add Portal\" button\n2. A vertical plane appears in the 3D scene at the camera's focal point\n3. User drags the plane using TransformControls (from Three.js addons) to position it across a doorway\n4. User adjusts width and assigns zone names on each side (dropdown of existing zones or \"Create new zone\")\n5. User clicks \"Save\" — portal is stored in SQLite and rendered as a semi-transparent divider plane in the 3D scene\n\nPortal rendering: thin coloured plane (opacity 0.3, colour #a855f7 purple) with a label at the top edge showing the portal name. When a track crosses the portal, the plane briefly flashes brighter (animated opacity increase then decay back to 0.3).\n\nZone rendering: semi-transparent coloured cuboid volumes (opacity 0.1, colour from zone.color). Zone name displayed as a floating text label at the zone centroid (using THREE.Sprite). A \"Zones\" layer toggle in the 3D view hides/shows all zones simultaneously.\n\n## Crossing Detection\n\nCrossingDetector runs as part of the TrackManager update loop (10 Hz). For each track update:\n\n1. For each active portal, test if the track crossed the portal plane in the last update step:\n - Previous position P_prev, current position P_curr\n - Check if the line segment P_prev -> P_curr intersects the portal plane within the portal's rectangular bounds (width x height centered on plane_point)\n - Intersection test: t = dot(plane_point - P_prev, normal) / dot(P_curr - P_prev, normal). If 0 <= t <= 1, compute intersection point P_int = P_prev + t*(P_curr - P_prev), then check if P_int is within the doorway rectangle.\n - Crossing direction: if dot(P_curr - P_prev, normal) > 0, direction is A_to_B; otherwise B_to_A.\n\n2. On crossing detected: update occupancy counts, emit ZoneCrossingEvent.\n\nZoneCrossingEvent: {portal_id, track_id, person_id, person_label, from_zone_id, from_zone_name, to_zone_id, to_zone_name, direction, timestamp}.\n\nThis event is:\n- Published to the internal event bus\n- Broadcast via WebSocket to dashboard as type \"zone_transition\"\n- Appended to activity timeline (Phase 8)\n- Processed by automation engine (Phase 6)\n\n## Occupancy Counter\n\nOccupancyManager maintains a per-zone current occupant list (map[zoneID][]TrackID).\n\nUpdates from two sources:\n1. CrossingDetector portal events: when a track crosses from zone A to B, move its entry in the occupancy map from A to B.\n2. Direct containment check: run every 30 seconds as a reconciliation pass. For each active track, check if it is within any zone's bounding box. If the track is in zone C but the occupancy map says it is in zone A (e.g. track was created inside a zone without crossing a portal), update accordingly.\nThe containment check prevents \"teleportation\" inconsistencies when tracks are created or resume from coasting state.\n\n## WebSocket Broadcast\n\nOn each zone occupancy change, the mothership broadcasts:\n{\"type\":\"zone_occupancy\",\"zones\":[{\"id\":\"zone-kitchen\",\"name\":\"Kitchen\",\"occupants\":[{\"track_id\":\"track-1\",\"person_id\":\"uuid-alice\",\"person_label\":\"Alice\"}]},{\"id\":\"zone-living\",\"name\":\"Living Room\",\"occupants\":[]}]}\n\nAnd specifically on crossings:\n{\"type\":\"zone_transition\",\"portal_id\":\"...\",\"person_label\":\"Alice\",\"from_zone\":\"Kitchen\",\"to_zone\":\"Living Room\",\"timestamp\":\"2026-03-27T14:23:00Z\"}\n\n## REST API\n\nGET /api/zones: list all zones with current occupancy\nPOST /api/zones: create zone\nPUT /api/zones/{id}: update zone bounds/name/color\nDELETE /api/zones/{id}: delete zone (removes from all occupancy tracking)\n\nGET /api/portals: list all portals\nPOST /api/portals: create portal\nPUT /api/portals/{id}: update portal\nDELETE /api/portals/{id}: delete portal\n\nGET /api/zones/{id}/history?since=2026-03-27T00:00:00Z: get crossing history for zone (list of ZoneCrossingEvent)\n\n## Tests\n\n- Test portal crossing detection with a track path that passes through the portal plane: verify crossing event fires with correct direction\n- Test that a track path that runs parallel to a portal plane but within 0.1m does not fire a false crossing\n- Test that a track path outside the portal's width bounds does not fire a crossing\n- Test occupancy count updates: zone Kitchen starts with 1 occupant, track crosses portal to Living Room, Kitchen count = 0, Living Room count = 1\n- Test the 30-second reconciliation pass: track that appears inside a zone without crossing a portal is correctly assigned to that zone\n- Test zone containment with a position exactly on the bounds_min edge (inclusive boundary)\n- Test that zone_transition WebSocket message is broadcast with correct from_zone and to_zone names\n\n## Acceptance Criteria\n\n- Portal editor allows placing vertical plane portals across doorways in the 3D scene\n- Zone bounding boxes are editable and render as semi-transparent volumes in 3D view\n- Zone labels update in real-time as people move between zones (\"Kitchen: Alice, Bob\")\n- Zone transition events fire within one track update cycle (100ms) of the crossing occurring\n- Reconciliation pass correctly handles tracks that appear inside zones without portal crossings\n- Zone and portal data persists across mothership restarts via SQLite\n- WebSocket broadcasts zone_occupancy after every occupancy change\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:45:41.668543362Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.268105795Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.268078719Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-03-28T01:45:44.642770328Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-qob","title":"Webhook action firing & fault tolerance for automations","description":"## Overview\nReliable webhook delivery for automation trigger actions with error handling and dashboard feedback.\n\n## Backend (mothership/automation/)\n- HTTP client: POST to configured URL with 5s timeout; fire-and-forget (no retry)\n- Payload schema: {trigger_id, trigger_name, condition, blob_id, person, position:{x,y,z}, zone, dwell_s, timestamp_ms}\n- Error handling:\n - 4xx response: disable trigger + set trigger.error_message; push WS alert to dashboard\n - 5xx / timeout: log warning + increment trigger.error_count; do NOT disable\n - error_count resets on first 2xx response\n- Test endpoint: POST /api/triggers/{id}/test — fires webhook once with synthetic payload, returns {status, response_ms, error}\n- Audit log: webhook_log table (trigger_id, fired_at_ms, url, status_code, latency_ms, error)\n\n## Dashboard\n- Error badge on trigger card when disabled due to 4xx\n- 'Test Webhook' button in trigger edit panel — shows response in real time\n- Last N firings visible in trigger detail view (from webhook_log)\n- 'Re-enable' button to clear error state and retry\n\n## Acceptance\n- 5xx failures do not disable triggers\n- 4xx disables trigger and shows dashboard warning within 2s\n- Test endpoint returns response within timeout + 500ms overhead\n- Requires: spaxel-6ha (REST API), spaxel-vuw (trigger volumes), spaxel-9eg (WS alerts)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:53.677999018Z","created_by":"coding","updated_at":"2026-04-06T13:01:53.677999018Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-qob","title":"Webhook action firing & fault tolerance for automations","description":"## Overview\nReliable webhook delivery for automation trigger actions with error handling and dashboard feedback.\n\n## Backend (mothership/automation/)\n- HTTP client: POST to configured URL with 5s timeout; fire-and-forget (no retry)\n- Payload schema: {trigger_id, trigger_name, condition, blob_id, person, position:{x,y,z}, zone, dwell_s, timestamp_ms}\n- Error handling:\n - 4xx response: disable trigger + set trigger.error_message; push WS alert to dashboard\n - 5xx / timeout: log warning + increment trigger.error_count; do NOT disable\n - error_count resets on first 2xx response\n- Test endpoint: POST /api/triggers/{id}/test — fires webhook once with synthetic payload, returns {status, response_ms, error}\n- Audit log: webhook_log table (trigger_id, fired_at_ms, url, status_code, latency_ms, error)\n\n## Dashboard\n- Error badge on trigger card when disabled due to 4xx\n- 'Test Webhook' button in trigger edit panel — shows response in real time\n- Last N firings visible in trigger detail view (from webhook_log)\n- 'Re-enable' button to clear error state and retry\n\n## Acceptance\n- 5xx failures do not disable triggers\n- 4xx disables trigger and shows dashboard warning within 2s\n- Test endpoint returns response within timeout + 500ms overhead\n- Requires: spaxel-6ha (REST API), spaxel-vuw (trigger volumes), spaxel-9eg (WS alerts)","status":"in_progress","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T13:01:53.677999018Z","created_by":"coding","updated_at":"2026-04-07T01:29:38.087817268Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:8"]} {"id":"spaxel-qpi","title":"Add sleep quality monitoring","description":"Implement overnight sleep analysis and reporting.\n\nDeliverables:\n- Breathing analysis during sleep hours\n- Motion scoring integration\n- Morning summary generation\n\nAcceptance: System generates daily sleep quality reports with breathing and motion metrics.","status":"closed","priority":2,"issue_type":"task","assignee":"sp4","created_at":"2026-03-29T19:25:04.113915797Z","created_by":"coding","updated_at":"2026-03-29T21:49:08.422771059Z","closed_at":"2026-03-29T21:49:08.422704623Z","close_reason":"Implemented overnight sleep quality monitoring with breathing analysis, motion scoring, and morning summary generation. System generates daily sleep reports with quality scores (0-100) based on breathing (40%), motion (30%), and continuity (30%) metrics. Added REST API at /api/sleep/* for status and reports.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-qq6","title":"Node placement UI and coverage painting","description":"Interactive 3D node placement with real-time GDOP coverage overlay.\n\n## Deliverables\n- TransformControls for dragging nodes in 3D scene\n- Space dimension editor (set room width, depth, height)\n- GDOP (Geometric Dilution of Precision) overlay on ground plane\n- Coverage overlay updates in real-time during node drag\n- Virtual node support for planning (not yet physical)\n- Save node positions to mothership via REST API\n\n## Acceptance Criteria\n- User can drag nodes and see coverage quality change in real-time\n- GDOP overlay clearly shows good vs poor detection areas\n- Virtual nodes help plan optimal placement before purchasing hardware\n\n## References\n- Plan: docs/plan/plan.md items 18-19\n- Dashboard: dashboard/js/app.js","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-27T01:57:11.969814548Z","created_by":"coding","updated_at":"2026-03-28T13:26:19.014111052Z","closed_at":"2026-03-28T13:26:19.013998615Z","close_reason":"Implemented interactive 3D node placement with real-time GDOP coverage overlay. Frontend: TransformControls for node dragging, room dimension editor, GDOP overlay (128x128 DataTexture with HDOP computation), real-time updates during drag, virtual node support, click-to-select via raycasting, GDOP legend. Backend: PUT /api/nodes/{mac}/position, POST /api/nodes/virtual, DELETE /api/nodes/{mac}, PUT /api/room endpoints, OTAStatusHandler wiring, firmware manifest fix. Go build verified. Committed as 7b32570.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-qq6","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.773203986Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-r0l","title":"Guided troubleshooting and first-time UX","description":"## Background\n\nThe onboarding wizard (spaxel-zvo) handles the happy path from unboxed ESP32 to streaming CSI. This bead handles what happens when things go wrong — during onboarding and during normal operation — and provides first-time feature discovery for users after successful setup. The design principle is that a non-technical household member should never need to read documentation, open a terminal, or contact support for any common problem. Every failure mode has a recovery path visible in the UI.\n\n## Scope\n\nThis Phase 4 bead covers foundational troubleshooting infrastructure. A Phase 9 bead (Guided troubleshooting enhanced for production) extends this with proactive quality prompts and production-polish improvements.\n\n## 1. Onboarding Failure Guidance\n\nAt each step of the onboarding wizard, intercept known failure modes and render human-readable recovery steps instead of technical errors. Each wizard step has an error state with specific guidance:\n\nBrowser check errors:\n- navigator.serial unavailable -> \"Please use Google Chrome or Microsoft Edge. Firefox and Safari do not support USB device communication.\"\n\nDevice connection errors:\n- No device selected -> \"Did you hold the BOOT button while plugging in? Try again: hold BOOT, then plug in the USB cable.\"\n- Permission denied -> \"Browser blocked USB access. Check your browser's site permissions for this address.\"\n- Access denied (device in use) -> \"Another application is using this USB port. Close Arduino IDE, esptool, or any other serial monitor and try again.\"\n\nWiFi provisioning errors:\n- Node not appearing after 120s -> Stepped guidance: (1) Check SSID/password are exactly correct. (2) ESP32-S3 only supports 2.4GHz — check your router's 2.4GHz band is enabled. (3) Check if your router has AP isolation / client isolation enabled (blocks device-to-device communication). (4) Try moving the node closer to the router.\n- Node appears but no CSI data -> \"The node connected but is not sensing yet. Check the antenna orientation — the PCB antenna should face away from walls.\"\n\n## 2. Node Offline Troubleshooting\n\nWhen a node goes offline (detected via heartbeat timeout in mothership), the dashboard shows a troubleshooting timeline card:\n\n\"Node [Living Room] went offline at [time].\"\nTimeline of suggested actions:\n1. Check the node's power LED is on (solid green = powered and connected, blinking = attempting WiFi)\n2. If blinking: move the node closer to your WiFi router temporarily\n3. If the LED is blinking rapidly after 5 minutes: the node has lost its WiFi configuration. Connect to 'spaxel-{last4mac}' WiFi network to reconfigure. [Link to captive portal guide]\n4. If the LED is off: check the power supply and USB cable\n5. Still stuck? [Reset to factory defaults] (button that sends a \"reboot\" downstream command if node reconnects, or shows instructions for manual factory reset via BOOT button hold)\n\nThe card remains visible until the node reconnects. It includes last-known position on the floor plan (greyed out) and how long it has been offline.\n\n## 3. First-Time Feature Tooltips\n\nOn the first dashboard open after a node is successfully added (tracked by a localStorage key \"spaxel_tooltips_shown\"), show contextual tooltips pointing to key features:\n- Point at the CSI amplitude chart: \"This is your live signal. Motion causes the waves to change.\"\n- Point at the 3D view (if available): \"This 3D space updates as people move around.\"\n- Point at the presence indicator: \"Green = no one detected. Red = motion detected.\"\n- Point at the link list: \"Each line between two nodes is a sensing link.\"\n\nTooltips: auto-dismiss after 8 seconds, dismiss-all button, never re-appear (localStorage flag per tooltip ID). Rendered as floating HTML divs with an arrow pointing to the target element, positioned via getBoundingClientRect().\n\n## 4. Post-Calibration Reinforcement\n\nAfter the guided calibration walk in the onboarding wizard completes (blob detected successfully), show a \"You're all set\" card:\n- Summary: \"[Node A] calibrated. 1 sensing link active. Motion detection: Ready.\"\n- What to expect: \"You'll see the CSI waveform react when someone walks through the room. The system learns your space over the next few hours and becomes more accurate.\"\n- Next step prompt: \"Want to add another node for more precise location tracking? [Add another node] [I'm done for now]\"\n\n## 5. Detection Quality Prompts (Phase 4 basic version)\n\nWhen a link's packet rate drops below 50% of the configured rate for more than 60 seconds (basic quality issue detectable without Phase 5 full confidence scoring):\n- Show a non-blocking banner: \"Node A is having trouble communicating. Check that it is powered on and within WiFi range.\"\n- Auto-dismiss when packet rate recovers.\n\n## Implementation\n\nTroubleshooting logic lives in dashboard/js/troubleshoot.js:\n- TroubleshootManager class subscribes to WebSocket events: node_offline, node_online, low_packet_rate, calibration_complete\n- For each event type, renders the appropriate UI component\n- Uses a simple state machine per issue: DETECTED -> NOTIFIED -> RESOLVED/DISMISSED\n- Issue state persisted in memory (cleared on page refresh — issues re-fire on next event)\n\nTooltip system: dashboard/js/tooltips.js\n- TooltipManager class: show(tooltipId, targetSelector, text, direction)\n- Checks localStorage \"spaxel_tooltip_{id}_shown\" before showing\n- Sets localStorage flag on dismiss\n- All tooltips in a manifest array, shown in sequence on first visit\n\nA dedicated troubleshoot.css for the offline card and tooltip styles.\n\n## Design Principles\n\n- Guidance must be actionable, not diagnostic: \"Move the node closer to your router\" not \"WiFi RSSI is -78 dBm\"\n- Never condescending: \"Your node went offline\" not \"Error: WebSocket connection closed with code 1006\"\n- Never blocks normal operation: every troubleshooting element is dismissible\n- Avoid information overload: show one most-likely cause first, with \"More options\" expander for alternatives\n- Use progressive disclosure: simple guidance first, technical details behind \"Advanced\" toggle\n\n## Tests\n\n- Test that node_offline WebSocket event triggers the troubleshooting panel render with correct node label\n- Test that tooltip TooltipManager correctly checks localStorage before showing each tooltip\n- Test that tooltips set the localStorage flag on dismiss and do not re-appear on subsequent show() calls\n- Test that node_online event after node_offline dismisses the offline card\n- Test that low_packet_rate event below 50% threshold triggers the quality banner\n- Test that the post-calibration card renders with correct link count and node label\n\n## Acceptance Criteria\n\n- All common onboarding failure modes have human-readable recovery paths in the wizard\n- Node offline card appears in the dashboard within 30 seconds of disconnection\n- Offline card includes actionable steps and the captive portal AP SSID\n- First-time tooltips appear exactly once on first dashboard open after node addition\n- Tooltips never re-appear after dismissal (localStorage persistence)\n- Post-calibration card shows correct summary after wizard completion\n- Detection quality banner fires when packet rate drops below 50% threshold\n- All UI elements are dismissible without blocking normal dashboard use\n- Tests pass","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:39:04.748866600Z","created_by":"coding","updated_at":"2026-03-28T08:19:35.525106448Z","closed_at":"2026-03-28T08:19:35.525040672Z","close_reason":"Implemented guided troubleshooting and first-time UX:\n\n1. Onboarding failure guidance - human-readable recovery paths at each wizard step\n2. Node offline troubleshooting - timeline cards with progressive disclosure, factory reset modal, captive portal AP SSID\n3. First-time feature tooltips - 4 contextual tooltips with localStorage persistence, auto-dismiss, sequential tour\n4. Post-calibration reinforcement card with summary and next steps\n5. Detection quality prompts - client-side link health check with auto-recovery\n6. 30 new tests, all 96 tests pass","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-r0l","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.926926473Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-r0l","depends_on_id":"spaxel-zvo","type":"blocks","created_at":"2026-03-28T01:39:10.975223706Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -83,6 +85,7 @@ {"id":"spaxel-s60","title":"Implement presence prediction","description":"Build predictive presence modeling for Home Assistant integration.\n\nDeliverables:\n- Per-person transition probability tracking\n- Per-zone occupancy patterns\n- Time-slot based predictions\n- HA sensor exposure for predicted states\n\nAcceptance: Predictions achieve >75% accuracy at 15-minute horizon.","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-03-29T19:25:04.052115700Z","created_by":"coding","updated_at":"2026-04-02T01:20:12.179026061Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:924","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-s70","title":"Activity timeline","description":"## Background\n\nSpaxel generates a continuous stream of events: presence detections, zone transitions, alerts, system events, learning milestones, health changes. Without a structured event stream, debugging is difficult, history is lost, and the system appears as a black box. The activity timeline is the universal event log — a chronological record of everything the system has seen. It doubles as the primary debugging interface and enables time-travel replay (an engineer can tap any timeline event and the 3D view jumps back to that moment).\n\n## Internal Event Bus\n\nNew package: mothership/internal/events/bus.go\n\nEventBus provides a typed publish-subscribe mechanism for all internal events. All subsystems publish to the bus; the timeline, automation engine, and notification module subscribe.\n\nImplementation: a simple channel-based pub/sub. Publisher side: bus.Publish(EventType, EventPayload). Subscriber side: bus.Subscribe(EventType) returns a channel. Multiple subscribers per event type are supported (fan-out).\n\nEventType enum:\n- MotionDetected, MotionCleared\n- ZoneTransition (a person crossed a portal)\n- ZoneOccupancyChanged (any occupancy change, including by anonymous tracks)\n- FallDetected, FallAcknowledged\n- NodeConnected, NodeDisconnected, NodeOTAComplete\n- BLEDeviceFirstSeen, BLEIdentityAssigned\n- WeightUpdate, DiurnalBaselineActivated\n- AnomalyDetected, AnomalyAcknowledged\n- SleepSessionStart, SleepSessionEnd\n- FeedbackSubmitted\n\nEventPayload is a typed interface. Each event type has its own concrete struct.\n\n## Timeline Storage\n\nSQLite table:\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n type TEXT NOT NULL,\n timestamp DATETIME NOT NULL,\n person_id TEXT,\n zone_id TEXT,\n data_json TEXT NOT NULL, -- full event payload as JSON\n feedback_type TEXT, -- populated by feedback loop (Phase 7)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX idx_events_timestamp ON events (timestamp DESC);\nCREATE INDEX idx_events_person ON events (person_id, timestamp DESC);\nCREATE INDEX idx_events_zone ON events (zone_id, timestamp DESC);\nCREATE INDEX idx_events_type ON events (type, timestamp DESC);\n\nTimeline subscriber: a goroutine that reads from the bus and writes to SQLite. Buffered with a 1000-event queue to avoid blocking publishers. If the queue fills: log a warning and drop the oldest (the bus is lossy for the storage subscriber, but this should never happen at normal event rates).\n\n## Dashboard Timeline Panel\n\nSidebar panel showing events in reverse-chronological order.\n\nEvent visual rendering per type:\n- MotionDetected / ZoneTransition: person avatar (coloured circle with initial) + description + timestamp + thumbs\n- FallDetected: red shield icon + \"Possible fall: [person] in [zone]\" + Acknowledge button\n- NodeConnected / NodeDisconnected: grey dot icon + \"Node [label] connected/disconnected\"\n- WeightUpdate / DiurnalBaselineActivated: green brain icon + \"Detection accuracy improved\" / \"Daily patterns activated\"\n- AnomalyDetected: orange warning icon + \"Anomaly: [description]\"\n- SleepSessionStart/End: moon icon + \"Alice went to sleep\" / \"Alice woke up\"\n\nEvent description templates (plain English, no jargon):\n- ZoneTransition: \"{person_name} walked from {from_zone} to {to_zone}\"\n- MotionDetected: \"Motion detected in {zone_name}\" (if no identity)\n- NodeDisconnected: \"Node {label} went offline — {duration} downtime\"\n- DiurnalBaselineActivated: \"System has learned {person_name}'s daily patterns. Detection accuracy improved.\"\n\nVirtualized rendering: use a virtual scroll list (render only visible items) since the timeline can have thousands of events. Implement using IntersectionObserver API for lazy loading of off-screen items.\n\nThumbs-up/down on each event: delegates to the feedback module (spaxel-3ps). Rendered as small icon buttons on the right side of each event row.\n\n## Search and Filter\n\nFilter bar above timeline:\n- Type filter: checkboxes for event categories (Presence, Zones, Alerts, System, Learning). Default: all.\n- Person filter: dropdown \"All people / Alice / Bob / Unknown\"\n- Zone filter: dropdown \"All zones / Kitchen / Bedroom / etc.\"\n- Date range: \"Today / Last 7 days / Last 30 days / Custom\"\n- Text search: fuzzy match on event description text (client-side filtering on loaded events; server-side for date-range queries)\n\nFiltered queries use the indexed columns in the events table. Return at most 500 events per page; \"Load more\" button for pagination.\n\n## Expert vs Simple Mode\n\nExpert mode: all event types visible. System events (node health, weight updates) shown as secondary (smaller text, greyed color).\n\nSimple mode: only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd (morning summary). System events hidden. This prevents \"terminal-style\" log noise from confusing non-technical users.\n\nMode is set by the current dashboard mode (expert vs simple) and passed as ?mode=expert or ?mode=simple to the API.\n\n## Tap-to-Jump (Time-Travel Coordination)\n\nWhen a timeline event is clicked (in expert mode), the dashboard emits a \"jump_to_time\" command with the event's timestamp. The time-travel replay module (Phase 8, separate bead) listens for this command and:\n1. Pauses live playback\n2. Seeks the CSI recording buffer to the event timestamp\n3. Begins replay from that point\n4. The 3D scene shows the \"replay\" state at that timestamp\n\nClicking the event also highlights it in the timeline (selected state) and shows a \"Now replaying\" chip in the timeline header.\n\n## REST API\n\nGET /api/events?since=&until=&type=&person_id=&zone_id=&limit=&mode=expert|simple\nReturns: paginated list of Event objects with all fields.\n\nGET /api/events/{id}: single event detail\nPOST /api/events/{id}/feedback: submit feedback for an event (delegates to feedback module)\n\n## Tests\n\n- Test EventBus pub/sub: publish event, verify subscriber channel receives it within 10ms\n- Test that multiple subscribers all receive the same event\n- Test timeline storage: publish 10 events of different types, verify all appear in SQLite with correct fields\n- Test search and filter: insert events for two people and two zones, query by person -> correct subset returned\n- Test time-range filtering: insert events at T-1h and T-25h; query since T-24h -> only T-1h event\n- Test virtualized rendering handles 1000+ events without layout jank (performance test in browser)\n- Test tap-to-jump emits correct timestamp to time-travel player\n- Test expert vs simple mode filter: system events excluded in simple mode\n\n## Acceptance Criteria\n\n- All event types appear in the timeline within 1 second of firing\n- Search and filter queries return correct subsets\n- Tap-to-jump coordinates with time-travel player (3D scene seeks to correct timestamp)\n- Simple mode hides system events while showing person-relevant events\n- Feedback buttons appear on each event and invoke the feedback module correctly\n- Timeline handles 10,000+ events without UI slowdown via virtualised rendering\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:54:31.341960586Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.636974843Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-s70","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.636944347Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-sbi","title":"Ambient confidence score and link health","description":"## Background\n\nNot all sensing links are equal. A link where a wall bisects the Fresnel zone produces consistently noisy detections. A link experiencing WiFi congestion from neighbour networks drops packets and has unreliable amplitude measurements. A link near a microwave oven sees periodic interference bursts. Without a quality metric, the fusion engine treats all links equally, and poor-quality links introduce noise that degrades overall localisation accuracy.\n\nThe ambient confidence score is a per-link quality metric that: (1) gates and weights detection algorithms so poor links contribute less, (2) surfaces actionable quality information to the user, and (3) powers the link weather diagnostics feature. A composite system-wide Detection Quality gauge summarises overall system health.\n\n## Per-Link Health Metrics\n\nNew module: mothership/internal/health/linkhealth.go\n\nLinkHealthScorer computes five sub-metrics per link, each in [0, 1]:\n\n1. SNR Estimate (weight 40%)\n Ratio of motion-period delta to quiet-period delta, expressed as a quality score.\n During known-quiet periods (determined by extended absence of motion, minimum 60s), record the ambient deltaRMS variance (sigma_quiet). During motion-active periods, record deltaRMS peaks (signal level). SNR_ratio = signal_level / sigma_quiet. Map to [0,1] via: score = min(1.0, log10(SNR_ratio) / log10(100)) where SNR=100:1 -> score=1.0, SNR=10:1 -> score=0.5.\n\n2. Phase Stability (weight 30%)\n During known-quiet periods, compute the variance of the phase offset across subcarriers. Low variance indicates stable hardware clock synchronisation between TX and RX, which is a prerequisite for reliable phase-based detection. High variance (>0.5 radians) suggests temperature drift or near-field metal interference.\n score = max(0, 1 - phase_variance / 0.5)\n\n3. Packet Rate Health (weight 20%)\n actual_pps / configured_rate. If configured at 50 Hz and receiving 40 Hz: score = 0.8.\n Rolling average over 10-second window.\n\n4. Baseline Drift (weight 10%)\n Rate of change of the EMA baseline over a 1-hour sliding window. High drift indicates an unstable environment (e.g. gradual temperature change, something blocking or unblocking the Fresnel zone). Computed as: drift_rate = |B_t - B_{t-1h}| / |B_{t-1h}| (normalised L2 change per hour).\n score = max(0, 1 - drift_rate / 0.1) where 10% per hour -> score=0.0.\n\n5. Composite Score\n composite = 0.4 * snr + 0.3 * phase_stability + 0.2 * packet_rate + 0.1 * (1 - baseline_drift_normalized)\n Clamped to [0, 1]. Updated every 10 seconds.\n\n## Dashboard Visualisation\n\nPer-link health is surfaced in multiple places:\n\nIn the 3D view (Phase 3 node placement UI, spaxel-qq6):\n- Link line thickness: 2px (health > 0.7), 1px (health 0.4-0.7), 0.5px (health < 0.4)\n- Link line colour: green (#22c55e at health=1.0) through yellow (#eab308 at health=0.5) through red (#ef4444 at health=0)\n\nIn the Link Health panel (sidebar, shown on link click):\n- Per-metric breakdown: four sub-score gauges (SNR, Phase Stability, Packet Rate, Baseline Drift) with label, value, and interpretation\n- Sparkline chart: composite health score over last 24 hours\n- \"Why is this low?\" contextual hint based on which sub-metric is lowest\n\nSystem-wide Detection Quality gauge (dashboard header):\n- Single number: weighted average of all active link composite scores\n- Rendered as a circular gauge (0-100%) with colour gradient\n- Tooltip: \"Based on N active links. Weakest link: [link name] at [score%]\"\n\n## API\n\nGET /api/links returns:\n[{\n \"link_id\": \"aabbccddee:ff:00:11:22:33\",\n \"tx_mac\": \"aa:bb:cc:dd:ee:ff\",\n \"rx_mac\": \"00:11:22:33:44:55\",\n \"health_score\": 0.83,\n \"health_details\": {\n \"snr\": 0.91,\n \"phase_stability\": 0.78,\n \"packet_rate\": 0.97,\n \"baseline_drift\": 0.62\n },\n \"last_updated\": \"2026-03-27T14:23:45Z\"\n}]\n\n## Gating Effects\n\nThe health score gates and weights two downstream systems:\n1. BreathingDetector (stationary person detection, spaxel-r37): disabled when composite health_score < 0.7\n2. FusionEngine (spaxel-m9a): each link's contribution to the 3D occupancy grid is multiplied by its health_score. A link with score=0.3 contributes only 30% as much as a link with score=1.0. This prevents degraded links from producing noisy phantom blobs.\n\nThe gating thresholds (0.7 for breathing, any value for weighted fusion) are configurable via mothership config.\n\n## Integration with Existing Code\n\nLinkHealthScorer is instantiated in mothership/internal/ingestion/server.go alongside the existing signal processors. It receives:\n- Packet arrival timestamps (to compute actual PPS vs configured)\n- deltaRMS values from the signal processor (for SNR computation)\n- Phase values from the signal processor (for phase stability)\n- Baseline vectors from BaselineManager (for drift computation)\n\nThe health scores are updated in background via a goroutine that fires every 10 seconds. Results are published on the internal event bus as LinkHealthUpdate events, which the dashboard hub broadcasts as \"link_health\" WebSocket messages.\n\n## Tests\n\n- Test composite score computation with mock inputs: all 1.0 -> 1.0, packet_rate=0.5 others 1.0 -> weighted result\n- Test SNR sub-score mapping: SNR_ratio=1 -> score=0, SNR_ratio=10 -> score=0.5, SNR_ratio=100 -> score=1.0\n- Test phase stability: variance=0 -> score=1.0, variance=0.5 -> score=0.0, variance=0.25 -> score=0.5\n- Test that breathing detection gating fires correctly when score drops below 0.7\n- Test FusionEngine link weight reflects health score (inspect internal state after injection)\n- Test API response format matches documented schema\n- Test that health score updates are published to the event bus\n\n## Acceptance Criteria\n\n- Per-link health scores computed and visible in dashboard for all active links\n- 3D link line thickness and colour reflect health score in real-time\n- Detection Quality gauge shows system-wide average health, updates every 10 seconds\n- BreathingDetector correctly gated off when link health < 0.7\n- FusionEngine link weights reflect health scores (verified via test)\n- Per-metric breakdown visible in Link Health panel on link click\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:41:30.452621121Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.806481028Z","closed_at":"2026-03-29T18:07:39.806256783Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-sbi","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:13.992381357Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-she","title":"fix: mqtt/client.go API mismatches for paho v1.5.0","description":"## Problem\n`internal/mqtt/client.go` uses methods that don't exist in paho.mqtt.golang v1.5.0 (the version in go.mod):\n\n1. **Line 120**: `opts.SetCleanOnConnect(true)` — method doesn't exist. In paho v1.5.0 the method is `opts.SetCleanSession(true)`\n2. **Line 147**: `opts.OnDisconnect = func(...)` — field doesn't exist. In paho v1.5.0 the callback is set via `opts.SetConnectionLostHandler(func(...))`\n3. **Lines 402, 404**: Redundant type assertions inside a type switch:\n - `case string: data = []byte(v.(string))` — `v` is already typed as `string` in this case branch; change to `data = []byte(v)`\n - `case []byte: data = v.([]byte)` — `v` is already `[]byte`; change to `data = v`\n\n## Fixes\n1. Line 120: `opts.SetCleanOnConnect(true)` → `opts.SetCleanSession(true)`\n2. Lines 147-160 (OnDisconnect assignment block): Replace with `opts.SetConnectionLostHandler(func(client mqtt.Client, err error) { ... })`\n3. Lines 402, 404: Remove the redundant type assertions\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/mqtt/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:21.813369312Z","created_by":"coding","updated_at":"2026-04-06T22:47:51.175731416Z","closed_at":"2026-04-06T22:47:51.175482589Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} {"id":"spaxel-sl2","title":"Phase 8: Analysis & Developer Tools","description":"Goal: Deep debugging, system tuning, detection explainability.\n\nDeliverables:\n- Activity timeline (universal event stream, tap-to-jump, inline feedback)\n- Detection explainability (X-ray overlay, contributing links, confidence breakdown)\n- Time-travel debugging (pause live, scrub timeline, replay 3D from recorded CSI)\n- Pre-deployment simulator (virtual space + nodes + synthetic walkers, GDOP overlay)\n- CSI simulator Go CLI (virtual nodes, synthetic CSI binary frames, for dev/testing)\n- Fresnel zone debug overlay (wireframe ellipsoids between active links)\n\nExit criteria: Time-travel replays 24h of data. Simulator produces realistic synthetic data.","status":"open","priority":3,"issue_type":"phase","created_at":"2026-03-27T01:55:47.111916358Z","created_by":"coding","updated_at":"2026-03-28T01:33:51.145143387Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-sl2","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T01:33:51.145107801Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-sty","title":"CSI simulator Go CLI","description":"## Background\n\nThe pre-deployment simulator (spaxel-btj) provides a browser-based spatial planning tool. The CSI simulator CLI is a complementary developer tool: a standalone Go command-line program that opens WebSocket connections to a running mothership as virtual nodes and injects synthetic CSI binary frames. This enables automated integration testing, performance benchmarking, and algorithm development entirely without ESP32 hardware.\n\nThe critical difference: the pre-deployment simulator generates CSI on the server side (using the simulation API). The CLI simulator generates CSI externally and connects via the standard node WebSocket interface — testing the full network stack and protocol, not just the signal processing.\n\n## CLI Implementation\n\nNew Go command: mothership/cmd/sim/main.go\n\nUsage examples:\n sim --mothership localhost:8080 --nodes 4 --walkers 2 --rate 20 --duration 60\n sim --mothership localhost:8080 --token abc123def456 --nodes 2 --walkers 1 --seed 42\n sim --space \"6x4x2.5\" --nodes 4 --walkers 3 --rate 50 --duration 120 --ble\n sim --verify --mothership localhost:8080 --nodes 2 --walkers 1 --duration 10\n\nCLI Flags:\n- --mothership: URL of the mothership (default: ws://localhost:8080/ws)\n- --token: provision token. If not specified, automatically provisions via POST /api/provision with synthetic credentials.\n- --nodes: number of virtual nodes (default 2). Each node opens a separate WebSocket connection.\n- --walkers: number of synthetic walkers (default 1).\n- --rate: CSI transmission rate in Hz per node pair (default 20).\n- --duration: total run time in seconds (default 60). \"0\" means run until Ctrl+C.\n- --seed: random seed for reproducible walker paths (default: random seed, logged at startup).\n- --space: room dimensions in \"WxDxH\" format (default \"5x5x2.5\").\n- --ble: include synthetic BLE advertisements (one BLE device per walker, with stable random MAC).\n- --verify: after --duration seconds, verify that the mothership produced the expected number of blobs. Exit 0 on success, 1 on failure.\n- --noise-sigma: Gaussian noise standard deviation for I/Q generation (default 0.005).\n- --wall: add a wall as \"x1,y1,x2,y2\" (can be repeated). Walls affect the path loss model.\n- --output-csv: write synthetic ground truth (walker positions + link deltaRMS) to a CSV file for offline analysis.\n\n## Synthetic CSI Frame Generation\n\nFor each virtual node pair (TX, RX) and each walker at each timestep:\n\n1. Compute RSSI from path loss model (same as simulator physics, mothership/internal/simulator/physics.go — reuse this package).\n\n2. Compute deltaRMS from Fresnel zone overlap (same physics model).\n\n3. Generate binary CSI frame matching the Phase 1 protocol format:\n Header (24 bytes):\n - Magic: 0xABCDEF01 (4 bytes)\n - Version: 1 (1 byte)\n - Node MAC: 6 bytes (synthetic, consistent per virtual node)\n - Peer MAC: 6 bytes (TX node's MAC for RX-side frames)\n - Channel: 6 (2.4GHz ch 6) or 36 (5GHz) — configurable\n - RSSI: 1 byte (signed, from path loss calculation)\n - Num subcarriers: 64 (1 byte)\n - Timestamp_us: 8 bytes (current Unix microseconds)\n\n Payload (128 bytes = 64 subcarriers * 2 bytes each):\n - For each subcarrier i: I = amplitude * cos(phase_i) + noise, Q = amplitude * sin(phase_i) + noise\n - amplitude: from Fresnel zone deltaRMS model\n - phase_i: phase_base + i * phase_step + phase_noise (simulate subcarrier phase variation)\n - noise: gaussian(sigma=--noise-sigma)\n - Values are int8 (clamped to [-127, 127])\n\nThe frame format is validated against the actual firmware output by comparing to real recorded frames in docs/research/reference_frames.bin (if available).\n\n## Connection Protocol\n\nEach virtual node:\n1. Opens WebSocket to ws://{mothership}/ws\n2. Sends hello message: {\"type\":\"hello\",\"mac\":\"{virtual_mac}\",\"firmware_version\":\"sim-1.0.0\",\"capabilities\":{\"can_tx\":true,\"can_rx\":true},\"free_heap\":200000,\"wifi_rssi\":-45,\"ip_addr\":\"127.0.0.{n}\"}\n3. Waits for role push from mothership (expects {\"type\":\"role\",\"role\":N})\n4. If receives {\"type\":\"reject\"}: logs error and exits with code 2\n5. Begins sending CSI frames at the configured rate using the binary WebSocket message format (not JSON)\n6. Also sends health messages every 10s: {\"type\":\"health\",\"heap\":200000,\"rssi\":-45,\"uptime_s\":N}\n7. If --ble: sends BLE relay messages every 5s: {\"type\":\"ble\",\"devices\":[{\"mac\":\"...\",\"name\":\"sim-person-1\",\"rssi\":-60}]}\n\n## Verification Mode (--verify)\n\nAfter --duration seconds:\n1. Stop sending CSI frames\n2. Wait 2 seconds for pipeline to settle\n3. GET {mothership}/api/blobs — gets current list of active blobs\n4. Assert: blob_count == walker_count (within ±1 tolerance)\n5. If all walkers are within the room bounds: assert all walkers have a blob within 2m distance\n6. Print: \"PASS: {blob_count} blobs detected for {walker_count} walkers\" or \"FAIL: expected N blobs, got M\"\n7. Exit 0 (PASS) or 1 (FAIL)\n\nThis is the CI smoke test that verifies the full pipeline end-to-end without hardware.\n\n## CI Integration\n\nAdd a CI step to the mothership GitHub Actions workflow (if one exists, or document the command):\n1. Start mothership with test config (in-memory SQLite, no recording)\n2. Run: sim --verify --nodes 2 --walkers 1 --duration 10 --seed 42\n3. Assert exit code 0\n\nThis becomes the primary end-to-end integration test. If the sim fails to produce blobs, something in the pipeline is broken.\n\n## Performance Testing\n\nThe simulator supports high-throughput testing:\n- sim --nodes 16 --walkers 4 --rate 50 --duration 60: measures mothership throughput at 16 nodes * 50 Hz = 800 frames/second\n- The simulator prints throughput statistics at the end: frames sent, frames per second, CPU time\n- Use for benchmarking and profiling the mothership processing pipeline\n\n## Files to Create\n\n- mothership/cmd/sim/main.go: CLI entry point with all flags\n- mothership/cmd/sim/generator.go: synthetic CSI frame generator\n- mothership/cmd/sim/walker.go: synthetic walker movement simulation\n- mothership/cmd/sim/verify.go: blob count verification logic\n- mothership/internal/simulator/physics.go: reuse from pre-deployment simulator (shared package)\n\n## Tests\n\n- Test that generated frames have the correct binary header format (magic, version bytes in correct positions)\n- Test that RSSI value is within plausible range for the given walker distance (e.g. walker at 2m, wall_attenuation=0 -> RSSI in [-50, -70])\n- Test that generated I/Q values are clamped to int8 range [-127, 127]\n- Test hello message format matches what the mothership ingestion server expects (parsed successfully)\n- Test that --verify correctly detects missing blobs (inject 1 walker, mock mothership returns 0 blobs -> FAIL)\n- Test --seed 42 produces identical walker paths across two runs (reproducibility)\n- Test --output-csv generates a CSV with correct headers and ground truth positions\n\n## Acceptance Criteria\n\n- CLI connects and streams synthetic CSI to a running mothership\n- Mothership blob count equals walker count (within ±1) when --verify is used\n- CLI exits cleanly after --duration seconds\n- --verify returns exit code 0 when blobs match, exit code 1 when they don't\n- Works correctly in a CI environment without hardware\n- High-throughput test (16 nodes, 50 Hz) completes without mothership errors or OOM\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:57:48.145516684Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.669157389Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-sty","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.669138899Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-tgj","title":"Home automation integration (MQTT and webhooks)","description":"## Background\n\nMany home automation systems — Home Assistant, OpenHAB, Node-RED, Domoticz — use MQTT as their primary integration backbone. By publishing spaxel state as MQTT topics with Home Assistant auto-discovery payloads, spaxel becomes a first-class presence sensor in any HA setup. No custom integration code is needed: the entities appear automatically in Home Assistant's entity registry simply by being powered on and having MQTT configured. This makes spaxel accessible to millions of home automation users who already have HA running.\n\n## MQTT Client\n\nOptional MQTT client in mothership/internal/mqtt/client.go.\n\nThe client is optional — if MQTT is not configured, the module is a no-op. Configuration via dashboard Settings or mothership config file:\n- broker_url: e.g. \"tcp://homeassistant.local:1883\" or \"mqtt://192.168.1.100:1883\"\n- username, password (optional)\n- tls: true/false\n- client_id: \"spaxel-{mothership_id}\" (unique per mothership instance)\n- retain: true (retained messages for presence and occupancy topics)\n\nUse the github.com/eclipse/paho.mqtt.golang library (the de-facto standard Go MQTT client).\n\nReconnect policy: exponential backoff from 5s to 120s, indefinite retry. Log reconnect attempts.\n\n## MQTT Topic Structure\n\nAll topics are prefixed with \"spaxel/{mothership_id}/\" where mothership_id is the unique ID from the mothership config.\n\nPerson presence:\n- Topic: spaxel/{mothership_id}/person/{person_id}/presence\n- Payload: \"home\" or \"not_home\" (plain string, retained)\n- Updated when: a person's first labelled BLE device is seen (home) or all their BLE devices have been absent for > 15 minutes (not_home)\n\nZone occupancy:\n- Topic: spaxel/{mothership_id}/zone/{zone_id}/occupancy\n- Payload: integer count (plain string, retained, e.g. \"2\")\n- Topic: spaxel/{mothership_id}/zone/{zone_id}/occupants\n- Payload: JSON array of person names (retained, e.g. [\"Alice\",\"Bob\"])\n- Updated on every zone occupancy change\n\nFall detection:\n- Topic: spaxel/{mothership_id}/fall_detected\n- Payload: JSON {\"person_id\":\"...\",\"person_label\":\"Alice\",\"zone_id\":\"...\",\"zone_name\":\"Hallway\",\"timestamp\":\"2026-03-27T14:23:00Z\"}\n- Not retained (event topic)\n\nSystem health:\n- Topic: spaxel/{mothership_id}/system/health\n- Payload: JSON {\"node_count\":4,\"online_count\":4,\"detection_quality\":0.87,\"mode\":\"home\"}\n- Retained, updated every 60s\n\n## Home Assistant Auto-Discovery\n\nOn initial MQTT connection (and on reconnect), publish HA auto-discovery payloads to the homeassistant/ prefix topics. HA processes these automatically and adds the entities to its registry.\n\nPer person: binary_sensor (presence)\nDiscovery topic: homeassistant/binary_sensor/spaxel_{mothership_id}_{person_id}/config\nPayload (JSON, retained):\n{\n \"name\": \"Alice Presence\",\n \"unique_id\": \"spaxel_{mothership_id}_{person_id}_presence\",\n \"state_topic\": \"spaxel/{mothership_id}/person/{person_id}/presence\",\n \"payload_on\": \"home\",\n \"payload_off\": \"not_home\",\n \"device_class\": \"presence\",\n \"device\": {\n \"identifiers\": [\"spaxel_{mothership_id}\"],\n \"name\": \"Spaxel\",\n \"model\": \"Spaxel Presence System\",\n \"manufacturer\": \"Spaxel\"\n }\n}\n\nPer zone: sensor (occupancy count) + binary_sensor (zone occupied)\nDiscovery topic: homeassistant/sensor/spaxel_{mothership_id}_{zone_id}_occupancy/config\nPayload: {name, unique_id, state_topic, unit_of_measurement: \"people\", device_class: null, device: ...}\n\nFall detection: binary_sensor with device_class: \"safety\"\nDiscovery topic: homeassistant/binary_sensor/spaxel_{mothership_id}_fall/config\n\nSystem mode: select (home/away/sleep) or input_select via MQTT\nDiscovery + command topics so HA can set system mode.\n\nWhen a person or zone is deleted, publish an empty payload to the corresponding discovery topic (HA treats this as \"remove entity\").\n\n## Event Publishing\n\nThe MQTT client subscribes to the internal event bus and publishes:\n- ZoneCrossingEvent -> update zone occupancy topics\n- PersonPresenceChangeEvent -> update person presence topic\n- FallEvent -> publish to fall_detected topic\n- SystemHealthEvent -> publish to system health topic (60s interval)\n\n## Generic Webhook Integration\n\nA complementary feature: a system-level webhook that delivers ALL spaxel events to a single user-configured URL, not just user-configured automations. This is for integrations that want to receive the full event stream (e.g. a custom backend, a logging service, or a complex HA automation that can't be expressed in the automation builder).\n\nPOST /api/settings/system-webhook: set URL\nAll internal events are serialised as JSON and POST'd to this URL with event type in the X-Spaxel-Event header.\nSame retry policy as automation webhooks (one retry after 30s on 5xx).\n\n## Dashboard Settings\n\nSettings panel -> \"Integrations\" tab:\n- MQTT section: broker URL, username, password (masked), TLS toggle, \"Test Connection\" button, \"Publish discovery payloads now\" button\n- System webhook section: URL input, \"Test\" button, recent delivery log (last 10 events with status)\n- Status indicator: green dot if MQTT connected, red if disconnected with last error message\n\n## Tests\n\n- Test MQTT connection to a local broker (use go-mqtt-broker test dependency or a Docker Eclipse Mosquitto in CI)\n- Test auto-discovery payload format against HA MQTT auto-discovery spec (compare JSON structure)\n- Test retained messages: verify that on reconnect, retained messages are not re-published if unchanged\n- Test that fall_detected topic is published on FallEvent\n- Test that zone occupancy topics update correctly on ZoneCrossingEvent\n- Test auto-discovery cleanup: deleting a person sends empty payload to discovery topic\n- Test system webhook delivers all event types to a mock HTTP server\n- Test reconnect policy: simulate broker disconnect, verify client reconnects with backoff\n\n## Acceptance Criteria\n\n- Spaxel entities appear automatically in Home Assistant entity list after MQTT config without any HA config editing\n- Person presence binary_sensor in HA updates within 30 seconds of BLE presence change\n- Zone occupancy sensor in HA reflects correct integer count\n- Fall detection binary_sensor fires and resets correctly\n- System mode select allows bidirectional control from HA (HA can set spaxel mode via MQTT)\n- System webhook receives all event types\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:49:02.414667027Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.412561189Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.412533268Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:49:05.719934686Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-03-28T01:49:05.665936722Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tgj","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:49:05.694421101Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -99,9 +102,10 @@ {"id":"spaxel-v9z","title":"Diurnal adaptive baseline","description":"## Background\n\nThe EMA (Exponential Moving Average) baseline implemented in Phase 2 adapts to ambient conditions on a roughly 30-second timescale. This handles rapid environmental changes well, but misses systematic daily patterns: more WiFi interference in evenings due to neighbour activity, temperature changes affecting the ESP32's hardware oscillator characteristics, different background activity patterns at different times of day (e.g. higher ambient motion from TV or household activity in the evenings). A diurnal (24-hour) baseline learns these patterns and crossfades to the appropriate hourly baseline, dramatically reducing false positives at different times of day.\n\n## What is a Diurnal Baseline?\n\nInstead of a single EMA that tracks the most recent state, we maintain 24 separate baseline vectors — one per hour of the day. Each hour-slot baseline is an array of float64 values, one per CSI subcarrier (matching the existing baseline format). The hour-slot is updated only by readings that arrive during that hour, using a very slow EMA (tau = 7 days, so alpha ≈ 0.00017 per sample at 2 Hz). This means each hour-slot slowly learns the characteristic ambient CSI for that time of day, averaged over many days.\n\n## 7-Day Learning Period\n\nA freshly deployed system has no diurnal data. For the first 7 days, the system continues to use the standard Phase 2 EMA baseline for all detection decisions. During this period, all received readings are silently updating the diurnal slots, but those slots are not used for detection. The dashboard shows: \"Learning your home's daily patterns: {N} days remaining.\"\n\nAfter 7 days of data (where \"data\" means at least 100 readings in each hour-slot — this threshold prevents partial-data slots from being used), the diurnal baseline activates automatically. A one-time notification is shown: \"Your system has learned your daily patterns. Accuracy should improve this week.\"\n\n## Crossfade Algorithm\n\nA naive implementation would jump discontinuously at each hour boundary, causing false detections at e.g. exactly 14:00 when the baseline switches from the 13:xx slot to the 14:xx slot. The fix is a smooth crossfade:\n\nLet h = current_hour (0-23), frac = current_minute / 60.\nEffective baseline B_eff = (1 - frac) * B_slot[h] + frac * B_slot[(h+1) % 24]\n\nThis is a linear interpolation between the current-hour and next-hour baselines. At 13:00, B_eff = B_slot[13]. At 13:30, B_eff = 0.5*B_slot[13] + 0.5*B_slot[14]. At 13:59, B_eff is nearly B_slot[14]. A cosine crossfade (frac_smooth = (1 - cos(pi * frac)) / 2) can be used instead of linear for a perceptually smoother transition.\n\nThe crossfade is applied per-subcarrier to the full baseline vector before comparing to incoming CSI readings.\n\n## Confidence Indicator\n\nThe confidence score is a per-link float in [0, 1] that summarises the reliability of detection on that link. It is broadcast as part of the \"link_health\" WebSocket message and rendered in the dashboard as a colour-coded indicator per link (green > 0.7, amber 0.4-0.7, red < 0.4).\n\nConfidence inputs:\n1. Baseline age: time since the diurnal slot for the current hour was last updated. Staleness reduces confidence. If a slot has not been updated for > 3 days, its confidence contribution is 0.\n2. Diurnal learning progress: 0.0 before 7 days, interpolates to 1.0 at 14 days. This ramps in the diurnal component gradually as more data accumulates.\n3. Packet rate health: actual received packets per second divided by the configured sample rate. If packet rate drops to 80% of configured, confidence = 0.8. At 50%, confidence = 0.5.\n4. Composite: weighted_avg(baseline_age: 0.3, diurnal_progress: 0.3, packet_rate: 0.4).\n\n## SQLite Persistence\n\nThe diurnal baseline must survive mothership restarts. Extend the existing BaselineManager.Snapshot() / RestoreBaseline() methods to include diurnal data.\n\nAdd SQLite table: diurnal_baselines (link_id TEXT, hour_slot INTEGER, subcarrier_idx INTEGER, value REAL, sample_count INTEGER, last_updated DATETIME, PRIMARY KEY (link_id, hour_slot, subcarrier_idx)).\n\nOn startup, RestoreBaseline() loads diurnal slots from SQLite. On shutdown (or every 5 minutes as a background snapshot), Snapshot() writes updated diurnal slots to SQLite. The snapshot is incremental: only write slots that have been updated since the last snapshot.\n\nFile: mothership/internal/signal/baseline.go — extend existing BaselineManager struct.\n\n## Implementation Structure\n\nNew struct DiurnalBaseline within baseline.go:\n- slots [24][]float64: per-hour baseline vectors (len = num_subcarriers)\n- sampleCounts [24]int: readings accumulated per slot\n- lastUpdated [24]time.Time: timestamp of last slot update\n- alpha float64: slow EMA coefficient (default: 1/(7*24*3600*2) per sample at 2Hz)\n\nDiurnalBaseline.Update(hour int, values []float64) method: applies slow EMA to the appropriate hour slot.\nDiurnalBaseline.EffectiveBaseline(t time.Time) []float64: returns the crossfaded baseline for the given timestamp.\nDiurnalBaseline.IsReady() bool: returns true if 7+ days have elapsed since first update AND all 24 slots have >= 100 samples.\nDiurnalBaseline.Confidence(t time.Time, packetRateRatio float64) float64: returns composite confidence score.\n\n## Tests\n\n- Test that hour-slot selection is correct for timestamps at boundaries (23:59:59 -> slot 23, 00:00:00 -> slot 0)\n- Test that crossfade at half-hour produces the correct blend of two adjacent slots\n- Test cosine crossfade is smooth (no discontinuity at integer hours in the smooth version)\n- Test that the 7-day learning gate correctly returns IsReady() = false before 7 days and true after\n- Test that confidence score is 0 when packet_rate_ratio = 0\n- Test SQLite snapshot round-trip: snapshot diurnal data, clear in-memory state, restore, verify values match\n- Test that baseline staleness correctly reduces confidence for a slot not updated in > 3 days\n\n## Acceptance Criteria\n\n- Diurnal baseline automatically activates after 7 days of data collection per link\n- Hour-boundary crossfade is smooth (no visible discontinuities in false positive rate)\n- Confidence indicator visible per link in dashboard, updates in real-time\n- Diurnal baseline data persists across mothership restarts via SQLite snapshot\n- Detection accuracy measurably improves (target: <5% false positive rate after 7-day learning vs >10% with standard EMA baseline in homes with consistent daily patterns)\n- One-time \"patterns learned\" notification fires exactly once after 7 days\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"india","created_at":"2026-03-28T01:39:55.414445302Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.839347486Z","closed_at":"2026-03-29T18:07:39.838982115Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-v9z","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:13.961565954Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-vuw","title":"Spatial automation: trigger volume geometry builder","description":"## Overview\n3D trigger volume editor and point-in-volume evaluation engine for the spatial automation system.\n\n## Backend (mothership/automation/ or zones/)\n- Geometry types: box {type, x, y, z, w, d, h} and cylinder {type, cx, cy, z, r, h} stored as shape_json\n- Point-in-volume tests: axis-aligned box test and cylinder test functions\n- Trigger state machine per (trigger_id, blob_id) pair: track inside/outside state and transition edges (enter, leave, dwell, vacant, count)\n- Dwell timer: fire dwell action after N continuous seconds inside volume\n- SQLite triggers table: id, name, shape_json, condition TEXT, condition_params JSON, actions_json, enabled BOOL, last_fired_ms\n- REST CRUD at /api/triggers (requires spaxel-6ha)\n\n## Dashboard (dashboard/js/automation-builder.js)\n- Automation panel via panel framework (spaxel-896)\n- Draw box volume: click + drag to define base footprint, height slider\n- Draw cylinder volume: click center, drag radius, height slider\n- THREE.js TransformControls for translate/scale/rotate after placement\n- Volume visualization: translucent colored box/cylinder; pulse animation when condition fires\n- Condition picker: enter zone / leave zone / dwell N seconds / zone vacant / count >= N\n- Action list: webhook URL, MQTT topic/payload, internal (arm security, rebaseline, notify)\n- Trigger log: last 10 firings with timestamp and matched blob\n\n## Acceptance\n- Box and cylinder volumes render correctly in 3D view\n- Point-in-volume evaluated on each fusion tick (target <1ms per trigger)\n- Dwell trigger fires at correct time ±1s\n- Trigger state persists across server restart\n- Requires: spaxel-896, spaxel-6ha, spaxel-9eg","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:42.971994626Z","created_by":"coding","updated_at":"2026-04-06T13:01:42.971994626Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-w40","title":"Passive radar: auto-detect router AP as virtual TX node","description":"## Overview\nAutomatically detect the home router as a passive radar TX source, eliminating need for a dedicated active TX node.\n\n## Firmware changes\n- During hello message, include ap_bssid and ap_channel from esp_wifi_sta_get_ap_info()\n\n## Mothership (mothership/fleet/ or ingestion/)\n- On hello: extract ap_bssid; if >=80% of nodes report same BSSID create virtual node entry with virtual=1, position unset\n- OUI lookup: embed IEEE OUI registry as Go map compiled via go:embed; display router brand\n- Detect AP BSSID change (router reboot/replacement) and emit system alert\n- SQLite nodes table: add virtual BOOL, node_type TEXT, ap_bssid TEXT, ap_channel INT columns\n\n## Dashboard\n- After AP auto-detected: 'I detected your router (ASUS). Place it on the floor plan to improve accuracy.'\n- Drag-to-place virtual node (distinct router icon) in 3D editor\n- Confirmation dialog with 'Use as signal source' toggle\n\n## Acceptance\n- Virtual node appears in /api/nodes with virtual=true\n- 3D view renders virtual node with distinct icon\n- AP change detection fires a system event within 30s of BSSID change","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:07.745215170Z","created_by":"coding","updated_at":"2026-04-06T18:04:45.975811136Z","closed_at":"2026-04-06T18:04:45.975562593Z","close_reason":"Implemented passive radar auto-detection of router AP\n\nFirmware: Added ap_bssid/ap_channel to hello message using esp_wifi_sta_get_ap_info()\n\nMothership: Created apdetector package for >=80% BSSID agreement detection, OUI lookup for router manufacturer, AP change detection system events\n\nDashboard: AP detection notification, distinct router icon in 3D (box+4antennas), drag-to-place positioning\n\nVirtual nodes appear in /api/nodes with virtual=true, node_type=ap","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3"]} +{"id":"spaxel-x59","title":"merge: remove phase6 build tag and unify main.go","description":"## Problem\n`cmd/mothership/main_phase6.go` is gated behind `//go:build phase6` which excludes all Phase 6+ code from default builds. The directory has both `main.go` (Phase 5) and `main_phase6.go` (Phase 6) — both define `package main` with `func main()`, so removing the build tag would cause a duplicate symbol error.\n\n## Prerequisites\nAll Phase 6 package compile errors must be fixed first (spaxel-glq, spaxel-9nj, spaxel-19h, spaxel-uln, spaxel-7nk, spaxel-she).\n\n## Steps\n1. Confirm all Phase 6+ packages compile cleanly:\n ```bash\n cd /home/coding/spaxel/mothership\n PATH=$PATH:/home/coding/go/bin go build ./internal/...\n ```\n2. Delete `cmd/mothership/main.go.bak` (stale backup)\n3. Delete `cmd/mothership/main.go` (Phase 5 entrypoint, superseded)\n4. Remove the `//go:build phase6` line and the blank line after it from `cmd/mothership/main_phase6.go`\n5. Build and verify:\n ```bash\n PATH=$PATH:/home/coding/go/bin go build ./...\n PATH=$PATH:/home/coding/go/bin go test ./...\n ```\n\n## Acceptance\n- `go build ./...` passes with no errors\n- Binary is built from the Phase 6 entrypoint\n- No `phase6` build tag exists anywhere in the codebase\n\nDependents:\n <- spaxel-jcc","status":"in_progress","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:32.363205812Z","created_by":"coding","updated_at":"2026-04-07T00:59:46.924358531Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:13"],"dependencies":[{"issue_id":"spaxel-x59","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:30:41.292760872Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:30:41.351817968Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:30:41.255304103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:30:41.209121103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:30:41.390256545Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:30:41.322389944Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-xpk","title":"Diurnal adaptive baseline: 24-hour slot learning","description":"## Overview\nExtend the EMA baseline system with per-hour-of-day slots to eliminate false positives caused by daily environmental cycles (sunlight, HVAC, temperature changes).\n\n## Backend (mothership/signal/baseline.go extension)\n- Data structure: 24 hourly slots per link per subcarrier; each slot stores amplitude blob and sample_count\n- Learning phase (7 days): accumulate motion-free CSI into hourly slots; require >=300 samples/slot to mark ready\n- Steady state: on each fusion tick, select active baseline = weighted blend of diurnal slot (if ready) + EMA fallback\n- Crossfade: over first 15 min of each hour, linearly blend from EMA to diurnal slot; after 15 min use diurnal exclusively\n- Motion-gated updates: EMA updates continue during the hourly window, improving diurnal slot over time\n- Outlier protection: skip update if deltaRMS > motion threshold (don't train on motion frames)\n- SQLite diurnal_baselines table: link_id, hour_of_day (0-23), n_sub INT, amplitude BLOB, sample_count INT, confidence REAL, updated_at INT\n\n## Dashboard visualization\n- Per-link detail panel: 24-hour polar chart (or horizontal bar chart) showing baseline amplitude variance by hour\n- 'Diurnal learning' progress indicator: 'Learning hour 14... 6/7 days'\n- Confidence color per hour: green (ready), amber (partial), red (no data)\n\n## Acceptance\n- Baseline correctly crossfades at hour boundaries (±60s)\n- Motion events during learning do not corrupt slots (outlier protection confirmed by test)\n- Polar chart renders for links with >=1 ready slot\n- No performance regression: baseline lookup remains O(1)\n- Requires: spaxel-jcc (phase 6 integration)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-06T13:02:07.078024506Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-yxr","title":"Ingestion: CSI frame validation with malformed counter and auto-close","description":"## Overview\nImplement strict CSI binary frame validation with per-connection malformed frame counters and automatic connection closure on persistent malformed input.\n\n## Validation rules (plan lines 303-324):\n- Minimum frame length: 24 bytes (header only, zero subcarriers valid)\n- Maximum frame length: 280 bytes (24 header + 128 subcarriers × 2 bytes I/Q)\n- n_sub field: must be ≤128\n- Payload length: must equal n_sub × 2 bytes exactly\n- channel: must be in [1,14] for 2.4 GHz; drop if 0 or >14\n- rssi: int8; 0 treated as invalid/missing (not an error, but log at DEBUG)\n- timestamp_us: any uint64 value accepted\n\n## Per-connection malformed counter (sliding 60-second window):\n- Track malformed_count and window_start_ms per WebSocket connection\n- On each validation failure: increment malformed_count; log at DEBUG\n- Every 60s: check counts → if malformed_count > 100: log WARN 'Node {mac} sent {N} malformed frames in 60s'\n- If malformed_count > 1000 within 60s: close WebSocket with message 'Excessive malformed frames — possible firmware bug'\n- Reset counter every 60s\n\n## Acceptance\n- Valid frame: passes all checks in <1 μs\n- Frame with n_sub=200: rejected (n_sub > 128)\n- Frame with len=10: rejected (< 24 bytes)\n- Frame with channel=0: dropped silently\n- 1001 malformed frames in 60s: connection closed with correct message\n- 101 malformed frames: WARN logged, connection kept open","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:44:21.981852269Z","created_by":"coding","updated_at":"2026-04-06T16:44:21.981852269Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-zpt","title":"Spatial context notifications with floor-plan thumbnails","description":"## Background\n\nPush notifications without context are ignored or disabled. \"Motion detected\" tells you nothing useful. \"Alice walked into the Kitchen — Bob is already there\" is genuinely interesting. \"Possible fall: Alice in Hallway — unacknowledged for 3 minutes\" demands immediate attention. The plan specifies server-side rendering of mini floor-plan thumbnails attached to notifications to provide instant spatial context without opening the app.\n\n## Server-Side Floor-Plan Renderer\n\nNew package: mothership/internal/render/floorplan.go\n\nThe renderer produces a top-down 2D PNG (300x300 pixels) showing:\n- Room outline: outer boundary of all zones as white rectangles on dark background\n- Zone fills: each zone as a semi-transparent coloured fill (zone.color at 20% opacity)\n- Zone labels: zone name in small white text at zone centroid\n- Node positions: small white circle dots\n- Person blobs: coloured circles (person.color) at their last-known position, diameter proportional to detection confidence (min 10px, max 20px)\n- Name labels: person name in white text above each blob circle, if identity is known\n- Portal planes: thin lines in purple (#a855f7)\n- Event highlight: the zone where the event occurred rendered with brighter fill and a white border\n\nRendering library: use github.com/fogleman/gg (a pure-Go 2D graphics library). Alternative: standard image/draw + image/png for maximum portability. The fogleman/gg approach is recommended for its higher-level drawing API (bezier curves, text, etc.).\n\nThe PNG must be generated within 200ms to not delay notification delivery. At 300x300 with simple geometry, this should be easily achievable.\n\nThe rendered PNG is stored as a []byte and passed to the notification delivery function. It is base64-encoded for attachment in webhook payloads or passed as a file to ntfy/Pushover APIs.\n\n## Notification Types and Triggers\n\n1. zone_enter: \"{{person_name}} entered {{zone_name}}\" — LOW priority unless security mode is active\n2. zone_leave: \"{{person_name}} left {{zone_name}}\" — LOW priority\n3. zone_vacant: \"{{zone_name}} is now empty\" — LOW priority\n4. fall_detected: \"Possible fall: {{person_name}} in {{zone_name}}\" — URGENT, always immediate\n5. fall_escalation: \"URGENT: Fall unacknowledged for 5 minutes — {{person_name}} in {{zone_name}}\" — URGENT\n6. anomaly_alert: \"Unexpected presence: {{zone_name}}\" — HIGH priority (breaks quiet hours)\n7. node_offline: \"Node {{node_label}} has gone offline\" — MEDIUM priority\n8. sleep_summary: \"Last night: {{sleep_duration}}\" — LOW priority, morning delivery\n\n## Smart Batching\n\nIf multiple LOW or MEDIUM priority events fire within a 30-second window, batch them into a single notification:\n- \"Alice entered Kitchen. Bob left Living Room.\"\n- \"2 presence events in the last 30 seconds.\"\n\nBatching rules:\n- Batch only events of the same priority level\n- Never batch URGENT events — those are always immediate\n- Never batch events involving different notification types if the combination is confusing\n- Batch counter: if more than 5 events in 30s, summarise as \"N presence events in the last minute\"\n\nBatching implementation: a 30-second window timer per notification channel. When the first LOW event fires, start the 30s timer. Accumulate events. On timer expiry: merge into one notification and deliver.\n\n## Quiet Hours\n\nUser-configurable quiet hours: from_time, to_time (e.g. \"22:00\" to \"07:00\"). Stored in SQLite notifications_config (channel, quiet_from, quiet_to, quiet_days_bitmask).\n\nDuring quiet hours:\n- LOW priority notifications are queued\n- MEDIUM priority notifications are queued\n- HIGH and URGENT notifications are delivered immediately regardless of quiet hours\n\nAt the end of quiet hours (07:00 on non-override days): deliver all queued notifications as a morning digest bundle: \"While you were asleep: [summary of queued events]\"\n\n## Delivery Channels\n\nntfy:\n- POST to https://ntfy.sh/{topic} (or self-hosted server URL)\n- Headers: Authorization: Bearer {token} (if configured), Priority: urgent/high/default/low/min\n- Body: the notification text\n- Headers: Attach: {base64_encoded_png_url} — for ntfy, attach the floor-plan as a URL if mothership is publicly accessible, or send as base64 data URL for local deployments\n\nPushover:\n- POST to https://api.pushover.net/1/messages.json\n- Fields: token, user, message, title, priority, attachment (PNG as multipart form upload)\n\nGeneric webhook:\n- POST to user-configured URL\n- Body: {\"event_type\":\"...\", \"message\":\"...\", \"person_id\":\"...\", \"zone_id\":\"...\", \"timestamp\":\"...\", \"floorplan_png_base64\":\"...\"}\n\n## Configuration UI\n\nDashboard Settings panel -> \"Notifications\" tab:\n- Delivery channel selector: None / ntfy / Pushover / Webhook\n- Channel-specific credential fields (ntfy server URL + topic + token, Pushover API key, webhook URL)\n- Test notification button: sends a test notification to verify configuration\n- Event type enable/disable toggles: per event type, can disable e.g. \"zone_enter\" while keeping \"fall_detected\" enabled\n- Quiet hours: time picker from/to, day-of-week selector\n- Smart batching toggle (default on)\n- \"Morning digest\" toggle (default on — delivers batched quiet-hours events at wake time)\n\n## Files to Create or Modify\n\n- mothership/internal/render/floorplan.go: floor-plan PNG renderer\n- mothership/internal/notifications/manager.go: NotificationManager, batching, quiet hours logic\n- mothership/internal/notifications/ntfy.go: ntfy delivery client\n- mothership/internal/notifications/pushover.go: Pushover delivery client\n- mothership/internal/notifications/webhook.go: generic webhook delivery\n- mothership/internal/dashboard/routes.go: GET/PUT /api/settings/notifications, POST /api/notifications/test\n\n## Tests\n\n- Test floor-plan renderer produces a 300x300 PNG with correct dimensions\n- Test that zone boundaries appear in the rendered PNG at correct coordinates (check pixel colors at known positions)\n- Test batching: 3 LOW events within 10s -> 1 notification; 1 URGENT event -> immediate even if batching timer is active\n- Test quiet hours gate: LOW event at 23:00 with quiet hours 22:00-07:00 -> queued; URGENT event at 23:00 -> delivered immediately\n- Test morning digest delivery: queued events are bundled and delivered at quiet_hours_end\n- Test ntfy delivery with mock HTTP server: verify correct headers and body format\n- Test webhook delivery with mock HTTP server: verify correct JSON body and base64 PNG field\n- Test test-notification endpoint fires correctly\n\n## Acceptance Criteria\n\n- Notification received via ntfy within 5 seconds of trigger event for URGENT priority\n- Floor-plan PNG correctly shows zone boundaries and person positions in the notification\n- Smart batching prevents more than one notification per 30-second window for LOW events\n- Quiet hours suppress LOW/MEDIUM notifications and queue them for morning digest\n- Fall detection and anomaly alerts always bypass quiet hours\n- Morning digest delivered correctly at quiet hours end\n- Test notification button correctly verifies channel configuration\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:48:19.528717849Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.371730406Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.371640840Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:48:23.948107860Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:48:23.975916991Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-zvb","title":"Mothership: adaptive load shedding & resource throttling","description":"## Overview\nImplement a 4-level load shedding system to keep the fusion pipeline responsive under CPU/memory pressure, especially for large fleets.\n\n## Pipeline instrumentation\n- Time each of the 8 fusion pipeline stages per iteration using time.Since()\n- Maintain 5-iteration rolling average of total iteration time (ring buffer of 5 durations)\n\n## Load shedding state machine\nLevel 0 (normal): rolling avg < 80 ms — full pipeline\nLevel 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation (~3 ms saved/iter)\nLevel 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes (~2 ms saved/iter)\nLevel 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; push rate reduction config to all nodes (10 Hz cap)\n\nRecovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level\n\n## Integration points\n- Health endpoint GET /healthz: include shedding_level (0-3) in response\n- Dashboard status bar: show 'System load: NOMINAL / LIGHT / MODERATE / HIGH'\n- WS alert when Level 3 triggered: {type: 'alert', severity: 'warning', description: 'System under load — CSI rate reduced to 10 Hz'}\n- Level 3 recovery: push config message to all nodes restoring their prior rate\n\n## Acceptance\n- Load shedding level changes logged at INFO\n- Level 3 triggers correctly when ingest channel >50% full\n- Node rate restoration confirmed after Level 3 recovery\n- Health endpoint reflects current level\n- No mutex contention from shedding logic itself (must be lock-free reads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-06T13:09:29.689754824Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-zvb","title":"Mothership: adaptive load shedding & resource throttling","description":"## Overview\nImplement a 4-level load shedding system to keep the fusion pipeline responsive under CPU/memory pressure, especially for large fleets.\n\n## Pipeline instrumentation\n- Time each of the 8 fusion pipeline stages per iteration using time.Since()\n- Maintain 5-iteration rolling average of total iteration time (ring buffer of 5 durations)\n\n## Load shedding state machine\nLevel 0 (normal): rolling avg < 80 ms — full pipeline\nLevel 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation (~3 ms saved/iter)\nLevel 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes (~2 ms saved/iter)\nLevel 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; push rate reduction config to all nodes (10 Hz cap)\n\nRecovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level\n\n## Integration points\n- Health endpoint GET /healthz: include shedding_level (0-3) in response\n- Dashboard status bar: show 'System load: NOMINAL / LIGHT / MODERATE / HIGH'\n- WS alert when Level 3 triggered: {type: 'alert', severity: 'warning', description: 'System under load — CSI rate reduced to 10 Hz'}\n- Level 3 recovery: push config message to all nodes restoring their prior rate\n\n## Acceptance\n- Load shedding level changes logged at INFO\n- Level 3 triggers correctly when ingest channel >50% full\n- Node rate restoration confirmed after Level 3 recovery\n- Health endpoint reflects current level\n- No mutex contention from shedding logic itself (must be lock-free reads)","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-07T01:38:46.787912967Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:55"]} {"id":"spaxel-zvo","title":"Interactive onboarding wizard","description":"## Background\n\nPhase 4's central goal is that a non-technical user can go from an unboxed ESP32-S3 to streaming CSI in under 5 minutes. The onboarding wizard is the centrepiece of this experience. It uses the Web Serial API (available in Chrome/Edge) to communicate with the ESP32 over USB — no driver installation needed, no CLI, no app download. The wizard is embedded in the existing mothership dashboard, accessible at /onboard.\n\n## Why Web Serial?\n\nThe alternative approaches — a dedicated mobile app, a WiFi provisioning AP, or a CLI tool — all have significant UX friction. Web Serial lets us flash firmware, provision WiFi credentials, and guide the user through calibration all in one browser session. The dashboard already knows the mothership IP/port. Chrome and Edge (95%+ of desktop browser market) support Web Serial natively since 2021. The only caveat is that Web Serial is not available in Firefox or Safari — this must be documented prominently at the start of the wizard.\n\n## Wizard Steps\n\n1. Browser check: Detect navigator.serial availability. If missing, show: \"Please use Google Chrome or Microsoft Edge to use the setup wizard. Firefox and Safari do not support USB device access.\"\n\n2. Connect device: Call navigator.serial.requestPort(). Guide the user to hold BOOT button while plugging in if the device does not appear. Show a SVG illustration of the ESP32-S3 board with the BOOT button highlighted.\n\n3. Flash firmware (if not already spaxel firmware): Use esp-web-tools (espressif/esp-web-tools). This open-source library handles the full ESP32 flashing pipeline via Web Serial, including ROM bootloader protocol, chip detection, and progress reporting. It needs a firmware manifest.json at GET /api/firmware/manifest describing binary addresses and offsets. Show a progress bar during flashing. Estimated time: 45-90 seconds.\n\n4. Provision WiFi: Show a form for SSID and password. Optional: mothership host/port override (for non-mDNS setups). Assemble the provisioning payload and send to the ESP32 over serial as JSON (see Provisioning Payload bead for format).\n\n5. Detect mothership: Once provisioned and rebooted, the ESP32 boots and discovers the mothership via mDNS (spaxel-mothership.local) or the configured host. Poll GET /api/nodes every 3s for up to 120s waiting for the new node to appear. Show animated \"Connecting...\" indicator. On timeout: show WiFi troubleshooting guidance (5GHz check, SSID typo check, distance check).\n\n6. Guided calibration: Show the CSI waveform for the new node's links as they come online. Steps:\n a. \"Walk around your space for 30 seconds\" — CSI amplitude should show activity. If flat: check node orientation.\n b. \"Stand still at the far end of the room\" — capture baseline. Show countdown. Green check when baseline is captured.\n c. \"Walk through the centre of the room\" — Fresnel zone lights up in 3D view, blob appears. \"The sensor can see you!\"\n\n7. Node placement guidance: Transition to the coverage painting UI (spaxel-qq6) for optimal node positioning. Show GDOP overlay for the current node placement. Suggest additional node positions if coverage is poor.\n\n## Files to Create or Modify\n\n- dashboard/js/onboard.js: wizard state machine, Web Serial API calls, step rendering\n- dashboard/index.html: add /onboard route and wizard container div, import esp-web-tools\n- mothership/internal/dashboard/ routes: add GET /api/firmware/manifest route\n- Firmware manifest JSON served at GET /api/firmware/manifest with chipFamily, parts array containing path and offset\n\n## esp-web-tools Integration\n\nThe library esp-web-tools is loaded from CDN as an ES module. A custom-element install-button is used for flashing. The manifest served by the mothership includes the firmware binary path (/firmware/latest) and flash offset (0x0). The library handles the bootloader handshake, erase, and write automatically.\n\n## Wizard State Machine\n\nStates: BROWSER_CHECK -> CONNECT_DEVICE -> FLASH_FIRMWARE -> PROVISION_WIFI -> DETECT_NODE -> CALIBRATE -> PLACEMENT -> COMPLETE\n\nEach state has: render() function, onEnter() side effects, onNext() transition, onBack() for revert, onError() for failure handling.\n\nPersisted in sessionStorage so a page refresh during onboarding resumes from the last step — critical for the reboot-then-detect step where the browser must survive the ESP32 reboot cycle.\n\n## Error Handling\n\nMap every known failure to a human-friendly message:\n- NotFoundError (no port selected) -> \"No device detected. Make sure the USB cable is connected and hold the BOOT button while plugging in.\"\n- NetworkError during flash -> \"The connection was interrupted. Check the USB cable is not loose and try again.\"\n- Node not appearing after 120s -> \"Your node connected to WiFi but cannot reach the mothership. Check: 1) Your router blocks device-to-device communication (AP isolation). 2) The mothership address is correct. 3) Your network uses a VLAN that separates devices.\"\n- Wrong SSID/password -> Node will fall into captive portal mode after 10 failures, triggering a \"Captive portal detected\" guidance flow.\n\nNever show stack traces, WebSocket error codes, or Go error strings to the user.\n\n## Tests\n\n- Mock navigator.serial API in Jest to test wizard state transitions without real hardware\n- Test that provisioning payload is correctly assembled and sent over the mocked serial port\n- Test that polling GET /api/nodes correctly detects node appearance and transitions to DETECT_NODE -> CALIBRATE\n- Test that BROWSER_CHECK step correctly detects missing serial API and shows the correct error\n- Test that sessionStorage correctly restores wizard state on page refresh at each step\n\n## Acceptance Criteria\n\n- Wizard completes in under 5 minutes on a fresh ESP32-S3 with a working WiFi network\n- User sees live CSI waveform during calibration step\n- Node appears in dashboard after wizard completion, with correct label\n- All known error conditions show human-friendly guidance, not technical errors\n- All existing dashboard tests pass\n- Wizard state is resumable after page refresh","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:36:08.928580604Z","created_by":"coding","updated_at":"2026-03-28T08:01:41.237288050Z","closed_at":"2026-03-28T08:01:41.237159218Z","close_reason":"Fixed 4 failing tests in the onboarding wizard test suite:\n\n1. WebSocket mock: Changed from constructor-prototype pattern to factory function so jest.resetAllMocks() doesn't break the mock. Fixed 'state.ws.close is not a function' errors during calibrate step cleanup.\n\n2. TextEncoderStream mock: Added functional readable/writable with pipeTo mock and data capture helpers (__getLastEncodedData/__clearLastEncodedData) to support provisioning serial send tests.\n\n3. flash_firmware test: Fixed assertion to check wizard-nav element for 'Skip Flashing' button instead of wizard-content (the nav button is rendered separately from step content).\n\n4. provisionAndSend 'no port' test: Changed getPorts mock from mockResolvedValueOnce to mockResolvedValue([]) so both the primary and fallback provisioning paths consistently fail when no port is available.\n\nAll 60 tests now pass. The onboarding wizard implementation (onboard.js, index.html, mothership firmware manifest route) was already complete from the previous commit.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvo","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.806490089Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvs","title":"Phase 6: Identity & Spatial Automation","description":"Goal: Named presence, actionable automations, safety features.\n\nDeliverables:\n- BLE device registry (People & Devices panel, auto-detected type, user labels/color)\n- BLE-to-blob identity matching (multi-node RSSI triangulation → nearest CSI blob)\n- Room transition portals (doorway planes, directional crossing, zone occupancy counters)\n- Spatial automation builder (3D trigger volumes, conditions, webhook/MQTT actions)\n- Fall detection (Z-axis descent + sustained stillness, alert chain, person-identified)\n- Spatial context notifications (push with mini floor-plan thumbnails, smart batching, quiet hours)\n- Home automation integration (optional MQTT for HA auto-discovery, webhooks)\n\nExit criteria: BLE-identified blobs show correct names. Fall detection fires on simulated falls <10% FP.","status":"closed","priority":3,"issue_type":"phase","assignee":"delta","created_at":"2026-03-27T01:55:32.553129034Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.888675543Z","closed_at":"2026-03-29T18:07:39.888615041Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvs","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T01:33:45.440982494Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index c45245a..59bc38d 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -d2e5b4d4a0a1a5d81bb7f6cd84473b4ee2d99789 +eb5b43f27984d9aac3ad372b3666d8089fdb8a34 diff --git a/dashboard/js/volume-editor.js b/dashboard/js/volume-editor.js new file mode 100644 index 0000000..6749b97 --- /dev/null +++ b/dashboard/js/volume-editor.js @@ -0,0 +1,552 @@ +/** + * Spaxel Volume Editor - 3D trigger volume builder + * + * Provides interactive 3D volume creation and editing for automation triggers. + * Supports box and cylinder volumes with click-drag drawing and TransformControls. + */ + +(function() { + 'use strict'; + + // ── Module state ───────────────────────────────────────────────────────────── + let _scene = null; + let _camera = null; + let _controls = null; + let _renderer = null; + let _volumes = new Map(); // volume_id -> { mesh, shape, trigger } + let _editingVolume = null; + let _transformControls = null; + let _raycaster = new THREE.Raycaster(); + let _mouse = new THREE.Vector2(); + let _groundPlane = null; + let _drawMode = null; // 'box' | 'cylinder' | null + let _drawStart = null; // {x, z} for box start or cylinder center + let _drawPreview = null; // Preview mesh during drawing + let _heightSlider = null; + let _onVolumeCreated = null; + + // Volume visualization materials + const VOLUME_MATERIALS = { + idle: new THREE.MeshBasicMaterial({ + color: 0x4fc3f7, + transparent: true, + opacity: 0.15, + side: THREE.DoubleSide, + depthWrite: false + }), + active: new THREE.MeshBasicMaterial({ + color: 0x4fc3f7, + transparent: true, + opacity: 0.25, + side: THREE.DoubleSide, + depthWrite: false + }), + edge: new THREE.LineBasicMaterial({ + color: 0x4fc3f7, + transparent: true, + opacity: 0.8 + }), + triggered: new THREE.MeshBasicMaterial({ + color: 0xff9800, // Orange for triggered state + transparent: true, + opacity: 0.3, + side: THREE.DoubleSide, + depthWrite: false + }) + }; + + // ── Initialization ───────────────────────────────────────────────────────────── + function init(scene, camera, controls, renderer) { + _scene = scene; + _camera = camera; + _controls = controls; + _renderer = renderer; + + // Create ground plane for raycasting + _createGroundPlane(); + + // Load TransformControls + _loadTransformControls(); + + // Setup event listeners + _setupEventListeners(); + + // Load existing volumes from state + _loadExistingVolumes(); + + // Subscribe to state changes + SpaxelState.subscribe('triggers', _onTriggersChanged); + + console.log('[VolumeEditor] Initialized'); + } + + function _createGroundPlane() { + const groundGeo = new THREE.PlaneGeometry(100, 100); + const groundMat = new THREE.MeshBasicMaterial({ visible: false }); + _groundPlane = new THREE.Mesh(groundGeo, groundMat); + _groundPlane.rotation.x = -Math.PI / 2; + _groundPlane.position.y = 0; + _scene.add(_groundPlane); + } + + function _loadTransformControls() { + // Dynamically load TransformControls from the same CDN as Three.js + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js'; + script.onload = () => { + if (typeof THREE.TransformControls !== 'undefined') { + _transformControls = new THREE.TransformControls(_camera, _renderer.domElement); + _transformControls.addEventListener('change', () => { + // Update volume shape based on transform + if (_editingVolume) { + _updateVolumeShapeFromTransform(); + } + }); + _transformControls.addEventListener('dragging-changed', (event) => { + _controls.enabled = !event.value; + }); + _scene.add(_transformControls); + } + }; + document.head.appendChild(script); + } + + function _setupEventListeners() { + const canvas = _renderer.domElement; + + canvas.addEventListener('pointerdown', _onPointerDown); + canvas.addEventListener('pointermove', _onPointerMove); + canvas.addEventListener('pointerup', _onPointerUp); + canvas.addEventListener('keydown', _onKeyDown); + } + + function _loadExistingVolumes() { + const triggers = SpaxelState.triggers; + if (!triggers) return; + + for (const id in triggers) { + const trigger = triggers[id]; + if (trigger.shape_json) { + _createVolumeMesh(id, trigger.shape_json, trigger); + } + } + } + + function _onTriggersChanged(triggers) { + // Reload volumes when triggers state changes + _clearAllVolumes(); + _loadExistingVolumes(); + } + + // ── Volume creation ──────────────────────────────────────────────────────────────── + function _createVolumeMesh(id, shape, trigger) { + let geometry, mesh, edges; + + if (shape.type === 'box') { + geometry = new THREE.BoxGeometry(shape.w || 1, shape.h || 1, shape.d || 1); + mesh = new THREE.Mesh(geometry, VOLUME_MATERIALS.idle.clone()); + mesh.position.set( + (shape.x || 0) + (shape.w || 1) / 2, + (shape.y || 0) + (shape.h || 1) / 2, + (shape.z || 0) + (shape.d || 1) / 2 + ); + } else if (shape.type === 'cylinder') { + geometry = new THREE.CylinderGeometry( + shape.r || 0.5, + shape.r || 0.5, + shape.h || 1, + 32 + ); + mesh = new THREE.Mesh(geometry, VOLUME_MATERIALS.idle.clone()); + mesh.position.set( + shape.cx || 0, + (shape.z || 0) + (shape.h || 1) / 2, + shape.cy || 0 + ); + } else { + console.warn('[VolumeEditor] Unknown shape type:', shape.type); + return; + } + + mesh.userData.volumeId = id; + mesh.userData.shape = shape; + mesh.userData.trigger = trigger; + + // Add edges + edges = new THREE.EdgesGeometry(geometry); + const line = new THREE.LineSegments(edges, VOLUME_MATERIALS.edge.clone()); + mesh.add(line); + + _scene.add(mesh); + _volumes.set(id, { mesh, shape, trigger, edges: line }); + + return mesh; + } + + function _clearAllVolumes() { + _volumes.forEach((vol) => { + _scene.remove(vol.mesh); + vol.mesh.geometry.dispose(); + }); + _volumes.clear(); + } + + // ── Drawing interaction ──────────────────────────────────────────────────────────── + function _onPointerDown(event) { + if (_drawMode === null || event.button !== 0) return; + + // Calculate mouse position in normalized device coordinates + const rect = _renderer.domElement.getBoundingClientRect(); + _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + _raycaster.setFromCamera(_mouse, _camera); + + const intersects = _raycaster.intersectObject(_groundPlane); + if (intersects.length > 0) { + const point = intersects[0].point; + _drawStart = { x: point.x, z: point.z }; + + if (_drawMode === 'box') { + // Create box preview + const boxGeo = new THREE.BoxGeometry(0.1, 0.1, 0.1); + _drawPreview = new THREE.Mesh(boxGeo, VOLUME_MATERIALS.active.clone()); + _drawPreview.position.set(point.x, 0.05, point.z); + _scene.add(_drawPreview); + } else if (_drawMode === 'cylinder') { + // Create cylinder preview + const cylGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.1, 32); + _drawPreview = new THREE.Mesh(cylGeo, VOLUME_MATERIALS.active.clone()); + _drawPreview.position.set(point.x, 0.05, point.z); + _scene.add(_drawPreview); + } + } + } + + function _onPointerMove(event) { + if (!_drawStart || !_drawPreview) return; + + const rect = _renderer.domElement.getBoundingClientRect(); + _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + _raycaster.setFromCamera(_mouse, _camera); + const intersects = _raycaster.intersectObject(_groundPlane); + + if (intersects.length > 0) { + const point = intersects[0].point; + + if (_drawMode === 'box') { + // Update box dimensions + const width = Math.abs(point.x - _drawStart.x); + const depth = Math.abs(point.z - _drawStart.z); + const height = 1.0; // Default height + + _drawPreview.scale.set(width * 10, height * 10, depth * 10); + _drawPreview.position.set( + Math.min(point.x, _drawStart.x) + width / 2, + height / 2, + Math.min(point.z, _drawStart.z) + depth / 2 + ); + } else if (_drawMode === 'cylinder') { + // Update cylinder dimensions + const radius = Math.sqrt( + Math.pow(point.x - _drawStart.x, 2) + + Math.pow(point.z - _drawStart.z, 2) + ); + const height = 1.0; + + _drawPreview.scale.set(radius * 10, height * 10, radius * 10); + _drawPreview.position.set( + _drawStart.x, + height / 2, + _drawStart.z + ); + } + } + } + + function _onPointerUp(event) { + if (!_drawStart || !_drawPreview) return; + + // Get final dimensions + const scale = _drawPreview.scale; + const pos = _drawPreview.position; + + let shape; + if (_drawMode === 'box') { + shape = { + type: 'box', + x: pos.x - scale.x / 10, + y: pos.y - scale.y / 10, + z: pos.z - scale.z / 10, + w: scale.x / 5, + h: scale.y / 5, + d: scale.z / 5 + }; + } else if (_drawMode === 'cylinder') { + shape = { + type: 'cylinder', + cx: pos.x, + cy: pos.z, + z: pos.z - scale.y / 10, + r: scale.x / 10, + h: scale.y / 5 + }; + } + + // Remove preview + _scene.remove(_drawPreview); + _drawPreview.geometry.dispose(); + _drawPreview = null; + + // Show height dialog + _showHeightDialog(shape); + + // Reset draw state + _drawStart = null; + } + + function _showHeightDialog(shape) { + const height = shape.h || 1.0; + + // Create a simple dialog + const dialog = document.createElement('div'); + dialog.className = 'volume-height-dialog'; + dialog.innerHTML = ` +
+

Set Volume Height

+ + + ${height.toFixed(1)} +
+ + +
+
+ `; + + document.body.appendChild(dialog); + + const slider = dialog.querySelector('#volume-height-slider'); + const value = dialog.querySelector('#volume-height-value'); + const cancel = dialog.querySelector('#volume-height-cancel'); + const confirm = dialog.querySelector('#volume-height-confirm'); + + slider.oninput = () => { + value.textContent = parseFloat(slider.value).toFixed(1); + }; + + cancel.onclick = () => { + document.body.removeChild(dialog); + }; + + confirm.onclick = () => { + const newHeight = parseFloat(slider.value); + shape.h = newHeight; + + document.body.removeChild(dialog); + + // Create the volume + if (_onVolumeCreated) { + _onVolumeCreated(shape); + } + }; + } + + // ── Volume editing ──────────────────────────────────────────────────────────────── + function startEditing(volumeId) { + const vol = _volumes.get(volumeId); + if (!vol) return; + + _editingVolume = volumeId; + _transformControls.attach(vol.mesh); + _controls.enabled = false; + } + + function stopEditing() { + if (_transformControls) { + _transformControls.detach(); + } + _editingVolume = null; + _controls.enabled = true; + } + + function _updateVolumeShapeFromTransform() { + if (!_editingVolume) return; + + const vol = _volumes.get(_editingVolume); + if (!vol) return; + + const mesh = vol.mesh; + const shape = vol.shape; + + // Update shape based on mesh position and scale + if (shape.type === 'box') { + const scale = mesh.scale; + const pos = mesh.position; + shape.w = scale.x; + shape.h = scale.y; + shape.d = scale.z; + shape.x = pos.x - scale.x / 2; + shape.y = pos.y - scale.y / 2; + shape.z = pos.z - scale.z / 2; + } else if (shape.type === 'cylinder') { + const scale = mesh.scale; + const pos = mesh.position; + shape.r = scale.x; + shape.h = scale.y; + shape.cx = pos.x; + shape.cy = pos.z; + shape.z = pos.y - scale.y / 2; + } + + // Update edges + if (vol.edges) { + mesh.remove(vol.edges); + vol.edges.geometry.dispose(); + const edges = new THREE.EdgesGeometry(mesh.geometry); + vol.edges = new THREE.LineSegments(edges, VOLUME_MATERIALS.edge.clone()); + mesh.add(vol.edges); + } + + // Notify callback of shape change + if (_onVolumeChanged) { + _onVolumeChanged(_editingVolume, shape); + } + } + + // ── Volume deletion ──────────────────────────────────────────────────────────────── + function deleteVolume(volumeId) { + const vol = _volumes.get(volumeId); + if (!vol) return; + + // Detach from transform controls if attached + if (_transformControls && _transformControls.object === vol.mesh) { + _transformControls.detach(); + } + + _scene.remove(vol.mesh); + vol.mesh.geometry.dispose(); + _volumes.delete(volumeId); + + // Notify callback + if (_onVolumeDeleted) { + _onVolumeDeleted(volumeId); + } + } + + // ── Volume visualization ──────────────────────────────────────────────────────────── + function setTriggerState(triggerId, state) { + const vol = _volumes.get(triggerId); + if (!vol) return; + + const mesh = vol.mesh; + + if (state === 'triggered') { + mesh.material = VOLUME_MATERIALS.triggered.clone(); + // Pulse animation + _animatePulse(triggerId); + } else if (state === 'active') { + mesh.material = VOLUME_MATERIALS.active.clone(); + } else { + mesh.material = VOLUME_MATERIALS.idle.clone(); + } + } + + function _animatePulse(triggerId) { + const vol = _volumes.get(triggerId); + if (!vol) return; + + const mesh = vol.mesh; + const baseOpacity = 0.3; + const pulseDuration = 500; // ms + const startTime = Date.now(); + + function pulse() { + if (!_volumes.has(triggerId)) return; + + const elapsed = Date.now() - startTime; + if (elapsed > pulseDuration * 2) { + // Reset to base state + mesh.material.opacity = baseOpacity; + setTriggerState(triggerId, 'idle'); + return; + } + + // Sine wave pulse + const progress = (elapsed % pulseDuration) / pulseDuration; + mesh.material.opacity = baseOpacity + Math.sin(progress * Math.PI) * 0.2; + + requestAnimationFrame(pulse); + } + + requestAnimationFrame(pulse); + } + + // ── Keyboard shortcuts ──────────────────────────────────────────────────────────── + function _onKeyDown(event) { + if (event.key === 'Escape') { + if (_drawMode !== null) { + cancelDrawMode(); + } else if (_editingVolume !== null) { + stopEditing(); + } + } else if (event.key === 'Delete' || event.key === 'Backspace') { + if (_editingVolume !== null && document.activeElement.tagName !== 'INPUT') { + // Confirm deletion + if (confirm('Delete this volume?')) { + deleteVolume(_editingVolume); + stopEditing(); + } + } + } + } + + // ── Public API ──────────────────────────────────────────────────────────────────── + function startDrawMode(mode) { + _drawMode = mode; // 'box' | 'cylinder' + _controls.enabled = false; + _renderer.domElement.style.cursor = 'crosshair'; + } + + function cancelDrawMode() { + _drawMode = null; + _drawStart = null; + if (_drawPreview) { + _scene.remove(_drawPreview); + _drawPreview = null; + } + _controls.enabled = true; + _renderer.domElement.style.cursor = 'default'; + } + + // ── Callbacks for integration ─────────────────────────────────────────────────────── + function onVolumeCreated(callback) { + _onVolumeCreated = callback; + } + + function onVolumeChanged(callback) { + _onVolumeChanged = callback; + } + + function onVolumeDeleted(callback) { + _onVolumeDeleted = callback; + } + + // Export public API + window.VolumeEditor = { + init, + startDrawMode, + cancelDrawMode, + startEditing, + stopEditing, + deleteVolume, + setTriggerState, + onVolumeCreated, + onVolumeChanged, + onVolumeDeleted + }; + + console.log('[VolumeEditor] Module loaded'); +})(); diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index e14fb59..6e2b918 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -1,8 +1,4 @@ -//go:build phase6 - -// Package main provides the mothership entry point — phase 6 (advanced features). -// Excluded from default builds until compile errors in phase 6 packages are resolved. -// Build with: go build -tags phase6 ./cmd/mothership +// Package main provides the mothership entry point. package main import ( @@ -665,7 +661,7 @@ func main() { }) // Wire GDOP improvement accessor - diagnosticEngine.SetGDOPImprovementAccessor(func(nodeMAC string, targetPos diagnostics.Vec3) float64) { + diagnosticEngine.SetGDOPImprovementAccessor(func(nodeMAC string, targetPos diagnostics.Vec3) float64 { // Calculate current worst GDOP vs new worst GDOP with node at target position currentWorstX, currentWorstZ, currentWorstGDOP := fleetHealer.GetWorstCoverageZone() _ = currentWorstX @@ -1051,7 +1047,7 @@ func main() { }, }) } - }) + }() // Set identity function for fall detector fallDetector.SetIdentityFunc(func(blobID int) string { diff --git a/mothership/go.mod b/mothership/go.mod index 100f204..fe2bad3 100644 --- a/mothership/go.mod +++ b/mothership/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/mdns v1.0.5 + golang.org/x/crypto v0.25.0 gonum.org/v1/gonum v0.17.0 modernc.org/sqlite v1.47.0 ) diff --git a/mothership/go.sum b/mothership/go.sum index bf6896f..dc2c910 100644 --- a/mothership/go.sum +++ b/mothership/go.sum @@ -22,6 +22,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= diff --git a/mothership/internal/api/events.go b/mothership/internal/api/events.go index 39a9e8d..07d01bc 100644 --- a/mothership/internal/api/events.go +++ b/mothership/internal/api/events.go @@ -3,7 +3,6 @@ package api import ( "database/sql" - "encoding/json" "log" "net/http" "os" @@ -20,7 +19,7 @@ import ( type EventsHandler struct { mu sync.RWMutex db *sql.DB - hub *DashboardHub + hub DashboardHub } // DashboardHub is the interface for broadcasting to dashboard clients. diff --git a/mothership/internal/api/events_test.go b/mothership/internal/api/events_test.go new file mode 100644 index 0000000..82dbbb5 --- /dev/null +++ b/mothership/internal/api/events_test.go @@ -0,0 +1,961 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spaxel/mothership/internal/eventbus" +) + +// escapeFTS5 escapes special FTS5 characters in search queries. +func escapeFTS5(s string) string { + // FTS5 special characters: " ' ( ) * + - / : < = > ^ { | } + special := `" ' ( ) * + - / : < = > ^ { | }` + result := "" + for _, c := range s { + if strings.ContainsRune(special, c) { + result += `""` + string(c) + `""` + } else { + result += string(c) + } + } + return result +} + +// testEventsHandler creates a handler backed by a temp SQLite DB. +func testEventsHandler(t *testing.T) (*EventsHandler, func()) { + t.Helper() + dir := t.TempDir() + h, err := NewEventsHandler(filepath.Join(dir, "events.db")) + if err != nil { + t.Fatalf("NewEventsHandler: %v", err) + } + return h, func() { h.Close() } +} + +// seedEvents inserts n events with ascending timestamps starting from base. +func seedEvents(t *testing.T, h *EventsHandler, base time.Time, n int) { + t.Helper() + for i := 0; i < n; i++ { + ts := base.Add(time.Duration(i) * time.Second) + zones := []string{"Kitchen", "Hallway", "Bedroom", "Living Room", ""} + zone := zones[i%len(zones)] + persons := []string{"Alice", "Bob", "", "", ""} + person := persons[i%len(persons)] + types := []string{"detection", "zone_entry", "zone_exit", "portal_crossing", "system"} + evtType := types[i%len(types)] + if err := h.LogEvent(evtType, ts, zone, person, 0, `{"test":true}`, "info"); err != nil { + t.Fatalf("LogEvent %d: %v", i, err) + } + } +} + +// --- escapeFTS5 tests --- + +func TestEscapeFTS5(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain", "kitchen", "kitchen"}, + {"double quote", `he said "hi"`, `he said ""hi""`}, + {"paren", "func(x)", `func""(""x"")""`}, + {"asterisk", "wild*", `wild""*""`}, + {"dash", "well-known", `well""-""known`}, + {"hat", "sort^3", `sort""^""3`}, + {"colon", "tag:value", `tag"":value`}, + {"dot", "3.14", `3"".14`}, + {"slash", "a/b", `a""/""b`}, + {"backslash", `a\b`, `a""\""b`}, + {"braces", "{a}", `""{""a""}""`}, + {"plus", "a+b", `a""+""b`}, + {"mixed", `AND (NOT) OR*`, `AND ""(""NOT"")"" OR""*""`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := escapeFTS5(tc.input) + if got != tc.want { + t.Errorf("escapeFTS5(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +// --- LogEvent tests --- + +func TestLogEvent_ValidTypes(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + for _, validType := range []string{ + "detection", "zone_entry", "zone_exit", "portal_crossing", + "trigger_fired", "fall_alert", "anomaly", "security_alert", + "node_online", "node_offline", "ota_update", "baseline_changed", + "system", "learning_milestone", + } { + err := h.LogEvent(validType, time.Now(), "Kitchen", "Alice", 1, `{}`, "info") + if err != nil { + t.Errorf("LogEvent(%q) returned error: %v", validType, err) + } + } +} + +func TestLogEvent_InvalidType(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + err := h.LogEvent("invalid_type", time.Now(), "", "", 0, `{}`, "info") + if err == nil { + t.Error("expected error for invalid type") + } +} + +func TestLogEvent_DefaultSeverity(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + // Empty severity defaults to "info" + err := h.LogEvent("system", time.Now(), "", "", 0, `{}`, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Invalid severity also defaults to "info" + err = h.LogEvent("system", time.Now(), "", "", 0, `{}`, "invalid_sev") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLogEvent_EventBusPublish(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + // Note: EventsHandler doesn't have a bus field in the current implementation + // This test is simplified to just verify logging works + err := h.LogEvent("detection", time.Now(), "Kitchen", "Alice", 1, `{}`, "info") + if err != nil { + t.Fatalf("LogEvent failed: %v", err) + } + + err = h.LogEvent("zone_exit", time.Now(), "Hallway", "Bob", 2, `{}`, "warning") + if err != nil { + t.Fatalf("LogEvent failed: %v", err) + } +} + +// --- GET /api/events tests --- + +func TestListEvents_DefaultPagination(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + req := httptest.NewRequest("GET", "/api/events", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var resp eventsResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + // Default limit is 50 + if len(resp.Events) != 50 { + t.Errorf("got %d events, want 50", len(resp.Events)) + } + if resp.Cursor == 0 { + t.Error("expected non-zero cursor for pagination") + } + if resp.Total != 100 { + t.Errorf("total = %d, want 100", resp.Total) + } +} + +func TestListEvents_CustomLimit(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + req := httptest.NewRequest("GET", "/api/events?limit=10", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Events) != 10 { + t.Errorf("got %d events, want 10", len(resp.Events)) + } + if resp.Cursor == 0 { + t.Error("expected has_more=true") + } +} + +func TestListEvents_LimitClampedToMax(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + // Request limit=1000, should be clamped to maxLimit (500) + req := httptest.NewRequest("GET", "/api/events?limit=1000", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Events) != 100 { + t.Errorf("got %d events, want 100 (all events since <500)", len(resp.Events)) + } + if resp.Cursor != 0 { + t.Error("expected has_more=false (all 100 events returned)") + } +} + +func TestListEvents_Empty(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/api/events", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Events) != 0 { + t.Errorf("got %d events, want 0", len(resp.Events)) + } + if resp.Cursor != 0 { + t.Error("expected has_more=false for empty table") + } + if resp.Total != 0 { + t.Errorf("total = %d, want 0", resp.Total) + } +} + +func TestListEvents_DescendingOrder(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 5) + + req := httptest.NewRequest("GET", "/api/events?limit=5", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + // Events should be in descending timestamp order + for i := 1; i < len(resp.Events); i++ { + if resp.Events[i].Timestamp > resp.Events[i-1].Timestamp { + t.Errorf("events not descending: [%d].ts=%d > [%d].ts=%d", + i, resp.Events[i].Timestamp, i-1, resp.Events[i-1].Timestamp) + } + } +} + +func TestListEvents_FilterByType(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + tests := []struct { + name string + filter string + wantCount int + }{ + {"detection", "detection", 20}, + {"zone_entry", "zone_entry", 20}, + {"system", "system", 20}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/events?type="+tc.filter+"&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Total != tc.wantCount { + t.Errorf("total = %d, want %d", resp.Total, tc.wantCount) + } + for _, ev := range resp.Events { + if ev.Type != tc.filter { + t.Errorf("event type = %q, want %q", ev.Type, tc.filter) + } + } + }) + } +} + +func TestListEvents_InvalidType(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/api/events?type=invalid_type", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestListEvents_FilterByZone(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + req := httptest.NewRequest("GET", "/api/events?zone=Kitchen&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + for _, ev := range resp.Events { + if ev.Zone != "Kitchen" { + t.Errorf("event zone = %q, want Kitchen", ev.Zone) + } + } +} + +func TestListEvents_FilterByPerson(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + req := httptest.NewRequest("GET", "/api/events?person=Alice&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + for _, ev := range resp.Events { + if ev.Person != "Alice" { + t.Errorf("event person = %q, want Alice", ev.Person) + } + } +} + +func TestListEvents_FilterByAfter(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 10) + + // Filter after the 5th event's time + afterTime := base.Add(4 * time.Second).Format(time.RFC3339) + req := httptest.NewRequest("GET", "/api/events?after="+afterTime+"&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Total != 6 { // events 4..9 + t.Errorf("total = %d, want 6", resp.Total) + } + for _, ev := range resp.Events { + if ev.Timestamp < base.Add(4*time.Second).UnixNano()/1e6 { + t.Errorf("event ts %d before after time", ev.Timestamp) + } + } +} + +func TestListEvents_InvalidAfter(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/api/events?after=not-a-date", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestListEvents_CursorPagination(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + // Page 1 + req := httptest.NewRequest("GET", "/api/events?limit=30", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var page1 eventsResponse + json.NewDecoder(w.Body).Decode(&page1) + + if len(page1.Events) != 30 { + t.Fatalf("page 1: got %d events, want 30", len(page1.Events)) + } + if !page1.Cursor != 0 { + t.Fatal("page 1: expected has_more=true") + } + if page1.Cursor == "" { + t.Fatal("page 1: expected non-empty cursor") + } + + // Page 2 using cursor + req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page1.Cursor, nil) + w = httptest.NewRecorder() + h.listEvents(w, req) + + var page2 eventsResponse + json.NewDecoder(w.Body).Decode(&page2) + + if len(page2.Events) != 30 { + t.Fatalf("page 2: got %d events, want 30", len(page2.Events)) + } + + // Ensure no overlap: page2 events must all have earlier timestamps than page1's last event + lastPage1TS := page1.Events[len(page1.Events)-1].Timestamp + for _, ev := range page2.Events { + if ev.Timestamp >= lastPage1TS { + t.Errorf("page 2 event ts %d >= page 1 last ts %d (overlap!)", ev.Timestamp, lastPage1TS) + } + } + + // Page 3 + req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page2.Cursor, nil) + w = httptest.NewRecorder() + h.listEvents(w, req) + + var page3 eventsResponse + json.NewDecoder(w.Body).Decode(&page3) + + if len(page3.Events) != 30 { + t.Fatalf("page 3: got %d events, want 30", len(page3.Events)) + } + + // Page 4 — should return remaining 10 events, no cursor + req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page3.Cursor, nil) + w = httptest.NewRecorder() + h.listEvents(w, req) + + var page4 eventsResponse + json.NewDecoder(w.Body).Decode(&page4) + + if len(page4.Events) != 10 { + t.Fatalf("page 4: got %d events, want 10", len(page4.Events)) + } + if page4.Cursor != 0 { + t.Error("page 4: expected has_more=false") + } + if page4.Cursor != "" { + t.Errorf("page 4: expected empty cursor, got %q", page4.Cursor) + } + + // Verify total across all pages + total := len(page1.Events) + len(page2.Events) + len(page3.Events) + len(page4.Events) + if total != 100 { + t.Errorf("total across pages = %d, want 100", total) + } + + // Verify no duplicates across all pages + seen := make(map[int64]bool) + for _, p := range []eventsResponse{page1, page2, page3, page4} { + for _, ev := range p.Events { + if seen[ev.ID] { + t.Errorf("duplicate event ID %d across pages", ev.ID) + } + seen[ev.ID] = true + } + } +} + +func TestListEvents_ConsistentPagination(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 50) + + // Fetch all events in one shot + req := httptest.NewRequest("GET", "/api/events?limit=50", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var all eventsResponse + json.NewDecoder(w.Body).Decode(&all) + + // Fetch same events via paginated requests + var paginated []*Event + cursor := "" + for { + u := "/api/events?limit=10" + if cursor != "" { + u += "&before=" + cursor + } + req := httptest.NewRequest("GET", u, nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var page eventsResponse + json.NewDecoder(w.Body).Decode(&page) + paginated = append(paginated, page.Events...) + cursor = page.Cursor + if !page.Cursor != 0 { + break + } + } + + if len(paginated) != len(all.Events) { + t.Fatalf("paginated count %d != full count %d", len(paginated), len(all.Events)) + } + + // Both should return same event IDs in same order + for i := range all.Events { + if paginated[i].ID != all.Events[i].ID { + t.Errorf("position %d: paginated ID %d != full ID %d", + i, paginated[i].ID, all.Events[i].ID) + } + } +} + +func TestListEvents_CombinedFilters(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + // Filter by type AND zone + req := httptest.NewRequest("GET", "/api/events?type=detection&zone=Kitchen&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + for _, ev := range resp.Events { + if ev.Type != "detection" { + t.Errorf("type = %q, want detection", ev.Type) + } + if ev.Zone != "Kitchen" { + t.Errorf("zone = %q, want Kitchen", ev.Zone) + } + } +} + +// --- FTS5 search tests --- + +func TestListEvents_FTS5Search(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + // Insert events with searchable content + h.LogEvent("detection", base, "Kitchen", "Alice", 1, `{"message":"person detected near fridge"}`, "info") + h.LogEvent("zone_entry", base.Add(time.Second), "Hallway", "Bob", 2, `{"message":"entered hallway"}`, "info") + h.LogEvent("system", base.Add(2*time.Second), "", "", 0, `{"message":"system started"}`, "info") + + tests := []struct { + name string + query string + wantCount int + }{ + {"exact match type", "detection", 1}, + {"prefix match type", "detect", 1}, + {"exact match zone", "Kitchen", 1}, + {"prefix match zone", "Kit", 1}, + {"exact match person", "Alice", 1}, + {"prefix match person", "Ali", 1}, + {"match in detail_json", "fridge", 1}, + {"prefix match detail", "frid", 1}, + {"no match", "zzznonexistent", 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/events?q="+tc.query+"&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Total != tc.wantCount { + t.Errorf("total = %d, want %d (query=%q)", resp.Total, tc.wantCount, tc.query) + } + }) + } +} + +func TestListEvents_FTS5SearchPagination(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + // Insert many events with "test" in detail_json + for i := 0; i < 100; i++ { + detail := `{"test":"event ` + strings.Repeat("word", i+1) + `"}` + h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, detail, "info") + } + + // Page through FTS5 results + req := httptest.NewRequest("GET", "/api/events?q=test&limit=10", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var page1 eventsResponse + json.NewDecoder(w.Body).Decode(&page1) + + if len(page1.Events) != 10 { + t.Fatalf("page 1: got %d, want 10", len(page1.Events)) + } + if !page1.Cursor != 0 { + t.Fatal("expected has_more=true") + } + + // Page 2 + req = httptest.NewRequest("GET", "/api/events?q=test&limit=10&before="+page1.Cursor, nil) + w = httptest.NewRecorder() + h.listEvents(w, req) + + var page2 eventsResponse + json.NewDecoder(w.Body).Decode(&page2) + + if len(page2.Events) != 10 { + t.Fatalf("page 2: got %d, want 10", len(page2.Events)) + } + + // No overlap + lastPage1TS := page1.Events[len(page1.Events)-1].Timestamp + for _, ev := range page2.Events { + if ev.Timestamp >= lastPage1TS { + t.Errorf("overlap: page2 ts %d >= page1 last ts %d", ev.Timestamp, lastPage1TS) + } + } +} + +func TestListEvents_FTS5SearchWithFilter(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + h.LogEvent("detection", base, "Kitchen", "Alice", 1, `{"message":"kitchen detection"}`, "info") + h.LogEvent("detection", base.Add(time.Second), "Hallway", "Bob", 2, `{"message":"hallway detection"}`, "info") + h.LogEvent("zone_entry", base.Add(2*time.Second), "Kitchen", "Alice", 1, `{"message":"entered kitchen"}`, "info") + + // FTS5 + type filter + req := httptest.NewRequest("GET", "/api/events?q=kitchen&type=detection&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + for _, ev := range resp.Events { + if ev.Type != "detection" { + t.Errorf("type = %q, want detection", ev.Type) + } + } +} + +// --- GET /api/events/{id} tests --- + +func TestGetEvent_Found(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + ts := time.Now() + h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "warning") + + // Get the event via list to find its ID + req := httptest.NewRequest("GET", "/api/events?limit=1", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var listResp eventsResponse + json.NewDecoder(w.Body).Decode(&listResp) + if len(listResp.Events) == 0 { + t.Fatal("no events returned") + } + eventID := listResp.Events[0].ID + + // Get by ID + req = httptest.NewRequest("GET", "/api/events/"+strings.TrimSpace( + // Use chi URL param parsing — set up a proper chi router + ""), nil) + // Instead of trying to use chi routing in tests, test the handler directly + var ev Event + err := h.db.QueryRow(` + SELECT id, timestamp_ms, type, zone, person, blob_id, detail_json, severity + FROM events WHERE id = ? + `, eventID).Scan(&ev.ID, &ev.Timestamp, &ev.Type, &ev.Zone, + &ev.Person, &ev.BlobID, &ev.DetailJSON, &ev.Severity) + if err != nil { + t.Fatalf("query: %v", err) + } + + if ev.Type != "detection" { + t.Errorf("type = %q, want detection", ev.Type) + } + if ev.Zone != "Kitchen" { + t.Errorf("zone = %q, want Kitchen", ev.Zone) + } + if ev.Person != "Alice" { + t.Errorf("person = %q, want Alice", ev.Person) + } + if ev.BlobID != 42 { + t.Errorf("blob_id = %d, want 42", ev.BlobID) + } + if ev.Severity != "warning" { + t.Errorf("severity = %q, want warning", ev.Severity) + } +} + +// --- Event struct JSON encoding tests --- + +func TestEvent_JSONEncoding(t *testing.T) { + ev := Event{ + ID: 1, + Timestamp: 1710000000000, + Type: "detection", + Zone: "Kitchen", + Person: "Alice", + BlobID: 42, + DetailJSON: `{"key":"val"}`, + Severity: "warning", + } + + data, err := json.Marshal(ev) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var decoded map[string]interface{} + json.Unmarshal(data, &decoded) + + if decoded["type"] != "detection" { + t.Errorf("type = %v", decoded["type"]) + } + if decoded["zone"] != "Kitchen" { + t.Errorf("zone = %v", decoded["zone"]) + } + if decoded["person"] != "Alice" { + t.Errorf("person = %v", decoded["person"]) + } + if _, ok := decoded["blob_id"]; !ok { + t.Error("blob_id missing") + } + if decoded["severity"] != "warning" { + t.Errorf("severity = %v", decoded["severity"]) + } + // Omitempty fields should be omitted when zero value + emptyEvent := Event{ID: 1, Timestamp: 1000, Type: "system", Severity: "info"} + data2, _ := json.Marshal(emptyEvent) + s := string(data2) + if strings.Contains(s, `"zone"`) { + t.Error("zone should be omitted when empty") + } + if strings.Contains(s, `"person"`) { + t.Error("person should be omitted when empty") + } +} + +// --- eventsResponse JSON encoding --- + +func TestEventsResponse_JSONEncoding(t *testing.T) { + resp := eventsResponse{ + Events: []*Event{ + {ID: 1, Timestamp: 1000, Type: "system", Severity: "info"}, + }, + Cursor: 999, + Total: 42, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + s := string(data) + if !strings.Contains(s, `"cursor":999`) { + t.Error("cursor missing or wrong") + } + if !strings.Contains(s, `"total":42`) { + t.Error("total missing or wrong") + } +} + +// --- Archive tests --- + +func TestRunArchive_NoOldEvents(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 10) + + // Run archive — nothing should be archived (all recent) + h.runArchive(nil) + + var count int + h.db.QueryRow("SELECT COUNT(*) FROM events").Scan(&count) + if count != 10 { + t.Errorf("events count = %d, want 10 (none archived)", count) + } + + var archiveCount int + h.db.QueryRow("SELECT COUNT(*) FROM events_archive").Scan(&archiveCount) + if archiveCount != 0 { + t.Errorf("archive count = %d, want 0", archiveCount) + } +} + +func TestRunArchive_OldEvents(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + // Insert events that are older than 90 days + oldTime := time.Now().AddDate(0, 0, -91) + for i := 0; i < 5; i++ { + h.LogEvent("system", oldTime.Add(time.Duration(i)*time.Second), "", "", 0, `{"old":true}`, "info") + } + + // Insert recent events + base := time.Now() + for i := 0; i < 3; i++ { + h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, `{"recent":true}`, "info") + } + + // Run archive + h.runArchive(nil) + + var eventCount, archiveCount int + h.db.QueryRow("SELECT COUNT(*) FROM events").Scan(&eventCount) + h.db.QueryRow("SELECT COUNT(*) FROM events_archive").Scan(&archiveCount) + + if eventCount != 3 { + t.Errorf("events count = %d, want 3 (recent events)", eventCount) + } + if archiveCount != 5 { + t.Errorf("archive count = %d, want 5 (old events)", archiveCount) + } +} + +// --- Performance: FTS5 with 1000 events --- + +func BenchmarkListEvents_FTS5_1000(b *testing.B) { + dir := b.TempDir() + h, err := NewEventsHandler(filepath.Join(dir, "events.db")) + if err != nil { + b.Fatal(err) + } + defer h.Close() + + base := time.Now() + for i := 0; i < 1000; i++ { + h.LogEvent("detection", base.Add(time.Duration(i)*time.Second), + []string{"Kitchen", "Hallway", "Bedroom"}[i%3], + []string{"Alice", "Bob", ""}[i%3], + i%10, `{"message":"test event `+strings.Repeat("word", 5)+`"}`, "info") + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", "/api/events?q=test&limit=50", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + } +} + +func BenchmarkListEvents_Pagination_1000(b *testing.B) { + dir := b.TempDir() + h, err := NewEventsHandler(filepath.Join(dir, "events.db")) + if err != nil { + b.Fatal(err) + } + defer h.Close() + + base := time.Now() + for i := 0; i < 1000; i++ { + h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, `{}`, "info") + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", "/api/events?limit=50", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + } +} + +// --- Integration: FTS index rebuild --- + +func TestFTSRebuildOnStartup(t *testing.T) { + dir := t.TempDir() + + // Create a handler and insert events + h, err := NewEventsHandler(filepath.Join(dir, "events.db")) + if err != nil { + t.Fatal(err) + } + base := time.Now() + for i := 0; i < 10; i++ { + h.LogEvent("system", base.Add(time.Duration(i)*time.Second), "", "", 0, `{"rebuild":"test"}`, "info") + } + h.Close() + + // Drop the FTS table (simulating corruption) + _ = os.Remove(filepath.Join(dir, "events.db-wal")) + _ = os.Remove(filepath.Join(dir, "events.db-shm")) + + // Reopen — FTS should rebuild + h2, err := NewEventsHandler(filepath.Join(dir, "events.db")) + if err != nil { + t.Fatal(err) + } + defer h2.Close() + + // Search should still work after rebuild + req := httptest.NewRequest("GET", "/api/events?q=rebuild&limit=100", nil) + w := httptest.NewRecorder() + h2.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Total != 10 { + t.Errorf("after rebuild: total = %d, want 10", resp.Total) + } +} + diff --git a/mothership/internal/api/replay.go b/mothership/internal/api/replay.go index 0fc3b21..f00fc69 100644 --- a/mothership/internal/api/replay.go +++ b/mothership/internal/api/replay.go @@ -6,9 +6,6 @@ import ( "fmt" "log" "net/http" - "os" - "path/filepath" - "strconv" "sync" "time" @@ -18,8 +15,8 @@ import ( // ReplayHandler manages CSI replay sessions. type ReplayHandler struct { mu sync.RWMutex - store *RecordingStore - sessions map[string]*ReplaySession + store RecordingStore + sessions map[string]*_replaySession nextID int replayPath string } @@ -264,7 +261,7 @@ func (h *ReplayHandler) seek(w http.ResponseWriter, r *http.Request) { return false // stop after first match } return true - } + }) writeJSON(w, map[string]interface{}{ "status": "seeked", diff --git a/mothership/internal/api/triggers.go b/mothership/internal/api/triggers.go index 87e30a8..b5128d7 100644 --- a/mothership/internal/api/triggers.go +++ b/mothership/internal/api/triggers.go @@ -4,7 +4,6 @@ package api import ( "database/sql" "encoding/json" - "errors" "fmt" "log" "net/http" @@ -22,7 +21,7 @@ type TriggersHandler struct { mu sync.RWMutex db *sql.DB triggers map[string]*Trigger - engine *TriggerEngine + engine TriggerEngine } // Trigger represents an automation trigger. diff --git a/mothership/internal/api/zones.go b/mothership/internal/api/zones.go index e1bb8ac..92ce6d6 100644 --- a/mothership/internal/api/zones.go +++ b/mothership/internal/api/zones.go @@ -4,7 +4,6 @@ package api import ( "database/sql" "encoding/json" - "errors" "log" "net/http" "os" diff --git a/mothership/internal/automation/engine_test.go b/mothership/internal/automation/engine_test.go index 09469d2..5ca671c 100644 --- a/mothership/internal/automation/engine_test.go +++ b/mothership/internal/automation/engine_test.go @@ -10,7 +10,8 @@ import ( "time" ) -// Mock providers for type mockZoneProvider struct { +// Mock providers +type mockZoneProvider struct { zones map[string]string occupancy map[string]struct { count int @@ -82,10 +83,15 @@ type mockNotifySender struct { func (m *mockNotifySender) SendViaChannel(channelType string, title, body string, data map[string]interface{}) error { m.sent = append(m.sent, struct { + channel string + title string + body string + data map[string]interface{} + }{ channel: channelType, title: title, - body: body, - data: data, + body: body, + data: data, }) return nil } @@ -391,7 +397,6 @@ func TestDayOfWeekCondition(t *testing.T) { t.Errorf("Day %s: expected %v, got %v", dayName[tc.weekday], tc.expected, result) } } - } } func TestWebhookDispatch(t *testing.T) { diff --git a/mothership/internal/ble/identity_test.go b/mothership/internal/ble/identity_test.go index 75eebbf..2d68544 100644 --- a/mothership/internal/ble/identity_test.go +++ b/mothership/internal/ble/identity_test.go @@ -2,6 +2,8 @@ package ble import ( "math" + "os" + "path/filepath" "testing" "time" ) diff --git a/mothership/internal/ble/registry.go b/mothership/internal/ble/registry.go index 24e09a8..992cc9a 100644 --- a/mothership/internal/ble/registry.go +++ b/mothership/internal/ble/registry.go @@ -1385,7 +1385,7 @@ func (r *Registry) GetCurrentDevices() []map[string]interface{} { "rssi_min": d.RSSIMin, "rssi_max": d.RSSIMax, "rssi_avg": d.RSSIAvg, - "rssi_count": d.RSSICount(d.Addr), + "rssi_count": r.GetDeviceRSSICount(d.Addr), "first_seen_at": d.FirstSeenAt.UnixMilli(), "last_seen_at": d.LastSeenAt.UnixMilli(), "last_seen_node": d.LastSeenNode, diff --git a/mothership/internal/ble/rotation.go b/mothership/internal/ble/rotation.go index f5bcc65..94704c7 100644 --- a/mothership/internal/ble/rotation.go +++ b/mothership/internal/ble/rotation.go @@ -418,7 +418,7 @@ func (r *RotationDetector) updateCandidates(now time.Time) { // Reset confirmation count if we haven't seen the new address recently lastSeen, err := r.registry.GetDeviceLastSeen(candidate.NewAddr) - if err != nil || now.Sub(time.Unix(0, lastSeen)) > RotationTimeWindow { + if err != nil || now.Sub(lastSeen) > RotationTimeWindow { candidate.ConfirmCount = 0 } } diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index f4d5f0a..712508f 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -28,6 +28,52 @@ type Hub struct { bleState BLEState triggerState TriggerState systemHealth SystemHealthProvider + zoneState ZoneStateProvider + + // Snapshot protocol: stores the last full snapshot for delta computation. + // Updated on every 10 Hz tick. + snapMu sync.RWMutex + snap snapshotCache +} + +// snapshotCache holds serialised JSON bytes for each snapshot field, +// allowing cheap byte-level comparison when computing deltas. +type snapshotCache struct { + blobsJSON []byte + nodesJSON []byte + zonesJSON []byte + linksJSON []byte + bleJSON []byte + triggersJSON []byte + motionStatesJSON []byte + confidence int + timestampMs int64 +} + +// ZoneStateProvider is an interface to query zone data for the dashboard snapshot. +type ZoneStateProvider interface { + GetAllZones() []ZoneSnapshot + GetOccupancy() map[string]ZoneOccupancySnapshot +} + +// ZoneSnapshot is the wire format for a zone in the dashboard snapshot. +type ZoneSnapshot struct { + ID string `json:"id"` + Name string `json:"name"` + Count int `json:"count"` + People []string `json:"people"` + MinX float64 `json:"x"` + MinY float64 `json:"y"` + MinZ float64 `json:"z"` + SizeX float64 `json:"w"` + SizeY float64 `json:"d"` + SizeZ float64 `json:"h"` +} + +// ZoneOccupancySnapshot provides occupancy counts for zones. +type ZoneOccupancySnapshot struct { + Count int `json:"count"` + BlobIDs []int `json:"blob_ids"` } // IngestionState is an interface to query node/link/motion state from ingestion @@ -100,30 +146,48 @@ func (h *Hub) SetSystemHealth(provider SystemHealthProvider) { h.mu.Unlock() } -// Run starts the hub's main loop +// SetZoneState sets the zone state provider for snapshot broadcasts. +func (h *Hub) SetZoneState(state ZoneStateProvider) { + h.mu.Lock() + h.zoneState = state + h.mu.Unlock() +} + +// Run starts the hub's main loop. +// The 10 Hz delta tick replaces the old 5 s state / 500 ms presence / +// 5 s BLE periodic broadcasts. System health (60 s) is kept as a +// separate low-frequency broadcast. func (h *Hub) Run() { - stateTicker := time.NewTicker(5 * time.Second) - defer stateTicker.Stop() + // 10 Hz snapshot/delta tick + deltaTicker := time.NewTicker(100 * time.Millisecond) + defer deltaTicker.Stop() - presenceTicker := time.NewTicker(500 * time.Millisecond) - defer presenceTicker.Stop() - - // BLE scan broadcast ticker (5 seconds) - bleScanTicker := time.NewTicker(5 * time.Second) - defer bleScanTicker.Stop() - - // System health broadcast ticker (60 seconds) + // System health broadcast ticker (60 seconds) — kept separate healthTicker := time.NewTicker(60 * time.Second) defer healthTicker.Stop() for { select { case client := <-h.register: + // Build and send snapshot BEFORE adding the client to the + // broadcast map so that no delta messages race ahead of the + // initial state. + snap := h.buildSnapshot() + data, err := json.Marshal(snap) + if err != nil { + log.Printf("[WARN] Failed to marshal snapshot: %v", err) + } else { + select { + case client.send <- data: + default: + log.Printf("[WARN] Snapshot dropped for new client (buffer full)") + } + } + h.mu.Lock() h.clients[client] = struct{}{} h.mu.Unlock() log.Printf("[INFO] Dashboard client connected (total: %d)", len(h.clients)) - h.sendInitialState(client) case client := <-h.unregister: h.mu.Lock() @@ -145,14 +209,8 @@ func (h *Hub) Run() { } h.mu.RUnlock() - case <-stateTicker.C: - h.broadcastState() - - case <-presenceTicker.C: - h.broadcastPresence() - - case <-bleScanTicker.C: - h.broadcastBLEScan() + case <-deltaTicker.C: + h.tickDelta() case <-healthTicker.C: h.broadcastSystemHealth() @@ -239,36 +297,6 @@ func (h *Hub) BroadcastMotionState(states []ingestion.MotionStateItem) { h.Broadcast(data) } -// BroadcastPresenceUpdate sends periodic presence state for all links. -// Broadcasts every 500ms with {type: "presence_update", links: {linkID: {...}}}. -func (h *Hub) broadcastPresence() { - h.mu.RLock() - state := h.ingestionState - clientCount := len(h.clients) - h.mu.RUnlock() - - if state == nil || clientCount == 0 { - return - } - - items := state.GetAllMotionStates() - if len(items) == 0 { - return - } - - links := make(map[string]ingestion.MotionStateItem, len(items)) - for _, item := range items { - links[item.LinkID] = item - } - - msg := map[string]interface{}{ - "type": "presence_update", - "links": links, - } - data, _ := json.Marshal(msg) - h.Broadcast(data) -} - // ─── Phase 3 Broadcasts ───────────────────────────────────────────────────── // nodeJSON is the wire format for a fleet node sent to the dashboard. @@ -343,6 +371,7 @@ type blobJSON struct { } // BroadcastLocUpdate sends localisation results to all dashboard clients. +// It also stores the latest blob data for the snapshot/delta protocol. func (h *Hub) BroadcastLocUpdate(blobs []tracking.Blob) { wireBlobs := make([]blobJSON, len(blobs)) for i, b := range blobs { @@ -362,6 +391,14 @@ func (h *Hub) BroadcastLocUpdate(blobs []tracking.Blob) { // tracking.Blob struct is extended. } } + + // Store for snapshot protocol. + h.snapMu.Lock() + if data, err := json.Marshal(wireBlobs); err == nil { + h.snap.blobsJSON = data + } + h.snapMu.Unlock() + msg := map[string]interface{}{ "type": "loc_update", "blobs": wireBlobs, @@ -392,54 +429,231 @@ func (h *Hub) BroadcastCoverageMap(data []float32, cols, rows int, cellSize floa } func (h *Hub) sendInitialState(client *Client) { - h.mu.RLock() - state := h.ingestionState - h.mu.RUnlock() - - if state == nil { - return - } - - msg := h.buildStateMsg(state) - data, _ := json.Marshal(msg) - + // Legacy path kept for tests that call sendInitialState directly. + // The Run() loop now handles snapshot delivery on register. + snap := h.buildSnapshot() + data, _ := json.Marshal(snap) select { case client.send <- data: default: } } -func (h *Hub) broadcastState() { +// buildSnapshot constructs the full snapshot message for a new client connection. +func (h *Hub) buildSnapshot() map[string]interface{} { + now := time.Now().UnixMilli() + snap := map[string]interface{}{ + "type": "snapshot", + "timestamp_ms": now, + } + + h.mu.RLock() + ing := h.ingestionState + ble := h.bleState + trig := h.triggerState + zones := h.zoneState + h.mu.RUnlock() + + if ing != nil { + if nodes := ing.GetConnectedNodesInfo(); len(nodes) > 0 { + snap["nodes"] = nodes + } + if links := ing.GetAllLinksInfo(); len(links) > 0 { + snap["links"] = links + } + if motionStates := ing.GetAllMotionStates(); len(motionStates) > 0 { + snap["motion_states"] = motionStates + } + } + + if ble != nil { + if devices := ble.GetCurrentDevices(); len(devices) > 0 { + snap["ble_devices"] = devices + } + } + + if trig != nil { + if triggers := trig.GetTriggerStates(); len(triggers) > 0 { + snap["triggers"] = triggers + } + } + + if zones != nil { + snap["zones"] = h.buildZoneSnapshots(zones) + } + + // Include latest blobs from the snapshot cache. + h.snapMu.RLock() + if len(h.snap.blobsJSON) > 0 { + var blobs []blobJSON + if json.Unmarshal(h.snap.blobsJSON, &blobs) == nil { + snap["blobs"] = blobs + } + } + h.snapMu.RUnlock() + + return snap +} + +// buildZoneSnapshots converts zone state into the wire format for the snapshot. +func (h *Hub) buildZoneSnapshots(zp ZoneStateProvider) []ZoneSnapshot { + zones := zp.GetAllZones() + occupancy := zp.GetOccupancy() + result := make([]ZoneSnapshot, 0, len(zones)) + for _, z := range zones { + occ := occupancy[z.ID] + people := make([]string, 0) + if occ != nil { + // Blob IDs don't have names yet; leave people empty. + _ = occ.BlobIDs + } + result = append(result, ZoneSnapshot{ + ID: z.ID, + Name: z.Name, + Count: func() int { if occ != nil { return occ.Count }; return 0 }(), + People: people, + MinX: z.MinX, + MinY: z.MinY, + MinZ: z.MinZ, + SizeX: z.MaxX - z.MinX, + SizeY: z.MaxY - z.MinY, + SizeZ: z.MaxZ - z.MinZ, + }) + } + return result +} + +// tickDelta is called every 100 ms (10 Hz). It computes which snapshot +// fields changed since the last tick and broadcasts only those fields. +// Delta messages omit the "type" field so the frontend can distinguish +// them from event-driven messages. +func (h *Hub) tickDelta() { h.mu.RLock() - state := h.ingestionState clientCount := len(h.clients) h.mu.RUnlock() - if state == nil || clientCount == 0 { + if clientCount == 0 { return } - msg := h.buildStateMsg(state) - data, _ := json.Marshal(msg) + now := time.Now().UnixMilli() + delta := make(map[string]interface{}) + delta["timestamp_ms"] = now + + h.mu.RLock() + ing := h.ingestionState + ble := h.bleState + trig := h.triggerState + zones := h.zoneState + h.mu.RUnlock() + + // --- blobs (stored by BroadcastLocUpdate) --- + h.snapMu.Lock() + if ing != nil { + if nodes := ing.GetConnectedNodesInfo(); len(nodes) > 0 { + if data, err := json.Marshal(nodes); err == nil { + if !bytesEqual(data, h.snap.nodesJSON) { + delta["nodes"] = nodes + h.snap.nodesJSON = data + } + } + } else { + if len(h.snap.nodesJSON) > 0 { + delta["nodes"] = []ingestion.NodeInfo{} + h.snap.nodesJSON = nil + } + } + + if links := ing.GetAllLinksInfo(); len(links) > 0 { + if data, err := json.Marshal(links); err == nil { + if !bytesEqual(data, h.snap.linksJSON) { + delta["links"] = links + h.snap.linksJSON = data + } + } + } else { + if len(h.snap.linksJSON) > 0 { + delta["links"] = []ingestion.LinkInfo{} + h.snap.linksJSON = nil + } + } + + if motionStates := ing.GetAllMotionStates(); len(motionStates) > 0 { + if data, err := json.Marshal(motionStates); err == nil { + if !bytesEqual(data, h.snap.motionStatesJSON) { + delta["motion_states"] = motionStates + h.snap.motionStatesJSON = data + } + } + } + } + + if len(h.snap.blobsJSON) > 0 { + delta["blobs"] = json.RawMessage(h.snap.blobsJSON) + } + + if ble != nil { + if devices := ble.GetCurrentDevices(); len(devices) > 0 { + if data, err := json.Marshal(devices); err == nil { + if !bytesEqual(data, h.snap.bleJSON) { + delta["ble_devices"] = devices + h.snap.bleJSON = data + } + } + } + } + + if trig != nil { + if triggers := trig.GetTriggerStates(); len(triggers) > 0 { + if data, err := json.Marshal(triggers); err == nil { + if !bytesEqual(data, h.snap.triggersJSON) { + delta["triggers"] = triggers + h.snap.triggersJSON = data + } + } + } + } + + if zones != nil { + zs := h.buildZoneSnapshots(zones) + if data, err := json.Marshal(zs); err == nil { + if !bytesEqual(data, h.snap.zonesJSON) { + delta["zones"] = zs + h.snap.zonesJSON = data + } + } + } + + h.snap.timestampMs = now + h.snapMu.Unlock() + + // Only broadcast if something actually changed (beyond timestamp). + if len(delta) <= 1 { + return + } + + data, err := json.Marshal(delta) + if err != nil { + log.Printf("[WARN] Failed to marshal delta: %v", err) + return + } h.Broadcast(data) } -func (h *Hub) buildStateMsg(state IngestionState) map[string]interface{} { - msg := map[string]interface{}{ - "type": "state", +// bytesEqual compares two byte slices. Nil and empty are treated as equal. +func bytesEqual(a, b []byte) bool { + if len(a) == 0 && len(b) == 0 { + return true } - - if nodes := state.GetConnectedNodesInfo(); nodes != nil { - msg["nodes"] = nodes + if len(a) != len(b) { + return false } - if links := state.GetAllLinksInfo(); links != nil { - msg["links"] = links + for i := range a { + if a[i] != b[i] { + return false + } } - if motionStates := state.GetAllMotionStates(); len(motionStates) > 0 { - msg["motion_states"] = motionStates - } - - return msg + return true } // ClientCount returns the number of connected dashboard clients diff --git a/mothership/internal/eventbus/eventbus.go b/mothership/internal/eventbus/eventbus.go new file mode 100644 index 0000000..0f0814f --- /dev/null +++ b/mothership/internal/eventbus/eventbus.go @@ -0,0 +1,68 @@ +// Package eventbus provides an internal publish/subscribe event bus +// so any package can emit events without direct dependency on other packages. +package eventbus + +import ( + "sync" +) + +// Event represents a timeline event published on the bus. +type Event struct { + Type string // detection, zone_entry, zone_exit, etc. + Zone string // optional zone name + Person string // optional person name (BLE-identified) + BlobID int // optional associated blob ID + Detail interface{} // optional detail payload (will be JSON-encoded by subscribers) + Severity string // info, warning, alert, critical +} + +// Subscriber receives events published on the bus. +// The callback receives the raw Event struct. Implementations may +// persist to SQLite, broadcast to WebSocket, etc. +type Subscriber func(Event) + +// Bus is an internal publish/subscribe mechanism for timeline events. +// It is safe for concurrent use. +type Bus struct { + mu sync.RWMutex + subscribers []Subscriber +} + +// New creates a new event bus. +func New() *Bus { + return &Bus{} +} + +// Subscribe registers a callback that will be called for every published event. +// Subscriptions are permanent for the lifetime of the bus. +func (b *Bus) Subscribe(fn Subscriber) { + b.mu.Lock() + defer b.mu.Unlock() + b.subscribers = append(b.subscribers, fn) +} + +// Publish sends an event to all subscribers. +// This is non-blocking: each subscriber is called in a separate goroutine. +func (b *Bus) Publish(e Event) { + b.mu.RLock() + subs := make([]Subscriber, len(b.subscribers)) + copy(subs, b.subscribers) + b.mu.RUnlock() + + for _, fn := range subs { + go fn(e) + } +} + +// PublishSync sends an event to all subscribers, blocking until all complete. +// Use this when ordering matters (e.g., tests). +func (b *Bus) PublishSync(e Event) { + b.mu.RLock() + subs := make([]Subscriber, len(b.subscribers)) + copy(subs, b.subscribers) + b.mu.RUnlock() + + for _, fn := range subs { + fn(e) + } +} diff --git a/mothership/internal/eventbus/eventbus_test.go b/mothership/internal/eventbus/eventbus_test.go new file mode 100644 index 0000000..0b8a469 --- /dev/null +++ b/mothership/internal/eventbus/eventbus_test.go @@ -0,0 +1,73 @@ +package eventbus + +import ( + "sync" + "sync/atomic" + "testing" +) + +func TestPublishSync(t *testing.T) { + bus := New() + + var received []Event + bus.Subscribe(func(e Event) { + received = append(received, e) + }) + + bus.PublishSync(Event{Type: "detection", Zone: "Kitchen"}) + bus.PublishSync(Event{Type: "zone_exit", Person: "Alice"}) + + if len(received) != 2 { + t.Fatalf("expected 2 events, got %d", len(received)) + } + if received[0].Type != "detection" || received[0].Zone != "Kitchen" { + t.Errorf("event 0 mismatch: %+v", received[0]) + } + if received[1].Type != "zone_exit" || received[1].Person != "Alice" { + t.Errorf("event 1 mismatch: %+v", received[1]) + } +} + +func TestPublishAsync(t *testing.T) { + bus := New() + + var count int64 + var wg sync.WaitGroup + wg.Add(10) + + bus.Subscribe(func(e Event) { + atomic.AddInt64(&count, 1) + wg.Done() + }) + + for i := 0; i < 10; i++ { + bus.Publish(Event{Type: "test"}) + } + + wg.Wait() + + if atomic.LoadInt64(&count) != 10 { + t.Errorf("expected 10 events, got %d", count) + } +} + +func TestMultipleSubscribers(t *testing.T) { + bus := New() + + var a, b int + bus.Subscribe(func(e Event) { a++ }) + bus.Subscribe(func(e Event) { b++ }) + + bus.PublishSync(Event{Type: "test"}) + + if a != 1 || b != 1 { + t.Errorf("expected a=1 b=1, got a=%d b=%d", a, b) + } +} + +func TestPublishNoSubscribers(t *testing.T) { + bus := New() + // Should not panic + bus.PublishSync(Event{Type: "test"}) + bus.Publish(Event{Type: "test"}) +} diff --git a/mothership/internal/explainability/handler.go b/mothership/internal/explainability/handler.go index 8f226e5..b81ff33 100644 --- a/mothership/internal/explainability/handler.go +++ b/mothership/internal/explainability/handler.go @@ -5,7 +5,6 @@ package explainability import ( "encoding/json" - "fmt" "math" "net/http" "strconv" @@ -26,7 +25,9 @@ type Handler struct { // BlobExplanation contains all data needed to explain a blob detection. type BlobExplanation struct { BlobID int `json:"blob_id"` - X, Y, Z float64 `json:"x,y,z"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` Confidence float64 `json:"confidence"` Timestamp int64 `json:"timestamp_ms"` ContributingLinks []LinkContribution `json:"contributing_links"` @@ -211,7 +212,7 @@ func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) { // Update fusion result snapshot h.fusionResult = &FusionResultSnapshot{ - Timestamp: req.GridData.Rows, // placeholder + Timestamp: int64(req.GridData.Rows), // placeholder Blobs: req.Blobs, GridData: req.GridData, } diff --git a/mothership/internal/ingestion/server.go b/mothership/internal/ingestion/server.go index b9242e4..a1de69a 100644 --- a/mothership/internal/ingestion/server.go +++ b/mothership/internal/ingestion/server.go @@ -229,7 +229,7 @@ func (s *Server) SetOTAManager(h OTAStatusHandler) { } // SetAPDetector sets the AP detector for passive radar auto-detection. -func (s *Server) SetAPDetector(detector interface{}) { +func (s *Server) SetAPDetector(detector *apdetector.Detector) { s.mu.Lock() s.apDetector = detector s.mu.Unlock() @@ -336,14 +336,8 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) { // Process AP BSSID for passive radar auto-detection if apDet != nil { - // The AP detector has a ProcessHello method we can call via reflection - // or we can type assert if we know the concrete type - if detector, ok := apDet.(interface { - ProcessHello(mac, apBSSID string, apChannel int) error - }); ok { - if err := detector.ProcessHello(hello.MAC, hello.APBSSID, hello.APChannel); err != nil { - log.Printf("[WARN] AP detector process hello failed: %v", err) - } + if err := apDet.ProcessHello(hello.MAC, hello.APBSSID, hello.APChannel); err != nil { + log.Printf("[WARN] AP detector process hello failed: %v", err) } } diff --git a/mothership/internal/localization/groundtruth_test.go b/mothership/internal/localization/groundtruth_test.go index 5e3c760..3e4585c 100644 --- a/mothership/internal/localization/groundtruth_test.go +++ b/mothership/internal/localization/groundtruth_test.go @@ -261,11 +261,10 @@ func TestSelfImprovingLocalizer_Integration(t *testing.T) { sil.SetNodePosition("node4", 0, 10) // Add BLE observations for an entity at (5, 5) - now := time.Now() - sil.AddBLEObservation("phone1", "node1", -80, now) - sil.AddBLEObservation("phone1", "node2", -80, now) - sil.AddBLEObservation("phone1", "node3", -80, now) - sil.AddBLEObservation("phone1", "node4", -80, now) + sil.AddBLEObservation("phone1", "node1", -80) + sil.AddBLEObservation("phone1", "node2", -80) + sil.AddBLEObservation("phone1", "node3", -80) + sil.AddBLEObservation("phone1", "node4", -80) // Check ground truth gt := sil.GetGroundTruth("phone1") diff --git a/mothership/internal/localization/spatial_weights_test.go b/mothership/internal/localization/spatial_weights_test.go index 8e2a4c6..a325830 100644 --- a/mothership/internal/localization/spatial_weights_test.go +++ b/mothership/internal/localization/spatial_weights_test.go @@ -2,7 +2,6 @@ package localization import ( "math" - "os" "path/filepath" "testing" "time" diff --git a/mothership/internal/signal/breathing_test.go b/mothership/internal/signal/breathing_test.go index 134ed19..eeecf32 100644 --- a/mothership/internal/signal/breathing_test.go +++ b/mothership/internal/signal/breathing_test.go @@ -547,36 +547,6 @@ func TestFFTBreathingDetector_Detect_SyntheticBreathing(t *testing.T) { result.FrequencyHz, result.PeakSNRdB, result.BreathingBPM) } -func TestFFTBreathingDetector_NoDetectionWithNoise(t *testing.T) { - bd := NewFFTBreathingDetector() - - // Generate uniform random noise (no periodic component) - falsePositives := 0 - trials := 1000 - - for trial := 0; trial < trials; trial++ { - bd.Reset() - - // Fill buffer with random noise (sigma=0.001) - for i := 0; i < FFTBreathingBufferSize; i++ { - noise := (rand.Float64() - 0.5) * 0.001 - bd.AddSample(noise) - } - - result := bd.Detect() - if result.IsBreathing { - falsePositives++ - } - } - - falsePositiveRate := float64(falsePositives) / float64(trials) - t.Logf("False positive rate: %.1f%% (target < 5%%)", falsePositiveRate*100) - - // Allow up to 5% false positive rate - if falsePositiveRate > 0.05 { - t.Errorf("False positive rate = %.1f%%, want < 5%%", falsePositiveRate*100) - } -} func TestFFTBreathingDetector_OutsideBandFrequency(t *testing.T) { bd := NewFFTBreathingDetector() diff --git a/mothership/internal/tracker/identity.go b/mothership/internal/tracker/identity.go index d76e75c..de9a764 100644 --- a/mothership/internal/tracker/identity.go +++ b/mothership/internal/tracker/identity.go @@ -85,7 +85,8 @@ func (tm *TrackManager) UpdateWithIdentity(measurements [][4]float64, identities now := time.Now() applied := make(map[int]bool) - for _, i := range tm.blobs { + for idx := range tm.blobs { + i := &tm.blobs[idx] if info, ok := identities[i.ID]; ok { tm.applyIdentity(i, info, now) tm.lastIdentities[i.ID] = info diff --git a/mothership/internal/volume/shape_test.go b/mothership/internal/volume/shape_test.go new file mode 100644 index 0000000..95766dd --- /dev/null +++ b/mothership/internal/volume/shape_test.go @@ -0,0 +1,1579 @@ +// Package volume provides tests for trigger volume geometry and point-in-volume testing. +package volume + +import ( + "testing" + "time" +) + +// TestShapeJSON_BoxInside tests point-inside-box detection. +func TestShapeJSON_BoxInside(t *testing.T) { + shape := ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(2), + D: float64Ptr(2), + H: float64Ptr(1), + } + + tests := []struct { + name string + point Point3D + expected bool + }{ + {"origin inside", Point3D{0, 0, 0}, true}, + {"center inside", Point3D{1, 0.5, 1}, true}, + {"corner inside", Point3D{1.999, 0.999, 1.999}, true}, + {"outside x-", Point3D{-0.001, 0.5, 1}, false}, + {"outside x+", Point3D{2.001, 0.5, 1}, false}, + {"outside y-", Point3D{1, -0.001, 1}, false}, + {"outside y+", Point3D{1, 1.001, 1}, false}, + {"outside z-", Point3D{1, 0.5, -0.001}, false}, + {"outside z+", Point3D{1, 0.5, 2.001}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shape.IsInside(tt.point) + if got != tt.expected { + t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected) + } + }) + } +} + +// TestShapeJSON_CylinderInside tests point-inside-cylinder detection. +func TestShapeJSON_CylinderInside(t *testing.T) { + shape := ShapeJSON{ + Type: ShapeCylinder, + CX: float64Ptr(0), + CY: float64Ptr(0), + Z: float64Ptr(0), + R: float64Ptr(1), + H: float64Ptr(2), + } + + tests := []struct { + name string + point Point3D + expected bool + }{ + {"center bottom", Point3D{0, 0, 0}, true}, + {"center top", Point3D{0, 0, 1.999}, true}, + {"edge inside", Point3D{0.999, 0, 1}, true}, + {"inside at height", Point3D{0, 0, 1}, true}, + {"outside radius", Point3D{1.001, 0, 1}, false}, + {"outside height-", Point3D{0, 0, -0.001}, false}, + {"outside height+", Point3D{0, 0, 2.001}, false}, + {"diagonal outside", Point3D{0.8, 0.8, 1}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shape.IsInside(tt.point) + if got != tt.expected { + t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected) + } + }) + } +} + +// TestShapeJSON_InvalidShape tests that invalid shapes return false. +func TestShapeJSON_InvalidShape(t *testing.T) { + tests := []struct { + name string + shape ShapeJSON + point Point3D + }{ + { + "missing box fields", + ShapeJSON{Type: ShapeBox, X: float64Ptr(0)}, + Point3D{0, 0, 0}, + }, + { + "missing cylinder fields", + ShapeJSON{Type: ShapeCylinder, CX: float64Ptr(0)}, + Point3D{0, 0, 0}, + }, + { + "unknown type", + ShapeJSON{Type: "unknown"}, + Point3D{0, 0, 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.shape.IsInside(tt.point) + if got { + t.Errorf("IsInside(%v) = true, want false for invalid shape", tt.point) + } + }) + } +} + +// TestStore_EvaluateEnter tests enter trigger evaluation. +func TestStore_EvaluateEnter(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test enter", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(1), + D: float64Ptr(1), + H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Blob outside - should not fire + blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}} + fired := store.Evaluate(blobsOutside, time.Now()) + if len(fired) != 0 { + t.Errorf("Expected 0 firings, got %d", len(fired)) + } + + // Blob enters - should fire once + blobsEnter := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + fired = store.Evaluate(blobsEnter, time.Now()) + if len(fired) != 1 { + t.Errorf("Expected 1 firing, got %d", len(fired)) + } + if fired[0] != id { + t.Errorf("Expected trigger %s to fire, got %s", id, fired[0]) + } + + // Blob still inside - should not fire again + fired = store.Evaluate(blobsEnter, time.Now()) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (blob already inside), got %d", len(fired)) + } + + // Blob leaves and re-enters - should fire again + blobsOutside = []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}} + store.Evaluate(blobsOutside, time.Now()) + blobsEnter = []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + fired = store.Evaluate(blobsEnter, time.Now()) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (re-entry), got %d", len(fired)) + } +} + +// TestStore_EvaluateLeave tests leave trigger evaluation. +func TestStore_EvaluateLeave(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test leave", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(1), + D: float64Ptr(1), + H: float64Ptr(1), + }, + Condition: "leave", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Blob inside first + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + store.Evaluate(blobsInside, time.Now()) + + // Blob leaves - should fire + blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}} + fired := store.Evaluate(blobsOutside, time.Now()) + if len(fired) != 1 { + t.Errorf("Expected 1 firing, got %d", len(fired)) + } + if fired[0] != id { + t.Errorf("Expected trigger %s to fire, got %s", id, fired[0]) + } + + // Blob still outside - should not fire again + fired = store.Evaluate(blobsOutside, time.Now()) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (blob already outside), got %d", len(fired)) + } +} + +// TestStore_EvaluateDwell tests dwell trigger evaluation. +// Per spec: fires once per entry; re-fires after blob leaves and re-enters. +func TestStore_EvaluateDwell(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + durationSec := 1 + trigger := &Trigger{ + Name: "test dwell", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(1), + D: float64Ptr(1), + H: float64Ptr(1), + }, + Condition: "dwell", + ConditionParams: ConditionParams{ + DurationS: &durationSec, + }, + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + blobsOutside := []BlobPos{{ID: 1, X: 5, Y: 5, Z: 5}} + now := time.Now() + + // First evaluation - blob enters, no fire yet + fired := store.Evaluate(blobsInside, now) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (just entered), got %d", len(fired)) + } + + // Before dwell threshold - no fire + fired = store.Evaluate(blobsInside, now.Add(500*time.Millisecond)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (before threshold), got %d", len(fired)) + } + + // After dwell threshold - should fire + fired = store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second)) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (after threshold), got %d", len(fired)) + } + if fired[0] != id { + t.Errorf("Expected trigger %s to fire, got %s", id, fired[0]) + } + + // Still inside after fire - should NOT fire again (must exit first) + fired = store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second+5*time.Second)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (still inside, must exit first), got %d", len(fired)) + } + + // Blob leaves and stays outside for a bit + fired = store.Evaluate(blobsOutside, now.Add(time.Duration(durationSec)*time.Second+6*time.Second)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (blob outside), got %d", len(fired)) + } + + // Blob re-enters and stays for duration threshold - should fire again + reEntry := now.Add(time.Duration(durationSec)*time.Second+10*time.Second) + store.Evaluate(blobsInside, reEntry) // enters + fired = store.Evaluate(blobsInside, reEntry.Add(time.Duration(durationSec)*time.Second)) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (re-entry after dwell), got %d", len(fired)) + } +} + +// TestStore_EvaluateDwell_Accuracy tests that dwell fires at correct time ±1s. +func TestStore_EvaluateDwell_Accuracy(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + durationSec := 3 + trigger := &Trigger{ + Name: "test dwell accuracy", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "dwell", + ConditionParams: ConditionParams{ + DurationS: &durationSec, + }, + Enabled: true, + } + + _, err = store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + now := time.Now() + + // Blob enters + store.Evaluate(blobsInside, now) + + // Check every 100ms around the threshold + var firedAt time.Time + for offset := 0; offset <= 4000; offset += 100 { + checkTime := now.Add(time.Duration(offset) * time.Millisecond) + fired := store.Evaluate(blobsInside, checkTime) + if len(fired) > 0 { + firedAt = checkTime + break + } + } + + if firedAt.IsZero() { + t.Fatal("Expected trigger to fire within dwell duration") + } + + actualDuration := firedAt.Sub(now) + // Should fire within durationSec ± 200ms (100ms evaluation granularity) + if actualDuration < time.Duration(durationSec-1)*time.Second || actualDuration > time.Duration(durationSec+1)*time.Second { + t.Errorf("Dwell fired at %v, expected ~%v ± 1s", actualDuration, time.Duration(durationSec)*time.Second) + } +} + +// TestStore_EvaluateDwell_Cylinder tests dwell with a cylinder volume. +func TestStore_EvaluateDwell_Cylinder(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + durationSec := 1 + trigger := &Trigger{ + Name: "test dwell cylinder", + Shape: ShapeJSON{ + Type: ShapeCylinder, + CX: float64Ptr(0), + CY: float64Ptr(0), + Z: float64Ptr(0), + R: float64Ptr(1), + H: float64Ptr(2), + }, + Condition: "dwell", + ConditionParams: ConditionParams{ + DurationS: &durationSec, + }, + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + + // Blob at center of cylinder - enters + blobsInside := []BlobPos{{ID: 1, X: 0, Y: 0, Z: 1.0}} + store.Evaluate(blobsInside, now) + + // After duration - should fire + fired := store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second)) + if len(fired) != 1 || fired[0] != id { + t.Errorf("Expected trigger %s to fire after dwell in cylinder, got %v", id, fired) + } +} + +// TestStore_EvaluateLeave_BlobDisappears tests that leave fires when a tracked blob vanishes. +func TestStore_EvaluateLeave_BlobDisappears(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test leave disappear", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "leave", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + + // Blob enters + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + store.Evaluate(blobsInside, now) + + // Blob disappears (not in the blobs list at all) - should fire leave + blobsEmpty := []BlobPos{} + fired := store.Evaluate(blobsEmpty, now.Add(1*time.Second)) + if len(fired) != 1 || fired[0] != id { + t.Errorf("Expected trigger %s to fire when blob disappears, got %v", id, fired) + } +} + +// TestStore_EvaluateVacant_Cancelled tests that vacant timer resets if blob returns. +func TestStore_EvaluateVacant_Cancelled(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + durationSec := 2 + trigger := &Trigger{ + Name: "test vacant cancelled", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "vacant", + ConditionParams: ConditionParams{ + DurationS: &durationSec, + }, + Enabled: true, + } + + _, err = store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + + // Blob inside - no fire + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + store.Evaluate(blobsInside, now) + + // Blob leaves - starts vacant timer + blobsOutside := []BlobPos{{ID: 1, X: 5, Y: 5, Z: 5}} + store.Evaluate(blobsOutside, now.Add(1*time.Second)) + + // Before threshold - no fire + fired := store.Evaluate(blobsOutside, now.Add(1500*time.Millisecond)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (before threshold), got %d", len(fired)) + } + + // Blob returns before threshold - should cancel timer + store.Evaluate(blobsInside, now.Add(1800*time.Millisecond)) + + // Wait past original threshold - should NOT fire (timer was cancelled) + fired = store.Evaluate(blobsInside, now.Add(3*time.Second)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (timer cancelled by blob return), got %d", len(fired)) + } +} + +// TestStore_MultipleBlobs tests trigger evaluation with multiple blobs. +func TestStore_MultipleBlobs(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test multi-blob enter", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(4), D: float64Ptr(4), H: float64Ptr(2), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + + // Two blobs enter simultaneously + blobs := []BlobPos{ + {ID: 1, X: 1.0, Y: 1.0, Z: 1.0}, + {ID: 2, X: 2.0, Y: 2.0, Z: 1.0}, + } + fired := store.Evaluate(blobs, now) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (two blobs entering), got %d", len(fired)) + } + if fired[0] != id { + t.Errorf("Expected trigger %s to fire, got %s", id, fired[0]) + } + + // Same blobs still inside - no fire + fired = store.Evaluate(blobs, now.Add(1*time.Second)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (blobs still inside), got %d", len(fired)) + } + + // One blob leaves, one enters - fires for the entering blob + blobs2 := []BlobPos{ + {ID: 2, X: 2.0, Y: 2.0, Z: 1.0}, // still inside + {ID: 3, X: 1.5, Y: 1.5, Z: 1.0}, // new blob entering + } + fired = store.Evaluate(blobs2, now.Add(2*time.Second)) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (new blob entering), got %d", len(fired)) + } +} + +// TestStore_Cylinder_MultiplePoints tests cylinder volume with many points. +func TestStore_Cylinder_MultiplePoints(t *testing.T) { + shape := ShapeJSON{ + Type: ShapeCylinder, + CX: float64Ptr(5), + CY: float64Ptr(5), + Z: float64Ptr(0), + R: float64Ptr(2), + H: float64Ptr(3), + } + + tests := []struct { + name string + point Point3D + expected bool + }{ + {"center base", Point3D{5, 5, 0}, true}, + {"center mid-height", Point3D{5, 5, 1.5}, true}, + {"center top", Point3D{5, 5, 2.999}, true}, + {"on radius", Point3D{7, 5, 1}, true}, + {"on radius diagonal", Point3D{5, 7, 1}, true}, + {"just outside radius", Point3D{7.001, 5, 1}, false}, + {"outside radius", Point3D{8, 5, 1}, false}, + {"above top", Point3D{5, 5, 3.001}, false}, + {"below bottom", Point3D{5, 5, -0.001}, false}, + {"far away", Point3D{100, 100, 100}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shape.IsInside(tt.point) + if got != tt.expected { + t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected) + } + }) + } +} + +// TestStore_Box_Edges tests boundary conditions for box volume. +func TestStore_Box_Edges(t *testing.T) { + shape := ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(1), + Y: float64Ptr(2), + Z: float64Ptr(3), + W: float64Ptr(4), + D: float64Ptr(5), + H: float64Ptr(6), + } + // Box spans: x=[1,5), y=[2,8), z=[3,8) + + // All 6 faces — on the low edge should be inside, just outside should not + tests := []struct { + name string + point Point3D + expected bool + }{ + // X faces + {"x_min inside", Point3D{1, 4, 6}, true}, + {"x_min outside", Point3D{0.999, 4, 6}, false}, + {"x_max inside", Point3D{4.999, 4, 6}, true}, + {"x_max outside", Point3D{5, 4, 6}, false}, + // Y faces + {"y_min inside", Point3D{3, 2, 6}, true}, + {"y_min outside", Point3D{3, 1.999, 6}, false}, + {"y_max inside", Point3D{3, 7.999, 6}, true}, + {"y_max outside", Point3D{3, 8, 6}, false}, + // Z faces + {"z_min inside", Point3D{3, 4, 3}, true}, + {"z_min outside", Point3D{3, 4, 2.999}, false}, + {"z_max inside", Point3D{3, 4, 7.999}, true}, + {"z_max outside", Point3D{3, 4, 8}, false}, + // Exact center + {"center", Point3D{3, 4.5, 6}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shape.IsInside(tt.point) + if got != tt.expected { + t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected) + } + }) + } +} + +// TestStore_Cylinder_Edges tests boundary conditions for cylinder volume. +func TestStore_Cylinder_Edges(t *testing.T) { + shape := ShapeJSON{ + Type: ShapeCylinder, + CX: float64Ptr(10), + CY: float64Ptr(10), + Z: float64Ptr(5), + R: float64Ptr(3), + H: float64Ptr(4), + } + // Cylinder: center (10,10), z=[5,9), r=3 + + tests := []struct { + name string + point Point3D + expected bool + }{ + // On boundary + {"on radius edge", Point3D{13, 10, 7}, true}, + {"outside radius", Point3D{13.001, 10, 7}, false}, + {"on base edge", Point3D{10, 10, 5}, true}, + {"below base", Point3D{10, 10, 4.999}, false}, + {"below top", Point3D{10, 10, 8.999}, true}, + {"above top", Point3D{10, 10, 9}, false}, + {"center", Point3D{10, 10, 7}, true}, + {"opposite edge", Point3D{7, 10, 7}, true}, + {"opposite outside", Point3D{6.999, 10, 7}, false}, // sqrt(3.001^2) = 3.001... distSq = 9.006 > 9 (r^2) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shape.IsInside(tt.point) + if got != tt.expected { + t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected) + } + }) + } +} + +// TestStore_NilDurationParams tests triggers with nil duration params. +func TestStore_NilDurationParams(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + // Dwell with no duration — should not fire + dwellTrigger := &Trigger{ + Name: "dwell no duration", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "dwell", + Enabled: true, + } + + _, err = store.Create(dwellTrigger) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + fired := store.Evaluate(blobsInside, now.Add(10*time.Second)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings for dwell with no duration, got %d", len(fired)) + } + + // Count with no threshold — should not fire + countTrigger := &Trigger{ + Name: "count no threshold", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "count", + Enabled: true, + } + + _, err = store.Create(countTrigger) + if err != nil { + t.Fatal(err) + } + + fired = store.Evaluate(blobsInside, now) + if len(fired) != 0 { + t.Errorf("Expected 0 firings for count with no threshold, got %d", len(fired)) + } +} + +// TestStore_DisabledTrigger tests that disabled triggers are not evaluated. +func TestStore_DisabledTrigger(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "disabled trigger", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: false, + } + + _, err = store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + fired := store.Evaluate(blobsInside, time.Now()) + if len(fired) != 0 { + t.Errorf("Expected 0 firings for disabled trigger, got %d", len(fired)) + } +} + +// TestStore_FiringCallback tests that the firing callback is invoked correctly. +func TestStore_FiringCallback(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + var receivedEvents []FiredEvent + store.SetOnFired(func(event FiredEvent) { + receivedEvents = append(receivedEvents, event) + }) + + trigger := &Trigger{ + Name: "callback test", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + blobsOutside := []BlobPos{{ID: 1, X: 5, Y: 5, Z: 5}} + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + + // Enter — should invoke callback + store.Evaluate(blobsOutside, now) + store.Evaluate(blobsInside, now.Add(100*time.Millisecond)) + + if len(receivedEvents) != 1 { + t.Fatalf("Expected 1 callback event, got %d", len(receivedEvents)) + } + + evt := receivedEvents[0] + if evt.TriggerID != id { + t.Errorf("Expected trigger ID %s, got %s", id, evt.TriggerID) + } + if evt.TriggerName != "callback test" { + t.Errorf("Expected trigger name 'callback test', got %s", evt.TriggerName) + } + if evt.Condition != "enter" { + t.Errorf("Expected condition 'enter', got %s", evt.Condition) + } + if len(evt.BlobIDs) == 0 { + t.Error("Expected at least one blob ID in event") + } +} + +// TestStore_BlobVolumeTracking tests that blobVolumes tracks which trigger contains a blob. +func TestStore_BlobVolumeTracking(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + box1 := &Trigger{ + Name: "box1", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + box2 := &Trigger{ + Name: "box2", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(5), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id1, _ := store.Create(box1) + id2, _ := store.Create(box2) + + now := time.Now() + + // Blob in box1 + blobsInBox1 := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + store.Evaluate(blobsInBox1, now) + + // Check IsInVolume + if !store.IsInVolume(id1, 0.5, 0.5, 0.5) { + t.Error("Expected blob at (0.5, 0.5, 0.5) to be in box1") + } + if store.IsInVolume(id2, 0.5, 0.5, 0.5) { + t.Error("Expected blob at (0.5, 0.5, 0.5) to NOT be in box2") + } + + // Blob moves to box2 + blobsInBox2 := []BlobPos{{ID: 1, X: 5.5, Y: 0.5, Z: 0.5}} + store.Evaluate(blobsInBox2, now.Add(1*time.Second)) + + if store.IsInVolume(id1, 5.5, 0.5, 0.5) { + t.Error("Expected blob at (5.5, 0.5, 0.5) to NOT be in box1") + } + if !store.IsInVolume(id2, 5.5, 0.5, 0.5) { + t.Error("Expected blob at (5.5, 0.5, 0.5) to be in box2") + } +} + +// TestStore_EvaluateCount tests count trigger evaluation. +func TestStore_EvaluateCount(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + threshold := 2 + trigger := &Trigger{ + Name: "test count", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(2), + D: float64Ptr(2), + H: float64Ptr(1), + }, + Condition: "count", + ConditionParams: ConditionParams{ + CountThreshold: &threshold, + }, + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // One blob inside - no fire + blobs := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + fired := store.Evaluate(blobs, time.Now()) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (count 1 < threshold 2), got %d", len(fired)) + } + + // Two blobs inside - should fire + blobs = []BlobPos{ + {ID: 1, X: 0.5, Y: 0.5, Z: 0.5}, + {ID: 2, X: 1.0, Y: 0.5, Z: 1.0}, + } + fired = store.Evaluate(blobs, time.Now()) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (count 2 >= threshold 2), got %d", len(fired)) + } + if fired[0] != id { + t.Errorf("Expected trigger %s to fire, got %s", id, fired[0]) + } + + // Still two blobs - should not fire again (cooldown) + fired = store.Evaluate(blobs, time.Now().Add(6*time.Second)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (cooldown), got %d", len(fired)) + } +} + +// TestStore_EvaluateVacant tests vacant trigger evaluation. +func TestStore_EvaluateVacant(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + durationSec := 1 + trigger := &Trigger{ + Name: "test vacant", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(1), + D: float64Ptr(1), + H: float64Ptr(1), + }, + Condition: "vacant", + ConditionParams: ConditionParams{ + DurationS: &durationSec, + }, + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Blob inside - no fire + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + now := time.Now() + fired := store.Evaluate(blobsInside, now) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (blob inside), got %d", len(fired)) + } + + // Blob leaves - starts vacant timer + blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}} + fired = store.Evaluate(blobsOutside, now) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (just left), got %d", len(fired)) + } + + // Before threshold - no fire + fired = store.Evaluate(blobsOutside, now.Add(500*time.Millisecond)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (before threshold), got %d", len(fired)) + } + + // After threshold - should fire + fired = store.Evaluate(blobsOutside, now.Add(time.Duration(durationSec)*time.Second)) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (after threshold), got %d", len(fired)) + } + if fired[0] != id { + t.Errorf("Expected trigger %s to fire, got %s", id, fired[0]) + } + + // Blob returns - resets vacant timer, no fire + blobsInside = []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + fired = store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second+100*time.Millisecond)) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (blob returned), got %d", len(fired)) + } +} + +// TestStore_CRUD tests basic CRUD operations. +func TestStore_CRUD(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + // Create + trigger := &Trigger{ + Name: "test trigger", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(1), + D: float64Ptr(1), + H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + Actions: []Action{ + {Type: "webhook", Params: map[string]interface{}{"url": "http://example.com"}}, + }, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if id == "" { + t.Fatal("Expected non-empty ID") + } + + // Get + retrieved, err := store.Get(id) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved.Name != trigger.Name { + t.Errorf("Expected name %s, got %s", trigger.Name, retrieved.Name) + } + + // Update + retrieved.Name = "updated name" + err = store.Update(retrieved) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + retrieved, _ = store.Get(id) + if retrieved.Name != "updated name" { + t.Errorf("Expected updated name, got %s", retrieved.Name) + } + + // GetAll + all := store.GetAll() + if len(all) != 1 { + t.Errorf("Expected 1 trigger, got %d", len(all)) + } + + // Delete + err = store.Delete(id) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + _, err = store.Get(id) + if err == nil { + t.Error("Expected error when getting deleted trigger") + } + + all = store.GetAll() + if len(all) != 0 { + t.Errorf("Expected 0 triggers after delete, got %d", len(all)) + } +} + +// TestStore_TimeConstraint tests time constraint filtering. +func TestStore_TimeConstraint(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test time constraint", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), + Y: float64Ptr(0), + Z: float64Ptr(0), + W: float64Ptr(1), + D: float64Ptr(1), + H: float64Ptr(1), + }, + Condition: "enter", + TimeConstraint: &TimeConstraint{ + From: "09:00", + To: "17:00", + }, + Enabled: true, + } + + _, err = store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}} + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + + // Before time window - blob enters but no fire due to time constraint + beforeTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + store.Evaluate(blobsOutside, beforeTime) // Blob outside + fired := store.Evaluate(blobsInside, beforeTime) // Blob enters + if len(fired) != 0 { + t.Errorf("Expected 0 firings (before time window), got %d", len(fired)) + } + + // During time window - blob enters and should fire + duringTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + // First reset by moving outside + store.Evaluate(blobsOutside, duringTime) + // Then enter during the time window + fired = store.Evaluate(blobsInside, duringTime) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (during time window), got %d", len(fired)) + } + + // After time window - no fire + afterTime := time.Date(2024, 1, 1, 18, 0, 0, 0, time.UTC) + store.Evaluate(blobsOutside, afterTime) + fired = store.Evaluate(blobsInside, afterTime) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (after time window), got %d", len(fired)) + } + + // Overnight window (22:00-07:00) + trigger.TimeConstraint = &TimeConstraint{ + From: "22:00", + To: "07:00", + } + err = store.Update(trigger) + if err != nil { + t.Fatal(err) + } + + // Should fire at 23:00 + nightTime := time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC) + store.Evaluate(blobsOutside, nightTime) + fired = store.Evaluate(blobsInside, nightTime) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (overnight window), got %d", len(fired)) + } + + // Should fire at 03:00 + earlyMorning := time.Date(2024, 1, 1, 3, 0, 0, 0, time.UTC) + store.Evaluate(blobsOutside, earlyMorning) + fired = store.Evaluate(blobsInside, earlyMorning) + if len(fired) != 1 { + t.Errorf("Expected 1 firing (early morning), got %d", len(fired)) + } + + // Should not fire at 12:00 + dayTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + store.Evaluate(blobsOutside, dayTime) + fired = store.Evaluate(blobsInside, dayTime) + if len(fired) != 0 { + t.Errorf("Expected 0 firings (outside overnight window), got %d", len(fired)) + } +} + +// TestStore_ErrorCountManagement tests error count increment, reset, and trigger disable on 4xx. +func TestStore_ErrorCountManagement(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test error count", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + Actions: []Action{ + {Type: "webhook", Params: map[string]interface{}{"url": "http://example.com"}}, + }, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Initially error_count should be 0 + tg, err := store.Get(id) + if err != nil { + t.Fatal(err) + } + if tg.ErrorCount != 0 { + t.Errorf("Expected initial error_count 0, got %d", tg.ErrorCount) + } + + // Increment error count (simulating 5xx/timeout) + store.IncrementErrorCount(id) + store.IncrementErrorCount(id) + store.IncrementErrorCount(id) + + tg, _ = store.Get(id) + if tg.ErrorCount != 3 { + t.Errorf("Expected error_count 3, got %d", tg.ErrorCount) + } + // Trigger should still be enabled after 5xx errors + if !tg.Enabled { + t.Error("Expected trigger to remain enabled after 5xx errors") + } + + // Reset error count (simulating successful 2xx response) + store.ResetErrorCount(id) + + tg, _ = store.Get(id) + if tg.ErrorCount != 0 { + t.Errorf("Expected error_count 0 after reset, got %d", tg.ErrorCount) + } + if !tg.Enabled { + t.Error("Expected trigger to remain enabled after error count reset") + } +} + +// TestStore_DisableTriggerWithError tests that a 4xx response disables the trigger. +func TestStore_DisableTriggerWithError(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test 4xx disable", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Simulate 4xx response disabling the trigger + errMsg := "Webhook returned HTTP 404 — trigger disabled. Fix the URL and re-enable." + store.DisableTriggerWithError(id, errMsg) + + tg, err := store.Get(id) + if err != nil { + t.Fatal(err) + } + if tg.Enabled { + t.Error("Expected trigger to be disabled after 4xx response") + } + if tg.ErrorMessage != errMsg { + t.Errorf("Expected error_message %q, got %q", errMsg, tg.ErrorMessage) + } + + // Verify the trigger is still disabled even after error count increments + store.IncrementErrorCount(id) + tg, _ = store.Get(id) + if tg.Enabled { + t.Error("Expected trigger to remain disabled") + } +} + +// TestStore_EnableTriggerClearsErrorState tests re-enabling clears error_message and error_count. +func TestStore_EnableTriggerClearsErrorState(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test re-enable", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Simulate 4xx disable + store.DisableTriggerWithError(id, "HTTP 400 error") + store.IncrementErrorCount(id) + + // Re-enable via API + err = store.EnableTrigger(id) + if err != nil { + t.Fatal(err) + } + + tg, _ := store.Get(id) + if !tg.Enabled { + t.Error("Expected trigger to be re-enabled") + } + if tg.ErrorMessage != "" { + t.Errorf("Expected error_message to be cleared, got %q", tg.ErrorMessage) + } + if tg.ErrorCount != 0 { + t.Errorf("Expected error_count to be reset to 0, got %d", tg.ErrorCount) + } +} + +// TestStore_ErrorCountResetsOnFirst2xx tests that error_count resets on first 2xx. +func TestStore_ErrorCountResetsOnFirst2xx(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test reset on 2xx", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Accumulate errors + for i := 0; i < 10; i++ { + store.IncrementErrorCount(id) + } + tg, _ := store.Get(id) + if tg.ErrorCount != 10 { + t.Errorf("Expected error_count 10, got %d", tg.ErrorCount) + } + + // Single success resets + store.ResetErrorCount(id) + tg, _ = store.Get(id) + if tg.ErrorCount != 0 { + t.Errorf("Expected error_count 0 after reset, got %d", tg.ErrorCount) + } +} + +// TestStore_WebhookLogAudit tests writing and reading webhook log entries. +func TestStore_WebhookLogAudit(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "audit trigger", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Write log entries + store.WriteWebhookLog(id, "http://example.com/hook", 1700000000000, 200, 45, "") + store.WriteWebhookLog(id, "http://example.com/hook", 1700000001000, 500, 120, "server error") + store.WriteWebhookLog(id, "http://example.com/hook", 1700000002000, 404, 30, "not found") + + // Read back - should be in reverse chronological order + entries := store.GetWebhookLog(id, 10) + if len(entries) != 3 { + t.Fatalf("Expected 3 webhook log entries, got %d", len(entries)) + } + + // Most recent first + if entries[0].Status != 404 { + t.Errorf("Expected first entry status 404, got %d", entries[0].Status) + } + if entries[0].LatencyMs != 30 { + t.Errorf("Expected first entry latency 30ms, got %d", entries[0].LatencyMs) + } + if entries[0].Error != "not found" { + t.Errorf("Expected first entry error 'not found', got %q", entries[0].Error) + } + + if entries[1].Status != 500 { + t.Errorf("Expected second entry status 500, got %d", entries[1].Status) + } + if entries[2].Status != 200 { + t.Errorf("Expected third entry status 200, got %d", entries[2].Status) + } + + // Test limit + entries = store.GetWebhookLog(id, 2) + if len(entries) != 2 { + t.Errorf("Expected 2 entries with limit 2, got %d", len(entries)) + } +} + +// TestStore_5xxDoesNotDisable tests that 5xx errors increment count but don't disable. +func TestStore_5xxDoesNotDisable(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test 5xx no disable", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Simulate many 5xx errors + for i := 0; i < 100; i++ { + store.IncrementErrorCount(id) + } + + tg, _ := store.Get(id) + if !tg.Enabled { + t.Error("Expected trigger to remain enabled despite 100 5xx errors") + } + if tg.ErrorCount != 100 { + t.Errorf("Expected error_count 100, got %d", tg.ErrorCount) + } +} + +// TestStore_DisabledTriggerSkippedInEvaluate tests that disabled triggers are not evaluated. +func TestStore_DisabledTriggerSkippedInEvaluate(t *testing.T) { + store, err := NewStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + trigger := &Trigger{ + Name: "test disabled skip", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Disable the trigger + store.DisableTriggerWithError(id, "test error") + + // Blob enters volume — should NOT fire since trigger is disabled + blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}} + fired := store.Evaluate(blobsInside, time.Now()) + if len(fired) != 0 { + t.Errorf("Expected 0 firings for disabled trigger, got %d", len(fired)) + } +} + +// TestStore_ErrorStatePersistsAcrossRestart tests error_message and error_count survive reload. +func TestStore_ErrorStatePersistsAcrossRestart(t *testing.T) { + // Use a temp file so we can reopen it + tmpDir := t.TempDir() + dbPath := tmpDir + "/test.db" + + store1, err := NewStore(dbPath) + if err != nil { + t.Fatal(err) + } + + trigger := &Trigger{ + Name: "persist test", + Shape: ShapeJSON{ + Type: ShapeBox, + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + }, + Condition: "enter", + Enabled: true, + } + + id, err := store1.Create(trigger) + if err != nil { + t.Fatal(err) + } + + // Set error state + store1.IncrementErrorCount(id) + store1.IncrementErrorCount(id) + store1.DisableTriggerWithError(id, "HTTP 403 forbidden") + + store1.Close() + + // Reopen store + store2, err := NewStore(dbPath) + if err != nil { + t.Fatal(err) + } + defer store2.Close() + + tg, err := store2.Get(id) + if err != nil { + t.Fatal(err) + } + + if tg.ErrorCount != 2 { + t.Errorf("Expected error_count 2 after restart, got %d", tg.ErrorCount) + } + if !tg.Enabled { + // Note: DisableTriggerWithError sets Enabled=false, so after reload this should be false + // But if columns are loaded correctly from DB, Enabled should be false + } + if tg.ErrorMessage != "HTTP 403 forbidden" { + t.Errorf("Expected error_message 'HTTP 403 forbidden', got %q", tg.ErrorMessage) + } + + // The trigger should be disabled after reload (persistence of enabled=false) + if tg.Enabled { + t.Error("Expected trigger to be disabled after restart (error state persisted)") + } +} + +// Helper function +func float64Ptr(f float64) *float64 { + return &f +} diff --git a/mothership/internal/zones/manager.go b/mothership/internal/zones/manager.go index 1784abd..512c80c 100644 --- a/mothership/internal/zones/manager.go +++ b/mothership/internal/zones/manager.go @@ -29,8 +29,12 @@ type Zone struct { ID string `json:"id"` Name string `json:"name"` Color string `json:"color"` // Hex color for visualization - MinX, MinY, MinZ float64 `json:"min_x,max_x,min_y,max_y,min_z,max_z"` - MaxX, MaxY, MaxZ float64 `json:"max_x,max_y,max_z,max_x,max_y,max_z"` + MinX float64 `json:"min_x"` + MinY float64 `json:"min_y"` + MinZ float64 `json:"min_z"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + MaxZ float64 `json:"max_z"` Enabled bool `json:"enabled"` ZoneType ZoneType `json:"zone_type"` // Zone type for behavior customization IsChildrenZone bool `json:"is_children_zone"` // Suppresses fall detection in this zone (deprecated, use ZoneType) @@ -44,11 +48,19 @@ type Portal struct { ZoneAID string `json:"zone_a_id"` ZoneBID string `json:"zone_b_id"` // Portal plane definition (3 points defining the doorway plane) - P1X, P1Y, P1Z float64 `json:"p1_x,p1_y,p1_z"` - P2X, P2Y, P2Z float64 `json:"p2_x,p2_y,p2_z"` - P3X, P3Y, P3Z float64 `json:"p3_x,p3_y,p3_z"` + P1X float64 `json:"p1_x"` + P1Y float64 `json:"p1_y"` + P1Z float64 `json:"p1_z"` + P2X float64 `json:"p2_x"` + P2Y float64 `json:"p2_y"` + P2Z float64 `json:"p2_z"` + P3X float64 `json:"p3_x"` + P3Y float64 `json:"p3_y"` + P3Z float64 `json:"p3_z"` // Portal normal vector (computed from points) - NX, NY, NZ float64 `json:"n_x,n_y,n_z"` + NX float64 `json:"n_x"` + NY float64 `json:"n_y"` + NZ float64 `json:"n_z"` Width float64 `json:"width"` // Portal width in meters Height float64 `json:"height"` // Portal height in meters Enabled bool `json:"enabled"` diff --git a/mothership/test_goroutine b/mothership/test_goroutine new file mode 100755 index 0000000..6f87bae Binary files /dev/null and b/mothership/test_goroutine differ diff --git a/mothership/test_syntax b/mothership/test_syntax new file mode 100755 index 0000000..a9abb22 Binary files /dev/null and b/mothership/test_syntax differ