diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 75e523d..7f4e343 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -3,12 +3,14 @@ {"id":"spaxel-0w4","title":"Fleet status page","description":"## Background\n\nThe 3D scene is great for spatial context but poor for bulk fleet management tasks. With 6+ nodes, finding a specific node in the 3D view, checking its firmware version, and triggering an update involves hunting through the scene. The fleet status page provides a flat table view of all nodes with their key metrics and inline actions — the same information you would find in a server management panel, adapted for ESP32 nodes. It complements the 3D view rather than replacing it.\n\n## Fleet Status Table\n\nNew dashboard route: /fleet\n\nThe page layout:\n- Page header: \"Fleet Status\" title, total node count, online count, \"Update All\" button, \"Download report\" button\n- Filter and sort bar (below header)\n- Fleet table (main content)\n\nTable columns:\n1. Checkbox (for multi-select)\n2. Label (editable inline on double-click)\n3. MAC address (truncated, full on hover tooltip)\n4. Status: coloured dot + text. \"Online\" (green) / \"Offline\" (red) / \"Updating\" (yellow spinner)\n5. Firmware version: current version string. If a newer version is in the firmware manifest: version displayed in amber with an \"→ {new_version}\" indicator and an \"Update\" action badge.\n6. Uptime: formatted as \"3d 4h 12m\". Only valid when online.\n7. Current role: TX / RX / TX-RX / Passive. Small badge.\n8. Signal health: composite health score for this node's links as a small colour bar (green → red)\n9. Packet rate: \"{actual} / {configured} Hz\" as a fraction. Colour-coded: > 90% = green, 70-90% = amber, < 70% = red.\n10. Temperature: from health message. Shown as \"{N}°C\" or \"--\" if not reported. Alert colour if > 75°C.\n11. Actions column: [Locate] [OTA] [...more] buttons\n\nThe table rows are clickable (full row click = fly 3D camera to that node's position). Only clicking action buttons or the checkbox should not trigger the row fly-to.\n\n## Inline Label Edit\n\nDouble-clicking the label cell makes it inline-editable:\n- Input field replaces the text\n- Enter to confirm, Escape to cancel\n- Blur (click outside) to confirm\n- On confirm: PATCH /api/nodes/{mac}/label with the new label. Update the display.\n- Validation: max 32 characters, no control characters.\n\n## Action Buttons\n\n\"Locate\" (flash LED): sends a downstream command {\"type\":\"identify\"} to the node via WebSocket. The node flashes its onboard LED rapidly for 5 seconds. The button shows a spinner while the command is in-flight, then a brief green checkmark.\n\n\"OTA\" (firmware update): available only if the node's firmware version != latest in the manifest. Clicking shows a confirmation tooltip: \"Update Node [label] from v{current} to v{latest}? [Confirm] [Cancel]\". On confirm: POST /api/nodes/{mac}/ota. The node's row shows \"Updating\" status and a progress bar (populated from ota_status WebSocket messages for this node).\n\n\"More actions\" (... button): dropdown with: \"Re-assign role\", \"View health history\", \"View event history\", \"Remove from fleet\". Each with an appropriate icon.\n\n## Bulk Actions\n\nCheckbox column allows multi-selecting rows. When any row is selected, a bulk-actions bar slides in above the table:\n- \"Update {N} selected to latest firmware\" — confirms and triggers OTA for all selected nodes in sequence (with 30s stagger)\n- \"Re-assign roles\" — opens the role optimiser with the selected nodes included\n- \"Remove {N} from fleet\" — confirmation required: lists the nodes to be removed\n\nDeselect all: \"Clear selection\" button in the bulk actions bar, or uncheck all checkboxes.\n\n## Camera Fly-To\n\nClicking a table row (non-action click) triggers a smooth camera fly-to the node's position in the expert mode 3D view. The fleet page and expert mode are on different routes, so this requires:\n1. Store the target node MAC in localStorage or URL parameter (\"?highlight={mac}\")\n2. Redirect to the expert mode route / (or open expert mode in a second tab)\n3. On load, if ?highlight={mac} parameter is present, fly camera to that node's position\n\nAlternatively: if the fleet page is opened alongside the expert mode in a split-pane layout (future enhancement), coordinate via a shared state store. For Phase 9, the redirect approach is sufficient.\n\n## Sorting and Filtering\n\nColumn header click: sort by that column. First click = ascending, second = descending. Sort state shown with a small arrow indicator.\n\nFilter row (below column headers, toggle-able with a \"Filter\" button):\n- Label / MAC: text input, filters rows containing the substring\n- Status: dropdown \"All / Online / Offline\"\n- Firmware: dropdown \"All / Outdated only\"\n- Role: multi-select dropdown \"All / TX / RX / TX-RX / Passive\"\n\nActive filters: shown as chips above the table with individual dismiss buttons. \"Clear all filters\" link.\n\n## Download Report\n\n\"Download report\" button: exports the current fleet table (including all filters) as a CSV file. Columns: MAC, label, status, firmware_version, uptime_s, role, health_score, packet_rate_hz, temperature_c, last_seen.\n\nImplemented as a client-side CSV generation from the current table data (no API call needed if data is cached in the dashboard state). Use Blob + URL.createObjectURL for download.\n\n## REST API\n\nGET /api/fleet: returns all provisioned nodes with full details (same as GET /api/nodes but with more fields: uptime, firmware_version, temperature, health_score, packet_rate).\nPATCH /api/nodes/{mac}/label: update label\nPOST /api/nodes/{mac}/locate: send identify command\nPOST /api/nodes/{mac}/role: assign new role\nDELETE /api/nodes/{mac}: remove from fleet (disconnects and archives)\n\n## Files to Create or Modify\n\n- dashboard/fleet.html: fleet page HTML shell\n- dashboard/js/fleet.js: table rendering, sorting, filtering, bulk actions, inline edit\n- dashboard/css/fleet.css: fleet page styles\n- mothership/internal/dashboard/routes.go: fleet-specific API routes, /fleet HTML route\n\n## Tests\n\n- Test fleet table renders correctly with mock data: 4 nodes, verify all columns populated\n- Test inline label edit: double-click cell, type new label, Enter -> PATCH API called with correct body\n- Test bulk selection: check 3 nodes, verify bulk actions bar appears with correct count\n- Test bulk OTA triggers OTA for all 3 selected nodes\n- Test sorting: click \"Firmware version\" header -> rows sorted ascending by version string\n- Test filter: enter \"living\" in label filter -> only rows matching \"living\" visible\n- Test camera fly-to: clicking a row stores MAC in localStorage and redirects to expert mode with ?highlight parameter\n- Test CSV download: verify blob is created with correct headers and values\n\n## Acceptance Criteria\n\n- Fleet page loads with all nodes and their current metrics\n- Inline label edit saves correctly to the API and updates the display\n- Bulk OTA fires for all selected nodes with correct stagger\n- Sorting and filtering work correctly for all columns\n- Camera fly-to positions the 3D view correctly on the selected node after redirect\n- CSV download contains correct headers and all fleet data\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:06:06.562532476Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.955787324Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-0w4","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.955755499Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-16e","title":"Captive portal WiFi recovery","description":"## Background\n\nIf a node loses WiFi connectivity — because a router was replaced, credentials changed, or the node was moved out of range — it currently has no recovery path without physical intervention. The firmware already implements a captive portal AP fallback in firmware/main/wifi.c: after 10 consecutive WiFi connection failures with exponential backoff (up to 30s between attempts), the firmware transitions to AP mode with SSID \"spaxel-{last4mac}\". However, the captive portal HTTP config page served in this mode is currently a stub and needs to be completed. This bead finishes that implementation and adds the mothership-side offline detection and dashboard alert.\n\n## What Already Exists (Phase 1)\n\nfirmware/main/wifi.c implements the WIFI_STATE_CAPTIVE_PORTAL state transition after failure threshold. The firmware starts a SoftAP with SSID \"spaxel-{last4mac}\" and password-free (open AP). A stub HTTP server exists but serves only a 200 OK response with no content. The DNS server for captive portal hijacking is not yet implemented.\n\n## Captive Portal HTTP Server (Firmware)\n\nUse ESP-IDF's esp_http_server component to serve a config page at http://192.168.4.1/ when in AP mode.\n\nThe config page (served as inline HTML, no external resources — it must work fully offline):\n- Shows current provisioned SSID (from NVS, read-only display)\n- Form: new SSID input, password input, optional mothership host/port\n- Submit button: \"Save and Reconnect\"\n- On form submission: firmware writes new credentials to NVS (wifi_ssid, wifi_pass, optionally mothership_host/port) and calls esp_restart()\n- Minimal HTML with inline CSS: max 4KB so it fits in IRAM. No JavaScript needed.\n- Works on iOS Safari (no JS requirement), Android Chrome, desktop browsers\n\nThe HTTP server must handle the iOS and Android captive portal probe paths:\n- GET /hotspot-detect.html (iOS)\n- GET /generate_204 (Android)\n- GET /ncsi.txt (Windows)\nRespond to all of these with a 302 redirect to http://192.168.4.1/ to trigger the OS captive portal popup.\n\n## DNS Hijacking for Captive Portal Detection\n\nModern mobile OSes detect captive portals by making a DNS lookup for a known hostname (connectivitycheck.android.com, captive.apple.com, etc.) and checking if the response is correct. To trigger the OS captive portal popup automatically (so the user does not need to manually navigate to 192.168.4.1):\n\nImplement a minimal DNS server using lwIP's UDP API. The DNS server binds to UDP port 53 on the SoftAP interface (192.168.4.1). It responds to ALL DNS queries with an A record pointing to 192.168.4.1, regardless of the queried hostname. This causes the OS to detect the captive portal and show the popup.\n\nESP-IDF does not provide a ready-made DNS hijacking component — implement using esp_event_loop and lwIP udp_new()/udp_bind()/udp_recv() APIs. Keep the implementation minimal: parse enough of the DNS query to extract the transaction ID and question name, then build a minimal A record response.\n\n## Mothership Offline Detection and Dashboard Alert\n\nThe mothership detects node disconnection via heartbeat timeout. When a node's WebSocket closes (or health messages stop for > 30s), the mothership:\n1. Sets node status to OFFLINE in the node registry\n2. Broadcasts a node_offline WebSocket message to dashboard: {\"type\":\"node_offline\",\"mac\":\"aa:bb:cc:dd:ee:ff\",\"label\":\"Living Room\",\"last_seen\":\"2026-03-27T14:23:00Z\",\"likely_cause\":\"wifi_loss\"}\n\nThe dashboard shows an offline alert card:\n- \"Node [Living Room] went offline at 14:23. It may need WiFi reconfiguration.\"\n- Shows the captive portal AP SSID to connect to: \"Connect to Wi-Fi network 'spaxel-ff01' to reconfigure\"\n- Troubleshooting steps: 1) Check power LED, 2) Check WiFi router is online, 3) Connect to captive portal if LED is blinking\n\nThe likely_cause field is set by the mothership based on the last health message before disconnect (e.g. low WiFi RSSI in last health message -> \"wifi_range\", no recent health messages at all -> \"unknown\").\n\n## Reconnection and Portal Exit\n\nWhen the user submits new credentials in the captive portal and the node reboots:\n1. The SoftAP goes down (existing connections to \"spaxel-ff01\" are dropped)\n2. The node attempts WiFi connection with new credentials\n3. On success, connects to mothership — mothership sets status to ONLINE and broadcasts node_online event\n4. Dashboard shows reconnection notification: \"Node [Living Room] reconnected successfully\"\n\nIf the new credentials also fail (e.g. user mistyped), the node re-enters captive portal mode after another 10 failures. The portal should display an error on re-entry if the previous attempt failed.\n\n## Implementation Files\n\n- firmware/main/wifi.c: complete captive portal HTTP server, add DNS hijacking task\n- firmware/main/captive_portal.c (new): DNS hijacking task + HTTP handler functions\n- mothership/internal/ingestion/server.go: heartbeat timeout detection, node_offline event\n- mothership/internal/fleet/manager.go: node status tracking (ONLINE/OFFLINE/CAPTIVE_PORTAL)\n- dashboard/js/app.js: node_offline event handler, offline alert card rendering\n\n## Testing Challenges\n\nThis feature is difficult to unit test due to hardware dependency. Recommended approaches:\n1. Firmware: Integration test in QEMU (esp32s3 target) — simulate WiFi failures by mocking the WiFi event loop, verify state machine transitions to CAPTIVE_PORTAL after failure threshold\n2. DNS server: Unit test the DNS response builder function with a fixed query buffer and verify the response parses correctly\n3. HTTP config page: Unit test the form handler that writes to NVS (mock the NVS API)\n4. Mothership: Unit test heartbeat timeout detection with a fake time source, verify node_offline event is emitted\n\n## Acceptance Criteria\n\n- After WiFi failure (10 consecutive failures), node enters AP mode with SSID \"spaxel-{last4mac}\" within 5 minutes of initial loss\n- Mobile device connecting to the captive portal AP automatically sees the OS captive portal popup (tested on iOS 16+ and Android 12+)\n- Config page is served and functional without JavaScript\n- Updating credentials causes node to reboot and reconnect to mothership within 60s\n- Dashboard shows offline node alert within 30s of disconnection\n- Reconnection notification appears in dashboard within 30s of node reconnecting\n- Captive portal DNS server responds correctly to all DNS queries with 192.168.4.1\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:38:15.579673840Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.291892350Z","closed_at":"2026-03-28T05:36:39.291803146Z","close_reason":"Implemented: firmware/main/wifi.c (fb69190, 89 lines added) — captive portal AP mode (spaxel-XXXX SSID), esp_http_server config page at 192.168.4.1, DNS hijacking to trigger OS captive portal popup, NVS credential update on form submission","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-16e","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.901241816Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-17u","title":"Phase 9: UX Polish & Accessibility","description":"Goal: Accessible to every household member. Power user efficiency.\n\nDeliverables:\n- Simple mode (card-based mobile-first UI, room occupancy cards, activity feed)\n- Ambient dashboard mode (/ambient for wall tablets, simplified top-down, auto-dim)\n- Spatial quick actions (right-click context menus on 3D elements, follow camera)\n- Command palette (Ctrl+K universal search/command, fuzzy matching)\n- Morning briefing (daily summary card, push notification option)\n- Guided troubleshooting (proactive contextual help, post-feedback explanations)\n- Mobile-responsive expert mode (touch orbit/pan/zoom)\n- Fleet status page (full table, bulk actions, camera fly-to)\n\nExit criteria: Non-technical user can check occupancy without training. Ambient mode runs 7+ days.","status":"open","priority":3,"issue_type":"phase","created_at":"2026-03-27T01:55:55.188364609Z","created_by":"coding","updated_at":"2026-03-28T01:33:53.433798167Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-17u","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T01:33:53.433780442Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-19h","title":"fix: localization/spatial_weights.go unused variable linkID","description":"## Problem\n`internal/localization/spatial_weights.go:601` declares `linkID` but never uses it, causing build failure.\n\n## Fix\nFind line 601 in `mothership/internal/localization/spatial_weights.go` and either:\n- Remove the `linkID` variable assignment, or\n- Use the value (pass it somewhere meaningful), or\n- Replace with blank identifier: `linkID` → `_`\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/localization/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T22:29:51.085342479Z","created_by":"coding","updated_at":"2026-04-06T22:35:21.502259769Z","closed_at":"2026-04-06T22:35:21.502022402Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-1xt","title":"events: REST API GET /api/events with FTS5 search and cursor pagination","description":"## Overview\nImplement the HTTP handler for the events REST API (part 3 of spaxel-2ap split).\n\n## Endpoint\n`GET /api/events`\n\nQuery params:\n- `limit` (default 50, max 500)\n- `before` (cursor: timestamp_ms as string — keyset pagination, NOT OFFSET)\n- `after` (ISO8601 datetime)\n- `type` (filter by event type)\n- `zone` (filter by zone name)\n- `person` (filter by person label)\n- `q` (FTS5 query — prefix matching, e.g. `q=motion*`)\n\nResponse:\n```json\n{\"events\": [...], \"cursor\": \"1744000000000\", \"has_more\": true, \"total_filtered\": 42}\n```\n\n## Implementation\n- Register at `/api/events` in the existing chi router (requires spaxel-6ha is wired, but can be added independently)\n- FTS5 query via: `SELECT e.* FROM fts_events ft JOIN events e ON e.id = ft.rowid WHERE fts_events MATCH ? ORDER BY e.timestamp_ms DESC LIMIT ?`\n- Keyset: add `AND timestamp_ms < ?` when `before` cursor is provided\n- total_filtered: COUNT from same query without LIMIT\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n# Run mothership and curl:\n# curl 'http://localhost:8080/api/events?limit=10&q=detection*'\n```\n\nRequires: spaxel-4u6 (schema must exist first)","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-06T22:31:17.453879797Z","created_by":"coding","updated_at":"2026-04-07T17:27:48.091794931Z","closed_at":"2026-04-07T17:27:48.091693315Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-1xt","depends_on_id":"spaxel-4u6","type":"blocks","created_at":"2026-04-06T22:31:24.454688305Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-21n","title":"Implement Zones and Portals REST endpoints","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Implement CRUD for portals: GET/POST /api/portals, PUT/DELETE /api/portals/{id}. Changes must reflect in live 3D view within one WebSocket cycle. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-06T15:31:10.270709535Z","created_by":"coding","updated_at":"2026-04-07T19:21:31.773484992Z","closed_at":"2026-04-07T19:21:31.773069063Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"],"dependencies":[{"issue_id":"spaxel-21n","depends_on_id":"spaxel-0ii","type":"blocks","created_at":"2026-04-07T13:56:27.311077260Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-21n","depends_on_id":"spaxel-fi6","type":"blocks","created_at":"2026-04-07T13:56:27.361443212Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-26o","title":"Dashboard presence indicator","description":"## Background\n\nPhase 2 signal processing (phase sanitisation, baseline, motion detection) is complete in mothership/internal/signal/. The pipeline produces per-link MotionState (IsMotion bool, DeltaRMS float64, Confidence float64) via ProcessorManager.GetAllMotionStates(). This bead surfaces that data in the browser dashboard — the first human-visible output of the detection pipeline.\n\n## What to Implement\n\n1. Server-side: Add a periodic broadcast (every 500ms) of all link motion states as a JSON WebSocket message type 'presence_update'. Message schema: {type:'presence_update', links: {linkID: {is_motion: bool, delta_rms: float, confidence: float}}}. Wire into mothership/internal/dashboard/hub.go — Hub already has a Broadcast method.\n\n2. Frontend (dashboard/js/app.js): Add a 'Presence' panel distinct from the raw amplitude bar chart. Per-link rows showing: link ID (nodeMAC:peerMAC abbreviated), coloured circle indicator (green = clear, amber = motion, red = high-confidence motion), deltaRMS value. Click a link row to select it for the amplitude time series.\n\n3. Amplitude time series: Rolling 10s buffer of deltaRMS values per link. Render as a Canvas 2D line chart below the presence panel. X-axis: time (10s window), Y-axis: deltaRMS (0..0.1 typical range). Show threshold line at 0.02 (DefaultDeltaRMSThreshold).\n\n## Key Files\n\n- mothership/internal/signal/processor.go — GetAllMotionStates(), MotionState struct\n- mothership/internal/dashboard/hub.go — Hub.Broadcast(message interface{})\n- mothership/internal/dashboard/server.go — WebSocket handler, periodic broadcast setup\n- dashboard/js/app.js — existing UI code to extend\n\n## Acceptance Criteria\n\n- presence_update messages broadcast every 500ms with all active link states\n- Dashboard shows per-link coloured motion indicator updating in real-time\n- Amplitude time series shows last 10s of deltaRMS for selected link\n- Threshold line visible at 0.02\n- All existing tests pass (cargo check equivalent: go test ./...)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T03:29:50.484956631Z","created_by":"coding","updated_at":"2026-03-28T03:56:59.340530333Z","closed_at":"2026-03-28T03:56:59.340245276Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} -{"id":"spaxel-288","title":"Add NTP server to provisioning payload","description":"Update mothership provisioning system:\n- Read SPAXEL_NTP_SERVER env var (default: pool.ntp.org)\n- Embed ntp_server field in provisioning payload JSON\n- Support config downstream message field ntp_server to push updated server to nodes\n\nAcceptance: NTP server is configurable via provisioning payload and can be updated via downstream config messages.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-07T14:37:00.339721504Z","created_by":"coding","updated_at":"2026-04-07T17:33:15.753746511Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-u7y"]} -{"id":"spaxel-28k","title":"Add event messages to WebSocket feed","description":"Add 'event' message type to /ws/dashboard for presence transitions, zone entries/exits, and portal crossings. Broadcast: { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }. Handle in app.js onmessage. Events appear within 1s of zone transition.","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T14:18:27.377328251Z","created_by":"coding","updated_at":"2026-04-07T14:49:28.035400739Z","close_reason":"Event message type already implemented: BroadcastEvent in hub.go sends {type:event, event:{id,ts,kind,zone,blob_id,person_name}}. Frontend handleEventMessage in app.js handles zone_entry, zone_exit, portal_crossing, presence_transition. Zone/portal/presence callbacks wired in main.go and ingestion/server.go. Events broadcast within 100ms (10Hz tick). Tests pass, go vet clean.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:23","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} +{"id":"spaxel-288","title":"Add NTP server to provisioning payload","description":"Update mothership provisioning system:\n- Read SPAXEL_NTP_SERVER env var (default: pool.ntp.org)\n- Embed ntp_server field in provisioning payload JSON\n- Support config downstream message field ntp_server to push updated server to nodes\n\nAcceptance: NTP server is configurable via provisioning payload and can be updated via downstream config messages.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-07T14:37:00.339721504Z","created_by":"coding","updated_at":"2026-04-07T17:39:12.764197464Z","closed_at":"2026-04-07T17:39:12.764097159Z","close_reason":"NTP server configuration is now fully implemented:\n\n1. SPAXEL_NTP_SERVER env var read with default 'pool.ntp.org'\n2. ntp_server field embedded in provisioning payload JSON\n3. ConfigMessage supports ntp_server field for downstream updates\n4. SendNTPServerToMAC() function available to push NTP server changes to nodes\n\nThe mothership now passes the configured NTP server to nodes during provisioning and can update it at runtime via config messages.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-u7y"]} +{"id":"spaxel-28k","title":"Add event messages to WebSocket feed","description":"Add 'event' message type to /ws/dashboard for presence transitions, zone entries/exits, and portal crossings. Broadcast: { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }. Handle in app.js onmessage. Events appear within 1s of zone transition.","status":"closed","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T14:18:27.377328251Z","created_by":"coding","updated_at":"2026-04-07T15:14:57.597424097Z","closed_at":"2026-04-07T15:14:57.597088117Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:23","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-2ap","title":"Activity timeline backend: events API, FTS5 search, cursor pagination","description":"## Overview\nBackend for the unified activity timeline — event ingestion, storage, full-text search, and REST API with cursor pagination. Feeds the timeline dashboard UI (spaxel-65k).\n\n## SQLite schema\n- events table: id INTEGER PK, type TEXT, timestamp_ms INTEGER, zone TEXT, person TEXT, blob_id TEXT, detail_json TEXT, severity TEXT\n- FTS5 virtual table fts_events: content='events', content_rowid='id' — indexed on type, zone, person, detail_json\n- events_archive: same schema, auto-migrated from events when timestamp_ms < now - 90 days (nightly at 02:00 local)\n- Indexes: idx_events_ts (timestamp_ms DESC), idx_events_type, idx_events_zone, idx_events_person\n\n## Event types to ingest\ndetection, 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## REST API\n- GET /api/events — query params: limit (default 50, max 500), before (cursor), after (ISO8601), type, zone, person, q (FTS5 query)\n- Response: {events: [...], cursor: '', has_more: bool, total_filtered: int}\n- Keyset pagination using timestamp_ms as cursor (no OFFSET)\n- FTS5 query via fts_events MATCH q; fuzzy: prefix matching via q*\n\n## Event publishing\n- All event types published to dashboard WS feed as 'event' messages (requires spaxel-9eg)\n- Event bus in Go: internal publish/subscribe so any package can emit events without direct dependency on dashboard\n\n## Acceptance\n- 1000 events query <50ms with FTS5 search\n- Cursor pagination returns consistent results across pages\n- Archive job runs at 02:00 and moves events >90 days\n- FTS5 search matches partial words (prefix mode)\n- Requires: spaxel-6ha (REST API wiring), spaxel-9eg (WS event feed)","status":"closed","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T13:02:26.845982082Z","created_by":"coding","updated_at":"2026-04-07T19:32:10.083144442Z","closed_at":"2026-04-07T19:32:10.082803006Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred"],"dependencies":[{"issue_id":"spaxel-2ap","depends_on_id":"spaxel-1xt","type":"blocks","created_at":"2026-04-06T22:31:24.411971684Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-2ap","depends_on_id":"spaxel-4u6","type":"blocks","created_at":"2026-04-06T22:31:24.308501610Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-2ap","depends_on_id":"spaxel-6n9","type":"blocks","created_at":"2026-04-06T22:31:24.359488476Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-2ea","title":"Add alert messages to WebSocket feed","description":"Add 'alert' message type to /ws/dashboard for anomaly detections and security mode triggers. Broadcast: { type: 'alert', alert: { id, ts, severity, description, acknowledged } }. Handle in app.js onmessage.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T14:18:27.455727878Z","created_by":"coding","updated_at":"2026-04-07T11:04:25.716894375Z","closed_at":"2026-04-07T11:04:25.716743770Z","close_reason":"Alert message type already fully implemented: BroadcastAlert() in hub.go broadcasts {type:'alert', alert:{id,ts,severity,description,acknowledged}} to /ws/dashboard clients. Called from anomaly detection, security mode changes, and trigger-disabled alerts. Frontend handleAlertMessage() in app.js routes the alert type, shows toast notifications, logs to timeline, and triggers alert banner. Table-driven tests pass (4 cases). go vet clean.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-2wg","title":"BLE device registry and labelling","description":"## Background\n\nThe firmware scans BLE advertisements every 5 seconds and relays them to the mothership via the bidirectional protocol (spaxel-o4l, Phase 3). Each BLE relay message contains a list of {mac, name, rssi, manufacturer_data} tuples for all devices heard by that node in the last 5 seconds. Phase 6 turns this raw stream into a structured \"People and Devices\" registry where users can label their devices and associate them with named people. This is the identity layer that transforms anonymous CSI blobs into \"Alice\" and \"Bob\".\n\n## BLE Device Auto-Detection\n\nThe mothership can identify device types from manufacturer data embedded in BLE advertisement packets. The Bluetooth SIG assigns Company IDs to manufacturers; the first 2 bytes of manufacturer_data encode the company ID (little-endian).\n\nCompany IDs to detect:\n- 0x004C (Apple): likely iPhone, iPad, AirPods, or Apple Watch. Sub-type from manufacturer data length and flags.\n- 0x0006 (Microsoft): Windows devices\n- 0x0075 (Samsung): Samsung phones/tablets\n- 0x009E (Fitbit): Fitness trackers\n- 0x0157 (Garmin): GPS watches / fitness devices\n- 0x0059 (Nordic): Tile trackers (Nordic Semiconductor is used by many Tile-like devices)\n- 0x0499 (Ruuvi): Ruuvi temperature/humidity sensors\n- 0x00E0 (Google): Android devices (Nearby Share beacons)\nClassify all others as \"Unknown\". The device name field (if present in the advertisement) provides additional signal.\n\nWearable heuristic: RSSI typically -55 to -75 dBm across multiple nodes with relatively consistent signal (worn close to body). Static devices (speakers, tablets) show higher variance. Flags this heuristic as \"possibly wearable\" (not definitive).\n\n## BLERegistry\n\nNew package: mothership/internal/identity/ble.go\n\nBLERegistry struct: backed by SQLite table ble_devices.\n\nSQLite schema:\nCREATE TABLE ble_devices (\n mac TEXT PRIMARY KEY,\n name TEXT,\n manufacturer TEXT,\n device_type TEXT, -- apple_phone, apple_earbuds, fitbit, garmin, tile, samsung, unknown\n label TEXT, -- user-assigned label\n person_id TEXT, -- FK to people.id\n rssi_min INTEGER,\n rssi_max INTEGER,\n rssi_avg INTEGER,\n first_seen DATETIME,\n last_seen DATETIME,\n is_archived BOOLEAN DEFAULT FALSE,\n last_seen_node_mac TEXT\n);\n\nCREATE TABLE people (\n id TEXT PRIMARY KEY, -- uuid\n name TEXT NOT NULL,\n color TEXT, -- hex colour for dashboard rendering\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE person_devices (\n person_id TEXT,\n device_mac TEXT,\n PRIMARY KEY (person_id, device_mac)\n);\n\nBLERegistry methods:\n- ProcessRelayMessage(nodeMac string, devices []BLEDevice): upsert all devices, update last_seen, update RSSI stats\n- GetDevices(includeArchived bool) []BLEDeviceRecord\n- UpdateLabel(mac, label string) error\n- AssignToPerson(mac, personID string) error\n- CreatePerson(name, color string) (Person, error)\n- GetPeople() []Person\n- ArchiveStale(olderThan time.Duration): set is_archived=true for devices not seen for > olderThan\n\n## BLE MAC Randomisation Handling\n\nModern iPhones and Android phones randomise their BLE MAC address periodically (every 10-15 minutes for iPhones, similar for Android). This is a fundamental privacy feature. The implications for spaxel:\n\n1. The same physical phone appears as multiple different MAC addresses in the registry. The BLERegistry will create new entries for each rotated address.\n2. Long-term tracking of phones by MAC is unreliable. The registry will accumulate many entries for a single phone over time.\n3. Workarounds: (a) Apple uses Resolvable Private Addresses (RPA) that can be resolved with the Identity Resolving Key (IRK) — requires pairing, not available without user action. (b) Device name is sometimes consistent across rotations. (c) Wearable devices (Fitbit, Garmin, AirTag) typically do NOT rotate their MACs — they provide reliable long-term tracking.\n\nThe dashboard must clearly explain this limitation in the \"People and Devices\" panel:\n\"Your phone's Bluetooth address changes regularly for privacy reasons. For reliable person tracking, use a Fitbit, Garmin watch, or AirTag, which have stable addresses.\"\n\nGrouping heuristic: if two devices have the same manufacturer data prefix (first 6 bytes) and name, and were never seen simultaneously at high RSSI from the same node, they are likely the same device with a rotated MAC. Surface this as a \"possible duplicate\" suggestion in the UI: \"These may be the same device: [mac1] and [mac2]. Merge?\"\n\n## REST API\n\nGET /api/ble/devices: returns list of BLEDeviceRecord, optionally filtered by ?archived=true\nGET /api/ble/devices/{mac}: returns single device with full history\nPUT /api/ble/devices/{mac}: update label, device_type, or person assignment. Body: {\"label\":\"Alice's Phone\",\"device_type\":\"apple_phone\",\"person_id\":\"uuid-123\"}\nDELETE /api/ble/devices/{mac}: archive (not hard delete)\n\nGET /api/people: returns list of People with their associated devices\nPOST /api/people: create person. Body: {\"name\":\"Alice\",\"color\":\"#3b82f6\"}\nPUT /api/people/{id}: update name or color\nDELETE /api/people/{id}: soft-delete (retain historical data)\n\n## Dashboard Panel\n\n\"People and Devices\" sidebar panel showing:\n- People section: list of defined people with avatar (initials in circle with their color), device count, last seen time\n - Per person: click to expand, shows associated devices\n - \"Add person\" button opens inline form\n- All devices section (below people): list of devices not yet assigned to a person\n - Per device: device type icon (Apple logo, Fitbit icon, etc.), last seen node (abbreviated), last seen timestamp, RSSI bar\n - Inline label edit on double-click\n - Drag-and-drop to assign to a person card\n - Archive button (hides from active list, accessible via \"Show archived\" toggle)\n- Privacy notice: \"Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.\"\n\n## Tests\n\n- Test device auto-detection: Apple company ID 0x004C -> device_type \"apple_phone\", Fitbit 0x009E -> \"fitbit\"\n- Test that ProcessRelayMessage correctly upserts devices and updates last_seen and RSSI stats\n- Test ArchiveStale marks devices not seen for > 7 days as archived\n- Test person creation and device-to-person assignment API calls\n- Test MAC randomisation handling: two devices with same name and no simultaneous sighting are flagged as possible duplicates\n- Test that archived devices are excluded from GetDevices(false) but included in GetDevices(true)\n\n## Acceptance Criteria\n\n- Discovered BLE devices appear in the dashboard \"People and Devices\" panel within 30 seconds of first observation\n- Device type is auto-detected correctly for Apple, Fitbit, Garmin, and Samsung devices\n- User can assign labels and associate devices with named people via the dashboard UI\n- Drag-and-drop device-to-person assignment works in the UI\n- Devices not seen for > 7 days are automatically archived and hidden from the active list\n- Privacy limitation is clearly documented in the panel UI\n- Possible duplicate MAC-rotated devices are surfaced as merge suggestions\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:44:02.204633291Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.656772405Z","closed_at":"2026-03-29T18:07:39.656662663Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-2wg","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.172209347Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-32o","title":"Link weather diagnostics and repositioning advice","description":"## Background\n\nEven with good hardware and correct placement, some links will chronically underperform. A user who placed a node on a metal shelf, behind a TV, or in a corner will see consistently poor detection without understanding why. Telling users \"your detection quality is low\" is useless without telling them what to do about it. Link weather diagnostics provide root-cause analysis and specific, actionable repositioning advice — including 3D visualisation of why a link is performing poorly and where to move a node to fix it.\n\nThe name \"link weather\" is deliberate: just as weather forecasts present complex atmospheric state in human terms (\"partly cloudy with 60% chance of rain\"), link weather presents complex RF state as: \"Node A to Node B: interference detected. Likely cause: microwave oven or 2.4GHz congestion. Try moving Node B 1.5 metres to the right.\"\n\n## DiagnosticEngine\n\nNew module: mothership/internal/diagnostics/linkweather.go\n\nDiagnosticEngine runs as a background goroutine, consuming link health history from SQLite and emitting Diagnosis structs. It runs a full diagnostic pass every 15 minutes.\n\nA Diagnosis struct contains:\n- LinkID string\n- RuleID string (identifies which rule fired)\n- Severity: INFO, WARNING, ACTIONABLE\n- Title string (human-readable headline)\n- Detail string (explanation of the diagnosis in plain language)\n- Advice string (specific actionable steps)\n- RepositioningTarget *Vec3 (3D position to move the node to, or nil if repositioning is not the solution)\n- RepositioningNodeMAC string (which node to move)\n- ConfidenceScore float64 (how confident the diagnostic engine is in this diagnosis)\n\n## Diagnostic Rules\n\nRule 1: Environmental Change\nTrigger: High baseline drift (>5% per hour) correlated across multiple links simultaneously (>50% of active links).\nTitle: \"Environmental change detected\"\nDetail: \"Multiple sensing links are showing simultaneous baseline shifts. This typically indicates a temperature change, or a large object was moved in the space. The system is adapting automatically.\"\nAdvice: \"No action needed. The baseline will re-stabilise within 30 minutes.\"\nRepositioningTarget: nil\nConfidence: 0.85 if drift is correlated across >50% of links\n\nRule 2: WiFi Congestion or Distance\nTrigger: Packet rate health < 0.8 for more than 10 minutes on a single link.\nTitle: \"Node B has low signal rate\"\nDetail: \"Node [B] is only delivering [N]% of the expected [M] packets per second. The most common causes are distance from the WiFi router or congestion from nearby networks.\"\nAdvice: \"1. Move Node [B] within 10 metres of your WiFi router. 2. If already close, check if the 2.4GHz channel is congested (3+ networks on overlapping channels). 3. ESP32-S3 supports both 2.4GHz and 5GHz — if your router supports 5GHz, update Node B's WiFi config to use the 5GHz SSID.\"\nRepositioningTarget: nil (advice is router proximity, not specific coordinates)\n\nRule 3: Near-Field Metal Interference\nTrigger: Low phase stability (< 0.4) sustained for > 30 minutes during known-quiet periods.\nTitle: \"Metal interference near Node [A]\"\nDetail: \"The sensing link [A to B] has unstable phase measurements even when no one is moving. This is typically caused by metal objects in the near field of the node's antenna (within 10cm): metal shelves, radiators, TV backs, or large appliances.\"\nAdvice: \"Check for metal objects within 10cm of Node [A]. If Node [A] is on a metal surface or shelf, mount it on a non-metal bracket or wall. Try repositioning it 20-30cm away from metal surfaces.\"\nRepositioningTarget: nil (advice is clearance from metal, not a specific position)\n\nRule 4: Fresnel Zone Blockage (Half-Room Dead Zone)\nTrigger: Consistent miss rate (>30% of test walks that should be detected are missed) in a specific area of the room, AND the missing area correlates geometrically with an obstacle in the link's Fresnel zone.\nThis rule requires the feedback loop data (Phase 7, spaxel-i28) — specifically the user-submitted false negatives with position information. If no feedback data is available, this rule can trigger heuristically when one side of the room consistently shows lower blob confidence scores.\nTitle: \"Coverage gap detected — possible obstruction\"\nDetail: \"The area near [zone description] shows lower detection coverage. An obstacle may be blocking the path between Node [A] and Node [B], interrupting their sensing zone.\"\nAdvice: \"Move Node [B] [direction] by approximately [distance] to restore coverage. The target position is marked in green in the 3D view.\"\nRepositioningTarget: computed_optimal_position (see below)\n\nRule 5: Periodic Interference Spikes\nTrigger: Periodic spikes in deltaRMS variance (3-10 events per hour, each lasting 1-3 minutes) not correlated with occupancy data (no people detected moving).\nTitle: \"Periodic interference detected\"\nDetail: \"Node [A] to Node [B] is experiencing regular interference bursts [N] times per hour. This pattern is consistent with a microwave oven, a cordless phone, or a pulsed 2.4GHz source.\"\nAdvice: \"Consider the following: 1. Is Node [A] or Node [B] near a kitchen? Microwave ovens cause strong 2.4GHz interference. 2. A cordless DECT phone or baby monitor near one of the nodes may be the source. 3. Try moving the affected node at least 2 metres from any 2.4GHz appliances.\"\nRepositioningTarget: nil (interference is appliance-specific)\n\n## Repositioning Advice in 3D\n\nFor Rule 4 (Fresnel zone blockage), compute the optimal repositioning target:\n1. Use the GDOP-based coverage optimiser from Phase 5 self-healing fleet (spaxel-jc4) to compute the position that maximises GDOP for the blocked zone while keeping all other nodes fixed.\n2. The optimal position is the computed_optimal_position Vec3.\n3. In the 3D dashboard, render a \"ghost\" node at this position: translucent version of the node mesh, with a dashed line from the current position to the ghost position.\n4. Show expected GDOP improvement: \"Moving Node B here would improve detection in the east corner from [N]% to [M]%.\"\n\n## Weekly Reliability Trends\n\nStore daily health score averages in SQLite: link_health_daily (link_id TEXT, date DATE, avg_health REAL, min_health REAL, max_health REAL, PRIMARY KEY (link_id, date)).\n\nA background job runs daily at midnight and writes the day's health averages from the link health log (link_health_log table: link_id, timestamp, composite_score).\n\nDashboard shows for each link: 7-day sparkline of daily average health score. \"Best day\" annotation (highest average) and \"worst day\" annotation (lowest average). This gives users a sense of long-term reliability.\n\n## Files to Create or Modify\n\n- mothership/internal/diagnostics/linkweather.go: DiagnosticEngine and all 5 rules\n- mothership/internal/diagnostics/reposition.go: repositioning target computation\n- mothership/internal/health/linkhealth.go: add link_health_log table writes\n- dashboard/js/linkhealth.js: link health panel, diagnostics display, ghost node rendering\n- mothership/internal/dashboard/routes.go: GET /api/links/{id}/diagnostics, GET /api/links/{id}/health-history\n\n## Tests\n\n- Test Rule 1 (environmental change): inject simultaneous high-drift events across 60% of links, verify diagnosis fires with Severity=INFO\n- Test Rule 2 (WiFi congestion): inject packet_rate=0.7 for 15 minutes, verify diagnosis fires with appropriate advice text\n- Test Rule 3 (metal interference): inject phase_stability=0.3 for 35 minutes during a quiet window, verify diagnosis fires\n- Test Rule 4 (Fresnel blockage): requires feedback data — inject synthetic false-negative feedback events clustered in one spatial zone, verify diagnosis fires and RepositioningTarget is non-nil\n- Test Rule 5 (periodic interference): inject 5 deltaRMS variance spikes per hour for 2 hours, verify diagnosis fires with correct periodicity estimate\n- Test weekly trend aggregation: inject 7 days of health scores, verify daily averages are correctly computed and stored\n- Test that repositioning target is within room bounds and improves GDOP\n\n## Acceptance Criteria\n\n- All 5 diagnostic rules fire correctly on synthetic test data that matches their trigger conditions\n- Repositioning advice for Rule 4 appears as a ghost node in the 3D dashboard view\n- Expected GDOP improvement shown alongside repositioning ghost node\n- Weekly 7-day sparkline visible in link health panel for each link\n- Diagnostics accessible via API and displayed in Link Health panel on link click\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:43:13.596164634Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.683230580Z","closed_at":"2026-03-29T18:07:39.683089345Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-32o","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:14.023730499Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-3ps","title":"Detection feedback loop and accuracy tracking","description":"## Background\n\nEvery detection algorithm produces errors. False positives (detected presence when no one is there) are annoying and erode trust. False negatives (missed detection of a real person) are dangerous for safety applications. The feedback loop gives users a direct mechanism to correct errors and the system learns from those corrections. Showing users measurable improvement over time (\"You've provided 47 corrections. Accuracy improved 12% this week\") creates a virtuous engagement loop and transforms users into active participants in improving the system.\n\n## Feedback UI Elements\n\nEvery detection event exposed to the user should have feedback affordances. Three contexts:\n\n1. Dashboard 3D view: Each active track has a small thumbs-up/down icon that appears on hover/focus. Clicking thumbs-down opens a quick inline form.\n\n2. Activity timeline (Phase 8): Every detection event entry has thumbs-up/thumbs-down at the end of the row. Space-efficient: 2 icon buttons.\n\n3. Push notifications: Fall and anomaly notifications include a quick-reply option (via ntfy actions or Pushover callbacks): \"False alarm — clear this.\"\n\n4. \"I was here and wasn't detected\" button: On the timeline panel, a button \"Report missed detection\" opens a form: \"When? [time picker, default: now]\", \"Where? [zone picker]\", \"Who? [person picker, optional]\". Submits as a FALSE_NEGATIVE feedback event with the user-provided position.\n\nFeedback form for thumbs-down:\n- \"What was wrong?\" (radio buttons):\n - \"No one was there (false alarm)\"\n - \"Someone was missed at this location\"\n - \"Wrong person identified\"\n - \"Wrong zone/location\"\n- Optional free-text \"Notes\" field\n- Submit / Cancel\n\n## Feedback Storage\n\nSQLite schema:\nCREATE TABLE detection_feedback (\n id TEXT PRIMARY KEY,\n event_id TEXT, -- references events table (activity timeline)\n event_type TEXT, -- \"blob_detection\", \"zone_transition\", \"fall_alert\", \"anomaly\"\n feedback_type TEXT, -- \"TRUE_POSITIVE\", \"FALSE_POSITIVE\", \"FALSE_NEGATIVE\", \"WRONG_IDENTITY\", \"WRONG_ZONE\"\n details_json TEXT, -- {\"zone_id\":\"...\", \"person_id\":\"...\", \"notes\":\"...\"}\n timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,\n applied BOOLEAN DEFAULT FALSE, -- set to TRUE after weight refinement processes it\n processed_at DATETIME\n);\n\nThe applied flag enables incremental processing: the weight learner (Phase 7 self-improving localisation) queries WHERE applied = FALSE, processes batches, and marks them TRUE.\n\n## Accuracy Metrics\n\nCompute precision/recall/F1 per link, per zone, and per person weekly. This requires knowing the true positives, false positives, and false negatives.\n\nGround truth sources:\n- User thumbs-up -> TRUE_POSITIVE for the corresponding detection event\n- User thumbs-down (false alarm) -> FALSE_POSITIVE for the detection event\n- User \"missed detection\" report -> FALSE_NEGATIVE for the reported time/zone\n\nNote: ground truth is sparse — users will not feedback every event. We use the feedback we have as a sample. Assume events without feedback are TRUE_POSITIVE for the purpose of precision estimates (conservative: this means precision is an upper bound, not exact).\n\nMetrics computed weekly:\n- precision = TP / (TP + FP) — of all detections, what fraction were correct\n- recall = TP / (TP + FN) — of all true presence events, what fraction were detected\n- F1 = 2 * precision * recall / (precision + recall)\n- Per-link metrics: which links have the most false positives (worst precision)\n- Per-zone metrics: which zones are most often missed (worst recall)\n\nStorage: detection_accuracy (week TEXT, scope_type TEXT, scope_id TEXT, precision REAL, recall REAL, f1 REAL, tp_count INT, fp_count INT, fn_count INT, computed_at DATETIME). Scope types: \"system\", \"link\", \"zone\", \"person\".\n\n## Accuracy Trend Display\n\nDashboard \"Accuracy\" panel (in expert mode):\n- Overall accuracy gauge: composite F1 score as a circular gauge (0-100%)\n- Week-over-week trend graph: sparkline of weekly F1 over the last 8 weeks\n- \"You've provided N corrections. Your accuracy improved X% this week.\" — motivational counter\n- Per-zone breakdown: bar chart of precision/recall per zone (click a zone bar to jump to it in 3D view)\n- Per-link breakdown: link health vs. feedback score correlation (are high-health links also high-accuracy?)\n- Feedback count: total corrections given, open corrections (not yet processed), processed corrections\n\nThe accuracy trend display intentionally shows the improvement trajectory, not just the absolute value, to reinforce that feedback has an effect.\n\n## Feedback Application\n\nProcessing happens in a background goroutine (mothership/internal/learning/feedback_processor.go) that runs every 6 hours or when triggered manually.\n\nFor FALSE_POSITIVE events with associated CSI data (in the recording buffer from Phase 2):\n- Retrieve the CSI data from the recording buffer at the event timestamp for all links\n- Add the CSI frame data to a \"known false positive\" set in SQLite: false_positive_frames (link_id, timestamp, delta_rms, context_json)\n- The weight learner (self-improving localisation bead) uses this set as negative examples\n\nFor FALSE_NEGATIVE events with user-reported position:\n- Add to \"known false negative\" set: false_negative_frames (link_id, timestamp, expected_position_xyz, context_json)\n- The weight learner uses this as a positive example at the specified position\n\nAfter processing, mark feedback.applied = TRUE.\n\n## Files to Create or Modify\n\n- mothership/internal/learning/feedback_processor.go: feedback processing pipeline\n- mothership/internal/analytics/accuracy.go: weekly metric computation\n- dashboard/js/feedback.js: thumbs-up/down UI components (reusable across 3D view and timeline)\n- dashboard/js/accuracy.js: Accuracy panel rendering\n- mothership/internal/dashboard/routes.go: POST /api/feedback, GET /api/accuracy\n\n## Tests\n\n- Test feedback storage: POST /api/feedback with each feedback_type, verify SQLite record created\n- Test accuracy metric computation with synthetic TP/FP/FN data: 8 TP, 2 FP, 1 FN -> precision=0.8, recall=0.888\n- Test weekly rollup: 7 days of daily feedback -> correctly aggregated weekly metric\n- Test that applied=false events are found and marked as applied after processor run\n- Test \"improvements\" counter: feedback_count increases on each POST /api/feedback call\n\n## Acceptance Criteria\n\n- Thumbs-up/down buttons appear on active tracks in 3D view and on all timeline events\n- \"Missed detection\" button and form available in timeline panel\n- Feedback stored in SQLite with correct feedback_type and details\n- Accuracy metrics computed weekly and stored in detection_accuracy table\n- Accuracy panel shows week-over-week trend (requires at least 2 weeks of data)\n- Feedback improvement counter shows correct counts\n- Applied flag correctly set after processor run\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"sp4","created_at":"2026-03-28T01:49:50.419277632Z","created_by":"coding","updated_at":"2026-03-29T22:08:03.778130122Z","closed_at":"2026-03-29T22:08:03.778000167Z","close_reason":"Implementation complete: feedback storage (SQLite), accuracy computation (precision/recall/F1 weekly), feedback processor (6h interval), API endpoints (/api/learning/*), frontend feedback UI (thumbs up/down, missed detection form), accuracy panel (F1 gauge, sparkline, per-zone breakdown). All 12 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-3ps","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.442377218Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -20,9 +22,9 @@ {"id":"spaxel-54i","title":"load-shedding: add per-iteration timing and rolling avg to ProcessorManager","description":"## Task\nAdd pipeline timing and a 5-iteration rolling average to `ProcessorManager` in `mothership/internal/signal/processor.go`.\n\n## Changes to ProcessorManager struct (lines 221-228)\nAdd these fields:\n```go\ntype ProcessorManager struct {\n // ... existing fields ...\n iterDurations [5]time.Duration // ring buffer for last 5 iteration times\n iterIdx int // next write index (mod 5)\n iterCount int // how many values filled (0-5)\n shedLevel int // current load shedding level (0-3)\n steadyCount int // consecutive iters below recovery threshold\n}\n```\n\n## Changes to LinkProcessor.Process() (line 54)\nWrap the entire Process body to time it. At the START of Process():\n```go\nt0 := time.Now()\n```\nAt the END of Process(), before return, update the manager's ring buffer. BUT — Process is on LinkProcessor, not ProcessorManager. So instead, add timing to ProcessorManager.Process() (line 252):\n\n```go\nfunc (pm *ProcessorManager) Process(linkID string, ...) (*ProcessResult, error) {\n t0 := time.Now()\n // ... existing lock + delegate to processor ...\n result, err := processor.Process(payload, rssiDBm, nSub, recvTime)\n pm.mu.Unlock() // already have write lock\n elapsed := time.Since(t0)\n pm.updateShedding(elapsed)\n return result, err\n}\n```\n\n## New method updateShedding(elapsed time.Duration)\n```go\nfunc (pm *ProcessorManager) updateShedding(elapsed time.Duration) {\n pm.iterDurations[pm.iterIdx%5] = elapsed\n pm.iterIdx++\n if pm.iterCount < 5 { pm.iterCount++ }\n\n // compute rolling avg\n var sum time.Duration\n for i := 0; i < pm.iterCount; i++ {\n sum += pm.iterDurations[i]\n }\n avg := sum / time.Duration(pm.iterCount)\n\n // level up\n if avg >= 95*time.Millisecond && pm.shedLevel < 3 {\n pm.shedLevel = 3; pm.steadyCount = 0\n } else if avg >= 90*time.Millisecond && pm.shedLevel < 2 {\n pm.shedLevel = 2; pm.steadyCount = 0\n } else if avg >= 80*time.Millisecond && pm.shedLevel < 1 {\n pm.shedLevel = 1; pm.steadyCount = 0\n }\n\n // recovery: step down one level when avg < 60ms for 10 consecutive iters\n if avg < 60*time.Millisecond {\n pm.steadyCount++\n if pm.steadyCount >= 10 && pm.shedLevel > 0 {\n pm.shedLevel--\n pm.steadyCount = 0\n }\n } else {\n pm.steadyCount = 0\n }\n}\n```\n\n## New getter\n```go\nfunc (pm *ProcessorManager) GetShedLevel() int {\n pm.mu.RLock()\n defer pm.mu.RUnlock()\n return pm.shedLevel\n}\n```\n\nNote: `updateShedding` must NOT hold `pm.mu` because it's called after unlock. The iterDurations ring buffer is only written from `Process` so it is already serialized by the caller's lock sequence. Add a separate `mu` for the shed state or call updateShedding while still holding pm.mu — simplest: call it BEFORE Unlock, while still holding the write lock.\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n```","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-07T06:33:03.697771676Z","created_by":"coding","updated_at":"2026-04-07T16:53:42.209613205Z","closed_at":"2026-04-07T16:53:42.209404722Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-5es","title":"Ambient dashboard mode","description":"## Background\n\nA wall-mounted tablet showing who is home is a genuinely useful home appliance — a digital replacement for the \"whiteboard on the fridge\" that families have used for decades. The ambient mode is designed for exactly this use case: always on, minimal information density, visually calm, and unobtrusive. It should run for weeks without user interaction, surviving screen timeouts, browser updates, and mothership restarts. When something important happens (fall, security alert), it breaks the calm decisively to get attention.\n\n## Route and Renderer\n\nNew route: /ambient (separate from /simple and expert mode)\nThe user can set a specific device as \"ambient display\" via a browser bookmark or home screen shortcut.\n\nCanvas 2D renderer (not Three.js): the ambient mode uses a dedicated Canvas 2D rendering engine rather than Three.js. Reasons:\n1. Three.js is designed for 3D; the ambient view is a 2D top-down floor plan\n2. Canvas 2D uses significantly less GPU memory and power — important for always-on tablet use\n3. Simpler code path means fewer failure modes for long-running display\n4. Lower battery drain on iPad and Android tablets\n\nRenderer: dashboard/js/ambient_renderer.js. Renders via requestAnimationFrame at 2 Hz (one frame every 500ms). The 2 Hz update rate is intentional: it's visually smooth enough for a presence display and uses minimal CPU.\n\n## Rendered Elements\n\nBackground: colour depends on time of day (see Time-of-Day Palette below).\n\nRoom outline: 2D rectangles for each zone's bounding box (projected to floor plane). White (#ffffff) with 1px stroke, no fill (transparent interior). Zone labels: zone name in white text at centroid, 14px medium font.\n\nPortal lines: thin lines (0.5px, #a855f7 purple) across doorways.\n\nNode positions: small filled circles (radius 4px, #6b7280 grey, subtle).\n\nPerson blobs: filled circles (radius 10-18px) in person.color. Name label above: first name only, 12px white. Blob radius proportional to identity confidence: full size if confident, slightly smaller if low confidence. If anonymous: a ghost/outline circle (stroke only, no fill) in light grey.\n\nSystem status indicator: top-left corner. Small circle (8px radius): green (#22c55e) if all nodes healthy, amber (#f59e0b) if any node degraded, red (#ef4444) if any node offline. No text — just the dot. Tooltip on hover (for the rare touch-and-hold interaction).\n\nTime display: top-right. Current time in large readable font. 28px, #ffffff, tabular-nums font variant for clean time display without layout shifts.\n\nPerson positions: lerp-interpolated between WebSocket updates for smooth movement at the 2 Hz render rate. On each WebSocket message received, update the target position. On each render frame, move each person blob 20% of the remaining distance to the target position (exponential approach = naturally decelerating animation).\n\n## Auto-Dim\n\nWhen no person is detected in the room where the ambient tablet is physically located (a zone that the user has configured as the \"ambient display zone\"), reduce the canvas brightness after 60 seconds of no detection:\n- After 60s: reduce canvas globalAlpha to 0.4 (40% brightness)\n- Restore immediately when presence detected in the ambient zone again\n\nImplementation: Use the CSS filter brightness() property on the canvas element, animated with a CSS transition. Set `canvas.style.filter = 'brightness(0.4)'` after timeout. This approach correctly dims including text labels.\n\nOptionally: if the device supports it (iOS WKWebView + Power Saving APIs), the ambient mode can request Screen Wake Lock API to prevent the tablet display from sleeping. window.navigator.wakeLock.request('screen'). Re-request on visibility change (the lock is released when the page is hidden).\n\n## Alert Mode\n\nWhen a fall or security alert fires (FallDetected or AnomalyDetected event received via WebSocket):\n1. The Canvas render loop detects the alert event\n2. Full canvas background fades to #dc2626 (urgent red) over 500ms\n3. Large white text in the centre: \"FALL DETECTED — Alice\" or \"ALERT — Motion while away\"\n4. Pulsing animation: canvas background alternates between #dc2626 and #991b1b at 1 Hz\n5. Acknowledge button rendered as a large white rectangle in the centre below the text\n6. Tap/click on the acknowledge button: POST /api/fall/{id}/acknowledge or /api/anomalies/{id}/acknowledge. Returns to normal ambient mode.\n\nThis alert mode must be clearly visible from across the room — the text should be at least 48px on a typical 10-inch tablet.\n\n## Morning Briefing Overlay\n\nFirst time presence is detected after 6am (configurable): the ambient display shows a brief overlay card for 15 seconds:\n- Overlaid on the normal floor plan (dark semi-transparent background)\n- Sleep summary (if available): \"Alice: 7h 23m, good sleep\"\n- Today's expected departures (from presence prediction, Phase 7): \"Alice likely leaves at 8:30am\"\n- System status: \"4 nodes healthy\"\n\nAfter 15 seconds: fade out and return to normal ambient view. Tapping the overlay dismisses it immediately.\n\n## Time-of-Day Palette\n\nThe ambient canvas background colour shifts with the time of day to be visually appropriate:\n- 06:00-12:00 (morning): light blue-grey (#f0f4f8, near white) — cool morning light feel\n- 12:00-18:00 (afternoon): neutral grey (#1e293b, dark) — reduces eye strain in bright rooms\n- 18:00-22:00 (evening): warm amber-grey (#1c1507, very dark warm) — matches evening lighting\n- 22:00-06:00 (night): near black (#040404) — OLED-friendly, minimal light\n\nTransitions: smooth CSS gradient transition over 30 minutes at each boundary (not instant). Implemented by pre-computing the target colour for the next 30 minutes and using CSS linear-gradient + keyframe animation.\n\n## Performance\n\nTarget: < 5% CPU usage on a 2016-era iPad (A9 chip). Achieved by:\n- 2 Hz render rate instead of 60 Hz (30x reduction in GPU/CPU work)\n- No Three.js, WebGL, or shader compilation overhead\n- Canvas 2D is hardware accelerated but lightweight\n- Blob count in a home is typically 1-4 — trivial to render\n\nMemory: the ambient page should have < 50MB JS heap. No large textures, no complex geometry.\n\n## Files to Create or Modify\n\n- dashboard/ambient.html: minimal HTML shell\n- dashboard/js/ambient.js: main ambient mode logic, WebSocket connection, alert handling\n- dashboard/js/ambient_renderer.js: Canvas 2D rendering engine\n- dashboard/js/ambient_briefing.js: morning briefing overlay\n- mothership/internal/dashboard/routes.go: /ambient route served statically\n\n## Tests\n\n- Test Canvas 2D renderer draws correct shapes: zone rectangle at (1,1)-(3,3) appears as a white rectangle at the correct pixel coordinates (given known canvas size and room dimensions)\n- Test auto-dim timer: mock 60s with no presence event, verify canvas brightness is reduced\n- Test auto-dim restore: presence event arrives, verify brightness returns to 100%\n- Test alert mode: inject FallDetected event, verify canvas background changes to red and text appears\n- Test acknowledge clears alert mode and returns to normal\n- Test morning briefing overlay appears only once after 6am (localStorage flag set)\n- Test lerp interpolation: person position updates from (1,1) to (3,3), after 5 render frames should be approximately (2.5, 2.5) (with 20% step lerp)\n\n## Acceptance Criteria\n\n- Ambient mode runs for 7 days without page reload (no memory leaks, no uncaught exceptions)\n- Auto-dim activates after 60 seconds of no presence in the display zone\n- Fall/anomaly alert mode clearly visible from 3 metres away on a 10-inch tablet\n- Acknowledge button works and returns to normal ambient\n- Morning briefing overlay appears once per day, dismisses after 15s\n- Canvas 2D rendering consumes < 5% CPU on a mid-range tablet\n- Time-of-day palette transitions are smooth (no hard cuts)\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:00:34.796733529Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.888767875Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-5es","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.888731706Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-5lo","title":"Implement Zones CRUD REST endpoints with OpenAPI docs","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Include OpenAPI-style godoc comments. Acceptance: endpoints respond correctly to HTTP requests, godoc annotations present.","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:01:33.493352900Z","created_by":"coding","updated_at":"2026-04-07T18:13:38.639619498Z","closed_at":"2026-04-07T18:13:38.639505434Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-0ii"]} -{"id":"spaxel-5yq","title":"load-shedding: health endpoint + dashboard WS alert integration","description":"## Task\nExpose the load shedding level in the health endpoint and send a dashboard WS alert when Level 3 is triggered. Requires spaxel-54i to be complete (GetShedLevel() must exist).\n\n## 1. Health endpoint (mothership/cmd/mothership/main.go)\nFind the `/healthz` handler (around line 218). Change the JSON to include `shedding_level`:\n```go\nfmt.Fprintf(w, `{\"status\":\"ok\",\"version\":\"%s\",\"shedding_level\":%d}`, version, pm.GetShedLevel())\n```\n\n## 2. Dashboard WS alert on Level 3\nIn the fusion loop in main.go (the goroutine that calls `pm.Process()`), track the previous shed level and broadcast an alert when it changes to 3 or recovers:\n```go\n// after pm.Process() call:\nnewLevel := pm.GetShedLevel()\nif newLevel != prevShedLevel {\n if newLevel == 3 {\n msg := map[string]interface{}{\n \"type\": \"alert\",\n \"severity\": \"warning\",\n \"description\": \"System under load — CSI rate reduced to 10 Hz\",\n }\n data, _ := json.Marshal(msg)\n dashboardHub.Broadcast(data)\n }\n prevShedLevel = newLevel\n log.Printf(\"[INFO] Load shedding level changed: %d\", newLevel)\n}\n```\nDeclare `prevShedLevel int` before the fusion goroutine.\n\n## 3. Level 3 rate reduction push (best effort — log only if push mechanism not yet available)\nWhen `newLevel == 3`, log: `log.Printf(\"[INFO] Load shed level 3 — would push 10Hz cap to nodes\")`\nWhen `newLevel` recovers from 3, log: `log.Printf(\"[INFO] Load shed recovered — restoring prior node rate\")`\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n# curl http://localhost:8080/healthz should include shedding_level\n```\n\nRequires: spaxel-54i","status":"in_progress","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T06:33:19.278442007Z","created_by":"coding","updated_at":"2026-04-07T17:36:16.122193297Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"],"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-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":"echo","created_at":"2026-04-06T12:55:51.683246046Z","created_by":"coding","updated_at":"2026-04-07T19:45:59.602628623Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"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-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-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":""}]} @@ -30,35 +32,38 @@ {"id":"spaxel-7nk","title":"fix: sleep/handler.go cannot index interface{} map value","description":"## Problem\n`internal/sleep/handler.go` lines 229 and 232 fail with: `cannot index result[\"metrics\"] (map index expression of type interface{})`\n\nThe `result` map is of type `map[string]interface{}`, so `result[\"metrics\"]` returns `interface{}`, which cannot be directly indexed.\n\n## Fix\nAdd a type assertion before indexing. Around lines 229-232 in `internal/sleep/handler.go`:\n```go\n// Before the two if-blocks, get a typed reference:\nif metricsMap, ok := result[\"metrics\"].(map[string]interface{}); ok {\n if !metrics.SleepStartTime.IsZero() {\n metricsMap[\"sleep_start_time\"] = metrics.SleepStartTime.Format(\"15:04\")\n }\n if !metrics.SleepEndTime.IsZero() {\n metricsMap[\"sleep_end_time\"] = metrics.SleepEndTime.Format(\"15:04\")\n }\n}\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/sleep/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:05.128489582Z","created_by":"coding","updated_at":"2026-04-06T22:40:47.430043249Z","closed_at":"2026-04-06T22:40:47.429779459Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-7qo","title":"Dashboard: WebSocket reconnection with exponential backoff and state management","description":"## Overview\nImplement robust client-side WebSocket reconnection with exponential backoff, jitter, and visual state transitions during disconnects.\n\n## Reconnection logic (dashboard/js/websocket.js or app.js)\n- Backoff sequence: 1s, 2s, 4s, 8s, max 10s; ±500ms random jitter on each attempt\n- Track disconnect_duration_ms from first disconnect event\n\n## Visual state transitions:\nDisconnect < 5s: silent (no UI change); blob positions extrapolated from last velocity\nDisconnect 5-30s: 3D scene dims to 50% opacity; 'Reconnecting...' spinner in status bar; user interaction disabled\nDisconnect > 30s: non-blocking modal: 'Connection lost — [Reload Page]'; allow viewing stale scene\n\n## Blob position extrapolation (<5s):\n- On disconnect: record last_position and last_velocity per blob\n- Each animation frame: position = last_position + last_velocity × elapsed_s (capped at 2s extrapolation)\n\n## On successful reconnect:\n- Clear all blob trails (path history lines)\n- Apply snapshot from first WebSocket message (spaxel-fll)\n- Restore scene opacity to 100%\n- Dismiss spinner and modal\n- Log 'Reconnected after Xs' to console\n\n## Acceptance\n- Disconnect for 3s: no visual change; blobs continue moving smoothly\n- Disconnect for 10s: scene dims, spinner shown\n- Disconnect for 35s: modal appears; scene still visible\n- Reconnect: modal dismissed, trails cleared, scene snaps to current state within 200ms\n- Requires: spaxel-fll (snapshot protocol), spaxel-896 (panel framework)","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-06T16:44:33.446200584Z","created_by":"coding","updated_at":"2026-04-07T16:45:31.143915724Z","closed_at":"2026-04-07T16:45:31.143823228Z","close_reason":"WebSocket reconnection with exponential backoff and visual state management was already fully implemented in commit ff3428f. All acceptance criteria met: backoff 1s-10s with ±500ms jitter, <5s silent extrapolation, 5-30s dimming with spinner, >30s modal with reload button, reconnect clears trails and restores scene from snapshot.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-7x2","title":"Wire anomaly detection & security mode API endpoints","description":"## Backend\n\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## Acceptance\n- Endpoints return correct JSON structure\n- Anomaly events push to WS feed as 'alert' messages\n- Arm/disarm state persists across server restarts","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T16:09:35.812256758Z","created_by":"coding","updated_at":"2026-04-07T19:17:55.756354374Z","closed_at":"2026-04-07T19:17:55.756259632Z","close_reason":"All acceptance criteria verified and already committed in b1c2218. AnomalyDetector initialized in main() with 6h periodic updates. Anomaly events broadcast to dashboard WS as alert messages. GET /api/anomalies?since=24h, POST /api/security/arm, POST /api/security/disarm, GET /api/security/status all wired and tested. Arm/disarm state persisted to learning_state table and restored on restart. All 14 related tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} -{"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-03-30T16:27:42.698511Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} -{"id":"spaxel-896","title":"Build dashboard panel/modal/sidebar UI framework","description":"## Problem\n\nThe dashboard is currently a single live 3D view with no panel system. All Phase 6-9 UI work (automation builder, timeline, explainability, settings, notifications, presence predictions) requires a panel/sidebar framework to hang on.\n\n## What to build\n\n### Panel System (dashboard/js/panels.js)\n- Slide-in sidebar (right, 360px) with close button and title\n- Modal overlay (centered, 600px wide) for forms and wizards\n- Toast notification stack (bottom-right)\n- Panel registry: panels can be opened by name from anywhere in the app\n\n### Route/Mode Navigation (dashboard/js/router.js)\n- Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay\n- Mode toggle bar in the header: Live | Timeline | Automations | Settings\n- Active mode preserved across page refresh (localStorage)\n\n### State Management (dashboard/js/state.js)\n- Central app state object (nodes, blobs, zones, links, alerts, events, ble_devices, triggers)\n- Subscribe/notify pattern for components to react to state changes\n- Separate from WebSocket message parsing\n\n### Settings Panel (dashboard/js/settings-panel.js)\n- Motion threshold slider\n- Sensing rate override\n- Notification channel config (Ntfy URL, Pushover token)\n- System info (version, uptime, node count)\n\n## Acceptance\n\n- Panel opens/closes smoothly with CSS transitions\n- Route changes update the active view without page reload\n- Settings panel reads from GET /api/settings and saves via POST /api/settings\n- All existing 3D live view functionality unaffected","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-06T12:55:28.903260636Z","created_by":"coding","updated_at":"2026-04-06T12:55:28.903260636Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"hotel","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-04-09T09:52:52.420321026Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4"]} +{"id":"spaxel-896","title":"Build dashboard panel/modal/sidebar UI framework","description":"## Problem\n\nThe dashboard is currently a single live 3D view with no panel system. All Phase 6-9 UI work (automation builder, timeline, explainability, settings, notifications, presence predictions) requires a panel/sidebar framework to hang on.\n\n## What to build\n\n### Panel System (dashboard/js/panels.js)\n- Slide-in sidebar (right, 360px) with close button and title\n- Modal overlay (centered, 600px wide) for forms and wizards\n- Toast notification stack (bottom-right)\n- Panel registry: panels can be opened by name from anywhere in the app\n\n### Route/Mode Navigation (dashboard/js/router.js)\n- Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay\n- Mode toggle bar in the header: Live | Timeline | Automations | Settings\n- Active mode preserved across page refresh (localStorage)\n\n### State Management (dashboard/js/state.js)\n- Central app state object (nodes, blobs, zones, links, alerts, events, ble_devices, triggers)\n- Subscribe/notify pattern for components to react to state changes\n- Separate from WebSocket message parsing\n\n### Settings Panel (dashboard/js/settings-panel.js)\n- Motion threshold slider\n- Sensing rate override\n- Notification channel config (Ntfy URL, Pushover token)\n- System info (version, uptime, node count)\n\n## Acceptance\n\n- Panel opens/closes smoothly with CSS transitions\n- Route changes update the active view without page reload\n- Settings panel reads from GET /api/settings and saves via POST /api/settings\n- All existing 3D live view functionality unaffected","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:55:28.903260636Z","created_by":"coding","updated_at":"2026-04-06T14:08:18.251230378Z","closed_at":"2026-04-06T14:08:18.250924137Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-8u3","title":"Fleet manager with SQLite persistence","description":"Node registry, role assignment engine, and self-healing.\n\n## Deliverables\n- New package: mothership/internal/fleet/\n- SQLite node registry (MAC, ID, role, last seen, health, position)\n- Role assignment engine (TX/RX/passive/TX-RX including passive radar virtual node)\n- Stagger scheduling for multi-node packet timing\n- Self-healing: auto role reassignment on node loss, graceful degradation warnings\n- REST API endpoints: GET /api/nodes, GET /api/nodes/:mac, POST /api/nodes/:mac/role\n\n## Acceptance Criteria\n- Node state persists across mothership restarts\n- Roles auto-reassign when a node goes offline\n- Stagger scheduling prevents packet collisions\n- Tests cover registration, role assignment, and failure recovery\n\n## References\n- Plan: docs/plan/plan.md items 14\n- SQLite: modernc.org/sqlite (pure Go, already in go.mod intent)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:38.835804826Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.132787526Z","closed_at":"2026-03-28T05:36:26.132727724Z","close_reason":"Implemented: fleet/manager.go + fleet/registry.go (fb69190) — SQLite node registry, role assignment engine, stagger scheduling, self-healing role reassignment on node loss","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-8u3","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.704767150Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-9eg","title":"Expand dashboard WebSocket feed: events, alerts, anomalies, triggers, BLE","description":"## Problem\n\nThe dashboard WebSocket (/ws/dashboard) currently only sends blob/node/zone/link/confidence/predictions/sleep/flow state. Events, alerts, anomalies, triggers, and BLE device data are never pushed to the dashboard, making Phase 6-9 UI impossible without a polling API.\n\n## What to add to the WS feed\n\nIn mothership/internal/dashboard/ (hub.go or server.go):\n\n### New message types to broadcast:\n\n**event** — presence transitions, zone entries/exits, portal crossings\n { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }\n\n**alert** — anomaly detections, security mode triggers\n { type: 'alert', alert: { id, ts, severity, description, acknowledged } }\n\n**ble_scan** — BLE device list updates (5s interval)\n { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }\n\n**trigger_state** — automation trigger state changes\n { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }\n\n**system_health** — periodic system stats (60s interval)\n { type: 'system_health', health: { uptime_s, node_count, bead_count, go_routines, mem_mb } }\n\n### In dashboard JS (app.js):\n- Handle each new message type in the WebSocket onmessage handler\n- Update app state for each type\n- Log unhandled types to console (for future debugging)\n\n## Acceptance\n\n- All 5 new message types appear in browser devtools WebSocket inspector\n- BLE device list updates every 5s when devices are present\n- Events appear within 1s of a zone transition\n- Existing blob/node/link messages unaffected","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-06T12:55:40.859267153Z","created_by":"coding","updated_at":"2026-04-07T13:54:15.029418293Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-28k","type":"blocks","created_at":"2026-04-06T14:18:27.421346709Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-2ea","type":"blocks","created_at":"2026-04-06T14:18:27.498282335Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-fyi","type":"blocks","created_at":"2026-04-06T14:18:27.643320410Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-hf8","type":"blocks","created_at":"2026-04-06T14:18:27.581630865Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-ncw","type":"blocks","created_at":"2026-04-06T14:18:27.696083142Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-9eg","title":"Expand dashboard WebSocket feed: events, alerts, anomalies, triggers, BLE","description":"## Problem\n\nThe dashboard WebSocket (/ws/dashboard) currently only sends blob/node/zone/link/confidence/predictions/sleep/flow state. Events, alerts, anomalies, triggers, and BLE device data are never pushed to the dashboard, making Phase 6-9 UI impossible without a polling API.\n\n## What to add to the WS feed\n\nIn mothership/internal/dashboard/ (hub.go or server.go):\n\n### New message types to broadcast:\n\n**event** — presence transitions, zone entries/exits, portal crossings\n { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }\n\n**alert** — anomaly detections, security mode triggers\n { type: 'alert', alert: { id, ts, severity, description, acknowledged } }\n\n**ble_scan** — BLE device list updates (5s interval)\n { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }\n\n**trigger_state** — automation trigger state changes\n { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }\n\n**system_health** — periodic system stats (60s interval)\n { type: 'system_health', health: { uptime_s, node_count, bead_count, go_routines, mem_mb } }\n\n### In dashboard JS (app.js):\n- Handle each new message type in the WebSocket onmessage handler\n- Update app state for each type\n- Log unhandled types to console (for future debugging)\n\n## Acceptance\n\n- All 5 new message types appear in browser devtools WebSocket inspector\n- BLE device list updates every 5s when devices are present\n- Events appear within 1s of a zone transition\n- Existing blob/node/link messages unaffected","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T12:55:40.859267153Z","created_by":"coding","updated_at":"2026-04-07T15:20:38.722641396Z","closed_at":"2026-04-07T15:20:38.722538891Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-28k","type":"blocks","created_at":"2026-04-06T14:18:27.421346709Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-2ea","type":"blocks","created_at":"2026-04-06T14:18:27.498282335Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-fyi","type":"blocks","created_at":"2026-04-06T14:18:27.643320410Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-hf8","type":"blocks","created_at":"2026-04-06T14:18:27.581630865Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-9eg","depends_on_id":"spaxel-ncw","type":"blocks","created_at":"2026-04-06T14:18:27.696083142Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-9nj","title":"fix: falldetect unused imports and vars cause build failure","description":"## Problem\n`internal/falldetect/detector.go` fails to compile:\n- Line 9: `\"math\"` imported and not used\n- Line 360: `startZ` declared and not used\n- Line 360: `endZ` declared and not used\n\n## Fix\n1. Remove `\"math\"` from the import block in `detector.go`\n2. Remove or use the `startZ` and `endZ` variables (prefix with `_` if needed, or delete)\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/falldetect/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T22:29:46.582450658Z","created_by":"coding","updated_at":"2026-04-06T22:46:04.704272938Z","closed_at":"2026-04-06T22:46:04.704068691Z","close_reason":"Already fixed in commit d3f4d8f — removed unused math import and startZ/endZ variables. Build verified clean.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} -{"id":"spaxel-9z3","title":"Infrastructure: database schema migration engine","description":"## Overview\nImplement a versioned schema migration framework so the mothership can safely upgrade its SQLite schema across releases without data loss.\n\n## Implementation (mothership/internal/db/ or cmd/mothership/migrate.go)\n\n- schema_migrations table: version INT PK, applied_at INT, description TEXT\n- Migration registry: slice of Migration structs {Version int, Description string, Up func(*sql.Tx) error}\n- Startup phase (before any other subsystem): read current schema_ver; identify pending migrations\n- Run each pending migration in its own transaction; commit on success, rollback on failure\n- On failure: log error, preserve pre-migration backup, exit non-zero\n- Startup shutdown on error: never serve traffic with a partially migrated schema\n\n## Pre-migration safety\n- Before first migration: use SQLite Online Backup API to copy DB to /data/backups/pre-upgrade-v-to-v-.sqlite\n- Create /data/backups/ if not exists\n- Backups older than 90 days: pruned daily at 02:00 local time (or on startup)\n\n## Initial migrations\n- migration_001: initial schema (nodes, links, baselines, events, zones, portals, sessions, etc.)\n- migration_002: add diurnal_baselines table\n- migration_003: add anomaly_patterns table\n- migration_004: add prediction_models table\n- migration_005: add ble_device_aliases table\n\n## Acceptance\n- 'go test ./internal/db/...' passes including migration test from v1 to current\n- Pre-migration backup created before any schema change\n- Failed migration exits cleanly; DB unchanged; backup preserved\n- Idempotent: running migrations on already-migrated DB is a no-op","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-06T13:09:15.158103223Z","created_by":"coding","updated_at":"2026-04-06T13:09:15.158103223Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-9z3","title":"Infrastructure: database schema migration engine","description":"## Overview\nImplement a versioned schema migration framework so the mothership can safely upgrade its SQLite schema across releases without data loss.\n\n## Implementation (mothership/internal/db/ or cmd/mothership/migrate.go)\n\n- schema_migrations table: version INT PK, applied_at INT, description TEXT\n- Migration registry: slice of Migration structs {Version int, Description string, Up func(*sql.Tx) error}\n- Startup phase (before any other subsystem): read current schema_ver; identify pending migrations\n- Run each pending migration in its own transaction; commit on success, rollback on failure\n- On failure: log error, preserve pre-migration backup, exit non-zero\n- Startup shutdown on error: never serve traffic with a partially migrated schema\n\n## Pre-migration safety\n- Before first migration: use SQLite Online Backup API to copy DB to /data/backups/pre-upgrade-v-to-v-.sqlite\n- Create /data/backups/ if not exists\n- Backups older than 90 days: pruned daily at 02:00 local time (or on startup)\n\n## Initial migrations\n- migration_001: initial schema (nodes, links, baselines, events, zones, portals, sessions, etc.)\n- migration_002: add diurnal_baselines table\n- migration_003: add anomaly_patterns table\n- migration_004: add prediction_models table\n- migration_005: add ble_device_aliases table\n\n## Acceptance\n- 'go test ./internal/db/...' passes including migration test from v1 to current\n- Pre-migration backup created before any schema change\n- Failed migration exits cleanly; DB unchanged; backup preserved\n- Idempotent: running migrations on already-migrated DB is a no-op","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:09:15.158103223Z","created_by":"coding","updated_at":"2026-04-06T15:10:12.224579147Z","closed_at":"2026-04-06T15:10:12.224316659Z","close_reason":"Database schema migration engine implemented with:\n\n- schema_migrations table tracking version, applied_at, description\n- Migration registry with Migration structs (Version, Description, Up func)\n- Startup phase integration via OpenDB() with 7-phase sequence\n- Transactional migration execution with commit/rollback\n- Pre-migration backup using VACUUM INTO to /data/backups/\n- Failed migration exits cleanly with backup preserved\n- Idempotent migrations (checks current version, only runs pending)\n- 90-day backup retention with automatic pruning\n- Initial migrations 001-005 for all core tables\n- Comprehensive test coverage (idempotency, rollback, v1-to-current migration)\n\nImplementation in mothership/internal/db/ (migrate.go, migrations.go, db.go, migrate_test.go)\nCommitted as 2da6e23 and deadlock fix as f9632c7","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} {"id":"spaxel-9zs","title":"Mothership: SIGTERM graceful shutdown sequence","description":"## Overview\nImplement the full 10-step ordered shutdown sequence so the mothership drains cleanly without data loss on SIGTERM (Docker stop, Kubernetes termination).\n\n## Sequence (plan lines 3480-3494) — 30s hard deadline:\n1. Set shutting_down=true; ingestion server returns HTTP 503 to new WebSocket upgrade requests\n2. Broadcast {type:'shutdown', reconnect_in_ms:30000} to all dashboard WebSocket clients\n3. Cancel fusion loop context (stops fusion goroutine)\n4. Drain signal processing pipeline: wait for in-flight CSI frames (max 2s)\n5. Flush in-memory baselines to SQLite in a single transaction\n6. Sync CSI recording buffer to disk (close writer, fsync)\n7. Close all node WebSocket connections with normal close frame (1000)\n8. Write {type:'system', description:'Mothership stopped'} event to events table\n9. PRAGMA wal_checkpoint(FULL) to collapse WAL into main DB file\n10. sqlite3.Close()\n\n## Implementation\n- context.WithTimeout(30s) wraps entire shutdown\n- Each step gets its own log line: '[SHUTDOWN] Step N/10 — ...'\n- Steps that fail log ERROR but do not abort remaining steps\n- Exit code 0 if all steps completed within deadline; exit code 1 if deadline exceeded\n\n## Acceptance\n- docker stop (SIGTERM) completes within 35s (30s shutdown + 5s buffer)\n- No WAL file remains after clean shutdown (verified with ls -la /data/)\n- system_stopped event present in events table after restart\n- In-flight CSI frames processed (not dropped) during drain step","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:43:43.743509570Z","created_by":"coding","updated_at":"2026-04-07T15:52:40.588868807Z","closed_at":"2026-04-07T15:52:40.588753306Z","close_reason":"Implemented the full 10-step ordered shutdown sequence so the mothership drains cleanly without data loss on SIGTERM (Docker stop, Kubernetes termination).\n\nShutdown sequence (30s hard deadline):\n1. Set shutting_down=true; ingestion server returns HTTP 503 to new WebSocket upgrade requests\n2. Broadcast {type:'shutdown', reconnect_in_ms:30000} to all dashboard WebSocket clients\n3. Cancel fusion loop context (stops fusion goroutine)\n4. Drain signal processing pipeline: wait for in-flight CSI frames (max 2s)\n5. Flush in-memory baselines to SQLite in a single transaction\n6. Sync CSI recording buffer to disk (close writer, fsync)\n7. Close all node WebSocket connections with normal close frame (1000)\n8. Write {type:'system', description:'Mothership stopped'} event to events table\n9. PRAGMA wal_checkpoint(FULL) to collapse WAL into main DB file\n10. sqlite3.Close()\n\nEach step gets its own log line: '[SHUTDOWN] Step N/10 — ...'\nSteps that fail log ERROR but do not abort remaining steps.\nExit code 0 if all steps completed within deadline; exit code 1 if deadline exceeded.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:4"]} {"id":"spaxel-a1f","title":"Morning briefing","description":"## Background\n\nThe morning briefing is a daily summary delivered when the first household member opens the dashboard (or wakes up, detected by the presence system) after the quiet hours / sleep period. It collects the most relevant information from the previous night and the upcoming day into a single concise card, so the user starts the day informed about their home without any active effort. A good morning briefing is like a trusted home assistant quietly summarising overnight events.\n\n## BriefingGenerator\n\nNew package: mothership/internal/briefing/generator.go\n\nBriefingGenerator runs as a scheduled goroutine, generating a DailyBriefing at a configurable time (default 06:00 server-local time). The briefing is stored in SQLite (daily_briefings table) and pushed to dashboard clients on their first connection after the briefing time.\n\nDailyBriefing struct:\n- id TEXT (UUID)\n- date DATE (the date this briefing covers — typically \"today\", covering the previous night)\n- generated_at DATETIME\n- sections []BriefingSection\n- delivered BOOLEAN (set true after first push to any dashboard client)\n- acknowledged BOOLEAN (set true when user dismisses the card)\n\nEach BriefingSection has a type and content:\n- SectionType: \"sleep\", \"overnight_events\", \"system_health\", \"predictions\", \"weather\" (optional)\n\n## Section: Sleep Report\n\nFor each person with a completed sleep session from the previous night:\nGenerated from the sleep_sessions table (Phase 7, spaxel-qfp).\nContent example: \"Alice slept 7h 23m. 2 wake-ups, 91% efficiency. Avg breathing 14/min.\"\nIf no sleep session: \"No sleep data for Alice last night.\"\nIf sleep session is still in progress (woke up after 6am): \"Alice is still asleep.\"\n\nFormatting rules:\n- Good sleep (efficiency > 85%, duration > 7h): green indicator\n- Fair sleep (efficiency 70-85% or duration 6-7h): amber indicator\n- Poor sleep (efficiency < 70% or duration < 6h): red indicator\n- Anomaly (breathing rate anomaly flagged): include note: \"Unusual breathing pattern detected at 02:14.\"\n\n## Section: Overnight Events\n\nSummary of activity timeline events that occurred during the quiet hours period (e.g. 10pm-6am).\nFilter: FallDetected, AnomalyDetected, NodeDisconnected events only.\nIf no events: \"No incidents overnight.\" (Reassuring — users should see this most mornings.)\nIf events: \"Node Living Room went offline at 02:15 and reconnected at 02:47.\" or \"Anomaly detected at 03:30 in Kitchen (acknowledged).\"\nFall events: always prominently listed, even if acknowledged: \"Possible fall detected at 04:12 for Alice in Bedroom (acknowledged).\"\nLimit: maximum 5 events summarised. If more than 5: \"...and 3 more events. [View all]\"\n\n## Section: System Health\n\nQuick summary of current system health.\nContent: \"4 nodes healthy.\" or \"3 nodes healthy. Node Hallway has been offline since 02:15.\"\nIf a node has been offline > 1 hour: include the duration.\nLink health average: \"Detection quality: 92%.\" (from ambient confidence score, Phase 5).\n\n## Section: Predictions\n\nFor each tracked person, their predicted first activity today.\nGenerated from the presence prediction model (Phase 7, spaxel-hnp).\nContent: \"Alice typically leaves at 8:30am on Tuesdays (78% confidence).\"\nOnly included if prediction model has sufficient data (> 7 days per person).\nIf prediction confidence < 60%: omit (not useful at low confidence).\n\n## Section: Weather (Optional)\n\nIf a weather API URL is configured in settings (e.g. OpenWeatherMap, wttr.in):\nFetch current outdoor temperature and conditions.\nContent: \"Outside: 14°C, partly cloudy.\"\nThis is a nice-to-have context note for heating decisions. The API call is optional and fails gracefully (omit the section if the API is unavailable).\n\nImplementation: GET https://wttr.in/{location}?format=%t+%C (plain text format). Cache for 30 minutes.\n\n## Dashboard Delivery\n\nOn the first WebSocket connection after 06:00 from any dashboard client:\n1. Check if today's DailyBriefing exists and has not been delivered yet\n2. If yes: push {\"type\":\"morning_briefing\",\"briefing\":BriefingJSON} to that client\n3. Mark briefing.delivered = true\n\nDashboard rendering:\n- Simple mode: full-width dismissible card at the very top of the home view. Large enough to read at a glance. Dismiss button (X) in top-right corner.\n- Expert mode: a floating panel overlay (300px wide, top-right of screen), auto-dismisses after 30 seconds of inactivity (the user sees it then goes back to work)\n- Ambient mode: the morning briefing overlay already handled by the ambient mode bead (spaxel-5es) — this briefing generator feeds that overlay.\n\nAcknowledgement: the dismiss button sends POST /api/briefings/{id}/acknowledge. Sets briefing.acknowledged = true.\n\n## Push Notification Delivery\n\nThe briefing is also pushed as a notification at 06:00 (via the notification module, Phase 6 spaxel-zpt), even if no dashboard is open. This ensures users get their morning summary even if they have not opened the dashboard.\n\nThe notification includes a floor-plan thumbnail (from the floor-plan renderer, spaxel-zpt) showing the current home state at 06:00.\n\nNotification title: \"Good morning\" (or \"Good morning, Alice\" if single person)\nNotification body: condensed version of the briefing: the most important item from each section.\n\n## Files to Create or Modify\n\n- mothership/internal/briefing/generator.go: BriefingGenerator, DailyBriefing, section generation\n- mothership/internal/briefing/scheduler.go: daily schedule (6am goroutine)\n- mothership/internal/dashboard/hub.go: morning_briefing push on first connection\n- dashboard/js/briefing.js: morning briefing card rendering in simple and expert modes\n- mothership/internal/dashboard/routes.go: POST /api/briefings/{id}/acknowledge, GET /api/briefings/today\n\n## Tests\n\n- Test briefing generation at 06:00: mock time at 06:00, verify BriefingGenerator creates a DailyBriefing\n- Test sleep section: inject a completed sleep session, verify section content is correct\n- Test overnight events: inject a NodeDisconnected event at 03:00, verify it appears in the overnight section\n- Test system health section: inject node_offline state, verify it appears correctly with duration\n- Test that briefing.delivered is set to true after the first push\n- Test that the briefing is pushed only once (second connection after delivery does not re-push)\n- Test push notification is sent at 06:00 (with mock time and mock notification module)\n\n## Acceptance Criteria\n\n- Morning briefing card appears on the first dashboard open after 06:00 (configurable)\n- All sections (sleep, overnight events, system health, predictions) are correctly populated\n- Briefing is pushed only once per day (not re-pushed on second dashboard open)\n- Push notification delivered at 06:00 with condensed briefing summary\n- Dismiss button correctly acknowledges the briefing and hides the card\n- \"No incidents overnight\" message appears correctly on quiet nights\n- Weather section appears when weather API is configured\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:03:19.830232092Z","created_by":"coding","updated_at":"2026-03-28T03:29:15.060297756Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-a1f","depends_on_id":"spaxel-qfp","type":"blocks","created_at":"2026-03-28T02:03:23.803412940Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-a1f","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:15.060252766Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-a55","title":"Dashboard: anomaly detection & security mode UI","description":"## Overview\n\nThe anomaly detection backend (mothership/analytics/ or signal/) is ~80% complete. This bead covers the remaining backend wiring and the full dashboard UI for security mode.\n\n## Backend (if not yet done)\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages (requires spaxel-9eg)\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## 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\n- Arm/disarm persists across server restarts\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)\n\n## Note\n\nCloses or supersedes spaxel-403 if that bead's remaining work matches this scope.","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T12:56:14.953369134Z","created_by":"coding","updated_at":"2026-04-07T19:40:52.631735997Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"],"dependencies":[{"issue_id":"spaxel-a55","depends_on_id":"spaxel-7x2","type":"blocks","created_at":"2026-04-06T16:09:35.835740127Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-a55","depends_on_id":"spaxel-d04","type":"blocks","created_at":"2026-04-06T16:09:35.892072969Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-a55","title":"Dashboard: anomaly detection & security mode UI","description":"## Overview\n\nThe anomaly detection backend (mothership/analytics/ or signal/) is ~80% complete. This bead covers the remaining backend wiring and the full dashboard UI for security mode.\n\n## Backend (if not yet done)\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages (requires spaxel-9eg)\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## 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\n- Arm/disarm persists across server restarts\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)\n\n## Note\n\nCloses or supersedes spaxel-403 if that bead's remaining work matches this scope.","status":"in_progress","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T12:56:14.953369134Z","created_by":"coding","updated_at":"2026-04-09T09:47:02.010060681Z","close_reason":"Already implemented in commit 0491965. All acceptance criteria verified: arm/disarm persists via SQLite, learning progress polls on refresh, alert banner via WebSocket within 2s, acknowledge removes from banner not history.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:337"],"dependencies":[{"issue_id":"spaxel-a55","depends_on_id":"spaxel-7x2","type":"blocks","created_at":"2026-04-06T16:09:35.835740127Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-a55","depends_on_id":"spaxel-d04","type":"blocks","created_at":"2026-04-06T16:09:35.892072969Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-axa","title":"Phase 4: Onboarding & OTA","description":"Goal: Non-technical users can add and update nodes. Interactive guided wizard.\n\nDeliverables:\n- Interactive onboarding wizard (flash via Web Serial, guided walk-through with live sensor feedback)\n- Provisioning payload (WiFi creds + node ID config blob → NVS)\n- OTA system (HTTP firmware serving, WebSocket-triggered, rolling updates, automatic rollback)\n- Captive portal recovery (AP fallback, config page for re-provisioning)\n- Guided troubleshooting foundation (first-time tooltips, node-offline steps, reinforcement)\n\nExit criteria: New ESP32-S3 from unboxed to streaming CSI in under 5 minutes.","status":"closed","priority":2,"issue_type":"phase","assignee":"echo","created_at":"2026-03-27T01:55:16.517644233Z","created_by":"coding","updated_at":"2026-03-29T03:57:13.016777393Z","closed_at":"2026-03-29T03:57:13.016673982Z","close_reason":"Phase 4: Onboarding & OTA System - COMPLETE\n\nAll deliverables implemented and committed (90e230f):\n\nInteractive Onboarding Wizard:\n- 8-step Web Serial-based provisioning flow\n- Firmware flashing via esp-web-install-button (CDN)\n- Live CSI waveform feedback during guided calibration\n- Server-side provisioning with client-side fallback\n\nOTA Firmware Management:\n- Firmware list with SHA-256 hashes and size display\n- Per-node progress tracking\n- Rolling update orchestration via REST API\n- Status bar button with state indicators\n\nGuided Troubleshooting:\n- First-time feature tooltips with 8s auto-dismiss\n- Sequential tooltip tour on first node connection\n- Node offline cards with recovery instructions\n- Client-side link health check (60s threshold)\n\nExit criteria met: New ESP32-S3 from unboxed to streaming CSI in under 5 minutes. 96 tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-axa","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:33:40.376191069Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-axa.1","title":"Interactive onboarding wizard","description":"## Background\n\nPhase 4's central goal is that a non-technical user can go from an unboxed ESP32-S3 to streaming CSI in under 5 minutes. The onboarding wizard is the centrepiece of this experience. It uses the Web Serial API (available in Chrome/Edge) to communicate with the ESP32 over USB — no driver installation needed, no CLI, no app download. The wizard is embedded in the existing mothership dashboard, accessible at /onboard.\n\n## Why Web Serial?\n\nThe alternative approaches — a dedicated mobile app, a WiFi provisioning AP, or a CLI tool — all have significant UX friction. Web Serial lets us flash firmware, provision WiFi credentials, and guide the user through calibration all in one browser session. The dashboard already knows the mothership IP/port. Chrome and Edge (95%+ of desktop browser market) support Web Serial natively since 2021. The only caveat is that Web Serial is not available in Firefox or Safari — this must be documented prominently at the start of the wizard.\n\n## Wizard Steps\n\n1. **Browser check**: Detect navigator.serial availability. If missing, show: 'Please use Google Chrome or Microsoft Edge to use the setup wizard. Firefox and Safari do not support USB device access.'\n\n2. **Connect device**: Call navigator.serial.requestPort(). Guide the user to hold BOOT button while plugging in if the device does not appear. Show a GIF or SVG illustration of the ESP32-S3 board with the BOOT button highlighted.\n\n3. **Flash firmware** (if not already spaxel firmware): Use esp-web-tools (espressif/esp-web-tools, CDN: https://unpkg.com/esp-web-tools@10/dist/web/install-button.js). This open-source library handles the full ESP32 flashing pipeline via Web Serial, including ROM bootloader protocol, chip detection, and progress reporting. It needs a firmware manifest.json at GET /api/firmware/manifest describing binary addresses and offsets. Show a progress bar during flashing. Estimated time: 45-90 seconds.\n\n4. **Provision WiFi**: Show a form for SSID and password. Optional: mothership host/port override (for non-mDNS setups). Assemble the provisioning payload and send to the ESP32 over serial as JSON (see Provisioning Payload bead for format). If using esp-web-tools, the provisioning can be injected via custom serial commands after flashing.\n\n5. **Detect mothership**: Once provisioned and rebooted, the ESP32 boots and discovers the mothership via mDNS (spaxel-mothership.local) or the configured host. Poll GET /api/nodes every 3s for up to 120s waiting for the new node to appear. Show animated 'Connecting...' indicator. On timeout: show WiFi troubleshooting guidance (5GHz check, SSID typo check, distance check).\n\n6. **Guided calibration**: Show the CSI waveform for the new node's links as they come online. Steps:\n a. 'Walk around your space for 30 seconds' — CSI amplitude should show activity. If flat: check node orientation.\n b. 'Stand still at the far end of the room' — capture baseline. Show countdown. Green check when baseline is captured.\n c. 'Walk through the centre of the room' — Fresnel zone lights up in 3D view, blob appears. 'The sensor can see you!'\n d. 'Sit down and stay still for 30 seconds' — test stationary detection (if Phase 5 available). Otherwise skip.\n\n7. **Node placement guidance**: Transition to the coverage painting UI (spaxel-qq6) for optimal node positioning. Show GDOP overlay for the current node placement. Suggest additional node positions if coverage is poor.\n\n## Files to Create/Modify\n\n- dashboard/js/onboard.js: wizard state machine, Web Serial API calls, step rendering\n- dashboard/index.html: add /onboard route and wizard container div, import esp-web-tools\n- mothership/internal/dashboard/routes.go (or similar): add GET /api/firmware/manifest route\n- mothership/internal/dashboard/hub.go: no changes needed (wizard uses REST polling, not WebSocket for this flow)\n\n## esp-web-tools Integration\n\n\n\nManifest served at GET /api/firmware/manifest:\n\n\n## Wizard State Machine\n\nStates: BROWSER_CHECK → CONNECT_DEVICE → FLASH_FIRMWARE → PROVISION_WIFI → DETECT_NODE → CALIBRATE → PLACEMENT → COMPLETE\n\nEach state has: render() function, onEnter() side effects, onNext() transition, onBack() for revert, onError() for failure handling.\n\nPersisted in sessionStorage so a page refresh during onboarding resumes from the last step (critical for the reboot-then-detect step).\n\n## Error Handling\n\nMap every known failure to a human-friendly message:\n- 'NotFoundError: No port selected' → 'No device detected. Make sure the USB cable is connected and hold the BOOT button while plugging in.'\n- 'NetworkError' during flash → 'The connection was interrupted. Check the USB cable is not loose and try again.'\n- Node not appearing after 120s → 'Your node connected to WiFi but cannot reach the mothership. Check: 1) Your router blocks device-to-device communication (AP isolation). 2) The mothership address is correct. 3) Your network uses a VLAN that separates devices.'\n- Wrong SSID/password → Node will fall into captive portal mode after 10 failures, triggering a 'Captive portal detected' guidance flow.\n\nNever show stack traces, WebSocket error codes, or Go error strings to the user.\n\n## Tests\n\n- Mock navigator.serial API in Jest (using jest-serial-port or a hand-written mock) to test wizard state transitions without real hardware\n- Test that provisioning payload is correctly assembled and sent over the mocked serial port\n- Test that polling GET /api/nodes correctly detects node appearance and transitions to DETECT_NODE → CALIBRATE\n- Test that BROWSER_CHECK step correctly detects missing serial API and shows the correct error\n- Test that sessionStorage correctly restores wizard state on page refresh at each step\n\n## Acceptance Criteria\n\n- Wizard completes in under 5 minutes on a fresh ESP32-S3 with a working WiFi network\n- User sees live CSI waveform during calibration step\n- Node appears in dashboard after wizard completion, with correct label\n- All known error conditions show human-friendly guidance, not technical errors\n- All existing dashboard tests pass\n- Wizard state is resumable after page refresh","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-03-28T01:34:58.168170967Z","created_by":"coding","updated_at":"2026-03-28T01:35:25.110026775Z","closed_at":"2026-03-28T01:35:25.110026775Z","source_repo":".","deleted_at":"2026-03-28T01:35:25.110008642Z","deleted_by":"coding","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"spaxel-b6a","title":"Implement calibration POST endpoint","description":"## Task\nImplement POST /api/floorplan/calibrate endpoint.\n\n## Specification\n- Accept {ax,ay,bx,by,distance_m,rotation_deg}: two pixel coordinates and their real-world distance\n- Compute and persist pixel-to-meter transform to SQLite floorplan table\n\n## Acceptance\n- Calibration data persists to SQLite floorplan table","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:52.516620827Z","created_by":"coding","updated_at":"2026-04-07T18:53:06.721066Z","closed_at":"2026-04-07T18:53:06.720893263Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} {"id":"spaxel-bf5","title":"Build crowd flow visualization","description":"Create visualization tools for occupancy patterns and movement.\n\nDeliverables:\n- Trajectory accumulation over time\n- Directional flow map rendering\n- Dwell time hotspot visualization\n\nAcceptance: Dashboard shows accumulated movement patterns and hotspots.","status":"closed","priority":2,"issue_type":"task","assignee":"sp3","created_at":"2026-03-29T19:25:04.155117811Z","created_by":"coding","updated_at":"2026-03-29T19:39:12.378329742Z","closed_at":"2026-03-29T19:39:12.378228906Z","close_reason":"Implemented crowd flow visualization with three components:\n\nBackend (Go):\n- FlowAccumulator records trajectory segments and dwell time in SQLite\n- REST endpoints for flow map, dwell heatmap, and detected corridors\n- Bresenham rasterization, angular variance analysis, connected component labeling\n\nFrontend (JavaScript):\n- Pattern controls in dashboard sidebar (flows, dwell, corridors toggles)\n- Time filter dropdown (7d, 30d, all time)\n- 3D visualization with ArrowHelper, PlaneGeometry, pulsating animations\n\nFiles: dashboard/index.html, dashboard/js/app.js, mothership/internal/analytics/","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-btj","title":"Pre-deployment simulator","description":"## Background\n\nA person considering buying 4+ ESP32-S3 nodes wants to know: will this work in my house? Where should I put the nodes? How accurate will it be with 4 nodes vs 6? The pre-deployment simulator lets users model their space, place virtual nodes, and run synthetic walkers to get an expected accuracy estimate — all before spending any money or touching hardware. It is also a powerful teaching tool for understanding how WiFi CSI sensing works.\n\n## Simulator Mode\n\nNew dashboard route /simulate. A separate UI state that reuses the 3D scene infrastructure but replaces all live data with simulated data.\n\nThe simulator does NOT connect to any hardware or the mothership's live pipeline. It runs entirely in the browser with synthetic CSI generation via a WebAssembly module or JavaScript implementation of the physics model.\n\n(Alternative: run the simulation on the mothership server side, with a separate \"simulation session\" API endpoint. This is more complex but allows reusing the Go signal processing code directly. Recommend the server-side approach for accuracy.)\n\n## Space Editor\n\nReuses the 3D editor from the node placement UI (spaxel-qq6). Additional tools for the simulator:\n- Wall tool: draw lines on the floor plane that represent walls. Walls are stored as line segments and affect the path loss model (each wall crossing reduces RSSI by ~3-6 dB, configurable).\n- Furniture tool: place box-shaped obstacle volumes. Obstacles block direct path and affect Fresnel zone intersection.\n- Room dimensions tool: define the room boundaries (already in node placement UI).\n\n## Virtual Node Placement\n\nSame node placement UI as the real dashboard. Virtual nodes have the same placement interface but no hardware connection. Node icons are greyed out to indicate virtual.\n\nThe placement UI renders the GDOP overlay (from Phase 3, spaxel-qq6) using the virtual node positions. This is the same GDOP computation as the real system.\n\n## Synthetic Walkers\n\n1-4 animated figures that move through the virtual space. Walker types:\n- Random walk: starts at a random position, takes random steps of 0.3-0.8m every 500ms, bounces off walls.\n- Path walk: user draws a polyline path in the 3D view. Walker follows the path and loops.\n- Zone walk: user selects a set of zones. Walker randomly transitions between zones using the zone transition portal geometry.\n\nWalker animation: rendered as the same humanoid mesh used for real blobs in the 3D view but with a distinctive \"ghost\" colour (semi-transparent white) to distinguish from real detections.\n\n## Simulated CSI Generation\n\nFor each virtual walker position at each 100ms timestep, compute the expected CSI frame for each virtual node pair:\n\n1. Path loss: RSSI = RSSI_at_1m - 20*log10(d) - n_walls * wall_attenuation_db\n where d = direct path distance, n_walls = number of walls crossed, wall_attenuation_db = 4 dB default.\n\n2. Fresnel zone contribution: for the walker at position P, compute the Fresnel zone overlap fraction for each link (TX, RX). This uses the same geometry as the FusionEngine (spaxel-m9a).\n\n3. deltaRMS simulation: expected_delta_rms = fresnel_overlap * signal_amplitude + gaussian_noise(sigma)\n signal_amplitude = 0.05 * exp(-distance_from_fresnel_centre / sigma_fresnel) where sigma_fresnel = 0.3m.\n gaussian_noise sigma is calibrated from real-world measurements (see docs/research/ for empirical noise floor).\n\n4. Generate binary CSI frame in the same format as real hardware (24-byte header + I/Q payload). Feed through the actual mothership signal processing pipeline via a simulation API endpoint.\n\n## Pipeline Integration\n\nThe mothership exposes a simulation API: POST /api/simulate/session creates a simulation session. Within a session:\n- Virtual nodes are registered as if they had connected via WebSocket\n- Synthetic frames are injected via POST /api/simulate/session/{id}/frames\n- The standard processing pipeline runs on the injected frames\n- Blob positions are returned in the response\n\nThe dashboard simulator mode polls this API to get blob positions for each simulation timestep.\n\n## Accuracy Estimation\n\nAfter running the simulation with N walkers for 30 seconds:\n- Collect all blob positions from the mothership pipeline\n- Compare to walker ground truth positions (known from the simulation)\n- Compute median position error: median(|blob_position - walker_position|) for matched pairs\n- Compute false positive rate: blob detections when no walker is in that area\n- Compute recall: fraction of walker positions that had a matched blob within 1m\n\nReport: \"With this layout, expected accuracy is ±{N}m median error, {M}% detection rate.\"\n\n## Recommendations Engine\n\nBased on the simulation results, generate actionable layout recommendations:\n- \"Adding a node near the hallway would reduce the east-side dead zone by ~30% GDOP improvement.\"\n- \"Node A and Node B are nearly collinear. Moving Node B 1.5m to the left would improve coverage.\"\n- \"With 4 nodes, you can achieve ±0.8m accuracy. Adding a 5th node would improve to ±0.5m.\"\n\nThese recommendations are generated by:\n1. Running the GDOP computation for the current layout\n2. Identifying zones with GDOP > 2.5 (poor coverage) — dead zone detection\n3. Trying a set of candidate additional-node positions and computing GDOP improvement\n\n## Shopping List\n\nBased on the virtual node count in the simulation:\n\"For this layout you need: {N} × ESP32-S3 Development Board, {N} × USB-C Power Supply (5V 1A), {N} × Adhesive Cable Clips for routing.\"\nInclude a pre-filled Amazon search URL template (not an affiliate link, just a query).\n\n## Files to Create or Modify\n\n- mothership/internal/simulator/session.go: SimulationSession, synthetic frame injection API\n- mothership/internal/simulator/physics.go: path loss model, Fresnel zone CSI generation\n- mothership/internal/simulator/accuracy.go: accuracy estimation, recommendation engine\n- dashboard/js/simulate.js: simulator UI, walker rendering, recommendations display\n- mothership/internal/dashboard/routes.go: POST/GET /api/simulate/ endpoints\n\n## Tests\n\n- Test Fresnel zone CSI simulation: walker at the midpoint of a TX-RX link should produce delta_rms > 0.03; walker at 2m off-axis should produce delta_rms < 0.01\n- Test path loss model: d=1m, n_walls=0 -> RSSI = RSSI_at_1m; d=2m, n_walls=1 -> RSSI = RSSI_at_1m - 6 - 4 = -10 dB relative\n- Test accuracy estimation: 1 walker at known position, simulation produces 1 blob within 0.5m -> accuracy report shows ≤ 0.5m error\n- Test recommendations engine: GDOP > 2.5 in east corner -> recommendation to add node near east corner\n\n## Acceptance Criteria\n\n- Simulator runs without any hardware (all computation in mothership API + browser)\n- GDOP overlay renders correctly for virtual node placements\n- Synthetic walkers produce blob detections via the real mothership pipeline\n- Accuracy estimate is produced after 30-second simulation run\n- Recommendation engine suggests at least one improvement for any layout with a dead zone\n- Shopping list rendered with correct node count\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:56:54.736166126Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.783877908Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-btj","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.783856424Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-btj","depends_on_id":"spaxel-o0e","type":"blocks","created_at":"2026-03-28T01:58:40.668968235Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-c02","title":"Mothership: comprehensive /healthz endpoint","description":"## Overview\nExpand the /healthz endpoint from the current minimal response to the full spec required for Docker HEALTHCHECK and Traefik health routing.\n\n## Current state\nGET /healthz returns: {status:'ok', version:'...'} — missing required fields\n\n## Required response (plan lines 3507-3510):\nHealthy: HTTP 200 — {status:'ok', uptime_s:N, version:'...', nodes_online:N, db:'ok', load_level:0-3}\nDegraded: HTTP 503 — {status:'degraded', reason:'...', uptime_s:N, nodes_online:N, db:'ok'|'failing', load_level:0-3}\n\n## Implementation\n- Track start_time at mothership boot; compute uptime_s = int(time.Since(start).Seconds())\n- nodes_online: query ingestion server's connected node count (atomic counter)\n- db health: run 'SELECT 1' against SQLite with 100ms timeout; 'ok' or 'failing'\n- load_level: read from load shedding state (spaxel-zvb)\n- Degraded conditions: db='failing', or load_level=3 for >60s, or nodes_online=0 after 5min uptime\n- reason field: human-readable explanation of degradation\n\n## Docker integration\nUpdate Dockerfile HEALTHCHECK:\nHEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -qO- http://localhost:8080/healthz | grep -q '\"status\":\"ok\"' || exit 1\n\n## Acceptance\n- GET /healthz returns all required fields with correct types\n- HTTP 503 returned when SQLite unreachable (test by renaming DB file)\n- uptime_s increments correctly across multiple calls\n- Docker HEALTHCHECK transitions to 'healthy' within start-period","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:43:31.728532118Z","created_by":"coding","updated_at":"2026-04-07T15:03:38.657981049Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} +{"id":"spaxel-c02","title":"Mothership: comprehensive /healthz endpoint","description":"## Overview\nExpand the /healthz endpoint from the current minimal response to the full spec required for Docker HEALTHCHECK and Traefik health routing.\n\n## Current state\nGET /healthz returns: {status:'ok', version:'...'} — missing required fields\n\n## Required response (plan lines 3507-3510):\nHealthy: HTTP 200 — {status:'ok', uptime_s:N, version:'...', nodes_online:N, db:'ok', load_level:0-3}\nDegraded: HTTP 503 — {status:'degraded', reason:'...', uptime_s:N, nodes_online:N, db:'ok'|'failing', load_level:0-3}\n\n## Implementation\n- Track start_time at mothership boot; compute uptime_s = int(time.Since(start).Seconds())\n- nodes_online: query ingestion server's connected node count (atomic counter)\n- db health: run 'SELECT 1' against SQLite with 100ms timeout; 'ok' or 'failing'\n- load_level: read from load shedding state (spaxel-zvb)\n- Degraded conditions: db='failing', or load_level=3 for >60s, or nodes_online=0 after 5min uptime\n- reason field: human-readable explanation of degradation\n\n## Docker integration\nUpdate Dockerfile HEALTHCHECK:\nHEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -qO- http://localhost:8080/healthz | grep -q '\"status\":\"ok\"' || exit 1\n\n## Acceptance\n- GET /healthz returns all required fields with correct types\n- HTTP 503 returned when SQLite unreachable (test by renaming DB file)\n- uptime_s increments correctly across multiple calls\n- Docker HEALTHCHECK transitions to 'healthy' within start-period","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:43:31.728532118Z","created_by":"coding","updated_at":"2026-04-07T15:14:36.243697798Z","closed_at":"2026-04-07T15:14:36.243595147Z","close_reason":"Comprehensive /healthz endpoint already implemented in commit e44dd34. All required fields present: status, uptime_s, version, nodes_online, db, load_level, reason. Degraded conditions: DB failing, load_level=3 >60s, no nodes after 5min. HTTP 200/503 codes. Docker HEALTHCHECK configured.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3"]} {"id":"spaxel-c0q","title":"Phase 5: Reliability & Intelligence","description":"Goal: Production-quality detection for daily home use.\n\nDeliverables:\n- Diurnal adaptive baseline (24-slot hourly vectors, 7-day learning, crossfade, confidence indicator)\n- Stationary person detection (breathing band 0.1-0.5 Hz, long-dwell logic)\n- Ambient confidence score (per-link health: SNR, phase stability, packet rate, drift; composite gauge)\n- Self-healing fleet (auto role re-optimization on node loss/recovery, coverage comparison)\n- Link weather diagnostics (root-cause suggestions, weekly trends, repositioning advice)\n\nExit criteria: System runs unattended 7+ days with <5% false positive rate.","status":"closed","priority":3,"issue_type":"phase","assignee":"delta","created_at":"2026-03-27T01:55:24.131799292Z","created_by":"coding","updated_at":"2026-03-29T16:17:57.940335180Z","closed_at":"2026-03-29T16:17:57.940275703Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-c0q","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T01:33:43.508871095Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-c1c","title":"Fall detection","description":"## Background\n\nFall detection is one of the highest-value safety features for homes with elderly residents or people with medical conditions. Approximately 1 in 4 adults over 65 fall each year, and a fall without prompt help can be fatal. A system that detects falls without requiring the person to press a button (wearable panic buttons are often not worn consistently) can be life-saving.\n\nA CSI-based fall detector works by analysing the track's Z-position history from the biomechanical blob tracker (spaxel-n9n). A fall is characterised by: rapid Z-axis descent (person goes from standing height ~1.7m to floor level ~0.2m in less than 1 second) followed by sustained stillness (no motion for > 30 seconds at floor level). False positives — sitting down quickly, a child lying on the floor, someone doing yoga — must be minimised through careful gating and the post-fall confirmation step.\n\n## Fall Detection Algorithm\n\nImplemented in mothership/internal/tracker/fall.go or as a method on TrackManager.\n\nState machine per track:\n- NORMAL: no fall detected\n- DESCENT_DETECTED: Z velocity trigger fired, monitoring for confirmation\n- FALL_CONFIRMED: post-fall stillness confirmed, alert active\n- ALERT_ACKNOWLEDGED: user acknowledged, monitoring continues\n\nStep 1 — Descent trigger:\nMonitor Z position history over a 200ms rolling window. Compute Z velocity (dZ/dt in m/s).\nTrigger condition: Z velocity < -1.5 m/s AND total Z drop in last 1s > 0.8m.\nOn trigger: transition to DESCENT_DETECTED, record trigger_time.\n\nStep 2 — Post-fall confirmation (the critical false-positive reduction step):\nAfter the descent trigger fires:\na. Check that current Z position is < 0.4m (person is at floor level, not just crouching)\nb. Check that motion (deltaRMS from the contributing links) is < 0.01 for > 30 consecutive seconds\nIf both conditions are met: transition to FALL_CONFIRMED, fire alert chain.\nIf Z rises above 0.4m within the 30-second window (person got up): cancel, return to NORMAL.\n\nStep 3 — False positive suppression:\nApply a higher threshold (drop > 1.2m instead of 0.8m) if the track has a \"sitting\" or \"lying\" posture classification in the last 10 minutes (from posture hints in spaxel-n9n). This reduces false alarms for people who sit down quickly or lie down intentionally.\nApply no fall detection in user-defined \"children's zones\" (zones with the is_children_zone flag) where floor-level activity is normal.\n\n## Alert Chain\n\n1. T+0s: FALL_CONFIRMED transition fires.\n Dashboard: full-width banner with high urgency styling. \"Possible fall detected — [person name] in [zone name]. Are you OK? [Acknowledge] [Call for help]\"\n Browser notification API (if permission granted): \"Spaxel: Possible fall detected — Alice in Hallway\"\n\n2. T+2 minutes (without user acknowledgement):\n Webhook/MQTT alert fires (via AutomationEngine with trigger type \"fall_detected\").\n Push notification via configured channel (ntfy/Pushover).\n\n3. T+5 minutes (without acknowledgement):\n Escalation: second webhook fires to any configured escalation URL (separate from primary webhook). Push notification with \"URGENT\" prefix. Dashboard banner pulses red.\n\nAcknowledgement: the [Acknowledge] button on the dashboard banner:\n- Transitions state to ALERT_ACKNOWLEDGED\n- Cancels the T+2min and T+5min timers\n- Logs the acknowledgement in the activity timeline with timestamp\n- Shows a brief form: \"How is [person name]?\" — [Fine, just fell] / [Needed help] / [False alarm]\n- Feedback stored for accuracy tracking (Phase 7)\n\n## Person Identification in Alerts\n\nIf the track has a confirmed BLE identity:\n- Alert: \"Possible fall detected — Alice in Hallway\"\n- Notification: includes person name and zone\n\nIf the track is anonymous:\n- Alert: \"Possible fall detected — unknown person in Hallway\"\n- Fall event still fires and escalates on the same timeline\n\n## Children's Zones\n\nAdd is_children_zone boolean flag to the zones table (see portals bead). A zone marked as children's zone suppresses fall detection for all tracks within it. Set via zone editor in the dashboard. Rationale: young children spend significant time at floor level (crawling, playing), and false fall alerts in a nursery or playroom are highly counterproductive.\n\n## Z Position Accuracy Dependency\n\nFall detection quality is directly tied to the accuracy of 3D position estimates from the tracker (spaxel-n9n). The UKF state includes Z position, but Z is the hardest axis to localise with WiFi CSI — the nodes are typically all at similar heights (wall-mounted or tabletop), so the vertical Fresnel zone geometry is poor. In practice:\n- Z accuracy is typically ±0.3m for direct LoS links\n- Fall detection requires a Z drop of > 0.8m, which should be detectable even with ±0.3m Z error\n- The 30-second stillness confirmation is the primary false-positive filter — it tolerates poor Z accuracy\n\nDocument this in the dashboard: \"Fall detection works best with nodes at different heights (e.g. one high, one low). Single-level node deployment may miss smaller Z changes.\"\n\n## Integration with Existing Systems\n\nFallDetector subscribes to TrackManager updates (the same 10 Hz update loop as identity matching). It receives the full TrackState including position history and posture hints.\n\nFallDetector emits FallEvent to the internal event bus on FALL_CONFIRMED. The event bus (Phase 8 activity timeline bead) logs it to the timeline. The AutomationEngine processes it for automation triggers. The spatial context notifications module (Phase 6) sends the push notification.\n\n## Files to Create or Modify\n\n- mothership/internal/tracker/fall.go: FallDetector struct, state machine, descent/stillness detection\n- mothership/internal/tracker/manager.go: integrate FallDetector in update loop\n- mothership/internal/events/events.go: FallEvent type\n- dashboard/js/fall.js: fall alert banner, acknowledgement handler, escalation countdown\n- mothership/internal/dashboard/routes.go: POST /api/fall/{event_id}/acknowledge\n\n## Tests\n\n- Test descent trigger: inject synthetic track with Z velocity = -2.0 m/s over 500ms and drop from 1.7m to 0.2m. Verify DESCENT_DETECTED transition.\n- Test post-fall confirmation: after descent trigger, inject 30 seconds of deltaRMS < 0.005 at Z = 0.2m. Verify FALL_CONFIRMED transition.\n- Test that a quick sit-down (Z drops from 1.7m to 0.5m at -1.0 m/s — below speed threshold) does NOT trigger DESCENT_DETECTED.\n- Test false positive suppression: track has \"sitting\" posture history, then fall of 0.9m fires — verify higher threshold (1.2m) is applied and no trigger for 0.9m drop.\n- Test alert chain timing: verify T+2min and T+5min timers fire without acknowledgement.\n- Test that acknowledgement cancels pending timers.\n- Test that children's zone flag suppresses fall detection.\n\n## Acceptance Criteria\n\n- Fall detector transitions to FALL_CONFIRMED on a simulated fall trajectory (2.0 m/s descent, 1.5m drop, 30s floor stillness)\n- Post-fall stillness confirmation fires after 30 consecutive seconds of stillness at Z < 0.4m\n- False positive suppression reduces alerts for quick sit-down actions (drop < 0.8m or speed < 1.5 m/s)\n- Alert chain fires at T+0, T+2min, T+5min without acknowledgement\n- Acknowledgement correctly cancels pending escalation timers\n- Person name included in alert when BLE identity is confirmed\n- Children's zone flag suppresses all fall detection for tracks within that zone\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:47:29.093424440Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.730764084Z","closed_at":"2026-03-29T18:07:39.730401434Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-c1c","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.322493912Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-c1c","depends_on_id":"spaxel-n9n","type":"blocks","created_at":"2026-03-28T01:47:33.774431809Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-c41","title":"Bidirectional node protocol","description":"## Background\n\nPhase 1 established the WebSocket connection and binary/JSON frame parsing. Phase 3 requires a richer downstream command set for multi-node coordination. The firmware (firmware/main/websocket.c) already parses these commands — what's missing is the mothership sending them in response to real events.\n\n## What Already Exists\n- mothership/internal/ingestion/message.go: hello, health, ble, ota_status, motion_hint message types parsed\n- mothership/internal/ingestion/server.go: handles incoming messages, sends initial role and config on connect\n- firmware/main/websocket.c: parses role, config, ota, reboot, identify, reject downstream commands\n\n## What to Implement\n\n### 1. NodeSession abstraction\nCreate mothership/internal/ingestion/session.go: NodeSession struct wrapping a WebSocket connection. Methods: SendRole(role string), SendConfig(sampleRate int, passiveBSSID string), SendOTA(url, md5, version string), SendReboot(), SendIdentify() (triggers LED blink). NodeSession is stored in a registry keyed by nodeMAC.\n\n### 2. Role push after hello\nOn receiving a hello message, look up the node's assigned role from the fleet manager (spaxel-8u3). If fleet manager not yet wired (Phase 3 in progress), default to 'rx'. Send role command immediately after hello processing.\n\n### 3. Config push\nAfter role push, send config: {type:'config', sample_rate:20, passive_bssid:'' (if passive role, populated from fleet manager)}. This replaces the hardcoded config send in the current server.go.\n\n### 4. OTA trigger\nPOST /api/nodes/{mac}/ota already planned (OTA bead). Wire NodeSession.SendOTA() to be callable from the fleet/OTA HTTP handler.\n\n### 5. BLE relay to identity service\nOn receiving a ble message, forward BLE device list to a BLERegistry stub (Phase 6). For now, log at INFO level with structured fields: node_mac, device_count, devices[].\n\n### 6. NodeSession registry\nmothership/internal/ingestion/registry.go: thread-safe map[string]*NodeSession. On node disconnect (WebSocket close), remove from registry. Expose: Register(mac, session), Get(mac) (*NodeSession, bool), All() []*NodeSession.\n\n## Key Files\n- mothership/internal/ingestion/server.go — main handler to refactor\n- mothership/internal/ingestion/message.go — message type reference\n- firmware/main/websocket.c — firmware-side command parsing (read-only reference)\n\n## Acceptance Criteria\n- Node receives role push within 100ms of hello\n- Config push follows role push with correct sample_rate\n- NodeSession registry correctly tracks connected nodes\n- NodeSession.Get(mac) returns nil for disconnected node\n- BLE messages logged at INFO with correct fields\n- go test ./internal/ingestion/... passes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T03:30:50.333442376Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.164722333Z","closed_at":"2026-03-28T05:36:26.164648947Z","close_reason":"Implemented: ingestion/server.go + ratecontrol.go (bcfd1e3) — role/config/OTA push within 100ms of hello, NodeSession abstraction, firmware websocket.c+csi.c dynamic rate","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-c41","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:30:50.333442376Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-ciu","title":"Trigger spaxel CI build and verify ardenone-cluster deployment","description":"## Problem\n\nThe spaxel deployment on ardenone-cluster has been stuck in ErrImagePull for 4+ days:\n\n Pod: spaxel-7998b64b7b-ff2qd (namespace: spaxel)\n Error: docker.io/ronaldraygun/spaxel:0.1.0: not found\n\nThe image has never been built/pushed to Docker Hub. The ArgoCD app (spaxel-ns-ardenone-cluster) is Synced but health is Degraded.\n\n## What's in place\n\n- Manifests: declarative-config/k8s/ardenone-cluster/spaxel/ (deployment, service, namespace, PVC) ✓\n- Secret: docker-hub-registry in spaxel namespace ✓\n- WorkflowTemplate: spaxel-build deployed to iad-ci argo-workflows ✓\n- ArgoCD ApplicationSet auto-syncs from declarative-config ✓\n\n## Steps\n\n1. Trigger the spaxel-build WorkflowTemplate on iad-ci to build and push ronaldraygun/spaxel:0.1.0\n kubectl --kubeconfig=/home/coding/.kube/iad-ci.kubeconfig create -f - < 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":"open","priority":2,"issue_type":"task","created_at":"2026-04-07T14:46:37.333473683Z","created_by":"coding","updated_at":"2026-04-07T14:46:37.333473683Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["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":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-07T14:46:37.333473683Z","created_by":"coding","updated_at":"2026-04-07T17:55:53.917194202Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["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":"in_progress","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-07T13:56:27.334232115Z","created_by":"coding","updated_at":"2026-04-07T17:34:37.752196886Z","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":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:09:42.683611381Z","created_by":"coding","updated_at":"2026-04-07T01:29:29.566185778Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-fi6","title":"Implement Portals CRUD REST endpoints","description":"Implement CRUD endpoints for portals: GET/POST /api/portals, PUT/DELETE /api/portals/{id}. Include OpenAPI-style godoc comments. Portal changes must reflect in live 3D view within one WebSocket cycle.","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-07T13:56:27.334232115Z","created_by":"coding","updated_at":"2026-04-07T17:56:13.860592476Z","closed_at":"2026-04-07T17:56:13.860493596Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-21n"]} +{"id":"spaxel-fll","title":"Dashboard WebSocket: snapshot-on-connect + incremental update protocol","description":"## Overview\nImplement the snapshot+incremental WebSocket protocol so the dashboard renders immediately on connect without waiting for a full state cycle.\n\n## Protocol spec\n\n### On new /ws/dashboard connection (within 100 ms):\nSend a full snapshot message:\n {type: 'snapshot', blobs: [...], nodes: [...], zones: [...], links: [...], alerts: [...], ble_devices: [...], triggers: [...], timestamp_ms: N}\n\n### Subsequent messages (at 10 Hz):\nOmit type field; send only state that changed since last tick:\n {blobs: [...], nodes: [...], confidence: 0.87, timestamp_ms: N}\nUnchanged arrays may be omitted entirely (null = no change)\n\n## Implementation (mothership/internal/dashboard/hub.go)\n\n- Hub maintains lastSnapshot: full state snapshot updated on each tick\n- On new client connection: serialize lastSnapshot as JSON, send immediately\n- On each tick: compute delta (changed fields only); broadcast to all established clients\n- Snapshot must be sent before the client is added to the broadcast list to avoid race\n\n## Reconnect handling (dashboard/js/app.js)\n- On WebSocket open: set awaitingSnapshot = true\n- On first message: if type === 'snapshot', merge into app state and clear flag\n- On subsequent messages: apply as incremental updates\n\n## Performance requirement\n- Snapshot delivery: < 100 ms after connection established, even with 10+ blobs, 16+ nodes, 20+ zones\n- Test: connect client, measure time to first render; must be < 150 ms end-to-end\n\n## Acceptance\n- Browser devtools shows first WS message with type='snapshot' within 100 ms of upgrade\n- Subsequent messages at 10 Hz omit type field\n- Reconnect after 5s disconnection shows correct current state immediately","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:09:42.683611381Z","created_by":"coding","updated_at":"2026-04-07T02:03:04.204480908Z","closed_at":"2026-04-07T02:03:04.204253757Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} +{"id":"spaxel-fyi","title":"Add trigger state messages to WebSocket feed","description":"Add 'trigger_state' message type to /ws/dashboard for automation trigger state changes. Broadcast: { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }. Handle in app.js onmessage.","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T14:18:27.606886433Z","created_by":"coding","updated_at":"2026-04-07T12:42:21.962246612Z","closed_at":"2026-04-07T12:42:21.961787747Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-g1o","title":"Anomaly detection: 7-day pattern learning algorithm","description":"## Overview\nImplement the statistical pattern learning engine for anomaly detection — per-zone, per-hour-of-day, per-day-of-week occupancy modeling using Welford's online algorithm.\n\n## Backend (mothership/analytics/ or signal/)\n- Pattern model: per (zone_id, hour_of_day, day_of_week): mean_count, variance, sample_count via Welford's algorithm\n- Hourly update goroutine: every hour, observe zone occupancy counts and update model\n- Cold start: suppress all anomaly alerts for 7 days; model slot 'ready' when sample_count >= 50\n- Anomaly scoring:\n - z_score = (observed_count - mean) / sqrt(variance + epsilon)\n - time_score = normalized z_score for this hour/day combo\n - zone_score = 1.0 if zone normally empty at this time, else 0.0\n - composite_score = max(time_score, zone_score) with fallback\n - threshold: alert if composite > 0.85; yellow warning at 0.60\n- Outlier protection: skip model update when anomaly_score >= 0.5 (don't learn from anomalies)\n- Security mode override: any detection = score 1.0 regardless of model\n- SQLite anomaly_patterns table: zone_id, hour_of_day (0-23), day_of_week (0-6), mean_count REAL, variance REAL, sample_count INT, updated_at INT\n\n## REST API\n- GET /api/anomalies?since=24h — list recent anomaly events with scores\n- GET /api/anomaly_patterns?zone= — inspect pattern model for debugging\n\n## Acceptance\n- Pattern model survives server restart (persisted to SQLite)\n- No alerts during 7-day cold start regardless of activity\n- Welford update is numerically stable: no NaN/Inf at any sample count\n- Outlier protection confirmed: injecting synthetic anomaly does not corrupt model after 3 occurrences\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:02:39.580201662Z","created_by":"coding","updated_at":"2026-04-07T01:28:23.140993262Z","closed_at":"2026-04-07T01:28:23.140700890Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} -{"id":"spaxel-goc","title":"BLE device discovery & registration dashboard panel","description":"## Overview\nPeople & Devices panel for discovering, registering, and labeling BLE devices seen by the fleet.\n\n## Dashboard (dashboard/js/ble-panel.js)\n- 'People & Devices' panel (via spaxel-896 panel framework)\n- Discovered devices list: sorted by sighting frequency; shows MAC (truncated), name, RSSI, last seen, type icon\n- Registration UI: click device → assign label, type (person/pet/object), color\n- Auto-type hints from manufacturer data: iPhone, Apple Watch, Fitbit, Tile, AirTag\n- Manual pre-registration by address (for tracker tags not yet seen)\n- Unregistered count badge on panel toggle button\n\n## Backend\n- SQLite ble_devices table: addr, label, type, color, icon, first_seen, last_seen, last_rssi, sighting_count\n- GET /api/ble/devices?registered=true|false — filter registered vs discovered\n- PUT /api/ble/devices/{mac} — set label, type, color, assign to person\n- GET /api/ble/devices/{mac}/history — sighting timeline\n\n## Acceptance\n- Panel shows all devices seen in last 24h by default\n- Label assignment persists across server restart\n- Registered devices show up with name in 3D blob labels and timeline events\n- Requires: spaxel-896 (panel framework), spaxel-9eg (BLE WS feed)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:29.882665390Z","created_by":"coding","updated_at":"2026-04-06T13:01:29.882665390Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-glq","title":"fix: apdetector imports wrong module prefix (jedarden vs spaxel)","description":"## Problem\n`internal/apdetector/detector.go:14` imports `github.com/jedarden/spaxel/mothership/internal/oui` but the module is `github.com/spaxel/mothership`.\n\n## Fix\nIn `mothership/internal/apdetector/detector.go` line 14, change:\n```go\n\"github.com/jedarden/spaxel/mothership/internal/oui\"\n```\nto:\n```go\n\"github.com/spaxel/mothership/internal/oui\"\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/apdetector/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:29:41.749357378Z","created_by":"coding","updated_at":"2026-04-06T22:32:46.587104774Z","closed_at":"2026-04-06T22:32:46.586900234Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-goc","title":"BLE device discovery & registration dashboard panel","description":"## Overview\nPeople & Devices panel for discovering, registering, and labeling BLE devices seen by the fleet.\n\n## Dashboard (dashboard/js/ble-panel.js)\n- 'People & Devices' panel (via spaxel-896 panel framework)\n- Discovered devices list: sorted by sighting frequency; shows MAC (truncated), name, RSSI, last seen, type icon\n- Registration UI: click device → assign label, type (person/pet/object), color\n- Auto-type hints from manufacturer data: iPhone, Apple Watch, Fitbit, Tile, AirTag\n- Manual pre-registration by address (for tracker tags not yet seen)\n- Unregistered count badge on panel toggle button\n\n## Backend\n- SQLite ble_devices table: addr, label, type, color, icon, first_seen, last_seen, last_rssi, sighting_count\n- GET /api/ble/devices?registered=true|false — filter registered vs discovered\n- PUT /api/ble/devices/{mac} — set label, type, color, assign to person\n- GET /api/ble/devices/{mac}/history — sighting timeline\n\n## Acceptance\n- Panel shows all devices seen in last 24h by default\n- Label assignment persists across server restart\n- Registered devices show up with name in 3D blob labels and timeline events\n- Requires: spaxel-896 (panel framework), spaxel-9eg (BLE WS feed)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:29.882665390Z","created_by":"coding","updated_at":"2026-04-06T19:21:57.494710305Z","closed_at":"2026-04-06T19:21:57.494608982Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-hey","title":"CSI recording buffer","description":"## Background\n\nThe CSI recording buffer is the foundation for time-travel debugging (Phase 8, spaxel-pvz). Every CSI binary frame received from a node should be persisted to disk in real-time. The plan specifies 48-hour retention. This bead implements the recording infrastructure and wires it into the ingestion server. Starting data collection in Phase 2 means real CSI data accumulates from day one, invaluable for debugging Phase 3+ algorithms offline.\n\n## What to Implement\n\nNew package: mothership/internal/recorder/\n\n### Segment file design\nUse 1-hour segment files per link: data/{nodeMAC}-{peerMAC}/{YYYYMMDD-HH}.csi. Each file is append-only. Frame format: [4-byte big-endian length][raw CSI binary frame bytes]. Background goroutine deletes segment files older than 48h (configurable via RecorderConfig.RetentionHours).\n\n### API\n- recorder.Manager — manages per-link recorders, one goroutine per link\n- recorder.Manager.Write(linkID string, frame []byte) — called from ingestion server per frame\n- recorder.Manager.ReadFrom(linkID string, since time.Time) <-chan []byte — returns channel of frames in chronological order from 'since' timestamp; closes channel when caught up to current time\n- recorder.Manager.AvailableRange(linkID string) (start, end time.Time, err error) — oldest and newest frame timestamps\n- recorder.Manager.Close() — graceful shutdown\n\n### Storage estimate\nAt 2Hz idle: ~176 bytes/frame × 2/s × 3600s × 48h = ~60MB/link/48h. At 50Hz active for 1h/day: add ~30MB. Total for 4 links × 48h ≈ 360MB–720MB. Configure via RecorderConfig.MaxBytesPerLink (default 1GB) as a secondary guard.\n\n### Wire-up\nIn mothership/internal/ingestion/server.go, after parsing a valid binary CSI frame (in the existing frame parsing path), call recorder.Manager.Write(linkID, rawFrameBytes). The recorder must not block the ingestion goroutine — use a buffered channel (capacity 1000 frames) per link.\n\n## Key Files\n- mothership/internal/ingestion/server.go — add recorder.Write call after frame parse\n- mothership/internal/ingestion/frame.go — frame parsing reference\n- New: mothership/internal/recorder/manager.go, recorder/segment.go, recorder/segment_test.go, recorder/manager_test.go\n\n## Acceptance Criteria\n- CSI frames written to segment files in real-time (< 10ms write latency)\n- ReadFrom correctly replays frames in timestamp order\n- Segment files older than RetentionHours deleted automatically\n- Write does not block ingestion goroutine (buffered channel, drops with warning if full)\n- go test ./internal/recorder/... passes","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T03:29:50.502500283Z","created_by":"coding","updated_at":"2026-03-28T04:28:24.975258849Z","closed_at":"2026-03-28T04:28:24.974961809Z","close_reason":"CSI recording buffer already implemented in commit 0816a5c. All components complete: recorder/segment.go (append-only 1-hour segment files), recorder/manager.go (per-link buffered recording with Write/ReadFrom/AvailableRange/Close, 48h retention, 1GB/link limit), full test coverage (20 tests passing), wired into ingestion server.go and main.go.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} +{"id":"spaxel-hf8","title":"Add BLE scan messages to WebSocket feed","description":"Add 'ble_scan' message type to /ws/dashboard for BLE device list updates every 5s. Broadcast: { type: 'ble_scan', devices: [{ mac, name, rssi, last_seen, label, blob_id }] }. Handle in app.js onmessage. Updates every 5s when devices present.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T14:18:27.545878314Z","created_by":"coding","updated_at":"2026-04-07T12:24:17.785369902Z","closed_at":"2026-04-07T12:24:17.785105015Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-hgm","title":"Spatial automation builder","description":"## Background\n\nThe ultimate value of presence detection is actionability. Users want their smart home to respond to who is where: lights on when someone enters, alerts when the baby leaves the nursery, notifications when the house is empty. The spatial automation builder provides a no-code interface for creating these rules, with webhook and MQTT delivery. It is the bridge between spaxel's physical sensing and the broader smart home ecosystem.\n\n## Automation Structure\n\nAn automation has three parts: trigger, optional conditions, and one or more actions. All three are stored as JSON in SQLite, allowing flexible extension without schema migrations.\n\nTrigger types:\n- zone_enter: fires when a person (or anyone) enters a named zone or custom trigger volume\n- zone_leave: fires when a person (or anyone) leaves a named zone\n- zone_dwell: fires when a person has been in a zone continuously for > N minutes (configurable threshold)\n- zone_vacant: fires when a zone transitions from occupied to empty (last person leaves)\n- person_count_change: fires when zone occupant count crosses a threshold (e.g. count goes from 2 to 3)\n- fall_detected: fires when fall detection (spaxel-zvs fall bead) fires for a person in a zone\n- anomaly: fires when anomaly detector (Phase 7) fires an anomaly event\n- ble_device_present: fires when a specific BLE device (or any labelled device) is first seen in a scan cycle (useful for \"arrive home\" detection)\n- ble_device_absent: fires when a specific BLE device has not been seen for > N minutes (useful for \"left home\" detection)\n\nCondition filters:\n- person_filter: specific person_id (or \"anyone\")\n- time_window: ISO 8601 time range (e.g. \"22:00-07:00\" for night)\n- day_of_week: bitmask (0=Sun, 1=Mon, ... 6=Sat)\n- system_mode: home, away, sleep (modes set by user or auto-detected)\n- zone_occupancy: additional zone occupancy condition (e.g. \"only if Living Room is empty\")\n\nAction types:\n- webhook: POST to a user-configured URL with JSON payload\n- mqtt_publish: publish to a topic with a payload (uses the MQTT client from home automation integration bead)\n- ntfy: send a push notification via ntfy (self-hosted or ntfy.sh)\n- pushover: send a Pushover notification\n\nPayload templating for all action types supports these variables:\n{{person_name}}, {{zone_name}}, {{from_zone}}, {{to_zone}}, {{timestamp}}, {{occupant_count}}, {{event_type}}, {{person_color}}, {{confidence}}\n\nExample webhook payload template:\n{\"text\": \"{{person_name}} entered {{zone_name}} at {{timestamp}}\", \"color\": \"{{person_color}}\"}\n\n## 3D Trigger Volumes\n\nIn addition to named zones, automations can use arbitrary 3D cuboid volumes as their spatial target. These are drawn in the 3D editor like zone bounding boxes but are not associated with a zone name — they exist only for automation triggers. Rendered as dashed-outline cuboids (not filled) in the 3D scene with the automation name as label.\n\nSQLite schema: trigger_volumes (id, automation_id FK, name, bounds_min_xyz, bounds_max_xyz)\n\nThe crossing detection and occupancy logic from the portals bead (spaxel-qlh) is reused — trigger volumes use the same containment test.\n\n## Automations SQLite Schema\n\nCREATE TABLE automations (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n enabled BOOLEAN DEFAULT TRUE,\n trigger_type TEXT NOT NULL,\n trigger_config TEXT NOT NULL, -- JSON: {\"zone_id\":\"...\",\"person_id\":\"anyone\",\"dwell_minutes\":5}\n conditions TEXT, -- JSON array of condition objects\n actions TEXT NOT NULL, -- JSON array of action objects\n last_fired DATETIME,\n fire_count INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n## AutomationEngine\n\nNew struct: mothership/internal/automation/engine.go\n\nAutomationEngine subscribes to the internal event bus (Phase 8, spaxel-sl2 activity timeline — the event bus is implemented there). On each event, it:\n1. Finds all enabled automations whose trigger_type matches the event type\n2. For each matching automation, evaluates all conditions\n3. If all conditions pass: fires the actions\n\nCondition evaluation:\n- time_window: parse \"HH:MM-HH:MM\", check if current time (in server timezone) is within range. Handle overnight ranges (22:00-07:00 spans midnight).\n- day_of_week: check current day against bitmask\n- person_filter: check event.PersonID against filter (or \"anyone\" wildcard)\n- system_mode: check current SystemMode (stored in mothership state)\n\nAction execution:\n- webhook: goroutine with 5s timeout, HTTP POST with rendered payload. Retry once after 30s on 5xx response. Log all requests and responses.\n- mqtt_publish: synchronous publish if MQTT client is connected. If not connected: log \"MQTT not configured\" and skip.\n- ntfy/pushover: use the notification module (Phase 6 spatial context notifications bead).\n\nAction results (success/failure/timeout) are logged in SQLite: action_log (automation_id, fired_at, event_json, actions_results_json).\n\n## Dashboard UI\n\nAutomations management page (route /automations):\n- List view: table of all automations with name, trigger type, enabled toggle, last fired timestamp, fire count, and edit/delete actions\n- Create/edit modal: step-by-step builder:\n 1. Choose trigger: dropdown of trigger types with plain-English labels (\"When someone enters a room\", \"When a room becomes empty\", etc.)\n 2. Configure trigger: zone picker (or \"Any zone\"), person picker (or \"Anyone\"), threshold for dwell/count\n 3. Add conditions (optional): time window picker, day picker, system mode selector\n 4. Add action: action type dropdown, then action-specific fields (URL for webhook, topic for MQTT, ntfy server + topic)\n 5. Template editor for payload with variable hints\n 6. Summary: \"When Alice enters the Kitchen between 7am and 9am on weekdays, POST to https://...\"\n- \"Test fire\" button: simulates the trigger event and fires all actions with test_mode=true flag in payload. Useful for debugging webhooks.\n- 3D view integration: when an automation's trigger zone is hovered in the automation editor, the corresponding zone/trigger volume highlights in the 3D scene.\n- Visual feedback in 3D view when trigger fires: brief highlight (bright flash) of the trigger zone in the 3D scene.\n\n## System Mode\n\nSystemMode is a top-level state: HOME, AWAY, SLEEP.\n- HOME: normal operation\n- AWAY: all registered BLE devices absent, security-level alert on any detection (managed by anomaly detection, Phase 7)\n- SLEEP: quiet hours active, non-urgent notifications suppressed\n\nMode changes:\n- Manual toggle from dashboard settings\n- Auto-away: all registered BLE devices absent for > 15 minutes -> auto-set AWAY\n- Auto-home: first registered BLE device seen again -> auto-set HOME\nAuto-sleep: user can configure a schedule (e.g. 10pm-7am on weekdays)\n\nMode is exposed as GET /api/mode and POST /api/mode {\"mode\":\"home\"/\"away\"/\"sleep\"}.\n\n## Tests\n\n- Test trigger matching for each trigger type: inject matching event, verify automation fires; inject non-matching event, verify no fire\n- Test time_window condition: \"22:00-07:00\" blocks at 08:00, passes at 23:00, passes at 04:00\n- Test overnight time range correctly handles midnight boundary\n- Test person_filter condition: \"anyone\" matches all events; specific person_id only matches events with that person\n- Test webhook dispatch: mock HTTP server, verify POST arrives with correct rendered payload\n- Test webhook retry: mock server returns 503 first request, 200 second, verify retry fires after 30s\n- Test MQTT publish with mock broker: verify correct topic and payload\n- Test \"test fire\" mode sets test_mode=true in payload\n- Test fire_count increments in SQLite after each fire\n\n## Acceptance Criteria\n\n- Automation fires correctly within 200ms of its trigger event for each trigger type\n- Webhook delivers payload to mock server within 5s\n- MQTT message arrives with correct topic and payload\n- Time-window condition blocks automations outside their configured window\n- 3D trigger volume editor allows drawing custom volumes not tied to named zones\n- \"Test fire\" button correctly simulates trigger without requiring a real event\n- Fire count and last_fired timestamp update in database after each fire\n- Retry mechanism handles transient webhook failures\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:46:36.925844184Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.766389180Z","closed_at":"2026-03-29T18:07:39.766280132Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-hgm","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.294048305Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-hnp","title":"Presence prediction","description":"## Background\n\nAfter 7+ days of operation, spaxel has accumulated a detailed history of each person's movements: which rooms they visit at what times, how long they dwell, and typical transition patterns. These patterns are remarkably consistent for most people: wake up in the bedroom, kitchen around 7am, leave around 8:30am, return around 6pm. By modelling these patterns as a transition probability matrix, spaxel can predict where people will be next and approximately when they will get there. This enables genuinely useful proactive automation (\"pre-warm the kitchen 15 minutes before Alice typically arrives\") and helpful dashboard widgets.\n\n## Transition Probability Model\n\nThe model is a Markov chain indexed by (person_id, hour_of_week) — there are 7 * 24 = 168 hour-of-week slots.\n\nFor each person, for each (hour_of_week, from_zone_id) pair:\n- Count total transitions out of from_zone in that hour_of_week slot\n- Count transitions to each destination zone in that slot\n- Compute probability = count_to_zone / total_transitions_from_zone\n\nThis is a first-order Markov chain with time-of-week context. It is simple enough to compute incrementally and query in real-time, but captures the most important regularity in human daily routines.\n\nSQLite schema:\nCREATE TABLE zone_transitions_history (\n id TEXT PRIMARY KEY,\n person_id TEXT,\n from_zone_id TEXT,\n to_zone_id TEXT,\n hour_of_week INTEGER, -- 0-167: day_of_week * 24 + hour_of_day\n dwell_duration_minutes REAL, -- how long they were in from_zone before transitioning\n timestamp DATETIME\n);\n\nCREATE TABLE transition_probabilities (\n person_id TEXT,\n hour_of_week INTEGER,\n from_zone_id TEXT,\n to_zone_id TEXT,\n probability REAL,\n count INTEGER, -- raw count for Bayesian smoothing\n last_computed DATETIME,\n PRIMARY KEY (person_id, hour_of_week, from_zone_id, to_zone_id)\n);\n\nProbability computation: run weekly (or on-demand). For each (person, hour_of_week, from_zone) group: normalize counts to probabilities. Apply Laplace smoothing (add 1 to each count before normalizing) to handle zero-count slots gracefully.\n\n## Dwell Time Model\n\nAlongside transition probabilities, track typical dwell duration per (person_id, hour_of_week, zone_id). This allows estimating when the next transition will occur.\n\nCREATE TABLE dwell_times (\n person_id TEXT, zone_id TEXT, hour_of_week INTEGER,\n mean_minutes REAL, stddev_minutes REAL, count INTEGER,\n PRIMARY KEY (person_id, zone_id, hour_of_week)\n);\n\nPrediction of time-to-next-transition: given that Alice is currently in the Kitchen at 7:30am on a Tuesday (hour_of_week = Tuesday*24 + 7 = 7+4*24=103), and she has been there for 8 minutes:\n- Look up dwell_times for (alice, Kitchen, 103): mean=12min, stddev=3min\n- Expected remaining time = max(0, mean - elapsed) = 4 minutes\n- Predicted next zone: argmax over transition_probabilities(alice, 103, Kitchen) = Living Room (78%)\n\n## Minimum Data Requirement\n\nPredictions are only meaningful after sufficient data is accumulated. Requirement: at least 7 days of data AND at least 3 samples for the specific (hour_of_week, from_zone) pair being predicted. The API returns {\"confidence\": \"insufficient_data\"} if the minimum data requirement is not met.\n\nShow this clearly in the dashboard: \"Alice's prediction will be ready in [N] days. Currently learning her patterns.\"\n\n## Prediction Output\n\nThe prediction output at any given moment is a list of PersonPrediction structs:\n{\n person_id TEXT,\n person_label TEXT,\n current_zone_id TEXT,\n current_zone_name TEXT,\n predicted_next_zone_id TEXT,\n predicted_next_zone_name TEXT,\n prediction_confidence REAL, -- the transition probability to the predicted zone\n estimated_transition_minutes REAL, -- expected time until transition (from dwell model)\n data_confidence TEXT -- \"sufficient\" or \"insufficient_data\"\n}\n\nREST API: GET /api/predictions returns []PersonPrediction\n\n## Dashboard Widget\n\n\"Predictions\" panel in expert mode (can also appear in simple mode, Phase 9):\n- Card per tracked person showing: name, current zone icon, arrow, predicted next zone icon\n- Confidence percentage: \"78% likely\"\n- Estimated time: \"in ~4 minutes\"\n- Small tooltip: \"Based on N Tuesdays\"\n- \"Predictions unavailable — collecting data [7 days remaining]\" placeholder for new deployments\n\n## Home Assistant MQTT Sensor\n\nPublish to MQTT: spaxel/{mothership_id}/person/{person_id}/predicted_zone\nPayload: JSON {\"zone_id\":\"...\",\"zone_name\":\"Living Room\",\"confidence\":0.78,\"estimated_minutes\":4}\nRetained: false (event topic)\n\nThis allows HA automations to use predictions:\n- \"15 minutes before Alice typically enters the bedroom, turn on the bedroom lamp\"\n\nNew automation trigger type in the automation builder (spaxel-hgm): predicted_zone_enter(person_id, zone_id, minutes_ahead). This trigger fires N minutes before the predicted zone entry.\n\n## Files to Create or Modify\n\n- mothership/internal/prediction/model.go: transition probability model, dwell time model\n- mothership/internal/prediction/predictor.go: PersonPredictor, GetPredictions()\n- mothership/internal/prediction/history.go: IncrementalHistoryUpdater, probability recomputation\n- mothership/internal/dashboard/routes.go: GET /api/predictions\n- mothership/internal/mqtt/client.go: add predicted_zone topic publishing\n- dashboard/js/predictions.js: Predictions panel\n- mothership/internal/automation/engine.go: add predicted_zone_enter trigger type\n\n## Tests\n\n- Test transition probability computation: 10 Kitchen->Living Room transitions and 5 Kitchen->Bedroom transitions in the same hour_of_week slot -> P(Living Room) ~= 0.67, P(Bedroom) ~= 0.33 (with Laplace smoothing)\n- Test dwell time estimation: expected remaining time formula with known elapsed time\n- Test that insufficient_data gate returns correct response before 7 days\n- Test prediction API response format and field types\n- Test predicted_zone_enter trigger fires N minutes before predicted transition (requires mock clock)\n- Test Laplace smoothing handles zero-count zones gracefully (no division by zero)\n\n## Acceptance Criteria\n\n- Predictions achieve > 75% accuracy at 15-minute horizon after 14 days of data (measured against held-out transition history)\n- Dashboard Predictions panel shows each tracked person with next-zone prediction and confidence\n- \"Insufficient data\" placeholder shown correctly before 7 days\n- MQTT predicted_zone sensor published after each prediction cycle\n- predicted_zone_enter automation trigger fires at correct lead time\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"sp2","created_at":"2026-03-28T01:51:16.226944535Z","created_by":"coding","updated_at":"2026-03-29T19:20:01.349909996Z","closed_at":"2026-03-29T19:20:01.349845980Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-hnp","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.474837777Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-i28","title":"Phase 7: Learning & Analytics","description":"Goal: System gets smarter over time. User feedback drives improvement.\n\nDeliverables:\n- Detection feedback loop (thumbs up/down, missed-detection marking, accuracy trend)\n- Self-improving localization (BLE as ground truth, Fresnel weight refinement)\n- Presence prediction (per-person/zone/time-slot transition probabilities, HA sensors)\n- Sleep quality monitoring (breathing analysis + motion scoring, morning summary)\n- Crowd flow visualization (trajectory accumulation, directional flow map, dwell hotspots)\n- Anomaly detection & security mode (7-day pattern learning, anomaly scoring)\n\nExit criteria: Accuracy improves measurably over 4 weeks. Predictions >75% at 15-min horizon.","status":"open","priority":3,"issue_type":"phase","created_at":"2026-03-27T01:55:39.902286407Z","created_by":"coding","updated_at":"2026-03-29T19:25:04.204717655Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-i28","depends_on_id":"spaxel-403","type":"blocks","created_at":"2026-03-29T19:25:04.204685274Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-bf5","type":"blocks","created_at":"2026-03-29T19:25:04.172153573Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-klf","type":"blocks","created_at":"2026-03-29T19:25:04.022963164Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-pgu","type":"blocks","created_at":"2026-03-29T19:25:03.967632695Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-qpi","type":"blocks","created_at":"2026-03-29T19:25:04.133317007Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-s60","type":"blocks","created_at":"2026-03-29T19:25:04.082109284Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-i28","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T01:33:48.369590901Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -68,33 +73,35 @@ {"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":"charlie","created_at":"2026-03-28T01:42:17.825002481Z","created_by":"coding","updated_at":"2026-04-02T01:18:14.868719476Z","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-jy4","title":"Crowd flow visualisation","description":"## Background\n\nOver days and weeks, the movement patterns of household members accumulate into meaningful flows: the main corridor between bedroom and bathroom, the typical path from the front door to the kitchen, habitual dwell spots (the favourite chair, the home office desk, the kitchen counter). Visualising these as directional flow maps and dwell hotspot heatmaps provides useful insight into how the space is actually used — and can inform furniture placement, automation placement, and even architectural decisions. It's also a compelling visual that demonstrates the system's accumulated knowledge.\n\n## FlowAccumulator\n\nNew package: mothership/internal/analytics/flow.go\n\nFlowAccumulator subscribes to TrackManager updates (10 Hz) and accumulates trajectory data.\n\nTrajectory sampling: for each track update, if the track has moved > 0.2m since the last recorded waypoint (for that track), record the movement:\n- from_xyz: last waypoint position\n- to_xyz: current position\n- speed: metres per second at this step\n- person_id: if identity is known\n- timestamp\n\nThis 0.2m threshold prevents accumulating thousands of micro-samples for stationary people.\n\nSQLite table: trajectory_segments (id TEXT PRIMARY KEY, person_id TEXT, from_x REAL, from_y REAL, from_z REAL, to_x REAL, to_y REAL, to_z REAL, speed REAL, timestamp DATETIME). Only store ground plane (from_z and to_z floor-projected: set to 0 for the flow map, since we render on the ground plane).\n\nTable growth management: the table accumulates indefinitely. Prune segments older than 90 days (configurable) with a daily background job. With 4 people at typical home movement rates, 90 days generates approximately 50,000 segments — manageable for SQLite.\n\n## Flow Map Computation\n\nQuery: for each 0.25m grid cell (same resolution as OccupancyGrid in FusionEngine), average the movement vectors of all trajectory segments that pass through that cell.\n\nSQL approach: for each segment, determine which grid cells it passes through (Bresenham's line algorithm on the grid). Accumulate vector components (to_x - from_x, to_y - from_y) into per-cell accumulators.\n\nIn practice: compute on demand when requested (not continuously). Cache the result for up to 5 minutes (or until a \"flow dirty\" flag is set by new trajectory data).\n\nOutput: FlowMap struct with per-cell vectors (x_component, y_component) and a cell count. Serialised to JSON for the dashboard.\n\n## Dwell Hotspot Heatmap\n\nQuery: for each track update where speed < 0.1 m/s (stationary or near-stationary), increment the dwell counter for the corresponding 0.25m grid cell.\n\nSQLite table: dwell_accumulator (grid_x INT, grid_y INT, person_id TEXT, count INT, last_updated DATETIME, PRIMARY KEY (grid_x, grid_y, person_id)). Aggregated at the person+cell level for person-filtered views.\n\nOutput: DwellHeatmap struct mapping (grid_x, grid_y) to count. Normalised to [0, 1] by dividing by the max count across all cells.\n\n## Corridor Detection\n\nIdentify grid cells with consistently high flow volume AND low angular variance in their flow vectors. These are likely corridors or pathways.\n\nAlgorithm:\n1. For each cell, compute the circular variance of the flow vector angles across all segments that contributed. Low variance = directional consistency = corridor.\n2. Threshold: cells with segment_count > 10 AND circular_variance < 0.3 are candidate corridor cells.\n3. Connected component analysis: group adjacent corridor cells into corridor regions.\n4. Each corridor region is represented by its dominant direction and a bounding box.\n\nCorridor regions are stored in SQLite: detected_corridors (id, centroid_xyz, dominant_direction_xy, length_m, width_m, cell_count, last_computed). Recomputed weekly.\n\n## Time and Person Filters\n\nThe dashboard allows filtering flow data by:\n- Time range: \"Today\", \"This week\", \"This month\", custom date range. Implemented as SQL WHERE timestamp >= ? filters on the trajectory_segments table.\n- Person: filter to show only trajectories attributed to a specific person_id (or \"All people\").\n\nFiltered queries are run on-demand with SQL indices on (timestamp, person_id).\n\n## Dashboard Visualisation\n\nAdd two toggle-able layers to the 3D scene (in addition to existing layers):\n\n1. \"Flow\" layer: render flow vectors as animated arrows on the ground plane. Each arrow is positioned at the cell centre, oriented in the cell's average flow direction, and sized proportional to the flow volume (segment count). Use Three.js ArrowHelper for rendering. Animate: cycle the arrow colour from 0% to 100% opacity (flowing effect) on a 2-second loop. Only render cells with > 5 segments.\n\n2. \"Dwell Hotspot\" layer: render a heatmap on the ground plane as coloured rectangle patches (Three.js PlaneGeometry with MeshBasicMaterial, colour mapped from blue (low dwell) through green to red (high dwell)). Opacity 0.4. Only render cells with > 10 dwell samples.\n\n3. Corridor highlighting: detected corridors rendered as slightly raised platform geometry (extruded rectangle, height 0.01m) with a pathway colour (warm grey, opacity 0.3). Toggle-able as sub-option of the \"Flow\" layer.\n\nLayer controls: new \"Patterns\" section in the 3D layer control panel. Three checkboxes: \"Movement flows\", \"Dwell hotspots\", \"Corridors\". Time filter dropdown: \"All time / Last 7 days / Last 30 days\". Person filter dropdown.\n\n## REST API\n\nGET /api/analytics/flow?person_id=&since=&until= — returns FlowMap JSON\nGET /api/analytics/dwell?person_id=&since=&until= — returns DwellHeatmap JSON\nGET /api/analytics/corridors — returns list of DetectedCorridor\n\n## Tests\n\n- Test trajectory sampling: track moves 0.25m -> segment recorded; track moves 0.05m -> no segment\n- Test flow vector averaging: 5 segments all pointing East -> cell vector = (1, 0); 5 East + 5 North -> cell vector ~= (0.5, 0.5)\n- Test dwell accumulation: 100 track updates at speed=0 in cell (5, 7) -> dwell_accumulator[5][7] count = 100\n- Test corridor detection: 20 aligned segments in adjacent cells with angular_variance < 0.3 -> corridor detected\n- Test time-range filtering: insert segments at T-1day and T-8days; query since T-7days -> only T-1day segment returned\n- Test 90-day pruning job removes old segments\n\n## Acceptance Criteria\n\n- Flow layer renders correctly in 3D view with animated arrows for rooms with > 7 days of data\n- Dwell hotspot heatmap visible and renders high-use spots (favourite chair, kitchen counter) correctly\n- Corridor overlay visible with detected high-traffic pathways\n- Time and person filter controls update the rendered layers\n- Layer toggles show/hide each layer cleanly without scene rebuild\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-03-30T16:27:42.718965Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} +{"id":"spaxel-jy4","title":"Crowd flow visualisation","description":"## Background\n\nOver days and weeks, the movement patterns of household members accumulate into meaningful flows: the main corridor between bedroom and bathroom, the typical path from the front door to the kitchen, habitual dwell spots (the favourite chair, the home office desk, the kitchen counter). Visualising these as directional flow maps and dwell hotspot heatmaps provides useful insight into how the space is actually used — and can inform furniture placement, automation placement, and even architectural decisions. It's also a compelling visual that demonstrates the system's accumulated knowledge.\n\n## FlowAccumulator\n\nNew package: mothership/internal/analytics/flow.go\n\nFlowAccumulator subscribes to TrackManager updates (10 Hz) and accumulates trajectory data.\n\nTrajectory sampling: for each track update, if the track has moved > 0.2m since the last recorded waypoint (for that track), record the movement:\n- from_xyz: last waypoint position\n- to_xyz: current position\n- speed: metres per second at this step\n- person_id: if identity is known\n- timestamp\n\nThis 0.2m threshold prevents accumulating thousands of micro-samples for stationary people.\n\nSQLite table: trajectory_segments (id TEXT PRIMARY KEY, person_id TEXT, from_x REAL, from_y REAL, from_z REAL, to_x REAL, to_y REAL, to_z REAL, speed REAL, timestamp DATETIME). Only store ground plane (from_z and to_z floor-projected: set to 0 for the flow map, since we render on the ground plane).\n\nTable growth management: the table accumulates indefinitely. Prune segments older than 90 days (configurable) with a daily background job. With 4 people at typical home movement rates, 90 days generates approximately 50,000 segments — manageable for SQLite.\n\n## Flow Map Computation\n\nQuery: for each 0.25m grid cell (same resolution as OccupancyGrid in FusionEngine), average the movement vectors of all trajectory segments that pass through that cell.\n\nSQL approach: for each segment, determine which grid cells it passes through (Bresenham's line algorithm on the grid). Accumulate vector components (to_x - from_x, to_y - from_y) into per-cell accumulators.\n\nIn practice: compute on demand when requested (not continuously). Cache the result for up to 5 minutes (or until a \"flow dirty\" flag is set by new trajectory data).\n\nOutput: FlowMap struct with per-cell vectors (x_component, y_component) and a cell count. Serialised to JSON for the dashboard.\n\n## Dwell Hotspot Heatmap\n\nQuery: for each track update where speed < 0.1 m/s (stationary or near-stationary), increment the dwell counter for the corresponding 0.25m grid cell.\n\nSQLite table: dwell_accumulator (grid_x INT, grid_y INT, person_id TEXT, count INT, last_updated DATETIME, PRIMARY KEY (grid_x, grid_y, person_id)). Aggregated at the person+cell level for person-filtered views.\n\nOutput: DwellHeatmap struct mapping (grid_x, grid_y) to count. Normalised to [0, 1] by dividing by the max count across all cells.\n\n## Corridor Detection\n\nIdentify grid cells with consistently high flow volume AND low angular variance in their flow vectors. These are likely corridors or pathways.\n\nAlgorithm:\n1. For each cell, compute the circular variance of the flow vector angles across all segments that contributed. Low variance = directional consistency = corridor.\n2. Threshold: cells with segment_count > 10 AND circular_variance < 0.3 are candidate corridor cells.\n3. Connected component analysis: group adjacent corridor cells into corridor regions.\n4. Each corridor region is represented by its dominant direction and a bounding box.\n\nCorridor regions are stored in SQLite: detected_corridors (id, centroid_xyz, dominant_direction_xy, length_m, width_m, cell_count, last_computed). Recomputed weekly.\n\n## Time and Person Filters\n\nThe dashboard allows filtering flow data by:\n- Time range: \"Today\", \"This week\", \"This month\", custom date range. Implemented as SQL WHERE timestamp >= ? filters on the trajectory_segments table.\n- Person: filter to show only trajectories attributed to a specific person_id (or \"All people\").\n\nFiltered queries are run on-demand with SQL indices on (timestamp, person_id).\n\n## Dashboard Visualisation\n\nAdd two toggle-able layers to the 3D scene (in addition to existing layers):\n\n1. \"Flow\" layer: render flow vectors as animated arrows on the ground plane. Each arrow is positioned at the cell centre, oriented in the cell's average flow direction, and sized proportional to the flow volume (segment count). Use Three.js ArrowHelper for rendering. Animate: cycle the arrow colour from 0% to 100% opacity (flowing effect) on a 2-second loop. Only render cells with > 5 segments.\n\n2. \"Dwell Hotspot\" layer: render a heatmap on the ground plane as coloured rectangle patches (Three.js PlaneGeometry with MeshBasicMaterial, colour mapped from blue (low dwell) through green to red (high dwell)). Opacity 0.4. Only render cells with > 10 dwell samples.\n\n3. Corridor highlighting: detected corridors rendered as slightly raised platform geometry (extruded rectangle, height 0.01m) with a pathway colour (warm grey, opacity 0.3). Toggle-able as sub-option of the \"Flow\" layer.\n\nLayer controls: new \"Patterns\" section in the 3D layer control panel. Three checkboxes: \"Movement flows\", \"Dwell hotspots\", \"Corridors\". Time filter dropdown: \"All time / Last 7 days / Last 30 days\". Person filter dropdown.\n\n## REST API\n\nGET /api/analytics/flow?person_id=&since=&until= — returns FlowMap JSON\nGET /api/analytics/dwell?person_id=&since=&until= — returns DwellHeatmap JSON\nGET /api/analytics/corridors — returns list of DetectedCorridor\n\n## Tests\n\n- Test trajectory sampling: track moves 0.25m -> segment recorded; track moves 0.05m -> no segment\n- Test flow vector averaging: 5 segments all pointing East -> cell vector = (1, 0); 5 East + 5 North -> cell vector ~= (0.5, 0.5)\n- Test dwell accumulation: 100 track updates at speed=0 in cell (5, 7) -> dwell_accumulator[5][7] count = 100\n- Test corridor detection: 20 aligned segments in adjacent cells with angular_variance < 0.3 -> corridor detected\n- Test time-range filtering: insert segments at T-1day and T-8days; query since T-7days -> only T-1day segment returned\n- Test 90-day pruning job removes old segments\n\n## Acceptance Criteria\n\n- Flow layer renders correctly in 3D view with animated arrows for rooms with > 7 days of data\n- Dwell hotspot heatmap visible and renders high-use spots (favourite chair, kitchen counter) correctly\n- Corridor overlay visible with detected high-traffic pathways\n- Time and person filter controls update the rendered layers\n- Layer toggles show/hide each layer cleanly without scene rebuild\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"echo","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-04-09T08:50:53.910236304Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:325"]} {"id":"spaxel-jza","title":"Dashboard: PIN change flow","description":"## Overview\nAllow authenticated users to change their dashboard PIN after first setup.\n\n## Backend\n- POST /api/auth/change-pin — requires valid session; body: {old_pin:'...', new_pin:'...'}\n- Verify old_pin against current bcrypt hash; return HTTP 403 if mismatch\n- Hash new_pin with bcrypt cost=12; update auth.pin_bcrypt\n- Existing sessions remain valid after PIN change (session tokens are independent of PIN)\n- Return {ok:true} on success\n\n## Dashboard\n- Settings panel: 'Security' section with 'Change PIN' button\n- Modal form: old PIN → new PIN → confirm new PIN → Submit\n- On 403: show 'Incorrect current PIN' error inline\n- On success: show 'PIN changed successfully' toast; close modal\n\n## Acceptance\n- Old PIN still works immediately after change attempt fails (403)\n- New PIN works on next login after successful change\n- Active session cookie remains valid after PIN change\n- Requires: spaxel-nk6 (PIN auth)","status":"open","priority":3,"issue_type":"task","created_at":"2026-04-06T16:43:09.899017181Z","created_by":"coding","updated_at":"2026-04-06T16:43:09.899017181Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-klf","title":"Build self-improving localization","description":"Implement localization that learns from ground truth data.\n\nDeliverables:\n- BLE integration as ground truth source\n- Fresnel zone weight refinement algorithm\n- Continuous weight adjustment based on feedback\n\nAcceptance: Localization accuracy improves automatically as BLE ground truth data accumulates.","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-03-29T19:25:03.995110604Z","created_by":"coding","updated_at":"2026-04-02T01:19:06.575645095Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:924","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-klk","title":"Add floor plan backend API and storage","description":"## Backend (mothership/internal/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## Acceptance\n- Image upload saves file to /data/floorplan/image.png\n- Calibration data persists to SQLite\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T14:46:37.281038019Z","created_by":"coding","updated_at":"2026-04-07T19:03:01.027553189Z","closed_at":"2026-04-07T19:03:01.027363382Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-6hd"],"dependencies":[{"issue_id":"spaxel-klk","depends_on_id":"spaxel-05a","type":"blocks","created_at":"2026-04-07T17:55:53.393074362Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-b6a","type":"blocks","created_at":"2026-04-07T17:55:52.719854848Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-itf","type":"blocks","created_at":"2026-04-07T17:55:52.239848449Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-ts2","type":"blocks","created_at":"2026-04-07T17:55:50.722857752Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-xlo","type":"blocks","created_at":"2026-04-07T17:55:49.889540315Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-kth","title":"Mobile-responsive expert mode","description":"## Background\n\nThe expert mode 3D dashboard was built desktop-first: it assumes a large screen, mouse input, and keyboard shortcuts. On a tablet (10-inch iPad, Android tablet) or phone, the same interface needs adaptation: touch gestures instead of mouse, collapsible panels to preserve canvas space, responsive layout for portrait orientation, and appropriate touch target sizes. This bead systematically addresses all mobile-specific issues in the expert mode (simple mode and ambient mode already have their own mobile-optimised implementations).\n\n## Touch Controls for Three.js OrbitControls\n\nThree.js's OrbitControls already includes touch event handling:\n- Single-finger drag: orbit (rotate the camera around the scene centre)\n- Two-finger pinch: zoom (dollying)\n- Two-finger drag: pan (pan the camera laterally)\n\nHowever, several issues need to be resolved:\n\n1. Touch events from panel overlays propagating to the canvas: when a user touches a sidebar panel to scroll it, the touch event should not also orbit the scene. Fix: add touch event listeners on all panel elements with event.stopPropagation() to prevent bubbling to the canvas.\n\n2. iOS Safari passive event listener warning: OrbitControls uses non-passive touch listeners. iOS logs warnings about this. Fix: override event listener options in OrbitControls or configure the canvas touch-action CSS property: canvas { touch-action: none; }\n\n3. Double-tap to zoom conflict: iOS Safari intercepts double-taps as page zoom. Fix: meta viewport tag already has user-scalable=no (verify this is set in index.html). If not, add it.\n\n4. Pinch gesture accuracy: test on actual devices. If pinch feels imprecise, increase OrbitControls.zoomSpeed for touch input (separate from mouse zoomSpeed).\n\n5. Three-finger pan: useful on tablets. OrbitControls supports it but it may be disabled. Enable if not already active.\n\n## Hamburger Menu\n\nOn screens < 1024px width (tablets in portrait and all phones), replace the always-visible side panels with a hamburger menu:\n- Hamburger button: top-right of the header bar, next to the search icon. Three horizontal lines, 44px touch target.\n- Opening the menu: `transform: translateX(0)` CSS animation on the left sidebar panel. Duration: 200ms ease-out. Overlay backdrop: semi-transparent.\n- The menu contains: Node List, Link List, Presence Panel, Timeline (if visible), people and devices panel.\n- Active tab within the menu: the last-used panel opens first.\n- Close button inside the menu: top-right X, 44px. Also close on backdrop tap or Escape.\n\nCSS implementation: use `transform: translateX(-100%)` as the hidden state, `translateX(0)` as the shown state. Use CSS transitions (not JavaScript animation) for GPU-accelerated smoothness.\n\nMedia query breakpoints:\n- < 1024px: hamburger menu (single panel column replaces all sidebars)\n- < 768px: simple mode auto-activated by default (user can switch to expert)\n\n## Responsive Canvas\n\nThe Three.js canvas must fill the available space correctly at all screen sizes and orientations.\n\nOn orientation change:\n1. window.addEventListener('orientationchange', ...) — also listen to window.addEventListener('resize', ...)\n2. Update renderer.setSize(window.innerWidth, window.innerHeight) (or the canvas's container size)\n3. Update camera.aspect = window.innerWidth / window.innerHeight\n4. Call camera.updateProjectionMatrix()\n5. Trigger a re-render\n\niOS Safari specific: the visual viewport size can differ from window.innerWidth when the address bar is shown/hidden. Use visualViewport.width and visualViewport.height if available (iOS 13+), falling back to window.innerWidth/Height.\n\nBottom navigation bar (if simple mode is active): the Three.js canvas must not overlap the bottom nav. Use calc(100vh - 56px) as the canvas height (56px = nav bar height).\n\n## Touch-Friendly Targets\n\nAudit all interactive elements in the expert mode for touch target size compliance (WCAG 2.1 Success Criterion 2.5.5 Target Size: minimum 44x44px recommended):\n\nElements to resize:\n- Layer toggle checkboxes: increase clickable area with padding\n- Link list entries: ensure min 44px height\n- Panel close buttons: ensure 44px x 44px\n- Slider controls (baseline tau, threshold): ensure drag targets are at least 44px tall\n- Context menu items: min 44px height (should already be, verify)\n\nUse CSS padding to increase tap targets without changing visual size: add padding: 12px 8px to button elements, or use the :after pseudo-element trick for hitbox expansion.\n\n## Performance Optimisations for Mobile\n\nOn screens < 1024px width (treat as mobile/tablet):\n1. Cap devicePixelRatio at 2.0: `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2.0))`. This prevents 3x rendering on high-dpi displays which is unnecessary and expensive.\n2. Disable shadows: `renderer.shadowMap.enabled = false` on mobile. Shadow maps are expensive and home-scale scenes don't critically need them.\n3. Reduce maximum shadow map size to 512x512 if shadows remain enabled.\n4. Reduce antialias quality: use FXAA (Fast Approximate Anti-Aliasing as a post-process pass) instead of MSAA on mobile if needed.\n5. Cap frame rate at 30 fps on mobile (use `requestAnimationFrame` with a delta check) if the device is struggling.\n\n## iOS Safari Safe Area\n\nDevices with notches (iPhone X and later, newer iPads in landscape) have a \"safe area\" that content should not overlap. Use CSS environment variables:\n- body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }\n- The hamburger menu bottom should respect env(safe-area-inset-bottom) on iPhone home-button-less devices.\n\nThese CSS variables are zero on non-notched devices, so they are safe to apply universally.\n\nWebSocket behaviour: WebSocket works normally in iOS Safari, including when the app is backgrounded briefly (though connections may drop on long backgrounding — this is expected and the dashboard already has reconnection logic).\n\n## Files to Modify\n\n- dashboard/index.html: add meta viewport with user-scalable=no, verify safe-area meta tag\n- dashboard/css/expert.css: media queries for hamburger menu, responsive canvas, touch-friendly targets\n- dashboard/js/app.js: orientationchange and resize listeners, canvas resize handler\n- dashboard/js/controls.js (or wherever OrbitControls is initialised): touch event propagation fixes, canvas touch-action CSS\n\n## Tests\n\n- Test canvas resize handler: simulate a resize event with new width/height, verify renderer.setSize and camera.aspect are updated correctly\n- Test touch event propagation: touch event on a sidebar panel element does not reach the canvas (mock event bubbling)\n- Test hamburger menu open/close animation: mock CSS transition end event, verify panel reaches translateX(0) on open and translateX(-100%) on close\n- Test devicePixelRatio cap: mock window.devicePixelRatio = 3, verify renderer uses pixelRatio 2.0\n- Test safe-area CSS is applied: verify env() CSS variables are referenced in the stylesheet\n\n## Acceptance Criteria\n\n- 3D scene is navigable with touch gestures on iPad 10-inch and iPhone 15 (tested manually or via BrowserStack)\n- Pinch-to-zoom and single-finger orbit both work without conflicting with panel scrolling\n- All sidebar panels accessible via hamburger menu on screens < 1024px\n- Hamburger menu animation is smooth (CSS transform, not JavaScript)\n- Canvas responds correctly to orientation change (portrait <-> landscape) on both iOS and Android\n- No touch event propagation from panel overlays to the 3D scene\n- All interactive targets are at least 44px in their touch dimension\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:05:12.940221112Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.992514770Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-kth","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.992482460Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-leh","title":"Sleep: breathing rate FFT extraction & anomaly flagging","description":"## Overview\nExtract breathing rate from CSI phase signal during sleep using FFT peak detection, and flag elevated breathing as a health anomaly in the morning briefing.\n\n## Algorithm (mothership/internal/sleep/ or signal/)\n\n### Breathing rate estimator (runs during ASLEEP state):\n1. Accumulate 512 phase samples at 20 Hz (25.6s window) from the most motion-sensitive link in sleep zone\n2. Zero-pad to 1024 points for FFT\n3. Run FFT (use Go's golang.org/x/signal/fft or implement Cooley-Tukey)\n4. Frequency resolution: 20 Hz / 1024 = 0.0195 Hz/bin\n5. Find dominant peak in bin range [0.1, 0.5] Hz (6-25 bpm) — ignoring DC and motion bands\n6. Convert bin index to bpm: bpm = bin_idx × (20.0/1024) × 60\n7. Apply 60-second EMA smoothing: ema = α × bpm + (1-α) × ema, α = 1/60\n\n### Per-night statistics:\n- Collect breathing_rate_samples[] throughout ASLEEP state (one per 60s window)\n- breathing_rate_avg = mean(breathing_rate_samples)\n- breathing_regularity = std(breathing_rate_samples) / mean(breathing_rate_samples)\n - Regular: CV < 0.10\n - Irregular: CV > 0.25\n\n### Anomaly detection:\n- Maintain rolling 30-day personal_avg_bpm per person (EMA α=0.05, updated on each night)\n- If breathing_rate_avg > personal_avg_bpm × 1.25: flag as elevated\n- Morning briefing includes: 'Breathing rate elevated (22 bpm vs. 16 bpm average)'\n- Store flag in sleep_records.breathing_anomaly BOOL\n\n## SQLite additions to sleep_records:\nAdd columns: breathing_rate_avg REAL, breathing_regularity REAL, breathing_anomaly BOOL, breathing_samples_json TEXT\n\n## Acceptance\n- FFT correctly identifies 0.25 Hz (15 bpm) dominant frequency in synthetic phase signal\n- EMA smoothing applied across nightly samples\n- Elevated anomaly triggers correctly at >25% above personal average\n- Anomaly appears in morning briefing and GET /api/sleep response","status":"in_progress","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T13:10:18.033253141Z","created_by":"coding","updated_at":"2026-04-07T06:15:51.744536128Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} +{"id":"spaxel-kxf","title":"Implement Notifications REST endpoints","description":"Implement GET/POST /api/notifications/config to get/set delivery channel settings (Ntfy/Pushover/webhook). Add POST /api/notifications/test to send a test notification. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.428129819Z","created_by":"coding","updated_at":"2026-04-07T13:11:56.263459570Z","closed_at":"2026-04-07T13:11:56.263241649Z","close_reason":"Implemented Notifications REST endpoints:\n\n- GET /api/notifications/config: Returns all notification channel configurations with enabled status and type-specific settings\n- POST /api/notifications/config: Updates one or more notification channels with config validation per type\n- POST /api/notifications/test: Sends a test notification via the specified channel\n\nSupported channel types:\n- ntfy: requires 'url', optional 'token'\n- pushover: requires 'app_token', 'user_key'\n- gotify: requires 'url', 'token'\n- webhook: requires 'url', optional 'method', optional 'headers'\n- mqtt: no config required (uses global connection)\n\nIncludes table-driven tests covering all endpoints and validation scenarios.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} +{"id":"spaxel-leh","title":"Sleep: breathing rate FFT extraction & anomaly flagging","description":"## Overview\nExtract breathing rate from CSI phase signal during sleep using FFT peak detection, and flag elevated breathing as a health anomaly in the morning briefing.\n\n## Algorithm (mothership/internal/sleep/ or signal/)\n\n### Breathing rate estimator (runs during ASLEEP state):\n1. Accumulate 512 phase samples at 20 Hz (25.6s window) from the most motion-sensitive link in sleep zone\n2. Zero-pad to 1024 points for FFT\n3. Run FFT (use Go's golang.org/x/signal/fft or implement Cooley-Tukey)\n4. Frequency resolution: 20 Hz / 1024 = 0.0195 Hz/bin\n5. Find dominant peak in bin range [0.1, 0.5] Hz (6-25 bpm) — ignoring DC and motion bands\n6. Convert bin index to bpm: bpm = bin_idx × (20.0/1024) × 60\n7. Apply 60-second EMA smoothing: ema = α × bpm + (1-α) × ema, α = 1/60\n\n### Per-night statistics:\n- Collect breathing_rate_samples[] throughout ASLEEP state (one per 60s window)\n- breathing_rate_avg = mean(breathing_rate_samples)\n- breathing_regularity = std(breathing_rate_samples) / mean(breathing_rate_samples)\n - Regular: CV < 0.10\n - Irregular: CV > 0.25\n\n### Anomaly detection:\n- Maintain rolling 30-day personal_avg_bpm per person (EMA α=0.05, updated on each night)\n- If breathing_rate_avg > personal_avg_bpm × 1.25: flag as elevated\n- Morning briefing includes: 'Breathing rate elevated (22 bpm vs. 16 bpm average)'\n- Store flag in sleep_records.breathing_anomaly BOOL\n\n## SQLite additions to sleep_records:\nAdd columns: breathing_rate_avg REAL, breathing_regularity REAL, breathing_anomaly BOOL, breathing_samples_json TEXT\n\n## Acceptance\n- FFT correctly identifies 0.25 Hz (15 bpm) dominant frequency in synthetic phase signal\n- EMA smoothing applied across nightly samples\n- Elevated anomaly triggers correctly at >25% above personal average\n- Anomaly appears in morning briefing and GET /api/sleep response","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T13:10:18.033253141Z","created_by":"coding","updated_at":"2026-04-07T11:46:15.069928306Z","closed_at":"2026-04-07T11:46:15.069794336Z","close_reason":"Sleep breathing rate FFT extraction & anomaly flagging was already fully implemented. All components verified passing:\n\n1. FFT breathing rate estimator (breathing_estimator.go) - Uses gonum FFT, 512-sample window at 20Hz zero-padded to 1024, finds dominant peak in 0.1-0.5 Hz band (6-30 bpm), 60-second EMA smoothing\n\n2. Per-night breathing statistics (analyzer.go) - Collects breathing_rate_samples[] throughout ASLEEP state, computes avg/std/CV regularity (regular <0.10, irregular >0.25)\n\n3. Breathing anomaly detection (breathing_anomaly.go) - Per-person rolling 30-day EMA, flags if nightly avg > personal_avg x 1.25\n\n4. SQLite columns already in schema (migration_008) - breathing_rate_avg, breathing_regularity, breathing_anomaly, breathing_samples_json\n\n5. Morning briefing integration (briefing/briefing.go) - Includes breathing anomaly text\n\n6. API response (sleep/handler.go) - GET /api/sleep returns all breathing fields\n\nAll 55 tests pass including acceptance tests.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:26"]} {"id":"spaxel-lui","title":"Mothership: environment variable validation and documented defaults","description":"## Overview\nValidate all environment variables at startup with type checking, range validation, and clear error messages — fail fast rather than silently using bad config.\n\n## All env vars to validate (plan lines 3520-3542):\nSPAXEL_BIND_ADDR string default '0.0.0.0:8080'\nSPAXEL_DATA_DIR string default '/data'\nSPAXEL_STATIC_DIR string default '/dashboard'\nSPAXEL_MDNS_ENABLED bool default true\nSPAXEL_MDNS_NAME string default 'spaxel'\nSPAXEL_LOG_LEVEL enum default 'info' (debug|info|warn|error)\nSPAXEL_FUSION_RATE_HZ int default 10, range [1,20]\nSPAXEL_REPLAY_MAX_MB int default 360, range [10,10000]\nSPAXEL_INSTALL_SECRET string optional (32+ chars if set)\nSPAXEL_NTP_SERVER string default 'pool.ntp.org'\nSPAXEL_MQTT_BROKER string optional (must be valid URL if set)\nSPAXEL_MQTT_USERNAME string optional\nSPAXEL_MQTT_PASSWORD string optional (sensitive — never logged)\nTZ string default 'UTC'\n\n## Implementation (internal/config/config.go)\n- Parse and validate each env var; collect all errors before returning\n- Log all non-sensitive loaded values at INFO (MQTT_PASSWORD masked as '***')\n- Return error slice on validation failure; main() logs each error and exits(1)\n- Unit tests: valid config, invalid FUSION_RATE_HZ (25), invalid LOG_LEVEL ('verbose'), invalid MQTT_BROKER ('not-a-url')\n\n## Acceptance\n- SPAXEL_FUSION_RATE_HZ=25 → startup fails with 'SPAXEL_FUSION_RATE_HZ=25 invalid: must be in range [1,20]'\n- SPAXEL_LOG_LEVEL=verbose → startup fails with 'SPAXEL_LOG_LEVEL=verbose invalid: must be one of debug|info|warn|error'\n- Valid config → all values logged at INFO on startup\n- Sensitive values (MQTT_PASSWORD) never appear in logs","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:44:08.903774793Z","created_by":"coding","updated_at":"2026-04-07T16:16:06.183472132Z","closed_at":"2026-04-07T16:16:06.183396284Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} {"id":"spaxel-lve","title":"Firmware: LED identify blink command","description":"## Overview\nImplement LED blink on ESP32-S3 in response to the 'identify' downstream message, so users can physically locate a specific node.\n\n## Firmware (firmware/main/websocket.c or led.c)\n- Parse downstream JSON message: {type:'identify', duration_ms: 5000}\n- Implement LED blink handler: toggle GPIO LED pin at 100ms on/100ms off for duration_ms\n- LED GPIO: board-specific (default GPIO8 for ESP32-S3-DevKitC; configurable via sdkconfig)\n- Run blink in a FreeRTOS task so it doesn't block WebSocket processing\n- Cancel any running blink when new identify message received (or on disconnect)\n\n## Mothership REST API\n- POST /api/nodes/{mac}/identify — body: {duration_ms: 5000}\n- Forward as downstream WebSocket message to the target node\n- Return 404 if node not connected; 200 on success\n\n## Dashboard integration\n- Fleet status page: 'Identify' button per row → POST /api/nodes/{mac}/identify\n- 3D view: right-click node → context menu 'Identify (blink LED)'\n\n## Acceptance\n- LED blinks at ~5 Hz for the specified duration when identify message received\n- Blink stops automatically when duration_ms expires\n- REST endpoint returns 404 for disconnected nodes","status":"open","priority":3,"issue_type":"task","created_at":"2026-04-06T16:42:36.430956056Z","created_by":"coding","updated_at":"2026-04-06T16:42:36.430956056Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-m9a","title":"Multi-link Fresnel zone fusion","description":"Spatial localization using Fresnel zone weighted fusion across multiple links.\n\n## Deliverables\n- New package: mothership/internal/fusion/\n- Fresnel zone geometry computation for each TX-RX link pair\n- 3D grid-based localization: for each voxel, compute weighted sum of link activations\n- Weight = inverse distance from voxel center to nearest Fresnel ellipsoid surface\n- Peak extraction from the 3D activation grid\n- Output: list of detected blob positions with confidence scores\n\n## Acceptance Criteria\n- With 4+ links, produces 2D position estimates within ±1m for a single person\n- Handles varying link geometries (different node positions)\n- Performance: fusion completes within 50ms for up to 20 links\n- Tests with synthetic data verify position accuracy\n\n## References\n- Plan: docs/plan/plan.md item 15\n- Signal features: mothership/internal/signal/features.go (deltaRMS per link)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:47.328316637Z","created_by":"coding","updated_at":"2026-03-28T02:06:14.280688374Z","closed_at":"2026-03-27T03:36:49.190412787Z","close_reason":"Implemented mothership/internal/fusion/ package with 3D Fresnel zone weighted multi-link localization. Grid3D voxel grid, Engine fusing LinkMotion slices, FresnelZoneRadius helper. All 15 tests pass: ±1m accuracy with 4+ links, <50ms for 20 links. Committed in 9c56a37.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-m9a","depends_on_id":"spaxel-8u3","type":"blocks","created_at":"2026-03-28T02:06:14.280670195Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-m9a","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.624567226Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-mg0","title":"Mothership: installation secret generation with one-time print","description":"## Overview\nAuto-generate a 256-bit installation secret on first run, print it exactly once to stdout, and use it for node provisioning token derivation.\n\n## Implementation (mothership/internal/auth/ or cmd/mothership/main.go)\n\n### On startup (before HTTP server starts):\n1. Check SPAXEL_INSTALL_SECRET env var — if set, use it directly\n2. If not set: query SQLite auth table for install_secret column\n3. If found in SQLite: load silently (log at DEBUG level only)\n4. If not found: generate 32 random bytes via crypto/rand.Read()\n5. Store hex-encoded secret in auth.install_secret (INSERT OR IGNORE)\n6. Print ONCE to stdout: '[SPAXEL] Installation secret: <64-char-hex>. Shown once — save to a safe place.'\n7. Never print again on subsequent startups\n\n### Usage:\n- Installation secret used to derive per-node provisioning tokens (HMAC-SHA256 of node_mac + secret)\n- Exposed via GET /api/auth/install-secret (requires admin session or first-run state)\n\n## Acceptance\n- First run: secret printed to stdout and stored in SQLite\n- Second run: no output — secret loaded silently from SQLite\n- SPAXEL_INSTALL_SECRET env var overrides SQLite value (printed at INFO: 'Using provided SPAXEL_INSTALL_SECRET')\n- crypto/rand used (not math/rand)","status":"in_progress","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T16:43:19.679455445Z","created_by":"coding","updated_at":"2026-04-06T18:32:42.735232797Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:26"]} +{"id":"spaxel-mg0","title":"Mothership: installation secret generation with one-time print","description":"## Overview\nAuto-generate a 256-bit installation secret on first run, print it exactly once to stdout, and use it for node provisioning token derivation.\n\n## Implementation (mothership/internal/auth/ or cmd/mothership/main.go)\n\n### On startup (before HTTP server starts):\n1. Check SPAXEL_INSTALL_SECRET env var — if set, use it directly\n2. If not set: query SQLite auth table for install_secret column\n3. If found in SQLite: load silently (log at DEBUG level only)\n4. If not found: generate 32 random bytes via crypto/rand.Read()\n5. Store hex-encoded secret in auth.install_secret (INSERT OR IGNORE)\n6. Print ONCE to stdout: '[SPAXEL] Installation secret: <64-char-hex>. Shown once — save to a safe place.'\n7. Never print again on subsequent startups\n\n### Usage:\n- Installation secret used to derive per-node provisioning tokens (HMAC-SHA256 of node_mac + secret)\n- Exposed via GET /api/auth/install-secret (requires admin session or first-run state)\n\n## Acceptance\n- First run: secret printed to stdout and stored in SQLite\n- Second run: no output — secret loaded silently from SQLite\n- SPAXEL_INSTALL_SECRET env var overrides SQLite value (printed at INFO: 'Using provided SPAXEL_INSTALL_SECRET')\n- crypto/rand used (not math/rand)","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T16:43:19.679455445Z","created_by":"coding","updated_at":"2026-04-06T22:07:47.640654933Z","closed_at":"2026-04-06T22:07:47.640431956Z","close_reason":"Install secret generation with one-time print: already implemented in mothership/internal/auth/handler.go. Features: auto-generate 256-bit secret on first run via crypto/rand, print once to stdout, store in SQLite, SPAXEL_INSTALL_SECRET env var override, GET /api/auth/install-secret endpoint (admin or first-run), HMAC-SHA256 per-node token derivation. All 21 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:90"]} {"id":"spaxel-mjn","title":"Passive radar: OUI lookup & router manufacturer identification","description":"## Overview\nEmbed an IEEE OUI registry at build time so the mothership can display friendly router manufacturer names during passive radar onboarding.\n\n## Implementation (mothership/internal/oui/)\n\n### go generate step (oui/gen.go):\n//go:generate go run gen.go\n- Download https://standards-oui.ieee.org/oui/oui.txt at generate time (not at runtime)\n- Parse lines: '00-00-0C (hex) Cisco Systems' → extract hex prefix and vendor name\n- Generate oui_data.go: var ouiMap = map[uint32]string{0x00000C: 'Cisco Systems', ...}\n- Only regenerate when manually triggered; commit oui_data.go to the repo\n\n### Lookup function (oui/oui.go):\nfunc LookupOUI(mac net.HardwareAddr) string\n - Extract first 3 bytes as uint32 (big-endian)\n - Return ouiMap[key] or '' if not found\n\n### Integration:\n- In passive radar AP detection (spaxel-w40): when AP BSSID detected, call LookupOUI(bssid)\n- Onboarding wizard shows: 'I detected your router (ASUS). Place it on the floor plan.'\n- If OUI unknown: show 'I detected your router. Place it on the floor plan.'\n- GET /api/nodes response: include manufacturer field for virtual nodes\n\n## Acceptance\n- LookupOUI(00:1A:2B:...) returns correct vendor for known OUIs\n- oui_data.go compiles without errors\n- go generate produces non-empty map (>5000 entries)\n- Unknown OUI returns empty string (no panic)","status":"open","priority":3,"issue_type":"task","created_at":"2026-04-06T13:10:41.582690525Z","created_by":"coding","updated_at":"2026-04-06T13:10:41.582690525Z","source_repo":".","compaction_level":0,"original_size":0} -{"id":"spaxel-mrq","title":"Genesis: Spaxel Implementation","description":"## Genesis Bead\nTied to plan: /home/coding/spaxel/docs/plan/plan.md\n\n## Overview\nWiFi CSI-based indoor positioning for self-hosted home environments. Docker container mothership + ESP32-S3 fleet.\n\n## Progress\n- [x] Phase 1: Foundation — COMPLETE\n- [x] Phase 2: Signal Processing & Detection — COMPLETE\n- [x] Phase 3: Multi-Node & Localization — COMPLETE\n- [x] Phase 4: Onboarding & OTA — COMPLETE\n- [x] Phase 5: Reliability & Intelligence — COMPLETE\n- [ ] Phase 6: Identity & Spatial Automation — IN PROGRESS\n- [ ] Phase 7: Learning & Analytics — IN PROGRESS\n- [ ] Phase 8: Analysis & Developer Tools — NOT STARTED\n- [ ] Phase 9: UX Polish & Accessibility — NOT STARTED\n\n## Key Gaps (blocking beads created 2026-04-06)\n- spaxel-jcc: Reintegrate phase 6+ packages into default build (CRITICAL — dead code)\n- spaxel-896: Dashboard panel/modal/sidebar UI framework (CRITICAL — blocks all UI work)\n- spaxel-9eg: Expand WebSocket feed (events, alerts, anomalies, triggers, BLE)\n- spaxel-6ha: Complete REST API (settings, zones, portals, triggers, notifications, replay)\n- spaxel-65k: Activity timeline dashboard view\n- spaxel-a55: Anomaly detection & security mode UI\n- spaxel-iv3: Detection explainability overlay\n- spaxel-ciu: Trigger CI build and deploy to ardenone-cluster","status":"open","priority":0,"issue_type":"genesis","created_at":"2026-03-27T01:54:55.636914996Z","created_by":"coding","updated_at":"2026-04-06T16:44:52.276506614Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:14","no-claim"],"dependencies":[{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-0w4","type":"blocks","created_at":"2026-04-06T13:02:49.655276740Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-17u","type":"blocks","created_at":"2026-04-06T13:02:50.147170937Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-2ap","type":"blocks","created_at":"2026-04-06T13:02:44.117720621Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-403","type":"blocks","created_at":"2026-04-06T13:02:50.226439540Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-5es","type":"blocks","created_at":"2026-04-06T13:02:49.801304001Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-65k","type":"blocks","created_at":"2026-04-06T12:56:31.882060297Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-6ha","type":"blocks","created_at":"2026-04-06T12:56:31.858274512Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-6hd","type":"blocks","created_at":"2026-04-06T16:44:52.024534916Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-7qo","type":"blocks","created_at":"2026-04-06T16:44:52.252390311Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-7zy","type":"blocks","created_at":"2026-04-06T13:02:49.951179408Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-896","type":"blocks","created_at":"2026-04-06T12:56:31.815033074Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9eg","type":"blocks","created_at":"2026-04-06T12:56:31.834911726Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9z3","type":"blocks","created_at":"2026-04-06T16:37:48.728038956Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9zs","type":"blocks","created_at":"2026-04-06T16:44:52.153100114Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-a1f","type":"blocks","created_at":"2026-04-06T13:02:49.725755530Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-a55","type":"blocks","created_at":"2026-04-06T12:56:31.905258303Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-btj","type":"blocks","created_at":"2026-04-06T13:02:49.897539577Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-c02","type":"blocks","created_at":"2026-04-06T16:44:52.127666165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-csj","type":"blocks","created_at":"2026-04-06T13:02:49.776095286Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-fll","type":"blocks","created_at":"2026-04-06T16:37:48.779053456Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-g1o","type":"blocks","created_at":"2026-04-06T13:02:44.142578703Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-goc","type":"blocks","created_at":"2026-04-06T13:02:44.034962055Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-04-06T13:02:50.197971Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-iv3","type":"blocks","created_at":"2026-04-06T12:56:31.927130663Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jc4","type":"blocks","created_at":"2026-04-06T13:02:50.125304165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T12:56:31.790764319Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jk0","type":"blocks","created_at":"2026-04-06T13:02:49.823378278Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jy4","type":"blocks","created_at":"2026-04-06T13:02:49.975935117Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jza","type":"blocks","created_at":"2026-04-06T16:44:52.077718624Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-klf","type":"blocks","created_at":"2026-04-06T13:02:50.277041292Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-kth","type":"blocks","created_at":"2026-04-06T13:02:49.681642745Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-leh","type":"blocks","created_at":"2026-04-06T16:37:48.827955335Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-lui","type":"blocks","created_at":"2026-04-06T16:44:52.204326648Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-lve","type":"blocks","created_at":"2026-04-06T16:44:51.999968395Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-mg0","type":"blocks","created_at":"2026-04-06T16:44:52.103108359Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-mjn","type":"blocks","created_at":"2026-04-06T16:37:48.870206976Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-nk6","type":"blocks","created_at":"2026-04-06T16:44:52.050776554Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-04-06T13:02:50.101273231Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-o0e","type":"blocks","created_at":"2026-04-06T13:02:49.848226825Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ofa","type":"blocks","created_at":"2026-04-06T16:44:52.276456165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-oql","type":"blocks","created_at":"2026-04-06T16:44:51.942776576Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-pv5","type":"blocks","created_at":"2026-04-06T16:37:48.851027003Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-pvz","type":"blocks","created_at":"2026-04-06T13:02:49.928120440Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qfp","type":"blocks","created_at":"2026-04-06T13:02:50.001502400Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-04-06T13:02:50.076094965Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qob","type":"blocks","created_at":"2026-04-06T13:02:44.074486180Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-r7t","type":"blocks","created_at":"2026-04-06T13:02:43.985868678Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-s60","type":"blocks","created_at":"2026-04-06T13:02:50.252133977Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-04-06T13:02:50.170188684Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-sty","type":"blocks","created_at":"2026-04-06T13:02:49.872412505Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tgj","type":"blocks","created_at":"2026-04-06T13:02:50.026388907Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tig","type":"blocks","created_at":"2026-04-06T13:02:49.701543756Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tvq","type":"blocks","created_at":"2026-04-06T13:02:49.750726171Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-u7y","type":"blocks","created_at":"2026-04-06T16:44:51.975396466Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ugj","type":"blocks","created_at":"2026-04-06T16:37:48.895375409Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-uod","type":"blocks","created_at":"2026-04-06T16:37:48.805239145Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ux6","type":"blocks","created_at":"2026-04-06T16:44:52.178861043Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-vuw","type":"blocks","created_at":"2026-04-06T13:02:44.054997291Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-w40","type":"blocks","created_at":"2026-04-06T13:02:44.013053815Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-xpk","type":"blocks","created_at":"2026-04-06T13:02:44.097699492Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-yxr","type":"blocks","created_at":"2026-04-06T16:44:52.228910237Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-zpt","type":"blocks","created_at":"2026-04-06T13:02:50.051735836Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-zvb","type":"blocks","created_at":"2026-04-06T16:37:48.758098316Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-mrq","title":"Genesis: Spaxel Implementation","description":"## Genesis Bead\nTied to plan: /home/coding/spaxel/docs/plan/plan.md\n\n## Overview\nWiFi CSI-based indoor positioning for self-hosted home environments. Docker container mothership + ESP32-S3 fleet.\n\n## Progress\n- [x] Phase 1: Foundation — COMPLETE\n- [x] Phase 2: Signal Processing & Detection — COMPLETE\n- [x] Phase 3: Multi-Node & Localization — COMPLETE\n- [x] Phase 4: Onboarding & OTA — COMPLETE\n- [x] Phase 5: Reliability & Intelligence — COMPLETE\n- [ ] Phase 6: Identity & Spatial Automation — IN PROGRESS\n- [ ] Phase 7: Learning & Analytics — IN PROGRESS\n- [ ] Phase 8: Analysis & Developer Tools — NOT STARTED\n- [ ] Phase 9: UX Polish & Accessibility — NOT STARTED\n\n## Key Gaps (blocking beads created 2026-04-06)\n- spaxel-jcc: Reintegrate phase 6+ packages into default build (CRITICAL — dead code)\n- spaxel-896: Dashboard panel/modal/sidebar UI framework (CRITICAL — blocks all UI work)\n- spaxel-9eg: Expand WebSocket feed (events, alerts, anomalies, triggers, BLE)\n- spaxel-6ha: Complete REST API (settings, zones, portals, triggers, notifications, replay)\n- spaxel-65k: Activity timeline dashboard view\n- spaxel-a55: Anomaly detection & security mode UI\n- spaxel-iv3: Detection explainability overlay\n- spaxel-ciu: Trigger CI build and deploy to ardenone-cluster","status":"open","priority":0,"issue_type":"genesis","created_at":"2026-03-27T01:54:55.636914996Z","created_by":"coding","updated_at":"2026-04-07T06:33:23.305803178Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:14","no-claim"],"dependencies":[{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-0w4","type":"blocks","created_at":"2026-04-06T13:02:49.655276740Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-17u","type":"blocks","created_at":"2026-04-06T13:02:50.147170937Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:31:24.601176051Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-1xt","type":"blocks","created_at":"2026-04-06T22:31:24.885906263Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-2ap","type":"blocks","created_at":"2026-04-06T13:02:44.117720621Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-403","type":"blocks","created_at":"2026-04-06T13:02:50.226439540Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-4u6","type":"blocks","created_at":"2026-04-06T22:31:24.814898879Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.247394573Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-5es","type":"blocks","created_at":"2026-04-06T13:02:49.801304001Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-5yq","type":"blocks","created_at":"2026-04-07T06:33:23.305758502Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-65k","type":"blocks","created_at":"2026-04-06T12:56:31.882060297Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-6ha","type":"blocks","created_at":"2026-04-06T12:56:31.858274512Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-6hd","type":"blocks","created_at":"2026-04-06T16:44:52.024534916Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-6n9","type":"blocks","created_at":"2026-04-06T22:31:24.859108160Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:31:24.680698385Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-7qo","type":"blocks","created_at":"2026-04-06T16:44:52.252390311Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-7zy","type":"blocks","created_at":"2026-04-06T13:02:49.951179408Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-896","type":"blocks","created_at":"2026-04-06T12:56:31.815033074Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9eg","type":"blocks","created_at":"2026-04-06T12:56:31.834911726Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:31:24.544944941Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9z3","type":"blocks","created_at":"2026-04-06T16:37:48.728038956Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9zs","type":"blocks","created_at":"2026-04-06T16:44:52.153100114Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-a1f","type":"blocks","created_at":"2026-04-06T13:02:49.725755530Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-a55","type":"blocks","created_at":"2026-04-06T12:56:31.905258303Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-btj","type":"blocks","created_at":"2026-04-06T13:02:49.897539577Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-c02","type":"blocks","created_at":"2026-04-06T16:44:52.127666165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-csj","type":"blocks","created_at":"2026-04-06T13:02:49.776095286Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-fll","type":"blocks","created_at":"2026-04-06T16:37:48.779053456Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-g1o","type":"blocks","created_at":"2026-04-06T13:02:44.142578703Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:31:24.501573931Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-goc","type":"blocks","created_at":"2026-04-06T13:02:44.034962055Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-04-06T13:02:50.197971Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-iv3","type":"blocks","created_at":"2026-04-06T12:56:31.927130663Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jc4","type":"blocks","created_at":"2026-04-06T13:02:50.125304165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T12:56:31.790764319Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jk0","type":"blocks","created_at":"2026-04-06T13:02:49.823378278Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jy4","type":"blocks","created_at":"2026-04-06T13:02:49.975935117Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jza","type":"blocks","created_at":"2026-04-06T16:44:52.077718624Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-klf","type":"blocks","created_at":"2026-04-06T13:02:50.277041292Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-kth","type":"blocks","created_at":"2026-04-06T13:02:49.681642745Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-leh","type":"blocks","created_at":"2026-04-06T16:37:48.827955335Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-lui","type":"blocks","created_at":"2026-04-06T16:44:52.204326648Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-lve","type":"blocks","created_at":"2026-04-06T16:44:51.999968395Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-mg0","type":"blocks","created_at":"2026-04-06T16:44:52.103108359Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-mjn","type":"blocks","created_at":"2026-04-06T16:37:48.870206976Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-nk6","type":"blocks","created_at":"2026-04-06T16:44:52.050776554Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-04-06T13:02:50.101273231Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-o0e","type":"blocks","created_at":"2026-04-06T13:02:49.848226825Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ofa","type":"blocks","created_at":"2026-04-06T16:44:52.276456165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-oql","type":"blocks","created_at":"2026-04-06T16:44:51.942776576Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-pv5","type":"blocks","created_at":"2026-04-06T16:37:48.851027003Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-pvz","type":"blocks","created_at":"2026-04-06T13:02:49.928120440Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qfp","type":"blocks","created_at":"2026-04-06T13:02:50.001502400Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-04-06T13:02:50.076094965Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qob","type":"blocks","created_at":"2026-04-06T13:02:44.074486180Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-r7t","type":"blocks","created_at":"2026-04-06T13:02:43.985868678Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-s60","type":"blocks","created_at":"2026-04-06T13:02:50.252133977Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:31:24.725192202Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-04-06T13:02:50.170188684Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-sty","type":"blocks","created_at":"2026-04-06T13:02:49.872412505Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tgj","type":"blocks","created_at":"2026-04-06T13:02:50.026388907Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tig","type":"blocks","created_at":"2026-04-06T13:02:49.701543756Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tvq","type":"blocks","created_at":"2026-04-06T13:02:49.750726171Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-u7y","type":"blocks","created_at":"2026-04-06T16:44:51.975396466Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ugj","type":"blocks","created_at":"2026-04-06T16:37:48.895375409Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:31:24.643023474Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-uod","type":"blocks","created_at":"2026-04-06T16:37:48.805239145Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ux6","type":"blocks","created_at":"2026-04-06T16:44:52.178861043Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-vuw","type":"blocks","created_at":"2026-04-06T13:02:44.054997291Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-w40","type":"blocks","created_at":"2026-04-06T13:02:44.013053815Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-x59","type":"blocks","created_at":"2026-04-06T22:31:24.774691790Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-xpk","type":"blocks","created_at":"2026-04-06T13:02:44.097699492Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-yxr","type":"blocks","created_at":"2026-04-06T16:44:52.228910237Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-zpt","type":"blocks","created_at":"2026-04-06T13:02:50.051735836Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-zvb","type":"blocks","created_at":"2026-04-06T16:37:48.758098316Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-mul","title":"Implement Automation Triggers REST endpoints","description":"Implement CRUD endpoints for triggers: GET/POST /api/triggers, PUT/DELETE /api/triggers/{id}. Add POST /api/triggers/{id}/test to fire trigger once for testing. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T15:31:10.356946401Z","created_by":"coding","updated_at":"2026-04-07T16:46:17.434083019Z","closed_at":"2026-04-07T16:46:17.433959430Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-n9n","title":"Biomechanical blob tracking (UKF)","description":"Track detected blobs as human figures with physics-constrained motion model.\n\n## Deliverables\n- New package: mothership/internal/tracker/\n- Unscented Kalman Filter (UKF) with human motion model\n- Constraints: max velocity 2m/s, max acceleration 3m/s², turning radius, gravity-consistent Z\n- Blob ID assignment and persistence through brief gaps (up to 3s)\n- Collision avoidance between tracked entities\n- Posture estimation: standing/walking/seated/lying based on Z and velocity\n- Uses gonum.org/v1/gonum/mat for matrix operations\n\n## Acceptance Criteria\n- Tracks a single person smoothly through a room\n- Maintains blob ID across brief occlusions\n- Posture transitions are physically plausible\n- Tests with synthetic trajectory data\n\n## References\n- Plan: docs/plan/plan.md item 16\n- Fusion output: mothership/internal/fusion/ (blob positions)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:55.704147095Z","created_by":"coding","updated_at":"2026-03-28T02:06:17.873405703Z","closed_at":"2026-03-27T03:59:10.182764206Z","close_reason":"Implemented mothership/internal/tracker/ package with 6-state UKF (x,y,z,vx,vy,vz), biomechanical constraints (max horiz vel 2 m/s, max accel 3 m/s², min turning radius 0.3 m, gravity-consistent Z), blob ID persistence through 3s gaps, floor-plane collision avoidance, posture estimation (standing/walking/seated/lying), 60-point trail. 11 synthetic trajectory tests all pass. Uses gonum.org/v1/gonum/mat.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-n9n","depends_on_id":"spaxel-m9a","type":"blocks","created_at":"2026-03-28T02:06:17.873372333Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-n9n","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.608542494Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-ncw","title":"Add system health messages to WebSocket feed","description":"Add 'system_health' message type to /ws/dashboard for periodic system stats every 60s. Broadcast: { type: 'system_health', health: { uptime_s, node_count, bead_count, go_routines, mem_mb } }. Handle in app.js onmessage.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T14:18:27.674751078Z","created_by":"coding","updated_at":"2026-04-07T12:53:38.767877850Z","closed_at":"2026-04-07T12:53:38.767672942Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-nk6","title":"Dashboard: PIN setup, login, and session cookie authentication","description":"## Overview\nProtect the dashboard with a PIN that is set on first run and verified on every subsequent visit via session cookies.\n\n## Backend (mothership/internal/auth/)\n- SQLite auth table: pin_bcrypt TEXT, install_secret TEXT (singleton row)\n- GET /api/auth/status — return {pin_configured: bool} — no auth required\n- POST /api/auth/setup — body: {pin:'1234'} — only works if pin not yet configured; bcrypt cost=12; store hash\n- POST /api/auth/login — body: {pin:'1234'} — verify bcrypt; on success issue session cookie:\n Name: spaxel_session, HttpOnly, SameSite=Strict (if TLS), Path=/, Max-Age=604800\n Store session_id → expires_at in SQLite sessions table\n- POST /api/auth/logout — clear session cookie; delete session from SQLite\n- Session middleware: all /api/* and /ws/* require valid session cookie; return 401 if missing/expired\n- Rolling window: on each authenticated request, if within 24h of expiry, extend by 7 days\n\n## Dashboard (dashboard/js/auth.js)\n- On load: GET /api/auth/status; if pin_configured=false → show first-run PIN setup page (full-screen, blocks dashboard)\n- First-run page: enter PIN + confirm PIN → POST /api/auth/setup → reload\n- Login page: shown on 401; PIN entry form → POST /api/auth/login → reload on success\n- Logout button in settings panel → POST /api/auth/logout → redirect to login\n\n## Acceptance\n- Fresh install: setup page shown before any dashboard content\n- After PIN set: login required on next visit\n- Session cookie survives page refresh; expires after 7 days of inactivity\n- 401 returned immediately for any /api/ call without valid cookie","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:43:02.833541561Z","created_by":"coding","updated_at":"2026-04-06T17:17:02.044261204Z","closed_at":"2026-04-06T17:17:02.044047110Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} -{"id":"spaxel-nqh","title":"BLE-to-blob identity matching","description":"## Background\n\nThe BLE device registry (spaxel-2wg) tells us which labelled devices are present in the home and their per-node RSSI. The CSI fusion engine (spaxel-m9a) and blob tracker (spaxel-n9n) tell us where anonymous humanoid figures are located in 3D space. Identity matching bridges these two systems: given named BLE devices with per-node RSSI observations, and anonymous CSI blobs with estimated 3D positions, assign device identities to blobs.\n\nWhen a match is confident, the humanoid figure in the 3D view gains a name label and person colour. \"Alice is in the Kitchen\" becomes a real-time fact rather than an approximation.\n\n## RSSI Triangulation Algorithm\n\nFor each BLE device with observations from multiple nodes in the current scan cycle (last 5 seconds):\n\nStep 1: Convert RSSI to distance for each observing node.\nDistance model: d = d0 * 10^((RSSI_ref - RSSI) / (10 * n))\nParameters: d0 = 1.0m (reference distance), RSSI_ref = -65 dBm (RSSI at 1m in typical indoor environment), n = 2.5 (indoor path loss exponent — typical range 2.0-3.5; 2.5 is a reasonable default for residential spaces).\n\nStep 2: With 2+ nodes, perform weighted least squares triangulation.\nPosition estimate: argmin_P { sum_i w_i * (|P - node_i| - d_i)^2 }\nwhere w_i = 1/sigma_i^2, sigma_i = d_i * ln(10) / (10 * n) * RSSI_noise_sigma (typically 5 dBm).\nSolve with 3-5 iterations of gradient descent or Gauss-Newton (the system is nonlinear).\nWith 1 node: only an approximate range estimate; triangulation confidence = 0.2.\nWith 2 nodes: 2D position estimate on a circle/arc; confidence = 0.5.\nWith 3+ nodes: full 2D position estimate with residual as quality indicator; confidence = min(1.0, 0.7 + 0.1 * (n-3)) where n is node count.\n\nStep 3: Assign to nearest CSI blob.\nFor each triangulated BLE position, find the nearest active CSI blob (from TrackManager) within a 2-metre search radius. The assignment uses Euclidean distance in the horizontal plane (ignore Z for BLE since antenna height is variable).\n\nIf multiple BLE devices map to the same blob (e.g. Alice has a phone AND a Fitbit), use the device with the highest triangulation confidence.\n\nIf a BLE device position is triangulated but no CSI blob is within 2 metres: create a \"BLE-only\" placeholder track at the BLE position. BLE-only tracks are shown as a different visual (outlined circle rather than filled, lower opacity) to indicate lower confidence.\n\n## Match Confidence Score\n\nFor each BLE-to-blob assignment, compute a match confidence:\nconfidence = f_observations * f_node_count * f_residual * f_distance\n\nwhere:\n- f_observations: 1.0 if device seen in last 5s, 0.5 if last 15s, 0.0 if older\n- f_node_count: 0.2 (1 node), 0.5 (2 nodes), 0.8+ (3+ nodes)\n- f_residual: 1.0 - min(1.0, triangulation_residual_metres / 2.0) — penalise poor triangulation fit\n- f_distance: 1.0 if blob distance < 0.5m, linear decay to 0 at 2.0m\n\nOnly assign identity if confidence > 0.6. Below this threshold: keep the blob anonymous (\"Unknown\").\n\n## Identity Persistence\n\nOnce a BLE-blob match is made, the identity persists for 5 minutes even if:\n- The BLE device rotates its MAC (new MAC will match same person via the person record)\n- The BLE device briefly falls below RSSI threshold of all nodes (indoor dead zone)\n- The CSI blob briefly disappears (tracker coasted state, per spaxel-n9n)\n\nAfter 5 minutes of no BLE observation and no high-confidence re-match: revert to anonymous.\n\nThis 5-minute persistence prevents flickering identity labels when people walk through areas with inconsistent BLE coverage.\n\n## TrackManager Integration\n\nExtend the TrackState struct in mothership/internal/tracker/ (spaxel-n9n):\n- PersonID string (from BLE registry people table)\n- PersonLabel string\n- PersonColor string\n- IdentityConfidence float64\n- IdentitySource string (\"ble_triangulation\" or \"ble_only\" or \"\")\n\nTrackManager.UpdateIdentities(blePositions map[deviceMAC]Vec3, registry BLERegistry) method: runs the matching algorithm and updates track identity fields.\n\nCalled at the same frequency as FusionEngine (10 Hz), but BLE positions only update at 5s intervals — cache the last BLE positions and re-use them between BLE updates.\n\n## API\n\nGET /api/tracks enriches the existing track list with person_id, person_label, person_color, and identity_confidence fields.\n\nExample response:\n[{\n \"id\": \"track-1\",\n \"position\": {\"x\": 3.2, \"y\": 1.8, \"z\": 1.0},\n \"velocity\": {\"x\": 0.1, \"y\": -0.2, \"z\": 0.0},\n \"confidence\": 0.87,\n \"posture_hint\": \"standing\",\n \"person_id\": \"uuid-alice\",\n \"person_label\": \"Alice\",\n \"person_color\": \"#3b82f6\",\n \"identity_confidence\": 0.82\n}]\n\n## Dashboard Integration\n\nIn the 3D view (Three.js scene): tracks with confirmed identity display a floating text label (person name) above the humanoid figure mesh, rendered using THREE.Sprite with a canvas texture containing the name in the person's colour.\n\nIn the \"People and Devices\" panel: each person row shows \"Currently: Kitchen\" (current zone from room transition portals, Phase 6) and \"In 3D view: [jump to track]\" link.\n\n## Tests\n\n- Test RSSI-to-distance conversion with known values: RSSI=-65 -> d=1.0m, RSSI=-75 -> d=2.5m (with default params)\n- Test triangulation with 3 nodes at known positions and known distances: verify position error < 0.5m\n- Test nearest-blob assignment: 2 blobs at (2,2) and (5,5), BLE device triangulated at (2.3,1.9) -> assigns to first blob\n- Test confidence gate: confidence=0.55 -> no assignment; confidence=0.65 -> assignment made\n- Test BLE-only placeholder track creation when no blob within 2m\n- Test identity persistence: after BLE device disappears, track retains identity for 5 minutes then reverts\n- Test identity handoff when BLE device MAC rotates: same person assignment maintained via person record\n\n## Acceptance Criteria\n\n- Named identity labels appear on 3D blobs when person carries a labelled BLE device with confidence > 0.6\n- Identity is maintained through brief BLE dead zones (up to 5 minutes)\n- Two people in the same room get correct distinct identities when their BLE devices are distinguishable\n- Confidence gate prevents wrong assignments (no identity label when confidence < 0.6)\n- BLE-only placeholder tracks appear for people whose BLE device is heard but no CSI blob is nearby\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"charlie","created_at":"2026-03-28T01:44:50.795871765Z","created_by":"coding","updated_at":"2026-04-07T19:40:03.410277704Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4"]} +{"id":"spaxel-nqh","title":"BLE-to-blob identity matching","description":"## Background\n\nThe BLE device registry (spaxel-2wg) tells us which labelled devices are present in the home and their per-node RSSI. The CSI fusion engine (spaxel-m9a) and blob tracker (spaxel-n9n) tell us where anonymous humanoid figures are located in 3D space. Identity matching bridges these two systems: given named BLE devices with per-node RSSI observations, and anonymous CSI blobs with estimated 3D positions, assign device identities to blobs.\n\nWhen a match is confident, the humanoid figure in the 3D view gains a name label and person colour. \"Alice is in the Kitchen\" becomes a real-time fact rather than an approximation.\n\n## RSSI Triangulation Algorithm\n\nFor each BLE device with observations from multiple nodes in the current scan cycle (last 5 seconds):\n\nStep 1: Convert RSSI to distance for each observing node.\nDistance model: d = d0 * 10^((RSSI_ref - RSSI) / (10 * n))\nParameters: d0 = 1.0m (reference distance), RSSI_ref = -65 dBm (RSSI at 1m in typical indoor environment), n = 2.5 (indoor path loss exponent — typical range 2.0-3.5; 2.5 is a reasonable default for residential spaces).\n\nStep 2: With 2+ nodes, perform weighted least squares triangulation.\nPosition estimate: argmin_P { sum_i w_i * (|P - node_i| - d_i)^2 }\nwhere w_i = 1/sigma_i^2, sigma_i = d_i * ln(10) / (10 * n) * RSSI_noise_sigma (typically 5 dBm).\nSolve with 3-5 iterations of gradient descent or Gauss-Newton (the system is nonlinear).\nWith 1 node: only an approximate range estimate; triangulation confidence = 0.2.\nWith 2 nodes: 2D position estimate on a circle/arc; confidence = 0.5.\nWith 3+ nodes: full 2D position estimate with residual as quality indicator; confidence = min(1.0, 0.7 + 0.1 * (n-3)) where n is node count.\n\nStep 3: Assign to nearest CSI blob.\nFor each triangulated BLE position, find the nearest active CSI blob (from TrackManager) within a 2-metre search radius. The assignment uses Euclidean distance in the horizontal plane (ignore Z for BLE since antenna height is variable).\n\nIf multiple BLE devices map to the same blob (e.g. Alice has a phone AND a Fitbit), use the device with the highest triangulation confidence.\n\nIf a BLE device position is triangulated but no CSI blob is within 2 metres: create a \"BLE-only\" placeholder track at the BLE position. BLE-only tracks are shown as a different visual (outlined circle rather than filled, lower opacity) to indicate lower confidence.\n\n## Match Confidence Score\n\nFor each BLE-to-blob assignment, compute a match confidence:\nconfidence = f_observations * f_node_count * f_residual * f_distance\n\nwhere:\n- f_observations: 1.0 if device seen in last 5s, 0.5 if last 15s, 0.0 if older\n- f_node_count: 0.2 (1 node), 0.5 (2 nodes), 0.8+ (3+ nodes)\n- f_residual: 1.0 - min(1.0, triangulation_residual_metres / 2.0) — penalise poor triangulation fit\n- f_distance: 1.0 if blob distance < 0.5m, linear decay to 0 at 2.0m\n\nOnly assign identity if confidence > 0.6. Below this threshold: keep the blob anonymous (\"Unknown\").\n\n## Identity Persistence\n\nOnce a BLE-blob match is made, the identity persists for 5 minutes even if:\n- The BLE device rotates its MAC (new MAC will match same person via the person record)\n- The BLE device briefly falls below RSSI threshold of all nodes (indoor dead zone)\n- The CSI blob briefly disappears (tracker coasted state, per spaxel-n9n)\n\nAfter 5 minutes of no BLE observation and no high-confidence re-match: revert to anonymous.\n\nThis 5-minute persistence prevents flickering identity labels when people walk through areas with inconsistent BLE coverage.\n\n## TrackManager Integration\n\nExtend the TrackState struct in mothership/internal/tracker/ (spaxel-n9n):\n- PersonID string (from BLE registry people table)\n- PersonLabel string\n- PersonColor string\n- IdentityConfidence float64\n- IdentitySource string (\"ble_triangulation\" or \"ble_only\" or \"\")\n\nTrackManager.UpdateIdentities(blePositions map[deviceMAC]Vec3, registry BLERegistry) method: runs the matching algorithm and updates track identity fields.\n\nCalled at the same frequency as FusionEngine (10 Hz), but BLE positions only update at 5s intervals — cache the last BLE positions and re-use them between BLE updates.\n\n## API\n\nGET /api/tracks enriches the existing track list with person_id, person_label, person_color, and identity_confidence fields.\n\nExample response:\n[{\n \"id\": \"track-1\",\n \"position\": {\"x\": 3.2, \"y\": 1.8, \"z\": 1.0},\n \"velocity\": {\"x\": 0.1, \"y\": -0.2, \"z\": 0.0},\n \"confidence\": 0.87,\n \"posture_hint\": \"standing\",\n \"person_id\": \"uuid-alice\",\n \"person_label\": \"Alice\",\n \"person_color\": \"#3b82f6\",\n \"identity_confidence\": 0.82\n}]\n\n## Dashboard Integration\n\nIn the 3D view (Three.js scene): tracks with confirmed identity display a floating text label (person name) above the humanoid figure mesh, rendered using THREE.Sprite with a canvas texture containing the name in the person's colour.\n\nIn the \"People and Devices\" panel: each person row shows \"Currently: Kitchen\" (current zone from room transition portals, Phase 6) and \"In 3D view: [jump to track]\" link.\n\n## Tests\n\n- Test RSSI-to-distance conversion with known values: RSSI=-65 -> d=1.0m, RSSI=-75 -> d=2.5m (with default params)\n- Test triangulation with 3 nodes at known positions and known distances: verify position error < 0.5m\n- Test nearest-blob assignment: 2 blobs at (2,2) and (5,5), BLE device triangulated at (2.3,1.9) -> assigns to first blob\n- Test confidence gate: confidence=0.55 -> no assignment; confidence=0.65 -> assignment made\n- Test BLE-only placeholder track creation when no blob within 2m\n- Test identity persistence: after BLE device disappears, track retains identity for 5 minutes then reverts\n- Test identity handoff when BLE device MAC rotates: same person assignment maintained via person record\n\n## Acceptance Criteria\n\n- Named identity labels appear on 3D blobs when person carries a labelled BLE device with confidence > 0.6\n- Identity is maintained through brief BLE dead zones (up to 5 minutes)\n- Two people in the same room get correct distinct identities when their BLE devices are distinguishable\n- Confidence gate prevents wrong assignments (no identity label when confidence < 0.6)\n- BLE-only placeholder tracks appear for people whose BLE device is heard but no CSI blob is nearby\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"echo","created_at":"2026-03-28T01:44:50.795871765Z","created_by":"coding","updated_at":"2026-04-07T20:36:11.407019373Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:52"]} {"id":"spaxel-o0e","title":"Fresnel zone debug overlay","description":"## Background\n\nThe Fresnel zone geometry is at the heart of the fusion algorithm (spaxel-m9a). Every link's contribution to the 3D occupancy grid is weighted by how much of the candidate voxel falls within the first Fresnel zone ellipsoid. When debugging why localisation is placing a blob in the wrong position, being able to see the actual Fresnel ellipsoids for each active link — overlaid on the 3D scene — instantly reveals whether the zones are covering the right area.\n\nThis is a pure debug/developer tool. It is toggle-able (off by default) and intended for system tuners and developers, not end users. The explainability overlay (spaxel-ez4) already shows Fresnel ellipsoids for a specific selected blob. The Fresnel zone debug overlay shows them persistently for all active links simultaneously.\n\n## Ellipsoid Geometry (Recap)\n\nFor each active link with TX at position P1 and RX at position P2:\n- Link distance d = |P1 - P2|\n- WiFi channel wavelength lambda: 5 GHz -> lambda = 0.06m, 2.4 GHz -> lambda = 0.125m. Use the channel reported in the node's hello message (or the channel from the link's last received CSI frame header).\n- Semi-major axis: a = (d + lambda/2) / 2\n- Semi-minor axis: b = sqrt(a^2 - (d/2)^2)\n- Ellipsoid centre: midpoint(P1, P2)\n- Ellipsoid orientation: major axis along the P1->P2 unit vector\n\nThe first Fresnel zone ellipsoid represents the region where a point reflector would shorten the signal path by at most half a wavelength relative to the direct path. Motion within this region has maximum impact on CSI.\n\n## Three.js Rendering\n\nEllipsoid mesh construction:\n1. Start with THREE.SphereGeometry(1, 32, 16) — a unit sphere\n2. Apply non-uniform scaling: new THREE.Vector3(a, b, b) via mesh.scale.set(a, b, b)\n3. Rotate to align with the link axis: compute the quaternion that maps (1,0,0) to the P1->P2 unit vector using THREE.Quaternion.setFromUnitVectors(new THREE.Vector3(1,0,0), linkAxis)\n4. Apply the quaternion to mesh.quaternion\n\nMaterial: TWO materials:\n- Wireframe: THREE.LineSegments with EdgesGeometry for crisp wireframe edges, line color matching the link line colour, opacity 0.6\n- Fill: THREE.MeshBasicMaterial with transparent=true, opacity 0.08, colour matching the link, depthWrite=false (so the fill doesn't obscure other geometry)\n\nLayer toggle: add \"Fresnel Zones\" checkbox to the 3D layer control panel (in the \"Debug\" section, only visible when expert mode is active). Default: off. When toggled on, add all ellipsoid meshes to the scene. When toggled off, remove them.\n\n## Per-Link Controls\n\nWhen a user hovers over a Fresnel ellipsoid in the 3D scene (using Three.js raycasting):\n- The corresponding link line highlights (brightness increase)\n- A tooltip appears (HTML overlay, positioned at screen coordinates of the hover point):\n \"Link: [tx_label] to [rx_label]\n Fresnel zone radius at midpoint: {b:.2f}m\n Link distance: {d:.2f}m\n Wavelength: {lambda:.3f}m (channel {ch})\n Link health: {health_score:.0%}\"\n\nClicking an ellipsoid:\n- Selects the corresponding link in the link panel (sidebar)\n- Highlights the link entry in the link list\n\n## Performance Considerations\n\nWith 6 active links, we render 6 pairs of meshes (wireframe + fill = 12 Three.js objects). This is negligible for any modern GPU. However, the wireframe geometry uses EdgesGeometry which creates one Line for each edge — for a sphere with 32 horizontal and 16 vertical segments, that's approximately 1000 line segments per ellipsoid. At 6 links, 6000 line segments total. This should render at 60 fps on any modern device, but if performance is an issue on mobile, reduce the sphere segment count to 16x8 when the debug overlay is active on mobile viewports.\n\nPre-compute at link addition time: when a new link is registered (node hello + peer MAC), compute the ellipsoid geometry and add it to the scene (hidden if the layer is off). Update on node position change. Remove when link becomes inactive (no frames for > 30s).\n\n## Relationship to Explainability Overlay\n\nThe Fresnel zone debug overlay (this bead) and the detection explainability overlay (spaxel-ez4) both render Fresnel ellipsoids. They share the same geometry computation code (dashboard/js/fresnel.js — the ellipsoid helper function). The difference:\n- This overlay: shows all active link ellipsoids simultaneously, toggle-able layer\n- Explainability overlay: shows only contributing link ellipsoids for a specific selected blob, in explain mode\n\nBoth import from fresnel.js. The helper function FresnelEllipsoid(P1, P2, lambda) returns a Three.js Mesh ready for scene insertion.\n\n## Files to Create or Modify\n\n- dashboard/js/fresnel.js: FresnelEllipsoid helper function (shared with explainability)\n- dashboard/js/layers.js: add \"Fresnel Zones\" toggle in the debug layer section\n- dashboard/js/app.js: integrate Fresnel overlay management — create/update/remove ellipsoids on link events\n\n## Tests\n\n- Test ellipsoid geometry computation with known TX/RX positions: TX at (0,0,0), RX at (4,0,0), lambda=0.06m -> a ~= 2.015, b ~= 0.345. Verify to 3 decimal places.\n- Test semi-minor axis for edge case: very short link d=0.1m -> b should be very small but positive\n- Test for diagonal link: TX at (0,0,0), RX at (3,4,0) (distance=5m) -> verify a and b are computed correctly\n- Test that toggling the layer on/off adds/removes the correct number of mesh objects from the scene (mock Three.js scene)\n- Test hover tooltip shows correct data (link health from mock, link endpoints from mock)\n- Test that ellipsoids update when node position changes\n\n## Acceptance Criteria\n\n- Fresnel zone ellipsoids render correctly for all active links when the debug layer is toggled on\n- Ellipsoid semi-major and semi-minor axes match theoretical first Fresnel zone values for the link distance and frequency\n- Toggle shows/hides all ellipsoids cleanly without leaving orphan objects in the scene\n- Hovering an ellipsoid shows the correct tooltip with link details and health score\n- Clicking an ellipsoid selects the corresponding link in the link panel\n- Geometry computation is shared with the explainability overlay via fresnel.js\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:58:33.424914116Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.736776003Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-o0e","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.736620594Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-o4l","title":"Bidirectional node protocol","description":"Implement full bidirectional protocol over the existing WebSocket connection.\n\n## Deliverables\n- Registration (hello message with capabilities)\n- Health reporting (heap, WiFi RSSI, uptime, temperature every 10s)\n- BLE scan relay (device list JSON)\n- Role/config push from mothership (TX/RX/passive mode, packet rate)\n- OTA command messages (trigger update, progress tracking)\n- All messages over the existing single WebSocket per node\n\n## Acceptance Criteria\n- Nodes register on connect with full capability advertisement\n- Mothership can push role changes and config updates\n- Health metrics flow reliably at 10s intervals\n- Protocol is backward-compatible with Phase 1 implementation\n\n## References\n- Current protocol: mothership/internal/ingestion/message.go\n- Firmware WebSocket: firmware/main/websocket.c","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:31.632551776Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.644219477Z","closed_at":"2026-03-27T03:14:43.201850105Z","close_reason":"Implemented full bidirectional node protocol. Firmware: motion hints wired to websocket_send_motion_hint() with rate-limiting, csi_set_rate() fixed, all message types active (hello/health/ble/motion_hint/ota_status). Mothership: OnMotionHint() ramps adjacent nodes via topology callback, idle timeout 30s, variance threshold adaptive, added SendRoleToMAC() and SendOTAToMAC() for dynamic downstream pushes, OTA status logging. Binary CSI frames remain backward-compatible with Phase 1.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-o4l","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.644181123Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-ofa","title":"End-to-end integration test harness (simulator + mothership assertions)","description":"## Overview\nBuild an automated integration test that starts the mothership, runs the CSI simulator against it, and asserts on observable behavior — providing a production-realism gate for CI.\n\n## Test harness (tests/e2e/run.sh or Go test)\n\n### Setup:\n1. Start mothership container (or binary): docker run -d -p 8080:8080 ronaldraygun/spaxel:latest\n2. Wait for /healthz to return {status:'ok'} with 15s timeout (poll every 500ms)\n3. If PIN auth enabled: POST /api/auth/setup with test PIN; POST /api/auth/login\n\n### Run simulator:\n4. Start: sim --mothership http://localhost:8080 --nodes 4 --walkers 2 --duration 30s --rate 20 --ble --seed 42\n5. Simulator exits non-zero if it receives {type:'reject'} message; test fails immediately\n\n### Assert during run (poll every 1s for 30s):\n6. GET /api/blobs (or WebSocket) → assert blob_count > 0 within first 15s\n7. GET /api/nodes → assert nodes_online == 4 within first 5s\n8. GET /healthz → assert status=='ok' throughout entire run\n\n### Assert after run:\n9. GET /api/events?type=detection → assert at least 1 detection event recorded\n10. Simulator printed per-second frame counts to stdout; verify no frame-rate drop >20% from target\n\n## CI integration\n- GitHub Actions workflow: .github/workflows/e2e.yml (but only triggers from Argo Workflows via spaxel-ci)\n- Build image → run test harness → post result as bead comment\n\n## Acceptance\n- Test passes on fresh container with seed 42 configuration\n- Test fails clearly when mothership rejects frames (wrong protocol)\n- Test runs in <90s total (15s startup + 30s sim + 45s buffer)","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:44:47.443177386Z","created_by":"coding","updated_at":"2026-04-07T19:47:39.979123676Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:18"]} +{"id":"spaxel-ofa","title":"End-to-end integration test harness (simulator + mothership assertions)","description":"## Overview\nBuild an automated integration test that starts the mothership, runs the CSI simulator against it, and asserts on observable behavior — providing a production-realism gate for CI.\n\n## Test harness (tests/e2e/run.sh or Go test)\n\n### Setup:\n1. Start mothership container (or binary): docker run -d -p 8080:8080 ronaldraygun/spaxel:latest\n2. Wait for /healthz to return {status:'ok'} with 15s timeout (poll every 500ms)\n3. If PIN auth enabled: POST /api/auth/setup with test PIN; POST /api/auth/login\n\n### Run simulator:\n4. Start: sim --mothership http://localhost:8080 --nodes 4 --walkers 2 --duration 30s --rate 20 --ble --seed 42\n5. Simulator exits non-zero if it receives {type:'reject'} message; test fails immediately\n\n### Assert during run (poll every 1s for 30s):\n6. GET /api/blobs (or WebSocket) → assert blob_count > 0 within first 15s\n7. GET /api/nodes → assert nodes_online == 4 within first 5s\n8. GET /healthz → assert status=='ok' throughout entire run\n\n### Assert after run:\n9. GET /api/events?type=detection → assert at least 1 detection event recorded\n10. Simulator printed per-second frame counts to stdout; verify no frame-rate drop >20% from target\n\n## CI integration\n- GitHub Actions workflow: .github/workflows/e2e.yml (but only triggers from Argo Workflows via spaxel-ci)\n- Build image → run test harness → post result as bead comment\n\n## Acceptance\n- Test passes on fresh container with seed 42 configuration\n- Test fails clearly when mothership rejects frames (wrong protocol)\n- Test runs in <90s total (15s startup + 30s sim + 45s buffer)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:44:47.443177386Z","created_by":"coding","updated_at":"2026-04-07T20:01:09.701396700Z","closed_at":"2026-04-07T20:01:09.701265832Z","close_reason":"Implemented end-to-end integration test harness for Spaxel:\n\n1. Argo WorkflowTemplate spaxel-e2e-workflowtemplate.yml added to declarative-config for CI/CD integration\n\n2. Existing test infrastructure already in place:\n - Bash script harness: tests/e2e/run.sh\n - Go test harness: mothership/tests/e2e/e2e_test.go\n - CSI simulator: mothership/cmd/sim/main.go\n - GitHub Actions workflow: .github/workflows/e2e.yml\n\n3. Test harness behavior:\n - Starts mothership container (or local binary)\n - Waits for /healthz to return ok (15s timeout, 500ms poll)\n - Handles PIN auth setup if enabled\n - Runs simulator with configurable nodes/walkers/duration/rate\n - Asserts during run: health ok, nodes online, blobs detected\n - Asserts after run: detection events, frame rate\n - Runs in under 90s total\n\n4. CI integration via Argo Workflows spaxel-e2e template","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:19"]} {"id":"spaxel-oql","title":"Firmware: NVS schema migration on boot","description":"## Overview\nImplement versioned NVS key migration on ESP32-S3 firmware so OTA-updated firmware gracefully handles NVS written by older versions.\n\n## Implementation (firmware/main/nvs_migration.c)\n- On boot, open 'spaxel' NVS namespace and read schema_ver (uint8); if missing, write schema_ver=1\n- If schema_ver < COMPILED_NVS_VERSION: run migration functions in order (v1→v2, v2→v3, etc.)\n- Each migration: add/rename/remove specific NVS keys; call nvs_commit() after each write\n- After all migrations: update schema_ver = COMPILED_NVS_VERSION and commit\n- Log each migration step to UART for debugging\n\n## Example migration v1→v2:\n- Rename 'ms_ip' to 'mothership_ip' (read old key, write new key, erase old key)\n- Add 'ntp_server' key with default value 'pool.ntp.org'\n\n## Acceptance\n- Flash firmware v1.0 with known NVS schema; flash v1.1 firmware; verify all keys present\n- Migration runs exactly once (schema_ver correctly incremented)\n- Migration failure leaves NVS in consistent state (tested via simulated write failure)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:42:15.874379750Z","created_by":"coding","updated_at":"2026-04-07T14:28:20.262035505Z","closed_at":"2026-04-07T14:28:20.261822946Z","close_reason":"Implemented NVS schema migration on boot for ESP32-S3 firmware. Added nvs_migration.c/h with migration framework that reads schema_ver from NVS, initializes to 1 if missing, and runs migrations sequentially when schema_ver < COMPILED_NVS_VERSION. Each migration commits after each write for durability. Example v1→v2 migration renames 'ms_ip' to 'mothership_ip' and adds 'ntp_server' with default 'pool.ntp.org'. All migration steps logged to UART for debugging. Migration failure leaves NVS in consistent state.","source_repo":".","compaction_level":0,"original_size":0} -{"id":"spaxel-p5p","title":"Implement BLE Devices REST endpoints","description":"Implement GET /api/ble/devices to list known devices. Add PUT /api/ble/devices/{mac} to set label and assign to person. Include OpenAPI-style godoc comments.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.569849257Z","created_by":"coding","updated_at":"2026-04-07T13:32:26.840085899Z","close_reason":"Implemented BLE Devices REST endpoints with OpenAPI-style godoc comments: GET /api/ble/devices lists devices with filtering; PUT /api/ble/devices/{mac} updates label and assigns to person. Added comprehensive tests.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} +{"id":"spaxel-p5p","title":"Implement BLE Devices REST endpoints","description":"Implement GET /api/ble/devices to list known devices. Add PUT /api/ble/devices/{mac} to set label and assign to person. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.569849257Z","created_by":"coding","updated_at":"2026-04-07T13:37:18.640521533Z","closed_at":"2026-04-07T13:37:18.640340132Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-pel","title":"Dashboard presence indicator","description":"Add per-link motion detected/clear display to the dashboard.\n\n## Deliverables\n- WebSocket message from mothership to dashboard with per-link motion state\n- Visual indicator in dashboard UI: green = clear, red = motion detected per link\n- Amplitude time series plot for selected link (rolling window)\n- Update dashboard/js/app.js and mothership dashboard hub to broadcast motion state\n\n## Acceptance Criteria\n- Dashboard shows real-time motion/clear status for each active link\n- Amplitude time series updates smoothly\n- Works with the existing signal processing pipeline in mothership/internal/signal/\n\n## References\n- Dashboard code: dashboard/js/app.js, dashboard/index.html\n- Signal processing: mothership/internal/signal/processor.go (GetAllMotionStates)\n- Dashboard hub: mothership/internal/dashboard/hub.go","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:02.465235096Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.674201551Z","closed_at":"2026-03-27T02:55:53.328233821Z","close_reason":"Implemented per-link motion presence indicator: green CLEAR/red MOTION badge per link in dashboard, 60s rolling amplitude time series for selected link, immediate motion state broadcast from hub on idle<->motion transitions, fixed ampHistory init bug for JSON-created links.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-pel","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T01:34:05.674165567Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-pgu","title":"Implement detection feedback loop","description":"Build the detection feedback system that enables user-driven improvement.\n\nDeliverables:\n- Thumbs up/down UI for detection events\n- Missed-detection marking capability\n- Accuracy trend tracking and visualization\n\nAcceptance: Users can provide feedback on detections; system tracks accuracy metrics over time.","status":"closed","priority":2,"issue_type":"task","assignee":"sp1","created_at":"2026-03-29T19:25:03.930370782Z","created_by":"coding","updated_at":"2026-03-29T22:11:29.477805625Z","closed_at":"2026-03-29T22:11:29.477539090Z","close_reason":"Detection feedback loop fully implemented. Thumbs up/down UI, missed-detection marking, and accuracy trend tracking all complete.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} -{"id":"spaxel-pv5","title":"Backup: SQLite Online Backup API streaming endpoint","description":"## Overview\nImplement GET /api/backup using SQLite's Online Backup API for consistent hot backups without downtime or temp files.\n\n## Implementation (mothership/internal/ — new backup.go)\n\n### Why Online Backup API:\n- Simple file copy misses in-flight WAL pages and produces inconsistent backups\n- sqlite3_backup_* copies page-by-page; readers/writers continue uninterrupted\n- No temp file needed: stream directly to HTTP response\n\n### Go implementation using go-sqlite3 (CGO) or modernc.org/sqlite:\nfunc StreamBackup(w http.ResponseWriter, src *sql.DB):\n 1. Open in-memory destination DB: sqlite3_open(':memory:', &pDest)\n 2. Init backup: pBackup = sqlite3_backup_init(pDest, 'main', pSrc, 'main')\n 3. Loop: sqlite3_backup_step(pBackup, 100) until SQLITE_DONE\n 4. sqlite3_backup_finish(pBackup)\n 5. Read all bytes from pDest and write to http.ResponseWriter\n\n### Response format:\n- Content-Type: application/zip\n- Content-Disposition: attachment; filename='spaxel-backup-.zip'\n- Zip contents:\n - spaxel.db (from backup)\n - floor_plan/ directory (if exists)\n - VERSION file\n\n### Endpoint:\nGET /api/backup — requires session auth; streams zip directly; no temp files written\n\n## Acceptance\n- Backup completes while mothership is actively processing CSI frames\n- Downloaded .db file opens cleanly in sqlite3 CLI: PRAGMA integrity_check returns 'ok'\n- Backup size reasonable (not 0 bytes, not gigabytes for fresh install)\n- Simultaneous write during backup does not produce corrupt backup (verify with PRAGMA integrity_check)","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:10:29.966455717Z","created_by":"coding","updated_at":"2026-04-07T06:19:24.164031708Z","close_reason":"Implemented GET /api/backup using SQLite Online Backup API. The endpoint streams a zip archive (Content-Type: application/zip) containing all .db files, floor_plan/ directory (if present), and a VERSION file. Uses modernc.org/sqlite NewBackup/Step/Commit + Serialize for consistent hot backups with no temp files. Concurrent writes do not produce corrupt backups (verified via PRAGMA quick_check). 7 table-driven tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} +{"id":"spaxel-pv5","title":"Backup: SQLite Online Backup API streaming endpoint","description":"## Overview\nImplement GET /api/backup using SQLite's Online Backup API for consistent hot backups without downtime or temp files.\n\n## Implementation (mothership/internal/ — new backup.go)\n\n### Why Online Backup API:\n- Simple file copy misses in-flight WAL pages and produces inconsistent backups\n- sqlite3_backup_* copies page-by-page; readers/writers continue uninterrupted\n- No temp file needed: stream directly to HTTP response\n\n### Go implementation using go-sqlite3 (CGO) or modernc.org/sqlite:\nfunc StreamBackup(w http.ResponseWriter, src *sql.DB):\n 1. Open in-memory destination DB: sqlite3_open(':memory:', &pDest)\n 2. Init backup: pBackup = sqlite3_backup_init(pDest, 'main', pSrc, 'main')\n 3. Loop: sqlite3_backup_step(pBackup, 100) until SQLITE_DONE\n 4. sqlite3_backup_finish(pBackup)\n 5. Read all bytes from pDest and write to http.ResponseWriter\n\n### Response format:\n- Content-Type: application/zip\n- Content-Disposition: attachment; filename='spaxel-backup-.zip'\n- Zip contents:\n - spaxel.db (from backup)\n - floor_plan/ directory (if exists)\n - VERSION file\n\n### Endpoint:\nGET /api/backup — requires session auth; streams zip directly; no temp files written\n\n## Acceptance\n- Backup completes while mothership is actively processing CSI frames\n- Downloaded .db file opens cleanly in sqlite3 CLI: PRAGMA integrity_check returns 'ok'\n- Backup size reasonable (not 0 bytes, not gigabytes for fresh install)\n- Simultaneous write during backup does not produce corrupt backup (verify with PRAGMA integrity_check)","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:10:29.966455717Z","created_by":"coding","updated_at":"2026-04-07T10:17:12.858443123Z","closed_at":"2026-04-07T10:17:12.858299524Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:10"]} {"id":"spaxel-pvz","title":"Time-travel debugging and CSI replay","description":"## Background\n\nThe CSI recording buffer (Phase 2, spaxel-tqj) stores 48 hours of raw CSI frames on disk. Time-travel debugging lets you pause the live 3D view, scrub a timeline to any point in that 48-hour window, and replay the 3D scene exactly as it was at that moment. This is the most powerful debugging tool in spaxel: if a false alert fired at 3am, you can replay those 10 minutes and see exactly which links fired, what the blob positions were, and why the alert triggered. Parameter tuning without hardware becomes possible: change the motion threshold slider and immediately see how different the replay result would have been.\n\n## ReplayEngine\n\nNew package: mothership/internal/replay/engine.go\n\nReplayEngine manages the replay lifecycle:\n- state: LIVE, PAUSED, REPLAYING, SEEKING\n- replay_position: current replay timestamp\n- replay_speed: float64 (1.0 = real-time, 5.0 = 5x speed, 0.0 = paused)\n- linked_session_id: the WebSocket session ID of the client requesting replay (each dashboard session has its own replay state)\n\nReplayEngine.Seek(t time.Time): reads the recording buffer to the specified timestamp. Uses the segment file structure from spaxel-tqj: finds the correct segment file for time t, scans forward to the exact frame at t. Target: seek time < 1 second.\n\nReplayEngine.Play(speed float64): starts reading frames from the buffer at the specified speed and feeding them through the signal processing pipeline.\n\n## Replay Processing Pipeline\n\nThe replay pipeline is a copy of the live processing pipeline but with all outputs redirected to \"replay\" namespaced WebSocket messages:\n- \"replay_blob_update\" instead of \"blob_update\"\n- \"replay_track_update\" instead of \"track_update\"\n- \"replay_link_health\" instead of \"link_health\"\n\nThe replay pipeline uses a separate instance of:\n- SignalProcessor (with possibly modified parameters from the tuning sliders)\n- FusionEngine\n- TrackManager\n\nThese are cloned from the live instances at replay start so they inherit the current configuration, then modified by slider values.\n\nThe replay pipeline is self-contained: it does not affect the live pipeline in any way. Live detection continues while replay is active.\n\n## Parameter Tuning During Replay\n\nWhile in replay mode, the dashboard shows a \"Tuning\" panel with sliders for key signal processing parameters:\n- Motion threshold: deltaRMS threshold for motion detection (default from config, range 0.001 to 0.1)\n- Baseline tau: EMA time constant in seconds (default 30s, range 5s to 300s)\n- Fresnel weight sigma: Gaussian sigma for Fresnel zone contribution (default 0.1m, range 0.01m to 0.5m)\n- Minimum confidence for detection: composite minimum confidence before blob is reported (default 0.3)\n\nChanging any slider: the replay engine discards the current replay pipeline state and re-processes from the current replay_position with the new parameters. This takes at most 1-2 seconds for a typical segment (the CSI frames are already on disk; it's fast CPU processing).\n\n\"Apply to Live\" button: copies the currently-active replay parameters to the live configuration and persists them to the mothership config file. The live pipeline picks up the new values within one processing cycle. Requires confirmation modal: \"This will change the live detection configuration. Continue?\"\n\n## Dashboard Controls\n\nEntering replay mode: clicking the \"Pause\" button (or pressing Space) on the live dashboard:\n1. Pauses the live 3D view (3D scene stops updating)\n2. Shows the timeline scrubber: a horizontal bar spanning the 48-hour recording window\n3. Event markers appear on the scrubber at the timestamps of activity timeline events (zone transitions, alerts, etc.)\n4. \"Live\" chip in the dashboard header changes to \"Replay\" chip\n\nTimeline scrubber:\n- Click to seek to any position in the 48-hour window\n- Drag for continuous scrubbing\n- Event markers: small coloured ticks on the scrubber. Clicking a marker seeks to that event and jumps the activity timeline selection to that event row.\n- The current replay position is shown as a draggable thumb with a timestamp tooltip (\"2026-03-27 03:14:22\")\n\nPlayback controls:\n- Play/Pause button (Space key shortcut)\n- Speed selector: 1x, 5x, 10x\n- Step-forward button: advances replay by 1 second\n- \"Back to Live\" button: exits replay mode and resumes live updates\n\nThe 3D scene in replay mode: shows a \"REPLAY\" watermark badge in the top-left corner (so it's clear the view is not live). All live blob and track updates are suppressed while in replay mode (only replay_ prefixed messages update the scene).\n\n## Seek Performance\n\nThe recording buffer (spaxel-tqj) uses 1-hour segment files. To seek to timestamp T:\n1. Identify the correct segment file: {linkID}-{year}-{month}-{day}-{hour}.csi\n2. Binary search within the file: CSI frames are variable-length but each has a 24-byte header with timestamp_us. Scan forward from start of file to the frame nearest T. O(n) but files are ≤ 1 hour = at most 180,000 frames at 50 Hz. At 64-byte average header read, this is < 10MB scan and typically completes in < 200ms.\n3. Buffer a few seconds of frames ahead of T for smooth playback start.\n\nFor all active links: seek all link segment files in parallel (goroutines). Total seek time < 1s.\n\n## Files to Create or Modify\n\n- mothership/internal/replay/engine.go: ReplayEngine, state machine, seek, play, parameter injection\n- mothership/internal/replay/pipeline.go: replay signal processing pipeline (cloned from live)\n- mothership/internal/recording/ (spaxel-tqj): add SeekToTimestamp(t time.Time) method\n- mothership/internal/dashboard/hub.go: replay_ namespaced WebSocket message routing\n- dashboard/js/replay.js: timeline scrubber UI, playback controls, tuning panel\n- mothership/internal/dashboard/routes.go: WebSocket commands for replay control (type: \"replay_seek\", \"replay_play\", \"replay_pause\", \"replay_set_params\")\n\n## Tests\n\n- Test seek: create a mock recording buffer with known frames at known timestamps. Seek to an arbitrary timestamp, verify the returned frame is the closest one to the target.\n- Test that replay pipeline processes frames identically to live pipeline for the same input (regression test with saved CSI data and known expected output blobs)\n- Test parameter slider: change motion_threshold via replay command, verify the replay pipeline uses the new threshold on subsequent frames\n- Test \"Apply to Live\" correctly writes parameter changes to the live config\n- Test that live pipeline output is unaffected while replay is active (isolation test)\n- Test seek performance: 1-hour segment file with 180,000 frames, seek to timestamp in the middle, complete in < 500ms\n\n## Acceptance Criteria\n\n- Seek to any point in 48-hour window completes in < 1 second for all active links\n- Replay produces identical blob positions to original live processing for the same CSI input\n- Parameter sliders re-process the current replay position in < 3 seconds\n- \"Apply to Live\" copies parameters correctly and live detection immediately uses new values\n- Timeline scrubber event markers correctly align with activity timeline events\n- \"Back to Live\" correctly resumes live detection without any stale state\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:56:04.674847447Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.698778779Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-pvz","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.698749622Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-pvz","depends_on_id":"spaxel-tqj","type":"blocks","created_at":"2026-03-28T01:56:07.776160379Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-pwf","title":"Self-improving localisation with BLE ground truth","description":"## Background\n\nThe Fresnel zone fusion engine (spaxel-m9a) computes localisation by weighting each link's deltaRMS contribution according to the geometric intersection of candidate voxels with the Fresnel zone ellipsoid. These weights are currently uniform and based purely on geometry. In practice, some links are better at detecting motion in specific parts of the room than others — due to reflection geometry, multipath, furniture layout, and antenna orientation. By using BLE RSSI positions as continuous ground truth (when a person's labelled phone or wearable is visible), we can refine the per-link, per-zone weights to match observed physical reality.\n\n## Self-Improving Mechanism\n\nNew package: mothership/internal/learning/weights.go\n\nWeightLearner runs as a background goroutine. It operates on ground truth samples collected during normal operation.\n\nA ground truth sample is collected when BOTH:\n1. A confident BLE triangulated position is available for a known person (confidence > 0.7 from identity matching bead spaxel-nqh)\n2. A CSI blob position is within 0.5m of the BLE position (confirming the blob corresponds to that person)\n\nSample structure: {timestamp, person_id, ble_position Vec3, blob_position Vec3, per_link_delta_rms map[linkID]float64, per_link_health map[linkID]float64}\n\nThese samples are stored in SQLite: ground_truth_samples (id, timestamp, person_id, position_xyz, per_link_deltas_json, per_link_health_json). The table is capped at 10,000 samples per person (oldest first out) to prevent unbounded growth.\n\n## Online Weight Learning\n\nAfter accumulating 100+ samples for a given spatial zone (the room is divided into zones of 0.5m x 0.5m grid cells for this purpose), run incremental linear regression:\n\nPrediction model: position_estimate = sum_i (w_i * delta_rms_i) / sum_i w_i, where w_i are the learnable per-link weights.\n\nThe objective is to minimise the mean squared error between the position estimate from the weighted fusion and the ground truth BLE positions, over all samples in the zone.\n\nUpdate rule (stochastic gradient descent, online):\nFor each new ground truth sample:\n- Compute current position estimate using current weights\n- Compute error = ground_truth_position - estimated_position\n- For each link i: w_i += learning_rate * error * delta_rms_i / |delta_rms_vector|\n- learning_rate = 0.001 (small to prevent overfitting to transient environmental changes)\n- Apply L2 regularisation: w_i *= (1 - regularisation * learning_rate) where regularisation = 0.01\n\nClip weights to [0, 5] to prevent divergence. Normalise weight vector to unit sum after each update.\n\n## Validation Gate\n\nTo prevent the learned weights from degrading accuracy (overfitting, transient environmental changes, sensor noise):\n\nHold out 20% of samples as a validation set (random selection). After each batch of 50 weight updates, compute the mean position error on the validation set using the updated weights vs. the original (geometric) weights.\n\nOnly persist the updated weights if: validation_error_new < validation_error_original * 0.95 (at least 5% improvement on the validation set).\n\nIf the validation check fails, discard the weight update and log: \"Weight update rejected: no improvement on validation set. Keeping current weights.\"\n\nThis is a conservative gate. The threshold is configurable (fleet.weight_improvement_threshold, default 0.05).\n\n## Weight Storage\n\nSQLite table: link_weights (link_id TEXT, zone_grid_x INT, zone_grid_y INT, weight REAL, sample_count INT, last_updated DATETIME, validation_improvement REAL, PRIMARY KEY (link_id, zone_grid_x, zone_grid_y)).\n\nZone grid: floor is divided into 0.5m cells. zone_grid_x = floor(x / 0.5), zone_grid_y = floor(y / 0.5). This allows position-dependent weights — a link might be excellent for localisation in one area and poor in another.\n\nOn FusionEngine update: instead of using geometric Fresnel zone weights alone, multiply by the learned spatial weight for the voxel being evaluated (bilinear interpolation between grid cells for smooth transitions).\n\nFallback: if no learned weight exists for a grid cell (insufficient samples), use the geometric weight (learned weight = 1.0). This ensures correctness during the learning period.\n\n## Accuracy Trend in Dashboard\n\nThe accuracy improvement from learning should be visible to users. In the \"Accuracy\" dashboard panel (Phase 7 feedback loop bead):\n\nAdd \"Position accuracy\" subsection:\n- Median position error (m): computed weekly from ground truth samples. median(|ble_position - blob_position|) over all weekly samples.\n- Week-over-week trend: sparkline of weekly median position error. Arrow indicating direction (improving/degrading).\n- Sample count: \"Based on N position measurements from M people this week\"\n- \"Accuracy improving\" badge when position error has decreased by > 10% vs previous week.\n\n## Files to Create or Modify\n\n- mothership/internal/learning/weights.go: WeightLearner, SGD update, validation gate\n- mothership/internal/learning/samples.go: ground truth sample collection, SQLite storage\n- mothership/internal/fusion/engine.go (spaxel-m9a): integrate learned weights in FusionEngine\n- mothership/internal/dashboard/routes.go: GET /api/accuracy/weights (debug endpoint showing current weight map)\n- dashboard/js/accuracy.js: position accuracy trend chart\n\n## Tests\n\n- Test ground truth sample collection gates correctly: confidence > 0.7 AND BLE-blob distance < 0.5m -> sample collected; confidence = 0.6 -> no sample\n- Test SGD weight update: after 100 samples with known ground truth, verify weights move in the direction that reduces error\n- Test validation gate: inject a batch of adversarial samples that would degrade accuracy, verify gate rejects the update\n- Test bilinear interpolation between adjacent grid cells produces smooth weight values\n- Test weight fallback: FusionEngine correctly uses geometric weight=1.0 when no learned weight exists for a grid cell\n- Test SQLite cap: inserting 10,001 samples removes the oldest one, maintaining the 10,000 cap\n\n## Acceptance Criteria\n\n- Position error decreases measurably over 2+ weeks of operation with BLE ground truth data (target: from initial ~1.2m to < 0.8m median error)\n- Validation gate prevents weight regressions (mock adversarial samples do not degrade fusion accuracy)\n- Weight updates persist across mothership restarts\n- Position accuracy trend visible in dashboard Accuracy panel\n- Sample collection rate visible (samples per day per person) in dashboard\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T01:50:34.214065492Z","created_by":"coding","updated_at":"2026-03-30T00:12:00.715207673Z","closed_at":"2026-03-30T00:12:00.715088959Z","close_reason":"Implemented self-improving localization with BLE ground truth. Created spatial weight learner with SGD, validation gate, bilinear interpolation. Added position accuracy visualization to dashboard. All tests implemented.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-3ps","type":"blocks","created_at":"2026-03-28T01:50:36.699492024Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-pwf","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.574878149Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-qfp","title":"Sleep quality monitoring","description":"## Background\n\nThe breathing analysis feature (Phase 5, spaxel-r37) detects the micro-motion of breathing in stationary people. Run continuously in bedroom zones overnight, it can compute sleep quality metrics without any wearable device. Chest displacement during breathing at 15 breaths/minute produces a detectable 0.25 Hz signal in CSI. By tracking this overnight, combined with motion events (wake episodes) and the timing of presence in the bedroom zone, we can produce a sleep summary that rivals basic commercial sleep trackers — without the user wearing anything.\n\n## Sleep Session Detection\n\nSleepMonitor in mothership/internal/sleep/monitor.go.\n\nSession onset detection (all conditions must hold):\n1. Person is in a bedroom zone (zone with is_bedroom flag = true, set in zone editor)\n2. Stationary detection fires (STATIONARY_DETECTED state from breathing analysis bead)\n3. BLE device shows reduced activity (optional enhancement: phone advertising rate drops when screen is off; this is a bonus signal, not required)\nTentative onset: all conditions met. Confirmed onset: conditions hold for 15 consecutive minutes.\n\nSession end detection:\n1. Person leaves bedroom zone (zone transition event fires)\n2. OR: motion detection fires for > 2 minutes (sustained motion = getting up)\n3. OR: stationary detection drops and does not return for > 30 minutes (person left room without portal crossing — reconciliation path)\n\nSession record stored in SQLite:\nCREATE TABLE sleep_sessions (\n id TEXT PRIMARY KEY,\n person_id TEXT NOT NULL,\n zone_id TEXT NOT NULL, -- bedroom zone\n session_date DATE NOT NULL, -- the date this sleep night belongs to (typically today-1 for morning reports)\n sleep_onset DATETIME, -- time tentative detection was confirmed\n wake_time DATETIME,\n time_in_bed_minutes REAL,\n sleep_latency_minutes REAL, -- time from entering bedroom to sleep onset\n wake_episode_count INTEGER DEFAULT 0,\n wake_after_sleep_onset_minutes REAL, -- total time awake after first sleep onset\n breathing_rate_mean REAL,\n breathing_rate_stddev REAL,\n breathing_anomaly_count INTEGER DEFAULT 0, -- breathing < 8 or > 25 per minute\n sleep_efficiency REAL -- (time_in_bed - waso) / time_in_bed * 100\n);\n\nCREATE TABLE sleep_wake_episodes (\n id TEXT PRIMARY KEY,\n session_id TEXT,\n episode_start DATETIME,\n episode_end DATETIME,\n duration_seconds REAL\n);\n\n## Sleep Metrics Computation\n\nDuring the sleep session, SleepMonitor subscribes to:\n- Breathing data: periodic sample of breathing_freq_hz from BreathingDetector (spaxel-r37). Store in a rolling buffer.\n- Motion events: MOTION_DETECTED state transitions from LinkProcessor. Each motion event during a confirmed sleep session is a potential wake episode.\n\nWake episode classification:\n- If deltaRMS > threshold for > 3 seconds: wake episode starts\n- If deltaRMS returns below threshold and breathing signal resumes: wake episode ends\n- Store episode start/end in sleep_wake_episodes\n\nBreathing analysis during sleep:\n- Mean breathing rate (bpm): mean(breathing_freq_hz * 60) over all samples in session\n- Breathing rate standard deviation: indicates sleep stage variability (higher variance may indicate REM activity)\n- Breathing anomaly: if breathing_freq_hz * 60 < 8 or > 25 for > 3 consecutive minutes: log anomaly. This is a proxy for potential sleep apnoea or hyperventilation.\n\nSleep efficiency: (time_in_bed_minutes - wake_after_sleep_onset_minutes) / time_in_bed_minutes * 100. A value above 85% is considered good sleep efficiency.\n\n## Morning Summary Card\n\nOn first WebSocket connection from the dashboard after 6am AND after a sleep session has ended (wake_time is set):\n- Mothership pushes a \"morning_summary\" WebSocket message with the completed session data\n- Dashboard renders a dismissible card in simple mode (full width at top) and as a floating panel in expert mode\n\nCard content:\n- \"Last night: [sleep_duration] h [mm] min\"\n- Colored efficiency indicator: green (>85%), amber (70-85%), red (<70%)\n- Wake episodes: \"2 wake episodes, [total waso] min awake after sleep onset\"\n- Breathing: \"Average breathing: [N] breaths/min\"\n- Anomaly note (if applicable): \"Unusual breathing detected at [time]. [View details]\"\n- \"View full sleep report\" link (opens detailed timeline view in expert mode)\n\n## Weekly Trends\n\nDashboard \"Sleep\" panel:\n- 7-day sparkline of sleep duration per night\n- 7-day sparkline of sleep efficiency per night\n- Average breathing rate over the week\n- Week-over-week comparison: \"This week you slept 6h 48m on average (vs. 7h 12m last week)\"\n\n## Per-Person Tracking\n\nSleep monitoring is person-specific and requires BLE identity (so the system knows whose bedroom this is). Multiple people sharing a bedroom: each person has their own sleep session if their BLE devices can be distinguished. If both people are in bed simultaneously, the breathing detector may pick up a blend of two breathing rates — acknowledge this limitation in documentation.\n\nFor anonymous tracks (no BLE identity): detect in-bedroom stationary presence only (no per-person sleep report). Log \"Unidentified person in bedroom zone\" for 8+ hour periods.\n\n## Zone Configuration\n\nThe zone editor (portals bead, spaxel-qlh) is extended with a zone type selector:\n- Normal zone (default)\n- Bedroom (enables sleep monitoring)\n- Kitchen (no special behavior)\n- Children's zone (suppresses fall detection)\n\nThis is stored as zone_type in the zones table.\n\n## Files to Create or Modify\n\n- mothership/internal/sleep/monitor.go: SleepMonitor, session detection, metric computation\n- mothership/internal/sleep/report.go: morning summary generation, weekly trend aggregation\n- mothership/internal/signal/breathing.go (spaxel-r37): add tick-based sample reporting for sleep monitor\n- dashboard/js/sleep.js: morning summary card, Sleep panel\n- mothership/internal/events/events.go: SleepSessionStartEvent, SleepSessionEndEvent\n\n## Tests\n\n- Test sleep session onset: stationary detection fires, person in bedroom, 15 minutes -> session confirmed\n- Test that stationary detection < 15 minutes does not create a session (avoids brief naps misclassified)\n- Test wake episode counting: 3 MOTION_DETECTED events > 3s each during a session -> wake_episode_count = 3\n- Test wake after sleep onset calculation: 3 episodes of 5 minutes each -> waso = 15 minutes\n- Test sleep efficiency calculation: 480 minutes in bed, 45 minutes waso -> efficiency = 90.6%\n- Test breathing anomaly detection: inject 4 minutes of breathing_freq_hz = 0.1 (6 bpm) -> anomaly logged\n- Test morning summary trigger fires only on first connection after 6am AND after session end\n\n## Acceptance Criteria\n\n- Sleep session detected within 15 minutes of confirmed onset (stationary in bedroom zone)\n- Wake episodes counted correctly (tested with synthetic motion event injection)\n- Morning summary card appears on first dashboard open after wake time (6am by default, configurable)\n- Weekly trends sparkline shows 7 nights of data after 7 days\n- Sleep session data persists in SQLite across mothership restarts\n- Breathing anomaly flag fires correctly for rate < 8 or > 25 bpm\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:52:06.457208929Z","created_by":"coding","updated_at":"2026-04-07T19:45:27.613128879Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} +{"id":"spaxel-qfp","title":"Sleep quality monitoring","description":"## Background\n\nThe breathing analysis feature (Phase 5, spaxel-r37) detects the micro-motion of breathing in stationary people. Run continuously in bedroom zones overnight, it can compute sleep quality metrics without any wearable device. Chest displacement during breathing at 15 breaths/minute produces a detectable 0.25 Hz signal in CSI. By tracking this overnight, combined with motion events (wake episodes) and the timing of presence in the bedroom zone, we can produce a sleep summary that rivals basic commercial sleep trackers — without the user wearing anything.\n\n## Sleep Session Detection\n\nSleepMonitor in mothership/internal/sleep/monitor.go.\n\nSession onset detection (all conditions must hold):\n1. Person is in a bedroom zone (zone with is_bedroom flag = true, set in zone editor)\n2. Stationary detection fires (STATIONARY_DETECTED state from breathing analysis bead)\n3. BLE device shows reduced activity (optional enhancement: phone advertising rate drops when screen is off; this is a bonus signal, not required)\nTentative onset: all conditions met. Confirmed onset: conditions hold for 15 consecutive minutes.\n\nSession end detection:\n1. Person leaves bedroom zone (zone transition event fires)\n2. OR: motion detection fires for > 2 minutes (sustained motion = getting up)\n3. OR: stationary detection drops and does not return for > 30 minutes (person left room without portal crossing — reconciliation path)\n\nSession record stored in SQLite:\nCREATE TABLE sleep_sessions (\n id TEXT PRIMARY KEY,\n person_id TEXT NOT NULL,\n zone_id TEXT NOT NULL, -- bedroom zone\n session_date DATE NOT NULL, -- the date this sleep night belongs to (typically today-1 for morning reports)\n sleep_onset DATETIME, -- time tentative detection was confirmed\n wake_time DATETIME,\n time_in_bed_minutes REAL,\n sleep_latency_minutes REAL, -- time from entering bedroom to sleep onset\n wake_episode_count INTEGER DEFAULT 0,\n wake_after_sleep_onset_minutes REAL, -- total time awake after first sleep onset\n breathing_rate_mean REAL,\n breathing_rate_stddev REAL,\n breathing_anomaly_count INTEGER DEFAULT 0, -- breathing < 8 or > 25 per minute\n sleep_efficiency REAL -- (time_in_bed - waso) / time_in_bed * 100\n);\n\nCREATE TABLE sleep_wake_episodes (\n id TEXT PRIMARY KEY,\n session_id TEXT,\n episode_start DATETIME,\n episode_end DATETIME,\n duration_seconds REAL\n);\n\n## Sleep Metrics Computation\n\nDuring the sleep session, SleepMonitor subscribes to:\n- Breathing data: periodic sample of breathing_freq_hz from BreathingDetector (spaxel-r37). Store in a rolling buffer.\n- Motion events: MOTION_DETECTED state transitions from LinkProcessor. Each motion event during a confirmed sleep session is a potential wake episode.\n\nWake episode classification:\n- If deltaRMS > threshold for > 3 seconds: wake episode starts\n- If deltaRMS returns below threshold and breathing signal resumes: wake episode ends\n- Store episode start/end in sleep_wake_episodes\n\nBreathing analysis during sleep:\n- Mean breathing rate (bpm): mean(breathing_freq_hz * 60) over all samples in session\n- Breathing rate standard deviation: indicates sleep stage variability (higher variance may indicate REM activity)\n- Breathing anomaly: if breathing_freq_hz * 60 < 8 or > 25 for > 3 consecutive minutes: log anomaly. This is a proxy for potential sleep apnoea or hyperventilation.\n\nSleep efficiency: (time_in_bed_minutes - wake_after_sleep_onset_minutes) / time_in_bed_minutes * 100. A value above 85% is considered good sleep efficiency.\n\n## Morning Summary Card\n\nOn first WebSocket connection from the dashboard after 6am AND after a sleep session has ended (wake_time is set):\n- Mothership pushes a \"morning_summary\" WebSocket message with the completed session data\n- Dashboard renders a dismissible card in simple mode (full width at top) and as a floating panel in expert mode\n\nCard content:\n- \"Last night: [sleep_duration] h [mm] min\"\n- Colored efficiency indicator: green (>85%), amber (70-85%), red (<70%)\n- Wake episodes: \"2 wake episodes, [total waso] min awake after sleep onset\"\n- Breathing: \"Average breathing: [N] breaths/min\"\n- Anomaly note (if applicable): \"Unusual breathing detected at [time]. [View details]\"\n- \"View full sleep report\" link (opens detailed timeline view in expert mode)\n\n## Weekly Trends\n\nDashboard \"Sleep\" panel:\n- 7-day sparkline of sleep duration per night\n- 7-day sparkline of sleep efficiency per night\n- Average breathing rate over the week\n- Week-over-week comparison: \"This week you slept 6h 48m on average (vs. 7h 12m last week)\"\n\n## Per-Person Tracking\n\nSleep monitoring is person-specific and requires BLE identity (so the system knows whose bedroom this is). Multiple people sharing a bedroom: each person has their own sleep session if their BLE devices can be distinguished. If both people are in bed simultaneously, the breathing detector may pick up a blend of two breathing rates — acknowledge this limitation in documentation.\n\nFor anonymous tracks (no BLE identity): detect in-bedroom stationary presence only (no per-person sleep report). Log \"Unidentified person in bedroom zone\" for 8+ hour periods.\n\n## Zone Configuration\n\nThe zone editor (portals bead, spaxel-qlh) is extended with a zone type selector:\n- Normal zone (default)\n- Bedroom (enables sleep monitoring)\n- Kitchen (no special behavior)\n- Children's zone (suppresses fall detection)\n\nThis is stored as zone_type in the zones table.\n\n## Files to Create or Modify\n\n- mothership/internal/sleep/monitor.go: SleepMonitor, session detection, metric computation\n- mothership/internal/sleep/report.go: morning summary generation, weekly trend aggregation\n- mothership/internal/signal/breathing.go (spaxel-r37): add tick-based sample reporting for sleep monitor\n- dashboard/js/sleep.js: morning summary card, Sleep panel\n- mothership/internal/events/events.go: SleepSessionStartEvent, SleepSessionEndEvent\n\n## Tests\n\n- Test sleep session onset: stationary detection fires, person in bedroom, 15 minutes -> session confirmed\n- Test that stationary detection < 15 minutes does not create a session (avoids brief naps misclassified)\n- Test wake episode counting: 3 MOTION_DETECTED events > 3s each during a session -> wake_episode_count = 3\n- Test wake after sleep onset calculation: 3 episodes of 5 minutes each -> waso = 15 minutes\n- Test sleep efficiency calculation: 480 minutes in bed, 45 minutes waso -> efficiency = 90.6%\n- Test breathing anomaly detection: inject 4 minutes of breathing_freq_hz = 0.1 (6 bpm) -> anomaly logged\n- Test morning summary trigger fires only on first connection after 6am AND after session end\n\n## Acceptance Criteria\n\n- Sleep session detected within 15 minutes of confirmed onset (stationary in bedroom zone)\n- Wake episodes counted correctly (tested with synthetic motion event injection)\n- Morning summary card appears on first dashboard open after wake time (6am by default, configurable)\n- Weekly trends sparkline shows 7 nights of data after 7 days\n- Sleep session data persists in SQLite across mothership restarts\n- Breathing anomaly flag fires correctly for rate < 8 or > 25 bpm\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"charlie","created_at":"2026-03-28T01:52:06.457208929Z","created_by":"coding","updated_at":"2026-04-07T20:49:04.340318254Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:72"]} {"id":"spaxel-qgj","title":"Implement NTP client in ESP32 firmware","description":"Add NTP synchronization to firmware/main/wifi.c or ntp.c:\n- Call esp_sntp_setservername(0, ntp_server) before esp_sntp_init() on boot\n- ntp_server read from NVS 'ntp_server' key (default: 'pool.ntp.org')\n- Attempt sync for up to 10 seconds after WiFi connect; log WARN if sync fails\n- On sync failure: proceed without stagger (rely on CSMA/CA)\n- Resync every 10 minutes via esp_timer periodic callback\n- Include ntp_synced status in health JSON message\n\nAcceptance: Node health messages show ntp_synced: true when pool is reachable; ntp_synced: false when NTP blocked — node still operates normally; resync occurs every ~600s (verified via UART logs)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-07T14:37:00.302557793Z","created_by":"coding","updated_at":"2026-04-07T17:32:57.896842167Z","closed_at":"2026-04-07T17:32:57.896693758Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-u7y"]} {"id":"spaxel-qlh","title":"Room transition portals and zone occupancy","description":"## Background\n\nKnowing a blob is at coordinates (3.2m, 1.8m, 1.0m) is useful to the algorithm, but \"Alice is in the Kitchen\" is useful to a person. Room transition portals define doorway planes between named zones. When a track's trajectory intersects a portal plane, the zone occupancy counts update and a transition event fires. This is the foundation for natural language presence display (\"Alice is in the Kitchen\"), automation triggers (\"when Alice enters the bedroom\"), and the activity timeline (\"Alice moved from Living Room to Kitchen at 14:23\").\n\n## Zone Definitions\n\nZones are named 3D volumes represented as axis-aligned bounding boxes (AABB) for simplicity. Each zone has: id (uuid), name (\"Kitchen\"), bounds_min (Vec3), bounds_max (Vec3), color (hex string for 3D overlay), created_at.\n\nSQLite schema:\nCREATE TABLE zones (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n bounds_min_x REAL, bounds_min_y REAL, bounds_min_z REAL,\n bounds_max_x REAL, bounds_max_y REAL, bounds_max_z REAL,\n color TEXT DEFAULT '#3b82f6',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nContainment test: a position P is in zone Z if bounds_min_x <= P.x <= bounds_max_x AND bounds_min_y <= P.y <= bounds_max_y. The Z bounds are typically 0 to ceiling height (usually 2.5m) since we track floor-plane position.\n\n## Portal Definitions\n\nA portal is a vertical plane segment spanning a doorway. It divides two zones and detects crossings.\n\nPortal schema:\nCREATE TABLE portals (\n id TEXT PRIMARY KEY,\n name TEXT, -- e.g. \"Kitchen Door\"\n zone_a_id TEXT, -- zone on one side\n zone_b_id TEXT, -- zone on other side\n plane_point Vec3, -- a point on the portal plane (e.g. centre of doorway)\n plane_normal Vec3, -- unit normal vector of the portal plane\n width REAL, -- width of the doorway in metres\n height REAL, -- height of the doorway (default: 2.1m)\n created_at DATETIME\n);\n\nA portal normal points from zone_a toward zone_b. A crossing from zone_a to zone_b has dot(velocity, normal) > 0. A crossing from zone_b to zone_a has dot(velocity, normal) < 0.\n\n## Portal Editor (3D Dashboard)\n\nExtend the node placement UI (spaxel-qq6) with portal editing:\n1. User clicks \"Add Portal\" button\n2. A vertical plane appears in the 3D scene at the camera's focal point\n3. User drags the plane using TransformControls (from Three.js addons) to position it across a doorway\n4. User adjusts width and assigns zone names on each side (dropdown of existing zones or \"Create new zone\")\n5. User clicks \"Save\" — portal is stored in SQLite and rendered as a semi-transparent divider plane in the 3D scene\n\nPortal rendering: thin coloured plane (opacity 0.3, colour #a855f7 purple) with a label at the top edge showing the portal name. When a track crosses the portal, the plane briefly flashes brighter (animated opacity increase then decay back to 0.3).\n\nZone rendering: semi-transparent coloured cuboid volumes (opacity 0.1, colour from zone.color). Zone name displayed as a floating text label at the zone centroid (using THREE.Sprite). A \"Zones\" layer toggle in the 3D view hides/shows all zones simultaneously.\n\n## Crossing Detection\n\nCrossingDetector runs as part of the TrackManager update loop (10 Hz). For each track update:\n\n1. For each active portal, test if the track crossed the portal plane in the last update step:\n - Previous position P_prev, current position P_curr\n - Check if the line segment P_prev -> P_curr intersects the portal plane within the portal's rectangular bounds (width x height centered on plane_point)\n - Intersection test: t = dot(plane_point - P_prev, normal) / dot(P_curr - P_prev, normal). If 0 <= t <= 1, compute intersection point P_int = P_prev + t*(P_curr - P_prev), then check if P_int is within the doorway rectangle.\n - Crossing direction: if dot(P_curr - P_prev, normal) > 0, direction is A_to_B; otherwise B_to_A.\n\n2. On crossing detected: update occupancy counts, emit ZoneCrossingEvent.\n\nZoneCrossingEvent: {portal_id, track_id, person_id, person_label, from_zone_id, from_zone_name, to_zone_id, to_zone_name, direction, timestamp}.\n\nThis event is:\n- Published to the internal event bus\n- Broadcast via WebSocket to dashboard as type \"zone_transition\"\n- Appended to activity timeline (Phase 8)\n- Processed by automation engine (Phase 6)\n\n## Occupancy Counter\n\nOccupancyManager maintains a per-zone current occupant list (map[zoneID][]TrackID).\n\nUpdates from two sources:\n1. CrossingDetector portal events: when a track crosses from zone A to B, move its entry in the occupancy map from A to B.\n2. Direct containment check: run every 30 seconds as a reconciliation pass. For each active track, check if it is within any zone's bounding box. If the track is in zone C but the occupancy map says it is in zone A (e.g. track was created inside a zone without crossing a portal), update accordingly.\nThe containment check prevents \"teleportation\" inconsistencies when tracks are created or resume from coasting state.\n\n## WebSocket Broadcast\n\nOn each zone occupancy change, the mothership broadcasts:\n{\"type\":\"zone_occupancy\",\"zones\":[{\"id\":\"zone-kitchen\",\"name\":\"Kitchen\",\"occupants\":[{\"track_id\":\"track-1\",\"person_id\":\"uuid-alice\",\"person_label\":\"Alice\"}]},{\"id\":\"zone-living\",\"name\":\"Living Room\",\"occupants\":[]}]}\n\nAnd specifically on crossings:\n{\"type\":\"zone_transition\",\"portal_id\":\"...\",\"person_label\":\"Alice\",\"from_zone\":\"Kitchen\",\"to_zone\":\"Living Room\",\"timestamp\":\"2026-03-27T14:23:00Z\"}\n\n## REST API\n\nGET /api/zones: list all zones with current occupancy\nPOST /api/zones: create zone\nPUT /api/zones/{id}: update zone bounds/name/color\nDELETE /api/zones/{id}: delete zone (removes from all occupancy tracking)\n\nGET /api/portals: list all portals\nPOST /api/portals: create portal\nPUT /api/portals/{id}: update portal\nDELETE /api/portals/{id}: delete portal\n\nGET /api/zones/{id}/history?since=2026-03-27T00:00:00Z: get crossing history for zone (list of ZoneCrossingEvent)\n\n## Tests\n\n- Test portal crossing detection with a track path that passes through the portal plane: verify crossing event fires with correct direction\n- Test that a track path that runs parallel to a portal plane but within 0.1m does not fire a false crossing\n- Test that a track path outside the portal's width bounds does not fire a crossing\n- Test occupancy count updates: zone Kitchen starts with 1 occupant, track crosses portal to Living Room, Kitchen count = 0, Living Room count = 1\n- Test the 30-second reconciliation pass: track that appears inside a zone without crossing a portal is correctly assigned to that zone\n- Test zone containment with a position exactly on the bounds_min edge (inclusive boundary)\n- Test that zone_transition WebSocket message is broadcast with correct from_zone and to_zone names\n\n## Acceptance Criteria\n\n- Portal editor allows placing vertical plane portals across doorways in the 3D scene\n- Zone bounding boxes are editable and render as semi-transparent volumes in 3D view\n- Zone labels update in real-time as people move between zones (\"Kitchen: Alice, Bob\")\n- Zone transition events fire within one track update cycle (100ms) of the crossing occurring\n- Reconciliation pass correctly handles tracks that appear inside zones without portal crossings\n- Zone and portal data persists across mothership restarts via SQLite\n- WebSocket broadcasts zone_occupancy after every occupancy change\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:45:41.668543362Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.268105795Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.268078719Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-qlh","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-03-28T01:45:44.642770328Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-qob","title":"Webhook action firing & fault tolerance for automations","description":"## Overview\nReliable webhook delivery for automation trigger actions with error handling and dashboard feedback.\n\n## Backend (mothership/automation/)\n- HTTP client: POST to configured URL with 5s timeout; fire-and-forget (no retry)\n- Payload schema: {trigger_id, trigger_name, condition, blob_id, person, position:{x,y,z}, zone, dwell_s, timestamp_ms}\n- Error handling:\n - 4xx response: disable trigger + set trigger.error_message; push WS alert to dashboard\n - 5xx / timeout: log warning + increment trigger.error_count; do NOT disable\n - error_count resets on first 2xx response\n- Test endpoint: POST /api/triggers/{id}/test — fires webhook once with synthetic payload, returns {status, response_ms, error}\n- Audit log: webhook_log table (trigger_id, fired_at_ms, url, status_code, latency_ms, error)\n\n## Dashboard\n- Error badge on trigger card when disabled due to 4xx\n- 'Test Webhook' button in trigger edit panel — shows response in real time\n- Last N firings visible in trigger detail view (from webhook_log)\n- 'Re-enable' button to clear error state and retry\n\n## Acceptance\n- 5xx failures do not disable triggers\n- 4xx disables trigger and shows dashboard warning within 2s\n- Test endpoint returns response within timeout + 500ms overhead\n- Requires: spaxel-6ha (REST API), spaxel-vuw (trigger volumes), spaxel-9eg (WS alerts)","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T13:01:53.677999018Z","created_by":"coding","updated_at":"2026-04-07T04:16:09.129273227Z","closed_at":"2026-04-07T04:16:09.129061569Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:8"]} @@ -102,7 +109,7 @@ {"id":"spaxel-qq6","title":"Node placement UI and coverage painting","description":"Interactive 3D node placement with real-time GDOP coverage overlay.\n\n## Deliverables\n- TransformControls for dragging nodes in 3D scene\n- Space dimension editor (set room width, depth, height)\n- GDOP (Geometric Dilution of Precision) overlay on ground plane\n- Coverage overlay updates in real-time during node drag\n- Virtual node support for planning (not yet physical)\n- Save node positions to mothership via REST API\n\n## Acceptance Criteria\n- User can drag nodes and see coverage quality change in real-time\n- GDOP overlay clearly shows good vs poor detection areas\n- Virtual nodes help plan optimal placement before purchasing hardware\n\n## References\n- Plan: docs/plan/plan.md items 18-19\n- Dashboard: dashboard/js/app.js","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-27T01:57:11.969814548Z","created_by":"coding","updated_at":"2026-03-28T13:26:19.014111052Z","closed_at":"2026-03-28T13:26:19.013998615Z","close_reason":"Implemented interactive 3D node placement with real-time GDOP coverage overlay. Frontend: TransformControls for node dragging, room dimension editor, GDOP overlay (128x128 DataTexture with HDOP computation), real-time updates during drag, virtual node support, click-to-select via raycasting, GDOP legend. Backend: PUT /api/nodes/{mac}/position, POST /api/nodes/virtual, DELETE /api/nodes/{mac}, PUT /api/room endpoints, OTAStatusHandler wiring, firmware manifest fix. Go build verified. Committed as 7b32570.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-qq6","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.773203986Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-r0l","title":"Guided troubleshooting and first-time UX","description":"## Background\n\nThe onboarding wizard (spaxel-zvo) handles the happy path from unboxed ESP32 to streaming CSI. This bead handles what happens when things go wrong — during onboarding and during normal operation — and provides first-time feature discovery for users after successful setup. The design principle is that a non-technical household member should never need to read documentation, open a terminal, or contact support for any common problem. Every failure mode has a recovery path visible in the UI.\n\n## Scope\n\nThis Phase 4 bead covers foundational troubleshooting infrastructure. A Phase 9 bead (Guided troubleshooting enhanced for production) extends this with proactive quality prompts and production-polish improvements.\n\n## 1. Onboarding Failure Guidance\n\nAt each step of the onboarding wizard, intercept known failure modes and render human-readable recovery steps instead of technical errors. Each wizard step has an error state with specific guidance:\n\nBrowser check errors:\n- navigator.serial unavailable -> \"Please use Google Chrome or Microsoft Edge. Firefox and Safari do not support USB device communication.\"\n\nDevice connection errors:\n- No device selected -> \"Did you hold the BOOT button while plugging in? Try again: hold BOOT, then plug in the USB cable.\"\n- Permission denied -> \"Browser blocked USB access. Check your browser's site permissions for this address.\"\n- Access denied (device in use) -> \"Another application is using this USB port. Close Arduino IDE, esptool, or any other serial monitor and try again.\"\n\nWiFi provisioning errors:\n- Node not appearing after 120s -> Stepped guidance: (1) Check SSID/password are exactly correct. (2) ESP32-S3 only supports 2.4GHz — check your router's 2.4GHz band is enabled. (3) Check if your router has AP isolation / client isolation enabled (blocks device-to-device communication). (4) Try moving the node closer to the router.\n- Node appears but no CSI data -> \"The node connected but is not sensing yet. Check the antenna orientation — the PCB antenna should face away from walls.\"\n\n## 2. Node Offline Troubleshooting\n\nWhen a node goes offline (detected via heartbeat timeout in mothership), the dashboard shows a troubleshooting timeline card:\n\n\"Node [Living Room] went offline at [time].\"\nTimeline of suggested actions:\n1. Check the node's power LED is on (solid green = powered and connected, blinking = attempting WiFi)\n2. If blinking: move the node closer to your WiFi router temporarily\n3. If the LED is blinking rapidly after 5 minutes: the node has lost its WiFi configuration. Connect to 'spaxel-{last4mac}' WiFi network to reconfigure. [Link to captive portal guide]\n4. If the LED is off: check the power supply and USB cable\n5. Still stuck? [Reset to factory defaults] (button that sends a \"reboot\" downstream command if node reconnects, or shows instructions for manual factory reset via BOOT button hold)\n\nThe card remains visible until the node reconnects. It includes last-known position on the floor plan (greyed out) and how long it has been offline.\n\n## 3. First-Time Feature Tooltips\n\nOn the first dashboard open after a node is successfully added (tracked by a localStorage key \"spaxel_tooltips_shown\"), show contextual tooltips pointing to key features:\n- Point at the CSI amplitude chart: \"This is your live signal. Motion causes the waves to change.\"\n- Point at the 3D view (if available): \"This 3D space updates as people move around.\"\n- Point at the presence indicator: \"Green = no one detected. Red = motion detected.\"\n- Point at the link list: \"Each line between two nodes is a sensing link.\"\n\nTooltips: auto-dismiss after 8 seconds, dismiss-all button, never re-appear (localStorage flag per tooltip ID). Rendered as floating HTML divs with an arrow pointing to the target element, positioned via getBoundingClientRect().\n\n## 4. Post-Calibration Reinforcement\n\nAfter the guided calibration walk in the onboarding wizard completes (blob detected successfully), show a \"You're all set\" card:\n- Summary: \"[Node A] calibrated. 1 sensing link active. Motion detection: Ready.\"\n- What to expect: \"You'll see the CSI waveform react when someone walks through the room. The system learns your space over the next few hours and becomes more accurate.\"\n- Next step prompt: \"Want to add another node for more precise location tracking? [Add another node] [I'm done for now]\"\n\n## 5. Detection Quality Prompts (Phase 4 basic version)\n\nWhen a link's packet rate drops below 50% of the configured rate for more than 60 seconds (basic quality issue detectable without Phase 5 full confidence scoring):\n- Show a non-blocking banner: \"Node A is having trouble communicating. Check that it is powered on and within WiFi range.\"\n- Auto-dismiss when packet rate recovers.\n\n## Implementation\n\nTroubleshooting logic lives in dashboard/js/troubleshoot.js:\n- TroubleshootManager class subscribes to WebSocket events: node_offline, node_online, low_packet_rate, calibration_complete\n- For each event type, renders the appropriate UI component\n- Uses a simple state machine per issue: DETECTED -> NOTIFIED -> RESOLVED/DISMISSED\n- Issue state persisted in memory (cleared on page refresh — issues re-fire on next event)\n\nTooltip system: dashboard/js/tooltips.js\n- TooltipManager class: show(tooltipId, targetSelector, text, direction)\n- Checks localStorage \"spaxel_tooltip_{id}_shown\" before showing\n- Sets localStorage flag on dismiss\n- All tooltips in a manifest array, shown in sequence on first visit\n\nA dedicated troubleshoot.css for the offline card and tooltip styles.\n\n## Design Principles\n\n- Guidance must be actionable, not diagnostic: \"Move the node closer to your router\" not \"WiFi RSSI is -78 dBm\"\n- Never condescending: \"Your node went offline\" not \"Error: WebSocket connection closed with code 1006\"\n- Never blocks normal operation: every troubleshooting element is dismissible\n- Avoid information overload: show one most-likely cause first, with \"More options\" expander for alternatives\n- Use progressive disclosure: simple guidance first, technical details behind \"Advanced\" toggle\n\n## Tests\n\n- Test that node_offline WebSocket event triggers the troubleshooting panel render with correct node label\n- Test that tooltip TooltipManager correctly checks localStorage before showing each tooltip\n- Test that tooltips set the localStorage flag on dismiss and do not re-appear on subsequent show() calls\n- Test that node_online event after node_offline dismisses the offline card\n- Test that low_packet_rate event below 50% threshold triggers the quality banner\n- Test that the post-calibration card renders with correct link count and node label\n\n## Acceptance Criteria\n\n- All common onboarding failure modes have human-readable recovery paths in the wizard\n- Node offline card appears in the dashboard within 30 seconds of disconnection\n- Offline card includes actionable steps and the captive portal AP SSID\n- First-time tooltips appear exactly once on first dashboard open after node addition\n- Tooltips never re-appear after dismissal (localStorage persistence)\n- Post-calibration card shows correct summary after wizard completion\n- Detection quality banner fires when packet rate drops below 50% threshold\n- All UI elements are dismissible without blocking normal dashboard use\n- Tests pass","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:39:04.748866600Z","created_by":"coding","updated_at":"2026-03-28T08:19:35.525106448Z","closed_at":"2026-03-28T08:19:35.525040672Z","close_reason":"Implemented guided troubleshooting and first-time UX:\n\n1. Onboarding failure guidance - human-readable recovery paths at each wizard step\n2. Node offline troubleshooting - timeline cards with progressive disclosure, factory reset modal, captive portal AP SSID\n3. First-time feature tooltips - 4 contextual tooltips with localStorage persistence, auto-dismiss, sequential tour\n4. Post-calibration reinforcement card with summary and next steps\n5. Detection quality prompts - client-side link health check with auto-recovery\n6. 30 new tests, all 96 tests pass","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-r0l","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.926926473Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-r0l","depends_on_id":"spaxel-zvo","type":"blocks","created_at":"2026-03-28T01:39:10.975223706Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-r37","title":"Stationary person detection via breathing analysis","description":"## Background\n\nStandard deltaRMS motion detection fires when someone physically moves. But a stationary person — reading, sleeping, watching TV — produces micro-motion from breathing: chest displacement of approximately 5mm at 0.1-0.5 Hz. This is well below the standard motion threshold but is detectable with careful bandpass filtering of the CSI signal. This is one of the hardest and most valuable features in the system. It transforms spaxel from a \"motion detector\" into a true \"presence detector\" that can tell you a sleeping baby is still breathing.\n\n## Physics of Breathing Detection in CSI\n\nWhen a person breathes, their chest moves approximately 5-10mm. This tiny displacement changes the path length of reflected wireless signals by up to 20mm (round trip). At 5 GHz (lambda ~= 0.06m), a 20mm path length change corresponds to a phase shift of 2*pi*0.02/0.06 ~= 2.1 radians — measurable in phase-sensitive CSI. In amplitude (IQ magnitude), the change is typically ~0.1% of the total amplitude — tiny but consistent.\n\nThe key is that breathing is periodic. A person breathes at 12-20 times per minute (0.2-0.33 Hz at rest, up to 0.5 Hz during mild activity). The CSI signal shows a weak periodic oscillation at this frequency. By taking the FFT of a 30-second window of deltaRMS samples, a sharp peak at the breathing frequency emerges from the noise floor.\n\nThe phase change cycles at TWICE the physical breathing rate because path length change cycles twice per breath cycle (chest goes out and comes back in, changing path length from +d to -d and back to +d). So a 15-breath/minute breathing rate (0.25 Hz) produces a CSI phase oscillation at 0.5 Hz, and we look for peaks at 0.2-1.0 Hz in the FFT spectrum.\n\n## BandpassDetector Implementation\n\nNew file: mothership/internal/signal/breathing.go\n\nBreathingDetector struct:\n- rollingBuffer []float64: circular buffer of deltaRMS samples, default 60 samples (30s at 2Hz adaptive rate)\n- bufferSize int: configurable, default 60\n- windowFn []float64: precomputed Hann window coefficients (reduces spectral leakage)\n- sampleRateHz float64: current sample rate (adaptive, from Phase 2 adaptive sensing rate bead)\n- minFreqHz float64: low end of breathing band (default 0.2 Hz)\n- maxFreqHz float64: high end of breathing band (default 1.0 Hz)\n- snrThreshold float64: minimum peak-to-noise ratio in dB to declare breathing (default 3 dB)\n\nMethods:\n- AddSample(deltaRMS float64): append to rolling buffer, overwrite oldest when full\n- Detect() BreathingResult: run FFT, find peak in breathing band, compute SNR, return result\n- BreathingResult: {IsBreathing bool, FrequencyHz float64, Confidence float64, PeakSNRdB float64}\n\nFFT implementation: use gonum.org/v1/gonum/dsp/fourier (already a project dependency from the UKF). Apply Hann window to buffer before FFT to reduce spectral leakage. FFT output is complex64 array; take abs to get amplitude spectrum. Bin resolution = sampleRateHz / bufferSize. For 2Hz * 60 samples: resolution = 0.033 Hz, which gives good separation of breathing harmonics.\n\nSNR computation: peak amplitude in [0.2, 1.0] Hz band divided by median amplitude of the full spectrum (median is robust to other peaks). SNR in dB = 20 * log10(peak/median). If SNR > snrThreshold, return IsBreathing=true.\n\n## Long-Dwell Logic\n\nEven without a breathing signal, a person who was detected in motion and then becomes still is likely still present for some time. Add a DwellTracker per link in the LinkProcessor (mothership/internal/signal/processor.go):\n\nDwell states:\n- CLEAR: no recent motion, no breathing signal\n- MOTION_DETECTED: current deltaRMS > motion threshold\n- POSSIBLY_PRESENT: was MOTION_DETECTED within last 10 seconds, now below threshold. Report as \"possibly present\" to fusion engine (lower weight).\n- STATIONARY_DETECTED: BreathingDetector reports IsBreathing=true. Report as \"stationary person\" with the breathing frequency.\n\nTransitions:\n- CLEAR -> MOTION_DETECTED: deltaRMS > motion threshold\n- MOTION_DETECTED -> POSSIBLY_PRESENT: deltaRMS < threshold for > 0.5s (debounce)\n- POSSIBLY_PRESENT -> MOTION_DETECTED: deltaRMS > threshold again\n- POSSIBLY_PRESENT -> STATIONARY_DETECTED: BreathingDetector fires\n- POSSIBLY_PRESENT -> CLEAR: 60 seconds without motion or breathing signal\n- STATIONARY_DETECTED -> POSSIBLY_PRESENT: BreathingDetector no longer fires\n- STATIONARY_DETECTED -> CLEAR: 120 seconds without motion or breathing signal (longer timeout because breathing detection is highly confident)\n\nThe dwell timer prevents premature \"CLEAR\" declarations for people sitting quietly, which is a common and highly frustrating false-negative.\n\n## Sensitivity Constraints\n\nBreathing detection only works reliably under these conditions:\n1. Direct line-of-sight (LoS) or single-reflection path between TX and RX — through-wall detection is too noisy\n2. The person is within the first Fresnel zone of the TX-RX link (see fusion bead)\n3. Link health score (ambient confidence bead) > 0.7 — low-confidence links produce too much noise\n4. No other people moving in the scene (other motion dominates the signal)\n5. Minimum duration: 15s of data before the first detection can fire (half the FFT window)\n\nThe system should gate breathing detection using the link health score from the ambient confidence bead. If health_score < 0.7, set BreathingDetector.enabled = false for that link.\n\n## Dashboard Integration\n\nAdd a \"Stationary person\" indicator to the dashboard link presence panel (distinct from the motion indicator):\n- Slow-pulsing blue dot (not the motion red/green) when STATIONARY_DETECTED state\n- Tooltip showing estimated breathing rate in breaths-per-minute (=frequencyHz * 60)\n- Timeline event logged: \"Stationary person detected on [link] at [time] — breathing at {N} bpm\"\n\nAdd to the link health WebSocket message (\"link_health\" type): breathing_state (\"CLEAR\"/\"POSSIBLY_PRESENT\"/\"MOTION_DETECTED\"/\"STATIONARY_DETECTED\"), breathing_freq_hz (null if not detected).\n\n## Tests\n\n- Test FFT output with synthetic breathing waveform: inject 60 samples of sin(2*pi*0.3*t) + noise (sigma=0.001) into BreathingDetector.AddSample(), verify Detect() returns IsBreathing=true, FrequencyHz ~= 0.3, SNR > 3 dB\n- Test that uniform random noise (no periodic component) does not trigger breathing detection (false positive rate < 5% across 1000 trials with sigma=0.001)\n- Test long-dwell timer transitions: MOTION_DETECTED -> POSSIBLY_PRESENT after 0.5s quiescence, POSSIBLY_PRESENT -> CLEAR after 60s, STATIONARY_DETECTED -> CLEAR after 120s\n- Test that BreathingDetector is disabled when health_score < 0.7\n- Test Hann window application produces expected output for a known input\n- Test that a breathing frequency outside the [0.2, 1.0] Hz band is not reported\n\n## Acceptance Criteria\n\n- Breathing detection fires for a stationary person in direct LoS with good link quality (health_score > 0.7) at SNR > 15 dB\n- False positive rate < 5% on an empty room with a high-quality link\n- Breathing frequency displayed in dashboard in breaths-per-minute (converted from FFT peak Hz)\n- Long-dwell logic prevents premature \"CLEAR\" declaration for a stationary person for at least 60 seconds after last motion\n- Breathing detection correctly gated off on low-health links\n- Dwell state transitions logged in activity timeline\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"charlie","created_at":"2026-03-28T01:40:45.831647006Z","created_by":"coding","updated_at":"2026-03-30T00:25:45.034604864Z","closed_at":"2026-03-30T00:25:45.034248272Z","close_reason":"Implemented stationary person detection via FFT-based breathing analysis. FFTBreathingDetector with 30s rolling buffer, DwellTracker state machine, health gating, dashboard integration with pulsing blue indicator, timeline event logging. All tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-r37","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:14.054454703Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-r37","depends_on_id":"spaxel-v9z","type":"blocks","created_at":"2026-03-28T01:40:48.996634547Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-r7t","title":"BLE address rotation detection & identity continuity","description":"## Overview\nHandle MAC address rotation in BLE devices (phones rotate every 15-30 min) to maintain continuous identity tracking.\n\n## Backend (mothership/ble/)\n- Rotation heuristics: manufacturer data fingerprinting, time+RSSI proximity, position continuity, merge confirmation\n- ble_device_aliases table: addr, canonical_addr, confidence, first_seen, last_seen\n- Alias matching in blob-to-device scoring: resolve rotated address to canonical identity\n- Graceful fallback: 5-min window before clearing identity when rotation is unresolved\n\n## Dashboard UI\n- Rotation icon indicator in BLE device registry\n- Manual merge/split UI: 'These look like the same device. Merge?' confirmation\n- Alias history expandable in device detail panel\n\n## Acceptance\n- Identity continuity across address rotation with >90% precision in test scenarios\n- No duplicate person tracks created on rotation event\n- Alias history queryable via GET /api/ble/devices/{mac}/aliases","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:20.030993892Z","created_by":"coding","updated_at":"2026-04-06T18:28:56.803569523Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} +{"id":"spaxel-r7t","title":"BLE address rotation detection & identity continuity","description":"## Overview\nHandle MAC address rotation in BLE devices (phones rotate every 15-30 min) to maintain continuous identity tracking.\n\n## Backend (mothership/ble/)\n- Rotation heuristics: manufacturer data fingerprinting, time+RSSI proximity, position continuity, merge confirmation\n- ble_device_aliases table: addr, canonical_addr, confidence, first_seen, last_seen\n- Alias matching in blob-to-device scoring: resolve rotated address to canonical identity\n- Graceful fallback: 5-min window before clearing identity when rotation is unresolved\n\n## Dashboard UI\n- Rotation icon indicator in BLE device registry\n- Manual merge/split UI: 'These look like the same device. Merge?' confirmation\n- Alias history expandable in device detail panel\n\n## Acceptance\n- Identity continuity across address rotation with >90% precision in test scenarios\n- No duplicate person tracks created on rotation event\n- Alias history queryable via GET /api/ble/devices/{mac}/aliases","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:20.030993892Z","created_by":"coding","updated_at":"2026-04-06T18:34:59.146861796Z","closed_at":"2026-04-06T18:34:59.146762203Z","close_reason":"Implemented BLE address rotation detection & identity continuity with manufacturer data fingerprinting, time+RSSI proximity heuristics, and merge confirmation. Backend includes RotationDetector, ble_device_aliases table, and REST API endpoints. Dashboard UI includes rotation icon indicator, manual merge/split UI, and alias history expandable panel.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} {"id":"spaxel-s60","title":"Implement presence prediction","description":"Build predictive presence modeling for Home Assistant integration.\n\nDeliverables:\n- Per-person transition probability tracking\n- Per-zone occupancy patterns\n- Time-slot based predictions\n- HA sensor exposure for predicted states\n\nAcceptance: Predictions achieve >75% accuracy at 15-minute horizon.","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-03-29T19:25:04.052115700Z","created_by":"coding","updated_at":"2026-04-02T01:20:12.179026061Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:924","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-s70","title":"Activity timeline","description":"## Background\n\nSpaxel generates a continuous stream of events: presence detections, zone transitions, alerts, system events, learning milestones, health changes. Without a structured event stream, debugging is difficult, history is lost, and the system appears as a black box. The activity timeline is the universal event log — a chronological record of everything the system has seen. It doubles as the primary debugging interface and enables time-travel replay (an engineer can tap any timeline event and the 3D view jumps back to that moment).\n\n## Internal Event Bus\n\nNew package: mothership/internal/events/bus.go\n\nEventBus provides a typed publish-subscribe mechanism for all internal events. All subsystems publish to the bus; the timeline, automation engine, and notification module subscribe.\n\nImplementation: a simple channel-based pub/sub. Publisher side: bus.Publish(EventType, EventPayload). Subscriber side: bus.Subscribe(EventType) returns a channel. Multiple subscribers per event type are supported (fan-out).\n\nEventType enum:\n- MotionDetected, MotionCleared\n- ZoneTransition (a person crossed a portal)\n- ZoneOccupancyChanged (any occupancy change, including by anonymous tracks)\n- FallDetected, FallAcknowledged\n- NodeConnected, NodeDisconnected, NodeOTAComplete\n- BLEDeviceFirstSeen, BLEIdentityAssigned\n- WeightUpdate, DiurnalBaselineActivated\n- AnomalyDetected, AnomalyAcknowledged\n- SleepSessionStart, SleepSessionEnd\n- FeedbackSubmitted\n\nEventPayload is a typed interface. Each event type has its own concrete struct.\n\n## Timeline Storage\n\nSQLite table:\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n type TEXT NOT NULL,\n timestamp DATETIME NOT NULL,\n person_id TEXT,\n zone_id TEXT,\n data_json TEXT NOT NULL, -- full event payload as JSON\n feedback_type TEXT, -- populated by feedback loop (Phase 7)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX idx_events_timestamp ON events (timestamp DESC);\nCREATE INDEX idx_events_person ON events (person_id, timestamp DESC);\nCREATE INDEX idx_events_zone ON events (zone_id, timestamp DESC);\nCREATE INDEX idx_events_type ON events (type, timestamp DESC);\n\nTimeline subscriber: a goroutine that reads from the bus and writes to SQLite. Buffered with a 1000-event queue to avoid blocking publishers. If the queue fills: log a warning and drop the oldest (the bus is lossy for the storage subscriber, but this should never happen at normal event rates).\n\n## Dashboard Timeline Panel\n\nSidebar panel showing events in reverse-chronological order.\n\nEvent visual rendering per type:\n- MotionDetected / ZoneTransition: person avatar (coloured circle with initial) + description + timestamp + thumbs\n- FallDetected: red shield icon + \"Possible fall: [person] in [zone]\" + Acknowledge button\n- NodeConnected / NodeDisconnected: grey dot icon + \"Node [label] connected/disconnected\"\n- WeightUpdate / DiurnalBaselineActivated: green brain icon + \"Detection accuracy improved\" / \"Daily patterns activated\"\n- AnomalyDetected: orange warning icon + \"Anomaly: [description]\"\n- SleepSessionStart/End: moon icon + \"Alice went to sleep\" / \"Alice woke up\"\n\nEvent description templates (plain English, no jargon):\n- ZoneTransition: \"{person_name} walked from {from_zone} to {to_zone}\"\n- MotionDetected: \"Motion detected in {zone_name}\" (if no identity)\n- NodeDisconnected: \"Node {label} went offline — {duration} downtime\"\n- DiurnalBaselineActivated: \"System has learned {person_name}'s daily patterns. Detection accuracy improved.\"\n\nVirtualized rendering: use a virtual scroll list (render only visible items) since the timeline can have thousands of events. Implement using IntersectionObserver API for lazy loading of off-screen items.\n\nThumbs-up/down on each event: delegates to the feedback module (spaxel-3ps). Rendered as small icon buttons on the right side of each event row.\n\n## Search and Filter\n\nFilter bar above timeline:\n- Type filter: checkboxes for event categories (Presence, Zones, Alerts, System, Learning). Default: all.\n- Person filter: dropdown \"All people / Alice / Bob / Unknown\"\n- Zone filter: dropdown \"All zones / Kitchen / Bedroom / etc.\"\n- Date range: \"Today / Last 7 days / Last 30 days / Custom\"\n- Text search: fuzzy match on event description text (client-side filtering on loaded events; server-side for date-range queries)\n\nFiltered queries use the indexed columns in the events table. Return at most 500 events per page; \"Load more\" button for pagination.\n\n## Expert vs Simple Mode\n\nExpert mode: all event types visible. System events (node health, weight updates) shown as secondary (smaller text, greyed color).\n\nSimple mode: only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd (morning summary). System events hidden. This prevents \"terminal-style\" log noise from confusing non-technical users.\n\nMode is set by the current dashboard mode (expert vs simple) and passed as ?mode=expert or ?mode=simple to the API.\n\n## Tap-to-Jump (Time-Travel Coordination)\n\nWhen a timeline event is clicked (in expert mode), the dashboard emits a \"jump_to_time\" command with the event's timestamp. The time-travel replay module (Phase 8, separate bead) listens for this command and:\n1. Pauses live playback\n2. Seeks the CSI recording buffer to the event timestamp\n3. Begins replay from that point\n4. The 3D scene shows the \"replay\" state at that timestamp\n\nClicking the event also highlights it in the timeline (selected state) and shows a \"Now replaying\" chip in the timeline header.\n\n## REST API\n\nGET /api/events?since=&until=&type=&person_id=&zone_id=&limit=&mode=expert|simple\nReturns: paginated list of Event objects with all fields.\n\nGET /api/events/{id}: single event detail\nPOST /api/events/{id}/feedback: submit feedback for an event (delegates to feedback module)\n\n## Tests\n\n- Test EventBus pub/sub: publish event, verify subscriber channel receives it within 10ms\n- Test that multiple subscribers all receive the same event\n- Test timeline storage: publish 10 events of different types, verify all appear in SQLite with correct fields\n- Test search and filter: insert events for two people and two zones, query by person -> correct subset returned\n- Test time-range filtering: insert events at T-1h and T-25h; query since T-24h -> only T-1h event\n- Test virtualized rendering handles 1000+ events without layout jank (performance test in browser)\n- Test tap-to-jump emits correct timestamp to time-travel player\n- Test expert vs simple mode filter: system events excluded in simple mode\n\n## Acceptance Criteria\n\n- All event types appear in the timeline within 1 second of firing\n- Search and filter queries return correct subsets\n- Tap-to-jump coordinates with time-travel player (3D scene seeks to correct timestamp)\n- Simple mode hides system events while showing person-relevant events\n- Feedback buttons appear on each event and invoke the feedback module correctly\n- Timeline handles 10,000+ events without UI slowdown via virtualised rendering\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:54:31.341960586Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.636974843Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-s70","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.636944347Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-sbi","title":"Ambient confidence score and link health","description":"## Background\n\nNot all sensing links are equal. A link where a wall bisects the Fresnel zone produces consistently noisy detections. A link experiencing WiFi congestion from neighbour networks drops packets and has unreliable amplitude measurements. A link near a microwave oven sees periodic interference bursts. Without a quality metric, the fusion engine treats all links equally, and poor-quality links introduce noise that degrades overall localisation accuracy.\n\nThe ambient confidence score is a per-link quality metric that: (1) gates and weights detection algorithms so poor links contribute less, (2) surfaces actionable quality information to the user, and (3) powers the link weather diagnostics feature. A composite system-wide Detection Quality gauge summarises overall system health.\n\n## Per-Link Health Metrics\n\nNew module: mothership/internal/health/linkhealth.go\n\nLinkHealthScorer computes five sub-metrics per link, each in [0, 1]:\n\n1. SNR Estimate (weight 40%)\n Ratio of motion-period delta to quiet-period delta, expressed as a quality score.\n During known-quiet periods (determined by extended absence of motion, minimum 60s), record the ambient deltaRMS variance (sigma_quiet). During motion-active periods, record deltaRMS peaks (signal level). SNR_ratio = signal_level / sigma_quiet. Map to [0,1] via: score = min(1.0, log10(SNR_ratio) / log10(100)) where SNR=100:1 -> score=1.0, SNR=10:1 -> score=0.5.\n\n2. Phase Stability (weight 30%)\n During known-quiet periods, compute the variance of the phase offset across subcarriers. Low variance indicates stable hardware clock synchronisation between TX and RX, which is a prerequisite for reliable phase-based detection. High variance (>0.5 radians) suggests temperature drift or near-field metal interference.\n score = max(0, 1 - phase_variance / 0.5)\n\n3. Packet Rate Health (weight 20%)\n actual_pps / configured_rate. If configured at 50 Hz and receiving 40 Hz: score = 0.8.\n Rolling average over 10-second window.\n\n4. Baseline Drift (weight 10%)\n Rate of change of the EMA baseline over a 1-hour sliding window. High drift indicates an unstable environment (e.g. gradual temperature change, something blocking or unblocking the Fresnel zone). Computed as: drift_rate = |B_t - B_{t-1h}| / |B_{t-1h}| (normalised L2 change per hour).\n score = max(0, 1 - drift_rate / 0.1) where 10% per hour -> score=0.0.\n\n5. Composite Score\n composite = 0.4 * snr + 0.3 * phase_stability + 0.2 * packet_rate + 0.1 * (1 - baseline_drift_normalized)\n Clamped to [0, 1]. Updated every 10 seconds.\n\n## Dashboard Visualisation\n\nPer-link health is surfaced in multiple places:\n\nIn the 3D view (Phase 3 node placement UI, spaxel-qq6):\n- Link line thickness: 2px (health > 0.7), 1px (health 0.4-0.7), 0.5px (health < 0.4)\n- Link line colour: green (#22c55e at health=1.0) through yellow (#eab308 at health=0.5) through red (#ef4444 at health=0)\n\nIn the Link Health panel (sidebar, shown on link click):\n- Per-metric breakdown: four sub-score gauges (SNR, Phase Stability, Packet Rate, Baseline Drift) with label, value, and interpretation\n- Sparkline chart: composite health score over last 24 hours\n- \"Why is this low?\" contextual hint based on which sub-metric is lowest\n\nSystem-wide Detection Quality gauge (dashboard header):\n- Single number: weighted average of all active link composite scores\n- Rendered as a circular gauge (0-100%) with colour gradient\n- Tooltip: \"Based on N active links. Weakest link: [link name] at [score%]\"\n\n## API\n\nGET /api/links returns:\n[{\n \"link_id\": \"aabbccddee:ff:00:11:22:33\",\n \"tx_mac\": \"aa:bb:cc:dd:ee:ff\",\n \"rx_mac\": \"00:11:22:33:44:55\",\n \"health_score\": 0.83,\n \"health_details\": {\n \"snr\": 0.91,\n \"phase_stability\": 0.78,\n \"packet_rate\": 0.97,\n \"baseline_drift\": 0.62\n },\n \"last_updated\": \"2026-03-27T14:23:45Z\"\n}]\n\n## Gating Effects\n\nThe health score gates and weights two downstream systems:\n1. BreathingDetector (stationary person detection, spaxel-r37): disabled when composite health_score < 0.7\n2. FusionEngine (spaxel-m9a): each link's contribution to the 3D occupancy grid is multiplied by its health_score. A link with score=0.3 contributes only 30% as much as a link with score=1.0. This prevents degraded links from producing noisy phantom blobs.\n\nThe gating thresholds (0.7 for breathing, any value for weighted fusion) are configurable via mothership config.\n\n## Integration with Existing Code\n\nLinkHealthScorer is instantiated in mothership/internal/ingestion/server.go alongside the existing signal processors. It receives:\n- Packet arrival timestamps (to compute actual PPS vs configured)\n- deltaRMS values from the signal processor (for SNR computation)\n- Phase values from the signal processor (for phase stability)\n- Baseline vectors from BaselineManager (for drift computation)\n\nThe health scores are updated in background via a goroutine that fires every 10 seconds. Results are published on the internal event bus as LinkHealthUpdate events, which the dashboard hub broadcasts as \"link_health\" WebSocket messages.\n\n## Tests\n\n- Test composite score computation with mock inputs: all 1.0 -> 1.0, packet_rate=0.5 others 1.0 -> weighted result\n- Test SNR sub-score mapping: SNR_ratio=1 -> score=0, SNR_ratio=10 -> score=0.5, SNR_ratio=100 -> score=1.0\n- Test phase stability: variance=0 -> score=1.0, variance=0.5 -> score=0.0, variance=0.25 -> score=0.5\n- Test that breathing detection gating fires correctly when score drops below 0.7\n- Test FusionEngine link weight reflects health score (inspect internal state after injection)\n- Test API response format matches documented schema\n- Test that health score updates are published to the event bus\n\n## Acceptance Criteria\n\n- Per-link health scores computed and visible in dashboard for all active links\n- 3D link line thickness and colour reflect health score in real-time\n- Detection Quality gauge shows system-wide average health, updates every 10 seconds\n- BreathingDetector correctly gated off when link health < 0.7\n- FusionEngine link weights reflect health scores (verified via test)\n- Per-metric breakdown visible in Link Health panel on link click\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"delta","created_at":"2026-03-28T01:41:30.452621121Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.806481028Z","closed_at":"2026-03-29T18:07:39.806256783Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-sbi","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:13.992381357Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -116,19 +123,21 @@ {"id":"spaxel-tr7","title":"3D spatial visualization","description":"Full Three.js 3D scene with humanoid figures, room bounds, and node meshes.\n\n## Deliverables\n- Room bounds visualization (walls, floor, ceiling from configured dimensions)\n- Floor plan texture support (uploaded image mapped to ground plane)\n- Humanoid figures using SkinnedMesh + AnimationMixer (standing/walking/seated/lying)\n- Vertical pillar anchors and footprint trails for tracked blobs\n- Node meshes at configured 3D positions with link lines between pairs\n- View presets (top-down, perspective, first-person follow)\n- WebSocket integration to receive blob positions from mothership\n\n## Acceptance Criteria\n- Humanoid figures animate smoothly between postures\n- User can orbit, pan, zoom with OrbitControls\n- Node positions and link lines update in real-time\n- Works with existing dashboard skeleton (dashboard/js/app.js)\n\n## References\n- Plan: docs/plan/plan.md item 17\n- Dashboard: dashboard/index.html, dashboard/js/app.js","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:57:04.504533558Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.148595152Z","closed_at":"2026-03-28T05:36:26.148493523Z","close_reason":"Implemented: dashboard/js/viz3d.js 566 lines (bcd19ad) — room bounds, humanoid SkinnedMesh 13-bone skeleton 4 postures, footprint trails, node meshes, link lines, 3 view presets","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-tr7","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.740185994Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-ts2","title":"Implement image upload endpoint","description":"## Task\nImplement POST /api/floorplan/image endpoint.\n\n## Specification\n- Multipart form handling\n- Accept PNG/JPG max 10 MB\n- Save to /data/floorplan/image.png\n- Reject > 10 MB upload with 413 error\n\n## Acceptance\n- Image upload saves file to /data/floorplan/image.png\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:50.419717078Z","created_by":"coding","updated_at":"2026-04-07T18:43:14.895237433Z","closed_at":"2026-04-07T18:43:14.895178829Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} {"id":"spaxel-tvq","title":"Command palette","description":"## Background\n\nExpert users — system installers, power users, home automation enthusiasts — want to operate the dashboard entirely from the keyboard. Reaching for the mouse to navigate between nodes, zones, and features breaks focus. The command palette (Ctrl+K / Cmd+K) provides a keyboard-driven interface to every feature: navigate to any person, zone, or node by typing their name, execute commands like \"Start OTA\" or \"Toggle Fresnel overlay\", and even jump the 3D scene to specific timestamps using a time syntax.\n\n## Trigger and Appearance\n\nKeyboard shortcut: Ctrl+K (Windows/Linux) or Cmd+K (macOS). Also triggerable via a small search icon button in the dashboard header bar.\n\nAvailable in: expert mode only. Not available in simple mode or ambient mode (those are for non-technical users who don't benefit from a command palette).\n\nThe palette appears as a centred modal overlay:\n- Semi-transparent dark backdrop (opacity 0.5, backdrop-filter: blur(4px))\n- Centred container: max-width 600px, border-radius 12px, background #1e293b\n- Top: search input (auto-focused, placeholder \"Search people, zones, nodes, commands...\")\n- Below: results list (max 8 visible, scrollable)\n\nClose on: Escape key, click on backdrop, second Ctrl+K/Cmd+K.\n\n## Search Scope\n\nAll entities are cached client-side in the dashboard's state store (already maintained from WebSocket updates). No server round-trips for search — purely client-side matching.\n\nSearch categories (in priority order in results):\n1. People: names from the people registry. Icon: person silhouette. Action: jump 3D camera to person's current blob.\n2. Zones: zone names. Icon: location pin. Action: jump 3D camera to zone centroid, select zone.\n3. Nodes: node labels or MAC addresses. Icon: radio antenna. Action: jump 3D camera to node position, select node.\n4. Recent events: last 20 timeline events (titles). Icon: clock. Action: open event detail in timeline.\n5. Commands: static list of dashboard commands (see Commands below). Icon: lightning bolt. Action: execute command.\n6. Time navigation: if query starts with \"@\", attempt time parsing (see below). Icon: clock. Action: seek replay to that time.\n\n## Fuzzy Matching\n\nClient-side fuzzy matching for all categories. Use a simple Jaro-Winkler or Levenshtein-distance based scorer:\n- Exact prefix match: highest score. \"Kit\" -> \"Kitchen\" gets top score.\n- Subsequence match: \"kitch rm\" -> \"Kitchen\" matches (not adjacent characters but in order).\n- Typo tolerance: 1 character substitution tolerated for strings > 4 characters.\n\nImplementation: a custom 30-line fuzzy match function in JavaScript. No dependencies needed. The function returns a score in [0, 1]; results with score < 0.3 are excluded.\n\nSort: primary by category priority (commands highest if starting with \"/\"), secondary by match score.\n\n## Commands\n\nStatic list of commands accessible from the palette:\n\nNavigation commands:\n- \"Open settings\" -> navigate to /settings\n- \"Open fleet page\" -> navigate to /fleet\n- \"Open automations\" -> navigate to /automations\n- \"Open simulator\" -> navigate to /simulate\n\nView commands:\n- \"Toggle Fresnel overlay\" -> toggle the Fresnel zone debug layer\n- \"Toggle flow map\" -> toggle the flow map layer\n- \"Toggle dwell heatmap\" -> toggle the dwell heatmap layer\n- \"Toggle zone volumes\" -> toggle zone cuboid visibility\n- \"Reset camera\" -> fly camera back to default top-down position\n\nSystem commands:\n- \"Enter away mode\" -> POST /api/mode {\"mode\":\"away\"}\n- \"Enter home mode\" -> POST /api/mode {\"mode\":\"home\"}\n- \"Enter sleep mode\" -> POST /api/mode {\"mode\":\"sleep\"}\n- \"Trigger fleet OTA\" -> opens the fleet OTA dialog\n- \"Add a person\" -> opens the Add Person form in the People & Devices panel\n- \"Add a zone\" -> starts zone creation mode in the 3D view\n- \"Add a portal\" -> starts portal creation mode\n\nDebug commands (shown at bottom, lower priority):\n- \"Export all events CSV\" -> GET /api/events?format=csv and download\n- \"Show link health table\" -> opens the link health panel\n- \"Run diagnostics\" -> triggers a diagnostics pass and shows results\n- \"Check firmware updates\" -> fetches latest firmware version and compares to all nodes\n\n## Time Navigation\n\nIf the query starts with \"@\": attempt to parse as a time expression and offer a \"Jump to time\" result.\n\nSupported formats:\n- \"@3am\" -> today at 03:00\n- \"@3:15am\" -> today at 03:15\n- \"@yesterday 11pm\" -> yesterday at 23:00\n- \"@2026-03-27 14:23\" -> specific datetime\n- \"@-30min\" -> 30 minutes ago from now\n- \"@-2h\" -> 2 hours ago\n\nOn selection: triggers time-travel replay to the parsed timestamp (same as clicking a timeline event's tap-to-jump).\n\nShow parsing preview in the result item: \"Jump to 2026-03-27 03:00:00\" with a clock icon.\n\n## Result Item Rendering\n\nEach result item in the list:\n- Left: icon (category-appropriate SVG, 16px)\n- Centre-left: primary text (entity name or command label, 14px)\n- Centre-right: secondary text (grey, 12px): for zones: \"[N] people currently\", for nodes: \"[online/offline]\", for commands: keyboard shortcut hint if any\n- Right: arrow icon (shows the item is actionable)\n\nSelected item (keyboard navigation): blue #3b82f6 background highlight.\n\n## Keyboard Navigation\n\n- Arrow up/down: move selection through results\n- Enter: execute selected item\n- Tab: same as Enter (for keyboard-first users who use Tab to confirm)\n- Escape: close palette\n- Character keys: type to refine search (selection resets to first result on each keystroke)\n\n## Recent History\n\nWhen the palette opens with an empty query: show \"Recent\" header with the last 5 palette actions (stored in localStorage \"spaxel_palette_history\"). Format: same as search results but without scores. \"Recent\" category shown before search results.\n\nRecent history excludes time navigation entries (those are ephemeral).\n\n## Files to Create or Modify\n\n- dashboard/js/commandpalette.js: CommandPaletteManager, fuzzy match, time parsing, result rendering\n- dashboard/js/commandpalette.css: modal overlay, input, result list styles\n- dashboard/js/app.js: keyboard shortcut listener (Ctrl+K / Cmd+K), integrate CommandPaletteManager\n\n## Tests\n\n- Test fuzzy matching: \"kit\" -> \"Kitchen\" score > 0.7; \"livig rm\" -> \"Living Room\" score > 0.5; \"xyz\" -> \"Kitchen\" score < 0.3 (excluded)\n- Test time navigation parsing: \"@3am\" parses to today at 03:00; \"@-30min\" parses to 30 minutes ago; \"@2026-03-27 14:23\" parses correctly\n- Test that commands list is complete (all documented commands present in the registry)\n- Test keyboard navigation: arrow down moves selection, Enter executes, Escape closes\n- Test recent history: execute 5 actions, open palette with empty query -> 5 recent items shown\n- Test that palette does not activate in simple mode or ambient mode (keyboard listener absent)\n- Test that viewport reposition correctly positions the palette centred on the screen\n\n## Acceptance Criteria\n\n- Command palette opens with Ctrl+K (or Cmd+K on macOS) in < 50ms\n- Fuzzy search returns \"Kitchen\" for query \"kitch\", \"kit\", \"ktchn\"\n- Time navigation \"@3am\" correctly seeks replay to 03:00 today\n- All documented commands are accessible and execute correctly\n- Arrow key navigation works correctly through results\n- Recent history shows last 5 palette actions on empty query\n- Palette unavailable in simple and ambient modes\n- All features accessible in 3 keystrokes or fewer from palette open\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:02:28.058307267Z","created_by":"coding","updated_at":"2026-03-28T03:29:15.028133142Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-tvq","depends_on_id":"spaxel-s70","type":"blocks","created_at":"2026-03-28T02:02:31.595593278Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-tvq","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:15.028093011Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-u7y","title":"Firmware: NTP clock sync for TX stagger accuracy","description":"## Overview\nImplement NTP synchronization on ESP32-S3 so all nodes share a common clock, enabling accurate TX stagger scheduling to avoid CSI collisions.\n\n## Firmware (firmware/main/wifi.c or ntp.c)\n- Call esp_sntp_setservername(0, ntp_server) before esp_sntp_init() on boot\n- ntp_server read from NVS 'ntp_server' key (default: 'pool.ntp.org')\n- Attempt sync for up to 10 seconds after WiFi connect; log WARN to UART if sync fails\n- On sync failure: proceed without stagger (rely on CSMA/CA for collision avoidance)\n- Resync every 10 minutes via esp_timer periodic callback\n- Include NTP sync status in health JSON message: {type:'health', ..., ntp_synced: true/false}\n\n## Mothership (provisioning payload)\n- Read SPAXEL_NTP_SERVER env var (default: pool.ntp.org)\n- Embed ntp_server field in provisioning payload JSON\n- Support config downstream message field ntp_server to push updated server to nodes\n\n## Acceptance\n- Node health messages show ntp_synced: true when pool is reachable\n- ntp_synced: false when NTP blocked — node still operates normally\n- Resync occurs every ~600s (verified via UART logs)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:42:26.894640218Z","created_by":"coding","updated_at":"2026-04-07T14:37:00.359302601Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-u7y","depends_on_id":"spaxel-288","type":"blocks","created_at":"2026-04-07T14:37:00.359263571Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-u7y","depends_on_id":"spaxel-qgj","type":"blocks","created_at":"2026-04-07T14:37:00.321904383Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-u7y","title":"Firmware: NTP clock sync for TX stagger accuracy","description":"## Overview\nImplement NTP synchronization on ESP32-S3 so all nodes share a common clock, enabling accurate TX stagger scheduling to avoid CSI collisions.\n\n## Firmware (firmware/main/wifi.c or ntp.c)\n- Call esp_sntp_setservername(0, ntp_server) before esp_sntp_init() on boot\n- ntp_server read from NVS 'ntp_server' key (default: 'pool.ntp.org')\n- Attempt sync for up to 10 seconds after WiFi connect; log WARN to UART if sync fails\n- On sync failure: proceed without stagger (rely on CSMA/CA for collision avoidance)\n- Resync every 10 minutes via esp_timer periodic callback\n- Include NTP sync status in health JSON message: {type:'health', ..., ntp_synced: true/false}\n\n## Mothership (provisioning payload)\n- Read SPAXEL_NTP_SERVER env var (default: pool.ntp.org)\n- Embed ntp_server field in provisioning payload JSON\n- Support config downstream message field ntp_server to push updated server to nodes\n\n## Acceptance\n- Node health messages show ntp_synced: true when pool is reachable\n- ntp_synced: false when NTP blocked — node still operates normally\n- Resync occurs every ~600s (verified via UART logs)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:42:26.894640218Z","created_by":"coding","updated_at":"2026-04-07T17:46:19.521425117Z","closed_at":"2026-04-07T17:46:19.521245004Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-u7y","depends_on_id":"spaxel-288","type":"blocks","created_at":"2026-04-07T14:37:00.359263571Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-u7y","depends_on_id":"spaxel-qgj","type":"blocks","created_at":"2026-04-07T14:37:00.321904383Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-ubu","title":"Implement Settings REST endpoints","description":"Implement GET and POST /api/settings endpoints. Return all configurable settings as JSON, support partial update with merge semantics. Persist to SQLite across restarts. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.207225469Z","created_by":"coding","updated_at":"2026-04-07T13:05:38.035630463Z","closed_at":"2026-04-07T13:05:38.035364795Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-uc9","title":"Phase 3: Multi-Node & Localization","description":"Goal: Spatial positioning with 4+ nodes. Humanoid blob rendering.\n\nDeliverables:\n- Bidirectional node protocol (registration, health, BLE relay, role/config push, OTA commands)\n- Fleet manager (node registry in SQLite, role assignment, stagger scheduling, self-healing)\n- Multi-link fusion (Fresnel zone weighted localization on 3D grid)\n- Biomechanical blob tracking (peak extraction, ID assignment, UKF with human motion constraints)\n- 3D spatial visualization (room bounds, floor plan, humanoid figures, footprint trails, node meshes)\n- Node placement UI (TransformControls for dragging nodes in 3D, space dimension editor)\n- Live coverage painting (GDOP overlay, updates during node drag, virtual node support)\n\nExit criteria: 4+ nodes produce a 3D view with humanoid figures tracking a walking person at ±1m accuracy.","status":"closed","priority":2,"issue_type":"phase","assignee":"spaxel-alpha","created_at":"2026-03-27T01:55:09.079935660Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.232273342Z","closed_at":"2026-03-28T05:36:39.232213114Z","close_reason":"Phase 3 core complete: bidirectional protocol (c41), fleet manager (8u3), multi-link fusion (6th), blob tracking (iq3), 3D viz (tr7) all closed. Node placement UI (qq6) continues as parallel task. Exit criteria met: 3D view with humanoid figures tracking via Fresnel zone fusion.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-uc9","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T01:33:38.387797170Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-ugj","title":"Firmware: dual-partition OTA rollback validation","description":"## Overview\nEnsure the ESP32-S3 firmware correctly uses dual OTA partitions and only marks a new image as valid after the mothership confirms connectivity — enabling automatic rollback on failed upgrades.\n\n## Partition layout (firmware/partitions.csv)\nfactory app factory 0x0 0x400000 (4 MB)\nota_0 app ota_0 0x410000 0x400000 (4 MB)\nota_1 app ota_1 0x810000 0x400000 (4 MB)\nnvs data nvs 0x9000 0x6000 (24 KB)\notadata data ota 0xE000 0x2000 (8 KB)\n\n## OTA validation sequence (firmware/main/websocket.c or main.c)\n1. After OTA download complete: esp_ota_set_boot_partition(new_partition) then esp_restart()\n2. New firmware boots from new partition\n3. Firmware sends hello WebSocket message within 10 s\n4. Firmware waits for role message from mothership (up to 60 s)\n5. On receipt of role: call esp_ota_mark_app_valid_cancel_rollback()\n6. If role not received within 60 s: do NOT call mark_valid; ESP-IDF rollback to previous partition on next reset\n7. Log: 'OTA validation: marked valid after role received' or 'OTA validation: timed out, rollback on next reset'\n\n## Test scenarios\n- Happy path: new firmware installs, connects, receives role, marks valid — confirmed with esp_ota_get_running_partition()\n- Crash before hello: simulate crash before ws_send_hello(); verify rollback restores old firmware\n- Role timeout: simulate mothership not sending role; verify rollback after next reset cycle\n- Version mismatch: mothership rejects connection (wrong token); verify rollback\n\n## Acceptance\n- partitions.csv present and correct with dual OTA layout\n- esp_ota_mark_app_valid_cancel_rollback() called ONLY after role received\n- Firmware logs show validation state transitions\n- Rollback confirmed working in at least one test scenario","status":"closed","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T13:10:54.909152872Z","created_by":"coding","updated_at":"2026-04-07T05:52:36.950239618Z","closed_at":"2026-04-07T05:52:36.950168205Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} +{"id":"spaxel-uln","title":"fix: notify/service.go color variable shadows color package","description":"## Problem\n`internal/notify/service.go` declares a local variable named `color` which shadows the `image/color` package import:\n- Line 652: `color := color.RGBA{...}` — the variable name shadows the package\n- Lines 654, 656: subsequent `color.RGBA{}` references fail because `color` is now a variable, not a package\n\n## Fix\nRename the local variable to avoid shadowing. Change lines 652, 654, 656, 670 in `internal/notify/service.go`:\n```go\n// Line 652: was\ncolor := color.RGBA{100, 181, 246, 255}\n// Change to:\nclr := color.RGBA{100, 181, 246, 255}\n\n// Lines 654, 656 similarly use clr instead of color\n// Line 670: same pattern\n```\nAlso update any uses of the `color` variable further in the same scope to use `clr`.\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/notify/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:29:57.938926005Z","created_by":"coding","updated_at":"2026-04-06T22:36:24.768691613Z","closed_at":"2026-04-06T22:36:24.768542481Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-uod","title":"Zones: occupancy reconciliation after server restart","description":"## Overview\nRestore zone occupancy counts after mothership restarts using SQLite-persisted values + portal crossing history, avoiding reset-to-zero artifacts.\n\n## Algorithm (mothership/internal/zones/ or fleet/)\n\n### On startup (after schema migrations):\n1. Load last_known_occupancy per zone from zones table (stored before each graceful shutdown)\n2. Mark all zone occupancies as 'uncertain' — dashboard shows grey/amber badge\n3. Compute midnight timestamp (local timezone, today)\n4. Query: SELECT zone_id, SUM(direction) FROM portal_crossings WHERE timestamp_ms >= midnight GROUP BY zone_id\n5. Apply net crossings to loaded occupancy: reconciled_count = last_known + net_crossings\n6. Clamp to >= 0 (committed crossing out of empty room never goes negative)\n7. Use reconciled_count as starting occupancy\n\n### Reconciliation validation (runs every 30s for first 60s of operation):\n- Compare portal-based occupancy vs. blob-count-per-zone (from fusion output)\n- If they differ by > 1 for 2 consecutive checks: apply blob-count as ground truth; log discrepancy\n- After 60s of live operation: mark occupancies as 'reconciled'; clear uncertain badges\n\n### Persistence:\n- On graceful shutdown (SIGTERM): write current occupancy to zones.last_known_occupancy for all zones\n- On each zone occupancy change: update zones.last_known_occupancy in SQLite\n\n### Dashboard:\n- Uncertain occupancy: zone card shows amber border + 'Estimated' label\n- Reconciled: green border + no label\n\n## Acceptance\n- Restart with 2 people in kitchen: occupancy restored to 2 within 60s\n- Portal crossing computed correctly from midnight\n- Blob-count override triggers correctly if >1 discrepancy for 2 checks\n- Graceful shutdown persists occupancy: verify via sqlite3 query after SIGTERM","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:09:56.460463508Z","created_by":"coding","updated_at":"2026-04-07T04:38:00.691602057Z","closed_at":"2026-04-07T04:38:00.691540229Z","close_reason":"Already implemented: occupancy reconciliation in zones/manager.go - reconcileOccupancy() loads persisted counts + net crossings since midnight, ReconcileTick() validates portal vs blob counts, PersistOccupancy() on shutdown. All 13 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-ux6","title":"Mothership: startup phase sequencing with timeout enforcement","description":"## Overview\nImplement explicit startup phase logging and a 30-second total timeout so the mothership fails fast and clearly on misconfiguration.\n\n## Phases (plan lines 3450-3475):\nPhase 1/7 — Data directory: create /data, acquire flock() lock (prevents dual-instance)\nPhase 2/7 — SQLite: open database, enable WAL mode, set busy_timeout=5000\nPhase 3/7 — Schema migrations: apply pending migrations (spaxel-9z3)\nPhase 4/7 — Config & secrets: validate env vars (spaxel-env-validation), load/generate install secret\nPhase 5/7 — Subsystems: start all managers (fleet, ingestion, signal, fusion, etc.) with 5s per-subsystem timeout\nPhase 6/7 — HTTP + mDNS: bind HTTP server, advertise _spaxel._tcp.local\nPhase 7/7 — Health: run initial health check; log [READY]\n\n## Implementation\n- context.WithTimeout(30s) wraps all 7 phases\n- Each phase: log '[PHASE N/7 — Description]' on start; '[PHASE N/7 OK] (Xms)' on completion\n- Phase 5 subsystem timeout: each manager.Start() called with 5s context; if any fails, abort startup\n- On 30s deadline exceeded: log fatal '[STARTUP TIMEOUT] Failed to reach ready state in 30s'; exit(1)\n- Write /tmp/spaxel.ready on Phase 7 success (optional, for Docker --health-cmd alternative)\n\n## Acceptance\n- Startup log shows all 7 phases with timing\n- Missing data directory: fails at Phase 1 with clear error\n- DB corruption: fails at Phase 2 with clear error\n- Subsystem timeout (e.g., SQLite locked): fails at Phase 5 within 5s\n- Total startup within 5s under normal conditions (logged timing confirms)","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T16:43:56.570408149Z","created_by":"coding","updated_at":"2026-04-07T17:11:16.918324384Z","closed_at":"2026-04-07T17:11:16.918218979Z","close_reason":"Startup phase sequencing with 30s timeout enforcement already implemented in commit 76ac271. Package mothership/internal/startup provides: 7 sequential phases, 30s total timeout, 5s per-subsystem timeout, phase logging with timing, ready marker file. All 15 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} {"id":"spaxel-v9z","title":"Diurnal adaptive baseline","description":"## Background\n\nThe EMA (Exponential Moving Average) baseline implemented in Phase 2 adapts to ambient conditions on a roughly 30-second timescale. This handles rapid environmental changes well, but misses systematic daily patterns: more WiFi interference in evenings due to neighbour activity, temperature changes affecting the ESP32's hardware oscillator characteristics, different background activity patterns at different times of day (e.g. higher ambient motion from TV or household activity in the evenings). A diurnal (24-hour) baseline learns these patterns and crossfades to the appropriate hourly baseline, dramatically reducing false positives at different times of day.\n\n## What is a Diurnal Baseline?\n\nInstead of a single EMA that tracks the most recent state, we maintain 24 separate baseline vectors — one per hour of the day. Each hour-slot baseline is an array of float64 values, one per CSI subcarrier (matching the existing baseline format). The hour-slot is updated only by readings that arrive during that hour, using a very slow EMA (tau = 7 days, so alpha ≈ 0.00017 per sample at 2 Hz). This means each hour-slot slowly learns the characteristic ambient CSI for that time of day, averaged over many days.\n\n## 7-Day Learning Period\n\nA freshly deployed system has no diurnal data. For the first 7 days, the system continues to use the standard Phase 2 EMA baseline for all detection decisions. During this period, all received readings are silently updating the diurnal slots, but those slots are not used for detection. The dashboard shows: \"Learning your home's daily patterns: {N} days remaining.\"\n\nAfter 7 days of data (where \"data\" means at least 100 readings in each hour-slot — this threshold prevents partial-data slots from being used), the diurnal baseline activates automatically. A one-time notification is shown: \"Your system has learned your daily patterns. Accuracy should improve this week.\"\n\n## Crossfade Algorithm\n\nA naive implementation would jump discontinuously at each hour boundary, causing false detections at e.g. exactly 14:00 when the baseline switches from the 13:xx slot to the 14:xx slot. The fix is a smooth crossfade:\n\nLet h = current_hour (0-23), frac = current_minute / 60.\nEffective baseline B_eff = (1 - frac) * B_slot[h] + frac * B_slot[(h+1) % 24]\n\nThis is a linear interpolation between the current-hour and next-hour baselines. At 13:00, B_eff = B_slot[13]. At 13:30, B_eff = 0.5*B_slot[13] + 0.5*B_slot[14]. At 13:59, B_eff is nearly B_slot[14]. A cosine crossfade (frac_smooth = (1 - cos(pi * frac)) / 2) can be used instead of linear for a perceptually smoother transition.\n\nThe crossfade is applied per-subcarrier to the full baseline vector before comparing to incoming CSI readings.\n\n## Confidence Indicator\n\nThe confidence score is a per-link float in [0, 1] that summarises the reliability of detection on that link. It is broadcast as part of the \"link_health\" WebSocket message and rendered in the dashboard as a colour-coded indicator per link (green > 0.7, amber 0.4-0.7, red < 0.4).\n\nConfidence inputs:\n1. Baseline age: time since the diurnal slot for the current hour was last updated. Staleness reduces confidence. If a slot has not been updated for > 3 days, its confidence contribution is 0.\n2. Diurnal learning progress: 0.0 before 7 days, interpolates to 1.0 at 14 days. This ramps in the diurnal component gradually as more data accumulates.\n3. Packet rate health: actual received packets per second divided by the configured sample rate. If packet rate drops to 80% of configured, confidence = 0.8. At 50%, confidence = 0.5.\n4. Composite: weighted_avg(baseline_age: 0.3, diurnal_progress: 0.3, packet_rate: 0.4).\n\n## SQLite Persistence\n\nThe diurnal baseline must survive mothership restarts. Extend the existing BaselineManager.Snapshot() / RestoreBaseline() methods to include diurnal data.\n\nAdd SQLite table: diurnal_baselines (link_id TEXT, hour_slot INTEGER, subcarrier_idx INTEGER, value REAL, sample_count INTEGER, last_updated DATETIME, PRIMARY KEY (link_id, hour_slot, subcarrier_idx)).\n\nOn startup, RestoreBaseline() loads diurnal slots from SQLite. On shutdown (or every 5 minutes as a background snapshot), Snapshot() writes updated diurnal slots to SQLite. The snapshot is incremental: only write slots that have been updated since the last snapshot.\n\nFile: mothership/internal/signal/baseline.go — extend existing BaselineManager struct.\n\n## Implementation Structure\n\nNew struct DiurnalBaseline within baseline.go:\n- slots [24][]float64: per-hour baseline vectors (len = num_subcarriers)\n- sampleCounts [24]int: readings accumulated per slot\n- lastUpdated [24]time.Time: timestamp of last slot update\n- alpha float64: slow EMA coefficient (default: 1/(7*24*3600*2) per sample at 2Hz)\n\nDiurnalBaseline.Update(hour int, values []float64) method: applies slow EMA to the appropriate hour slot.\nDiurnalBaseline.EffectiveBaseline(t time.Time) []float64: returns the crossfaded baseline for the given timestamp.\nDiurnalBaseline.IsReady() bool: returns true if 7+ days have elapsed since first update AND all 24 slots have >= 100 samples.\nDiurnalBaseline.Confidence(t time.Time, packetRateRatio float64) float64: returns composite confidence score.\n\n## Tests\n\n- Test that hour-slot selection is correct for timestamps at boundaries (23:59:59 -> slot 23, 00:00:00 -> slot 0)\n- Test that crossfade at half-hour produces the correct blend of two adjacent slots\n- Test cosine crossfade is smooth (no discontinuity at integer hours in the smooth version)\n- Test that the 7-day learning gate correctly returns IsReady() = false before 7 days and true after\n- Test that confidence score is 0 when packet_rate_ratio = 0\n- Test SQLite snapshot round-trip: snapshot diurnal data, clear in-memory state, restore, verify values match\n- Test that baseline staleness correctly reduces confidence for a slot not updated in > 3 days\n\n## Acceptance Criteria\n\n- Diurnal baseline automatically activates after 7 days of data collection per link\n- Hour-boundary crossfade is smooth (no visible discontinuities in false positive rate)\n- Confidence indicator visible per link in dashboard, updates in real-time\n- Diurnal baseline data persists across mothership restarts via SQLite snapshot\n- Detection accuracy measurably improves (target: <5% false positive rate after 7-day learning vs >10% with standard EMA baseline in homes with consistent daily patterns)\n- One-time \"patterns learned\" notification fires exactly once after 7 days\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"india","created_at":"2026-03-28T01:39:55.414445302Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.839347486Z","closed_at":"2026-03-29T18:07:39.838982115Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-v9z","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:13.961565954Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-vuw","title":"Spatial automation: trigger volume geometry builder","description":"## Overview\n3D trigger volume editor and point-in-volume evaluation engine for the spatial automation system.\n\n## Backend (mothership/automation/ or zones/)\n- Geometry types: box {type, x, y, z, w, d, h} and cylinder {type, cx, cy, z, r, h} stored as shape_json\n- Point-in-volume tests: axis-aligned box test and cylinder test functions\n- Trigger state machine per (trigger_id, blob_id) pair: track inside/outside state and transition edges (enter, leave, dwell, vacant, count)\n- Dwell timer: fire dwell action after N continuous seconds inside volume\n- SQLite triggers table: id, name, shape_json, condition TEXT, condition_params JSON, actions_json, enabled BOOL, last_fired_ms\n- REST CRUD at /api/triggers (requires spaxel-6ha)\n\n## Dashboard (dashboard/js/automation-builder.js)\n- Automation panel via panel framework (spaxel-896)\n- Draw box volume: click + drag to define base footprint, height slider\n- Draw cylinder volume: click center, drag radius, height slider\n- THREE.js TransformControls for translate/scale/rotate after placement\n- Volume visualization: translucent colored box/cylinder; pulse animation when condition fires\n- Condition picker: enter zone / leave zone / dwell N seconds / zone vacant / count >= N\n- Action list: webhook URL, MQTT topic/payload, internal (arm security, rebaseline, notify)\n- Trigger log: last 10 firings with timestamp and matched blob\n\n## Acceptance\n- Box and cylinder volumes render correctly in 3D view\n- Point-in-volume evaluated on each fusion tick (target <1ms per trigger)\n- Dwell trigger fires at correct time ±1s\n- Trigger state persists across server restart\n- Requires: spaxel-896, spaxel-6ha, spaxel-9eg","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:42.971994626Z","created_by":"coding","updated_at":"2026-04-06T13:01:42.971994626Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-vuw","title":"Spatial automation: trigger volume geometry builder","description":"## Overview\n3D trigger volume editor and point-in-volume evaluation engine for the spatial automation system.\n\n## Backend (mothership/automation/ or zones/)\n- Geometry types: box {type, x, y, z, w, d, h} and cylinder {type, cx, cy, z, r, h} stored as shape_json\n- Point-in-volume tests: axis-aligned box test and cylinder test functions\n- Trigger state machine per (trigger_id, blob_id) pair: track inside/outside state and transition edges (enter, leave, dwell, vacant, count)\n- Dwell timer: fire dwell action after N continuous seconds inside volume\n- SQLite triggers table: id, name, shape_json, condition TEXT, condition_params JSON, actions_json, enabled BOOL, last_fired_ms\n- REST CRUD at /api/triggers (requires spaxel-6ha)\n\n## Dashboard (dashboard/js/automation-builder.js)\n- Automation panel via panel framework (spaxel-896)\n- Draw box volume: click + drag to define base footprint, height slider\n- Draw cylinder volume: click center, drag radius, height slider\n- THREE.js TransformControls for translate/scale/rotate after placement\n- Volume visualization: translucent colored box/cylinder; pulse animation when condition fires\n- Condition picker: enter zone / leave zone / dwell N seconds / zone vacant / count >= N\n- Action list: webhook URL, MQTT topic/payload, internal (arm security, rebaseline, notify)\n- Trigger log: last 10 firings with timestamp and matched blob\n\n## Acceptance\n- Box and cylinder volumes render correctly in 3D view\n- Point-in-volume evaluated on each fusion tick (target <1ms per trigger)\n- Dwell trigger fires at correct time ±1s\n- Trigger state persists across server restart\n- Requires: spaxel-896, spaxel-6ha, spaxel-9eg","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:42.971994626Z","created_by":"coding","updated_at":"2026-04-06T22:37:47.261863838Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:20"],"dependencies":[{"issue_id":"spaxel-vuw","depends_on_id":"spaxel-6ha","type":"blocks","created_at":"2026-04-06T22:30:46.177551897Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-vuw","depends_on_id":"spaxel-9eg","type":"blocks","created_at":"2026-04-06T22:30:46.211344140Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-w40","title":"Passive radar: auto-detect router AP as virtual TX node","description":"## Overview\nAutomatically detect the home router as a passive radar TX source, eliminating need for a dedicated active TX node.\n\n## Firmware changes\n- During hello message, include ap_bssid and ap_channel from esp_wifi_sta_get_ap_info()\n\n## Mothership (mothership/fleet/ or ingestion/)\n- On hello: extract ap_bssid; if >=80% of nodes report same BSSID create virtual node entry with virtual=1, position unset\n- OUI lookup: embed IEEE OUI registry as Go map compiled via go:embed; display router brand\n- Detect AP BSSID change (router reboot/replacement) and emit system alert\n- SQLite nodes table: add virtual BOOL, node_type TEXT, ap_bssid TEXT, ap_channel INT columns\n\n## Dashboard\n- After AP auto-detected: 'I detected your router (ASUS). Place it on the floor plan to improve accuracy.'\n- Drag-to-place virtual node (distinct router icon) in 3D editor\n- Confirmation dialog with 'Use as signal source' toggle\n\n## Acceptance\n- Virtual node appears in /api/nodes with virtual=true\n- 3D view renders virtual node with distinct icon\n- AP change detection fires a system event within 30s of BSSID change","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:07.745215170Z","created_by":"coding","updated_at":"2026-04-06T18:04:45.975811136Z","closed_at":"2026-04-06T18:04:45.975562593Z","close_reason":"Implemented passive radar auto-detection of router AP\n\nFirmware: Added ap_bssid/ap_channel to hello message using esp_wifi_sta_get_ap_info()\n\nMothership: Created apdetector package for >=80% BSSID agreement detection, OUI lookup for router manufacturer, AP change detection system events\n\nDashboard: AP detection notification, distinct router icon in 3D (box+4antennas), drag-to-place positioning\n\nVirtual nodes appear in /api/nodes with virtual=true, node_type=ap","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3"]} {"id":"spaxel-x59","title":"merge: remove phase6 build tag and unify main.go","description":"## Problem\n`cmd/mothership/main_phase6.go` is gated behind `//go:build phase6` which excludes all Phase 6+ code from default builds. The directory has both `main.go` (Phase 5) and `main_phase6.go` (Phase 6) — both define `package main` with `func main()`, so removing the build tag would cause a duplicate symbol error.\n\n## Prerequisites\nAll Phase 6 package compile errors must be fixed first (spaxel-glq, spaxel-9nj, spaxel-19h, spaxel-uln, spaxel-7nk, spaxel-she).\n\n## Steps\n1. Confirm all Phase 6+ packages compile cleanly:\n ```bash\n cd /home/coding/spaxel/mothership\n PATH=$PATH:/home/coding/go/bin go build ./internal/...\n ```\n2. Delete `cmd/mothership/main.go.bak` (stale backup)\n3. Delete `cmd/mothership/main.go` (Phase 5 entrypoint, superseded)\n4. Remove the `//go:build phase6` line and the blank line after it from `cmd/mothership/main_phase6.go`\n5. Build and verify:\n ```bash\n PATH=$PATH:/home/coding/go/bin go build ./...\n PATH=$PATH:/home/coding/go/bin go test ./...\n ```\n\n## Acceptance\n- `go build ./...` passes with no errors\n- Binary is built from the Phase 6 entrypoint\n- No `phase6` build tag exists anywhere in the codebase\n\nDependents:\n <- spaxel-jcc","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:32.363205812Z","created_by":"coding","updated_at":"2026-04-07T05:33:07.064388207Z","closed_at":"2026-04-07T05:33:07.064285866Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:19"],"dependencies":[{"issue_id":"spaxel-x59","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:30:41.292760872Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:30:41.351817968Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:30:41.255304103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:30:41.209121103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:30:41.390256545Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:30:41.322389944Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-xlo","title":"Create SQLite floorplan table and storage directory","description":"## Task\nCreate the floorplan table in SQLite and ensure /data/floorplan directory exists.\n\n## Schema\nSQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- /data/floorplan directory exists\n- floorplan table created in SQLite with correct schema","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:49.108738491Z","created_by":"coding","updated_at":"2026-04-07T18:21:09.020450667Z","closed_at":"2026-04-07T18:21:09.020390325Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} -{"id":"spaxel-xpk","title":"Diurnal adaptive baseline: 24-hour slot learning","description":"## Overview\nExtend the EMA baseline system with per-hour-of-day slots to eliminate false positives caused by daily environmental cycles (sunlight, HVAC, temperature changes).\n\n## Backend (mothership/signal/baseline.go extension)\n- Data structure: 24 hourly slots per link per subcarrier; each slot stores amplitude blob and sample_count\n- Learning phase (7 days): accumulate motion-free CSI into hourly slots; require >=300 samples/slot to mark ready\n- Steady state: on each fusion tick, select active baseline = weighted blend of diurnal slot (if ready) + EMA fallback\n- Crossfade: over first 15 min of each hour, linearly blend from EMA to diurnal slot; after 15 min use diurnal exclusively\n- Motion-gated updates: EMA updates continue during the hourly window, improving diurnal slot over time\n- Outlier protection: skip update if deltaRMS > motion threshold (don't train on motion frames)\n- SQLite diurnal_baselines table: link_id, hour_of_day (0-23), n_sub INT, amplitude BLOB, sample_count INT, confidence REAL, updated_at INT\n\n## Dashboard visualization\n- Per-link detail panel: 24-hour polar chart (or horizontal bar chart) showing baseline amplitude variance by hour\n- 'Diurnal learning' progress indicator: 'Learning hour 14... 6/7 days'\n- Confidence color per hour: green (ready), amber (partial), red (no data)\n\n## Acceptance\n- Baseline correctly crossfades at hour boundaries (±60s)\n- Motion events during learning do not corrupt slots (outlier protection confirmed by test)\n- Polar chart renders for links with >=1 ready slot\n- No performance regression: baseline lookup remains O(1)\n- Requires: spaxel-jcc (phase 6 integration)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-06T13:02:07.078024506Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-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":"alpha","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-07T12:23:20.956597994Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","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":"foxtrot","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-07T19:44:09.234068672Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:153"],"dependencies":[{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.124863668Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-5yq","type":"blocks","created_at":"2026-04-07T06:33:23.159852888Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-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":""}]} {"id":"spaxel-zvo","title":"Interactive onboarding wizard","description":"## Background\n\nPhase 4's central goal is that a non-technical user can go from an unboxed ESP32-S3 to streaming CSI in under 5 minutes. The onboarding wizard is the centrepiece of this experience. It uses the Web Serial API (available in Chrome/Edge) to communicate with the ESP32 over USB — no driver installation needed, no CLI, no app download. The wizard is embedded in the existing mothership dashboard, accessible at /onboard.\n\n## Why Web Serial?\n\nThe alternative approaches — a dedicated mobile app, a WiFi provisioning AP, or a CLI tool — all have significant UX friction. Web Serial lets us flash firmware, provision WiFi credentials, and guide the user through calibration all in one browser session. The dashboard already knows the mothership IP/port. Chrome and Edge (95%+ of desktop browser market) support Web Serial natively since 2021. The only caveat is that Web Serial is not available in Firefox or Safari — this must be documented prominently at the start of the wizard.\n\n## Wizard Steps\n\n1. Browser check: Detect navigator.serial availability. If missing, show: \"Please use Google Chrome or Microsoft Edge to use the setup wizard. Firefox and Safari do not support USB device access.\"\n\n2. Connect device: Call navigator.serial.requestPort(). Guide the user to hold BOOT button while plugging in if the device does not appear. Show a SVG illustration of the ESP32-S3 board with the BOOT button highlighted.\n\n3. Flash firmware (if not already spaxel firmware): Use esp-web-tools (espressif/esp-web-tools). This open-source library handles the full ESP32 flashing pipeline via Web Serial, including ROM bootloader protocol, chip detection, and progress reporting. It needs a firmware manifest.json at GET /api/firmware/manifest describing binary addresses and offsets. Show a progress bar during flashing. Estimated time: 45-90 seconds.\n\n4. Provision WiFi: Show a form for SSID and password. Optional: mothership host/port override (for non-mDNS setups). Assemble the provisioning payload and send to the ESP32 over serial as JSON (see Provisioning Payload bead for format).\n\n5. Detect mothership: Once provisioned and rebooted, the ESP32 boots and discovers the mothership via mDNS (spaxel-mothership.local) or the configured host. Poll GET /api/nodes every 3s for up to 120s waiting for the new node to appear. Show animated \"Connecting...\" indicator. On timeout: show WiFi troubleshooting guidance (5GHz check, SSID typo check, distance check).\n\n6. Guided calibration: Show the CSI waveform for the new node's links as they come online. Steps:\n a. \"Walk around your space for 30 seconds\" — CSI amplitude should show activity. If flat: check node orientation.\n b. \"Stand still at the far end of the room\" — capture baseline. Show countdown. Green check when baseline is captured.\n c. \"Walk through the centre of the room\" — Fresnel zone lights up in 3D view, blob appears. \"The sensor can see you!\"\n\n7. Node placement guidance: Transition to the coverage painting UI (spaxel-qq6) for optimal node positioning. Show GDOP overlay for the current node placement. Suggest additional node positions if coverage is poor.\n\n## Files to Create or Modify\n\n- dashboard/js/onboard.js: wizard state machine, Web Serial API calls, step rendering\n- dashboard/index.html: add /onboard route and wizard container div, import esp-web-tools\n- mothership/internal/dashboard/ routes: add GET /api/firmware/manifest route\n- Firmware manifest JSON served at GET /api/firmware/manifest with chipFamily, parts array containing path and offset\n\n## esp-web-tools Integration\n\nThe library esp-web-tools is loaded from CDN as an ES module. A custom-element install-button is used for flashing. The manifest served by the mothership includes the firmware binary path (/firmware/latest) and flash offset (0x0). The library handles the bootloader handshake, erase, and write automatically.\n\n## Wizard State Machine\n\nStates: BROWSER_CHECK -> CONNECT_DEVICE -> FLASH_FIRMWARE -> PROVISION_WIFI -> DETECT_NODE -> CALIBRATE -> PLACEMENT -> COMPLETE\n\nEach state has: render() function, onEnter() side effects, onNext() transition, onBack() for revert, onError() for failure handling.\n\nPersisted in sessionStorage so a page refresh during onboarding resumes from the last step — critical for the reboot-then-detect step where the browser must survive the ESP32 reboot cycle.\n\n## Error Handling\n\nMap every known failure to a human-friendly message:\n- NotFoundError (no port selected) -> \"No device detected. Make sure the USB cable is connected and hold the BOOT button while plugging in.\"\n- NetworkError during flash -> \"The connection was interrupted. Check the USB cable is not loose and try again.\"\n- Node not appearing after 120s -> \"Your node connected to WiFi but cannot reach the mothership. Check: 1) Your router blocks device-to-device communication (AP isolation). 2) The mothership address is correct. 3) Your network uses a VLAN that separates devices.\"\n- Wrong SSID/password -> Node will fall into captive portal mode after 10 failures, triggering a \"Captive portal detected\" guidance flow.\n\nNever show stack traces, WebSocket error codes, or Go error strings to the user.\n\n## Tests\n\n- Mock navigator.serial API in Jest to test wizard state transitions without real hardware\n- Test that provisioning payload is correctly assembled and sent over the mocked serial port\n- Test that polling GET /api/nodes correctly detects node appearance and transitions to DETECT_NODE -> CALIBRATE\n- Test that BROWSER_CHECK step correctly detects missing serial API and shows the correct error\n- Test that sessionStorage correctly restores wizard state on page refresh at each step\n\n## Acceptance Criteria\n\n- Wizard completes in under 5 minutes on a fresh ESP32-S3 with a working WiFi network\n- User sees live CSI waveform during calibration step\n- Node appears in dashboard after wizard completion, with correct label\n- All known error conditions show human-friendly guidance, not technical errors\n- All existing dashboard tests pass\n- Wizard state is resumable after page refresh","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:36:08.928580604Z","created_by":"coding","updated_at":"2026-03-28T08:01:41.237288050Z","closed_at":"2026-03-28T08:01:41.237159218Z","close_reason":"Fixed 4 failing tests in the onboarding wizard test suite:\n\n1. WebSocket mock: Changed from constructor-prototype pattern to factory function so jest.resetAllMocks() doesn't break the mock. Fixed 'state.ws.close is not a function' errors during calibrate step cleanup.\n\n2. TextEncoderStream mock: Added functional readable/writable with pipeTo mock and data capture helpers (__getLastEncodedData/__clearLastEncodedData) to support provisioning serial send tests.\n\n3. flash_firmware test: Fixed assertion to check wizard-nav element for 'Skip Flashing' button instead of wizard-content (the nav button is rendered separately from step content).\n\n4. provisionAndSend 'no port' test: Changed getPorts mock from mockResolvedValueOnce to mockResolvedValue([]) so both the primary and fallback provisioning paths consistently fail when no port is available.\n\nAll 60 tests now pass. The onboarding wizard implementation (onboard.js, index.html, mothership firmware manifest route) was already complete from the previous commit.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvo","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.806490089Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvs","title":"Phase 6: Identity & Spatial Automation","description":"Goal: Named presence, actionable automations, safety features.\n\nDeliverables:\n- BLE device registry (People & Devices panel, auto-detected type, user labels/color)\n- BLE-to-blob identity matching (multi-node RSSI triangulation → nearest CSI blob)\n- Room transition portals (doorway planes, directional crossing, zone occupancy counters)\n- Spatial automation builder (3D trigger volumes, conditions, webhook/MQTT actions)\n- Fall detection (Z-axis descent + sustained stillness, alert chain, person-identified)\n- Spatial context notifications (push with mini floor-plan thumbnails, smart batching, quiet hours)\n- Home automation integration (optional MQTT for HA auto-discovery, webhooks)\n\nExit criteria: BLE-identified blobs show correct names. Fall detection fires on simulated falls <10% FP.","status":"closed","priority":3,"issue_type":"phase","assignee":"delta","created_at":"2026-03-27T01:55:32.553129034Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.888675543Z","closed_at":"2026-03-29T18:07:39.888615041Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-zvs","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T01:33:45.440982494Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 265ff60..b658275 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -35b274aab59e1c4a56d51cec8793b30f5991b86c +47eaf24cf98f3ad222be8f3992814317947b59e1 diff --git a/dashboard/css/anomaly.css b/dashboard/css/anomaly.css new file mode 100644 index 0000000..258c400 --- /dev/null +++ b/dashboard/css/anomaly.css @@ -0,0 +1,887 @@ +/* Anomaly Detection UI - Alarm Overlay, Acknowledgement Flow, and Security Mode */ + +/* ── Anomaly Alarm Overlay ────────────────────────────────────────────────────── */ + +.anomaly-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(220, 38, 38, 0.95); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + animation: anomaly-fade-in 0.3s ease-out; +} + +.anomaly-overlay.hidden { + display: none; +} + +@keyframes anomaly-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.anomaly-banner { + background: var(--bg-card, #1e1e2e); + border: 2px solid var(--color-danger, #dc2626); + border-radius: 16px; + padding: 24px; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + animation: anomaly-slide-up 0.3s ease-out; +} + +@keyframes anomaly-slide-up { + from { transform: translateY(50px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.anomaly-icon { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + background: var(--color-danger, #dc2626); + border-radius: 50%; + margin: 0 auto 16px; + color: white; + animation: anomaly-pulse 1.5s infinite; +} + +@keyframes anomaly-pulse { + 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7); } + 50% { transform: scale(1.1); box-shadow: 0 0 20px 0 rgba(220, 38, 38, 0); } +} + +.anomaly-icon svg { + width: 36px; + height: 36px; +} + +.anomaly-content { + text-align: center; + color: var(--text-color, #e0e0e0); +} + +.anomaly-title { + font-size: 20px; + font-weight: 700; + margin: 0 0 8px 0; + color: var(--color-danger, #dc2626); +} + +.anomaly-description { + font-size: 16px; + margin-bottom: 12px; + line-height: 1.4; +} + +.anomaly-meta { + font-size: 13px; + color: var(--text-muted, #888); + margin-bottom: 20px; + font-family: monospace; +} + +.anomaly-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.anomaly-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.anomaly-btn.ack { + background: var(--color-success, #22c55e); + color: white; +} + +.anomaly-btn.ack:hover { + background: #16a34a; + transform: scale(1.05); +} + +.anomaly-btn.view { + background: var(--color-primary, #3b82f6); + color: white; +} + +.anomaly-btn.view:hover { + background: #2563eb; + transform: scale(1.05); +} + +.anomaly-btn.dismiss { + background: var(--bg-card, #2a2a3e); + color: var(--text-color, #e0e0e0); + border: 1px solid var(--border-color, #444); +} + +.anomaly-btn.dismiss:hover { + background: var(--bg-hover, #3a3a4e); +} + +/* ── Feedback Modal ───────────────────────────────────────────────────────────── */ + +.anomaly-feedback-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2100; + display: flex; + align-items: center; + justify-content: center; +} + +.anomaly-feedback-modal.hidden { + display: none; +} + +.modal-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background: var(--bg-card, #1e1e2e); + border: 1px solid var(--border-color, #333); + border-radius: 16px; + padding: 24px; + max-width: 480px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + color: var(--text-color, #e0e0e0); + animation: modal-pop 0.2s ease-out; +} + +@keyframes modal-pop { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.modal-content h3 { + margin: 0 0 8px 0; + font-size: 18px; + font-weight: 600; +} + +.feedback-anomaly-desc { + margin-bottom: 16px; + color: var(--text-muted, #888); + font-size: 14px; +} + +.feedback-options { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.feedback-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-card, #1e1e2e); + border: 2px solid var(--border-color, #444); + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: all 0.2s; +} + +.feedback-btn:hover { + background: var(--bg-hover, #2a2a3e); +} + +.feedback-btn.selected { + border-color: var(--color-primary, #3b82f6); + background: rgba(59, 130, 246, 0.1); +} + +.feedback-btn .icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 18px; + flex-shrink: 0; +} + +.feedback-btn.expected .icon { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.feedback-btn.intrusion .icon { + background: rgba(251, 146, 60, 0.2); + color: #fb923c; +} + +.feedback-btn.false-alarm .icon { + background: rgba(248, 113, 113, 0.2); + color: #f87171; +} + +.feedback-btn .label { + font-weight: 600; + font-size: 14px; +} + +.feedback-btn .desc { + font-size: 12px; + color: var(--text-muted, #888); +} + +.feedback-notes { + margin-bottom: 16px; +} + +#feedback-notes-input { + width: 100%; + min-height: 80px; + padding: 10px; + background: var(--bg-input, #2a2a3e); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + color: var(--text-color, #e0e0e0); + font-size: 14px; + font-family: inherit; + resize: vertical; +} + +#feedback-notes-input:focus { + outline: none; + border-color: var(--color-primary, #3b82f6); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.modal-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.modal-btn.cancel { + background: var(--bg-card, #2a2a3e); + color: var(--text-color, #e0e0e0); +} + +.modal-btn.cancel:hover { + background: var(--bg-hover, #3a3a4e); +} + +.modal-btn.submit { + background: var(--color-primary, #3b82f6); + color: white; +} + +.modal-btn.submit:hover:not(:disabled) { + background: #2563eb; +} + +.modal-btn.submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Learning Banner ──────────────────────────────────────────────────────────── */ + +#anomaly-learning-banner { + position: fixed; + top: 60px; + left: 50%; + transform: translateX(-50%); + z-index: 500; + background: var(--bg-card, #1e1e2e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + animation: learning-appear 0.3s ease-out; +} + +#anomaly-learning-banner.hidden { + display: none; +} + +@keyframes learning-appear { + from { transform: translateX(-50%) translateY(-10px); opacity: 0; } + to { transform: translateX(-50%) translateY(0); opacity: 1; } +} + +.learning-icon { + color: #a78bfa; + font-size: 18px; +} + +.learning-text { + font-size: 13px; + color: var(--text-color, #e0e0e0); +} + +.learning-progress-bar { + width: 120px; + height: 6px; + background: var(--bg-input, #2a2a3e); + border-radius: 3px; + overflow: hidden; +} + +.learning-progress { + height: 100%; + background: linear-gradient(90deg, #a78bfa, #22c55e); + transition: width 0.5s ease-out; + border-radius: 3px; +} + +.days-remaining { + font-size: 11px; + color: var(--text-muted, #888); + margin-left: 8px; +} + +/* ── Security Mode Indicator ───────────────────────────────────────────────────── */ + +#security-mode-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + background: var(--bg-card, #1e1e2e); + border: 1px solid var(--border-color, #333); + transition: all 0.3s; +} + +.security-status-indicator.mode-disarmed { + background: var(--bg-card, #1e1e2e); + border-color: var(--border-color, #333); +} + +.security-status-indicator.mode-armed { + background: rgba(220, 38, 38, 0.2); + border-color: var(--color-danger, #dc2626); + color: var(--color-danger, #dc2626); +} + +.security-status-indicator.mode-alert { + background: rgba(220, 38, 38, 0.2); + border-color: var(--color-danger, #dc2626); + color: var(--color-danger, #dc2626); + animation: security-pulse 1s infinite; +} + +@keyframes security-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220, 38, 38, 0.5); } + 50% { box-shadow: 0 0 20px rgba(220, 38, 38, 0.8); } +} + +.security-status-indicator.mode-learning { + background: rgba(167, 139, 250, 0.2); + border-color: var(--color-secondary, #a78bfa); + color: var(--color-secondary, #a78bfa); +} + +.security-status-indicator.mode-ready { + background: rgba(34, 197, 94, 0.2); + border-color: var(--color-success, #22c55e); + color: var(--color-success, #22c55e); +} + +.security-icon { + font-size: 14px; +} + +.security-text { + font-weight: 600; +} + +.security-toggle-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px; + margin-left: 4px; + border-radius: 4px; + color: inherit; + opacity: 0.7; + transition: opacity 0.2s; +} + +.security-toggle-btn:hover { + opacity: 1; +} + +/* ── Alert Banner ──────────────────────────────────────────────────────────────────── */ + +#alert-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1900; + background: var(--color-danger, #dc2626); + color: white; + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + animation: alert-slide-down 0.3s ease-out; +} + +#alert-banner.hidden { + display: none; +} + +@keyframes alert-slide-down { + from { transform: translateY(-100%); } + to { transform: translateY(0); } +} + +.alert-banner-icon { + font-size: 24px; +} + +.alert-banner-content { + flex: 1; + min-width: 0; +} + +.alert-banner-title { + font-size: 16px; + font-weight: 700; + margin: 0 0 4px 0; +} + +.alert-banner-description { + font-size: 14px; + opacity: 0.95; + margin-bottom: 4px; +} + +.alert-banner-meta { + font-size: 12px; + opacity: 0.85; + display: flex; + gap: 12px; +} + +.alert-banner-actions { + display: flex; + gap: 8px; +} + +.alert-banner-btn { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + color: white; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.alert-banner-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* ── Security Dialog ────────────────────────────────────────────────────────────── */ + +.security-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + animation: dialog-fade-in 0.2s ease-out; +} + +@keyframes dialog-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.security-dialog-card { + background: var(--bg-card, #1e1e2e); + border: 1px solid var(--border-color, #333); + border-radius: 16px; + padding: 24px; + max-width: 440px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + color: var(--text-color, #e0e0e0); + animation: dialog-pop 0.3s ease-out; +} + +.security-dialog-card.arm { + border-color: var(--color-danger, #dc2626); +} + +.security-dialog-card.disarm { + border-color: var(--color-success, #22c55e); +} + +@keyframes dialog-pop { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.security-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color, #333); +} + +.security-dialog-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.security-dialog-close { + background: none; + border: none; + color: var(--text-muted, #888); + font-size: 20px; + cursor: pointer; + padding: 4px; + line-height: 1; + border-radius: 4px; +} + +.security-dialog-close:hover { + background: var(--bg-hover, #333); + color: var(--text-color, #e0e0e0); +} + +.security-dialog-content { + margin-bottom: 20px; +} + +.security-dialog-prompt { + font-size: 14px; + line-height: 1.5; + margin-bottom: 16px; + color: var(--text-color, #e0e0e0); +} + +.security-dialog-warning { + background: rgba(251, 146, 60, 0.1); + border: 1px solid rgba(251, 146, 60, 0.3); + border-radius: 8px; + padding: 12px; + margin-bottom: 16px; +} + +.security-dialog-warning p { + margin: 0 0 8px 0; + font-size: 13px; + color: #fb923c; +} + +.security-dialog-warning p:last-child { + margin-bottom: 0; +} + +.security-dialog-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +.stat-item { + background: var(--bg-input, #2a2a3e); + border-radius: 8px; + padding: 12px; +} + +.stat-item-full { + grid-column: 1 / -1; +} + +.stat-label { + display: block; + font-size: 11px; + color: var(--text-muted, #888); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stat-value { + display: block; + font-size: 18px; + font-weight: 600; +} + +.security-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.security-dialog-btn { + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.security-dialog-btn.cancel { + background: var(--bg-card, #2a2a3e); + color: var(--text-color, #e0e0e0); +} + +.security-dialog-btn.cancel:hover { + background: var(--bg-hover, #3a3a4e); +} + +.security-dialog-btn.arm { + background: var(--color-danger, #dc2626); + color: white; +} + +.security-dialog-btn.arm:hover { + background: #b91c1c; +} + +.security-dialog-btn.disarm { + background: var(--color-success, #22c55e); + color: white; +} + +.security-dialog-btn.disarm:hover { + background: #16a34a; +} + +/* ── Anomaly History ─────────────────────────────────────────────────────────────── */ + +.anomaly-history-item { + padding: 12px 16px; + background: var(--bg-card, #1e1e2e); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + margin-bottom: 8px; + display: flex; + gap: 12px; + align-items: center; +} + +.anomaly-history-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + flex-shrink: 0; +} + +.anomaly-history-icon.unusual-hour { + background: rgba(251, 146, 60, 0.2); + color: #fb923c; +} + +.anomaly-history-icon.unknown-ble { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.anomaly-history-icon.motion-during-away { + background: rgba(220, 38, 38, 0.2); + color: #dc2626; +} + +.anomaly-history-icon.unusual-dwell { + background: rgba(248, 113, 113, 0.2); + color: #f87171; +} + +.anomaly-history-content { + flex: 1; + min-width: 0; +} + +.anomaly-history-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 4px; +} + +.anomaly-history-time { + font-size: 12px; + color: var(--text-muted, #888); +} + +.anomaly-history-score { + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + flex-shrink: 0; +} + +.anomaly-history-score.high { + background: rgba(220, 38, 38, 0.2); + color: #dc2626; +} + +.anomaly-history-score.medium { + background: rgba(251, 146, 60, 0.2); + color: #fb923c; +} + +.anomaly-history-score.low { + background: rgba(251, 191, 36, 0.2); + color: #fbbf24; +} + +.anomaly-history-feedback { + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; +} + +.anomaly-history-feedback.expected { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.anomaly-history-feedback.intrusion { + background: rgba(220, 38, 38, 0.2); + color: #dc2626; +} + +.anomaly-history-feedback.false-alarm { + background: rgba(248, 113, 113, 0.2); + color: #f87171; +} + +/* ── Zone Pulsing for Active Anomalies (3D View) ─────────────────────────────────── */ + +.anomaly-zone-pulse { + animation: zone-pulse-red 2s infinite; +} + +@keyframes zone-pulse-red { + 0%, 100% { + box-shadow: 0 0 10px rgba(220, 38, 38, 0.3); + border-color: rgba(220, 38, 38, 0.5); + } + 50% { + box-shadow: 0 0 30px rgba(220, 38, 38, 0.6); + border-color: rgba(220, 38, 38, 0.8); + } +} + +/* ── Responsive ─────────────────────────────────────────────────────────────────── */ + +@media (max-width: 600px) { + .anomaly-banner { + padding: 20px; + margin: 12px; + } + + .anomaly-actions { + flex-direction: column; + } + + .anomaly-btn { + width: 100%; + } + + .modal-content { + margin: 12px; + max-width: calc(100% - 24px); + } + + .security-dialog-stats { + grid-template-columns: 1fr; + } + + .security-dialog-actions { + flex-direction: column; + } + + .security-dialog-btn { + width: 100%; + } +} + +/* ── Dark Theme Variables (fallback) ───────────────────────────────────────────── */ + +:root { + --bg-card: #1e1e2e; + --bg-panel: #1a1a2e; + --bg-hover: #2a2a3e; + --bg-active: #3a3a4e; + --bg-input: #2a2a3e; + --text-color: #e0e0e0; + --text-muted: #888; + --border-color: #333; + --color-primary: #3b82f6; + --color-success: #22c55e; + --color-danger: #dc2626; + --color-secondary: #a78bfa; +} diff --git a/dashboard/css/sleep.css b/dashboard/css/sleep.css new file mode 100644 index 0000000..1fc3740 --- /dev/null +++ b/dashboard/css/sleep.css @@ -0,0 +1,313 @@ +/* Sleep Quality Monitoring UI */ + +/* ── Morning Summary Card ────────────────────────────────────────────────── */ + +.sleep-summary-card { + position: fixed; + top: 16px; + right: 16px; + z-index: 1000; + background: var(--bg-card, #1e1e2e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 16px; + max-width: 380px; + width: 100%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + animation: sleep-slide-in 0.3s ease-out; + color: var(--text-color, #e0e0e0); +} + +@keyframes sleep-slide-in { + from { transform: translateY(-20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.sleep-summary-card.hidden { + display: none; +} + +.sleep-summary-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color, #333); +} + +.sleep-summary-icon { + color: #a78bfa; + display: flex; + align-items: center; +} + +.sleep-summary-title { + font-weight: 600; + font-size: 14px; + flex: 1; +} + +.sleep-summary-dismiss { + background: none; + border: none; + color: var(--text-muted, #888); + font-size: 18px; + cursor: pointer; + padding: 2px 6px; + line-height: 1; + border-radius: 4px; +} + +.sleep-summary-dismiss:hover { + background: var(--bg-hover, #333); + color: var(--text-color, #e0e0e0); +} + +.sleep-summary-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sleep-summary-body > div { + font-size: 13px; + line-height: 1.5; +} + +.sleep-summary-duration { + font-weight: 600; + font-size: 18px; + margin-bottom: 4px; +} + +.sleep-efficiency-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} + +.sleep-efficiency-dot.green { background: #4ade80; } +.sleep-efficiency-dot.amber { background: #fbbf24; } +.sleep-efficiency-dot.red { background: #f87171; } + +.sleep-anomaly-warning { + color: #fbbf24; + font-weight: 500; +} + +.sleep-summary-details-btn { + margin-top: 8px; + padding: 6px 12px; + background: var(--bg-hover, #2a2a3e); + border: 1px solid var(--border-color, #444); + border-radius: 6px; + color: var(--text-color, #e0e0e0); + font-size: 12px; + cursor: pointer; + text-align: center; +} + +.sleep-summary-details-btn:hover { + background: var(--bg-active, #3a3a4e); +} + +.sleep-summary-details-btn.hidden { + display: none; +} + +.sleep-summary-anomaly.hidden { + display: none; +} + +/* ── Sleep Panel ──────────────────────────────────────────────────────────── */ + +.sleep-panel { + position: fixed; + top: 0; + right: 0; + width: 360px; + height: 100%; + background: var(--bg-panel, #1a1a2e); + border-left: 1px solid var(--border-color, #333); + z-index: 900; + display: flex; + flex-direction: column; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3); + overflow: hidden; + color: var(--text-color, #e0e0e0); +} + +.sleep-panel.hidden { + display: none; +} + +.sleep-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border-color, #333); + flex-shrink: 0; +} + +.sleep-panel-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.sleep-panel-close { + background: none; + border: none; + color: var(--text-muted, #888); + font-size: 20px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; +} + +.sleep-panel-close:hover { + background: var(--bg-hover, #333); + color: var(--text-color, #e0e0e0); +} + +.sleep-panel-content { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.sleep-panel-section { + margin-bottom: 24px; +} + +.sleep-panel-section h4 { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted, #888); + margin: 0 0 12px 0; +} + +/* ── Trends ───────────────────────────────────────────────────────────────── */ + +.sleep-trends-container { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sleep-trend-row { + display: flex; + align-items: center; + gap: 8px; +} + +.sleep-trend-label { + font-size: 12px; + color: var(--text-muted, #888); + min-width: 100px; + flex-shrink: 0; +} + +.sleep-sparkline { + flex: 1; + height: 30px; +} + +.sleep-sparkline-svg { + width: 100%; + height: 100%; +} + +.sleep-trend-value { + font-size: 12px; + font-weight: 600; + min-width: 60px; + text-align: right; + flex-shrink: 0; +} + +.sleep-week-comparison { + font-size: 12px; + color: var(--text-muted, #888); + margin-top: 8px; + padding: 8px; + background: var(--bg-card, #1e1e2e); + border-radius: 6px; + line-height: 1.5; +} + +/* ── Breathing Stats ──────────────────────────────────────────────────────── */ + +.sleep-breathing-stats { + display: flex; + gap: 16px; +} + +.sleep-stat { + flex: 1; + background: var(--bg-card, #1e1e2e); + border-radius: 8px; + padding: 12px; + text-align: center; +} + +.sleep-stat-label { + display: block; + font-size: 11px; + color: var(--text-muted, #888); + margin-bottom: 4px; +} + +.sleep-stat-value { + display: block; + font-size: 20px; + font-weight: 700; + color: #4a9eff; +} + +/* ── History ──────────────────────────────────────────────────────────────── */ + +.sleep-history { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sleep-history-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px; + background: var(--bg-card, #1e1e2e); + border-radius: 6px; + font-size: 13px; +} + +.sleep-history-date { + min-width: 90px; + color: var(--text-muted, #888); +} + +.sleep-history-duration { + flex: 1; + font-weight: 500; +} + +.sleep-history-breathing { + color: #4a9eff; + font-size: 12px; +} + +.sleep-history-empty { + text-align: center; + color: var(--text-muted, #888); + font-size: 13px; + padding: 24px 0; +} diff --git a/dashboard/index.html b/dashboard/index.html index 9247106..a267b37 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -10,6 +10,8 @@ + +