diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index becec7b..3b51a09 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,7 +2,7 @@ {"id":"spaxel-0fm8","title":"Add quiet hours gate tests","description":"Write tests for quiet hours gate: LOW at 23:00 with 22:00-07:00 quiet hours -> queued, URGENT at 23:00 -> delivered. Acceptance Criteria: Quiet hours tests pass (queueing, bypass).","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-11T08:15:07.990827798Z","created_by":"coding","updated_at":"2026-04-11T08:39:52.275270077Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-40tl"]} {"id":"spaxel-0ii","title":"Implement Zones CRUD REST endpoints","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Include OpenAPI-style godoc comments. Zone changes must reflect in live 3D view within one WebSocket cycle.","status":"closed","priority":2,"issue_type":"task","assignee":"echo","created_at":"2026-04-07T13:56:27.275139529Z","created_by":"coding","updated_at":"2026-04-07T19:01:48.974563569Z","closed_at":"2026-04-07T19:01:48.974408083Z","close_reason":"Zones CRUD REST endpoints already fully implemented: GET/POST /api/zones, PUT/DELETE /api/zones/{id}, GET /api/zones/{id}/history, plus portals CRUD. OpenAPI godoc comments, WebSocket broadcasting for live 3D view, 31 table-driven tests. go vet and go test pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-21n"],"dependencies":[{"issue_id":"spaxel-0ii","depends_on_id":"spaxel-3rd","type":"blocks","created_at":"2026-04-07T17:01:33.629176640Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-0ii","depends_on_id":"spaxel-5lo","type":"blocks","created_at":"2026-04-07T17:01:33.542274773Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-0j0k","title":"Implement responsive canvas resize and orientation handling","description":"Handle canvas resize on window resize and orientation change events, including iOS Safari visual viewport quirks and bottom navigation bar spacing.\n\n**Files:** dashboard/js/app.js (resize/orientationchange listeners), dashboard/css/expert.css (canvas height calculation)\n\n**Acceptance Criteria:**\n- Canvas resizes correctly on window resize event\n- Canvas resizes correctly on orientationchange event\n- renderer.setSize() called with updated dimensions\n- camera.aspect updated and camera.updateProjectionMatrix() called\n- Uses visualViewport.width/height on iOS Safari (fallback to window.innerWidth/Height)\n- Canvas height uses calc(100vh - 56px) when simple mode nav is visible","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-11T06:26:50.081049506Z","created_by":"coding","updated_at":"2026-04-11T06:51:20.327121642Z","closed_at":"2026-04-11T06:51:20.327061414Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-kth"]} -{"id":"spaxel-0w4","title":"Fleet status page","description":"## Background\n\nThe 3D scene is great for spatial context but poor for bulk fleet management tasks. With 6+ nodes, finding a specific node in the 3D view, checking its firmware version, and triggering an update involves hunting through the scene. The fleet status page provides a flat table view of all nodes with their key metrics and inline actions — the same information you would find in a server management panel, adapted for ESP32 nodes. It complements the 3D view rather than replacing it.\n\n## Fleet Status Table\n\nNew dashboard route: /fleet\n\nThe page layout:\n- Page header: \"Fleet Status\" title, total node count, online count, \"Update All\" button, \"Download report\" button\n- Filter and sort bar (below header)\n- Fleet table (main content)\n\nTable columns:\n1. Checkbox (for multi-select)\n2. Label (editable inline on double-click)\n3. MAC address (truncated, full on hover tooltip)\n4. Status: coloured dot + text. \"Online\" (green) / \"Offline\" (red) / \"Updating\" (yellow spinner)\n5. Firmware version: current version string. If a newer version is in the firmware manifest: version displayed in amber with an \"→ {new_version}\" indicator and an \"Update\" action badge.\n6. Uptime: formatted as \"3d 4h 12m\". Only valid when online.\n7. Current role: TX / RX / TX-RX / Passive. Small badge.\n8. Signal health: composite health score for this node's links as a small colour bar (green → red)\n9. Packet rate: \"{actual} / {configured} Hz\" as a fraction. Colour-coded: > 90% = green, 70-90% = amber, < 70% = red.\n10. Temperature: from health message. Shown as \"{N}°C\" or \"--\" if not reported. Alert colour if > 75°C.\n11. Actions column: [Locate] [OTA] [...more] buttons\n\nThe table rows are clickable (full row click = fly 3D camera to that node's position). Only clicking action buttons or the checkbox should not trigger the row fly-to.\n\n## Inline Label Edit\n\nDouble-clicking the label cell makes it inline-editable:\n- Input field replaces the text\n- Enter to confirm, Escape to cancel\n- Blur (click outside) to confirm\n- On confirm: PATCH /api/nodes/{mac}/label with the new label. Update the display.\n- Validation: max 32 characters, no control characters.\n\n## Action Buttons\n\n\"Locate\" (flash LED): sends a downstream command {\"type\":\"identify\"} to the node via WebSocket. The node flashes its onboard LED rapidly for 5 seconds. The button shows a spinner while the command is in-flight, then a brief green checkmark.\n\n\"OTA\" (firmware update): available only if the node's firmware version != latest in the manifest. Clicking shows a confirmation tooltip: \"Update Node [label] from v{current} to v{latest}? [Confirm] [Cancel]\". On confirm: POST /api/nodes/{mac}/ota. The node's row shows \"Updating\" status and a progress bar (populated from ota_status WebSocket messages for this node).\n\n\"More actions\" (... button): dropdown with: \"Re-assign role\", \"View health history\", \"View event history\", \"Remove from fleet\". Each with an appropriate icon.\n\n## Bulk Actions\n\nCheckbox column allows multi-selecting rows. When any row is selected, a bulk-actions bar slides in above the table:\n- \"Update {N} selected to latest firmware\" — confirms and triggers OTA for all selected nodes in sequence (with 30s stagger)\n- \"Re-assign roles\" — opens the role optimiser with the selected nodes included\n- \"Remove {N} from fleet\" — confirmation required: lists the nodes to be removed\n\nDeselect all: \"Clear selection\" button in the bulk actions bar, or uncheck all checkboxes.\n\n## Camera Fly-To\n\nClicking a table row (non-action click) triggers a smooth camera fly-to the node's position in the expert mode 3D view. The fleet page and expert mode are on different routes, so this requires:\n1. Store the target node MAC in localStorage or URL parameter (\"?highlight={mac}\")\n2. Redirect to the expert mode route / (or open expert mode in a second tab)\n3. On load, if ?highlight={mac} parameter is present, fly camera to that node's position\n\nAlternatively: if the fleet page is opened alongside the expert mode in a split-pane layout (future enhancement), coordinate via a shared state store. For Phase 9, the redirect approach is sufficient.\n\n## Sorting and Filtering\n\nColumn header click: sort by that column. First click = ascending, second = descending. Sort state shown with a small arrow indicator.\n\nFilter row (below column headers, toggle-able with a \"Filter\" button):\n- Label / MAC: text input, filters rows containing the substring\n- Status: dropdown \"All / Online / Offline\"\n- Firmware: dropdown \"All / Outdated only\"\n- Role: multi-select dropdown \"All / TX / RX / TX-RX / Passive\"\n\nActive filters: shown as chips above the table with individual dismiss buttons. \"Clear all filters\" link.\n\n## Download Report\n\n\"Download report\" button: exports the current fleet table (including all filters) as a CSV file. Columns: MAC, label, status, firmware_version, uptime_s, role, health_score, packet_rate_hz, temperature_c, last_seen.\n\nImplemented as a client-side CSV generation from the current table data (no API call needed if data is cached in the dashboard state). Use Blob + URL.createObjectURL for download.\n\n## REST API\n\nGET /api/fleet: returns all provisioned nodes with full details (same as GET /api/nodes but with more fields: uptime, firmware_version, temperature, health_score, packet_rate).\nPATCH /api/nodes/{mac}/label: update label\nPOST /api/nodes/{mac}/locate: send identify command\nPOST /api/nodes/{mac}/role: assign new role\nDELETE /api/nodes/{mac}: remove from fleet (disconnects and archives)\n\n## Files to Create or Modify\n\n- dashboard/fleet.html: fleet page HTML shell\n- dashboard/js/fleet.js: table rendering, sorting, filtering, bulk actions, inline edit\n- dashboard/css/fleet.css: fleet page styles\n- mothership/internal/dashboard/routes.go: fleet-specific API routes, /fleet HTML route\n\n## Tests\n\n- Test fleet table renders correctly with mock data: 4 nodes, verify all columns populated\n- Test inline label edit: double-click cell, type new label, Enter -> PATCH API called with correct body\n- Test bulk selection: check 3 nodes, verify bulk actions bar appears with correct count\n- Test bulk OTA triggers OTA for all 3 selected nodes\n- Test sorting: click \"Firmware version\" header -> rows sorted ascending by version string\n- Test filter: enter \"living\" in label filter -> only rows matching \"living\" visible\n- Test camera fly-to: clicking a row stores MAC in localStorage and redirects to expert mode with ?highlight parameter\n- Test CSV download: verify blob is created with correct headers and values\n\n## Acceptance Criteria\n\n- Fleet page loads with all nodes and their current metrics\n- Inline label edit saves correctly to the API and updates the display\n- Bulk OTA fires for all selected nodes with correct stagger\n- Sorting and filtering work correctly for all columns\n- Camera fly-to positions the 3D view correctly on the selected node after redirect\n- CSV download contains correct headers and all fleet data\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T02:06:06.562532476Z","created_by":"coding","updated_at":"2026-04-11T11:35:25.350321566Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3"],"dependencies":[{"issue_id":"spaxel-0w4","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.955755499Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-0w4","title":"Fleet status page","description":"## Background\n\nThe 3D scene is great for spatial context but poor for bulk fleet management tasks. With 6+ nodes, finding a specific node in the 3D view, checking its firmware version, and triggering an update involves hunting through the scene. The fleet status page provides a flat table view of all nodes with their key metrics and inline actions — the same information you would find in a server management panel, adapted for ESP32 nodes. It complements the 3D view rather than replacing it.\n\n## Fleet Status Table\n\nNew dashboard route: /fleet\n\nThe page layout:\n- Page header: \"Fleet Status\" title, total node count, online count, \"Update All\" button, \"Download report\" button\n- Filter and sort bar (below header)\n- Fleet table (main content)\n\nTable columns:\n1. Checkbox (for multi-select)\n2. Label (editable inline on double-click)\n3. MAC address (truncated, full on hover tooltip)\n4. Status: coloured dot + text. \"Online\" (green) / \"Offline\" (red) / \"Updating\" (yellow spinner)\n5. Firmware version: current version string. If a newer version is in the firmware manifest: version displayed in amber with an \"→ {new_version}\" indicator and an \"Update\" action badge.\n6. Uptime: formatted as \"3d 4h 12m\". Only valid when online.\n7. Current role: TX / RX / TX-RX / Passive. Small badge.\n8. Signal health: composite health score for this node's links as a small colour bar (green → red)\n9. Packet rate: \"{actual} / {configured} Hz\" as a fraction. Colour-coded: > 90% = green, 70-90% = amber, < 70% = red.\n10. Temperature: from health message. Shown as \"{N}°C\" or \"--\" if not reported. Alert colour if > 75°C.\n11. Actions column: [Locate] [OTA] [...more] buttons\n\nThe table rows are clickable (full row click = fly 3D camera to that node's position). Only clicking action buttons or the checkbox should not trigger the row fly-to.\n\n## Inline Label Edit\n\nDouble-clicking the label cell makes it inline-editable:\n- Input field replaces the text\n- Enter to confirm, Escape to cancel\n- Blur (click outside) to confirm\n- On confirm: PATCH /api/nodes/{mac}/label with the new label. Update the display.\n- Validation: max 32 characters, no control characters.\n\n## Action Buttons\n\n\"Locate\" (flash LED): sends a downstream command {\"type\":\"identify\"} to the node via WebSocket. The node flashes its onboard LED rapidly for 5 seconds. The button shows a spinner while the command is in-flight, then a brief green checkmark.\n\n\"OTA\" (firmware update): available only if the node's firmware version != latest in the manifest. Clicking shows a confirmation tooltip: \"Update Node [label] from v{current} to v{latest}? [Confirm] [Cancel]\". On confirm: POST /api/nodes/{mac}/ota. The node's row shows \"Updating\" status and a progress bar (populated from ota_status WebSocket messages for this node).\n\n\"More actions\" (... button): dropdown with: \"Re-assign role\", \"View health history\", \"View event history\", \"Remove from fleet\". Each with an appropriate icon.\n\n## Bulk Actions\n\nCheckbox column allows multi-selecting rows. When any row is selected, a bulk-actions bar slides in above the table:\n- \"Update {N} selected to latest firmware\" — confirms and triggers OTA for all selected nodes in sequence (with 30s stagger)\n- \"Re-assign roles\" — opens the role optimiser with the selected nodes included\n- \"Remove {N} from fleet\" — confirmation required: lists the nodes to be removed\n\nDeselect all: \"Clear selection\" button in the bulk actions bar, or uncheck all checkboxes.\n\n## Camera Fly-To\n\nClicking a table row (non-action click) triggers a smooth camera fly-to the node's position in the expert mode 3D view. The fleet page and expert mode are on different routes, so this requires:\n1. Store the target node MAC in localStorage or URL parameter (\"?highlight={mac}\")\n2. Redirect to the expert mode route / (or open expert mode in a second tab)\n3. On load, if ?highlight={mac} parameter is present, fly camera to that node's position\n\nAlternatively: if the fleet page is opened alongside the expert mode in a split-pane layout (future enhancement), coordinate via a shared state store. For Phase 9, the redirect approach is sufficient.\n\n## Sorting and Filtering\n\nColumn header click: sort by that column. First click = ascending, second = descending. Sort state shown with a small arrow indicator.\n\nFilter row (below column headers, toggle-able with a \"Filter\" button):\n- Label / MAC: text input, filters rows containing the substring\n- Status: dropdown \"All / Online / Offline\"\n- Firmware: dropdown \"All / Outdated only\"\n- Role: multi-select dropdown \"All / TX / RX / TX-RX / Passive\"\n\nActive filters: shown as chips above the table with individual dismiss buttons. \"Clear all filters\" link.\n\n## Download Report\n\n\"Download report\" button: exports the current fleet table (including all filters) as a CSV file. Columns: MAC, label, status, firmware_version, uptime_s, role, health_score, packet_rate_hz, temperature_c, last_seen.\n\nImplemented as a client-side CSV generation from the current table data (no API call needed if data is cached in the dashboard state). Use Blob + URL.createObjectURL for download.\n\n## REST API\n\nGET /api/fleet: returns all provisioned nodes with full details (same as GET /api/nodes but with more fields: uptime, firmware_version, temperature, health_score, packet_rate).\nPATCH /api/nodes/{mac}/label: update label\nPOST /api/nodes/{mac}/locate: send identify command\nPOST /api/nodes/{mac}/role: assign new role\nDELETE /api/nodes/{mac}: remove from fleet (disconnects and archives)\n\n## Files to Create or Modify\n\n- dashboard/fleet.html: fleet page HTML shell\n- dashboard/js/fleet.js: table rendering, sorting, filtering, bulk actions, inline edit\n- dashboard/css/fleet.css: fleet page styles\n- mothership/internal/dashboard/routes.go: fleet-specific API routes, /fleet HTML route\n\n## Tests\n\n- Test fleet table renders correctly with mock data: 4 nodes, verify all columns populated\n- Test inline label edit: double-click cell, type new label, Enter -> PATCH API called with correct body\n- Test bulk selection: check 3 nodes, verify bulk actions bar appears with correct count\n- Test bulk OTA triggers OTA for all 3 selected nodes\n- Test sorting: click \"Firmware version\" header -> rows sorted ascending by version string\n- Test filter: enter \"living\" in label filter -> only rows matching \"living\" visible\n- Test camera fly-to: clicking a row stores MAC in localStorage and redirects to expert mode with ?highlight parameter\n- Test CSV download: verify blob is created with correct headers and values\n\n## Acceptance Criteria\n\n- Fleet page loads with all nodes and their current metrics\n- Inline label edit saves correctly to the API and updates the display\n- Bulk OTA fires for all selected nodes with correct stagger\n- Sorting and filtering work correctly for all columns\n- Camera fly-to positions the 3D view correctly on the selected node after redirect\n- CSV download contains correct headers and all fleet data\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-03-28T02:06:06.562532476Z","created_by":"coding","updated_at":"2026-04-11T12:29:10.658386945Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:7"],"dependencies":[{"issue_id":"spaxel-0w4","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.955755499Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-16e","title":"Captive portal WiFi recovery","description":"## Background\n\nIf a node loses WiFi connectivity — because a router was replaced, credentials changed, or the node was moved out of range — it currently has no recovery path without physical intervention. The firmware already implements a captive portal AP fallback in firmware/main/wifi.c: after 10 consecutive WiFi connection failures with exponential backoff (up to 30s between attempts), the firmware transitions to AP mode with SSID \"spaxel-{last4mac}\". However, the captive portal HTTP config page served in this mode is currently a stub and needs to be completed. This bead finishes that implementation and adds the mothership-side offline detection and dashboard alert.\n\n## What Already Exists (Phase 1)\n\nfirmware/main/wifi.c implements the WIFI_STATE_CAPTIVE_PORTAL state transition after failure threshold. The firmware starts a SoftAP with SSID \"spaxel-{last4mac}\" and password-free (open AP). A stub HTTP server exists but serves only a 200 OK response with no content. The DNS server for captive portal hijacking is not yet implemented.\n\n## Captive Portal HTTP Server (Firmware)\n\nUse ESP-IDF's esp_http_server component to serve a config page at http://192.168.4.1/ when in AP mode.\n\nThe config page (served as inline HTML, no external resources — it must work fully offline):\n- Shows current provisioned SSID (from NVS, read-only display)\n- Form: new SSID input, password input, optional mothership host/port\n- Submit button: \"Save and Reconnect\"\n- On form submission: firmware writes new credentials to NVS (wifi_ssid, wifi_pass, optionally mothership_host/port) and calls esp_restart()\n- Minimal HTML with inline CSS: max 4KB so it fits in IRAM. No JavaScript needed.\n- Works on iOS Safari (no JS requirement), Android Chrome, desktop browsers\n\nThe HTTP server must handle the iOS and Android captive portal probe paths:\n- GET /hotspot-detect.html (iOS)\n- GET /generate_204 (Android)\n- GET /ncsi.txt (Windows)\nRespond to all of these with a 302 redirect to http://192.168.4.1/ to trigger the OS captive portal popup.\n\n## DNS Hijacking for Captive Portal Detection\n\nModern mobile OSes detect captive portals by making a DNS lookup for a known hostname (connectivitycheck.android.com, captive.apple.com, etc.) and checking if the response is correct. To trigger the OS captive portal popup automatically (so the user does not need to manually navigate to 192.168.4.1):\n\nImplement a minimal DNS server using lwIP's UDP API. The DNS server binds to UDP port 53 on the SoftAP interface (192.168.4.1). It responds to ALL DNS queries with an A record pointing to 192.168.4.1, regardless of the queried hostname. This causes the OS to detect the captive portal and show the popup.\n\nESP-IDF does not provide a ready-made DNS hijacking component — implement using esp_event_loop and lwIP udp_new()/udp_bind()/udp_recv() APIs. Keep the implementation minimal: parse enough of the DNS query to extract the transaction ID and question name, then build a minimal A record response.\n\n## Mothership Offline Detection and Dashboard Alert\n\nThe mothership detects node disconnection via heartbeat timeout. When a node's WebSocket closes (or health messages stop for > 30s), the mothership:\n1. Sets node status to OFFLINE in the node registry\n2. Broadcasts a node_offline WebSocket message to dashboard: {\"type\":\"node_offline\",\"mac\":\"aa:bb:cc:dd:ee:ff\",\"label\":\"Living Room\",\"last_seen\":\"2026-03-27T14:23:00Z\",\"likely_cause\":\"wifi_loss\"}\n\nThe dashboard shows an offline alert card:\n- \"Node [Living Room] went offline at 14:23. It may need WiFi reconfiguration.\"\n- Shows the captive portal AP SSID to connect to: \"Connect to Wi-Fi network 'spaxel-ff01' to reconfigure\"\n- Troubleshooting steps: 1) Check power LED, 2) Check WiFi router is online, 3) Connect to captive portal if LED is blinking\n\nThe likely_cause field is set by the mothership based on the last health message before disconnect (e.g. low WiFi RSSI in last health message -> \"wifi_range\", no recent health messages at all -> \"unknown\").\n\n## Reconnection and Portal Exit\n\nWhen the user submits new credentials in the captive portal and the node reboots:\n1. The SoftAP goes down (existing connections to \"spaxel-ff01\" are dropped)\n2. The node attempts WiFi connection with new credentials\n3. On success, connects to mothership — mothership sets status to ONLINE and broadcasts node_online event\n4. Dashboard shows reconnection notification: \"Node [Living Room] reconnected successfully\"\n\nIf the new credentials also fail (e.g. user mistyped), the node re-enters captive portal mode after another 10 failures. The portal should display an error on re-entry if the previous attempt failed.\n\n## Implementation Files\n\n- firmware/main/wifi.c: complete captive portal HTTP server, add DNS hijacking task\n- firmware/main/captive_portal.c (new): DNS hijacking task + HTTP handler functions\n- mothership/internal/ingestion/server.go: heartbeat timeout detection, node_offline event\n- mothership/internal/fleet/manager.go: node status tracking (ONLINE/OFFLINE/CAPTIVE_PORTAL)\n- dashboard/js/app.js: node_offline event handler, offline alert card rendering\n\n## Testing Challenges\n\nThis feature is difficult to unit test due to hardware dependency. Recommended approaches:\n1. Firmware: Integration test in QEMU (esp32s3 target) — simulate WiFi failures by mocking the WiFi event loop, verify state machine transitions to CAPTIVE_PORTAL after failure threshold\n2. DNS server: Unit test the DNS response builder function with a fixed query buffer and verify the response parses correctly\n3. HTTP config page: Unit test the form handler that writes to NVS (mock the NVS API)\n4. Mothership: Unit test heartbeat timeout detection with a fake time source, verify node_offline event is emitted\n\n## Acceptance Criteria\n\n- After WiFi failure (10 consecutive failures), node enters AP mode with SSID \"spaxel-{last4mac}\" within 5 minutes of initial loss\n- Mobile device connecting to the captive portal AP automatically sees the OS captive portal popup (tested on iOS 16+ and Android 12+)\n- Config page is served and functional without JavaScript\n- Updating credentials causes node to reboot and reconnect to mothership within 60s\n- Dashboard shows offline node alert within 30s of disconnection\n- Reconnection notification appears in dashboard within 30s of node reconnecting\n- Captive portal DNS server responds correctly to all DNS queries with 192.168.4.1\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:38:15.579673840Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.291892350Z","closed_at":"2026-03-28T05:36:39.291803146Z","close_reason":"Implemented: firmware/main/wifi.c (fb69190, 89 lines added) — captive portal AP mode (spaxel-XXXX SSID), esp_http_server config page at 192.168.4.1, DNS hijacking to trigger OS captive portal popup, NVS credential update on form submission","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-16e","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.901241816Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-16z3","title":"Add webhook delivery tests","description":"Write tests for webhook delivery: verifies JSON structure and base64 PNG field. Acceptance Criteria: Webhook delivery tests pass.","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-11T08:15:08.130589490Z","created_by":"coding","updated_at":"2026-04-11T09:07:17.379388848Z","closed_at":"2026-04-11T09:07:17.379333570Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-40tl"]} {"id":"spaxel-17u","title":"Phase 9: UX Polish & Accessibility","description":"Goal: Accessible to every household member. Power user efficiency.\n\nDeliverables:\n- Simple mode (card-based mobile-first UI, room occupancy cards, activity feed)\n- Ambient dashboard mode (/ambient for wall tablets, simplified top-down, auto-dim)\n- Spatial quick actions (right-click context menus on 3D elements, follow camera)\n- Command palette (Ctrl+K universal search/command, fuzzy matching)\n- Morning briefing (daily summary card, push notification option)\n- Guided troubleshooting (proactive contextual help, post-feedback explanations)\n- Mobile-responsive expert mode (touch orbit/pan/zoom)\n- Fleet status page (full table, bulk actions, camera fly-to)\n\nExit criteria: Non-technical user can check occupancy without training. Ambient mode runs 7+ days.","status":"closed","priority":3,"issue_type":"phase","assignee":"golf","created_at":"2026-03-27T01:55:55.188364609Z","created_by":"coding","updated_at":"2026-04-10T09:52:30.053325636Z","closed_at":"2026-04-10T09:52:30.053221884Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2"],"dependencies":[{"issue_id":"spaxel-17u","depends_on_id":"spaxel-2tlm","type":"blocks","created_at":"2026-04-10T02:03:09.311198456Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-2x1w","type":"blocks","created_at":"2026-04-10T02:03:09.525865595Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-7b3g","type":"blocks","created_at":"2026-04-10T02:03:09.377986112Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-bsek","type":"blocks","created_at":"2026-04-10T02:03:09.442882165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-duvd","type":"blocks","created_at":"2026-04-10T02:03:09.577884728Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-eelr","type":"blocks","created_at":"2026-04-10T02:03:09.654765280Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-jxru","type":"blocks","created_at":"2026-04-10T02:03:09.755196510Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T01:33:53.433780442Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-17u","depends_on_id":"spaxel-trsm","type":"blocks","created_at":"2026-04-10T02:03:09.707647462Z","created_by":"coding","metadata":"{}","thread_id":""}]} @@ -107,7 +107,7 @@ {"id":"spaxel-jkw","title":"Add Identify context menu to 3D view","description":"Add 'Identify (blink LED)' option to the right-click context menu in the 3D view that POSTs to /api/nodes/{mac}/identify.\n\n**Acceptance:**\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T11:11:50.047388206Z","created_by":"coding","updated_at":"2026-04-09T11:32:19.559003892Z","closed_at":"2026-04-09T11:32:19.558903935Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-h58"]} {"id":"spaxel-jv8q","title":"Cap devicePixelRatio on mobile","description":"On screens < 1024px width, cap devicePixelRatio at 2.0 using Math.min(window.devicePixelRatio, 2.0) in renderer initialization.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-11T06:59:34.136495463Z","created_by":"coding","updated_at":"2026-04-11T06:59:34.136495463Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-gufk"]} {"id":"spaxel-jxru","title":"Build fleet status page","description":"Full table view with bulk actions and camera fly-to functionality.","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-10T02:03:09.725489218Z","created_by":"coding","updated_at":"2026-04-10T09:39:43.690167347Z","closed_at":"2026-04-10T09:39:43.690013432Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4","mitosis-child","mitosis-depth:1","parent-spaxel-17u"]} -{"id":"spaxel-jy4","title":"Crowd flow visualisation","description":"## Background\n\nOver days and weeks, the movement patterns of household members accumulate into meaningful flows: the main corridor between bedroom and bathroom, the typical path from the front door to the kitchen, habitual dwell spots (the favourite chair, the home office desk, the kitchen counter). Visualising these as directional flow maps and dwell hotspot heatmaps provides useful insight into how the space is actually used — and can inform furniture placement, automation placement, and even architectural decisions. It's also a compelling visual that demonstrates the system's accumulated knowledge.\n\n## FlowAccumulator\n\nNew package: mothership/internal/analytics/flow.go\n\nFlowAccumulator subscribes to TrackManager updates (10 Hz) and accumulates trajectory data.\n\nTrajectory sampling: for each track update, if the track has moved > 0.2m since the last recorded waypoint (for that track), record the movement:\n- from_xyz: last waypoint position\n- to_xyz: current position\n- speed: metres per second at this step\n- person_id: if identity is known\n- timestamp\n\nThis 0.2m threshold prevents accumulating thousands of micro-samples for stationary people.\n\nSQLite table: trajectory_segments (id TEXT PRIMARY KEY, person_id TEXT, from_x REAL, from_y REAL, from_z REAL, to_x REAL, to_y REAL, to_z REAL, speed REAL, timestamp DATETIME). Only store ground plane (from_z and to_z floor-projected: set to 0 for the flow map, since we render on the ground plane).\n\nTable growth management: the table accumulates indefinitely. Prune segments older than 90 days (configurable) with a daily background job. With 4 people at typical home movement rates, 90 days generates approximately 50,000 segments — manageable for SQLite.\n\n## Flow Map Computation\n\nQuery: for each 0.25m grid cell (same resolution as OccupancyGrid in FusionEngine), average the movement vectors of all trajectory segments that pass through that cell.\n\nSQL approach: for each segment, determine which grid cells it passes through (Bresenham's line algorithm on the grid). Accumulate vector components (to_x - from_x, to_y - from_y) into per-cell accumulators.\n\nIn practice: compute on demand when requested (not continuously). Cache the result for up to 5 minutes (or until a \"flow dirty\" flag is set by new trajectory data).\n\nOutput: FlowMap struct with per-cell vectors (x_component, y_component) and a cell count. Serialised to JSON for the dashboard.\n\n## Dwell Hotspot Heatmap\n\nQuery: for each track update where speed < 0.1 m/s (stationary or near-stationary), increment the dwell counter for the corresponding 0.25m grid cell.\n\nSQLite table: dwell_accumulator (grid_x INT, grid_y INT, person_id TEXT, count INT, last_updated DATETIME, PRIMARY KEY (grid_x, grid_y, person_id)). Aggregated at the person+cell level for person-filtered views.\n\nOutput: DwellHeatmap struct mapping (grid_x, grid_y) to count. Normalised to [0, 1] by dividing by the max count across all cells.\n\n## Corridor Detection\n\nIdentify grid cells with consistently high flow volume AND low angular variance in their flow vectors. These are likely corridors or pathways.\n\nAlgorithm:\n1. For each cell, compute the circular variance of the flow vector angles across all segments that contributed. Low variance = directional consistency = corridor.\n2. Threshold: cells with segment_count > 10 AND circular_variance < 0.3 are candidate corridor cells.\n3. Connected component analysis: group adjacent corridor cells into corridor regions.\n4. Each corridor region is represented by its dominant direction and a bounding box.\n\nCorridor regions are stored in SQLite: detected_corridors (id, centroid_xyz, dominant_direction_xy, length_m, width_m, cell_count, last_computed). Recomputed weekly.\n\n## Time and Person Filters\n\nThe dashboard allows filtering flow data by:\n- Time range: \"Today\", \"This week\", \"This month\", custom date range. Implemented as SQL WHERE timestamp >= ? filters on the trajectory_segments table.\n- Person: filter to show only trajectories attributed to a specific person_id (or \"All people\").\n\nFiltered queries are run on-demand with SQL indices on (timestamp, person_id).\n\n## Dashboard Visualisation\n\nAdd two toggle-able layers to the 3D scene (in addition to existing layers):\n\n1. \"Flow\" layer: render flow vectors as animated arrows on the ground plane. Each arrow is positioned at the cell centre, oriented in the cell's average flow direction, and sized proportional to the flow volume (segment count). Use Three.js ArrowHelper for rendering. Animate: cycle the arrow colour from 0% to 100% opacity (flowing effect) on a 2-second loop. Only render cells with > 5 segments.\n\n2. \"Dwell Hotspot\" layer: render a heatmap on the ground plane as coloured rectangle patches (Three.js PlaneGeometry with MeshBasicMaterial, colour mapped from blue (low dwell) through green to red (high dwell)). Opacity 0.4. Only render cells with > 10 dwell samples.\n\n3. Corridor highlighting: detected corridors rendered as slightly raised platform geometry (extruded rectangle, height 0.01m) with a pathway colour (warm grey, opacity 0.3). Toggle-able as sub-option of the \"Flow\" layer.\n\nLayer controls: new \"Patterns\" section in the 3D layer control panel. Three checkboxes: \"Movement flows\", \"Dwell hotspots\", \"Corridors\". Time filter dropdown: \"All time / Last 7 days / Last 30 days\". Person filter dropdown.\n\n## REST API\n\nGET /api/analytics/flow?person_id=&since=&until= — returns FlowMap JSON\nGET /api/analytics/dwell?person_id=&since=&until= — returns DwellHeatmap JSON\nGET /api/analytics/corridors — returns list of DetectedCorridor\n\n## Tests\n\n- Test trajectory sampling: track moves 0.25m -> segment recorded; track moves 0.05m -> no segment\n- Test flow vector averaging: 5 segments all pointing East -> cell vector = (1, 0); 5 East + 5 North -> cell vector ~= (0.5, 0.5)\n- Test dwell accumulation: 100 track updates at speed=0 in cell (5, 7) -> dwell_accumulator[5][7] count = 100\n- Test corridor detection: 20 aligned segments in adjacent cells with angular_variance < 0.3 -> corridor detected\n- Test time-range filtering: insert segments at T-1day and T-8days; query since T-7days -> only T-1day segment returned\n- Test 90-day pruning job removes old segments\n\n## Acceptance Criteria\n\n- Flow layer renders correctly in 3D view with animated arrows for rooms with > 7 days of data\n- Dwell hotspot heatmap visible and renders high-use spots (favourite chair, kitchen counter) correctly\n- Corridor overlay visible with detected high-traffic pathways\n- Time and person filter controls update the rendered layers\n- Layer toggles show/hide each layer cleanly without scene rebuild\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-04-11T11:29:26.620373313Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:328"]} +{"id":"spaxel-jy4","title":"Crowd flow visualisation","description":"## Background\n\nOver days and weeks, the movement patterns of household members accumulate into meaningful flows: the main corridor between bedroom and bathroom, the typical path from the front door to the kitchen, habitual dwell spots (the favourite chair, the home office desk, the kitchen counter). Visualising these as directional flow maps and dwell hotspot heatmaps provides useful insight into how the space is actually used — and can inform furniture placement, automation placement, and even architectural decisions. It's also a compelling visual that demonstrates the system's accumulated knowledge.\n\n## FlowAccumulator\n\nNew package: mothership/internal/analytics/flow.go\n\nFlowAccumulator subscribes to TrackManager updates (10 Hz) and accumulates trajectory data.\n\nTrajectory sampling: for each track update, if the track has moved > 0.2m since the last recorded waypoint (for that track), record the movement:\n- from_xyz: last waypoint position\n- to_xyz: current position\n- speed: metres per second at this step\n- person_id: if identity is known\n- timestamp\n\nThis 0.2m threshold prevents accumulating thousands of micro-samples for stationary people.\n\nSQLite table: trajectory_segments (id TEXT PRIMARY KEY, person_id TEXT, from_x REAL, from_y REAL, from_z REAL, to_x REAL, to_y REAL, to_z REAL, speed REAL, timestamp DATETIME). Only store ground plane (from_z and to_z floor-projected: set to 0 for the flow map, since we render on the ground plane).\n\nTable growth management: the table accumulates indefinitely. Prune segments older than 90 days (configurable) with a daily background job. With 4 people at typical home movement rates, 90 days generates approximately 50,000 segments — manageable for SQLite.\n\n## Flow Map Computation\n\nQuery: for each 0.25m grid cell (same resolution as OccupancyGrid in FusionEngine), average the movement vectors of all trajectory segments that pass through that cell.\n\nSQL approach: for each segment, determine which grid cells it passes through (Bresenham's line algorithm on the grid). Accumulate vector components (to_x - from_x, to_y - from_y) into per-cell accumulators.\n\nIn practice: compute on demand when requested (not continuously). Cache the result for up to 5 minutes (or until a \"flow dirty\" flag is set by new trajectory data).\n\nOutput: FlowMap struct with per-cell vectors (x_component, y_component) and a cell count. Serialised to JSON for the dashboard.\n\n## Dwell Hotspot Heatmap\n\nQuery: for each track update where speed < 0.1 m/s (stationary or near-stationary), increment the dwell counter for the corresponding 0.25m grid cell.\n\nSQLite table: dwell_accumulator (grid_x INT, grid_y INT, person_id TEXT, count INT, last_updated DATETIME, PRIMARY KEY (grid_x, grid_y, person_id)). Aggregated at the person+cell level for person-filtered views.\n\nOutput: DwellHeatmap struct mapping (grid_x, grid_y) to count. Normalised to [0, 1] by dividing by the max count across all cells.\n\n## Corridor Detection\n\nIdentify grid cells with consistently high flow volume AND low angular variance in their flow vectors. These are likely corridors or pathways.\n\nAlgorithm:\n1. For each cell, compute the circular variance of the flow vector angles across all segments that contributed. Low variance = directional consistency = corridor.\n2. Threshold: cells with segment_count > 10 AND circular_variance < 0.3 are candidate corridor cells.\n3. Connected component analysis: group adjacent corridor cells into corridor regions.\n4. Each corridor region is represented by its dominant direction and a bounding box.\n\nCorridor regions are stored in SQLite: detected_corridors (id, centroid_xyz, dominant_direction_xy, length_m, width_m, cell_count, last_computed). Recomputed weekly.\n\n## Time and Person Filters\n\nThe dashboard allows filtering flow data by:\n- Time range: \"Today\", \"This week\", \"This month\", custom date range. Implemented as SQL WHERE timestamp >= ? filters on the trajectory_segments table.\n- Person: filter to show only trajectories attributed to a specific person_id (or \"All people\").\n\nFiltered queries are run on-demand with SQL indices on (timestamp, person_id).\n\n## Dashboard Visualisation\n\nAdd two toggle-able layers to the 3D scene (in addition to existing layers):\n\n1. \"Flow\" layer: render flow vectors as animated arrows on the ground plane. Each arrow is positioned at the cell centre, oriented in the cell's average flow direction, and sized proportional to the flow volume (segment count). Use Three.js ArrowHelper for rendering. Animate: cycle the arrow colour from 0% to 100% opacity (flowing effect) on a 2-second loop. Only render cells with > 5 segments.\n\n2. \"Dwell Hotspot\" layer: render a heatmap on the ground plane as coloured rectangle patches (Three.js PlaneGeometry with MeshBasicMaterial, colour mapped from blue (low dwell) through green to red (high dwell)). Opacity 0.4. Only render cells with > 10 dwell samples.\n\n3. Corridor highlighting: detected corridors rendered as slightly raised platform geometry (extruded rectangle, height 0.01m) with a pathway colour (warm grey, opacity 0.3). Toggle-able as sub-option of the \"Flow\" layer.\n\nLayer controls: new \"Patterns\" section in the 3D layer control panel. Three checkboxes: \"Movement flows\", \"Dwell hotspots\", \"Corridors\". Time filter dropdown: \"All time / Last 7 days / Last 30 days\". Person filter dropdown.\n\n## REST API\n\nGET /api/analytics/flow?person_id=&since=&until= — returns FlowMap JSON\nGET /api/analytics/dwell?person_id=&since=&until= — returns DwellHeatmap JSON\nGET /api/analytics/corridors — returns list of DetectedCorridor\n\n## Tests\n\n- Test trajectory sampling: track moves 0.25m -> segment recorded; track moves 0.05m -> no segment\n- Test flow vector averaging: 5 segments all pointing East -> cell vector = (1, 0); 5 East + 5 North -> cell vector ~= (0.5, 0.5)\n- Test dwell accumulation: 100 track updates at speed=0 in cell (5, 7) -> dwell_accumulator[5][7] count = 100\n- Test corridor detection: 20 aligned segments in adjacent cells with angular_variance < 0.3 -> corridor detected\n- Test time-range filtering: insert segments at T-1day and T-8days; query since T-7days -> only T-1day segment returned\n- Test 90-day pruning job removes old segments\n\n## Acceptance Criteria\n\n- Flow layer renders correctly in 3D view with animated arrows for rooms with > 7 days of data\n- Dwell hotspot heatmap visible and renders high-use spots (favourite chair, kitchen counter) correctly\n- Corridor overlay visible with detected high-traffic pathways\n- Time and person filter controls update the rendered layers\n- Layer toggles show/hide each layer cleanly without scene rebuild\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-04-11T12:26:03.263498375Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:334"]} {"id":"spaxel-jza","title":"Dashboard: PIN change flow","description":"## Overview\nAllow authenticated users to change their dashboard PIN after first setup.\n\n## Backend\n- POST /api/auth/change-pin — requires valid session; body: {old_pin:'...', new_pin:'...'}\n- Verify old_pin against current bcrypt hash; return HTTP 403 if mismatch\n- Hash new_pin with bcrypt cost=12; update auth.pin_bcrypt\n- Existing sessions remain valid after PIN change (session tokens are independent of PIN)\n- Return {ok:true} on success\n\n## Dashboard\n- Settings panel: 'Security' section with 'Change PIN' button\n- Modal form: old PIN → new PIN → confirm new PIN → Submit\n- On 403: show 'Incorrect current PIN' error inline\n- On success: show 'PIN changed successfully' toast; close modal\n\n## Acceptance\n- Old PIN still works immediately after change attempt fails (403)\n- New PIN works on next login after successful change\n- Active session cookie remains valid after PIN change\n- Requires: spaxel-nk6 (PIN auth)","status":"closed","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:43:09.899017181Z","created_by":"coding","updated_at":"2026-04-09T12:10:28.896292868Z","closed_at":"2026-04-09T12:10:28.896154010Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} {"id":"spaxel-k0rs","title":"Add batching logic tests","description":"Write tests for notification batching behavior: 3 LOW events in 10s -> 1 notification, 1 URGENT -> immediate. Acceptance Criteria: Batching tests pass (windowing, priority bypass).","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-11T08:15:07.948908838Z","created_by":"coding","updated_at":"2026-04-11T08:28:56.889629625Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-40tl"]} {"id":"spaxel-kgn4","title":"Implement feature discovery notifications","description":"Fire single non-blocking notification when features become available. Events: DiurnalBaselineActivated (7 days), FirstSleepSessionComplete, WeightUpdateApproved, AutomationFirstFired, PredictionModelReady (7 days per person). Each keyed by unique event ID in SQLite (feature_notifications table: event_id, fired_at, acknowledged_at). Never fires twice. Dismissed by tapping. Does not fire during quiet hours. Files: mothership/internal/help/notifier.go. Acceptance: each notification fires exactly once per feature; plain language messages; respects quiet hours; SQLite persistence prevents duplicates.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-11T03:34:49.150476063Z","created_by":"coding","updated_at":"2026-04-11T04:59:10.558085497Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:5","mitosis-child","mitosis-depth:1","parent-spaxel-tig"]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 97c9617..a929958 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -f99dc15a2d8b2e49c52231ff56d98c322dba8cb7 +bbb29a2629d9adb853c2924c688bf3d5f65d22b4 diff --git a/dashboard/js/app.js b/dashboard/js/app.js index 42ca9ec..c0cf848 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -2094,6 +2094,27 @@ drCtx.fillText('0.1', width - 2, padTop + 10); } + // ============================================ + // URL Parameter Handling + // ============================================ + function handleURLParameters() { + // Parse URL parameters for camera fly-to and other features + const params = new URLSearchParams(window.location.search); + const highlightMAC = params.get('highlight'); + + if (highlightMAC && window.Viz3D && window.Viz3D.flyToNode) { + console.log('[Spaxel] Highlight parameter found, flying to node:', highlightMAC); + // Wait a bit for scene to fully initialize before flying + setTimeout(function() { + window.Viz3D.flyToNode(highlightMAC); + // Clear the parameter from URL without reloading + const url = new URL(window.location); + url.searchParams.delete('highlight'); + window.history.replaceState({}, '', url); + }, 500); + } + } + // ============================================ // Initialization // ============================================ @@ -2107,6 +2128,9 @@ startDiurnalPolling(); animate(); + // Handle URL parameters after initialization + handleURLParameters(); + console.log('[Spaxel] Dashboard ready'); } diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 0779921..e6109c8 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -3683,7 +3683,7 @@ func main() { log.Printf("[INFO] Tracked blobs API registered at /api/blobs") // Tracks REST API (BLE-to-blob identity enriched tracked people) - tracksHandler := api.NewTracksHandler(pm) + tracksHandler := api.NewTracksHandlerFromSignal(pm) tracksHandler.RegisterRoutes(r) log.Printf("[INFO] Tracks API registered at /api/tracks") diff --git a/mothership/internal/analytics/handler.go b/mothership/internal/analytics/handler.go index 2f729e2..4621292 100644 --- a/mothership/internal/analytics/handler.go +++ b/mothership/internal/analytics/handler.go @@ -110,7 +110,8 @@ func (h *Handler) handleGetCorridors(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, corridors) + // Return in format expected by frontend: {corridors: [...]} + writeJSON(w, map[string]interface{}{"corridors": corridors}) } func writeJSON(w http.ResponseWriter, v interface{}) { diff --git a/mothership/internal/api/analytics_test.go b/mothership/internal/api/analytics_test.go new file mode 100644 index 0000000..02e2080 --- /dev/null +++ b/mothership/internal/api/analytics_test.go @@ -0,0 +1,516 @@ +// Package api provides tests for crowd flow analytics API endpoints. +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" + + "github.com/spaxel/mothership/internal/analytics" +) + +func TestAnalyticsHandler_GetFlowMap(t *testing.T) { + // Create temp database + tmpDir, err := os.MkdirTemp("", "analytics_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create flow accumulator and add test data + flowAcc := analytics.NewFlowAccumulator(db, 0.25) + if err := flowAcc.InitSchema(); err != nil { + t.Fatalf("Failed to init schema: %v", err) + } + + // Add some test segments + flowAcc.AddTrackUpdate("track-1", 0, 0, 0, 0.3, 0, 0, "person1") + flowAcc.AddTrackUpdate("track-1", 0.3, 0, 0, 0.3, 0, 0, "person1") + flowAcc.AddTrackUpdate("track-2", 1, 0, 0, 0.3, 0, 0, "person2") + flowAcc.AddTrackUpdate("track-2", 1.3, 0, 0, 0.3, 0, 0, "person2") + + if err := flowAcc.Flush(); err != nil { + t.Fatalf("Failed to flush accumulator: %v", err) + } + + // Create handler + handler := NewAnalyticsHandler(db, 0.25) + + // Test request with no filters + req := httptest.NewRequest("GET", "/api/analytics/flow", nil) + w := httptest.NewRecorder() + + handler.getFlowMap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var flowMap analytics.FlowMap + if err := json.NewDecoder(w.Body).Decode(&flowMap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(flowMap.Cells) == 0 { + t.Error("Expected at least one flow cell") + } + + // Test with person filter + req = httptest.NewRequest("GET", "/api/analytics/flow?person_id=person1", nil) + w = httptest.NewRecorder() + + handler.getFlowMap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for person filter, got %d", w.Code) + } + + var personFlowMap analytics.FlowMap + if err := json.NewDecoder(w.Body).Decode(&personFlowMap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Person-filtered flow should have fewer or equal cells compared to all flow + if len(personFlowMap.Cells) > len(flowMap.Cells) { + t.Error("Person-filtered flow should have <= cells than unfiltered flow") + } + + // Test with time range + since := time.Now().Add(-1 * time.Hour) + req = httptest.NewRequest("GET", "/api/analytics/flow?since="+since.Format(time.RFC3339), nil) + w = httptest.NewRecorder() + + handler.getFlowMap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for time range, got %d", w.Code) + } + + // Test invalid timestamp + req = httptest.NewRequest("GET", "/api/analytics/flow?since=invalid", nil) + w = httptest.NewRecorder() + + handler.getFlowMap(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid timestamp, got %d", w.Code) + } +} + +func TestAnalyticsHandler_GetDwellHeatmap(t *testing.T) { + // Create temp database + tmpDir, err := os.MkdirTemp("", "analytics_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create flow accumulator and add test dwell data + flowAcc := analytics.NewFlowAccumulator(db, 0.25) + if err := flowAcc.InitSchema(); err != nil { + t.Fatalf("Failed to init schema: %v", err) + } + + // Add 100 stationary updates at the same location + x, y := 1.5, 2.0 + flowAcc.AddTrackUpdate("track-1", x, y, 0, 0, 0, 0, "person1") + for i := 0; i < 99; i++ { + flowAcc.AddTrackUpdate("track-1", x, y, 0, 0, 0, 0, "person1") + } + + if err := flowAcc.Flush(); err != nil { + t.Fatalf("Failed to flush accumulator: %v", err) + } + + // Create handler + handler := NewAnalyticsHandler(db, 0.25) + + // Test request with no filters + req := httptest.NewRequest("GET", "/api/analytics/dwell", nil) + w := httptest.NewRecorder() + + handler.getDwellHeatmap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var heatmap analytics.DwellHeatmap + if err := json.NewDecoder(w.Body).Decode(&heatmap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(heatmap.Cells) == 0 { + t.Error("Expected at least one dwell cell") + } + + if heatmap.MaxCount == 0 { + t.Error("Expected max count > 0") + } + + // Test with person filter + req = httptest.NewRequest("GET", "/api/analytics/dwell?person_id=person1", nil) + w = httptest.NewRecorder() + + handler.getDwellHeatmap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for person filter, got %d", w.Code) + } + + var personHeatmap analytics.DwellHeatmap + if err := json.NewDecoder(w.Body).Decode(&personHeatmap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if personHeatmap.PersonID != "person1" { + t.Errorf("Expected person_id to be 'person1', got '%s'", personHeatmap.PersonID) + } +} + +func TestAnalyticsHandler_GetCorridors(t *testing.T) { + // Create temp database + tmpDir, err := os.MkdirTemp("", "analytics_api_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create flow accumulator and add test corridor data + flowAcc := analytics.NewFlowAccumulator(db, 0.25) + if err := flowAcc.InitSchema(); err != nil { + t.Fatalf("Failed to init schema: %v", err) + } + + // Create aligned segments for corridor detection + for i := 0; i < 20; i++ { + trackID := string(rune('a' + i)) + x := float64(i) * 0.25 + flowAcc.AddTrackUpdate(trackID, x, 0, 1.0, 0.25, 0, 0, "") + flowAcc.AddTrackUpdate(trackID, x+0.25, 0, 1.0, 0.25, 0, 0, "") + } + + if err := flowAcc.Flush(); err != nil { + t.Fatalf("Failed to flush accumulator: %v", err) + } + + // Run corridor detection + if _, err := flowAcc.DetectCorridors(); err != nil { + t.Logf("Warning: Failed to detect corridors (may need more data): %v", err) + } + + // Create handler + handler := NewAnalyticsHandler(db, 0.25) + + // Test request + req := httptest.NewRequest("GET", "/api/analytics/corridors", nil) + w := httptest.NewRecorder() + + handler.getCorridors(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Parse response - it might be an array directly or wrapped in an object + body := w.Body.String() + var corridors []analytics.DetectedCorridor + if strings.HasPrefix(body, "[") { + // Direct array + if err := json.Unmarshal(w.Body.Bytes(), &corridors); err != nil { + t.Fatalf("Failed to decode response as array: %v", err) + } + } else { + // Wrapped in object + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + corridorsArray, ok := response["corridors"].([]interface{}) + if ok { + // Convert to array of DetectedCorridor + corridorsJSON, _ := json.Marshal(corridorsArray) + json.Unmarshal(corridorsJSON, &corridors) + } + } + + // Corridors may be empty if not enough data, but response should be valid + // We just verify the response was successful +} + +func TestAnalyticsHandler_Integration(t *testing.T) { + // Integration test that verifies the full flow from API request to response + // Create temp database + tmpDir, err := os.MkdirTemp("", "analytics_api_integration") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create handler + handler := NewAnalyticsHandler(db, 0.25) + + // Test 1: Flow map with no data should return empty cells + t.Run("EmptyFlowMap", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/analytics/flow", nil) + w := httptest.NewRecorder() + + handler.getFlowMap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var flowMap analytics.FlowMap + if err := json.NewDecoder(w.Body).Decode(&flowMap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if flowMap.SegmentCount != 0 { + t.Errorf("Expected 0 segments, got %d", flowMap.SegmentCount) + } + }) + + // Test 2: Dwell heatmap with no data should return empty cells + t.Run("EmptyDwellHeatmap", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/analytics/dwell", nil) + w := httptest.NewRecorder() + + handler.getDwellHeatmap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var heatmap analytics.DwellHeatmap + if err := json.NewDecoder(w.Body).Decode(&heatmap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(heatmap.Cells) != 0 { + t.Errorf("Expected 0 cells, got %d", len(heatmap.Cells)) + } + }) + + // Test 3: Corridors with no data should return empty array + t.Run("EmptyCorridors", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/analytics/corridors", nil) + w := httptest.NewRecorder() + + handler.getCorridors(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Parse response - it might be an array directly or wrapped in an object + body := w.Body.String() + if strings.HasPrefix(body, "[") { + var corridors []analytics.DetectedCorridor + if err := json.Unmarshal([]byte(body), &corridors); err != nil { + t.Fatalf("Failed to decode response as array: %v", err) + } + if len(corridors) != 0 { + t.Errorf("Expected 0 corridors, got %d", len(corridors)) + } + } else { + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + // Just verify we got a valid response + } + }) + + // Test 4: Full workflow - add data then query + t.Run("FullWorkflow", func(t *testing.T) { + flowAcc := handler.GetFlowAccumulator() + + // Add trajectory data + flowAcc.AddTrackUpdate("track-1", 0, 0, 0, 0.3, 0, 0, "alice") + flowAcc.AddTrackUpdate("track-1", 0.3, 0, 0, 0.3, 0, 0, "alice") + + // Add dwell data + flowAcc.AddTrackUpdate("track-2", 1.0, 1.0, 0, 0, 0, 0, "bob") + for i := 0; i < 50; i++ { + flowAcc.AddTrackUpdate("track-2", 1.0, 1.0, 0, 0, 0, 0, "bob") + } + + if err := flowAcc.Flush(); err != nil { + t.Fatalf("Failed to flush: %v", err) + } + + // Query flow map + req := httptest.NewRequest("GET", "/api/analytics/flow", nil) + w := httptest.NewRecorder() + handler.getFlowMap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var flowMap analytics.FlowMap + if err := json.NewDecoder(w.Body).Decode(&flowMap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(flowMap.Cells) == 0 { + t.Error("Expected flow cells after adding data") + } + + // Query dwell heatmap + req = httptest.NewRequest("GET", "/api/analytics/dwell", nil) + w = httptest.NewRecorder() + handler.getDwellHeatmap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var heatmap analytics.DwellHeatmap + if err := json.NewDecoder(w.Body).Decode(&heatmap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(heatmap.Cells) == 0 { + t.Error("Expected dwell cells after adding data") + } + + // Query with person filter + req = httptest.NewRequest("GET", "/api/analytics/dwell?person_id=alice", nil) + w = httptest.NewRecorder() + handler.getDwellHeatmap(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for person filter, got %d", w.Code) + } + + var aliceHeatmap analytics.DwellHeatmap + if err := json.NewDecoder(w.Body).Decode(&aliceHeatmap); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Alice should have fewer dwell samples than all people combined + if len(aliceHeatmap.Cells) > len(heatmap.Cells) { + t.Error("Alice's dwell cells should be <= total dwell cells") + } + }) +} + +func TestAnalyticsHandler_RegisterRoutes(t *testing.T) { + // Test that routes are properly registered + tmpDir, err := os.MkdirTemp("", "analytics_routes_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + handler := NewAnalyticsHandler(db, 0.25) + + // Verify the handler has the expected accumulator + if handler.GetFlowAccumulator() == nil { + t.Error("Expected GetFlowAccumulator to return non-nil") + } +} + +func TestAnalyticsHandler_ContentHeaders(t *testing.T) { + // Test that responses have correct content type + tmpDir, err := os.MkdirTemp("", "analytics_headers_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + handler := NewAnalyticsHandler(db, 0.25) + + req := httptest.NewRequest("GET", "/api/analytics/flow", nil) + w := httptest.NewRecorder() + + handler.getFlowMap(w, req) + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType) + } +} + +func TestAnalyticsHandler_ErrorHandling(t *testing.T) { + // Test error handling with nil accumulator by creating a handler with nil db + // We need to create a proper database for the handler to work + tmpDir, err := os.MkdirTemp("", "analytics_error_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + handler := NewAnalyticsHandler(db, 0.25) + + // Test with invalid timestamp format + req := httptest.NewRequest("GET", "/api/analytics/flow?since=invalid-timestamp", nil) + w := httptest.NewRecorder() + + handler.getFlowMap(w, req) + + // Should return 400 Bad Request for invalid timestamp + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid timestamp, got %d", w.Code) + } +} diff --git a/mothership/internal/api/tracks.go b/mothership/internal/api/tracks.go index 4554ca7..9728fb5 100644 --- a/mothership/internal/api/tracks.go +++ b/mothership/internal/api/tracks.go @@ -44,6 +44,26 @@ func NewTracksHandler(provider TracksProvider) *TracksHandler { return &TracksHandler{provider: provider} } +// signalProcessorTracksAdapter wraps *signal.ProcessorManager to implement TracksProvider. +type signalProcessorTracksAdapter struct { + pm interface { + GetTrackedBlobs() []signal.TrackedBlob + } +} + +func (a *signalProcessorTracksAdapter) GetTrackedBlobs() []TrackedBlob { + return a.pm.GetTrackedBlobs() +} + +// NewTracksHandlerFromSignal creates a TracksHandler from a *signal.ProcessorManager. +func NewTracksHandlerFromSignal(pm interface { + GetTrackedBlobs() []signal.TrackedBlob +}) *TracksHandler { + return &TracksHandler{ + provider: &signalProcessorTracksAdapter{pm: pm}, + } +} + // RegisterRoutes mounts tracks endpoints on r. // // GET /api/tracks diff --git a/mothership/internal/fleet/fleet_test.go b/mothership/internal/fleet/fleet_test.go index f3d0527..20a834d 100644 --- a/mothership/internal/fleet/fleet_test.go +++ b/mothership/internal/fleet/fleet_test.go @@ -43,6 +43,17 @@ func (m *mockNotifier) GetConnectedMACs() []string { return append([]string{}, m.connected...) } +func (m *mockNotifier) SendIdentifyToMAC(mac string, durationMS int) bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, c := range m.connected { + if c == mac { + return true + } + } + return false +} + func (m *mockNotifier) sentRole(mac string) string { m.mu.Lock() defer m.mu.Unlock() @@ -478,17 +489,17 @@ func TestManager_AutoAwayActivates(t *testing.T) { t.Errorf("Expected mode to be away after auto-away, got %s", mgr.GetSystemMode()) } - events := modeBroadcaster.getEvents() - if len(events) != 1 { - t.Fatalf("Expected 1 mode change event, got %d", len(events)) + modeEvents := modeBroadcaster.getEvents() + if len(modeEvents) != 1 { + t.Fatalf("Expected 1 mode change event, got %d", len(modeEvents)) } - if events[0].NewMode != events.ModeAway { - t.Errorf("Expected new mode to be away, got %s", events[0].NewMode) + if modeEvents[0].NewMode != events.ModeAway { + t.Errorf("Expected new mode to be away, got %s", modeEvents[0].NewMode) } - if events[0].Reason != "auto_away" { - t.Errorf("Expected reason to be auto_away, got %s", events[0].Reason) + if modeEvents[0].Reason != "auto_away" { + t.Errorf("Expected reason to be auto_away, got %s", modeEvents[0].Reason) } } @@ -527,25 +538,25 @@ func TestManager_AutoDisarmTriggers(t *testing.T) { t.Errorf("Expected mode to be home after auto-disarm, got %s", mgr.GetSystemMode()) } - events := modeBroadcaster.getEvents() - if len(events) != 1 { - t.Fatalf("Expected 1 mode change event, got %d", len(events)) + modeEvents := modeBroadcaster.getEvents() + if len(modeEvents) != 1 { + t.Fatalf("Expected 1 mode change event, got %d", len(modeEvents)) } - if events[0].NewMode != events.ModeHome { - t.Errorf("Expected new mode to be home, got %s", events[0].NewMode) + if modeEvents[0].NewMode != events.ModeHome { + t.Errorf("Expected new mode to be home, got %s", modeEvents[0].NewMode) } - if events[0].Reason != "auto_disarm" { - t.Errorf("Expected reason to be auto_disarm, got %s", events[0].Reason) + if modeEvents[0].Reason != "auto_disarm" { + t.Errorf("Expected reason to be auto_disarm, got %s", modeEvents[0].Reason) } - if events[0].PersonID != "person1" { - t.Errorf("Expected person_id to be person1, got %s", events[0].PersonID) + if modeEvents[0].PersonID != "person1" { + t.Errorf("Expected person_id to be person1, got %s", modeEvents[0].PersonID) } - if events[0].PersonName != "Alice" { - t.Errorf("Expected person_name to be Alice, got %s", events[0].PersonName) + if modeEvents[0].PersonName != "Alice" { + t.Errorf("Expected person_name to be Alice, got %s", modeEvents[0].PersonName) } } diff --git a/mothership/internal/fleet/handler_test.go b/mothership/internal/fleet/handler_test.go index 2edaf32..4056aba 100644 --- a/mothership/internal/fleet/handler_test.go +++ b/mothership/internal/fleet/handler_test.go @@ -14,7 +14,9 @@ import ( // mockNodeIdentifier is a mock implementation of NodeIdentifier for testing. type mockNodeIdentifier struct { - sendIdentifyFunc func(mac string, durationMS int) bool + sendIdentifyFunc func(mac string, durationMS int) bool + sendRebootFunc func(mac string, delayMS int) bool + getConnectedMACs func() []string } func (m *mockNodeIdentifier) SendIdentifyToMAC(mac string, durationMS int) bool { @@ -24,20 +26,30 @@ func (m *mockNodeIdentifier) SendIdentifyToMAC(mac string, durationMS int) bool return true } -// mockRegistry is a minimal mock of Registry for testing. -type mockRegistry struct { - nodes map[string]NodeRecord - err error +func (m *mockNodeIdentifier) SendRebootToMAC(mac string, delayMS int) bool { + if m.sendRebootFunc != nil { + return m.sendRebootFunc(mac, delayMS) + } + return true } -func (m *mockRegistry) GetNode(mac string) (NodeRecord, error) { - if m.err != nil { - return NodeRecord{}, m.err +func (m *mockNodeIdentifier) GetConnectedMACs() []string { + if m.getConnectedMACs != nil { + return m.getConnectedMACs() } + return []string{} +} + +// mockRegistry is a mock implementation of Registry for testing. +type mockRegistry struct { + nodes map[string]NodeRecord +} + +func (m *mockRegistry) GetNode(mac string) (*NodeRecord, error) { if node, ok := m.nodes[mac]; ok { - return node, nil + return &node, nil } - return NodeRecord{}, sql.ErrNoRows + return nil, sql.ErrNoRows } func (m *mockRegistry) GetAllNodes() ([]NodeRecord, error) { @@ -45,31 +57,76 @@ func (m *mockRegistry) GetAllNodes() ([]NodeRecord, error) { for _, node := range m.nodes { nodes = append(nodes, node) } - return nodes, m.err + return nodes, nil +} + +func (m *mockRegistry) SetNodeLabel(mac, label string) error { + if node, ok := m.nodes[mac]; ok { + node.Name = label + m.nodes[mac] = node + return nil + } + return sql.ErrNoRows } func (m *mockRegistry) SetNodePosition(mac string, x, y, z float64) error { - return nil + if node, ok := m.nodes[mac]; ok { + node.PosX = x + node.PosY = y + node.PosZ = z + m.nodes[mac] = node + return nil + } + return sql.ErrNoRows } func (m *mockRegistry) AddVirtualNode(mac, name string, x, y, z float64) error { + m.nodes[mac] = NodeRecord{ + MAC: mac, + Name: name, + Role: "virtual", + PosX: x, + PosY: y, + PosZ: z, + Virtual: true, + } return nil } func (m *mockRegistry) DeleteNode(mac string) error { + delete(m.nodes, mac) return nil } -func (m *mockRegistry) SetRoom(room RoomConfig) error { +func (m *mockRegistry) UpsertNode(mac, firmware, chip string) error { + if _, ok := m.nodes[mac]; !ok { + m.nodes[mac] = NodeRecord{ + MAC: mac, + Name: "", + Role: "rx", + FirmwareVersion: firmware, + ChipModel: chip, + } + } else { + node := m.nodes[mac] + node.FirmwareVersion = firmware + node.ChipModel = chip + m.nodes[mac] = node + } return nil } -func (m *mockRegistry) GetRoom() (RoomConfig, error) { - return RoomConfig{}, nil +func (m *mockRegistry) SetNodeRole(mac, role string) error { + if node, ok := m.nodes[mac]; ok { + node.Role = role + m.nodes[mac] = node + return nil + } + return sql.ErrNoRows } -func (m *mockRegistry) GetNodesByRole(role string) ([]NodeRecord, error) { - return nil, nil +func (m *mockRegistry) Close() error { + return nil } func TestHandlerIdentifyNode(t *testing.T) { @@ -149,22 +206,17 @@ func TestHandlerIdentifyNode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create a mock registry with the test node - reg := &mockRegistry{ - nodes: make(map[string]NodeRecord), - } + // Create a real registry with the test node + reg := newTestRegistry(t) if tt.nodeExists { - reg.nodes[tt.mac] = NodeRecord{ - MAC: tt.mac, - Name: "Test Node", - Role: "rx", + err := reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3") + if err != nil { + t.Fatalf("UpsertNode: %v", err) } } - // Create a manager with the mock registry - mgr := &Manager{ - registry: reg, - } + // Create a manager with the registry + mgr := NewManager(reg) // Create handler with mock node identifier h := &Handler{ @@ -239,19 +291,13 @@ func TestHandlerIdentifyNodeDurationParsing(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var actualDuration int - reg := &mockRegistry{ - nodes: map[string]NodeRecord{ - "AA:BB:CC:DD:EE:FF": { - MAC: "AA:BB:CC:DD:EE:FF", - Name: "Test Node", - Role: "rx", - }, - }, + reg := newTestRegistry(t) + err := reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3") + if err != nil { + t.Fatalf("UpsertNode: %v", err) } - mgr := &Manager{ - registry: reg, - } + mgr := NewManager(reg) h := &Handler{ mgr: mgr, @@ -337,14 +383,8 @@ func TestIdentifyNodeRequest(t *testing.T) { // ─── System mode endpoint tests ───────────────────────────────────────────── func TestHandlerGetSystemMode(t *testing.T) { - reg := &mockRegistry{ - nodes: make(map[string]NodeRecord), - } - - mgr := &Manager{ - registry: reg, - } - + reg := newTestRegistry(t) + mgr := NewManager(reg) h := &Handler{mgr: mgr} req := httptest.NewRequest("GET", "/api/mode", nil) @@ -453,3 +493,971 @@ func TestHandlerSetSystemMode(t *testing.T) { }) } } + +// ─── Fleet list endpoint tests ──────────────────────────────────────────────── + +func TestHandlerListFleet(t *testing.T) { + reg := newTestRegistry(t) + reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3") + reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1") + reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 1.0, 2.0, 3.0) + reg.UpsertNode("11:22:33:44:55:66", "1.1.0", "ESP32-S3") + reg.SetNodeLabel("11:22:33:44:55:66", "Node 2") + reg.SetNodePosition("11:22:33:44:55:66", 4.0, 5.0, 6.0) + + mgr := NewManager(reg) + + h := &Handler{ + mgr: mgr, + nodeID: &mockNodeIdentifier{ + sendIdentifyFunc: func(mac string, durationMS int) bool { + return true + }, + getConnectedMACs: func() []string { + return []string{"AA:BB:CC:DD:EE:FF"} + }, + }, + } + + req := httptest.NewRequest("GET", "/api/fleet", nil) + w := httptest.NewRecorder() + + h.listFleet(w, req) + + if w.Code != http.StatusOK { + t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK) + } + + var nodes []FleetNode + if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(nodes) != 2 { + t.Errorf("Expected 2 nodes, got %d", len(nodes)) + } + + // Check first node (should be online) + if nodes[0].MAC != "AA:BB:CC:DD:EE:FF" { + t.Errorf("Expected first node MAC to be AA:BB:CC:DD:EE:FF, got %s", nodes[0].MAC) + } + if nodes[0].Status != "online" { + t.Errorf("Expected first node status to be online, got %s", nodes[0].Status) + } + + // Check second node (should be offline - not in connected list) + if nodes[1].MAC != "11:22:33:44:55:66" { + t.Errorf("Expected second node MAC to be 11:22:33:44:55:66, got %s", nodes[1].MAC) + } + if nodes[1].Status != "offline" { + t.Errorf("Expected second node status to be offline, got %s", nodes[1].Status) + } +} + +func TestHandlerListFleetEmpty(t *testing.T) { + reg := newTestRegistry(t) + + mgr := NewManager(reg) + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("GET", "/api/fleet", nil) + w := httptest.NewRecorder() + + h.listFleet(w, req) + + if w.Code != http.StatusOK { + t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK) + } + + var nodes []FleetNode + if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(nodes) != 0 { + t.Errorf("Expected 0 nodes, got %d", len(nodes)) + } +} + +// ─── Get node endpoint tests ─────────────────────────────────────────────────── + +func TestHandlerGetNode(t *testing.T) { + tests := []struct { + name string + mac string + nodeExists bool + wantStatus int + }{ + { + name: "node found", + mac: "AA:BB:CC:DD:EE:FF", + nodeExists: true, + wantStatus: http.StatusOK, + }, + { + name: "node not found", + mac: "AA:BB:CC:DD:EE:FF", + nodeExists: false, + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := newTestRegistry(t) + if tt.nodeExists { + reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3") + reg.SetNodeLabel(tt.mac, "Test Node") + } + + mgr := NewManager(reg) + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("GET", "/api/nodes/"+tt.mac, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", tt.mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.getNode(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("getNode() status = %v, want %v", w.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var node NodeRecord + if err := json.NewDecoder(w.Body).Decode(&node); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if node.MAC != tt.mac { + t.Errorf("Expected MAC to be %s, got %s", tt.mac, node.MAC) + } + } + }) + } +} + +// ─── Update node label endpoint tests ─────────────────────────────────────────── + +func TestHandlerUpdateNodeLabel(t *testing.T) { + tests := []struct { + name string + mac string + requestBody string + nodeExists bool + wantStatus int + expectedLabel string + }{ + { + name: "successful label update", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"label": "New Label"}`, + nodeExists: true, + wantStatus: http.StatusNoContent, + expectedLabel: "New Label", + }, + { + name: "node not found", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"label": "New Label"}`, + nodeExists: false, + wantStatus: http.StatusNotFound, + }, + { + name: "invalid request body", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `invalid json`, + nodeExists: true, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + if tt.nodeExists { + reg.nodes[tt.mac] = NodeRecord{ + MAC: tt.mac, + Name: "Old Label", + Role: "rx", + } + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("PATCH", "/api/nodes/"+tt.mac+"/label", bytes.NewBufferString(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", tt.mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.updateNodeLabel(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("updateNodeLabel() status = %v, want %v", w.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusNoContent { + // Verify the label was updated + if reg.nodes[tt.mac].Name != tt.expectedLabel { + t.Errorf("Expected label to be %s, got %s", tt.expectedLabel, reg.nodes[tt.mac].Name) + } + } + }) + } +} + +// ─── Set node role endpoint tests ─────────────────────────────────────────────── + +func TestHandlerSetNodeRole(t *testing.T) { + tests := []struct { + name string + mac string + requestBody string + nodeExists bool + wantStatus int + expectedRole string + }{ + { + name: "successful role change to tx", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"role": "tx"}`, + nodeExists: true, + wantStatus: http.StatusOK, + expectedRole: "tx", + }, + { + name: "successful role change to rx", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"role": "rx"}`, + nodeExists: true, + wantStatus: http.StatusOK, + expectedRole: "rx", + }, + { + name: "successful role change to tx_rx", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"role": "tx_rx"}`, + nodeExists: true, + wantStatus: http.StatusOK, + expectedRole: "tx_rx", + }, + { + name: "successful role change to passive", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"role": "passive"}`, + nodeExists: true, + wantStatus: http.StatusOK, + expectedRole: "passive", + }, + { + name: "node not found", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"role": "tx"}`, + nodeExists: false, + wantStatus: http.StatusNotFound, + }, + { + name: "invalid role", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"role": "invalid"}`, + nodeExists: true, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid request body", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `invalid json`, + nodeExists: true, + wantStatus: http.StatusBadRequest, + }, + { + name: "empty role", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"role": ""}`, + nodeExists: true, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + if tt.nodeExists { + reg.nodes[tt.mac] = NodeRecord{ + MAC: tt.mac, + Name: "Test Node", + Role: "rx", + } + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/role", bytes.NewBufferString(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", tt.mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.setNodeRole(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("setNodeRole() status = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +// ─── Delete node endpoint tests ───────────────────────────────────────────────── + +func TestHandlerDeleteNode(t *testing.T) { + tests := []struct { + name string + mac string + nodeExists bool + wantStatus int + }{ + { + name: "successful deletion", + mac: "AA:BB:CC:DD:EE:FF", + nodeExists: true, + wantStatus: http.StatusNoContent, + }, + { + name: "delete non-existent node succeeds", + mac: "AA:BB:CC:DD:EE:FF", + nodeExists: false, + wantStatus: http.StatusNoContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + if tt.nodeExists { + reg.nodes[tt.mac] = NodeRecord{ + MAC: tt.mac, + Name: "Test Node", + Role: "rx", + } + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("DELETE", "/api/nodes/"+tt.mac, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", tt.mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.deleteNode(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("deleteNode() status = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +// ─── OTA endpoint tests ─────────────────────────────────────────────────────────── + +func TestHandlerTriggerNodeOTA(t *testing.T) { + tests := []struct { + name string + mac string + requestBody string + nodeExists bool + otaAvailable bool + wantStatus int + }{ + { + name: "successful OTA trigger", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{}`, + nodeExists: true, + otaAvailable: true, + wantStatus: http.StatusOK, + }, + { + name: "OTA with specific version", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"version": "1.2.0"}`, + nodeExists: true, + otaAvailable: true, + wantStatus: http.StatusOK, + }, + { + name: "node not found", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{}`, + nodeExists: false, + otaAvailable: true, + wantStatus: http.StatusNotFound, + }, + { + name: "invalid request body", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `invalid json`, + nodeExists: true, + otaAvailable: true, + wantStatus: http.StatusBadRequest, + }, + { + name: "OTA manager not available", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{}`, + nodeExists: true, + otaAvailable: false, + wantStatus: http.StatusOK, // Still succeeds without OTA manager + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + if tt.nodeExists { + reg.nodes[tt.mac] = NodeRecord{ + MAC: tt.mac, + Name: "Test Node", + Role: "rx", + FirmwareVersion: "1.0.0", + } + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + if tt.otaAvailable { + // OTA manager is optional in the handler + h.otaMgr = nil // Mock would go here + } + + req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/ota", bytes.NewBufferString(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", tt.mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.triggerNodeOTA(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("triggerNodeOTA() status = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +// ─── Reboot endpoint tests ───────────────────────────────────────────────────────── + +func TestHandlerRebootNode(t *testing.T) { + tests := []struct { + name string + mac string + requestBody string + nodeExists bool + nodeConnected bool + wantStatus int + }{ + { + name: "successful reboot with default delay", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{}`, + nodeExists: true, + nodeConnected: true, + wantStatus: http.StatusOK, + }, + { + name: "successful reboot with custom delay", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"delay_ms": 5000}`, + nodeExists: true, + nodeConnected: true, + wantStatus: http.StatusOK, + }, + { + name: "node not found", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{}`, + nodeExists: false, + nodeConnected: true, + wantStatus: http.StatusNotFound, + }, + { + name: "node not connected", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{}`, + nodeExists: true, + nodeConnected: false, + wantStatus: http.StatusNotFound, + }, + { + name: "invalid request body", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `invalid json`, + nodeExists: true, + nodeConnected: true, + wantStatus: http.StatusBadRequest, + }, + { + name: "zero delay uses default", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"delay_ms": 0}`, + nodeExists: true, + nodeConnected: true, + wantStatus: http.StatusOK, + }, + { + name: "negative delay uses default", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"delay_ms": -1000}`, + nodeExists: true, + nodeConnected: true, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + if tt.nodeExists { + reg.nodes[tt.mac] = NodeRecord{ + MAC: tt.mac, + Name: "Test Node", + Role: "rx", + } + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{ + mgr: mgr, + nodeID: &mockNodeIdentifier{ + sendIdentifyFunc: func(mac string, delayMS int) bool { + return tt.nodeConnected + }, + }, + } + + req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/reboot", bytes.NewBufferString(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", tt.mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.rebootNode(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("rebootNode() status = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +// ─── Update all endpoint tests ──────────────────────────────────────────────────── + +func TestHandlerUpdateAllNodes(t *testing.T) { + tests := []struct { + name string + connectedMACs []string + wantStatus int + expectedCount int + }{ + { + name: "update all connected nodes", + connectedMACs: []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"}, + wantStatus: http.StatusOK, + expectedCount: 2, + }, + { + name: "no connected nodes", + connectedMACs: []string{}, + wantStatus: http.StatusOK, + expectedCount: 0, + }, + { + name: "single connected node", + connectedMACs: []string{"AA:BB:CC:DD:EE:FF"}, + wantStatus: http.StatusOK, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{ + mgr: mgr, + nodeID: &mockNodeIdentifier{ + GetConnectedMACs: func() []string { + return tt.connectedMACs + }, + }, + } + + req := httptest.NewRequest("POST", "/api/nodes/update-all", nil) + w := httptest.NewRecorder() + + h.updateAllNodes(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("updateAllNodes() status = %v, want %v", w.Code, tt.wantStatus) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + count, ok := resp["count"].(float64) + if !ok { + t.Fatalf("Expected count to be a number, got %T", resp["count"]) + } + + if int(count) != tt.expectedCount { + t.Errorf("Expected count to be %d, got %d", tt.expectedCount, int(count)) + } + }) + } +} + +// ─── Export/Import endpoint tests ───────────────────────────────────────────────── + +func TestHandlerExportConfig(t *testing.T) { + reg := &mockRegistry{ + nodes: map[string]NodeRecord{ + "AA:BB:CC:DD:EE:FF": { + MAC: "AA:BB:CC:DD:EE:FF", + Name: "Node 1", + Role: "rx", + FirmwareVersion: "1.0.0", + ChipModel: "ESP32-S3", + }, + "11:22:33:44:55:66": { + MAC: "11:22:33:44:55:66", + Name: "Node 2", + Role: "tx", + FirmwareVersion: "1.1.0", + ChipModel: "ESP32-S3", + }, + }, + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("GET", "/api/export", nil) + w := httptest.NewRecorder() + + h.exportConfig(w, req) + + if w.Code != http.StatusOK { + t.Errorf("exportConfig() status = %v, want %v", w.Code, http.StatusOK) + } + + var config map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&config); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check version + if config["version"] != float64(1) { + t.Errorf("Expected version to be 1, got %v", config["version"]) + } + + // Check exported_at exists + if config["exported_at"] == nil { + t.Error("Expected exported_at to be present") + } + + // Check nodes + nodes, ok := config["nodes"].([]interface{}) + if !ok { + t.Fatalf("Expected nodes to be an array, got %T", config["nodes"]) + } + + if len(nodes) != 2 { + t.Errorf("Expected 2 nodes, got %d", len(nodes)) + } +} + +func TestHandlerImportConfig(t *testing.T) { + tests := []struct { + name string + requestBody string + wantStatus int + }{ + { + name: "valid import config", + requestBody: `{"version": 1, "nodes": []}`, + wantStatus: http.StatusOK, + }, + { + name: "valid import with nodes", + requestBody: `{"version": 1, "nodes": [{"mac": "AA:BB:CC:DD:EE:FF", "name": "Test"}]}`, + wantStatus: http.StatusOK, + }, + { + name: "invalid json", + requestBody: `invalid json`, + wantStatus: http.StatusBadRequest, + }, + { + name: "empty object", + requestBody: `{}`, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("POST", "/api/import", bytes.NewBufferString(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.importConfig(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("importConfig() status = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +// ─── Rebaseline endpoint tests ──────────────────────────────────────────────────── + +func TestHandlerRebaselineAllNodes(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("POST", "/api/nodes/rebaseline-all", nil) + w := httptest.NewRecorder() + + h.rebaselineAllNodes(w, req) + + if w.Code != http.StatusOK { + t.Errorf("rebaselineAllNodes() status = %v, want %v", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if resp["ok"] != true { + t.Errorf("Expected ok to be true, got %v", resp["ok"]) + } +} + +// ─── Add virtual node endpoint tests ─────────────────────────────────────────────── + +func TestHandlerAddVirtualNode(t *testing.T) { + tests := []struct { + name string + requestBody string + wantStatus int + }{ + { + name: "successful virtual node creation", + requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`, + wantStatus: http.StatusCreated, + }, + { + name: "missing MAC address", + requestBody: `{"name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid json", + requestBody: `invalid json`, + wantStatus: http.StatusBadRequest, + }, + { + name: "empty body", + requestBody: `{}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "negative coordinates", + requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": -1.0, "y": -2.0, "z": -3.0}`, + wantStatus: http.StatusCreated, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: make(map[string]NodeRecord), + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("POST", "/api/nodes/virtual", bytes.NewBufferString(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.addVirtualNode(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("addVirtualNode() status = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +// ─── Update node position endpoint tests ──────────────────────────────────────── + +func TestHandlerUpdateNodePosition(t *testing.T) { + tests := []struct { + name string + mac string + requestBody string + wantStatus int + expectedX float64 + expectedY float64 + expectedZ float64 + }{ + { + name: "successful position update", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"x": 1.5, "y": 2.5, "z": 3.5}`, + wantStatus: http.StatusNoContent, + expectedX: 1.5, + expectedY: 2.5, + expectedZ: 3.5, + }, + { + name: "negative coordinates", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"x": -1.0, "y": -2.0, "z": -3.0}`, + wantStatus: http.StatusNoContent, + expectedX: -1.0, + expectedY: -2.0, + expectedZ: -3.0, + }, + { + name: "zero coordinates", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `{"x": 0, "y": 0, "z": 0}`, + wantStatus: http.StatusNoContent, + expectedX: 0, + expectedY: 0, + expectedZ: 0, + }, + { + name: "invalid request body", + mac: "AA:BB:CC:DD:EE:FF", + requestBody: `invalid json`, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &mockRegistry{ + nodes: map[string]NodeRecord{ + "AA:BB:CC:DD:EE:FF": { + MAC: "AA:BB:CC:DD:EE:FF", + Name: "Test Node", + Role: "rx", + PosX: 0, + PosY: 0, + PosZ: 0, + }, + }, + } + + mgr := &Manager{ + registry: reg, + } + + h := &Handler{mgr: mgr} + + req := httptest.NewRequest("PUT", "/api/nodes/"+tt.mac+"/position", bytes.NewBufferString(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", tt.mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + h.updateNodePosition(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("updateNodePosition() status = %v, want %v", w.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusNoContent { + // Verify the position was updated + node := reg.nodes[tt.mac] + if node.PosX != tt.expectedX || node.PosY != tt.expectedY || node.PosZ != tt.expectedZ { + t.Errorf("Expected position to be (%v, %v, %v), got (%v, %v, %v)", + tt.expectedX, tt.expectedY, tt.expectedZ, + node.PosX, node.PosY, node.PosZ) + } + } + }) + } +} diff --git a/mothership/internal/fleet/manager.go b/mothership/internal/fleet/manager.go index 6e3de2a..842d846 100644 --- a/mothership/internal/fleet/manager.go +++ b/mothership/internal/fleet/manager.go @@ -639,3 +639,10 @@ func (m *Manager) IsSecurityMode() bool { defer m.mu.RUnlock() return m.systemMode == events.ModeAway } + +// IsManualOverrideActive returns true if a manual mode override is currently active. +func (m *Manager) IsManualOverrideActive() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return time.Now().Before(m.manualOverrideUntil) +} diff --git a/mothership/mothership b/mothership/mothership index ff82d6c..3d1f5c2 100755 Binary files a/mothership/mothership and b/mothership/mothership differ diff --git a/mothership/sim b/mothership/sim index 94721c9..25e44cd 100755 Binary files a/mothership/sim and b/mothership/sim differ