From 008d3caa60239b4c9272fdc2f51d98700a7a2793 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 7 Apr 2026 14:20:00 -0400 Subject: [PATCH] feat: fix floorplan table schema and create /data/floorplan directory - Fix migration 001 floorplan schema: use distance_m instead of cal_distance_m, and rotation_deg instead of room_bounds_json - Update migration 010 to ALTER existing floorplan tables for databases that already ran migration 001 - Create /data/floorplan directory in db.OpenDB for storing floor plan images --- .beads/issues.jsonl | 7 +-- .needle-predispatch-sha | 2 +- mothership/internal/api/zones.go | 71 +++++++++++++++++++++++++++- mothership/internal/dashboard/hub.go | 53 +++++++++++++++++++++ mothership/internal/db/db.go | 6 +++ mothership/internal/db/migrations.go | 45 ++++++++++-------- tests/e2e/run.sh | 19 ++++++-- 7 files changed, 173 insertions(+), 30 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4617706..0f32f83 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -18,7 +18,7 @@ {"id":"spaxel-51k","title":"OTA firmware update system","description":"## Background\n\nOnce nodes are deployed in a home, they need to be updated without physical access. ESP-IDF has a mature OTA (Over-The-Air) update mechanism: two OTA flash partitions (factory, ota_0, ota_1 as defined in firmware/partitions.csv), HTTP download to the inactive partition, cryptographic verification, set boot partition, reboot. The mothership serves firmware binaries and triggers the update via a WebSocket downstream command. Phase 1 laid the groundwork in the firmware; this bead completes the mothership side.\n\n## What Already Exists\n\nfirmware/main/websocket.c has OTA command handling and an ota_download_task that handles the HTTP download to the inactive OTA partition. The partition table in firmware/partitions.csv has factory + ota_0 + ota_1 slots. The firmware parses {type:\"ota\", url:\"...\", md5:\"...\", version:\"...\"} downstream commands and initiates the download. What is missing is:\n- The mothership HTTP server for firmware binary serving\n- The REST API for triggering OTA per-node or fleet-wide\n- The firmware manifest for version management\n- The rollback detection logic on the mothership side\n- The dashboard UI for OTA management\n\n## Mothership Firmware Serving\n\nGET /firmware/latest or GET /firmware/{version}: serves the compiled .bin file from the /firmware volume mount. The response must include:\n- Content-Length header (required by ESP-IDF OTA HTTP client for progress reporting)\n- ETag header (MD5 of the binary, for caching)\n- Content-Type: application/octet-stream\n\nFirmware binaries are placed in the /firmware volume mount (configured in docker-compose.yml or k8s volume mount). The mothership reads the firmware manifest on startup and re-reads it when a new file appears (inotify watch or periodic re-scan every 60s).\n\n## Firmware Manifest\n\nFile: /firmware/manifest.json — auto-generated by the CI build process, or manually created.\nFormat: list of objects, each with version (semver string), filename (basename within /firmware/), md5 (hex string of binary MD5), size (integer bytes), build_timestamp (ISO8601).\nThe mothership's \"latest\" version is determined by sorting manifest entries by semver and taking the highest.\n\n## OTA Trigger API\n\nPOST /api/nodes/{mac}/ota — trigger OTA for a specific node\nRequest body: {\"version\": \"0.2.0\"} (optional; defaults to latest if omitted)\nResponse: {\"job_id\": \"abc123\", \"node_mac\": \"aa:bb:cc:dd:ee:ff\", \"target_version\": \"0.2.0\", \"status\": \"initiated\"}\n\nThe mothership looks up the node's active WebSocket session in the connection registry and sends the OTA command:\n{\"type\":\"ota\",\"url\":\"http://{mothership_ip}:{port}/firmware/0.2.0\",\"md5\":\"{hex_md5}\",\"version\":\"0.2.0\"}\n\nThe firmware immediately begins the download in a background task, sends ota_status messages ({\"type\":\"ota_status\",\"progress\":45,\"status\":\"downloading\"}) which the mothership logs and broadcasts to the dashboard.\n\n## Fleet Rolling Update\n\nPOST /api/ota/fleet — trigger OTA for all connected nodes\nRequest body: {\"version\": \"0.2.0\", \"stagger_seconds\": 30} (default stagger: 30s)\n\nThe rolling update coordinator in the mothership triggers OTA for the first node, waits stagger_seconds, then the next, and so on. This ensures:\n- Not all nodes reboot simultaneously (avoids a coverage gap window)\n- If a node fails OTA, the remaining nodes can be halted before more disruption\n- Fleet update progress is visible in dashboard per-node\n\nThe fleet update job is stored in SQLite (ota_jobs table) and survives mothership restarts.\n\n## Rollback Detection\n\nESP-IDF automatically rolls back to the previous firmware if the new image does not call esp_ota_mark_app_valid_cancel_rollback() within a boot window (the firmware does this on successful WebSocket connection to the mothership). The mothership detects rollback by comparing the firmware_version field in the hello message after OTA against the requested target version. If they differ, the mothership logs an OTA rollback event and updates the node's status to \"rollback\".\n\n## Dashboard OTA UI\n\nAdd an OTA panel to the dashboard settings or fleet page:\n- Per-node: current firmware version, available version (if newer), \"Update\" button\n- Fleet: \"Update All\" button with stagger slider, progress per node (with percentage from ota_status messages), last updated time per node\n- Version history: per-node firmware version history in tooltip or expandable row\n- Rollback indicator: nodes that rolled back are highlighted with a warning and the reason (if known)\n\n## Files to Create or Modify\n\n- mothership/internal/ota/server.go: firmware file serving with Content-Length and ETag\n- mothership/internal/ota/manifest.go: manifest parsing and latest-version logic\n- mothership/internal/ota/jobs.go: OTA job creation, fleet rolling update coordinator, status tracking\n- mothership/internal/dashboard/routes.go: register OTA API routes\n- dashboard/js/fleet.js or dashboard/js/ota.js: OTA UI panel\n\n## Tests\n\n- Test OTA command JSON serialisation matches firmware's expected format exactly\n- Test rolling update stagger timing with a mock time source (use a clock interface for testability)\n- Test that firmware version in hello message is parsed and stored in the node registry\n- Test manifest parsing: valid manifest, empty manifest, malformed manifest\n- Test rollback detection when hello version does not match target version\n\n## Acceptance Criteria\n\n- OTA command reaches firmware and triggers download (verified via ota_status messages)\n- Rolling update staggers correctly with the configured delay between nodes\n- After successful OTA, node reconnects with new firmware version in hello message\n- Rollback is detectable via hello version mismatch and displayed in dashboard\n- MD5 verification failure in firmware logs an error and the old firmware remains running\n- Fleet update status visible per-node in dashboard\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:37:32.472078279Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.250035631Z","closed_at":"2026-03-28T05:36:39.249972673Z","close_reason":"Implemented: ota/manager.go + ota/server.go (fb69190) — HTTP firmware serving from /firmware volume, WebSocket-triggered OTA command, rolling update with 30s stagger, MD5 verification, firmware manifest.json, rollback detection via hello version mismatch","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-51k","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.874999678Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-54i","title":"load-shedding: add per-iteration timing and rolling avg to ProcessorManager","description":"## Task\nAdd pipeline timing and a 5-iteration rolling average to `ProcessorManager` in `mothership/internal/signal/processor.go`.\n\n## Changes to ProcessorManager struct (lines 221-228)\nAdd these fields:\n```go\ntype ProcessorManager struct {\n // ... existing fields ...\n iterDurations [5]time.Duration // ring buffer for last 5 iteration times\n iterIdx int // next write index (mod 5)\n iterCount int // how many values filled (0-5)\n shedLevel int // current load shedding level (0-3)\n steadyCount int // consecutive iters below recovery threshold\n}\n```\n\n## Changes to LinkProcessor.Process() (line 54)\nWrap the entire Process body to time it. At the START of Process():\n```go\nt0 := time.Now()\n```\nAt the END of Process(), before return, update the manager's ring buffer. BUT — Process is on LinkProcessor, not ProcessorManager. So instead, add timing to ProcessorManager.Process() (line 252):\n\n```go\nfunc (pm *ProcessorManager) Process(linkID string, ...) (*ProcessResult, error) {\n t0 := time.Now()\n // ... existing lock + delegate to processor ...\n result, err := processor.Process(payload, rssiDBm, nSub, recvTime)\n pm.mu.Unlock() // already have write lock\n elapsed := time.Since(t0)\n pm.updateShedding(elapsed)\n return result, err\n}\n```\n\n## New method updateShedding(elapsed time.Duration)\n```go\nfunc (pm *ProcessorManager) updateShedding(elapsed time.Duration) {\n pm.iterDurations[pm.iterIdx%5] = elapsed\n pm.iterIdx++\n if pm.iterCount < 5 { pm.iterCount++ }\n\n // compute rolling avg\n var sum time.Duration\n for i := 0; i < pm.iterCount; i++ {\n sum += pm.iterDurations[i]\n }\n avg := sum / time.Duration(pm.iterCount)\n\n // level up\n if avg >= 95*time.Millisecond && pm.shedLevel < 3 {\n pm.shedLevel = 3; pm.steadyCount = 0\n } else if avg >= 90*time.Millisecond && pm.shedLevel < 2 {\n pm.shedLevel = 2; pm.steadyCount = 0\n } else if avg >= 80*time.Millisecond && pm.shedLevel < 1 {\n pm.shedLevel = 1; pm.steadyCount = 0\n }\n\n // recovery: step down one level when avg < 60ms for 10 consecutive iters\n if avg < 60*time.Millisecond {\n pm.steadyCount++\n if pm.steadyCount >= 10 && pm.shedLevel > 0 {\n pm.shedLevel--\n pm.steadyCount = 0\n }\n } else {\n pm.steadyCount = 0\n }\n}\n```\n\n## New getter\n```go\nfunc (pm *ProcessorManager) GetShedLevel() int {\n pm.mu.RLock()\n defer pm.mu.RUnlock()\n return pm.shedLevel\n}\n```\n\nNote: `updateShedding` must NOT hold `pm.mu` because it's called after unlock. The iterDurations ring buffer is only written from `Process` so it is already serialized by the caller's lock sequence. Add a separate `mu` for the shed state or call updateShedding while still holding pm.mu — simplest: call it BEFORE Unlock, while still holding the write lock.\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n```","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-07T06:33:03.697771676Z","created_by":"coding","updated_at":"2026-04-07T16:53:42.209613205Z","closed_at":"2026-04-07T16:53:42.209404722Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-5es","title":"Ambient dashboard mode","description":"## Background\n\nA wall-mounted tablet showing who is home is a genuinely useful home appliance — a digital replacement for the \"whiteboard on the fridge\" that families have used for decades. The ambient mode is designed for exactly this use case: always on, minimal information density, visually calm, and unobtrusive. It should run for weeks without user interaction, surviving screen timeouts, browser updates, and mothership restarts. When something important happens (fall, security alert), it breaks the calm decisively to get attention.\n\n## Route and Renderer\n\nNew route: /ambient (separate from /simple and expert mode)\nThe user can set a specific device as \"ambient display\" via a browser bookmark or home screen shortcut.\n\nCanvas 2D renderer (not Three.js): the ambient mode uses a dedicated Canvas 2D rendering engine rather than Three.js. Reasons:\n1. Three.js is designed for 3D; the ambient view is a 2D top-down floor plan\n2. Canvas 2D uses significantly less GPU memory and power — important for always-on tablet use\n3. Simpler code path means fewer failure modes for long-running display\n4. Lower battery drain on iPad and Android tablets\n\nRenderer: dashboard/js/ambient_renderer.js. Renders via requestAnimationFrame at 2 Hz (one frame every 500ms). The 2 Hz update rate is intentional: it's visually smooth enough for a presence display and uses minimal CPU.\n\n## Rendered Elements\n\nBackground: colour depends on time of day (see Time-of-Day Palette below).\n\nRoom outline: 2D rectangles for each zone's bounding box (projected to floor plane). White (#ffffff) with 1px stroke, no fill (transparent interior). Zone labels: zone name in white text at centroid, 14px medium font.\n\nPortal lines: thin lines (0.5px, #a855f7 purple) across doorways.\n\nNode positions: small filled circles (radius 4px, #6b7280 grey, subtle).\n\nPerson blobs: filled circles (radius 10-18px) in person.color. Name label above: first name only, 12px white. Blob radius proportional to identity confidence: full size if confident, slightly smaller if low confidence. If anonymous: a ghost/outline circle (stroke only, no fill) in light grey.\n\nSystem status indicator: top-left corner. Small circle (8px radius): green (#22c55e) if all nodes healthy, amber (#f59e0b) if any node degraded, red (#ef4444) if any node offline. No text — just the dot. Tooltip on hover (for the rare touch-and-hold interaction).\n\nTime display: top-right. Current time in large readable font. 28px, #ffffff, tabular-nums font variant for clean time display without layout shifts.\n\nPerson positions: lerp-interpolated between WebSocket updates for smooth movement at the 2 Hz render rate. On each WebSocket message received, update the target position. On each render frame, move each person blob 20% of the remaining distance to the target position (exponential approach = naturally decelerating animation).\n\n## Auto-Dim\n\nWhen no person is detected in the room where the ambient tablet is physically located (a zone that the user has configured as the \"ambient display zone\"), reduce the canvas brightness after 60 seconds of no detection:\n- After 60s: reduce canvas globalAlpha to 0.4 (40% brightness)\n- Restore immediately when presence detected in the ambient zone again\n\nImplementation: Use the CSS filter brightness() property on the canvas element, animated with a CSS transition. Set `canvas.style.filter = 'brightness(0.4)'` after timeout. This approach correctly dims including text labels.\n\nOptionally: if the device supports it (iOS WKWebView + Power Saving APIs), the ambient mode can request Screen Wake Lock API to prevent the tablet display from sleeping. window.navigator.wakeLock.request('screen'). Re-request on visibility change (the lock is released when the page is hidden).\n\n## Alert Mode\n\nWhen a fall or security alert fires (FallDetected or AnomalyDetected event received via WebSocket):\n1. The Canvas render loop detects the alert event\n2. Full canvas background fades to #dc2626 (urgent red) over 500ms\n3. Large white text in the centre: \"FALL DETECTED — Alice\" or \"ALERT — Motion while away\"\n4. Pulsing animation: canvas background alternates between #dc2626 and #991b1b at 1 Hz\n5. Acknowledge button rendered as a large white rectangle in the centre below the text\n6. Tap/click on the acknowledge button: POST /api/fall/{id}/acknowledge or /api/anomalies/{id}/acknowledge. Returns to normal ambient mode.\n\nThis alert mode must be clearly visible from across the room — the text should be at least 48px on a typical 10-inch tablet.\n\n## Morning Briefing Overlay\n\nFirst time presence is detected after 6am (configurable): the ambient display shows a brief overlay card for 15 seconds:\n- Overlaid on the normal floor plan (dark semi-transparent background)\n- Sleep summary (if available): \"Alice: 7h 23m, good sleep\"\n- Today's expected departures (from presence prediction, Phase 7): \"Alice likely leaves at 8:30am\"\n- System status: \"4 nodes healthy\"\n\nAfter 15 seconds: fade out and return to normal ambient view. Tapping the overlay dismisses it immediately.\n\n## Time-of-Day Palette\n\nThe ambient canvas background colour shifts with the time of day to be visually appropriate:\n- 06:00-12:00 (morning): light blue-grey (#f0f4f8, near white) — cool morning light feel\n- 12:00-18:00 (afternoon): neutral grey (#1e293b, dark) — reduces eye strain in bright rooms\n- 18:00-22:00 (evening): warm amber-grey (#1c1507, very dark warm) — matches evening lighting\n- 22:00-06:00 (night): near black (#040404) — OLED-friendly, minimal light\n\nTransitions: smooth CSS gradient transition over 30 minutes at each boundary (not instant). Implemented by pre-computing the target colour for the next 30 minutes and using CSS linear-gradient + keyframe animation.\n\n## Performance\n\nTarget: < 5% CPU usage on a 2016-era iPad (A9 chip). Achieved by:\n- 2 Hz render rate instead of 60 Hz (30x reduction in GPU/CPU work)\n- No Three.js, WebGL, or shader compilation overhead\n- Canvas 2D is hardware accelerated but lightweight\n- Blob count in a home is typically 1-4 — trivial to render\n\nMemory: the ambient page should have < 50MB JS heap. No large textures, no complex geometry.\n\n## Files to Create or Modify\n\n- dashboard/ambient.html: minimal HTML shell\n- dashboard/js/ambient.js: main ambient mode logic, WebSocket connection, alert handling\n- dashboard/js/ambient_renderer.js: Canvas 2D rendering engine\n- dashboard/js/ambient_briefing.js: morning briefing overlay\n- mothership/internal/dashboard/routes.go: /ambient route served statically\n\n## Tests\n\n- Test Canvas 2D renderer draws correct shapes: zone rectangle at (1,1)-(3,3) appears as a white rectangle at the correct pixel coordinates (given known canvas size and room dimensions)\n- Test auto-dim timer: mock 60s with no presence event, verify canvas brightness is reduced\n- Test auto-dim restore: presence event arrives, verify brightness returns to 100%\n- Test alert mode: inject FallDetected event, verify canvas background changes to red and text appears\n- Test acknowledge clears alert mode and returns to normal\n- Test morning briefing overlay appears only once after 6am (localStorage flag set)\n- Test lerp interpolation: person position updates from (1,1) to (3,3), after 5 render frames should be approximately (2.5, 2.5) (with 20% step lerp)\n\n## Acceptance Criteria\n\n- Ambient mode runs for 7 days without page reload (no memory leaks, no uncaught exceptions)\n- Auto-dim activates after 60 seconds of no presence in the display zone\n- Fall/anomaly alert mode clearly visible from 3 metres away on a 10-inch tablet\n- Acknowledge button works and returns to normal ambient\n- Morning briefing overlay appears once per day, dismisses after 15s\n- Canvas 2D rendering consumes < 5% CPU on a mid-range tablet\n- Time-of-day palette transitions are smooth (no hard cuts)\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:00:34.796733529Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.888767875Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-5es","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.888731706Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-5lo","title":"Implement Zones CRUD REST endpoints with OpenAPI docs","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Include OpenAPI-style godoc comments. Acceptance: endpoints respond correctly to HTTP requests, godoc annotations present.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-07T17:01:33.493352900Z","created_by":"coding","updated_at":"2026-04-07T17:01:33.493352900Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-0ii"]} +{"id":"spaxel-5lo","title":"Implement Zones CRUD REST endpoints with OpenAPI docs","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Include OpenAPI-style godoc comments. Acceptance: endpoints respond correctly to HTTP requests, godoc annotations present.","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:01:33.493352900Z","created_by":"coding","updated_at":"2026-04-07T18:13:38.639619498Z","closed_at":"2026-04-07T18:13:38.639505434Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-0ii"]} {"id":"spaxel-5yq","title":"load-shedding: health endpoint + dashboard WS alert integration","description":"## Task\nExpose the load shedding level in the health endpoint and send a dashboard WS alert when Level 3 is triggered. Requires spaxel-54i to be complete (GetShedLevel() must exist).\n\n## 1. Health endpoint (mothership/cmd/mothership/main.go)\nFind the `/healthz` handler (around line 218). Change the JSON to include `shedding_level`:\n```go\nfmt.Fprintf(w, `{\"status\":\"ok\",\"version\":\"%s\",\"shedding_level\":%d}`, version, pm.GetShedLevel())\n```\n\n## 2. Dashboard WS alert on Level 3\nIn the fusion loop in main.go (the goroutine that calls `pm.Process()`), track the previous shed level and broadcast an alert when it changes to 3 or recovers:\n```go\n// after pm.Process() call:\nnewLevel := pm.GetShedLevel()\nif newLevel != prevShedLevel {\n if newLevel == 3 {\n msg := map[string]interface{}{\n \"type\": \"alert\",\n \"severity\": \"warning\",\n \"description\": \"System under load — CSI rate reduced to 10 Hz\",\n }\n data, _ := json.Marshal(msg)\n dashboardHub.Broadcast(data)\n }\n prevShedLevel = newLevel\n log.Printf(\"[INFO] Load shedding level changed: %d\", newLevel)\n}\n```\nDeclare `prevShedLevel int` before the fusion goroutine.\n\n## 3. Level 3 rate reduction push (best effort — log only if push mechanism not yet available)\nWhen `newLevel == 3`, log: `log.Printf(\"[INFO] Load shed level 3 — would push 10Hz cap to nodes\")`\nWhen `newLevel` recovers from 3, log: `log.Printf(\"[INFO] Load shed recovered — restoring prior node rate\")`\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./...\n# curl http://localhost:8080/healthz should include shedding_level\n```\n\nRequires: spaxel-54i","status":"in_progress","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T06:33:19.278442007Z","created_by":"coding","updated_at":"2026-04-07T17:36:16.122193297Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"],"dependencies":[{"issue_id":"spaxel-5yq","depends_on_id":"spaxel-54i","type":"blocks","created_at":"2026-04-07T06:33:23.206754212Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-65k","title":"Dashboard: activity timeline view","description":"## Overview\n\nBuild the unified activity timeline — the primary event history UI, accessible via the #timeline route added in the dashboard framework bead.\n\n## What to build (dashboard/js/timeline.js + timeline.css)\n\n### Timeline sidebar\n- Scrollable chronological event list (newest at top)\n- Event types with distinct icons/colors:\n - Zone entry/exit (green/orange)\n - Portal crossing (blue arrow)\n - Anomaly / security alert (red pulse)\n - Learning milestone (purple star)\n - System event (grey gear)\n- Each event shows: timestamp, description, person name (if identified), zone name\n- Click event → jump to that moment in the 3D view (triggers replay seek to that timestamp)\n\n### Filter bar\n- Filter by: person, zone, event type, time range (today / last 7d / custom)\n- Search box with debounced text filter across event descriptions\n\n### Inline feedback\n- Thumbs up / thumbs down on presence detection events\n- POST /api/feedback with event_id and correct (bool)\n- System response toast: 'Thanks — threshold adjusted for kitchen link'\n\n### Data source\n- Initial load: GET /api/events?limit=200&since=24h\n- Live updates: 'event' messages from WebSocket feed (requires spaxel-9eg)\n\n## Acceptance\n\n- 200 events render within 200ms of page load\n- New events prepend without layout shift\n- Clicking an event in replay mode seeks the replay to ±5s around the event\n- Feedback buttons POST successfully and show toast confirmation","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:56:03.195915329Z","created_by":"coding","updated_at":"2026-04-06T16:01:48.118589901Z","closed_at":"2026-04-06T16:01:48.118470381Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-6ha","title":"Complete REST API: settings, zones, portals, triggers, notifications, replay","description":"## Problem\n\nMany HTTP endpoints are stubbed or missing. The dashboard settings panel, automation builder, and replay UI all require working REST endpoints.\n\n## Endpoints to implement in mothership/\n\n### Settings\n- GET /api/settings — return all configurable settings as JSON\n- POST /api/settings — update settings (partial update, merge semantics)\n\n### Zones & Portals\n- GET /api/zones — list all zones\n- POST /api/zones — create zone\n- PUT /api/zones/{id} — update zone geometry/name\n- DELETE /api/zones/{id} — delete zone\n- GET /api/portals — list all portals\n- POST /api/portals — create portal\n- PUT /api/portals/{id} — update\n- DELETE /api/portals/{id} — delete\n\n### Automation Triggers\n- GET /api/triggers — list all triggers\n- POST /api/triggers — create trigger\n- PUT /api/triggers/{id} — update\n- DELETE /api/triggers/{id} — delete\n- POST /api/triggers/{id}/test — fire trigger once for testing\n\n### Notifications\n- GET /api/notifications/config — get delivery channel config\n- POST /api/notifications/config — set Ntfy/Pushover/webhook settings\n- POST /api/notifications/test — send a test notification\n\n### Replay / Time-Travel\n- GET /api/replay/sessions — list available recording sessions\n- POST /api/replay/start — start replay at given timestamp\n- POST /api/replay/stop — stop replay, return to live\n- POST /api/replay/seek — seek to timestamp within session\n- POST /api/replay/tune — update pipeline parameters mid-replay\n\n### BLE Devices\n- GET /api/ble/devices — list known devices\n- PUT /api/ble/devices/{mac} — set label, assign to person\n\n## Acceptance\n\n- All endpoints return JSON with appropriate status codes\n- Settings endpoint persists to SQLite across restarts\n- Zone/portal CRUD reflected in the live 3D view within one WebSocket cycle\n- OpenAPI-style godoc comment on each handler with method, path, request, response","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T12:55:51.683246046Z","created_by":"coding","updated_at":"2026-04-06T12:55:51.683246046Z","source_repo":".","compaction_level":0,"original_size":0} @@ -28,7 +28,7 @@ {"id":"spaxel-72t","title":"Provisioning payload and NVS write","description":"## Background\n\nWhen a new node is provisioned, it needs WiFi credentials (SSID + password) and a unique node ID (for use in the hello message and as a persistent identifier). The provisioning payload is assembled by the mothership and sent to the firmware over serial during onboarding, or can also be sent over the WebSocket after reconnect. Getting this right is foundational to the security and identity model of the entire system.\n\n## Why Mothership-Generated Node IDs?\n\nRather than generating a random ID on device, having the mothership assign node IDs allows it to: track provisioned-but-never-connected nodes for inventory management, support re-provisioning with ID continuity (same physical device gets same ID after factory reset), prevent ID collisions in multi-node deployments, and maintain a token-based security model where only provisioned nodes can connect.\n\n## NVS Schema\n\nThe firmware NVS schema is defined in firmware/main/spaxel.h (schema version 1). Keys and types:\n- wifi_ssid (string, max 32 chars)\n- wifi_pass (string, max 64 chars)\n- node_id (uint16, assigned by mothership)\n- node_token (string, 12-char hex, assigned by mothership)\n- mothership_host (string, empty = use mDNS)\n- mothership_port (uint16, default 8080)\n- role (uint8, 0=rx, 1=tx, 2=tx-rx, 3=passive)\n- sample_rate (uint16, default 20 Hz)\n- schema_ver (uint8, current = 1)\n\n## Mothership API\n\nPOST /api/provision\nRequest body: {\"ssid\": \"MyWifi\", \"password\": \"secret\", \"label\": \"Living Room Node\"}\nResponse: {\"node_id\": 42, \"provision_token\": \"a3f7b2c1d8e9\", \"config_blob\": \"{...json...}\"}\n\nThe config_blob is a JSON string encoding all NVS keys listed above. It is passed verbatim to the firmware over serial or WebSocket. The firmware parses it, writes each key to NVS, and reboots.\n\nExample config_blob:\n{\"wifi_ssid\":\"MyWifi\",\"wifi_pass\":\"secret\",\"node_id\":42,\"node_token\":\"a3f7b2c1d8e9\",\"mothership_host\":\"\",\"mothership_port\":8080,\"role\":0,\"sample_rate\":20,\"schema_ver\":1}\n\n## Firmware Provisioning Handling\n\nTwo provisioning paths:\n\n1. Serial provisioning (onboarding wizard path): Before WiFi is connected, the firmware listens on UART0 (115200 baud) for a JSON line starting with {\"provision\":. On receipt, write to NVS and reboot. This path works even before WiFi credentials are configured.\n\n2. WebSocket provisioning (re-provisioning path): A new downstream command type \"provision\" alongside existing role/config/ota/reboot in firmware/main/websocket.c. Allows the mothership to update credentials or reset a node over the air. This is useful for credential rotation without physical access.\n\n## Security Model\n\nThe provision_token is used as a bearer token in the WebSocket Authorization header (Authorization: Bearer ) for all subsequent connections. The mothership validates the token on every connection attempt against the provisioned_nodes SQLite table. Nodes without a valid token receive a {type:\"reject\"} downstream message and the connection is closed.\n\nToken format: 12 lowercase hex characters (48 bits of entropy). Generated server-side using crypto/rand. Not derivable from node_id or MAC address.\n\n## SQLite Storage\n\nAdd a provisioned_nodes table to the mothership SQLite database:\nCREATE TABLE provisioned_nodes (\n node_id INTEGER PRIMARY KEY,\n mac TEXT,\n token TEXT NOT NULL,\n label TEXT,\n provisioned_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n first_seen DATETIME,\n last_seen DATETIME,\n firmware_version TEXT,\n current_role INTEGER DEFAULT 0\n);\n\nThe mac field is populated when the node first connects and sends its hello message. Before first connection, mac is NULL (node is provisioned but not yet seen).\n\n## Implementation Location\n\n- mothership/internal/provision/handler.go: POST /api/provision handler\n- mothership/internal/provision/store.go: SQLite CRUD for provisioned_nodes\n- mothership/internal/ingestion/auth.go: WebSocket token validation on connection\n- firmware/main/websocket.c: add \"provision\" downstream command type\n- firmware/main/wifi.c: add serial JSON provisioning path on UART0 (pre-WiFi)\n\n## Re-provisioning\n\nIf a node already exists in provisioned_nodes (matched by mac), the mothership can re-provision it with a new token. The old token is invalidated immediately. The new config_blob is sent via the existing WebSocket connection (if online) or over serial (if physically accessible). This handles: WiFi password changes, mothership IP changes, node relabelling.\n\n## Tests\n\n- Test that POST /api/provision returns valid config_blob containing all required NVS keys\n- Test that node_id is unique and increments correctly\n- Test that token validation rejects connections with unknown tokens\n- Test that token validation rejects connections with expired/rotated tokens\n- Test NVS serialisation round-trip: parse config_blob back to NVS key-value map and verify all values\n- Test that a second provision for the same MAC updates rather than duplicates the record\n\n## Acceptance Criteria\n\n- Provisioned node connects to mothership successfully with the assigned node_id and token\n- Token validation correctly rejects unprovisioned connection attempts with {type:\"reject\"}\n- Node label stored and returned via GET /api/nodes in the node list\n- Re-provisioning updates token and invalidates old token within one round-trip\n- config_blob contains all required NVS keys with correct types\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:36:54.067220841Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.268631627Z","closed_at":"2026-03-28T05:36:39.268468107Z","close_reason":"Implemented: provisioning/server.go (fb69190) + firmware/main/provision.c/h (fb69190) — POST /api/provision generates node_id+token+config_blob, UART serial provisioning window on ESP32, NVS write, provisioned_nodes SQLite table with token validation","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-72t","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.844135515Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-7nk","title":"fix: sleep/handler.go cannot index interface{} map value","description":"## Problem\n`internal/sleep/handler.go` lines 229 and 232 fail with: `cannot index result[\"metrics\"] (map index expression of type interface{})`\n\nThe `result` map is of type `map[string]interface{}`, so `result[\"metrics\"]` returns `interface{}`, which cannot be directly indexed.\n\n## Fix\nAdd a type assertion before indexing. Around lines 229-232 in `internal/sleep/handler.go`:\n```go\n// Before the two if-blocks, get a typed reference:\nif metricsMap, ok := result[\"metrics\"].(map[string]interface{}); ok {\n if !metrics.SleepStartTime.IsZero() {\n metricsMap[\"sleep_start_time\"] = metrics.SleepStartTime.Format(\"15:04\")\n }\n if !metrics.SleepEndTime.IsZero() {\n metricsMap[\"sleep_end_time\"] = metrics.SleepEndTime.Format(\"15:04\")\n }\n}\n```\n\n## Verify\n```bash\ncd /home/coding/spaxel/mothership && PATH=$PATH:/home/coding/go/bin go build ./internal/sleep/\n```","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:05.128489582Z","created_by":"coding","updated_at":"2026-04-06T22:40:47.430043249Z","closed_at":"2026-04-06T22:40:47.429779459Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-7qo","title":"Dashboard: WebSocket reconnection with exponential backoff and state management","description":"## Overview\nImplement robust client-side WebSocket reconnection with exponential backoff, jitter, and visual state transitions during disconnects.\n\n## Reconnection logic (dashboard/js/websocket.js or app.js)\n- Backoff sequence: 1s, 2s, 4s, 8s, max 10s; ±500ms random jitter on each attempt\n- Track disconnect_duration_ms from first disconnect event\n\n## Visual state transitions:\nDisconnect < 5s: silent (no UI change); blob positions extrapolated from last velocity\nDisconnect 5-30s: 3D scene dims to 50% opacity; 'Reconnecting...' spinner in status bar; user interaction disabled\nDisconnect > 30s: non-blocking modal: 'Connection lost — [Reload Page]'; allow viewing stale scene\n\n## Blob position extrapolation (<5s):\n- On disconnect: record last_position and last_velocity per blob\n- Each animation frame: position = last_position + last_velocity × elapsed_s (capped at 2s extrapolation)\n\n## On successful reconnect:\n- Clear all blob trails (path history lines)\n- Apply snapshot from first WebSocket message (spaxel-fll)\n- Restore scene opacity to 100%\n- Dismiss spinner and modal\n- Log 'Reconnected after Xs' to console\n\n## Acceptance\n- Disconnect for 3s: no visual change; blobs continue moving smoothly\n- Disconnect for 10s: scene dims, spinner shown\n- Disconnect for 35s: modal appears; scene still visible\n- Reconnect: modal dismissed, trails cleared, scene snaps to current state within 200ms\n- Requires: spaxel-fll (snapshot protocol), spaxel-896 (panel framework)","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-06T16:44:33.446200584Z","created_by":"coding","updated_at":"2026-04-07T16:45:31.143915724Z","closed_at":"2026-04-07T16:45:31.143823228Z","close_reason":"WebSocket reconnection with exponential backoff and visual state management was already fully implemented in commit ff3428f. All acceptance criteria met: backoff 1s-10s with ±500ms jitter, <5s silent extrapolation, 5-30s dimming with spinner, >30s modal with reload button, reconnect clears trails and restores scene from snapshot.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} -{"id":"spaxel-7x2","title":"Wire anomaly detection & security mode API endpoints","description":"## Backend\n\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## Acceptance\n- Endpoints return correct JSON structure\n- Anomaly events push to WS feed as 'alert' messages\n- Arm/disarm state persists across server restarts","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T16:09:35.812256758Z","created_by":"coding","updated_at":"2026-04-07T17:35:00.839409389Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} +{"id":"spaxel-7x2","title":"Wire anomaly detection & security mode API endpoints","description":"## Backend\n\n- Confirm AnomalyDetector is initialized and running in main()\n- Anomaly events must be pushed to the dashboard WS feed as 'alert' messages\n- GET /api/anomalies?since=24h — list recent anomaly events\n- POST /api/security/arm + /api/security/disarm — arm/disarm security mode\n- GET /api/security/status — { armed, learning_until, anomaly_count_24h }\n\n## Acceptance\n- Endpoints return correct JSON structure\n- Anomaly events push to WS feed as 'alert' messages\n- Arm/disarm state persists across server restarts","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T16:09:35.812256758Z","created_by":"coding","updated_at":"2026-04-07T18:13:13.019601444Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","mitosis-child","mitosis-depth:1","parent-spaxel-a55"]} {"id":"spaxel-7zy","title":"Anomaly detection and security mode","description":"## Background\n\nAfter 7+ days of learning, spaxel knows the household's normal patterns: who is home when, which zones are occupied at which hours, which BLE devices are typically present. Deviations from these patterns — unexpected late-night presence, unknown BLE devices, motion during away mode — can indicate security events. Security mode explicitly arms anomaly detection with immediate alerts and a comprehensive alert chain, transforming spaxel into a basic home security system.\n\n## Normal Behaviour Model\n\nAnomalyDetector maintains a statistical model of normal behaviour per (hour_of_week, zone_id) slot. For each slot:\n- expected_occupancy: whether this zone is typically occupied at this time (fraction of historical samples that had occupancy > 0)\n- typical_person_count: mean occupant count\n- typical_ble_devices: set of BLE device MACs typically present (with minimum frequency threshold: seen in > 50% of this hour_of_week slot)\n\nThe model is updated weekly from the activity history and zone transition history. A minimum of 7 days of data is required before anomaly detection activates. Before 7 days: no anomaly alerts fire.\n\nNew file: mothership/internal/analytics/anomaly.go\n\n## Anomaly Scoring\n\nFour anomaly types, each with a base score contribution:\n\n1. Unusual hour presence (score: 0.7 by default, 0.9 in security mode):\n Motion detected in zone Z at hour H, but expected_occupancy for (H, Z) < 0.1 (this zone is empty >90% of the time at hour H historically). Apply time-of-day sensitivity: late night (00:00-06:00) has 1.5x multiplier.\n\n2. Unknown BLE device (score: 0.5 by default, 0.8 in security mode):\n BLE device with RSSI > -60 dBm (close range) that is not in the registered device list AND has not been seen before (not even in the archive). If it was seen once before but not regularly, score = 0.3.\n\n3. Motion during away mode (score: 0.95, always immediate):\n Any presence detected when SystemMode == AWAY. By definition anomalous — all registered people are absent. This always fires an alert regardless of model training status.\n\n4. Unusual dwell duration (score: 0.4):\n Person present in zone for > 5x the historical mean dwell time for that (person, zone, hour_of_week) combination. May indicate a person is incapacitated (fell and can't get up) rather than a security event — cross-check with fall detection before escalating.\n\nComposite anomaly score: max(individual scores) for the most anomalous concurrent event. Alert threshold: score > 0.6 (default mode), score > 0.4 (security mode).\n\n## Security Mode\n\nSecurityMode is an extension of the SystemMode. When SystemMode is AWAY:\n- All anomaly detection thresholds are lowered (as per scores above)\n- Alert chain is immediate (no T+2min or T+5min delays — fires immediately)\n- Quiet hours are suppressed (all alerts bypass quiet hours when in security mode)\n- All four anomaly types are active regardless of model training status (even before 7 days)\n\nAuto-away detection:\n- Condition: all registered person_ids have had no BLE device seen by any node for > 15 minutes\n- On condition met: set SystemMode = AWAY. Log: \"Auto-away activated — all BLE devices absent\"\n- Broadcast system_mode_change WebSocket event to dashboard\n\nAuto-disarm:\n- Condition: any registered person's BLE device seen with RSSI > -70 dBm at any node\n- On condition met: set SystemMode = HOME. Clear security alerts (or keep them acknowledged-pending).\n- Broadcast system_mode_change event\n- Show \"Welcome home\" card in dashboard if identity is known: \"Alice arrived home.\"\n\nManual override: dashboard has a Home/Away/Sleep toggle that overrides auto-detection. Once manually set, auto-detection is paused for 30 minutes (avoids immediate re-trigger).\n\n## Alert Chain\n\nOn anomaly score > threshold:\n\n1. T+0s: Dashboard alarm overlay (red full-screen banner, z-index: top):\n \"Anomaly detected: [description]. [Acknowledge] [View in 3D] [Dismiss]\"\n Description examples: \"Motion detected in Kitchen at 3:12am (unusual hour)\", \"Unknown device detected near front door\", \"Motion detected while everyone is away\"\n\n2. T+0s (security mode) or T+30s (normal mode): push notification via configured channel with floor-plan thumbnail (using notification module, spaxel-zpt)\n\n3. T+0s (security mode) or T+2min (normal mode): webhook/MQTT via automation engine (trigger type: anomaly)\n\n4. T+5min (without acknowledgement): escalation webhook (secondary URL in settings)\n\nAcknowledge: acknowledges the alert, logs in activity timeline with current time, stops escalation timers. Shows brief form: \"What was this?\" — Expected/known event / Genuine intrusion / False alarm. This feeds the false positive rate for the anomaly model.\n\n## Detection History and Visualisation\n\nAll anomaly events are logged in the activity timeline (Phase 8). The 3D view adds an \"Anomaly\" layer:\n- When an active unacknowledged anomaly exists, the relevant zone pulses with a red overlay in the 3D scene\n- Anomaly events in the timeline are marked with a red shield icon\n\nWeekly anomaly summary: \"0 anomalies this week\" (reassuring) or \"3 anomalies detected: 2 false alarms, 1 unacknowledged.\"\n\n## Files to Create or Modify\n\n- mothership/internal/analytics/anomaly.go: AnomalyDetector, normal behaviour model\n- mothership/internal/fleet/manager.go: SystemMode integration, auto-away detection, BLE presence tracking for auto-disarm\n- dashboard/js/anomaly.js: alarm overlay, acknowledge UI\n- mothership/internal/dashboard/routes.go: GET /api/mode, POST /api/mode, GET /api/anomalies/history\n- mothership/internal/events/events.go: AnomalyEvent type\n\n## Tests\n\n- Test anomaly score for \"unusual hour presence\": expected_occupancy=0.05 at 3am -> score fires\n- Test \"unknown BLE device\": inject device MAC not in registry at RSSI -55 -> anomaly fires\n- Test \"motion during away\": set SystemMode=AWAY, inject presence event -> immediate alert fires regardless of thresholds\n- Test auto-away: all BLE devices absent for 900s -> SystemMode becomes AWAY\n- Test auto-disarm: device seen at RSSI=-65 -> SystemMode becomes HOME\n- Test alert chain timing in normal mode: alert at T+0, notification at T+30s, webhook at T+2min\n- Test security mode immediate alert chain: all three fire at T+0\n- Test acknowledgement cancels pending escalation timers\n\n## Acceptance Criteria\n\n- Anomaly fires correctly for unexpected late-night motion after 7 days of baseline\n- Security mode auto-activates when all registered BLE devices absent for 15 minutes\n- Alert chain fires in correct sequence for both normal and security mode\n- Auto-disarm triggers correctly when first registered BLE device returns\n- Dashboard alarm overlay is clearly visible (full-screen red banner) on anomaly detection\n- Zone pulsing in 3D view during active unacknowledged anomaly\n- Acknowledgement and feedback form records correctly\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:53:44.888473549Z","created_by":"coding","updated_at":"2026-03-30T16:27:42.698511Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-896","title":"Build dashboard panel/modal/sidebar UI framework","description":"## Problem\n\nThe dashboard is currently a single live 3D view with no panel system. All Phase 6-9 UI work (automation builder, timeline, explainability, settings, notifications, presence predictions) requires a panel/sidebar framework to hang on.\n\n## What to build\n\n### Panel System (dashboard/js/panels.js)\n- Slide-in sidebar (right, 360px) with close button and title\n- Modal overlay (centered, 600px wide) for forms and wizards\n- Toast notification stack (bottom-right)\n- Panel registry: panels can be opened by name from anywhere in the app\n\n### Route/Mode Navigation (dashboard/js/router.js)\n- Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay\n- Mode toggle bar in the header: Live | Timeline | Automations | Settings\n- Active mode preserved across page refresh (localStorage)\n\n### State Management (dashboard/js/state.js)\n- Central app state object (nodes, blobs, zones, links, alerts, events, ble_devices, triggers)\n- Subscribe/notify pattern for components to react to state changes\n- Separate from WebSocket message parsing\n\n### Settings Panel (dashboard/js/settings-panel.js)\n- Motion threshold slider\n- Sensing rate override\n- Notification channel config (Ntfy URL, Pushover token)\n- System info (version, uptime, node count)\n\n## Acceptance\n\n- Panel opens/closes smoothly with CSS transitions\n- Route changes update the active view without page reload\n- Settings panel reads from GET /api/settings and saves via POST /api/settings\n- All existing 3D live view functionality unaffected","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-06T12:55:28.903260636Z","created_by":"coding","updated_at":"2026-04-06T12:55:28.903260636Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-8u3","title":"Fleet manager with SQLite persistence","description":"Node registry, role assignment engine, and self-healing.\n\n## Deliverables\n- New package: mothership/internal/fleet/\n- SQLite node registry (MAC, ID, role, last seen, health, position)\n- Role assignment engine (TX/RX/passive/TX-RX including passive radar virtual node)\n- Stagger scheduling for multi-node packet timing\n- Self-healing: auto role reassignment on node loss, graceful degradation warnings\n- REST API endpoints: GET /api/nodes, GET /api/nodes/:mac, POST /api/nodes/:mac/role\n\n## Acceptance Criteria\n- Node state persists across mothership restarts\n- Roles auto-reassign when a node goes offline\n- Stagger scheduling prevents packet collisions\n- Tests cover registration, role assignment, and failure recovery\n\n## References\n- Plan: docs/plan/plan.md items 14\n- SQLite: modernc.org/sqlite (pure Go, already in go.mod intent)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:38.835804826Z","created_by":"coding","updated_at":"2026-03-28T05:36:26.132787526Z","closed_at":"2026-03-28T05:36:26.132727724Z","close_reason":"Implemented: fleet/manager.go + fleet/registry.go (fb69190) — SQLite node registry, role assignment engine, stagger scheduling, self-healing role reassignment on node loss","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-8u3","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T03:29:13.704767150Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -83,7 +83,7 @@ {"id":"spaxel-nqh","title":"BLE-to-blob identity matching","description":"## Background\n\nThe BLE device registry (spaxel-2wg) tells us which labelled devices are present in the home and their per-node RSSI. The CSI fusion engine (spaxel-m9a) and blob tracker (spaxel-n9n) tell us where anonymous humanoid figures are located in 3D space. Identity matching bridges these two systems: given named BLE devices with per-node RSSI observations, and anonymous CSI blobs with estimated 3D positions, assign device identities to blobs.\n\nWhen a match is confident, the humanoid figure in the 3D view gains a name label and person colour. \"Alice is in the Kitchen\" becomes a real-time fact rather than an approximation.\n\n## RSSI Triangulation Algorithm\n\nFor each BLE device with observations from multiple nodes in the current scan cycle (last 5 seconds):\n\nStep 1: Convert RSSI to distance for each observing node.\nDistance model: d = d0 * 10^((RSSI_ref - RSSI) / (10 * n))\nParameters: d0 = 1.0m (reference distance), RSSI_ref = -65 dBm (RSSI at 1m in typical indoor environment), n = 2.5 (indoor path loss exponent — typical range 2.0-3.5; 2.5 is a reasonable default for residential spaces).\n\nStep 2: With 2+ nodes, perform weighted least squares triangulation.\nPosition estimate: argmin_P { sum_i w_i * (|P - node_i| - d_i)^2 }\nwhere w_i = 1/sigma_i^2, sigma_i = d_i * ln(10) / (10 * n) * RSSI_noise_sigma (typically 5 dBm).\nSolve with 3-5 iterations of gradient descent or Gauss-Newton (the system is nonlinear).\nWith 1 node: only an approximate range estimate; triangulation confidence = 0.2.\nWith 2 nodes: 2D position estimate on a circle/arc; confidence = 0.5.\nWith 3+ nodes: full 2D position estimate with residual as quality indicator; confidence = min(1.0, 0.7 + 0.1 * (n-3)) where n is node count.\n\nStep 3: Assign to nearest CSI blob.\nFor each triangulated BLE position, find the nearest active CSI blob (from TrackManager) within a 2-metre search radius. The assignment uses Euclidean distance in the horizontal plane (ignore Z for BLE since antenna height is variable).\n\nIf multiple BLE devices map to the same blob (e.g. Alice has a phone AND a Fitbit), use the device with the highest triangulation confidence.\n\nIf a BLE device position is triangulated but no CSI blob is within 2 metres: create a \"BLE-only\" placeholder track at the BLE position. BLE-only tracks are shown as a different visual (outlined circle rather than filled, lower opacity) to indicate lower confidence.\n\n## Match Confidence Score\n\nFor each BLE-to-blob assignment, compute a match confidence:\nconfidence = f_observations * f_node_count * f_residual * f_distance\n\nwhere:\n- f_observations: 1.0 if device seen in last 5s, 0.5 if last 15s, 0.0 if older\n- f_node_count: 0.2 (1 node), 0.5 (2 nodes), 0.8+ (3+ nodes)\n- f_residual: 1.0 - min(1.0, triangulation_residual_metres / 2.0) — penalise poor triangulation fit\n- f_distance: 1.0 if blob distance < 0.5m, linear decay to 0 at 2.0m\n\nOnly assign identity if confidence > 0.6. Below this threshold: keep the blob anonymous (\"Unknown\").\n\n## Identity Persistence\n\nOnce a BLE-blob match is made, the identity persists for 5 minutes even if:\n- The BLE device rotates its MAC (new MAC will match same person via the person record)\n- The BLE device briefly falls below RSSI threshold of all nodes (indoor dead zone)\n- The CSI blob briefly disappears (tracker coasted state, per spaxel-n9n)\n\nAfter 5 minutes of no BLE observation and no high-confidence re-match: revert to anonymous.\n\nThis 5-minute persistence prevents flickering identity labels when people walk through areas with inconsistent BLE coverage.\n\n## TrackManager Integration\n\nExtend the TrackState struct in mothership/internal/tracker/ (spaxel-n9n):\n- PersonID string (from BLE registry people table)\n- PersonLabel string\n- PersonColor string\n- IdentityConfidence float64\n- IdentitySource string (\"ble_triangulation\" or \"ble_only\" or \"\")\n\nTrackManager.UpdateIdentities(blePositions map[deviceMAC]Vec3, registry BLERegistry) method: runs the matching algorithm and updates track identity fields.\n\nCalled at the same frequency as FusionEngine (10 Hz), but BLE positions only update at 5s intervals — cache the last BLE positions and re-use them between BLE updates.\n\n## API\n\nGET /api/tracks enriches the existing track list with person_id, person_label, person_color, and identity_confidence fields.\n\nExample response:\n[{\n \"id\": \"track-1\",\n \"position\": {\"x\": 3.2, \"y\": 1.8, \"z\": 1.0},\n \"velocity\": {\"x\": 0.1, \"y\": -0.2, \"z\": 0.0},\n \"confidence\": 0.87,\n \"posture_hint\": \"standing\",\n \"person_id\": \"uuid-alice\",\n \"person_label\": \"Alice\",\n \"person_color\": \"#3b82f6\",\n \"identity_confidence\": 0.82\n}]\n\n## Dashboard Integration\n\nIn the 3D view (Three.js scene): tracks with confirmed identity display a floating text label (person name) above the humanoid figure mesh, rendered using THREE.Sprite with a canvas texture containing the name in the person's colour.\n\nIn the \"People and Devices\" panel: each person row shows \"Currently: Kitchen\" (current zone from room transition portals, Phase 6) and \"In 3D view: [jump to track]\" link.\n\n## Tests\n\n- Test RSSI-to-distance conversion with known values: RSSI=-65 -> d=1.0m, RSSI=-75 -> d=2.5m (with default params)\n- Test triangulation with 3 nodes at known positions and known distances: verify position error < 0.5m\n- Test nearest-blob assignment: 2 blobs at (2,2) and (5,5), BLE device triangulated at (2.3,1.9) -> assigns to first blob\n- Test confidence gate: confidence=0.55 -> no assignment; confidence=0.65 -> assignment made\n- Test BLE-only placeholder track creation when no blob within 2m\n- Test identity persistence: after BLE device disappears, track retains identity for 5 minutes then reverts\n- Test identity handoff when BLE device MAC rotates: same person assignment maintained via person record\n\n## Acceptance Criteria\n\n- Named identity labels appear on 3D blobs when person carries a labelled BLE device with confidence > 0.6\n- Identity is maintained through brief BLE dead zones (up to 5 minutes)\n- Two people in the same room get correct distinct identities when their BLE devices are distinguishable\n- Confidence gate prevents wrong assignments (no identity label when confidence < 0.6)\n- BLE-only placeholder tracks appear for people whose BLE device is heard but no CSI blob is nearby\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:44:50.795871765Z","created_by":"coding","updated_at":"2026-03-30T16:27:42.750802Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} {"id":"spaxel-o0e","title":"Fresnel zone debug overlay","description":"## Background\n\nThe Fresnel zone geometry is at the heart of the fusion algorithm (spaxel-m9a). Every link's contribution to the 3D occupancy grid is weighted by how much of the candidate voxel falls within the first Fresnel zone ellipsoid. When debugging why localisation is placing a blob in the wrong position, being able to see the actual Fresnel ellipsoids for each active link — overlaid on the 3D scene — instantly reveals whether the zones are covering the right area.\n\nThis is a pure debug/developer tool. It is toggle-able (off by default) and intended for system tuners and developers, not end users. The explainability overlay (spaxel-ez4) already shows Fresnel ellipsoids for a specific selected blob. The Fresnel zone debug overlay shows them persistently for all active links simultaneously.\n\n## Ellipsoid Geometry (Recap)\n\nFor each active link with TX at position P1 and RX at position P2:\n- Link distance d = |P1 - P2|\n- WiFi channel wavelength lambda: 5 GHz -> lambda = 0.06m, 2.4 GHz -> lambda = 0.125m. Use the channel reported in the node's hello message (or the channel from the link's last received CSI frame header).\n- Semi-major axis: a = (d + lambda/2) / 2\n- Semi-minor axis: b = sqrt(a^2 - (d/2)^2)\n- Ellipsoid centre: midpoint(P1, P2)\n- Ellipsoid orientation: major axis along the P1->P2 unit vector\n\nThe first Fresnel zone ellipsoid represents the region where a point reflector would shorten the signal path by at most half a wavelength relative to the direct path. Motion within this region has maximum impact on CSI.\n\n## Three.js Rendering\n\nEllipsoid mesh construction:\n1. Start with THREE.SphereGeometry(1, 32, 16) — a unit sphere\n2. Apply non-uniform scaling: new THREE.Vector3(a, b, b) via mesh.scale.set(a, b, b)\n3. Rotate to align with the link axis: compute the quaternion that maps (1,0,0) to the P1->P2 unit vector using THREE.Quaternion.setFromUnitVectors(new THREE.Vector3(1,0,0), linkAxis)\n4. Apply the quaternion to mesh.quaternion\n\nMaterial: TWO materials:\n- Wireframe: THREE.LineSegments with EdgesGeometry for crisp wireframe edges, line color matching the link line colour, opacity 0.6\n- Fill: THREE.MeshBasicMaterial with transparent=true, opacity 0.08, colour matching the link, depthWrite=false (so the fill doesn't obscure other geometry)\n\nLayer toggle: add \"Fresnel Zones\" checkbox to the 3D layer control panel (in the \"Debug\" section, only visible when expert mode is active). Default: off. When toggled on, add all ellipsoid meshes to the scene. When toggled off, remove them.\n\n## Per-Link Controls\n\nWhen a user hovers over a Fresnel ellipsoid in the 3D scene (using Three.js raycasting):\n- The corresponding link line highlights (brightness increase)\n- A tooltip appears (HTML overlay, positioned at screen coordinates of the hover point):\n \"Link: [tx_label] to [rx_label]\n Fresnel zone radius at midpoint: {b:.2f}m\n Link distance: {d:.2f}m\n Wavelength: {lambda:.3f}m (channel {ch})\n Link health: {health_score:.0%}\"\n\nClicking an ellipsoid:\n- Selects the corresponding link in the link panel (sidebar)\n- Highlights the link entry in the link list\n\n## Performance Considerations\n\nWith 6 active links, we render 6 pairs of meshes (wireframe + fill = 12 Three.js objects). This is negligible for any modern GPU. However, the wireframe geometry uses EdgesGeometry which creates one Line for each edge — for a sphere with 32 horizontal and 16 vertical segments, that's approximately 1000 line segments per ellipsoid. At 6 links, 6000 line segments total. This should render at 60 fps on any modern device, but if performance is an issue on mobile, reduce the sphere segment count to 16x8 when the debug overlay is active on mobile viewports.\n\nPre-compute at link addition time: when a new link is registered (node hello + peer MAC), compute the ellipsoid geometry and add it to the scene (hidden if the layer is off). Update on node position change. Remove when link becomes inactive (no frames for > 30s).\n\n## Relationship to Explainability Overlay\n\nThe Fresnel zone debug overlay (this bead) and the detection explainability overlay (spaxel-ez4) both render Fresnel ellipsoids. They share the same geometry computation code (dashboard/js/fresnel.js — the ellipsoid helper function). The difference:\n- This overlay: shows all active link ellipsoids simultaneously, toggle-able layer\n- Explainability overlay: shows only contributing link ellipsoids for a specific selected blob, in explain mode\n\nBoth import from fresnel.js. The helper function FresnelEllipsoid(P1, P2, lambda) returns a Three.js Mesh ready for scene insertion.\n\n## Files to Create or Modify\n\n- dashboard/js/fresnel.js: FresnelEllipsoid helper function (shared with explainability)\n- dashboard/js/layers.js: add \"Fresnel Zones\" toggle in the debug layer section\n- dashboard/js/app.js: integrate Fresnel overlay management — create/update/remove ellipsoids on link events\n\n## Tests\n\n- Test ellipsoid geometry computation with known TX/RX positions: TX at (0,0,0), RX at (4,0,0), lambda=0.06m -> a ~= 2.015, b ~= 0.345. Verify to 3 decimal places.\n- Test semi-minor axis for edge case: very short link d=0.1m -> b should be very small but positive\n- Test for diagonal link: TX at (0,0,0), RX at (3,4,0) (distance=5m) -> verify a and b are computed correctly\n- Test that toggling the layer on/off adds/removes the correct number of mesh objects from the scene (mock Three.js scene)\n- Test hover tooltip shows correct data (link health from mock, link endpoints from mock)\n- Test that ellipsoids update when node position changes\n\n## Acceptance Criteria\n\n- Fresnel zone ellipsoids render correctly for all active links when the debug layer is toggled on\n- Ellipsoid semi-major and semi-minor axes match theoretical first Fresnel zone values for the link distance and frequency\n- Toggle shows/hides all ellipsoids cleanly without leaving orphan objects in the scene\n- Hovering an ellipsoid shows the correct tooltip with link details and health score\n- Clicking an ellipsoid selects the corresponding link in the link panel\n- Geometry computation is shared with the explainability overlay via fresnel.js\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:58:33.424914116Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.736776003Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-o0e","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-03-28T03:29:14.736620594Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-o4l","title":"Bidirectional node protocol","description":"Implement full bidirectional protocol over the existing WebSocket connection.\n\n## Deliverables\n- Registration (hello message with capabilities)\n- Health reporting (heap, WiFi RSSI, uptime, temperature every 10s)\n- BLE scan relay (device list JSON)\n- Role/config push from mothership (TX/RX/passive mode, packet rate)\n- OTA command messages (trigger update, progress tracking)\n- All messages over the existing single WebSocket per node\n\n## Acceptance Criteria\n- Nodes register on connect with full capability advertisement\n- Mothership can push role changes and config updates\n- Health metrics flow reliably at 10s intervals\n- Protocol is backward-compatible with Phase 1 implementation\n\n## References\n- Current protocol: mothership/internal/ingestion/message.go\n- Firmware WebSocket: firmware/main/websocket.c","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:31.632551776Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.644219477Z","closed_at":"2026-03-27T03:14:43.201850105Z","close_reason":"Implemented full bidirectional node protocol. Firmware: motion hints wired to websocket_send_motion_hint() with rate-limiting, csi_set_rate() fixed, all message types active (hello/health/ble/motion_hint/ota_status). Mothership: OnMotionHint() ramps adjacent nodes via topology callback, idle timeout 30s, variance threshold adaptive, added SendRoleToMAC() and SendOTAToMAC() for dynamic downstream pushes, OTA status logging. Binary CSI frames remain backward-compatible with Phase 1.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-o4l","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.644181123Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"spaxel-ofa","title":"End-to-end integration test harness (simulator + mothership assertions)","description":"## Overview\nBuild an automated integration test that starts the mothership, runs the CSI simulator against it, and asserts on observable behavior — providing a production-realism gate for CI.\n\n## Test harness (tests/e2e/run.sh or Go test)\n\n### Setup:\n1. Start mothership container (or binary): docker run -d -p 8080:8080 ronaldraygun/spaxel:latest\n2. Wait for /healthz to return {status:'ok'} with 15s timeout (poll every 500ms)\n3. If PIN auth enabled: POST /api/auth/setup with test PIN; POST /api/auth/login\n\n### Run simulator:\n4. Start: sim --mothership http://localhost:8080 --nodes 4 --walkers 2 --duration 30s --rate 20 --ble --seed 42\n5. Simulator exits non-zero if it receives {type:'reject'} message; test fails immediately\n\n### Assert during run (poll every 1s for 30s):\n6. GET /api/blobs (or WebSocket) → assert blob_count > 0 within first 15s\n7. GET /api/nodes → assert nodes_online == 4 within first 5s\n8. GET /healthz → assert status=='ok' throughout entire run\n\n### Assert after run:\n9. GET /api/events?type=detection → assert at least 1 detection event recorded\n10. Simulator printed per-second frame counts to stdout; verify no frame-rate drop >20% from target\n\n## CI integration\n- GitHub Actions workflow: .github/workflows/e2e.yml (but only triggers from Argo Workflows via spaxel-ci)\n- Build image → run test harness → post result as bead comment\n\n## Acceptance\n- Test passes on fresh container with seed 42 configuration\n- Test fails clearly when mothership rejects frames (wrong protocol)\n- Test runs in <90s total (15s startup + 30s sim + 45s buffer)","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T16:44:47.443177386Z","created_by":"coding","updated_at":"2026-04-07T17:29:33.684833382Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:7"]} +{"id":"spaxel-ofa","title":"End-to-end integration test harness (simulator + mothership assertions)","description":"## Overview\nBuild an automated integration test that starts the mothership, runs the CSI simulator against it, and asserts on observable behavior — providing a production-realism gate for CI.\n\n## Test harness (tests/e2e/run.sh or Go test)\n\n### Setup:\n1. Start mothership container (or binary): docker run -d -p 8080:8080 ronaldraygun/spaxel:latest\n2. Wait for /healthz to return {status:'ok'} with 15s timeout (poll every 500ms)\n3. If PIN auth enabled: POST /api/auth/setup with test PIN; POST /api/auth/login\n\n### Run simulator:\n4. Start: sim --mothership http://localhost:8080 --nodes 4 --walkers 2 --duration 30s --rate 20 --ble --seed 42\n5. Simulator exits non-zero if it receives {type:'reject'} message; test fails immediately\n\n### Assert during run (poll every 1s for 30s):\n6. GET /api/blobs (or WebSocket) → assert blob_count > 0 within first 15s\n7. GET /api/nodes → assert nodes_online == 4 within first 5s\n8. GET /healthz → assert status=='ok' throughout entire run\n\n### Assert after run:\n9. GET /api/events?type=detection → assert at least 1 detection event recorded\n10. Simulator printed per-second frame counts to stdout; verify no frame-rate drop >20% from target\n\n## CI integration\n- GitHub Actions workflow: .github/workflows/e2e.yml (but only triggers from Argo Workflows via spaxel-ci)\n- Build image → run test harness → post result as bead comment\n\n## Acceptance\n- Test passes on fresh container with seed 42 configuration\n- Test fails clearly when mothership rejects frames (wrong protocol)\n- Test runs in <90s total (15s startup + 30s sim + 45s buffer)","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:44:47.443177386Z","created_by":"coding","updated_at":"2026-04-07T18:13:27.587964274Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:9"]} {"id":"spaxel-oql","title":"Firmware: NVS schema migration on boot","description":"## Overview\nImplement versioned NVS key migration on ESP32-S3 firmware so OTA-updated firmware gracefully handles NVS written by older versions.\n\n## Implementation (firmware/main/nvs_migration.c)\n- On boot, open 'spaxel' NVS namespace and read schema_ver (uint8); if missing, write schema_ver=1\n- If schema_ver < COMPILED_NVS_VERSION: run migration functions in order (v1→v2, v2→v3, etc.)\n- Each migration: add/rename/remove specific NVS keys; call nvs_commit() after each write\n- After all migrations: update schema_ver = COMPILED_NVS_VERSION and commit\n- Log each migration step to UART for debugging\n\n## Example migration v1→v2:\n- Rename 'ms_ip' to 'mothership_ip' (read old key, write new key, erase old key)\n- Add 'ntp_server' key with default value 'pool.ntp.org'\n\n## Acceptance\n- Flash firmware v1.0 with known NVS schema; flash v1.1 firmware; verify all keys present\n- Migration runs exactly once (schema_ver correctly incremented)\n- Migration failure leaves NVS in consistent state (tested via simulated write failure)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:42:15.874379750Z","created_by":"coding","updated_at":"2026-04-07T14:28:20.262035505Z","closed_at":"2026-04-07T14:28:20.261822946Z","close_reason":"Implemented NVS schema migration on boot for ESP32-S3 firmware. Added nvs_migration.c/h with migration framework that reads schema_ver from NVS, initializes to 1 if missing, and runs migrations sequentially when schema_ver < COMPILED_NVS_VERSION. Each migration commits after each write for durability. Example v1→v2 migration renames 'ms_ip' to 'mothership_ip' and adds 'ntp_server' with default 'pool.ntp.org'. All migration steps logged to UART for debugging. Migration failure leaves NVS in consistent state.","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-p5p","title":"Implement BLE Devices REST endpoints","description":"Implement GET /api/ble/devices to list known devices. Add PUT /api/ble/devices/{mac} to set label and assign to person. Include OpenAPI-style godoc comments.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.569849257Z","created_by":"coding","updated_at":"2026-04-07T13:32:26.840085899Z","close_reason":"Implemented BLE Devices REST endpoints with OpenAPI-style godoc comments: GET /api/ble/devices lists devices with filtering; PUT /api/ble/devices/{mac} updates label and assigns to person. Added comprehensive tests.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-pel","title":"Dashboard presence indicator","description":"Add per-link motion detected/clear display to the dashboard.\n\n## Deliverables\n- WebSocket message from mothership to dashboard with per-link motion state\n- Visual indicator in dashboard UI: green = clear, red = motion detected per link\n- Amplitude time series plot for selected link (rolling window)\n- Update dashboard/js/app.js and mothership dashboard hub to broadcast motion state\n\n## Acceptance Criteria\n- Dashboard shows real-time motion/clear status for each active link\n- Amplitude time series updates smoothly\n- Works with the existing signal processing pipeline in mothership/internal/signal/\n\n## References\n- Dashboard code: dashboard/js/app.js, dashboard/index.html\n- Signal processing: mothership/internal/signal/processor.go (GetAllMotionStates)\n- Dashboard hub: mothership/internal/dashboard/hub.go","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:02.465235096Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.674201551Z","closed_at":"2026-03-27T02:55:53.328233821Z","close_reason":"Implemented per-link motion presence indicator: green CLEAR/red MOTION badge per link in dashboard, 60s rolling amplitude time series for selected link, immediate motion state broadcast from hub on idle<->motion transitions, fixed ampHistory init bug for JSON-created links.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-pel","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T01:34:05.674165567Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -121,6 +121,7 @@ {"id":"spaxel-vuw","title":"Spatial automation: trigger volume geometry builder","description":"## Overview\n3D trigger volume editor and point-in-volume evaluation engine for the spatial automation system.\n\n## Backend (mothership/automation/ or zones/)\n- Geometry types: box {type, x, y, z, w, d, h} and cylinder {type, cx, cy, z, r, h} stored as shape_json\n- Point-in-volume tests: axis-aligned box test and cylinder test functions\n- Trigger state machine per (trigger_id, blob_id) pair: track inside/outside state and transition edges (enter, leave, dwell, vacant, count)\n- Dwell timer: fire dwell action after N continuous seconds inside volume\n- SQLite triggers table: id, name, shape_json, condition TEXT, condition_params JSON, actions_json, enabled BOOL, last_fired_ms\n- REST CRUD at /api/triggers (requires spaxel-6ha)\n\n## Dashboard (dashboard/js/automation-builder.js)\n- Automation panel via panel framework (spaxel-896)\n- Draw box volume: click + drag to define base footprint, height slider\n- Draw cylinder volume: click center, drag radius, height slider\n- THREE.js TransformControls for translate/scale/rotate after placement\n- Volume visualization: translucent colored box/cylinder; pulse animation when condition fires\n- Condition picker: enter zone / leave zone / dwell N seconds / zone vacant / count >= N\n- Action list: webhook URL, MQTT topic/payload, internal (arm security, rebaseline, notify)\n- Trigger log: last 10 firings with timestamp and matched blob\n\n## Acceptance\n- Box and cylinder volumes render correctly in 3D view\n- Point-in-volume evaluated on each fusion tick (target <1ms per trigger)\n- Dwell trigger fires at correct time ±1s\n- Trigger state persists across server restart\n- Requires: spaxel-896, spaxel-6ha, spaxel-9eg","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:01:42.971994626Z","created_by":"coding","updated_at":"2026-04-06T13:01:42.971994626Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-w40","title":"Passive radar: auto-detect router AP as virtual TX node","description":"## Overview\nAutomatically detect the home router as a passive radar TX source, eliminating need for a dedicated active TX node.\n\n## Firmware changes\n- During hello message, include ap_bssid and ap_channel from esp_wifi_sta_get_ap_info()\n\n## Mothership (mothership/fleet/ or ingestion/)\n- On hello: extract ap_bssid; if >=80% of nodes report same BSSID create virtual node entry with virtual=1, position unset\n- OUI lookup: embed IEEE OUI registry as Go map compiled via go:embed; display router brand\n- Detect AP BSSID change (router reboot/replacement) and emit system alert\n- SQLite nodes table: add virtual BOOL, node_type TEXT, ap_bssid TEXT, ap_channel INT columns\n\n## Dashboard\n- After AP auto-detected: 'I detected your router (ASUS). Place it on the floor plan to improve accuracy.'\n- Drag-to-place virtual node (distinct router icon) in 3D editor\n- Confirmation dialog with 'Use as signal source' toggle\n\n## Acceptance\n- Virtual node appears in /api/nodes with virtual=true\n- 3D view renders virtual node with distinct icon\n- AP change detection fires a system event within 30s of BSSID change","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T13:01:07.745215170Z","created_by":"coding","updated_at":"2026-04-06T18:04:45.975811136Z","closed_at":"2026-04-06T18:04:45.975562593Z","close_reason":"Implemented passive radar auto-detection of router AP\n\nFirmware: Added ap_bssid/ap_channel to hello message using esp_wifi_sta_get_ap_info()\n\nMothership: Created apdetector package for >=80% BSSID agreement detection, OUI lookup for router manufacturer, AP change detection system events\n\nDashboard: AP detection notification, distinct router icon in 3D (box+4antennas), drag-to-place positioning\n\nVirtual nodes appear in /api/nodes with virtual=true, node_type=ap","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3"]} {"id":"spaxel-x59","title":"merge: remove phase6 build tag and unify main.go","description":"## Problem\n`cmd/mothership/main_phase6.go` is gated behind `//go:build phase6` which excludes all Phase 6+ code from default builds. The directory has both `main.go` (Phase 5) and `main_phase6.go` (Phase 6) — both define `package main` with `func main()`, so removing the build tag would cause a duplicate symbol error.\n\n## Prerequisites\nAll Phase 6 package compile errors must be fixed first (spaxel-glq, spaxel-9nj, spaxel-19h, spaxel-uln, spaxel-7nk, spaxel-she).\n\n## Steps\n1. Confirm all Phase 6+ packages compile cleanly:\n ```bash\n cd /home/coding/spaxel/mothership\n PATH=$PATH:/home/coding/go/bin go build ./internal/...\n ```\n2. Delete `cmd/mothership/main.go.bak` (stale backup)\n3. Delete `cmd/mothership/main.go` (Phase 5 entrypoint, superseded)\n4. Remove the `//go:build phase6` line and the blank line after it from `cmd/mothership/main_phase6.go`\n5. Build and verify:\n ```bash\n PATH=$PATH:/home/coding/go/bin go build ./...\n PATH=$PATH:/home/coding/go/bin go test ./...\n ```\n\n## Acceptance\n- `go build ./...` passes with no errors\n- Binary is built from the Phase 6 entrypoint\n- No `phase6` build tag exists anywhere in the codebase\n\nDependents:\n <- spaxel-jcc","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T22:30:32.363205812Z","created_by":"coding","updated_at":"2026-04-07T05:33:07.064388207Z","closed_at":"2026-04-07T05:33:07.064285866Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:19"],"dependencies":[{"issue_id":"spaxel-x59","depends_on_id":"spaxel-19h","type":"blocks","created_at":"2026-04-06T22:30:41.292760872Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-7nk","type":"blocks","created_at":"2026-04-06T22:30:41.351817968Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-9nj","type":"blocks","created_at":"2026-04-06T22:30:41.255304103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-glq","type":"blocks","created_at":"2026-04-06T22:30:41.209121103Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-she","type":"blocks","created_at":"2026-04-06T22:30:41.390256545Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-x59","depends_on_id":"spaxel-uln","type":"blocks","created_at":"2026-04-06T22:30:41.322389944Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-xlo","title":"Create SQLite floorplan table and storage directory","description":"## Task\nCreate the floorplan table in SQLite and ensure /data/floorplan directory exists.\n\n## Schema\nSQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- /data/floorplan directory exists\n- floorplan table created in SQLite with correct schema","status":"in_progress","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T17:55:49.108738491Z","created_by":"coding","updated_at":"2026-04-07T18:14:22.582409236Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-klk"]} {"id":"spaxel-xpk","title":"Diurnal adaptive baseline: 24-hour slot learning","description":"## Overview\nExtend the EMA baseline system with per-hour-of-day slots to eliminate false positives caused by daily environmental cycles (sunlight, HVAC, temperature changes).\n\n## Backend (mothership/signal/baseline.go extension)\n- Data structure: 24 hourly slots per link per subcarrier; each slot stores amplitude blob and sample_count\n- Learning phase (7 days): accumulate motion-free CSI into hourly slots; require >=300 samples/slot to mark ready\n- Steady state: on each fusion tick, select active baseline = weighted blend of diurnal slot (if ready) + EMA fallback\n- Crossfade: over first 15 min of each hour, linearly blend from EMA to diurnal slot; after 15 min use diurnal exclusively\n- Motion-gated updates: EMA updates continue during the hourly window, improving diurnal slot over time\n- Outlier protection: skip update if deltaRMS > motion threshold (don't train on motion frames)\n- SQLite diurnal_baselines table: link_id, hour_of_day (0-23), n_sub INT, amplitude BLOB, sample_count INT, confidence REAL, updated_at INT\n\n## Dashboard visualization\n- Per-link detail panel: 24-hour polar chart (or horizontal bar chart) showing baseline amplitude variance by hour\n- 'Diurnal learning' progress indicator: 'Learning hour 14... 6/7 days'\n- Confidence color per hour: green (ready), amber (partial), red (no data)\n\n## Acceptance\n- Baseline correctly crossfades at hour boundaries (±60s)\n- Motion events during learning do not corrupt slots (outlier protection confirmed by test)\n- Polar chart renders for links with >=1 ready slot\n- No performance regression: baseline lookup remains O(1)\n- Requires: spaxel-jcc (phase 6 integration)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:02:07.078024506Z","created_by":"coding","updated_at":"2026-04-06T13:02:07.078024506Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-yxr","title":"Ingestion: CSI frame validation with malformed counter and auto-close","description":"## Overview\nImplement strict CSI binary frame validation with per-connection malformed frame counters and automatic connection closure on persistent malformed input.\n\n## Validation rules (plan lines 303-324):\n- Minimum frame length: 24 bytes (header only, zero subcarriers valid)\n- Maximum frame length: 280 bytes (24 header + 128 subcarriers × 2 bytes I/Q)\n- n_sub field: must be ≤128\n- Payload length: must equal n_sub × 2 bytes exactly\n- channel: must be in [1,14] for 2.4 GHz; drop if 0 or >14\n- rssi: int8; 0 treated as invalid/missing (not an error, but log at DEBUG)\n- timestamp_us: any uint64 value accepted\n\n## Per-connection malformed counter (sliding 60-second window):\n- Track malformed_count and window_start_ms per WebSocket connection\n- On each validation failure: increment malformed_count; log at DEBUG\n- Every 60s: check counts → if malformed_count > 100: log WARN 'Node {mac} sent {N} malformed frames in 60s'\n- If malformed_count > 1000 within 60s: close WebSocket with message 'Excessive malformed frames — possible firmware bug'\n- Reset counter every 60s\n\n## Acceptance\n- Valid frame: passes all checks in <1 μs\n- Frame with n_sub=200: rejected (n_sub > 128)\n- Frame with len=10: rejected (< 24 bytes)\n- Frame with channel=0: dropped silently\n- 1001 malformed frames in 60s: connection closed with correct message\n- 101 malformed frames: WARN logged, connection kept open","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-06T16:44:21.981852269Z","created_by":"coding","updated_at":"2026-04-07T16:23:24.731432820Z","closed_at":"2026-04-07T16:23:24.731370070Z","close_reason":"Implemented CSI frame validation with DEBUG logging and performance benchmark.\n\nAll validation rules from plan lines 303-324 implemented:\n- Minimum frame length: 24 bytes ✓\n- Maximum frame length: 280 bytes ✓ \n- n_sub ≤ 128 ✓\n- Payload length = n_sub × 2 bytes ✓\n- Channel in [1,14] for 2.4 GHz ✓\n- RSSI=0 logged at DEBUG (allowed) ✓\n- timestamp_us any value ✓\n\nPer-connection malformed counter (60s sliding window):\n- DEBUG log on each validation failure ✓\n- WARN log when count > 100 ✓\n- Auto-close when count > 1000 ✓\n- Counter resets every 60s ✓\n\nAdded benchmark tests to verify <1 μs validation performance for valid frames.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"]} {"id":"spaxel-zpt","title":"Spatial context notifications with floor-plan thumbnails","description":"## Background\n\nPush notifications without context are ignored or disabled. \"Motion detected\" tells you nothing useful. \"Alice walked into the Kitchen — Bob is already there\" is genuinely interesting. \"Possible fall: Alice in Hallway — unacknowledged for 3 minutes\" demands immediate attention. The plan specifies server-side rendering of mini floor-plan thumbnails attached to notifications to provide instant spatial context without opening the app.\n\n## Server-Side Floor-Plan Renderer\n\nNew package: mothership/internal/render/floorplan.go\n\nThe renderer produces a top-down 2D PNG (300x300 pixels) showing:\n- Room outline: outer boundary of all zones as white rectangles on dark background\n- Zone fills: each zone as a semi-transparent coloured fill (zone.color at 20% opacity)\n- Zone labels: zone name in small white text at zone centroid\n- Node positions: small white circle dots\n- Person blobs: coloured circles (person.color) at their last-known position, diameter proportional to detection confidence (min 10px, max 20px)\n- Name labels: person name in white text above each blob circle, if identity is known\n- Portal planes: thin lines in purple (#a855f7)\n- Event highlight: the zone where the event occurred rendered with brighter fill and a white border\n\nRendering library: use github.com/fogleman/gg (a pure-Go 2D graphics library). Alternative: standard image/draw + image/png for maximum portability. The fogleman/gg approach is recommended for its higher-level drawing API (bezier curves, text, etc.).\n\nThe PNG must be generated within 200ms to not delay notification delivery. At 300x300 with simple geometry, this should be easily achievable.\n\nThe rendered PNG is stored as a []byte and passed to the notification delivery function. It is base64-encoded for attachment in webhook payloads or passed as a file to ntfy/Pushover APIs.\n\n## Notification Types and Triggers\n\n1. zone_enter: \"{{person_name}} entered {{zone_name}}\" — LOW priority unless security mode is active\n2. zone_leave: \"{{person_name}} left {{zone_name}}\" — LOW priority\n3. zone_vacant: \"{{zone_name}} is now empty\" — LOW priority\n4. fall_detected: \"Possible fall: {{person_name}} in {{zone_name}}\" — URGENT, always immediate\n5. fall_escalation: \"URGENT: Fall unacknowledged for 5 minutes — {{person_name}} in {{zone_name}}\" — URGENT\n6. anomaly_alert: \"Unexpected presence: {{zone_name}}\" — HIGH priority (breaks quiet hours)\n7. node_offline: \"Node {{node_label}} has gone offline\" — MEDIUM priority\n8. sleep_summary: \"Last night: {{sleep_duration}}\" — LOW priority, morning delivery\n\n## Smart Batching\n\nIf multiple LOW or MEDIUM priority events fire within a 30-second window, batch them into a single notification:\n- \"Alice entered Kitchen. Bob left Living Room.\"\n- \"2 presence events in the last 30 seconds.\"\n\nBatching rules:\n- Batch only events of the same priority level\n- Never batch URGENT events — those are always immediate\n- Never batch events involving different notification types if the combination is confusing\n- Batch counter: if more than 5 events in 30s, summarise as \"N presence events in the last minute\"\n\nBatching implementation: a 30-second window timer per notification channel. When the first LOW event fires, start the 30s timer. Accumulate events. On timer expiry: merge into one notification and deliver.\n\n## Quiet Hours\n\nUser-configurable quiet hours: from_time, to_time (e.g. \"22:00\" to \"07:00\"). Stored in SQLite notifications_config (channel, quiet_from, quiet_to, quiet_days_bitmask).\n\nDuring quiet hours:\n- LOW priority notifications are queued\n- MEDIUM priority notifications are queued\n- HIGH and URGENT notifications are delivered immediately regardless of quiet hours\n\nAt the end of quiet hours (07:00 on non-override days): deliver all queued notifications as a morning digest bundle: \"While you were asleep: [summary of queued events]\"\n\n## Delivery Channels\n\nntfy:\n- POST to https://ntfy.sh/{topic} (or self-hosted server URL)\n- Headers: Authorization: Bearer {token} (if configured), Priority: urgent/high/default/low/min\n- Body: the notification text\n- Headers: Attach: {base64_encoded_png_url} — for ntfy, attach the floor-plan as a URL if mothership is publicly accessible, or send as base64 data URL for local deployments\n\nPushover:\n- POST to https://api.pushover.net/1/messages.json\n- Fields: token, user, message, title, priority, attachment (PNG as multipart form upload)\n\nGeneric webhook:\n- POST to user-configured URL\n- Body: {\"event_type\":\"...\", \"message\":\"...\", \"person_id\":\"...\", \"zone_id\":\"...\", \"timestamp\":\"...\", \"floorplan_png_base64\":\"...\"}\n\n## Configuration UI\n\nDashboard Settings panel -> \"Notifications\" tab:\n- Delivery channel selector: None / ntfy / Pushover / Webhook\n- Channel-specific credential fields (ntfy server URL + topic + token, Pushover API key, webhook URL)\n- Test notification button: sends a test notification to verify configuration\n- Event type enable/disable toggles: per event type, can disable e.g. \"zone_enter\" while keeping \"fall_detected\" enabled\n- Quiet hours: time picker from/to, day-of-week selector\n- Smart batching toggle (default on)\n- \"Morning digest\" toggle (default on — delivers batched quiet-hours events at wake time)\n\n## Files to Create or Modify\n\n- mothership/internal/render/floorplan.go: floor-plan PNG renderer\n- mothership/internal/notifications/manager.go: NotificationManager, batching, quiet hours logic\n- mothership/internal/notifications/ntfy.go: ntfy delivery client\n- mothership/internal/notifications/pushover.go: Pushover delivery client\n- mothership/internal/notifications/webhook.go: generic webhook delivery\n- mothership/internal/dashboard/routes.go: GET/PUT /api/settings/notifications, POST /api/notifications/test\n\n## Tests\n\n- Test floor-plan renderer produces a 300x300 PNG with correct dimensions\n- Test that zone boundaries appear in the rendered PNG at correct coordinates (check pixel colors at known positions)\n- Test batching: 3 LOW events within 10s -> 1 notification; 1 URGENT event -> immediate even if batching timer is active\n- Test quiet hours gate: LOW event at 23:00 with quiet hours 22:00-07:00 -> queued; URGENT event at 23:00 -> delivered immediately\n- Test morning digest delivery: queued events are bundled and delivered at quiet_hours_end\n- Test ntfy delivery with mock HTTP server: verify correct headers and body format\n- Test webhook delivery with mock HTTP server: verify correct JSON body and base64 PNG field\n- Test test-notification endpoint fires correctly\n\n## Acceptance Criteria\n\n- Notification received via ntfy within 5 seconds of trigger event for URGENT priority\n- Floor-plan PNG correctly shows zone boundaries and person positions in the notification\n- Smart batching prevents more than one notification per 30-second window for LOW events\n- Quiet hours suppress LOW/MEDIUM notifications and queue them for morning digest\n- Fall detection and anomaly alerts always bypass quiet hours\n- Morning digest delivered correctly at quiet hours end\n- Test notification button correctly verifies channel configuration\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:48:19.528717849Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.371730406Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.371640840Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-c1c","type":"blocks","created_at":"2026-03-28T01:48:23.948107860Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-zpt","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-03-28T01:48:23.975916991Z","created_by":"coding","metadata":"{}","thread_id":""}]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 984a861..3838ab0 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -a4e4cff5ae8105a980e9e439ac6916dec7161cc9 +04129addd309e35bd0a9f98e9d718943185552a0 diff --git a/mothership/internal/api/zones.go b/mothership/internal/api/zones.go index 49a3d4c..65f9c44 100644 --- a/mothership/internal/api/zones.go +++ b/mothership/internal/api/zones.go @@ -10,15 +10,17 @@ import ( "time" "github.com/go-chi/chi" + "github.com/spaxel/mothership/internal/dashboard" "github.com/spaxel/mothership/internal/zones" ) // ZonesHandler manages zones and portals via the zones.Manager. -// Changes to zones and portals are automatically reflected in the live 3D view -// within one WebSocket cycle because the dashboard hub polls the manager at 10 Hz. +// Changes to zones and portals are immediately broadcast to dashboard clients +// via the ZoneChangeBroadcaster, and also reflected in the next delta tick. type ZonesHandler struct { mu sync.RWMutex mgr *zones.Manager + bc dashboard.ZoneChangeBroadcaster } // zoneWithOcc extends a zone with current occupancy and people list for API responses. @@ -83,6 +85,71 @@ func NewZonesHandler(mgr *zones.Manager) *ZonesHandler { return &ZonesHandler{mgr: mgr} } +// SetZoneChangeBroadcaster sets the broadcaster for immediate WebSocket +// notifications when zones or portals are modified. +func (h *ZonesHandler) SetZoneChangeBroadcaster(bc dashboard.ZoneChangeBroadcaster) { + h.mu.Lock() + defer h.mu.Unlock() + h.bc = bc +} + +// notifyZoneChange broadcasts a zone change event if a broadcaster is set. +func (h *ZonesHandler) notifyZoneChange(action string, z *zones.Zone) { + h.mu.RLock() + bc := h.bc + h.mu.RUnlock() + if bc == nil { + return + } + occ := h.mgr.GetZoneOccupancy(z.ID) + count := 0 + if occ != nil { + count = occ.Count + } + bc.BroadcastZoneChange(action, dashboard.ZoneSnapshot{ + ID: z.ID, + Name: z.Name, + Count: count, + MinX: z.MinX, + MinY: z.MinY, + MinZ: z.MinZ, + SizeX: z.MaxX - z.MinX, + SizeY: z.MaxY - z.MinY, + SizeZ: z.MaxZ - z.MinZ, + }) +} + +// notifyPortalChange broadcasts a portal change event if a broadcaster is set. +func (h *ZonesHandler) notifyPortalChange(action string, p *zones.Portal) { + h.mu.RLock() + bc := h.bc + h.mu.RUnlock() + if bc == nil { + return + } + bc.BroadcastPortalChange(action, dashboard.PortalSnapshot{ + ID: p.ID, + Name: p.Name, + ZoneA: p.ZoneAID, + ZoneB: p.ZoneBID, + P1X: p.P1X, + P1Y: p.P1Y, + P1Z: p.P1Z, + P2X: p.P2X, + P2Y: p.P2Y, + P2Z: p.P2Z, + P3X: p.P3X, + P3Y: p.P3Y, + P3Z: p.P3Z, + NX: p.NX, + NY: p.NY, + NZ: p.NZ, + Width: p.Width, + Height: p.Height, + Enabled: p.Enabled, + }) +} + // Close closes the underlying manager. func (h *ZonesHandler) Close() error { return h.mgr.Close() diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index 5575fa2..4e2de17 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -139,6 +139,15 @@ type EventStore interface { LogEvent(eventType string, timestamp time.Time, zone, person string, blobID int, detailJSON, severity string) error } +// ZoneChangeBroadcaster notifies dashboard clients when zones or portals +// are created, updated, or deleted via the REST API. Implementations should +// both send an immediate typed broadcast and invalidate the snapshot cache +// so the next delta tick doesn't send stale data. +type ZoneChangeBroadcaster interface { + BroadcastZoneChange(action string, zone ZoneSnapshot) + BroadcastPortalChange(action string, portal PortalSnapshot) +} + // Client represents a dashboard WebSocket client type Client struct { hub *Hub @@ -681,6 +690,14 @@ func (h *Hub) tickDelta() { h.snap.zonesJSON = data } } + + ps := zones.GetAllPortals() + if data, err := json.Marshal(ps); err == nil { + if !bytesEqual(data, h.snap.portalsJSON) { + delta["portals"] = ps + h.snap.portalsJSON = data + } + } } h.snap.timestampMs = now @@ -1018,3 +1035,39 @@ func (h *Hub) BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, p data, _ := json.Marshal(msg) h.Broadcast(data) } + +// BroadcastZoneChange sends an immediate zone change event to all dashboard +// clients and invalidates the cached zone snapshot so the next delta tick +// reflects the new state. action is "created", "updated", or "deleted". +func (h *Hub) BroadcastZoneChange(action string, zone ZoneSnapshot) { + msg := map[string]interface{}{ + "type": "zone_change", + "action": action, + "zone": zone, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) + + // Invalidate cached zones snapshot so the next delta tick re-serialises. + h.snapMu.Lock() + h.snap.zonesJSON = nil + h.snapMu.Unlock() +} + +// BroadcastPortalChange sends an immediate portal change event to all dashboard +// clients and invalidates the cached portal snapshot. action is "created", +// "updated", or "deleted". +func (h *Hub) BroadcastPortalChange(action string, portal PortalSnapshot) { + msg := map[string]interface{}{ + "type": "portal_change", + "action": action, + "portal": portal, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) + + // Invalidate cached portals snapshot. + h.snapMu.Lock() + h.snap.portalsJSON = nil + h.snapMu.Unlock() +} diff --git a/mothership/internal/db/db.go b/mothership/internal/db/db.go index 88f0cd5..0936cf6 100644 --- a/mothership/internal/db/db.go +++ b/mothership/internal/db/db.go @@ -46,6 +46,12 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error) return nil, fmt.Errorf("create data dir: %w", err) } + // Create floorplan storage directory + floorplanDir := filepath.Join(dataDir, "floorplan") + if err := os.MkdirAll(floorplanDir, 0755); err != nil { + return nil, fmt.Errorf("create floorplan dir: %w", err) + } + // Acquire exclusive flock to prevent duplicate instances lockPath := filepath.Join(dataDir, ".lock") lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_WRONLY, 0644) diff --git a/mothership/internal/db/migrations.go b/mothership/internal/db/migrations.go index 99dca19..f18c0dd 100644 --- a/mothership/internal/db/migrations.go +++ b/mothership/internal/db/migrations.go @@ -166,8 +166,8 @@ func migration_001_initial_schema(tx *sql.Tx) error { cal_ay REAL, cal_bx REAL, cal_by REAL, - cal_distance_m REAL, - room_bounds_json TEXT, + distance_m REAL, + rotation_deg REAL, updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) ); @@ -489,25 +489,32 @@ func migration_009_sleep_records_unique(tx *sql.Tx) error { return err } -// migration_010_add_floorplan creates the floorplan table for storing +// migration_010_add_floorplan updates the floorplan table schema for // uploaded floor plan images and pixel-to-meter calibration data. +// For databases with the old schema (cal_distance_m, room_bounds_json), +// it adds the new columns (distance_m, rotation_deg). func migration_010_add_floorplan(tx *sql.Tx) error { - schema := ` --- Floor plan definition -CREATE TABLE IF NOT EXISTS floorplan ( - id INTEGER PRIMARY KEY CHECK (id = 1), - image_path TEXT, - cal_ax REAL, - cal_ay REAL, - cal_bx REAL, - cal_by REAL, - distance_m REAL, - rotation_deg REAL, - updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) -); -` - _, err := tx.Exec(schema) - return err + // Check if distance_m column already exists (indicates correct schema) + var colExists bool + err := tx.QueryRow(` + SELECT COUNT(*) > 0 FROM pragma_table_info('floorplan') WHERE name = 'distance_m' + `).Scan(&colExists) + if err != nil { + return err + } + + // If distance_m doesn't exist, we have the old schema - add new columns + if !colExists { + _, err = tx.Exec(`ALTER TABLE floorplan ADD COLUMN distance_m REAL`) + if err != nil { + return err + } + _, err = tx.Exec(`ALTER TABLE floorplan ADD COLUMN rotation_deg REAL`) + if err != nil { + return err + } + } + return nil } // migration_011_add_events_fts adds FTS5 full-text search for events. diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index eae9f9b..1c8ef75 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -2,7 +2,7 @@ # End-to-end integration test harness for Spaxel # Starts the mothership, runs the CSI simulator, and asserts on behavior -set -euo pipefail +set -eo pipefail # Get the script directory and project root SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -10,7 +10,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" MOTHERSHIP_DIR="$PROJECT_ROOT/mothership" # Configuration -MOTHERSHIP_IMAGE="${MOTHERSHIP_IMAGE:-ronaldraygun/spaxel:latest}" +MOTHERSHIP_IMAGE="${MOTHERSHIP_IMAGE:-spaxel-e2e:test}" LOCAL_BUILD="${LOCAL_BUILD:-false}" # Set to "true" to use local build instead of Docker MOTHERSHIP_CONTAINER="spaxel-e2e-test" MOTHERSHIP_PORT=8080 @@ -22,6 +22,10 @@ SIM_RATE=20 SIM_SEED=42 TEST_TIMEOUT=90 +# Initialize PIDs +SIM_PID="" +MOTHERSHIP_PID="" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -161,16 +165,21 @@ while true; do elapsed=$(($(date +%s) - start_time)) if [ $elapsed -ge $HEALTH_TIMEOUT ]; then log_error "Health check timeout after ${HEALTH_TIMEOUT}s" - docker logs "$MOTHERSHIP_CONTAINER" --tail 50 + if [ "$LOCAL_BUILD" = "true" ]; then + cat /tmp/spaxel-mothership.log | tail -50 + else + docker logs "$MOTHERSHIP_CONTAINER" --tail 50 + fi exit 1 fi health_response=$(http_get "http://localhost:$MOTHERSHIP_PORT/healthz" 1 0 2>/dev/null || echo "") if [ -n "$health_response" ]; then - status=$(json_field "$health_response" ".status") + status=$(echo "$health_response" | jq -r '.status // empty' 2>/dev/null || echo "") if [ "$status" = "ok" ]; then - log_info "Mothership is healthy (status: ok, uptime: $(json_field "$health_response" ".uptime_s")s)" + uptime=$(echo "$health_response" | jq -r '.uptime_s // 0' 2>/dev/null || echo "0") + log_info "Mothership is healthy (status: ok, uptime: ${uptime}s)" break fi fi