diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2c27d02..3fd5837 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,6 +27,7 @@ {"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-5a3","title":"Add Fresnel zone visualization","description":"Implement wireframe ellipsoid overlay showing Fresnel zones between active links.\n\nAcceptance: 3D visualization shows ellipsoids between communicating nodes with proper scaling.","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T14:54:38.915673399Z","created_by":"coding","updated_at":"2026-04-09T16:37:30.545014159Z","closed_at":"2026-04-09T16:37:30.544898040Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-sl2"]} {"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":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T02:00:34.796733529Z","created_by":"coding","updated_at":"2026-04-11T03:17:08.016688139Z","closed_at":"2026-04-11T03:17:08.016444040Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:6"],"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-5kf8","title":"Use FXAA instead of MSAA on mobile","description":"On screens < 1024px width, use FXAA antialiasing instead of MSAA if antialiasing is needed in renderer initialization.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T06:59:34.231285657Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.231285657Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-gufk"]} {"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":"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"]} @@ -83,12 +84,13 @@ {"id":"spaxel-g1o","title":"Anomaly detection: 7-day pattern learning algorithm","description":"## Overview\nImplement the statistical pattern learning engine for anomaly detection — per-zone, per-hour-of-day, per-day-of-week occupancy modeling using Welford's online algorithm.\n\n## Backend (mothership/analytics/ or signal/)\n- Pattern model: per (zone_id, hour_of_day, day_of_week): mean_count, variance, sample_count via Welford's algorithm\n- Hourly update goroutine: every hour, observe zone occupancy counts and update model\n- Cold start: suppress all anomaly alerts for 7 days; model slot 'ready' when sample_count >= 50\n- Anomaly scoring:\n - z_score = (observed_count - mean) / sqrt(variance + epsilon)\n - time_score = normalized z_score for this hour/day combo\n - zone_score = 1.0 if zone normally empty at this time, else 0.0\n - composite_score = max(time_score, zone_score) with fallback\n - threshold: alert if composite > 0.85; yellow warning at 0.60\n- Outlier protection: skip model update when anomaly_score >= 0.5 (don't learn from anomalies)\n- Security mode override: any detection = score 1.0 regardless of model\n- SQLite anomaly_patterns table: zone_id, hour_of_day (0-23), day_of_week (0-6), mean_count REAL, variance REAL, sample_count INT, updated_at INT\n\n## REST API\n- GET /api/anomalies?since=24h — list recent anomaly events with scores\n- GET /api/anomaly_patterns?zone= — inspect pattern model for debugging\n\n## Acceptance\n- Pattern model survives server restart (persisted to SQLite)\n- No alerts during 7-day cold start regardless of activity\n- Welford update is numerically stable: no NaN/Inf at any sample count\n- Outlier protection confirmed: injecting synthetic anomaly does not corrupt model after 3 occurrences\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:02:39.580201662Z","created_by":"coding","updated_at":"2026-04-07T01:28:23.140993262Z","closed_at":"2026-04-07T01:28:23.140700890Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-glq","title":"fix: apdetector imports wrong module prefix (jedarden vs spaxel)","description":"## Problem\n`internal/apdetector/detector.go:14` imports `github.com/jedarden/spaxel/mothership/internal/oui` but the module is `github.com/spaxel/mothership`.\n\n## Fix\nIn `mothership/internal/apdetector/detector.go` line 14, change:\n```go\n\"github.com/jedarden/spaxel/mothership/internal/oui\"\n```\nto:\n```go\n\"github.com/spaxel/mothership/internal/oui\"\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/apdetector/\n```\nMust compile with no errors.","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:29:41.749357378Z","created_by":"coding","updated_at":"2026-04-06T22:32:46.587104774Z","closed_at":"2026-04-06T22:32:46.586900234Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"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-gufk","title":"Add mobile performance optimizations","description":"On screens < 1024px width, cap devicePixelRatio at 2.0, disable or reduce shadow map quality, use FXAA instead of MSAA if needed, and optionally cap frame rate at 30fps.\n\n**Files:** dashboard/js/app.js or dashboard/js/expert.js (renderer initialization)\n\n**Acceptance Criteria:**\n- devicePixelRatio capped at 2.0 on mobile (Math.min(window.devicePixelRatio, 2.0))\n- Shadow maps disabled on mobile (or capped at 512x512)\n- FXAA used instead of MSAA on mobile if antialiasing needed\n- Frame rate optionally capped at 30fps on struggling devices","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-11T06:26:50.170090595Z","created_by":"coding","updated_at":"2026-04-11T06:51:44.645058649Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-kth"]} +{"id":"spaxel-gufk","title":"Add mobile performance optimizations","description":"On screens < 1024px width, cap devicePixelRatio at 2.0, disable or reduce shadow map quality, use FXAA instead of MSAA if needed, and optionally cap frame rate at 30fps.\n\n**Files:** dashboard/js/app.js or dashboard/js/expert.js (renderer initialization)\n\n**Acceptance Criteria:**\n- devicePixelRatio capped at 2.0 on mobile (Math.min(window.devicePixelRatio, 2.0))\n- Shadow maps disabled on mobile (or capped at 512x512)\n- FXAA used instead of MSAA on mobile if antialiasing needed\n- Frame rate optionally capped at 30fps on struggling devices","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T06:26:50.170090595Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.297897033Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-kth"],"dependencies":[{"issue_id":"spaxel-gufk","depends_on_id":"spaxel-5kf8","type":"blocks","created_at":"2026-04-11T06:59:34.248463716Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-gufk","depends_on_id":"spaxel-hruq","type":"blocks","created_at":"2026-04-11T06:59:34.297836844Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-gufk","depends_on_id":"spaxel-jv8q","type":"blocks","created_at":"2026-04-11T06:59:34.170061832Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-gufk","depends_on_id":"spaxel-z3k8","type":"blocks","created_at":"2026-04-11T06:59:34.213674698Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-h58","title":"Add dashboard identify buttons","description":"Add 'Identify' button to fleet status page that POSTs to /api/nodes/{mac}/identify. Add 'Identify (blink LED)' context menu option on right-click in 3D view.\n\n**Acceptance:**\n- Fleet status page has 'Identify' button per row\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T10:58:29.796981551Z","created_by":"coding","updated_at":"2026-04-09T11:46:58.465831688Z","closed_at":"2026-04-09T11:46:58.465691143Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-lve"],"dependencies":[{"issue_id":"spaxel-h58","depends_on_id":"spaxel-783","type":"blocks","created_at":"2026-04-09T11:11:50.023008981Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-h58","depends_on_id":"spaxel-jkw","type":"blocks","created_at":"2026-04-09T11:11:50.070094371Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"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-hruq","title":"Cap frame rate on struggling devices","description":"Optionally cap frame rate at 30fps on mobile devices that are struggling, configurable in renderer initialization.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T06:59:34.269682222Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.269682222Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-gufk"]} {"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":"closed","priority":3,"issue_type":"phase","assignee":"golf","created_at":"2026-03-27T01:55:39.902286407Z","created_by":"coding","updated_at":"2026-04-09T14:44:39.587415275Z","closed_at":"2026-04-09T14:44:39.587291675Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"],"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":""}]} {"id":"spaxel-iq3","title":"Biomechanical blob tracking with UKF","description":"## Background\n\nRaw blob detections from FusionEngine (spaxel-FUSION) are noisy — blobs appear/disappear and jump between frames. We need a filter that: persists tracks through 5-frame occlusion gaps, constrains motion to physically plausible human movement, enables identity assignment (person A vs person B), and infers posture hints for 3D visualization. The Unscented Kalman Filter (UKF) is used because the motion model is nonlinear and UKF avoids requiring a Jacobian.\n\n## What to Implement\n\nNew package: mothership/internal/tracker/\n\n### UKF State\nState vector: [x, y, z, vx, vy, vz] (6-DOF: position + velocity)\n\nProcess model: constant velocity with acceleration noise. Process noise covariance Q:\n- Position noise: sigma_pos = 0.05m (small — position doesn't jump)\n- Velocity noise: sigma_vel = 0.5 m/s per axis (humans can accelerate ~3 m/s²)\n- Z velocity noise: sigma_vz = 0.05 m/s (humans rarely move vertically fast)\n\nHuman constraints applied as post-update clamps:\n- Max speed: clamp ||v|| to 2.0 m/s\n- Z position: clamp to [0.0, 2.5m] (floor to ceiling)\n- Z velocity: clamp to [-0.5, 0.5] m/s during normal locomotion (0.2m/s for sit-down)\n\n### TrackManager\n- mothership/internal/tracker/manager.go\n- Update(blobs []fusion.BlobDetection, timestamp time.Time) []TrackState\n- Data association: Hungarian algorithm for ≤6 blobs (O(n³)), greedy nearest-neighbor for >6\n- Mahalanobis distance gating: reject assignments if distance > chi2_threshold(0.99, df=3) ≈ 11.34\n- Track lifecycle:\n - TENTATIVE: blob seen 1-2 times. Not reported in output.\n - CONFIRMED: seen ≥3 consecutive updates. Reported.\n - COASTED: no detection for 1-5 updates. UKF predict-only. Still reported.\n - DELETED: no detection for >5 updates. Removed from state.\n- Collision avoidance: if two CONFIRMED tracks within 0.5m, add a repulsion force to the weaker track\n\n### Posture inference (heuristic, no ML)\nFrom TrackState position and velocity:\n- WALKING: speed > 0.3 m/s\n- STANDING: speed < 0.1 m/s, z > 1.0m\n- SEATED: speed < 0.1 m/s, 0.3m < z < 0.8m \n- LYING: z < 0.3m (regardless of speed)\n\n### Output\nTrackState: {id string, position vec3, velocity vec3, posture Posture, confidence float32, age int (frames)}\nBroadcast via hub as 'track_update' JSON at 10Hz, same cadence as blob_update.\n\n### Integration\nFusionEngine calls TrackManager.Update() after each BlobExtractor run.\n\n## Key Files\n- mothership/internal/fusion/engine.go — call TrackManager.Update() after blob extraction\n- mothership/internal/fusion/blobs.go — BlobDetection type\n- New: mothership/internal/tracker/ukf.go, tracker/manager.go, tracker/association.go + tests\n\n## Acceptance Criteria\n- Track IDs stable across 5-frame gaps\n- Two tracks do not merge when blobs cross within 0.5m\n- Posture WALKING fires when speed > 0.3 m/s\n- TENTATIVE tracks not included in track_update output\n- Hungarian assignment correct for 4-blob test case\n- go test ./internal/tracker/... passes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T03:30:50.410975161Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.222736024Z","closed_at":"2026-03-28T05:36:26.222463200Z","close_reason":"Implemented: tracker/tracker.go + tracker/ukf.go (59404aa) — 6-DOF UKF, human motion constraints, TrackManager Hungarian association, Mahalanobis gating, posture inference (walking/standing/seated/lying)","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-iq3","depends_on_id":"spaxel-6th","type":"blocks","created_at":"2026-03-28T03:30:50.410975161Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-itf","title":"Implement image serving endpoint","description":"## Task\nImplement GET /api/floorplan/image endpoint.\n\n## Specification\n- Serve the stored image from /data/floorplan/image.png\n- Return 200 with image if exists\n- Return 404 if no image\n\n## Acceptance\n- Returns 200 with image content when image.png exists\n- Returns 404 when image.png does not exist","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:51.201971868Z","created_by":"coding","updated_at":"2026-04-07T18:47:14.323279291Z","closed_at":"2026-04-07T18:47:14.323177509Z","close_reason":"Implementation verified: GET /api/floorplan/image endpoint already implemented in mothership/internal/floorplan/floorplan.go. Returns 200 with image from /data/floorplan/image.png if exists, 404 otherwise. Tests exist for both cases.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} @@ -97,6 +99,7 @@ {"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":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T01:59:32.690434334Z","created_by":"coding","updated_at":"2026-04-11T01:42:15.956630697Z","closed_at":"2026-04-11T01:42:15.956422946Z","close_reason":"All acceptance criteria met. Simple mode with progressive disclosure is complete with:\n- Auto-detection based on screen width (<768px) and user-agent\n- Card-based mobile UI with room occupancy, activity feed, alerts\n- Night mode with OLED optimization (10pm-7am)\n- Sleep summary card (6am-11am display window)\n- Mode toggle with localStorage persistence\n- All 29 tests passing","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3"],"dependencies":[{"issue_id":"spaxel-jk0","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.851714754Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-jkw","title":"Add Identify context menu to 3D view","description":"Add 'Identify (blink LED)' option to the right-click context menu in the 3D view that POSTs to /api/nodes/{mac}/identify.\n\n**Acceptance:**\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T11:11:50.047388206Z","created_by":"coding","updated_at":"2026-04-09T11:32:19.559003892Z","closed_at":"2026-04-09T11:32:19.558903935Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-h58"]} +{"id":"spaxel-jv8q","title":"Cap devicePixelRatio on mobile","description":"On screens < 1024px width, cap devicePixelRatio at 2.0 using Math.min(window.devicePixelRatio, 2.0) in renderer initialization.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T06:59:34.136495463Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.136495463Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-gufk"]} {"id":"spaxel-jxru","title":"Build fleet status page","description":"Full table view with bulk actions and camera fly-to functionality.","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-10T02:03:09.725489218Z","created_by":"coding","updated_at":"2026-04-10T09:39:43.690167347Z","closed_at":"2026-04-10T09:39:43.690013432Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4","mitosis-child","mitosis-depth:1","parent-spaxel-17u"]} {"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-04-09T23:28:13.618819456Z","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":"closed","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:43:09.899017181Z","created_by":"coding","updated_at":"2026-04-09T12:10:28.896292868Z","closed_at":"2026-04-09T12:10:28.896154010Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} @@ -144,7 +147,7 @@ {"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":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:20.030993892Z","created_by":"coding","updated_at":"2026-04-06T18:34:59.146861796Z","closed_at":"2026-04-06T18:34:59.146762203Z","close_reason":"Implemented BLE address rotation detection & identity continuity with manufacturer data fingerprinting, time+RSSI proximity heuristics, and merge confirmation. Backend includes RotationDetector, ble_device_aliases table, and REST API endpoints. Dashboard UI includes rotation icon indicator, manual merge/split UI, and alias history expandable panel.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"]} -{"id":"spaxel-s1ze","title":"Add iOS Safari safe area CSS support","description":"Add CSS environment variables for safe-area-inset to prevent content overlap with notch/home indicator on iOS devices.\n\n**Files:** dashboard/css/expert.css, dashboard/index.html (verify safe-area meta tag)\n\n**Acceptance Criteria:**\n- body has padding-top: env(safe-area-inset-top) and padding-bottom: env(safe-area-inset-bottom)\n- Hamburger menu respects env(safe-area-inset-bottom)\n- Safe-area meta tag present in index.html (viewport-fit=cover)","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-11T06:26:50.207276809Z","created_by":"coding","updated_at":"2026-04-11T06:55:15.784040435Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-kth"]} +{"id":"spaxel-s1ze","title":"Add iOS Safari safe area CSS support","description":"Add CSS environment variables for safe-area-inset to prevent content overlap with notch/home indicator on iOS devices.\n\n**Files:** dashboard/css/expert.css, dashboard/index.html (verify safe-area meta tag)\n\n**Acceptance Criteria:**\n- body has padding-top: env(safe-area-inset-top) and padding-bottom: env(safe-area-inset-bottom)\n- Hamburger menu respects env(safe-area-inset-bottom)\n- Safe-area meta tag present in index.html (viewport-fit=cover)","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-11T06:26:50.207276809Z","created_by":"coding","updated_at":"2026-04-11T07:02:52.862975215Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-kth"]} {"id":"spaxel-s60","title":"Implement presence prediction","description":"Build predictive presence modeling for Home Assistant integration.\n\nDeliverables:\n- Per-person transition probability tracking\n- Per-zone occupancy patterns\n- Time-slot based predictions\n- HA sensor exposure for predicted states\n\nAcceptance: Predictions achieve >75% accuracy at 15-minute horizon.","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-03-29T19:25:04.052115700Z","created_by":"coding","updated_at":"2026-04-09T14:25:38.711030189Z","closed_at":"2026-04-09T14:25:38.710853888Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:927","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-s70","title":"Activity timeline","description":"## Background\n\nSpaxel generates a continuous stream of events: presence detections, zone transitions, alerts, system events, learning milestones, health changes. Without a structured event stream, debugging is difficult, history is lost, and the system appears as a black box. The activity timeline is the universal event log — a chronological record of everything the system has seen. It doubles as the primary debugging interface and enables time-travel replay (an engineer can tap any timeline event and the 3D view jumps back to that moment).\n\n## Internal Event Bus\n\nNew package: mothership/internal/events/bus.go\n\nEventBus provides a typed publish-subscribe mechanism for all internal events. All subsystems publish to the bus; the timeline, automation engine, and notification module subscribe.\n\nImplementation: a simple channel-based pub/sub. Publisher side: bus.Publish(EventType, EventPayload). Subscriber side: bus.Subscribe(EventType) returns a channel. Multiple subscribers per event type are supported (fan-out).\n\nEventType enum:\n- MotionDetected, MotionCleared\n- ZoneTransition (a person crossed a portal)\n- ZoneOccupancyChanged (any occupancy change, including by anonymous tracks)\n- FallDetected, FallAcknowledged\n- NodeConnected, NodeDisconnected, NodeOTAComplete\n- BLEDeviceFirstSeen, BLEIdentityAssigned\n- WeightUpdate, DiurnalBaselineActivated\n- AnomalyDetected, AnomalyAcknowledged\n- SleepSessionStart, SleepSessionEnd\n- FeedbackSubmitted\n\nEventPayload is a typed interface. Each event type has its own concrete struct.\n\n## Timeline Storage\n\nSQLite table:\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n type TEXT NOT NULL,\n timestamp DATETIME NOT NULL,\n person_id TEXT,\n zone_id TEXT,\n data_json TEXT NOT NULL, -- full event payload as JSON\n feedback_type TEXT, -- populated by feedback loop (Phase 7)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX idx_events_timestamp ON events (timestamp DESC);\nCREATE INDEX idx_events_person ON events (person_id, timestamp DESC);\nCREATE INDEX idx_events_zone ON events (zone_id, timestamp DESC);\nCREATE INDEX idx_events_type ON events (type, timestamp DESC);\n\nTimeline subscriber: a goroutine that reads from the bus and writes to SQLite. Buffered with a 1000-event queue to avoid blocking publishers. If the queue fills: log a warning and drop the oldest (the bus is lossy for the storage subscriber, but this should never happen at normal event rates).\n\n## Dashboard Timeline Panel\n\nSidebar panel showing events in reverse-chronological order.\n\nEvent visual rendering per type:\n- MotionDetected / ZoneTransition: person avatar (coloured circle with initial) + description + timestamp + thumbs\n- FallDetected: red shield icon + \"Possible fall: [person] in [zone]\" + Acknowledge button\n- NodeConnected / NodeDisconnected: grey dot icon + \"Node [label] connected/disconnected\"\n- WeightUpdate / DiurnalBaselineActivated: green brain icon + \"Detection accuracy improved\" / \"Daily patterns activated\"\n- AnomalyDetected: orange warning icon + \"Anomaly: [description]\"\n- SleepSessionStart/End: moon icon + \"Alice went to sleep\" / \"Alice woke up\"\n\nEvent description templates (plain English, no jargon):\n- ZoneTransition: \"{person_name} walked from {from_zone} to {to_zone}\"\n- MotionDetected: \"Motion detected in {zone_name}\" (if no identity)\n- NodeDisconnected: \"Node {label} went offline — {duration} downtime\"\n- DiurnalBaselineActivated: \"System has learned {person_name}'s daily patterns. Detection accuracy improved.\"\n\nVirtualized rendering: use a virtual scroll list (render only visible items) since the timeline can have thousands of events. Implement using IntersectionObserver API for lazy loading of off-screen items.\n\nThumbs-up/down on each event: delegates to the feedback module (spaxel-3ps). Rendered as small icon buttons on the right side of each event row.\n\n## Search and Filter\n\nFilter bar above timeline:\n- Type filter: checkboxes for event categories (Presence, Zones, Alerts, System, Learning). Default: all.\n- Person filter: dropdown \"All people / Alice / Bob / Unknown\"\n- Zone filter: dropdown \"All zones / Kitchen / Bedroom / etc.\"\n- Date range: \"Today / Last 7 days / Last 30 days / Custom\"\n- Text search: fuzzy match on event description text (client-side filtering on loaded events; server-side for date-range queries)\n\nFiltered queries use the indexed columns in the events table. Return at most 500 events per page; \"Load more\" button for pagination.\n\n## Expert vs Simple Mode\n\nExpert mode: all event types visible. System events (node health, weight updates) shown as secondary (smaller text, greyed color).\n\nSimple mode: only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd (morning summary). System events hidden. This prevents \"terminal-style\" log noise from confusing non-technical users.\n\nMode is set by the current dashboard mode (expert vs simple) and passed as ?mode=expert or ?mode=simple to the API.\n\n## Tap-to-Jump (Time-Travel Coordination)\n\nWhen a timeline event is clicked (in expert mode), the dashboard emits a \"jump_to_time\" command with the event's timestamp. The time-travel replay module (Phase 8, separate bead) listens for this command and:\n1. Pauses live playback\n2. Seeks the CSI recording buffer to the event timestamp\n3. Begins replay from that point\n4. The 3D scene shows the \"replay\" state at that timestamp\n\nClicking the event also highlights it in the timeline (selected state) and shows a \"Now replaying\" chip in the timeline header.\n\n## REST API\n\nGET /api/events?since=&until=&type=&person_id=&zone_id=&limit=&mode=expert|simple\nReturns: paginated list of Event objects with all fields.\n\nGET /api/events/{id}: single event detail\nPOST /api/events/{id}/feedback: submit feedback for an event (delegates to feedback module)\n\n## Tests\n\n- Test EventBus pub/sub: publish event, verify subscriber channel receives it within 10ms\n- Test that multiple subscribers all receive the same event\n- Test timeline storage: publish 10 events of different types, verify all appear in SQLite with correct fields\n- Test search and filter: insert events for two people and two zones, query by person -> correct subset returned\n- Test time-range filtering: insert events at T-1h and T-25h; query since T-24h -> only T-1h event\n- Test virtualized rendering handles 1000+ events without layout jank (performance test in browser)\n- Test tap-to-jump emits correct timestamp to time-travel player\n- Test expert vs simple mode filter: system events excluded in simple mode\n\n## Acceptance Criteria\n\n- All event types appear in the timeline within 1 second of firing\n- Search and filter queries return correct subsets\n- Tap-to-jump coordinates with time-travel player (3D scene seeks to correct timestamp)\n- Simple mode hides system events while showing person-relevant events\n- Feedback buttons appear on each event and invoke the feedback module correctly\n- Timeline handles 10,000+ events without UI slowdown via virtualised rendering\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:54:31.341960586Z","created_by":"coding","updated_at":"2026-04-09T17:50:35.214988526Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-s70","depends_on_id":"spaxel-fu9","type":"blocks","created_at":"2026-04-09T17:50:34.875910357Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.636944347Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-oqds","type":"blocks","created_at":"2026-04-09T17:50:35.175837858Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-q99z","type":"blocks","created_at":"2026-04-09T17:50:35.214930426Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-sdu9","type":"blocks","created_at":"2026-04-09T17:50:35.064914258Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-ufg","type":"blocks","created_at":"2026-04-09T17:50:34.932418435Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-v5p2","type":"blocks","created_at":"2026-04-09T17:50:35.122067637Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-s70","depends_on_id":"spaxel-yeh","type":"blocks","created_at":"2026-04-09T17:50:34.993081489Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-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":""}]} @@ -178,7 +181,9 @@ {"id":"spaxel-xlo","title":"Create SQLite floorplan table and storage directory","description":"## Task\nCreate the floorplan table in SQLite and ensure /data/floorplan directory exists.\n\n## Schema\nSQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- /data/floorplan directory exists\n- floorplan table created in SQLite with correct schema","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:49.108738491Z","created_by":"coding","updated_at":"2026-04-07T18:21:09.020450667Z","closed_at":"2026-04-07T18:21:09.020390325Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} {"id":"spaxel-xpk","title":"Diurnal adaptive baseline: 24-hour slot learning","description":"## Overview\nExtend the EMA baseline system with per-hour-of-day slots to eliminate false positives caused by daily environmental cycles (sunlight, HVAC, temperature changes).\n\n## Backend (mothership/signal/baseline.go extension)\n- Data structure: 24 hourly slots per link per subcarrier; each slot stores amplitude blob and sample_count\n- Learning phase (7 days): accumulate motion-free CSI into hourly slots; require >=300 samples/slot to mark ready\n- Steady state: on each fusion tick, select active baseline = weighted blend of diurnal slot (if ready) + EMA fallback\n- Crossfade: over first 15 min of each hour, linearly blend from EMA to diurnal slot; after 15 min use diurnal exclusively\n- Motion-gated updates: EMA updates continue during the hourly window, improving diurnal slot over time\n- Outlier protection: skip update if deltaRMS > motion threshold (don't train on motion frames)\n- SQLite diurnal_baselines table: link_id, hour_of_day (0-23), n_sub INT, amplitude BLOB, sample_count INT, confidence REAL, updated_at INT\n\n## Dashboard visualization\n- Per-link detail panel: 24-hour polar chart (or horizontal bar chart) showing baseline amplitude variance by hour\n- 'Diurnal learning' progress indicator: 'Learning hour 14... 6/7 days'\n- Confidence color per hour: green (ready), amber (partial), red (no data)\n\n## Acceptance\n- Baseline correctly crossfades at hour boundaries (±60s)\n- Motion events during learning do not corrupt slots (outlier protection confirmed by test)\n- Polar chart renders for links with >=1 ready slot\n- No performance regression: baseline lookup remains O(1)\n- Requires: spaxel-jcc (phase 6 integration)","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-09T13:05:47.358547333Z","closed_at":"2026-04-09T13:05:47.358191247Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:138"],"dependencies":[{"issue_id":"spaxel-xpk","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T22:30:46.133690574Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-yeh","title":"Implement REST API for Events","description":"Implement GET /api/events with query parameters: since, until, type, person_id, zone_id, limit, mode. Implement GET /api/events/{id} for single event detail. Implement POST /api/events/{id}/feedback delegating to feedback module. Support pagination (max 500 events per page).\n\nAcceptance Criteria:\n- Filtered queries use indexed columns correctly\n- Time-range filtering returns correct subsets\n- Person and zone filters return correct subsets\n- Mode parameter filters system events in simple mode\n- Pagination works correctly with limit parameter","status":"in_progress","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T17:50:34.963474002Z","created_by":"coding","updated_at":"2026-04-09T19:22:47.143876950Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-s70"],"dependencies":[{"issue_id":"spaxel-yeh","depends_on_id":"spaxel-1l2j","type":"blocks","created_at":"2026-04-09T18:14:45.503859569Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-yeh","depends_on_id":"spaxel-cfxr","type":"blocks","created_at":"2026-04-09T18:14:45.447844628Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-yeh","depends_on_id":"spaxel-l5ai","type":"blocks","created_at":"2026-04-09T18:14:45.560154535Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-yvwf","title":"Fix touch event propagation from panels to canvas","description":"Prevent touch events on sidebar panels from propagating to the canvas by adding event.stopPropagation() on panel touch listeners.\n\nFiles: dashboard/js/controls.js, dashboard/css/expert.css\n\nAcceptance Criteria:\n- Touch events on sidebar panels do not propagate to the canvas (event.stopPropagation() on panel touch listeners)","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-11T06:34:56.635013412Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.359672427Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-9mkk"]} {"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-z3k8","title":"Disable shadow maps on mobile","description":"On screens < 1024px width, disable shadow maps entirely or cap shadow map resolution at 512x512 in renderer initialization.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T06:59:34.187164922Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.187164922Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-gufk"]} {"id":"spaxel-z43","title":"Implement virtual nodes","description":"Create virtual nodes within the virtual space.\n\nAcceptance:\n- Nodes can be created at specified positions\n- Nodes maintain their state within the virtual space","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-09T16:11:25.470800938Z","created_by":"coding","updated_at":"2026-04-09T16:58:55.673360656Z","closed_at":"2026-04-09T16:58:55.673218743Z","close_reason":"Implemented virtual nodes within the virtual space with state management.\n\nThe VirtualNodeStore provides:\n- CreateNode/CreateVirtualNode/CreateAPNode: Create nodes at specified positions with bounds validation\n- State persistence to disk via JSON with atomic writes\n- Thread-safe operations with mutex locking\n- Enable/disable, position updates, role changes, metadata, tags management\n- Space association with automatic node disable when bounds change\n- Conversion to/from NodeSet for simulation integration\n\nFixed a bug in the Close() function where the closed flag was incorrectly managed.\n\nAcceptance criteria met:\n✓ Nodes can be created at specified positions\n✓ Nodes maintain their state within the virtual space","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-d41"]} {"id":"spaxel-zpt","title":"Spatial context notifications with floor-plan thumbnails","description":"## Background\n\nPush notifications without context are ignored or disabled. \"Motion detected\" tells you nothing useful. \"Alice walked into the Kitchen — Bob is already there\" is genuinely interesting. \"Possible fall: Alice in Hallway — unacknowledged for 3 minutes\" demands immediate attention. The plan specifies server-side rendering of mini floor-plan thumbnails attached to notifications to provide instant spatial context without opening the app.\n\n## Server-Side Floor-Plan Renderer\n\nNew package: mothership/internal/render/floorplan.go\n\nThe renderer produces a top-down 2D PNG (300x300 pixels) showing:\n- Room outline: outer boundary of all zones as white rectangles on dark background\n- Zone fills: each zone as a semi-transparent coloured fill (zone.color at 20% opacity)\n- Zone labels: zone name in small white text at zone centroid\n- Node positions: small white circle dots\n- Person blobs: coloured circles (person.color) at their last-known position, diameter proportional to detection confidence (min 10px, max 20px)\n- Name labels: person name in white text above each blob circle, if identity is known\n- Portal planes: thin lines in purple (#a855f7)\n- Event highlight: the zone where the event occurred rendered with brighter fill and a white border\n\nRendering library: use github.com/fogleman/gg (a pure-Go 2D graphics library). Alternative: standard image/draw + image/png for maximum portability. The fogleman/gg approach is recommended for its higher-level drawing API (bezier curves, text, etc.).\n\nThe PNG must be generated within 200ms to not delay notification delivery. At 300x300 with simple geometry, this should be easily achievable.\n\nThe rendered PNG is stored as a []byte and passed to the notification delivery function. It is base64-encoded for attachment in webhook payloads or passed as a file to ntfy/Pushover APIs.\n\n## Notification Types and Triggers\n\n1. zone_enter: \"{{person_name}} entered {{zone_name}}\" — LOW priority unless security mode is active\n2. zone_leave: \"{{person_name}} left {{zone_name}}\" — LOW priority\n3. zone_vacant: \"{{zone_name}} is now empty\" — LOW priority\n4. fall_detected: \"Possible fall: {{person_name}} in {{zone_name}}\" — URGENT, always immediate\n5. fall_escalation: \"URGENT: Fall unacknowledged for 5 minutes — {{person_name}} in {{zone_name}}\" — URGENT\n6. anomaly_alert: \"Unexpected presence: {{zone_name}}\" — HIGH priority (breaks quiet hours)\n7. node_offline: \"Node {{node_label}} has gone offline\" — MEDIUM priority\n8. sleep_summary: \"Last night: {{sleep_duration}}\" — LOW priority, morning delivery\n\n## Smart Batching\n\nIf multiple LOW or MEDIUM priority events fire within a 30-second window, batch them into a single notification:\n- \"Alice entered Kitchen. Bob left Living Room.\"\n- \"2 presence events in the last 30 seconds.\"\n\nBatching rules:\n- Batch only events of the same priority level\n- Never batch URGENT events — those are always immediate\n- Never batch events involving different notification types if the combination is confusing\n- Batch counter: if more than 5 events in 30s, summarise as \"N presence events in the last minute\"\n\nBatching implementation: a 30-second window timer per notification channel. When the first LOW event fires, start the 30s timer. Accumulate events. On timer expiry: merge into one notification and deliver.\n\n## Quiet Hours\n\nUser-configurable quiet hours: from_time, to_time (e.g. \"22:00\" to \"07:00\"). Stored in SQLite notifications_config (channel, quiet_from, quiet_to, quiet_days_bitmask).\n\nDuring quiet hours:\n- LOW priority notifications are queued\n- MEDIUM priority notifications are queued\n- HIGH and URGENT notifications are delivered immediately regardless of quiet hours\n\nAt the end of quiet hours (07:00 on non-override days): deliver all queued notifications as a morning digest bundle: \"While you were asleep: [summary of queued events]\"\n\n## Delivery Channels\n\nntfy:\n- POST to https://ntfy.sh/{topic} (or self-hosted server URL)\n- Headers: Authorization: Bearer {token} (if configured), Priority: urgent/high/default/low/min\n- Body: the notification text\n- Headers: Attach: {base64_encoded_png_url} — for ntfy, attach the floor-plan as a URL if mothership is publicly accessible, or send as base64 data URL for local deployments\n\nPushover:\n- POST to https://api.pushover.net/1/messages.json\n- Fields: token, user, message, title, priority, attachment (PNG as multipart form upload)\n\nGeneric webhook:\n- POST to user-configured URL\n- Body: {\"event_type\":\"...\", \"message\":\"...\", \"person_id\":\"...\", \"zone_id\":\"...\", \"timestamp\":\"...\", \"floorplan_png_base64\":\"...\"}\n\n## Configuration UI\n\nDashboard Settings panel -> \"Notifications\" tab:\n- Delivery channel selector: None / ntfy / Pushover / Webhook\n- Channel-specific credential fields (ntfy server URL + topic + token, Pushover API key, webhook URL)\n- Test notification button: sends a test notification to verify configuration\n- Event type enable/disable toggles: per event type, can disable e.g. \"zone_enter\" while keeping \"fall_detected\" enabled\n- Quiet hours: time picker from/to, day-of-week selector\n- Smart batching toggle (default on)\n- \"Morning digest\" toggle (default on — delivers batched quiet-hours events at wake time)\n\n## Files to Create or Modify\n\n- mothership/internal/render/floorplan.go: floor-plan PNG renderer\n- mothership/internal/notifications/manager.go: NotificationManager, batching, quiet hours logic\n- mothership/internal/notifications/ntfy.go: ntfy delivery client\n- mothership/internal/notifications/pushover.go: Pushover delivery client\n- mothership/internal/notifications/webhook.go: generic webhook delivery\n- mothership/internal/dashboard/routes.go: GET/PUT /api/settings/notifications, POST /api/notifications/test\n\n## Tests\n\n- Test floor-plan renderer produces a 300x300 PNG with correct dimensions\n- Test that zone boundaries appear in the rendered PNG at correct coordinates (check pixel colors at known positions)\n- Test batching: 3 LOW events within 10s -> 1 notification; 1 URGENT event -> immediate even if batching timer is active\n- Test quiet hours gate: LOW event at 23:00 with quiet hours 22:00-07:00 -> queued; URGENT event at 23:00 -> delivered immediately\n- Test morning digest delivery: queued events are bundled and delivered at quiet_hours_end\n- Test ntfy delivery with mock HTTP server: verify correct headers and body format\n- Test webhook delivery with mock HTTP server: verify correct JSON body and base64 PNG field\n- Test test-notification endpoint fires correctly\n\n## Acceptance Criteria\n\n- Notification received via ntfy within 5 seconds of trigger event for URGENT priority\n- Floor-plan PNG correctly shows zone boundaries and person positions in the notification\n- Smart batching prevents more than one notification per 30-second window for LOW events\n- Quiet hours suppress LOW/MEDIUM notifications and queue them for morning digest\n- Fall detection and anomaly alerts always bypass quiet hours\n- Morning digest delivered correctly at quiet hours end\n- Test notification button correctly verifies channel configuration\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:48:19.528717849Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.371730406Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.371640840Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:48:23.948107860Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:48:23.975916991Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-zvb","title":"Mothership: adaptive load shedding & resource throttling","description":"## Overview\nImplement a 4-level load shedding system to keep the fusion pipeline responsive under CPU/memory pressure, especially for large fleets.\n\n## Pipeline instrumentation\n- Time each of the 8 fusion pipeline stages per iteration using time.Since()\n- Maintain 5-iteration rolling average of total iteration time (ring buffer of 5 durations)\n\n## Load shedding state machine\nLevel 0 (normal): rolling avg < 80 ms — full pipeline\nLevel 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation (~3 ms saved/iter)\nLevel 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes (~2 ms saved/iter)\nLevel 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; push rate reduction config to all nodes (10 Hz cap)\n\nRecovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level\n\n## Integration points\n- Health endpoint GET /healthz: include shedding_level (0-3) in response\n- Dashboard status bar: show 'System load: NOMINAL / LIGHT / MODERATE / HIGH'\n- WS alert when Level 3 triggered: {type: 'alert', severity: 'warning', description: 'System under load — CSI rate reduced to 10 Hz'}\n- Level 3 recovery: push config message to all nodes restoring their prior rate\n\n## Acceptance\n- Load shedding level changes logged at INFO\n- Level 3 triggers correctly when ingest channel >50% full\n- Node rate restoration confirmed after Level 3 recovery\n- Health endpoint reflects current level\n- No mutex contention from shedding logic itself (must be lock-free reads)","status":"closed","priority":2,"issue_type":"task","assignee":"hotel","created_at":"2026-04-06T13:09:29.689754824Z","created_by":"coding","updated_at":"2026-04-09T14:33:26.907490595Z","closed_at":"2026-04-09T14:33:26.907301564Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["blocked","deferred","failure-count:228"],"dependencies":[{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.124863668Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zvb","depends_on_id":"spaxel-5yq","type":"blocks","created_at":"2026-04-07T06:33:23.159852888Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 5716672..e31d3f8 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -6da01d477c1fcd68b3350166cdf6a59b7ff1addf +dfcd90b28a8c421476ceb9b731146a5b8f7afa70 diff --git a/dashboard/index.html b/dashboard/index.html index 980269b..9d727e0 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -2481,6 +2481,7 @@ border-bottom: 1px solid rgba(255, 255, 255, 0.1); overflow-x: auto; scrollbar-width: none; /* Firefox */ + padding-bottom: env(safe-area-inset-bottom); } .hamburger-tabs::-webkit-scrollbar { @@ -2494,6 +2495,7 @@ align-items: center; justify-content: center; padding: 12px 16px; + padding-bottom: max(12px, env(safe-area-inset-bottom)); min-width: 70px; min-height: 64px; background: none; diff --git a/dashboard/js/panels.js b/dashboard/js/panels.js index a7d4235..d440b87 100644 --- a/dashboard/js/panels.js +++ b/dashboard/js/panels.js @@ -337,6 +337,33 @@ modalElement.appendChild(content); + // Add touch event listeners to prevent propagation to canvas + // This prevents OrbitControls from responding to touches on the modal + modalElement.addEventListener('touchstart', function(e) { + e.stopPropagation(); + }, { passive: true }); + + modalElement.addEventListener('touchmove', function(e) { + e.stopPropagation(); + }, { passive: false }); // Non-passive to allow preventDefault if needed + + modalElement.addEventListener('touchend', function(e) { + e.stopPropagation(); + }, { passive: true }); + + // Also add to backdrop to prevent canvas touches through backdrop + modalBackdrop.addEventListener('touchstart', function(e) { + e.stopPropagation(); + }, { passive: true }); + + modalBackdrop.addEventListener('touchmove', function(e) { + e.stopPropagation(); + }, { passive: false }); + + modalBackdrop.addEventListener('touchend', function(e) { + e.stopPropagation(); + }, { passive: true }); + // Modal footer (if buttons requested) if (config.showConfirm || config.showCancel) { const footer = document.createElement('div');