diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index af52d68..81c862e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -25,7 +25,7 @@ {"id":"spaxel-5yq","title":"load-shedding: health endpoint + dashboard WS alert integration","description":"## Task\nExpose the load shedding level in the health endpoint and send a dashboard WS alert when Level 3 is triggered. Requires spaxel-54i to be complete (GetShedLevel() must exist).\n\n## 1. Health endpoint (mothership/cmd/mothership/main.go)\nFind the `/healthz` handler (around line 218). Change the JSON to include `shedding_level`:\n```go\nfmt.Fprintf(w, `{\"status\":\"ok\",\"version\":\"%s\",\"shedding_level\":%d}`, version, pm.GetShedLevel())\n```\n\n## 2. Dashboard WS alert on Level 3\nIn the fusion loop in main.go (the goroutine that calls `pm.Process()`), track the previous shed level and broadcast an alert when it changes to 3 or recovers:\n```go\n// after pm.Process() call:\nnewLevel := pm.GetShedLevel()\nif newLevel != prevShedLevel {\n if newLevel == 3 {\n msg := map[string]interface{}{\n \"type\": \"alert\",\n \"severity\": \"warning\",\n \"description\": \"System under load — CSI rate reduced to 10 Hz\",\n }\n data, _ := json.Marshal(msg)\n dashboardHub.Broadcast(data)\n }\n prevShedLevel = newLevel\n log.Printf(\"[INFO] Load shedding level changed: %d\", newLevel)\n}\n```\nDeclare `prevShedLevel int` before the fusion goroutine.\n\n## 3. Level 3 rate reduction push (best effort — log only if push mechanism not yet available)\nWhen `newLevel == 3`, log: `log.Printf(\"[INFO] Load shed level 3 — would push 10Hz cap to nodes\")`\nWhen `newLevel` recovers from 3, log: `log.Printf(\"[INFO] Load shed recovered — restoring prior node rate\")`\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n# curl http://localhost:8080/healthz should include shedding_level\n```\n\nRequires: spaxel-54i","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T06:33:19.278442007Z","created_by":"coding","updated_at":"2026-04-07T17:56:41.358181685Z","closed_at":"2026-04-07T17:56:41.358116981Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4"],"dependencies":[{"issue_id":"spaxel-5yq","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.206754212Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-65k","title":"Dashboard: activity timeline view","description":"## Overview\n\nBuild the unified activity timeline — the primary event history UI, accessible via the #timeline route added in the dashboard framework bead.\n\n## What to build (dashboard/js/timeline.js + timeline.css)\n\n### Timeline sidebar\n- Scrollable chronological event list (newest at top)\n- Event types with distinct icons/colors:\n - Zone entry/exit (green/orange)\n - Portal crossing (blue arrow)\n - Anomaly / security alert (red pulse)\n - Learning milestone (purple star)\n - System event (grey gear)\n- Each event shows: timestamp, description, person name (if identified), zone name\n- Click event → jump to that moment in the 3D view (triggers replay seek to that timestamp)\n\n### Filter bar\n- Filter by: person, zone, event type, time range (today / last 7d / custom)\n- Search box with debounced text filter across event descriptions\n\n### Inline feedback\n- Thumbs up / thumbs down on presence detection events\n- POST /api/feedback with event_id and correct (bool)\n- System response toast: 'Thanks — threshold adjusted for kitchen link'\n\n### Data source\n- Initial load: GET /api/events?limit=200&since=24h\n- Live updates: 'event' messages from WebSocket feed (requires spaxel-9eg)\n\n## Acceptance\n\n- 200 events render within 200ms of page load\n- New events prepend without layout shift\n- Clicking an event in replay mode seeks the replay to ±5s around the event\n- Feedback buttons POST successfully and show toast confirmation","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:56:03.195915329Z","created_by":"coding","updated_at":"2026-04-06T16:01:48.118589901Z","closed_at":"2026-04-06T16:01:48.118470381Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-6ha","title":"Complete REST API: settings, zones, portals, triggers, notifications, replay","description":"## Problem\n\nMany HTTP endpoints are stubbed or missing. The dashboard settings panel, automation builder, and replay UI all require working REST endpoints.\n\n## Endpoints to implement in mothership/\n\n### Settings\n- GET /api/settings — return all configurable settings as JSON\n- POST /api/settings — update settings (partial update, merge semantics)\n\n### Zones & Portals\n- GET /api/zones — list all zones\n- POST /api/zones — create zone\n- PUT /api/zones/{id} — update zone geometry/name\n- DELETE /api/zones/{id} — delete zone\n- GET /api/portals — list all portals\n- POST /api/portals — create portal\n- PUT /api/portals/{id} — update\n- DELETE /api/portals/{id} — delete\n\n### Automation Triggers\n- GET /api/triggers — list all triggers\n- POST /api/triggers — create trigger\n- PUT /api/triggers/{id} — update\n- DELETE /api/triggers/{id} — delete\n- POST /api/triggers/{id}/test — fire trigger once for testing\n\n### Notifications\n- GET /api/notifications/config — get delivery channel config\n- POST /api/notifications/config — set Ntfy/Pushover/webhook settings\n- POST /api/notifications/test — send a test notification\n\n### Replay / Time-Travel\n- GET /api/replay/sessions — list available recording sessions\n- POST /api/replay/start — start replay at given timestamp\n- POST /api/replay/stop — stop replay, return to live\n- POST /api/replay/seek — seek to timestamp within session\n- POST /api/replay/tune — update pipeline parameters mid-replay\n\n### BLE Devices\n- GET /api/ble/devices — list known devices\n- PUT /api/ble/devices/{mac} — set label, assign to person\n\n## Acceptance\n\n- All endpoints return JSON with appropriate status codes\n- Settings endpoint persists to SQLite across restarts\n- Zone/portal CRUD reflected in the live 3D view within one WebSocket cycle\n- OpenAPI-style godoc comment on each handler with method, path, request, response","status":"in_progress","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T12:55:51.683246046Z","created_by":"coding","updated_at":"2026-04-07T20:33:14.451043337Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:7"],"dependencies":[{"issue_id":"spaxel-6ha","depends_on_id":"spaxel-21n","type":"blocks","created_at":"2026-04-06T15:31:10.298537585Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6ha","depends_on_id":"spaxel-4fg","type":"blocks","created_at":"2026-04-06T15:31:10.528996520Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6ha","depends_on_id":"spaxel-kxf","type":"blocks","created_at":"2026-04-06T15:31:10.466981102Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6ha","depends_on_id":"spaxel-mul","type":"blocks","created_at":"2026-04-06T15:31:10.407580303Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6ha","depends_on_id":"spaxel-p5p","type":"blocks","created_at":"2026-04-06T15:31:10.594070369Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6ha","depends_on_id":"spaxel-ubu","type":"blocks","created_at":"2026-04-06T15:31:10.240906965Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"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-07T14:46:37.377695195Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-6hd","depends_on_id":"spaxel-dbd","type":"blocks","created_at":"2026-04-07T14:46:37.377627731Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6hd","depends_on_id":"spaxel-klk","type":"blocks","created_at":"2026-04-07T14:46:37.307745453Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-6hd","title":"Floor plan image upload and pixel-to-meter calibration","description":"## Overview\nAllow users to upload a floor plan image (PNG/JPG) and calibrate it to real-world coordinates so the 3D scene displays nodes and blobs at accurate physical positions.\n\n## Backend (mothership/internal/ — new floorplan.go)\n- POST /api/floorplan/image — multipart form; accept PNG/JPG max 10 MB; save to /data/floorplan/image.png\n- GET /api/floorplan/image — serve the stored image (200 or 404 if none)\n- POST /api/floorplan/calibrate — accept {ax,ay,bx,by,distance_m,rotation_deg}: two pixel coordinates and their real-world distance; compute and persist pixel-to-meter transform\n- GET /api/floorplan/calibrate — return current calibration or 404 if none\n- SQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Dashboard (dashboard/js/floorplan-setup.js)\n- Setup panel section: 'Floor Plan' with upload button\n- On image select: POST to /api/floorplan/image; display uploaded image on ground plane in 3D scene\n- Calibration UI: click point A on image → click point B → enter real-world distance in meters → Save\n- Compute pixel-to-meter scale factor: scale = distance_m / pixel_distance(A,B)\n- Apply scale and rotation to Three.js ground plane texture on load\n\n## Acceptance\n- Uploaded image displayed as ground plane texture in 3D view\n- Calibrated coordinate system maps pixel positions to correct meter positions\n- Image persists across server restart\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:42:49.829463356Z","created_by":"coding","updated_at":"2026-04-09T12:45:23.190650793Z","closed_at":"2026-04-09T12:45:23.190526084Z","close_reason":"Implementation complete. All acceptance criteria met:\n- Backend API with image upload (max 10 MB) and calibration endpoints\n- Dashboard UI with two-point calibration and pixel-to-meter scale computation\n- Viz3D integration with texture transformation for accurate positioning\n- SQLite persistence for image metadata and calibration data\n- Image file storage at /data/floorplan/image.png\n\nBoth dependent beads (spaxel-dbd dashboard UI, spaxel-klk backend API) were already CLOSED.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-6hd","depends_on_id":"spaxel-dbd","type":"blocks","created_at":"2026-04-07T14:46:37.377627731Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-6hd","depends_on_id":"spaxel-klk","type":"blocks","created_at":"2026-04-07T14:46:37.307745453Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-6n9","title":"events: internal pub/sub event bus (decouple packages from dashboard)","description":"## Overview\nCreate a lightweight Go pub/sub event bus so any internal package can emit events without importing the dashboard package directly (part 2 of spaxel-2ap split).\n\n## Implementation in mothership/internal/eventbus/ (package already exists — extend it)\n\n```go\n// bus.go\npackage eventbus\n\ntype Event struct {\n Type string\n TimestampMs int64\n Zone string\n Person string\n BlobID string\n DetailJSON interface{}\n Severity string\n}\n\ntype Handler func(Event)\n\nvar (\n mu sync.RWMutex\n handlers []Handler\n)\n\nfunc Subscribe(h Handler)\nfunc Publish(e Event)\n```\n\n- `Publish` calls all subscribers in separate goroutines (non-blocking)\n- `Subscribe` is safe to call at any time, including after startup\n- Event types to define as constants: detection, zone_entry, zone_exit, portal_crossing, trigger_fired, fall_alert, anomaly, security_alert, node_online, node_offline, ota_update, baseline_changed, system, learning_milestone\n\n## Integration\n- Have the events package's `InsertEvent` also call `eventbus.Publish`\n- Dashboard WS handler subscribes to the bus to forward events to connected clients (wired in spaxel-9eg)\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/eventbus/\nPATH=$PATH:/home/coding/go/bin go test ./internal/eventbus/\n```","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-06T22:31:07.051525693Z","created_by":"coding","updated_at":"2026-04-07T16:52:34.549568384Z","closed_at":"2026-04-07T16:52:34.549293102Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-6th","title":"Multi-link CSI fusion and localization","description":"## Background\n\nSingle-link motion detection (Phase 2) shows presence on a link axis. With 4+ links we can localise people to ±0.5–1.0m using Fresnel zone weighted localization. This is the core spatial intelligence of spaxel. The physics: WiFi CSI is most sensitive to motion within the first Fresnel zone (an ellipsoid between TX and RX). The approach: for each occupancy grid voxel, compute its weight for each link based on Fresnel zone intersection, multiply by that link's deltaRMS, sum contributions, extract blob peaks.\n\n## What to Implement\n\nNew package: mothership/internal/fusion/\n\n### OccupancyGrid\n- mothership/internal/fusion/grid.go\n- 3D float32 grid, configurable resolution (default 0.25m)\n- Dimensions from room config (width, depth, height in meters)\n- Methods: Reset(), Set(x,y,z int, val float32), Get(x,y,z int) float32, Dims() (nx,ny,nz int)\n\n### Fresnel zone geometry\n- mothership/internal/fusion/fresnel.go\n- FresnelWeight(voxelPos, txPos, rxPos vec3, wavelength float64) float64\n- For 5GHz WiFi: wavelength = 0.06m\n- A voxel is in the first Fresnel zone if: d1+d2 <= dist(tx,rx) + wavelength/2\n where d1 = dist(voxel, tx), d2 = dist(voxel, rx)\n- Weight = deltaRMS × exp(-excess_path_length² / (2×0.1²))\n where excess_path_length = (d1+d2) - dist(tx,rx)\n- Weight = 0 outside Fresnel zone\n\n### FusionEngine\n- mothership/internal/fusion/engine.go\n- Inputs: ProcessorManager (from signal package), NodeRegistry (from fleet/session)\n- Runs at 10Hz via time.Ticker\n- Each tick: reset grid, for each active link get deltaRMS from ProcessorManager, for each voxel compute FresnelWeight × deltaRMS, accumulate to grid\n- Output: call BlobExtractor.Extract(grid), broadcast via dashboard hub as 'blob_update' JSON message\n\n### BlobExtractor\n- mothership/internal/fusion/blobs.go\n- Find 3D local maxima in the grid above threshold (default 0.02)\n- Non-maximum suppression: suppress any peak within 0.5m of a higher peak\n- Output: []BlobDetection{Position vec3, Confidence float32, Radius float32}\n- Limit to max 10 blobs\n\n### Room config\n- Add to mothership config (JSON): room.width_m, room.depth_m, room.height_m (defaults: 5, 5, 2.5)\n- Node positions: initially from fleet manager, defaulting to corners if unset\n\n## Key Files\n- mothership/internal/signal/processor.go — GetAllMotionStates()\n- mothership/internal/dashboard/hub.go — Broadcast() for blob_update\n- New: mothership/internal/fusion/grid.go, fresnel.go, engine.go, blobs.go + tests\n\n## Acceptance Criteria\n- FusionEngine produces blob_update WebSocket messages at 10Hz\n- Single active link produces blob peak along the TX-RX axis\n- Two crossing links produce peak near their intersection\n- BlobExtractor correctly suppresses nearby peaks\n- FresnelWeight returns 0 for voxels clearly outside the Fresnel zone\n- go test ./internal/fusion/... passes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T03:30:50.362272102Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.188829209Z","closed_at":"2026-03-28T05:36:26.188507646Z","close_reason":"Implemented: fusion/fusion.go + fusion/grid3d.go (9c56a37) — 3D occupancy grid 0.25m res, Fresnel zone ellipsoid weighting, FusionEngine 10Hz, BlobExtractor with NMS","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-6th","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:30:50.362272102Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-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":""}]} @@ -55,7 +55,7 @@ {"id":"spaxel-csj","title":"Spatial quick actions context menu","description":"## Background\n\nThe 3D scene contains many interactive elements: humanoid track blobs, node spheres, zone cuboids, portal planes, trigger volumes, and empty space. Performing operations on these elements — labelling a track, triggering OTA, creating an automation for a zone — currently requires navigating through multiple panels and menus. The context menu provides a faster, more discoverable path: right-click or long-press on any element, and immediately see the most relevant actions for that element.\n\nThis is a UI polish bead, but one with significant usability impact for power users who frequently interact with the 3D scene.\n\n## Raycasting and Element Detection\n\nWhen a right-click (or 300ms long-press on touch) event fires on the Three.js canvas:\n1. Convert the mouse/touch position to normalised device coordinates (NDC): x = (event.clientX / canvas.width) * 2 - 1, y = -(event.clientY / canvas.height) * 2 + 1\n2. Create a THREE.Raycaster and call raycaster.setFromCamera(ndcCoords, camera)\n3. Cast the ray against the following scene object groups (in priority order, check outermost first):\n a. Track blobs and humanoid meshes (highest priority — users click on people most often)\n b. Node spheres/cylinders\n c. Zone cuboids (bounding box meshes)\n d. Portal plane meshes\n e. Trigger volume bounding boxes\n f. Ground plane (for empty space context menu — always intersects last)\n4. Take the closest intersection with a ray distance check (ignore intersections behind the camera)\n5. Identify the element type from the mesh's userData.type field (set at mesh creation time: \"track\", \"node\", \"zone\", \"portal\", \"trigger_volume\", \"ground\")\n\n## Context Menu Rendering\n\nThe context menu is an HTML div overlay (not a Three.js object) positioned at the mouse/touch coordinates. It must:\n- Appear instantly (no animation delay)\n- Stay within viewport bounds (reposition if near edges)\n- Dismiss on: Escape key, click anywhere outside, second right-click, Tab\n- Not interfere with OrbitControls (prevent camera orbit while menu is open: set controls.enabled = false while menu is visible, restore on dismiss)\n\nContext menu HTML: a `div.context-menu` with `ul.context-menu-list` containing `li.context-menu-item` elements. Each item has an icon (SVG inline) and a label. Dividers are `li.context-menu-divider`.\n\nCSS: box-shadow, border-radius, background #1e293b (dark), 14px font, 36px min item height, hover state highlight.\n\n## Menu Items Per Element Type\n\nTrack/blob:\n- \"Who is this? Assign label\" — opens the People & Devices panel filtered to this track\n- \"Follow (camera)\" — enables follow-camera mode for this track\n- \"View history\" — jumps activity timeline to this track's events (filter by track_id)\n- \"Mark as false positive\" — shortcut to the feedback form with FALSE_POSITIVE pre-selected\n- \"Explain detection\" — triggers explain mode (spaxel-ez4 explainability overlay)\n- divider\n- \"Set as unknown (anonymous)\" — removes identity assignment from this track\n\nNode:\n- \"Edit label\" — opens an inline edit field directly on the node's label in the 3D scene\n- \"View health details\" — opens the link health panel focused on this node's links\n- \"Trigger OTA update\" — triggers OTA for this specific node (with confirmation dialog if node is the last online)\n- \"Locate node (blink LED)\" — sends a blink/identify command via WebSocket to the node\n- \"Re-assign role\" — opens a role picker (TX/RX/TX-RX/passive) inline\n- divider\n- \"Remove from fleet\" — opens confirmation: \"This will disconnect Node [label] and remove its data.\"\n\nEmpty space:\n- \"Add virtual node here\" — places a new virtual node at the clicked ground position (for placement simulation)\n- \"Create zone here\" — starts zone creation mode with the clicked position as one corner\n- \"Set as home point\" — sets the coordinate origin to this position (recentres the floor plan)\n- \"Place portal here\" — starts portal creation mode centred at this position\n\nZone:\n- \"Edit zone bounds\" — enters zone edit mode (drag handles to resize)\n- \"Rename zone\" — inline rename in the zone label\n- \"View occupancy history\" — opens timeline filtered to this zone's transition events\n- \"Create automation for this zone\" — opens the automation builder with this zone pre-selected as trigger target\n- divider\n- \"Delete zone\" — with confirmation\n\nPortal:\n- \"Edit portal\" — enters portal edit mode (move/resize)\n- \"View crossing history\" — opens timeline filtered to portal crossing events for this portal\n- divider\n- \"Delete portal\" — with confirmation\n\nTrigger volume:\n- \"Edit trigger\" — opens the automation associated with this volume in the automation builder\n- \"Test fire\" — fires the automation with test_mode=true flag\n- \"Enable / Disable\" — toggles the automation's enabled flag\n- divider\n- \"Delete trigger volume\" — deletes the volume and its associated automation trigger\n\n## Follow Camera Mode\n\nWhen \"Follow (camera)\" is selected from a track's context menu:\n1. Camera enters follow mode: every render frame, camera.position and camera.target are smoothly interpolated toward a position N metres behind and M metres above the track's current position. N=3m, M=2m default (third-person camera).\n2. A \"Following: Alice\" chip appears in the top-left corner of the 3D canvas.\n3. Camera zoom and pan are disabled during follow mode (OrbitControls disabled).\n4. \"Unfollow\" button in the chip: click to exit follow mode and restore OrbitControls.\n5. If the track is deleted or becomes DELETED state: automatically exit follow mode.\n\nThe interpolation uses Three.js VectorLerp and QuaternionSlerp (or equivalent). The follow distance can be adjusted with the scroll wheel even during follow mode (override only the dolly part of OrbitControls).\n\n## Files to Create or Modify\n\n- dashboard/js/contextmenu.js: ContextMenuManager, raycasting, menu rendering, action dispatch\n- dashboard/js/camera.js (or app.js): follow camera mode logic\n- dashboard/js/contextmenu.css: context menu styles\n- dashboard/js/app.js: register right-click and long-press listeners, integrate ContextMenuManager\n\n## Tests\n\n- Test raycasting correctly identifies element type: mock scene with a \"track\" mesh and a \"node\" mesh; raycast at the track's screen position -> returns element type \"track\"\n- Test that correct menu items appear for each element type: mock scene with one of each type, right-click each, verify menu item labels match the specification\n- Test that \"Follow\" camera mode activates: verify controls.enabled = false and camera follow interpolation fires on each render frame\n- Test dismiss on Escape key, click outside, and second right-click\n- Test that menu stays within viewport: when context menu would extend beyond right edge, it repositions to left of cursor\n- Test \"Trigger OTA\" opens confirmation dialog when node is last online\n- Test \"Mark as false positive\" dispatches correct feedback event\n\n## Acceptance Criteria\n\n- Context menu appears on right-click for all element types in under 50ms\n- Correct action set shown for each element type (no irrelevant actions)\n- \"Follow\" camera mode smoothly tracks the selected track with correct camera offset\n- \"Unfollow\" exits follow mode and restores normal OrbitControls\n- All menu actions execute correctly (dispatching to the correct handler)\n- Menu repositions to stay within viewport bounds\n- Menu dismisses on Escape, click outside, and second right-click\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:01:33.863869938Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.918513652Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-csj","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.918462075Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-cxm","title":"Phase 2: Signal Processing & Detection","description":"Goal: Detect presence on a single link.\n\n3 of 6 items complete (phase sanitisation, baseline system, motion detection).\n\nRemaining:\n- Dashboard presence indicator — per-link motion detected/clear display with amplitude time series\n- CSI recording buffer — disk-backed circular buffer (48h default) for time-travel replay\n- Adaptive sensing rate — mothership-controlled rate changes (idle 2Hz ↔ active 50Hz), on-device amplitude variance check\n\nExit criteria: Dashboard reliably shows motion detected/clear for a single link. Idle links auto-drop to 2 Hz.","status":"closed","priority":2,"issue_type":"phase","assignee":"spaxel-alpha","created_at":"2026-03-27T01:55:01.708603531Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.109167705Z","closed_at":"2026-03-28T05:36:26.109107331Z","close_reason":"Phase 2 complete. All 6 deliverables implemented: phase sanitisation, baseline system, motion detection (973b0a0), dashboard presence indicator (75edd83 + spaxel-26o), CSI recording buffer (0816a5c + spaxel-hey), adaptive sensing rate (bcfd1e3 + spaxel-tim). go test ./... passes.","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-d04","title":"Implement security mode dashboard UI","description":"## Dashboard UI (dashboard/js/security-panel.js)\n\n### Security mode card (always visible in header or sidebar)\n- Arm / Disarm toggle button with confirmation dialog\n- Status badge: DISARMED / LEARNING (N days remaining) / ARMED / ALERT\n- Learning period progress bar: '5 of 7 days complete'\n- Last anomaly: '2 hours ago — kitchen motion at 3:14am'\n\n### Alert banner\n- Full-width red banner when anomaly triggered while armed\n- Description, timestamp, affected zone\n- Acknowledge button (POST /api/anomalies/{id}/acknowledge)\n\n### Anomaly timeline tab\n- List of recent anomaly events with severity, zone, timestamp\n- Links to timeline view for full context\n\n## Acceptance\n- Learning period progress updates on page refresh\n- Anomaly alert banner appears within 2s of detection\n- Acknowledged alerts disappear from the banner (not from history)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:09:35.859782007Z","created_by":"coding","updated_at":"2026-04-07T14:22:13.362922232Z","closed_at":"2026-04-07T14:22:13.362861907Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} -{"id":"spaxel-dbd","title":"Add floor plan dashboard UI","description":"## Dashboard (dashboard/js/floorplan-setup.js)\n- Setup panel section: 'Floor Plan' with upload button\n- On image select: POST to /api/floorplan/image; display uploaded image on ground plane in 3D scene\n- Calibration UI: click point A on image → click point B → enter real-world distance in meters → Save\n- Compute pixel-to-meter scale factor: scale = distance_m / pixel_distance(A,B)\n- Apply scale and rotation to Three.js ground plane texture on load\n\n## Acceptance\n- Uploaded image displayed as ground plane texture in 3D view\n- Calibrated coordinate system maps pixel positions to correct meter positions\n- Image persists across server restart","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-07T14:46:37.333473683Z","created_by":"coding","updated_at":"2026-04-09T12:29:15.527465555Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6hd"]} +{"id":"spaxel-dbd","title":"Add floor plan dashboard UI","description":"## Dashboard (dashboard/js/floorplan-setup.js)\n- Setup panel section: 'Floor Plan' with upload button\n- On image select: POST to /api/floorplan/image; display uploaded image on ground plane in 3D scene\n- Calibration UI: click point A on image → click point B → enter real-world distance in meters → Save\n- Compute pixel-to-meter scale factor: scale = distance_m / pixel_distance(A,B)\n- Apply scale and rotation to Three.js ground plane texture on load\n\n## Acceptance\n- Uploaded image displayed as ground plane texture in 3D view\n- Calibrated coordinate system maps pixel positions to correct meter positions\n- Image persists across server restart","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-07T14:46:37.333473683Z","created_by":"coding","updated_at":"2026-04-09T12:37:44.104723182Z","closed_at":"2026-04-09T12:37:44.104601579Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6hd"]} {"id":"spaxel-ez4","title":"Detection explainability overlay","description":"## Background\n\nWhen a blob appears in an unexpected position, or an alert fires that seems wrong, the first question is \"why?\" The explainability overlay answers this question visually in the 3D scene, without requiring the user to understand deltaRMS, Fresnel zones, or UKF — though the data is available for those who want it. This transforms a \"magic box\" into a comprehensible physical system.\n\nThis is also the most important debugging tool for a developer tuning the system: seeing which links contributed most to a blob position, and by how much, is the fastest path to understanding localisation errors.\n\n## ExplainabilitySnapshot\n\nThe FusionEngine (spaxel-m9a) is extended to emit an ExplainabilitySnapshot alongside each BlobUpdate. This snapshot contains all the data needed to explain why a specific blob appeared at a specific position.\n\nExplainabilitySnapshot struct (mothership/internal/fusion/explain.go):\n- blob_id: the ID of the blob being explained\n- blob_position: Vec3 — final estimated position\n- per_link_contributions: []LinkContribution\n - link_id, tx_mac, rx_mac\n - weight float64 — the geometric Fresnel weight for this blob position\n - learned_weight float64 — the learned spatial weight (from weight learner, Phase 7)\n - combined_weight float64 = weight * learned_weight\n - delta_rms float64 — the current deltaRMS for this link\n - contribution_pct float64 — percentage of total fusion score contributed by this link\n - fresnel_intersection_volume float64 — volume of Fresnel zone ellipsoid that overlaps the blob's voxel (proxy for \"how much does this link see this position\")\n- ble_match: optional — if identity is matched: {device_mac, person_id, person_label, ble_distance_m, triangulation_confidence}\n- fusion_score float64 — total occupancy grid score at blob position\n- timestamp of snapshot\n\nThe snapshot is broadcast via WebSocket as \"blob_explain\" message type, alongside the regular \"blob_update\". The frontend requests a snapshot by sending {\"type\":\"request_explain\",\"blob_id\":\"...\"} — the server then enriches the next blob update with the explain data.\n\n## 3D Explain Mode UI\n\nRight-click (desktop) or long-press (mobile, 300ms) on any blob/track in the Three.js scene triggers explain mode.\n\nScene transformation in explain mode:\n1. All link lines dim to 20% opacity (using THREE.MeshBasicMaterial.opacity)\n2. Contributing links — those with contribution_pct > 2% — increase to 100% opacity and glow with colour intensity mapped to contribution_pct (low contribution = pale blue, high contribution = bright yellow)\n3. First Fresnel zone ellipsoids rendered for each contributing link: THREE.Mesh with SphereGeometry scaled by (a, b, b) and rotated to the link axis, translucent wireframe + fill (opacity 0.1). The ellipsoid colour matches the link line colour.\n4. A \"blob explanation panel\" (sidebar overlay, not a Three.js object) shows the breakdown:\n - Blob position in metres: \"Detected at (3.2m, 1.8m, 1.0m)\"\n - Fusion score: \"Detection confidence: [N]%\"\n - Contributing links table: link name, contribution %, deltaRMS, health score — sorted by contribution descending\n - Motion sparkline: small 30-second deltaRMS chart per link (uses the recording buffer data if available, otherwise the in-memory history)\n - BLE match details: \"Identity: Alice (BLE triangulation, confidence 82%, 0.4m from blob)\"\n - If no BLE match: \"Identity: Unknown (no BLE device match)\"\n\nExit explain mode: click anywhere outside the blob, or press Escape. Scene returns to normal opacity levels.\n\n## Fresnel Ellipsoid Geometry\n\nThe first Fresnel zone ellipsoid geometry for a link:\n- TX position P1, RX position P2\n- Link distance d = |P1 - P2|\n- WiFi wavelength lambda = 0.06m (5 GHz) or 0.125m (2.4 GHz) — use the channel from the node's hello message\n- Semi-major axis: a = (d + lambda/2) / 2\n- Semi-minor axis: b = sqrt(a^2 - (d/2)^2)\n- Centre: midpoint(P1, P2)\n- Orientation: the major axis is along the P1->P2 unit vector\n\nIn Three.js: SphereGeometry with radius=1, then scale (a, b, b) with the correct rotation matrix (use THREE.Quaternion.setFromUnitVectors to align with P1->P2 direction).\n\n## Motion Sparkline\n\nFor each contributing link in the explanation panel, show a 30-second history of deltaRMS as a small canvas sparkline (using the existing amplitude history if available from the dashboard WebSocket connection, or fetching from GET /api/recordings/{link_id}/recent?seconds=30 if the recording buffer is available).\n\nThe sparkline shows the moment of detection as a vertical line at the right edge. A horizontal dashed line shows the current motion threshold. Visually conveying \"the signal crossed the threshold at this moment.\"\n\n## Files to Create or Modify\n\n- mothership/internal/fusion/explain.go: ExplainabilitySnapshot, emission logic in FusionEngine\n- mothership/internal/fusion/engine.go: extend to emit ExplainabilitySnapshot alongside BlobUpdate\n- dashboard/js/explain.js: explain mode 3D scene transforms, sidebar panel\n- dashboard/js/fresnel.js: Fresnel ellipsoid geometry helper (reused by Fresnel debug overlay bead)\n- mothership/internal/dashboard/hub.go: blob_explain WebSocket message type\n\n## Tests\n\n- Test ExplainabilitySnapshot generation: with 3 known links and a blob at a known position, verify per_link_contributions are computed correctly\n- Test contribution_pct sums to approximately 100% across all links with non-zero weight\n- Test Fresnel ellipsoid geometry: for TX at (0,0,0) and RX at (4,0,0) with lambda=0.06: a ≈ 2.015, b ≈ 0.345. Verify these values from the geometry computation.\n- Test that explain mode correctly dims/highlights links in the Three.js scene (test via scene state inspection, not visual rendering)\n- Test that WebSocket \"request_explain\" message triggers snapshot emission in the next update cycle\n- Test sidebar panel rendering with mock ExplainabilitySnapshot data\n\n## Acceptance Criteria\n\n- Right-click on any blob triggers explain mode with correct contributing link highlighting\n- Fresnel ellipsoids render at correct positions and sizes for all contributing links\n- Confidence breakdown panel shows per-link contributions that sum to 100%\n- Non-contributing links visually dimmed in explain mode\n- Motion sparklines show 30-second history for each contributing link\n- BLE match details shown when identity is available\n- Escaping explain mode restores all link opacities to normal\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:55:18.006377304Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.817464555Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-ez4","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.817442776Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-ez4","depends_on_id":"spaxel-s70","type":"blocks","created_at":"2026-03-28T01:55:20.955603637Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-fi6","title":"Implement Portals CRUD REST endpoints","description":"Implement CRUD endpoints for portals: GET/POST /api/portals, PUT/DELETE /api/portals/{id}. Include OpenAPI-style godoc comments. Portal changes must reflect in live 3D view within one WebSocket cycle.","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-07T13:56:27.334232115Z","created_by":"coding","updated_at":"2026-04-07T17:56:13.860592476Z","closed_at":"2026-04-07T17:56:13.860493596Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-21n"]} {"id":"spaxel-fll","title":"Dashboard WebSocket: snapshot-on-connect + incremental update protocol","description":"## Overview\nImplement the snapshot+incremental WebSocket protocol so the dashboard renders immediately on connect without waiting for a full state cycle.\n\n## Protocol spec\n\n### On new /ws/dashboard connection (within 100 ms):\nSend a full snapshot message:\n {type: 'snapshot', blobs: [...], nodes: [...], zones: [...], links: [...], alerts: [...], ble_devices: [...], triggers: [...], timestamp_ms: N}\n\n### Subsequent messages (at 10 Hz):\nOmit type field; send only state that changed since last tick:\n {blobs: [...], nodes: [...], confidence: 0.87, timestamp_ms: N}\nUnchanged arrays may be omitted entirely (null = no change)\n\n## Implementation (mothership/internal/dashboard/hub.go)\n\n- Hub maintains lastSnapshot: full state snapshot updated on each tick\n- On new client connection: serialize lastSnapshot as JSON, send immediately\n- On each tick: compute delta (changed fields only); broadcast to all established clients\n- Snapshot must be sent before the client is added to the broadcast list to avoid race\n\n## Reconnect handling (dashboard/js/app.js)\n- On WebSocket open: set awaitingSnapshot = true\n- On first message: if type === 'snapshot', merge into app state and clear flag\n- On subsequent messages: apply as incremental updates\n\n## Performance requirement\n- Snapshot delivery: < 100 ms after connection established, even with 10+ blobs, 16+ nodes, 20+ zones\n- Test: connect client, measure time to first render; must be < 150 ms end-to-end\n\n## Acceptance\n- Browser devtools shows first WS message with type='snapshot' within 100 ms of upgrade\n- Subsequent messages at 10 Hz omit type field\n- Reconnect after 5s disconnection shows correct current state immediately","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:09:42.683611381Z","created_by":"coding","updated_at":"2026-04-07T02:03:04.204480908Z","closed_at":"2026-04-07T02:03:04.204253757Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} @@ -72,7 +72,7 @@ {"id":"spaxel-iq3","title":"Biomechanical blob tracking with UKF","description":"## Background\n\nRaw blob detections from FusionEngine (spaxel-FUSION) are noisy — blobs appear/disappear and jump between frames. We need a filter that: persists tracks through 5-frame occlusion gaps, constrains motion to physically plausible human movement, enables identity assignment (person A vs person B), and infers posture hints for 3D visualization. The Unscented Kalman Filter (UKF) is used because the motion model is nonlinear and UKF avoids requiring a Jacobian.\n\n## What to Implement\n\nNew package: mothership/internal/tracker/\n\n### UKF State\nState vector: [x, y, z, vx, vy, vz] (6-DOF: position + velocity)\n\nProcess model: constant velocity with acceleration noise. Process noise covariance Q:\n- Position noise: sigma_pos = 0.05m (small — position doesn't jump)\n- Velocity noise: sigma_vel = 0.5 m/s per axis (humans can accelerate ~3 m/s²)\n- Z velocity noise: sigma_vz = 0.05 m/s (humans rarely move vertically fast)\n\nHuman constraints applied as post-update clamps:\n- Max speed: clamp ||v|| to 2.0 m/s\n- Z position: clamp to [0.0, 2.5m] (floor to ceiling)\n- Z velocity: clamp to [-0.5, 0.5] m/s during normal locomotion (0.2m/s for sit-down)\n\n### TrackManager\n- mothership/internal/tracker/manager.go\n- Update(blobs []fusion.BlobDetection, timestamp time.Time) []TrackState\n- Data association: Hungarian algorithm for ≤6 blobs (O(n³)), greedy nearest-neighbor for >6\n- Mahalanobis distance gating: reject assignments if distance > chi2_threshold(0.99, df=3) ≈ 11.34\n- Track lifecycle:\n - TENTATIVE: blob seen 1-2 times. Not reported in output.\n - CONFIRMED: seen ≥3 consecutive updates. Reported.\n - COASTED: no detection for 1-5 updates. UKF predict-only. Still reported.\n - DELETED: no detection for >5 updates. Removed from state.\n- Collision avoidance: if two CONFIRMED tracks within 0.5m, add a repulsion force to the weaker track\n\n### Posture inference (heuristic, no ML)\nFrom TrackState position and velocity:\n- WALKING: speed > 0.3 m/s\n- STANDING: speed < 0.1 m/s, z > 1.0m\n- SEATED: speed < 0.1 m/s, 0.3m < z < 0.8m \n- LYING: z < 0.3m (regardless of speed)\n\n### Output\nTrackState: {id string, position vec3, velocity vec3, posture Posture, confidence float32, age int (frames)}\nBroadcast via hub as 'track_update' JSON at 10Hz, same cadence as blob_update.\n\n### Integration\nFusionEngine calls TrackManager.Update() after each BlobExtractor run.\n\n## Key Files\n- mothership/internal/fusion/engine.go — call TrackManager.Update() after blob extraction\n- mothership/internal/fusion/blobs.go — BlobDetection type\n- New: mothership/internal/tracker/ukf.go, tracker/manager.go, tracker/association.go + tests\n\n## Acceptance Criteria\n- Track IDs stable across 5-frame gaps\n- Two tracks do not merge when blobs cross within 0.5m\n- Posture WALKING fires when speed > 0.3 m/s\n- TENTATIVE tracks not included in track_update output\n- Hungarian assignment correct for 4-blob test case\n- go test ./internal/tracker/... passes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T03:30:50.410975161Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.222736024Z","closed_at":"2026-03-28T05:36:26.222463200Z","close_reason":"Implemented: tracker/tracker.go + tracker/ukf.go (59404aa) — 6-DOF UKF, human motion constraints, TrackManager Hungarian association, Mahalanobis gating, posture inference (walking/standing/seated/lying)","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-iq3","depends_on_id":"spaxel-6th","type":"blocks","created_at":"2026-03-28T03:30:50.410975161Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-itf","title":"Implement image serving endpoint","description":"## Task\nImplement GET /api/floorplan/image endpoint.\n\n## Specification\n- Serve the stored image from /data/floorplan/image.png\n- Return 200 with image if exists\n- Return 404 if no image\n\n## Acceptance\n- Returns 200 with image content when image.png exists\n- Returns 404 when image.png does not exist","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:51.201971868Z","created_by":"coding","updated_at":"2026-04-07T18:47:14.323279291Z","closed_at":"2026-04-07T18:47:14.323177509Z","close_reason":"Implementation verified: GET /api/floorplan/image endpoint already implemented in mothership/internal/floorplan/floorplan.go. Returns 200 with image from /data/floorplan/image.png if exists, 404 otherwise. Tests exist for both cases.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} {"id":"spaxel-iv3","title":"Dashboard: detection explainability overlay","description":"## Overview\n\nUsers need to understand why a blob is detected where it is. The 'Why is this here?' overlay is the primary explainability tool and a key trust-building feature.\n\n## What to build\n\n### Trigger\n- Right-click (or long-press) on a blob in the 3D view → context menu includes 'Why is this here?'\n- Also accessible from the blob's info panel\n\n### Overlay (dashboard/js/explainability.js)\n- Dim all scene elements except contributing links and the selected blob (THREE.js material opacity)\n- Highlight contributing links with a glowing yellow shader (MeshLineMaterial or emissive)\n- Render Fresnel zone ellipsoids for each contributing link at reduced opacity\n- Show intersection zones where Fresnel ellipsoids overlap (this is the detection hotspot)\n\n### Sidebar panel\n- Title: 'Detection confidence: 87%'\n- Per-link contribution table:\n | Link | deltaRMS | Zone # | Weight | Contributing? |\n |------|----------|--------|--------|---------------|\n | A:B | 0.041 | 1 | 0.34 | ✓ |\n- BLE match section (if applicable): 'Matched: Alice's iPhone (96% confidence)'\n- 'Close' button restores normal scene\n\n### Data source\n- GET /api/explain/{blob_id} — returns per-link contributions, confidence breakdown, BLE match\n- Must be implemented server-side (mothership/internal/fusion or localization)\n\n## Acceptance\n\n- Overlay renders within 300ms of user action\n- Non-contributing links are visually distinct from contributing ones\n- Fresnel ellipsoids visible and correctly scaled\n- Close button fully restores scene state","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:56:27.780919021Z","created_by":"coding","updated_at":"2026-04-06T17:31:34.658876657Z","closed_at":"2026-04-06T17:31:34.658774555Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:5"]} -{"id":"spaxel-jc4","title":"Self-healing fleet with role re-optimisation","description":"## Background\n\nIn a home deployment, nodes experience disruptions: power outages causing reboots, firmware crashes, being physically moved by a curious family member, or being temporarily unplugged. The fleet manager (spaxel-8u3) handles basic node connection management. This bead adds intelligence: when the set of available nodes changes, the system recomputes the optimal role assignment — which node should be TX, which should be RX, which should be passive radar — to maximise coverage over the occupied space.\n\nThis is \"self-healing\" in the sense that the system automatically recovers from topology changes without user intervention, maintaining the best possible detection coverage with whatever nodes are currently available.\n\n## Role Assignment Background\n\nA spaxel node can operate in one of four roles:\n- RX: passive receiver, listens for CSI from other nodes\n- TX: active transmitter, sends at configured rate\n- TX-RX: both transmits and receives (most flexible but uses more power)\n- Passive: uses existing WiFi traffic as CSI source (no active transmission)\n\nA sensing link requires one TX and one RX. With N nodes, the number of possible links is bounded by the assignment. Orthogonal links (non-collinear Fresnel zones) provide better coverage than parallel links. The optimal assignment maximises the number of orthogonally-placed links covering the occupied zones.\n\n## RoleOptimiser\n\nNew struct RoleOptimiser in mothership/internal/fleet/optimiser.go.\n\nInputs:\n- NodeList: current connected nodes with their 3D positions (from placement UI, spaxel-qq6)\n- NodeCapabilities: map from MAC to {canTX bool, canRX bool, hardwareType string} (from hello message capabilities field)\n- LinkHealthScores: current health scores from ambient confidence bead (spaxel-sbi)\n- RoomConfig: room dimensions and zone occupancy from config\n\nOutput:\n- RoleAssignment: map from MAC to Role\n\nOptimisation algorithm (greedy, O(n^2) which is fine for n <= 16 nodes):\n1. Start with all nodes unassigned\n2. For each pair of nodes (A, B) that both have adequate health and are within sensing range:\n - Compute the angle between their TX-RX axis and all other assigned TX-RX axes\n - Score = sum of min angular separation to each existing assigned link axis (penalise near-parallel links)\n3. Assign the pair with the highest score as TX-RX\n4. Repeat for remaining unassigned nodes until all capable nodes are assigned\n5. For nodes that cannot improve coverage (e.g. co-located with an existing node), assign as passive\n\nThe GDOP computation from Phase 3 (spaxel-qq6 coverage painting) provides a more principled score: compute GDOP for the candidate assignment and maximise the average GDOP over occupied zones. If the GDOP computation is available, use it; otherwise fall back to the angular separation heuristic.\n\n## Graceful Degradation on Node Loss\n\nWhen a node disconnects (WebSocket closes), the fleet manager immediately:\n\n1. Marks the node as OFFLINE in the node registry\n2. Identifies which sensing links have been lost (links where the offline node was TX or RX)\n3. Calls RoleOptimiser.Optimise(currentAvailableNodes) to get the new optimal assignment\n4. Compares old GDOP to new GDOP:\n - If new GDOP >= old GDOP * 0.9 (no significant coverage degradation): apply new assignment silently\n - If new GDOP < old GDOP * 0.9 (significant coverage degradation): apply new assignment AND show dashboard warning\n5. Broadcasts RoleChange commands to all affected surviving nodes via WebSocket\n6. Broadcasts fleet_change event to dashboard with before/after GDOP overlay data\n\nDashboard warning when coverage is significantly degraded:\n\"Detection accuracy reduced — Node [label] is offline. [Zone name] coverage dropped from [N]% to [M]%. [View impact] [Dismiss]\"\nThe \"View impact\" button shows a side-by-side 3D GDOP overlay comparison.\n\n## 5-Minute Reconnect Window\n\nIf the offline node reconnects within 5 minutes of going offline:\n- Do NOT run the optimiser again\n- Restore the node's previous role assignment from the node registry\n- Send the role push command to the reconnected node\n- Clear the coverage reduction warning in the dashboard\n- Log: \"Node [label] reconnected — restoring previous role\"\n\nThis prevents unnecessary churn when nodes experience brief power blips or firmware restarts. The 5-minute window is configurable via mothership config (fleet.reconnect_grace_period_seconds, default 300).\n\n## Before/After Coverage Comparison\n\nWhen re-optimisation occurs, compute GDOP for the old and new assignments and store both in the fleet_change event:\n- gdop_before: 2D array of GDOP values over the floor plan grid with old assignment\n- gdop_after: 2D array with new assignment\n- coverage_change_pct: percentage change in occupied-zone coverage\n\nThe dashboard 3D view can render these as two overlaid heat maps (before in red, after in green, with a blend slider).\n\n## Dashboard Fleet Health Panel\n\nAdd a \"Fleet Health\" section to the dashboard (sidebar panel or dedicated route):\n- Current role assignment: table showing each node, its role, and health score\n- Coverage quality: Detection Quality gauge (from ambient confidence bead) and GDOP overlay toggle\n- Re-optimisation history: last 5 optimisation events with timestamps, trigger reason, and GDOP change\n- \"Optimise Now\" button: manually triggers the optimiser regardless of coverage change threshold\n- \"Simulate node removal\" tool: shows predicted coverage impact if a specific node were removed (useful for planning maintenance)\n\n## Files to Create or Modify\n\n- mothership/internal/fleet/optimiser.go: RoleOptimiser struct and Optimise() method\n- mothership/internal/fleet/manager.go: extend with reconnect grace period, degradation detection\n- mothership/internal/fleet/manager.go: add fleet_change event emission\n- dashboard/js/fleet.js: fleet health panel, before/after GDOP comparison view\n- mothership/internal/dashboard/routes.go: GET /api/fleet/history, POST /api/fleet/optimise\n\n## Tests\n\n- Test that role optimiser selects the most orthogonal link pair from a set of 4 nodes at known positions\n- Test graceful degradation: when a node goes offline, the optimiser produces a valid assignment for the remaining nodes\n- Test 5-minute reconnect window: reconnecting node within 300s restores previous role without re-optimisation\n- Test that the reconnect window expires correctly at 300s and the optimised assignment is kept\n- Test GDOP comparison logic: when new GDOP >= old * 0.9, no dashboard warning; when < 0.9, warning fires\n- Test fleet_change event contains correct before/after GDOP data\n\n## Acceptance Criteria\n\n- Node loss triggers role re-optimisation within 10 seconds\n- Dashboard shows coverage impact for significant degradation (>10% GDOP change)\n- Reconnecting nodes within 5 minutes restore their previous role without unnecessary re-optimisation\n- Re-optimisation does not disrupt surviving links (surviving nodes receive new role commands without dropouts)\n- Coverage comparison overlay visible in dashboard when re-optimisation is triggered\n- \"Optimise Now\" manual trigger works\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:42:17.825002481Z","created_by":"coding","updated_at":"2026-04-09T12:10:46.063222849Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:921"]} +{"id":"spaxel-jc4","title":"Self-healing fleet with role re-optimisation","description":"## Background\n\nIn a home deployment, nodes experience disruptions: power outages causing reboots, firmware crashes, being physically moved by a curious family member, or being temporarily unplugged. The fleet manager (spaxel-8u3) handles basic node connection management. This bead adds intelligence: when the set of available nodes changes, the system recomputes the optimal role assignment — which node should be TX, which should be RX, which should be passive radar — to maximise coverage over the occupied space.\n\nThis is \"self-healing\" in the sense that the system automatically recovers from topology changes without user intervention, maintaining the best possible detection coverage with whatever nodes are currently available.\n\n## Role Assignment Background\n\nA spaxel node can operate in one of four roles:\n- RX: passive receiver, listens for CSI from other nodes\n- TX: active transmitter, sends at configured rate\n- TX-RX: both transmits and receives (most flexible but uses more power)\n- Passive: uses existing WiFi traffic as CSI source (no active transmission)\n\nA sensing link requires one TX and one RX. With N nodes, the number of possible links is bounded by the assignment. Orthogonal links (non-collinear Fresnel zones) provide better coverage than parallel links. The optimal assignment maximises the number of orthogonally-placed links covering the occupied zones.\n\n## RoleOptimiser\n\nNew struct RoleOptimiser in mothership/internal/fleet/optimiser.go.\n\nInputs:\n- NodeList: current connected nodes with their 3D positions (from placement UI, spaxel-qq6)\n- NodeCapabilities: map from MAC to {canTX bool, canRX bool, hardwareType string} (from hello message capabilities field)\n- LinkHealthScores: current health scores from ambient confidence bead (spaxel-sbi)\n- RoomConfig: room dimensions and zone occupancy from config\n\nOutput:\n- RoleAssignment: map from MAC to Role\n\nOptimisation algorithm (greedy, O(n^2) which is fine for n <= 16 nodes):\n1. Start with all nodes unassigned\n2. For each pair of nodes (A, B) that both have adequate health and are within sensing range:\n - Compute the angle between their TX-RX axis and all other assigned TX-RX axes\n - Score = sum of min angular separation to each existing assigned link axis (penalise near-parallel links)\n3. Assign the pair with the highest score as TX-RX\n4. Repeat for remaining unassigned nodes until all capable nodes are assigned\n5. For nodes that cannot improve coverage (e.g. co-located with an existing node), assign as passive\n\nThe GDOP computation from Phase 3 (spaxel-qq6 coverage painting) provides a more principled score: compute GDOP for the candidate assignment and maximise the average GDOP over occupied zones. If the GDOP computation is available, use it; otherwise fall back to the angular separation heuristic.\n\n## Graceful Degradation on Node Loss\n\nWhen a node disconnects (WebSocket closes), the fleet manager immediately:\n\n1. Marks the node as OFFLINE in the node registry\n2. Identifies which sensing links have been lost (links where the offline node was TX or RX)\n3. Calls RoleOptimiser.Optimise(currentAvailableNodes) to get the new optimal assignment\n4. Compares old GDOP to new GDOP:\n - If new GDOP >= old GDOP * 0.9 (no significant coverage degradation): apply new assignment silently\n - If new GDOP < old GDOP * 0.9 (significant coverage degradation): apply new assignment AND show dashboard warning\n5. Broadcasts RoleChange commands to all affected surviving nodes via WebSocket\n6. Broadcasts fleet_change event to dashboard with before/after GDOP overlay data\n\nDashboard warning when coverage is significantly degraded:\n\"Detection accuracy reduced — Node [label] is offline. [Zone name] coverage dropped from [N]% to [M]%. [View impact] [Dismiss]\"\nThe \"View impact\" button shows a side-by-side 3D GDOP overlay comparison.\n\n## 5-Minute Reconnect Window\n\nIf the offline node reconnects within 5 minutes of going offline:\n- Do NOT run the optimiser again\n- Restore the node's previous role assignment from the node registry\n- Send the role push command to the reconnected node\n- Clear the coverage reduction warning in the dashboard\n- Log: \"Node [label] reconnected — restoring previous role\"\n\nThis prevents unnecessary churn when nodes experience brief power blips or firmware restarts. The 5-minute window is configurable via mothership config (fleet.reconnect_grace_period_seconds, default 300).\n\n## Before/After Coverage Comparison\n\nWhen re-optimisation occurs, compute GDOP for the old and new assignments and store both in the fleet_change event:\n- gdop_before: 2D array of GDOP values over the floor plan grid with old assignment\n- gdop_after: 2D array with new assignment\n- coverage_change_pct: percentage change in occupied-zone coverage\n\nThe dashboard 3D view can render these as two overlaid heat maps (before in red, after in green, with a blend slider).\n\n## Dashboard Fleet Health Panel\n\nAdd a \"Fleet Health\" section to the dashboard (sidebar panel or dedicated route):\n- Current role assignment: table showing each node, its role, and health score\n- Coverage quality: Detection Quality gauge (from ambient confidence bead) and GDOP overlay toggle\n- Re-optimisation history: last 5 optimisation events with timestamps, trigger reason, and GDOP change\n- \"Optimise Now\" button: manually triggers the optimiser regardless of coverage change threshold\n- \"Simulate node removal\" tool: shows predicted coverage impact if a specific node were removed (useful for planning maintenance)\n\n## Files to Create or Modify\n\n- mothership/internal/fleet/optimiser.go: RoleOptimiser struct and Optimise() method\n- mothership/internal/fleet/manager.go: extend with reconnect grace period, degradation detection\n- mothership/internal/fleet/manager.go: add fleet_change event emission\n- dashboard/js/fleet.js: fleet health panel, before/after GDOP comparison view\n- mothership/internal/dashboard/routes.go: GET /api/fleet/history, POST /api/fleet/optimise\n\n## Tests\n\n- Test that role optimiser selects the most orthogonal link pair from a set of 4 nodes at known positions\n- Test graceful degradation: when a node goes offline, the optimiser produces a valid assignment for the remaining nodes\n- Test 5-minute reconnect window: reconnecting node within 300s restores previous role without re-optimisation\n- Test that the reconnect window expires correctly at 300s and the optimised assignment is kept\n- Test GDOP comparison logic: when new GDOP >= old * 0.9, no dashboard warning; when < 0.9, warning fires\n- Test fleet_change event contains correct before/after GDOP data\n\n## Acceptance Criteria\n\n- Node loss triggers role re-optimisation within 10 seconds\n- Dashboard shows coverage impact for significant degradation (>10% GDOP change)\n- Reconnecting nodes within 5 minutes restore their previous role without unnecessary re-optimisation\n- Re-optimisation does not disrupt surviving links (surviving nodes receive new role commands without dropouts)\n- Coverage comparison overlay visible in dashboard when re-optimisation is triggered\n- \"Optimise Now\" manual trigger works\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-03-28T01:42:17.825002481Z","created_by":"coding","updated_at":"2026-04-09T12:45:40.147157461Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:921"]} {"id":"spaxel-jcc","title":"Reintegrate phase 6+ packages into default build","description":"## Problem\n\nmain_phase6.go uses a 'phase6' build tag which excludes all Phase 6+ code from the default binary. All backend work from Phase 6 onward is dead code that never runs.\n\n## What needs to change\n\n- Remove the 'phase6' build tag gating from main_phase6.go (or merge into cmd/mothership/main.go)\n- Ensure all packages under: automation/, events/, notify/, replay/, prediction/, learning/, analytics/, sleep/, tracker/, zones/, mqtt/ are wired into the server at startup\n- Run 'go build ./...' to confirm all packages compile cleanly\n- Run 'go test ./...' — all tests must pass\n\n## Files to check\n\n- mothership/cmd/mothership/main.go\n- mothership/cmd/mothership/main_phase6.go (if it exists)\n- Any file with '//go:build phase6'\n\n## Acceptance\n\n'go build -o /dev/null ./...' succeeds with no build tags. All routes, goroutines, and managers from phase 6 packages are initialized in main().","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:55:18.201589244Z","created_by":"coding","updated_at":"2026-04-07T06:29:44.917129307Z","closed_at":"2026-04-07T06:29:44.917067598Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","failure-count:82"],"dependencies":[{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:30:41.001290765Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:30:41.103813566Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:30:40.971412241Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:30:40.945729745Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:30:41.126328414Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:30:41.045136934Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-jcc","depends_on_id":"spaxel-x59","type":"blocks","created_at":"2026-04-06T22:30:41.167499700Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-jk0","title":"Simple mode with progressive disclosure","description":"## Background\n\nThe 3D expert interface is powerful but overwhelming for casual household members who just want to know \"is anyone home?\" or \"is the baby still asleep?\". Simple mode is a card-based, mobile-first UI that surfaces the most important information without any 3D scene. It should be usable by anyone who can read a smartphone screen, including elderly family members and non-technical partners who did not install the system.\n\nProgressive disclosure means: start with the simplest possible view, and let users reach more complexity when they want it — without being exposed to complexity they don't need.\n\n## Auto-Detection of Simple Mode\n\nSimple mode is automatically selected as the default view based on:\n1. Screen width < 768px (phones, small tablets in portrait)\n2. User-agent contains \"Mobile\" (additional phone detection signal)\n3. User has previously selected simple mode (localStorage \"spaxel_mode\" = \"simple\")\n\nExpert mode is the default for desktop browsers. A user can override the auto-detection and save their preference.\n\n## Room Occupancy Cards\n\nThe main view is a card grid (CSS Grid, 1 column on phones, 2 columns on small tablets). One card per defined zone.\n\nEach occupancy card shows:\n- Zone name (large, readable typography, min 20px font size)\n- Zone colour as a left border accent\n- Occupant count: large number\n- Named occupants: first names in a row (e.g. \"Alice, Bob\"). Anonymous tracks: \"1 person\"\n- Status icon: person silhouette if occupied, empty room icon if vacant\n- \"Last activity\" time: \"3 minutes ago\" (time since last zone transition event in this zone)\n\nAuto-update: cards update in real-time via WebSocket. When occupancy changes, the card animates briefly (a gentle pulse or highlight) to draw attention to the change.\n\nEmpty state (no zones defined): show a \"Get started\" prompt: \"Set up your rooms to see who's home. [Go to setup]\"\n\n## Activity Feed\n\nScrollable list below the zone cards (or as a separate tab). Shows the last 20 person-relevant events, filtered to exclude system noise (no node_connected, no weight_update events — only ZoneTransition, FallDetected, AnomalyDetected).\n\nEvent items: icon + one-line description + timestamp. Examples:\n- \"Alice walked into the Kitchen — 2 minutes ago\"\n- \"Bob left the house — 14 minutes ago\"\n- \"No one home since 8:32am\"\n- \"Possible fall: Alice in Hallway — 3 minutes ago [View] [Acknowledge]\"\n\nPlain English descriptions — no jargon. \"Possible fall\" not \"FallDetected event in zone_hallway_01.\"\n\nTap any event: shows a brief detail popup (not a full detail view). For zone transitions: \"Alice (via Living Room door) at 14:23\". For falls: the fall alert card with acknowledge button.\n\n## Alert Banner\n\nWhen an active unacknowledged alert exists (fall, anomaly, node offline), show a full-width banner at the top of the simple mode view:\n- Fall alert: red background, \"Possible fall — Alice in Hallway. [Acknowledge]\"\n- Anomaly (away mode): orange background, \"Movement detected while away. [View details]\"\n- Node offline: yellow background, \"Node Living Room went offline. [Help]\"\n\nAlerts are ordered by severity (fall > anomaly > node offline) if multiple are active. The [Help] button links to the troubleshooting flow (spaxel-r0l Phase 4 bead).\n\n## Sleep Summary Card\n\nA morning-only card, shown only between 6am and 11am on the day after a sleep session:\n- Position: above zone cards (highest priority in morning)\n- Content: \"Alice slept 7h 23m last night. 2 brief wake-ups. [View details]\"\n- If multiple people: stacked cards or a compact multi-person summary\n- Dismiss button: card hidden for today (localStorage flag \"spaxel_sleep_summary_{date}_shown\")\n- \"View details\" navigates to the Sleep panel in expert mode\n\n## Navigation\n\nBottom navigation bar (mobile-standard pattern): five tabs with icons + labels:\n1. Home (house icon) — occupancy cards (default tab)\n2. Activity (clock icon) — activity feed\n3. (empty centre spot for potential quick-action FAB in future)\n4. Alerts (bell icon) — active alerts and history. Badge with alert count.\n5. Settings (gear icon) — simplified settings: notification channel, person names, mode toggle\n\nThe Settings tab in simple mode shows only: display name for each person (from BLE registry), notification channel status (green = configured, grey = not set), and \"Switch to expert mode\" at the bottom.\n\n## Expert Mode Toggle\n\nA clearly labelled button: \"Expert Mode\" in the settings tab and as a persistent bottom-nav item. Tapping it:\n- If a PIN is configured (expert mode lock, settable in expert mode settings): shows PIN entry pad\n- If no PIN: immediately switches to expert mode\n- Saves preference to localStorage\n\nThe mode toggle is intentionally in settings/bottom-nav rather than prominently on the home screen — to reduce accidental switches for non-technical users.\n\n## Night Mode (OLED Dark)\n\nAuto-active during configured quiet hours (e.g. 10pm-7am). Uses CSS media query prefers-color-scheme: dark plus a manual override. OLED optimised: true black background (#000000), not just dark grey. This saves battery on OLED screens and reduces light disruption in bedrooms.\n\nAll card backgrounds in night mode: #0a0a0a (near-black). Text: #ffffff. Zone colour accents remain colourful.\n\n## Design System\n\nSimple mode uses a separate CSS file (dashboard/css/simple.css) that does NOT import from the expert mode styles. No Three.js canvas, no OrbitControls, no shader materials. Pure HTML + CSS + vanilla JS.\n\nFont size hierarchy: 14px minimum for secondary text, 16px for primary, 24px+ for zone names and counts. All interactive targets: minimum 44px height for WCAG AA touch target compliance.\n\n## Files to Create or Modify\n\n- dashboard/simple.html: simple mode HTML shell with bottom nav\n- dashboard/js/simple.js: card rendering, WebSocket updates, event feed\n- dashboard/js/simplemode.js: mode detection, localStorage mode preference\n- dashboard/css/simple.css: simple mode styles, night mode\n- mothership/internal/dashboard/routes.go: ensure /simple route is served\n\n## Tests\n\n- Test that room occupancy cards correctly reflect current zone state from WebSocket messages\n- Test activity feed filtering: inject a node_connected event and a zone_transition event; only the zone_transition should appear in simple mode feed\n- Test alert banner appears and dismisses correctly on acknowledge\n- Test mode toggle correctly switches between simple and expert mode routes\n- Test sleep summary card appears only between 6am-11am on the morning after a session\n- Test that occupancy card updates show the animated pulse on change\n- Test night mode activates based on quiet hours configuration\n\n## Acceptance Criteria\n\n- Simple mode loads correctly on a 320px wide screen (iPhone SE) without horizontal scrolling\n- Occupancy cards update in real-time as people move between zones\n- Activity feed shows only person-relevant events in plain English\n- Alert banner appears prominently and dismisses on acknowledge\n- Sleep summary card shown between 6am-11am after sleep session, dismissed when tapped\n- Mode toggle to expert mode works and saves preference\n- Night mode activates during configured quiet hours\n- All tap targets are at least 44px in height\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:59:32.690434334Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.851752276Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-jk0","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.851714754Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-jkw","title":"Add Identify context menu to 3D view","description":"Add 'Identify (blink LED)' option to the right-click context menu in the 3D view that POSTs to /api/nodes/{mac}/identify.\n\n**Acceptance:**\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T11:11:50.047388206Z","created_by":"coding","updated_at":"2026-04-09T11:32:19.559003892Z","closed_at":"2026-04-09T11:32:19.558903935Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-h58"]} @@ -140,7 +140,7 @@ {"id":"spaxel-w40","title":"Passive radar: auto-detect router AP as virtual TX node","description":"## Overview\nAutomatically detect the home router as a passive radar TX source, eliminating need for a dedicated active TX node.\n\n## Firmware changes\n- During hello message, include ap_bssid and ap_channel from esp_wifi_sta_get_ap_info()\n\n## Mothership (mothership/fleet/ or ingestion/)\n- On hello: extract ap_bssid; if >=80% of nodes report same BSSID create virtual node entry with virtual=1, position unset\n- OUI lookup: embed IEEE OUI registry as Go map compiled via go:embed; display router brand\n- Detect AP BSSID change (router reboot/replacement) and emit system alert\n- SQLite nodes table: add virtual BOOL, node_type TEXT, ap_bssid TEXT, ap_channel INT columns\n\n## Dashboard\n- After AP auto-detected: 'I detected your router (ASUS). Place it on the floor plan to improve accuracy.'\n- Drag-to-place virtual node (distinct router icon) in 3D editor\n- Confirmation dialog with 'Use as signal source' toggle\n\n## Acceptance\n- Virtual node appears in /api/nodes with virtual=true\n- 3D view renders virtual node with distinct icon\n- AP change detection fires a system event within 30s of BSSID change","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:07.745215170Z","created_by":"coding","updated_at":"2026-04-06T18:04:45.975811136Z","closed_at":"2026-04-06T18:04:45.975562593Z","close_reason":"Implemented passive radar auto-detection of router AP\n\nFirmware: Added ap_bssid/ap_channel to hello message using esp_wifi_sta_get_ap_info()\n\nMothership: Created apdetector package for >=80% BSSID agreement detection, OUI lookup for router manufacturer, AP change detection system events\n\nDashboard: AP detection notification, distinct router icon in 3D (box+4antennas), drag-to-place positioning\n\nVirtual nodes appear in /api/nodes with virtual=true, node_type=ap","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3"]} {"id":"spaxel-x59","title":"merge: remove phase6 build tag and unify main.go","description":"## Problem\n`cmd/mothership/main_phase6.go` is gated behind `//go:build phase6` which excludes all Phase 6+ code from default builds. The directory has both `main.go` (Phase 5) and `main_phase6.go` (Phase 6) — both define `package main` with `func main()`, so removing the build tag would cause a duplicate symbol error.\n\n## Prerequisites\nAll Phase 6 package compile errors must be fixed first (spaxel-glq, spaxel-9nj, spaxel-19h, spaxel-uln, spaxel-7nk, spaxel-she).\n\n## Steps\n1. Confirm all Phase 6+ packages compile cleanly:\n ```bash\n cd /home/coding/spaxel/mothership\n PATH=$PATH:/home/coding/go/bin go build ./internal/...\n ```\n2. Delete `cmd/mothership/main.go.bak` (stale backup)\n3. Delete `cmd/mothership/main.go` (Phase 5 entrypoint, superseded)\n4. Remove the `//go:build phase6` line and the blank line after it from `cmd/mothership/main_phase6.go`\n5. Build and verify:\n ```bash\n PATH=$PATH:/home/coding/go/bin go build ./...\n PATH=$PATH:/home/coding/go/bin go test ./...\n ```\n\n## Acceptance\n- `go build ./...` passes with no errors\n- Binary is built from the Phase 6 entrypoint\n- No `phase6` build tag exists anywhere in the codebase\n\nDependents:\n <- spaxel-jcc","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:32.363205812Z","created_by":"coding","updated_at":"2026-04-07T05:33:07.064388207Z","closed_at":"2026-04-07T05:33:07.064285866Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:19"],"dependencies":[{"issue_id":"spaxel-x59","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:30:41.292760872Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:30:41.351817968Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:30:41.255304103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:30:41.209121103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:30:41.390256545Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:30:41.322389944Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-xlo","title":"Create SQLite floorplan table and storage directory","description":"## Task\nCreate the floorplan table in SQLite and ensure /data/floorplan directory exists.\n\n## Schema\nSQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- /data/floorplan directory exists\n- floorplan table created in SQLite with correct schema","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:49.108738491Z","created_by":"coding","updated_at":"2026-04-07T18:21:09.020450667Z","closed_at":"2026-04-07T18:21:09.020390325Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} -{"id":"spaxel-xpk","title":"Diurnal adaptive baseline: 24-hour slot learning","description":"## Overview\nExtend the EMA baseline system with per-hour-of-day slots to eliminate false positives caused by daily environmental cycles (sunlight, HVAC, temperature changes).\n\n## Backend (mothership/signal/baseline.go extension)\n- Data structure: 24 hourly slots per link per subcarrier; each slot stores amplitude blob and sample_count\n- Learning phase (7 days): accumulate motion-free CSI into hourly slots; require >=300 samples/slot to mark ready\n- Steady state: on each fusion tick, select active baseline = weighted blend of diurnal slot (if ready) + EMA fallback\n- Crossfade: over first 15 min of each hour, linearly blend from EMA to diurnal slot; after 15 min use diurnal exclusively\n- Motion-gated updates: EMA updates continue during the hourly window, improving diurnal slot over time\n- Outlier protection: skip update if deltaRMS > motion threshold (don't train on motion frames)\n- SQLite diurnal_baselines table: link_id, hour_of_day (0-23), n_sub INT, amplitude BLOB, sample_count INT, confidence REAL, updated_at INT\n\n## Dashboard visualization\n- Per-link detail panel: 24-hour polar chart (or horizontal bar chart) showing baseline amplitude variance by hour\n- 'Diurnal learning' progress indicator: 'Learning hour 14... 6/7 days'\n- Confidence color per hour: green (ready), amber (partial), red (no data)\n\n## Acceptance\n- Baseline correctly crossfades at hour boundaries (±60s)\n- Motion events during learning do not corrupt slots (outlier protection confirmed by test)\n- Polar chart renders for links with >=1 ready slot\n- No performance regression: baseline lookup remains O(1)\n- Requires: spaxel-jcc (phase 6 integration)","status":"in_progress","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-09T12:26:09.997708333Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:137"],"dependencies":[{"issue_id":"spaxel-xpk","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T22:30:46.133690574Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-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":"in_progress","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-09T12:49:55.716908660Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:137"],"dependencies":[{"issue_id":"spaxel-xpk","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T22:30:46.133690574Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-yxr","title":"Ingestion: CSI frame validation with malformed counter and auto-close","description":"## Overview\nImplement strict CSI binary frame validation with per-connection malformed frame counters and automatic connection closure on persistent malformed input.\n\n## Validation rules (plan lines 303-324):\n- Minimum frame length: 24 bytes (header only, zero subcarriers valid)\n- Maximum frame length: 280 bytes (24 header + 128 subcarriers × 2 bytes I/Q)\n- n_sub field: must be ≤128\n- Payload length: must equal n_sub × 2 bytes exactly\n- channel: must be in [1,14] for 2.4 GHz; drop if 0 or >14\n- rssi: int8; 0 treated as invalid/missing (not an error, but log at DEBUG)\n- timestamp_us: any uint64 value accepted\n\n## Per-connection malformed counter (sliding 60-second window):\n- Track malformed_count and window_start_ms per WebSocket connection\n- On each validation failure: increment malformed_count; log at DEBUG\n- Every 60s: check counts → if malformed_count > 100: log WARN 'Node {mac} sent {N} malformed frames in 60s'\n- If malformed_count > 1000 within 60s: close WebSocket with message 'Excessive malformed frames — possible firmware bug'\n- Reset counter every 60s\n\n## Acceptance\n- Valid frame: passes all checks in <1 μs\n- Frame with n_sub=200: rejected (n_sub > 128)\n- Frame with len=10: rejected (< 24 bytes)\n- Frame with channel=0: dropped silently\n- 1001 malformed frames in 60s: connection closed with correct message\n- 101 malformed frames: WARN logged, connection kept open","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-06T16:44:21.981852269Z","created_by":"coding","updated_at":"2026-04-07T16:23:24.731432820Z","closed_at":"2026-04-07T16:23:24.731370070Z","close_reason":"Implemented CSI frame validation with DEBUG logging and performance benchmark.\n\nAll validation rules from plan lines 303-324 implemented:\n- Minimum frame length: 24 bytes ✓\n- Maximum frame length: 280 bytes ✓ \n- n_sub ≤ 128 ✓\n- Payload length = n_sub × 2 bytes ✓\n- Channel in [1,14] for 2.4 GHz ✓\n- RSSI=0 logged at DEBUG (allowed) ✓\n- timestamp_us any value ✓\n\nPer-connection malformed counter (60s sliding window):\n- DEBUG log on each validation failure ✓\n- WARN log when count > 100 ✓\n- Auto-close when count > 1000 ✓\n- Counter resets every 60s ✓\n\nAdded benchmark tests to verify <1 μs validation performance for valid frames.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} {"id":"spaxel-zpt","title":"Spatial context notifications with floor-plan thumbnails","description":"## Background\n\nPush notifications without context are ignored or disabled. \"Motion detected\" tells you nothing useful. \"Alice walked into the Kitchen — Bob is already there\" is genuinely interesting. \"Possible fall: Alice in Hallway — unacknowledged for 3 minutes\" demands immediate attention. The plan specifies server-side rendering of mini floor-plan thumbnails attached to notifications to provide instant spatial context without opening the app.\n\n## Server-Side Floor-Plan Renderer\n\nNew package: mothership/internal/render/floorplan.go\n\nThe renderer produces a top-down 2D PNG (300x300 pixels) showing:\n- Room outline: outer boundary of all zones as white rectangles on dark background\n- Zone fills: each zone as a semi-transparent coloured fill (zone.color at 20% opacity)\n- Zone labels: zone name in small white text at zone centroid\n- Node positions: small white circle dots\n- Person blobs: coloured circles (person.color) at their last-known position, diameter proportional to detection confidence (min 10px, max 20px)\n- Name labels: person name in white text above each blob circle, if identity is known\n- Portal planes: thin lines in purple (#a855f7)\n- Event highlight: the zone where the event occurred rendered with brighter fill and a white border\n\nRendering library: use github.com/fogleman/gg (a pure-Go 2D graphics library). Alternative: standard image/draw + image/png for maximum portability. The fogleman/gg approach is recommended for its higher-level drawing API (bezier curves, text, etc.).\n\nThe PNG must be generated within 200ms to not delay notification delivery. At 300x300 with simple geometry, this should be easily achievable.\n\nThe rendered PNG is stored as a []byte and passed to the notification delivery function. It is base64-encoded for attachment in webhook payloads or passed as a file to ntfy/Pushover APIs.\n\n## Notification Types and Triggers\n\n1. zone_enter: \"{{person_name}} entered {{zone_name}}\" — LOW priority unless security mode is active\n2. zone_leave: \"{{person_name}} left {{zone_name}}\" — LOW priority\n3. zone_vacant: \"{{zone_name}} is now empty\" — LOW priority\n4. fall_detected: \"Possible fall: {{person_name}} in {{zone_name}}\" — URGENT, always immediate\n5. fall_escalation: \"URGENT: Fall unacknowledged for 5 minutes — {{person_name}} in {{zone_name}}\" — URGENT\n6. anomaly_alert: \"Unexpected presence: {{zone_name}}\" — HIGH priority (breaks quiet hours)\n7. node_offline: \"Node {{node_label}} has gone offline\" — MEDIUM priority\n8. sleep_summary: \"Last night: {{sleep_duration}}\" — LOW priority, morning delivery\n\n## Smart Batching\n\nIf multiple LOW or MEDIUM priority events fire within a 30-second window, batch them into a single notification:\n- \"Alice entered Kitchen. Bob left Living Room.\"\n- \"2 presence events in the last 30 seconds.\"\n\nBatching rules:\n- Batch only events of the same priority level\n- Never batch URGENT events — those are always immediate\n- Never batch events involving different notification types if the combination is confusing\n- Batch counter: if more than 5 events in 30s, summarise as \"N presence events in the last minute\"\n\nBatching implementation: a 30-second window timer per notification channel. When the first LOW event fires, start the 30s timer. Accumulate events. On timer expiry: merge into one notification and deliver.\n\n## Quiet Hours\n\nUser-configurable quiet hours: from_time, to_time (e.g. \"22:00\" to \"07:00\"). Stored in SQLite notifications_config (channel, quiet_from, quiet_to, quiet_days_bitmask).\n\nDuring quiet hours:\n- LOW priority notifications are queued\n- MEDIUM priority notifications are queued\n- HIGH and URGENT notifications are delivered immediately regardless of quiet hours\n\nAt the end of quiet hours (07:00 on non-override days): deliver all queued notifications as a morning digest bundle: \"While you were asleep: [summary of queued events]\"\n\n## Delivery Channels\n\nntfy:\n- POST to https://ntfy.sh/{topic} (or self-hosted server URL)\n- Headers: Authorization: Bearer {token} (if configured), Priority: urgent/high/default/low/min\n- Body: the notification text\n- Headers: Attach: {base64_encoded_png_url} — for ntfy, attach the floor-plan as a URL if mothership is publicly accessible, or send as base64 data URL for local deployments\n\nPushover:\n- POST to https://api.pushover.net/1/messages.json\n- Fields: token, user, message, title, priority, attachment (PNG as multipart form upload)\n\nGeneric webhook:\n- POST to user-configured URL\n- Body: {\"event_type\":\"...\", \"message\":\"...\", \"person_id\":\"...\", \"zone_id\":\"...\", \"timestamp\":\"...\", \"floorplan_png_base64\":\"...\"}\n\n## Configuration UI\n\nDashboard Settings panel -> \"Notifications\" tab:\n- Delivery channel selector: None / ntfy / Pushover / Webhook\n- Channel-specific credential fields (ntfy server URL + topic + token, Pushover API key, webhook URL)\n- Test notification button: sends a test notification to verify configuration\n- Event type enable/disable toggles: per event type, can disable e.g. \"zone_enter\" while keeping \"fall_detected\" enabled\n- Quiet hours: time picker from/to, day-of-week selector\n- Smart batching toggle (default on)\n- \"Morning digest\" toggle (default on — delivers batched quiet-hours events at wake time)\n\n## Files to Create or Modify\n\n- mothership/internal/render/floorplan.go: floor-plan PNG renderer\n- mothership/internal/notifications/manager.go: NotificationManager, batching, quiet hours logic\n- mothership/internal/notifications/ntfy.go: ntfy delivery client\n- mothership/internal/notifications/pushover.go: Pushover delivery client\n- mothership/internal/notifications/webhook.go: generic webhook delivery\n- mothership/internal/dashboard/routes.go: GET/PUT /api/settings/notifications, POST /api/notifications/test\n\n## Tests\n\n- Test floor-plan renderer produces a 300x300 PNG with correct dimensions\n- Test that zone boundaries appear in the rendered PNG at correct coordinates (check pixel colors at known positions)\n- Test batching: 3 LOW events within 10s -> 1 notification; 1 URGENT event -> immediate even if batching timer is active\n- Test quiet hours gate: LOW event at 23:00 with quiet hours 22:00-07:00 -> queued; URGENT event at 23:00 -> delivered immediately\n- Test morning digest delivery: queued events are bundled and delivered at quiet_hours_end\n- Test ntfy delivery with mock HTTP server: verify correct headers and body format\n- Test webhook delivery with mock HTTP server: verify correct JSON body and base64 PNG field\n- Test test-notification endpoint fires correctly\n\n## Acceptance Criteria\n\n- Notification received via ntfy within 5 seconds of trigger event for URGENT priority\n- Floor-plan PNG correctly shows zone boundaries and person positions in the notification\n- Smart batching prevents more than one notification per 30-second window for LOW events\n- Quiet hours suppress LOW/MEDIUM notifications and queue them for morning digest\n- Fall detection and anomaly alerts always bypass quiet hours\n- Morning digest delivered correctly at quiet hours end\n- Test notification button correctly verifies channel configuration\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:48:19.528717849Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.371730406Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.371640840Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:48:23.948107860Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:48:23.975916991Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvb","title":"Mothership: adaptive load shedding & resource throttling","description":"## Overview\nImplement a 4-level load shedding system to keep the fusion pipeline responsive under CPU/memory pressure, especially for large fleets.\n\n## Pipeline instrumentation\n- Time each of the 8 fusion pipeline stages per iteration using time.Since()\n- Maintain 5-iteration rolling average of total iteration time (ring buffer of 5 durations)\n\n## Load shedding state machine\nLevel 0 (normal): rolling avg < 80 ms — full pipeline\nLevel 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation (~3 ms saved/iter)\nLevel 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes (~2 ms saved/iter)\nLevel 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; push rate reduction config to all nodes (10 Hz cap)\n\nRecovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level\n\n## Integration points\n- Health endpoint GET /healthz: include shedding_level (0-3) in response\n- Dashboard status bar: show 'System load: NOMINAL / LIGHT / MODERATE / HIGH'\n- WS alert when Level 3 triggered: {type: 'alert', severity: 'warning', description: 'System under load — CSI rate reduced to 10 Hz'}\n- Level 3 recovery: push config message to all nodes restoring their prior rate\n\n## Acceptance\n- Load shedding level changes logged at INFO\n- Level 3 triggers correctly when ingest channel >50% full\n- Node rate restoration confirmed after Level 3 recovery\n- Health endpoint reflects current level\n- No mutex contention from shedding logic itself (must be lock-free reads)","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-07T20:49:19.853741601Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:228"],"dependencies":[{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.124863668Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-5yq","type":"blocks","created_at":"2026-04-07T06:33:23.159852888Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index abe1f52..bf664b9 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -3ff80c294d817bffc2c455069fbc592932c211ab +281bbefb570e6972fbc45cf641f02152e4123a6d diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index a544023..00248b1 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -3154,6 +3154,11 @@ func main() { tracksHandler.RegisterRoutes(r) log.Printf("[INFO] Tracks API registered at /api/tracks") + // Diurnal baseline REST API + diurnalHandler := api.NewDiurnalHandler(pm) + diurnalHandler.RegisterRoutes(r) + log.Printf("[INFO] Diurnal baseline API registered at /api/diurnal/*") + // Backup API — streams a zip of all databases via SQLite Online Backup API backupHandler := api.NewBackupHandler(cfg.DataDir, version) r.Get("/api/backup", backupHandler.HandleBackup) diff --git a/mothership/internal/api/diurnal.go b/mothership/internal/api/diurnal.go new file mode 100644 index 0000000..60d881c --- /dev/null +++ b/mothership/internal/api/diurnal.go @@ -0,0 +1,103 @@ +// Package api provides REST API handlers for diurnal baseline data. +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/spaxel/mothership/internal/signal" +) + +// DiurnalHandler manages the diurnal baseline API endpoints. +type DiurnalHandler struct { + pm DiurnalProcessorManager +} + +// DiurnalProcessorManager defines the interface for accessing diurnal data from the signal processor. +type DiurnalProcessorManager interface { + GetDiurnalLearningStatus() []signal.DiurnalLearningStatus + GetProcessor(linkID string) DiurnalLinkProcessor +} + +// DiurnalLinkProcessor defines the interface for accessing a single link's diurnal data. +type DiurnalLinkProcessor interface { + GetDiurnal() *signal.DiurnalBaseline +} + +// NewDiurnalHandler creates a new diurnal API handler. +func NewDiurnalHandler(pm DiurnalProcessorManager) *DiurnalHandler { + return &DiurnalHandler{ + pm: pm, + } +} + +// RegisterRoutes registers diurnal endpoints. +func (h *DiurnalHandler) RegisterRoutes(r chi.Router) { + r.Get("/api/diurnal/status", h.getDiurnalStatus) + r.Get("/api/diurnal/slots/{linkID}", h.getDiurnalSlots) +} + +// getDiurnalStatus handles GET /api/diurnal/status +// Returns the diurnal learning status for all links. +func (h *DiurnalHandler) getDiurnalStatus(w http.ResponseWriter, r *http.Request) { + statuses := h.pm.GetDiurnalLearningStatus() + writeJSON(w, http.StatusOK, statuses) +} + +// getDiurnalSlots handles GET /api/diurnal/slots/{linkID} +// Returns the diurnal baseline slot data for a specific link. +func (h *DiurnalHandler) getDiurnalSlots(w http.ResponseWriter, r *http.Request) { + linkID := chi.URLParam(r, "linkID") + if linkID == "" { + writeJSONError(w, http.StatusBadRequest, "link_id is required") + return + } + + processor := h.pm.GetProcessor(linkID) + if processor == nil { + writeJSONError(w, http.StatusNotFound, "link not found") + return + } + + diurnal := processor.GetDiurnal() + if diurnal == nil { + writeJSONError(w, http.StatusNotFound, "diurnal baseline not found") + return + } + + snapshot := diurnal.GetSnapshot() + if snapshot == nil { + writeJSONError(w, http.StatusInternalServerError, "failed to get diurnal snapshot") + return + } + + // Build response with slot data + response := map[string]interface{}{ + "link_id": snapshot.LinkID, + "created_at": snapshot.Created, + "current_hour": snapshot.Created.Hour(), // For consistency with signal package + "slot_amplitudes": snapshot.SlotValues, + "slot_confidences": diurnal.GetAllSlotConfidences(), + "slot_counts": snapshot.SlotCounts, + "is_learning": diurnal.IsLearning(), + "learning_progress": diurnal.GetLearningProgress(), + "is_ready": diurnal.IsReady(), + } + + writeJSON(w, http.StatusOK, response) +} + +// writeJSON writes a JSON response. +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// writeJSONError writes a JSON error response. +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/mothership/internal/api/diurnal_test.go b/mothership/internal/api/diurnal_test.go new file mode 100644 index 0000000..a0fc544 --- /dev/null +++ b/mothership/internal/api/diurnal_test.go @@ -0,0 +1,233 @@ +// Package api provides tests for the diurnal baseline API. +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spaxel/mothership/internal/signal" +) + +// mockProcessorManager mocks the ProcessorManager interface for testing. +type mockDiurnalProcessorManager struct { + processors map[string]*mockDiurnalLinkProcessor +} + +type mockDiurnalLinkProcessor struct { + diurnal *signal.DiurnalBaseline +} + +func (m *mockDiurnalLinkProcessor) GetDiurnal() *signal.DiurnalBaseline { + return m.diurnal +} + +func (m *mockDiurnalProcessorManager) GetDiurnalLearningStatus() []signal.DiurnalLearningStatus { + // For testing, we'll create mock status directly + return []signal.DiurnalLearningStatus{ + { + LinkID: "AA:BB:CC:DD:EE:FF", + IsLearning: true, + DaysRemaining: 5.0, + Progress: 28.5, + IsReady: false, + SlotsReady: 8, + DiurnalConfidence: 0.33, + CreatedAt: time.Now().Add(-2 * 24 * time.Hour), + }, + } +} + +func (m *mockDiurnalProcessorManager) GetProcessor(linkID string) DiurnalLinkProcessor { + return m.processors[linkID] +} + +// newMockDiurnalBaseline creates a mock diurnal baseline with test data. +func newMockDiurnalBaseline() *signal.DiurnalBaseline { + db := signal.NewDiurnalBaseline("AA:BB:CC:DD:EE:FF", 64) + + // Simulate having some data by directly manipulating the slots + for i := 0; i < 24; i++ { + slot := db.GetSlot(i) + if slot != nil { + // Fill with test amplitude values + for k := 0; k < 64; k++ { + slot.Values[k] = 0.5 + float64(i)*0.01 + } + slot.SampleCount = 300 + i*10 // Start with minimum required samples + slot.LastUpdate = time.Now().Add(-time.Duration(i) * time.Hour) + } + } + + return db +} + +// Test getDiurnalStatus +func TestGetDiurnalStatus(t *testing.T) { + handler := &DiurnalHandler{ + pm: &mockDiurnalProcessorManager{}, + } + + req := httptest.NewRequest("GET", "/api/diurnal/status", nil) + w := httptest.NewRecorder() + + handler.getDiurnalStatus(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var statuses []signal.DiurnalLearningStatus + if err := json.NewDecoder(w.Body).Decode(&statuses); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(statuses) != 1 { + t.Fatalf("got %d statuses, want 1", len(statuses)) + } + + status := statuses[0] + if status.LinkID != "AA:BB:CC:DD:EE:FF" { + t.Errorf("link_id = %q, want AA:BB:CC:DD:EE:FF", status.LinkID) + } + if !status.IsLearning { + t.Error("is_learning = false, want true") + } + if status.DaysRemaining != 5.0 { + t.Errorf("days_remaining = %f, want 5.0", status.DaysRemaining) + } + if status.Progress != 28.5 { + t.Errorf("progress = %f, want 28.5", status.Progress) + } +} + +// Test getDiurnalSlots +func TestGetDiurnalSlots(t *testing.T) { + mockDiurnal := newMockDiurnalBaseline() + mockProc := &mockDiurnalLinkProcessor{diurnal: mockDiurnal} + + handler := &DiurnalHandler{ + pm: &mockDiurnalProcessorManager{ + processors: map[string]*mockDiurnalLinkProcessor{ + "AA:BB:CC:DD:EE:FF": mockProc, + }, + }, + } + + req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil) + w := httptest.NewRecorder() + + handler.getDiurnalSlots(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("decode: %v", err) + } + + if response["link_id"] != "AA:BB:CC:DD:EE:FF" { + t.Errorf("link_id = %v, want AA:BB:CC:DD:EE:FF", response["link_id"]) + } + + // Check slot_amplitudes exists and has 24 slots + slotAmplitudes, ok := response["slot_amplitudes"].([24][]float64) + if !ok { + t.Fatal("slot_amplitudes missing or wrong type") + } + + if len(slotAmplitudes) != 24 { + t.Errorf("got %d slots, want 24", len(slotAmplitudes)) + } + + // Check first slot has data + if len(slotAmplitudes[0]) != 64 { + t.Errorf("slot 0 has %d values, want 64", len(slotAmplitudes[0])) + } + + // Check confidence values exist + slotConfidences, ok := response["slot_confidences"].([]float64) + if !ok { + t.Fatal("slot_confidences missing or wrong type") + } + + if len(slotConfidences) != 24 { + t.Errorf("got %d confidences, want 24", len(slotConfidences)) + } + + // Check learning status + if response["is_learning"] != true { + t.Error("is_learning = false, want true") + } + + if response["is_ready"] != false { + t.Error("is_ready = true, want false (only 2 days old)") + } +} + +// Test getDiurnalSlots - missing linkID +func TestGetDiurnalSlots_MissingLinkID(t *testing.T) { + handler := &DiurnalHandler{ + pm: &mockDiurnalProcessorManager{}, + } + + req := httptest.NewRequest("GET", "/api/diurnal/slots/", nil) + w := httptest.NewRecorder() + + handler.getDiurnalSlots(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + + var errResp map[string]string + json.NewDecoder(w.Body).Decode(&errResp) + + if errResp["error"] == "" { + t.Error("expected error message") + } +} + +// Test getDiurnalSlots - link not found +func TestGetDiurnalSlots_LinkNotFound(t *testing.T) { + handler := &DiurnalHandler{ + pm: &mockDiurnalProcessorManager{ + processors: map[string]*mockDiurnalLinkProcessor{}, + }, + } + + req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil) + w := httptest.NewRecorder() + + handler.getDiurnalSlots(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } +} + +// Test getDiurnalSlots - nil diurnal +func TestGetDiurnalSlots_NilDiurnal(t *testing.T) { + mockProc := &mockDiurnalLinkProcessor{diurnal: nil} + + handler := &DiurnalHandler{ + pm: &mockDiurnalProcessorManager{ + processors: map[string]*mockDiurnalLinkProcessor{ + "AA:BB:CC:DD:EE:FF": mockProc, + }, + }, + } + + req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil) + w := httptest.NewRecorder() + + handler.getDiurnalSlots(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } +}