From fe68bb5fdcdb5e3be315e410fc4822057a238297 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 7 Apr 2026 09:36:04 -0400 Subject: [PATCH] feat: implement BLE Devices REST endpoints with OpenAPI docs - GET /api/ble/devices: list all BLE devices with filtering - PUT /api/ble/devices/{mac}: update device label and assign to person - Added comprehensive OpenAPI-style godoc comments - Supports filtering by registered/discovered/archived status - Includes device history and aliases endpoints --- .beads/issues.jsonl | 6 +- .needle-predispatch-sha | 2 +- mothership/internal/api/events.go | 4 +- mothership/internal/api/triggers.go | 13 +- mothership/internal/api/volume_triggers.go | 19 +- mothership/internal/api/zones.go | 905 ++++++++------------- mothership/internal/ble/handler.go | 186 ++++- 7 files changed, 546 insertions(+), 589 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3132235..0252a56 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,13 +1,15 @@ {"id":"spaxel-0w4","title":"Fleet status page","description":"## Background\n\nThe 3D scene is great for spatial context but poor for bulk fleet management tasks. With 6+ nodes, finding a specific node in the 3D view, checking its firmware version, and triggering an update involves hunting through the scene. The fleet status page provides a flat table view of all nodes with their key metrics and inline actions — the same information you would find in a server management panel, adapted for ESP32 nodes. It complements the 3D view rather than replacing it.\n\n## Fleet Status Table\n\nNew dashboard route: /fleet\n\nThe page layout:\n- Page header: \"Fleet Status\" title, total node count, online count, \"Update All\" button, \"Download report\" button\n- Filter and sort bar (below header)\n- Fleet table (main content)\n\nTable columns:\n1. Checkbox (for multi-select)\n2. Label (editable inline on double-click)\n3. MAC address (truncated, full on hover tooltip)\n4. Status: coloured dot + text. \"Online\" (green) / \"Offline\" (red) / \"Updating\" (yellow spinner)\n5. Firmware version: current version string. If a newer version is in the firmware manifest: version displayed in amber with an \"→ {new_version}\" indicator and an \"Update\" action badge.\n6. Uptime: formatted as \"3d 4h 12m\". Only valid when online.\n7. Current role: TX / RX / TX-RX / Passive. Small badge.\n8. Signal health: composite health score for this node's links as a small colour bar (green → red)\n9. Packet rate: \"{actual} / {configured} Hz\" as a fraction. Colour-coded: > 90% = green, 70-90% = amber, < 70% = red.\n10. Temperature: from health message. Shown as \"{N}°C\" or \"--\" if not reported. Alert colour if > 75°C.\n11. Actions column: [Locate] [OTA] [...more] buttons\n\nThe table rows are clickable (full row click = fly 3D camera to that node's position). Only clicking action buttons or the checkbox should not trigger the row fly-to.\n\n## Inline Label Edit\n\nDouble-clicking the label cell makes it inline-editable:\n- Input field replaces the text\n- Enter to confirm, Escape to cancel\n- Blur (click outside) to confirm\n- On confirm: PATCH /api/nodes/{mac}/label with the new label. Update the display.\n- Validation: max 32 characters, no control characters.\n\n## Action Buttons\n\n\"Locate\" (flash LED): sends a downstream command {\"type\":\"identify\"} to the node via WebSocket. The node flashes its onboard LED rapidly for 5 seconds. The button shows a spinner while the command is in-flight, then a brief green checkmark.\n\n\"OTA\" (firmware update): available only if the node's firmware version != latest in the manifest. Clicking shows a confirmation tooltip: \"Update Node [label] from v{current} to v{latest}? [Confirm] [Cancel]\". On confirm: POST /api/nodes/{mac}/ota. The node's row shows \"Updating\" status and a progress bar (populated from ota_status WebSocket messages for this node).\n\n\"More actions\" (... button): dropdown with: \"Re-assign role\", \"View health history\", \"View event history\", \"Remove from fleet\". Each with an appropriate icon.\n\n## Bulk Actions\n\nCheckbox column allows multi-selecting rows. When any row is selected, a bulk-actions bar slides in above the table:\n- \"Update {N} selected to latest firmware\" — confirms and triggers OTA for all selected nodes in sequence (with 30s stagger)\n- \"Re-assign roles\" — opens the role optimiser with the selected nodes included\n- \"Remove {N} from fleet\" — confirmation required: lists the nodes to be removed\n\nDeselect all: \"Clear selection\" button in the bulk actions bar, or uncheck all checkboxes.\n\n## Camera Fly-To\n\nClicking a table row (non-action click) triggers a smooth camera fly-to the node's position in the expert mode 3D view. The fleet page and expert mode are on different routes, so this requires:\n1. Store the target node MAC in localStorage or URL parameter (\"?highlight={mac}\")\n2. Redirect to the expert mode route / (or open expert mode in a second tab)\n3. On load, if ?highlight={mac} parameter is present, fly camera to that node's position\n\nAlternatively: if the fleet page is opened alongside the expert mode in a split-pane layout (future enhancement), coordinate via a shared state store. For Phase 9, the redirect approach is sufficient.\n\n## Sorting and Filtering\n\nColumn header click: sort by that column. First click = ascending, second = descending. Sort state shown with a small arrow indicator.\n\nFilter row (below column headers, toggle-able with a \"Filter\" button):\n- Label / MAC: text input, filters rows containing the substring\n- Status: dropdown \"All / Online / Offline\"\n- Firmware: dropdown \"All / Outdated only\"\n- Role: multi-select dropdown \"All / TX / RX / TX-RX / Passive\"\n\nActive filters: shown as chips above the table with individual dismiss buttons. \"Clear all filters\" link.\n\n## Download Report\n\n\"Download report\" button: exports the current fleet table (including all filters) as a CSV file. Columns: MAC, label, status, firmware_version, uptime_s, role, health_score, packet_rate_hz, temperature_c, last_seen.\n\nImplemented as a client-side CSV generation from the current table data (no API call needed if data is cached in the dashboard state). Use Blob + URL.createObjectURL for download.\n\n## REST API\n\nGET /api/fleet: returns all provisioned nodes with full details (same as GET /api/nodes but with more fields: uptime, firmware_version, temperature, health_score, packet_rate).\nPATCH /api/nodes/{mac}/label: update label\nPOST /api/nodes/{mac}/locate: send identify command\nPOST /api/nodes/{mac}/role: assign new role\nDELETE /api/nodes/{mac}: remove from fleet (disconnects and archives)\n\n## Files to Create or Modify\n\n- dashboard/fleet.html: fleet page HTML shell\n- dashboard/js/fleet.js: table rendering, sorting, filtering, bulk actions, inline edit\n- dashboard/css/fleet.css: fleet page styles\n- mothership/internal/dashboard/routes.go: fleet-specific API routes, /fleet HTML route\n\n## Tests\n\n- Test fleet table renders correctly with mock data: 4 nodes, verify all columns populated\n- Test inline label edit: double-click cell, type new label, Enter -> PATCH API called with correct body\n- Test bulk selection: check 3 nodes, verify bulk actions bar appears with correct count\n- Test bulk OTA triggers OTA for all 3 selected nodes\n- Test sorting: click \"Firmware version\" header -> rows sorted ascending by version string\n- Test filter: enter \"living\" in label filter -> only rows matching \"living\" visible\n- Test camera fly-to: clicking a row stores MAC in localStorage and redirects to expert mode with ?highlight parameter\n- Test CSV download: verify blob is created with correct headers and values\n\n## Acceptance Criteria\n\n- Fleet page loads with all nodes and their current metrics\n- Inline label edit saves correctly to the API and updates the display\n- Bulk OTA fires for all selected nodes with correct stagger\n- Sorting and filtering work correctly for all columns\n- Camera fly-to positions the 3D view correctly on the selected node after redirect\n- CSV download contains correct headers and all fleet data\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:06:06.562532476Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.955787324Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-0w4","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.955755499Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-16e","title":"Captive portal WiFi recovery","description":"## Background\n\nIf a node loses WiFi connectivity — because a router was replaced, credentials changed, or the node was moved out of range — it currently has no recovery path without physical intervention. The firmware already implements a captive portal AP fallback in firmware/main/wifi.c: after 10 consecutive WiFi connection failures with exponential backoff (up to 30s between attempts), the firmware transitions to AP mode with SSID \"spaxel-{last4mac}\". However, the captive portal HTTP config page served in this mode is currently a stub and needs to be completed. This bead finishes that implementation and adds the mothership-side offline detection and dashboard alert.\n\n## What Already Exists (Phase 1)\n\nfirmware/main/wifi.c implements the WIFI_STATE_CAPTIVE_PORTAL state transition after failure threshold. The firmware starts a SoftAP with SSID \"spaxel-{last4mac}\" and password-free (open AP). A stub HTTP server exists but serves only a 200 OK response with no content. The DNS server for captive portal hijacking is not yet implemented.\n\n## Captive Portal HTTP Server (Firmware)\n\nUse ESP-IDF's esp_http_server component to serve a config page at http://192.168.4.1/ when in AP mode.\n\nThe config page (served as inline HTML, no external resources — it must work fully offline):\n- Shows current provisioned SSID (from NVS, read-only display)\n- Form: new SSID input, password input, optional mothership host/port\n- Submit button: \"Save and Reconnect\"\n- On form submission: firmware writes new credentials to NVS (wifi_ssid, wifi_pass, optionally mothership_host/port) and calls esp_restart()\n- Minimal HTML with inline CSS: max 4KB so it fits in IRAM. No JavaScript needed.\n- Works on iOS Safari (no JS requirement), Android Chrome, desktop browsers\n\nThe HTTP server must handle the iOS and Android captive portal probe paths:\n- GET /hotspot-detect.html (iOS)\n- GET /generate_204 (Android)\n- GET /ncsi.txt (Windows)\nRespond to all of these with a 302 redirect to http://192.168.4.1/ to trigger the OS captive portal popup.\n\n## DNS Hijacking for Captive Portal Detection\n\nModern mobile OSes detect captive portals by making a DNS lookup for a known hostname (connectivitycheck.android.com, captive.apple.com, etc.) and checking if the response is correct. To trigger the OS captive portal popup automatically (so the user does not need to manually navigate to 192.168.4.1):\n\nImplement a minimal DNS server using lwIP's UDP API. The DNS server binds to UDP port 53 on the SoftAP interface (192.168.4.1). It responds to ALL DNS queries with an A record pointing to 192.168.4.1, regardless of the queried hostname. This causes the OS to detect the captive portal and show the popup.\n\nESP-IDF does not provide a ready-made DNS hijacking component — implement using esp_event_loop and lwIP udp_new()/udp_bind()/udp_recv() APIs. Keep the implementation minimal: parse enough of the DNS query to extract the transaction ID and question name, then build a minimal A record response.\n\n## Mothership Offline Detection and Dashboard Alert\n\nThe mothership detects node disconnection via heartbeat timeout. When a node's WebSocket closes (or health messages stop for > 30s), the mothership:\n1. Sets node status to OFFLINE in the node registry\n2. Broadcasts a node_offline WebSocket message to dashboard: {\"type\":\"node_offline\",\"mac\":\"aa:bb:cc:dd:ee:ff\",\"label\":\"Living Room\",\"last_seen\":\"2026-03-27T14:23:00Z\",\"likely_cause\":\"wifi_loss\"}\n\nThe dashboard shows an offline alert card:\n- \"Node [Living Room] went offline at 14:23. It may need WiFi reconfiguration.\"\n- Shows the captive portal AP SSID to connect to: \"Connect to Wi-Fi network 'spaxel-ff01' to reconfigure\"\n- Troubleshooting steps: 1) Check power LED, 2) Check WiFi router is online, 3) Connect to captive portal if LED is blinking\n\nThe likely_cause field is set by the mothership based on the last health message before disconnect (e.g. low WiFi RSSI in last health message -> \"wifi_range\", no recent health messages at all -> \"unknown\").\n\n## Reconnection and Portal Exit\n\nWhen the user submits new credentials in the captive portal and the node reboots:\n1. The SoftAP goes down (existing connections to \"spaxel-ff01\" are dropped)\n2. The node attempts WiFi connection with new credentials\n3. On success, connects to mothership — mothership sets status to ONLINE and broadcasts node_online event\n4. Dashboard shows reconnection notification: \"Node [Living Room] reconnected successfully\"\n\nIf the new credentials also fail (e.g. user mistyped), the node re-enters captive portal mode after another 10 failures. The portal should display an error on re-entry if the previous attempt failed.\n\n## Implementation Files\n\n- firmware/main/wifi.c: complete captive portal HTTP server, add DNS hijacking task\n- firmware/main/captive_portal.c (new): DNS hijacking task + HTTP handler functions\n- mothership/internal/ingestion/server.go: heartbeat timeout detection, node_offline event\n- mothership/internal/fleet/manager.go: node status tracking (ONLINE/OFFLINE/CAPTIVE_PORTAL)\n- dashboard/js/app.js: node_offline event handler, offline alert card rendering\n\n## Testing Challenges\n\nThis feature is difficult to unit test due to hardware dependency. Recommended approaches:\n1. Firmware: Integration test in QEMU (esp32s3 target) — simulate WiFi failures by mocking the WiFi event loop, verify state machine transitions to CAPTIVE_PORTAL after failure threshold\n2. DNS server: Unit test the DNS response builder function with a fixed query buffer and verify the response parses correctly\n3. HTTP config page: Unit test the form handler that writes to NVS (mock the NVS API)\n4. Mothership: Unit test heartbeat timeout detection with a fake time source, verify node_offline event is emitted\n\n## Acceptance Criteria\n\n- After WiFi failure (10 consecutive failures), node enters AP mode with SSID \"spaxel-{last4mac}\" within 5 minutes of initial loss\n- Mobile device connecting to the captive portal AP automatically sees the OS captive portal popup (tested on iOS 16+ and Android 12+)\n- Config page is served and functional without JavaScript\n- Updating credentials causes node to reboot and reconnect to mothership within 60s\n- Dashboard shows offline node alert within 30s of disconnection\n- Reconnection notification appears in dashboard within 30s of node reconnecting\n- Captive portal DNS server responds correctly to all DNS queries with 192.168.4.1\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:38:15.579673840Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.291892350Z","closed_at":"2026-03-28T05:36:39.291803146Z","close_reason":"Implemented: firmware/main/wifi.c (fb69190, 89 lines added) — captive portal AP mode (spaxel-XXXX SSID), esp_http_server config page at 192.168.4.1, DNS hijacking to trigger OS captive portal popup, NVS credential update on form submission","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-16e","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.901241816Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-17u","title":"Phase 9: UX Polish & Accessibility","description":"Goal: Accessible to every household member. Power user efficiency.\n\nDeliverables:\n- Simple mode (card-based mobile-first UI, room occupancy cards, activity feed)\n- Ambient dashboard mode (/ambient for wall tablets, simplified top-down, auto-dim)\n- Spatial quick actions (right-click context menus on 3D elements, follow camera)\n- Command palette (Ctrl+K universal search/command, fuzzy matching)\n- Morning briefing (daily summary card, push notification option)\n- Guided troubleshooting (proactive contextual help, post-feedback explanations)\n- Mobile-responsive expert mode (touch orbit/pan/zoom)\n- Fleet status page (full table, bulk actions, camera fly-to)\n\nExit criteria: Non-technical user can check occupancy without training. Ambient mode runs 7+ days.","status":"open","priority":3,"issue_type":"phase","created_at":"2026-03-27T01:55:55.188364609Z","created_by":"coding","updated_at":"2026-03-28T01:33:53.433798167Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-17u","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T01:33:53.433780442Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-21n","title":"Implement Zones and Portals REST endpoints","description":"Implement CRUD endpoints for zones: GET/POST /api/zones, PUT/DELETE /api/zones/{id}. Implement CRUD for portals: GET/POST /api/portals, PUT/DELETE /api/portals/{id}. Changes must reflect in live 3D view within one WebSocket cycle. Include OpenAPI-style godoc comments.","status":"in_progress","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T15:31:10.270709535Z","created_by":"coding","updated_at":"2026-04-07T13:26:57.301407374Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-26o","title":"Dashboard presence indicator","description":"## Background\n\nPhase 2 signal processing (phase sanitisation, baseline, motion detection) is complete in mothership/internal/signal/. The pipeline produces per-link MotionState (IsMotion bool, DeltaRMS float64, Confidence float64) via ProcessorManager.GetAllMotionStates(). This bead surfaces that data in the browser dashboard — the first human-visible output of the detection pipeline.\n\n## What to Implement\n\n1. Server-side: Add a periodic broadcast (every 500ms) of all link motion states as a JSON WebSocket message type 'presence_update'. Message schema: {type:'presence_update', links: {linkID: {is_motion: bool, delta_rms: float, confidence: float}}}. Wire into mothership/internal/dashboard/hub.go — Hub already has a Broadcast method.\n\n2. Frontend (dashboard/js/app.js): Add a 'Presence' panel distinct from the raw amplitude bar chart. Per-link rows showing: link ID (nodeMAC:peerMAC abbreviated), coloured circle indicator (green = clear, amber = motion, red = high-confidence motion), deltaRMS value. Click a link row to select it for the amplitude time series.\n\n3. Amplitude time series: Rolling 10s buffer of deltaRMS values per link. Render as a Canvas 2D line chart below the presence panel. X-axis: time (10s window), Y-axis: deltaRMS (0..0.1 typical range). Show threshold line at 0.02 (DefaultDeltaRMSThreshold).\n\n## Key Files\n\n- mothership/internal/signal/processor.go — GetAllMotionStates(), MotionState struct\n- mothership/internal/dashboard/hub.go — Hub.Broadcast(message interface{})\n- mothership/internal/dashboard/server.go — WebSocket handler, periodic broadcast setup\n- dashboard/js/app.js — existing UI code to extend\n\n## Acceptance Criteria\n\n- presence_update messages broadcast every 500ms with all active link states\n- Dashboard shows per-link coloured motion indicator updating in real-time\n- Amplitude time series shows last 10s of deltaRMS for selected link\n- Threshold line visible at 0.02\n- All existing tests pass (cargo check equivalent: go test ./...)","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-28T03:29:50.484956631Z","created_by":"coding","updated_at":"2026-03-28T03:56:59.340530333Z","closed_at":"2026-03-28T03:56:59.340245276Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]} -{"id":"spaxel-28k","title":"Add event messages to WebSocket feed","description":"Add 'event' message type to /ws/dashboard for presence transitions, zone entries/exits, and portal crossings. Broadcast: { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }. Handle in app.js onmessage. Events appear within 1s of zone transition.","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T14:18:27.377328251Z","created_by":"coding","updated_at":"2026-04-07T06:25:10.865917811Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} +{"id":"spaxel-28k","title":"Add event messages to WebSocket feed","description":"Add 'event' message type to /ws/dashboard for presence transitions, zone entries/exits, and portal crossings. Broadcast: { type: 'event', event: { id, ts, kind, zone, blob_id, person_name } }. Handle in app.js onmessage. Events appear within 1s of zone transition.","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T14:18:27.377328251Z","created_by":"coding","updated_at":"2026-04-07T13:32:19.313287763Z","close_reason":"Feature already fully implemented. Event messages on /ws/dashboard are broadcast via BroadcastEvent() in hub.go for presence transitions, zone entries/exits, and portal crossings. Frontend handles them in app.js handleEventMessage(). All 9 related tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:23","mitosis-child","mitosis-depth:1","parent-spaxel-9eg"]} {"id":"spaxel-2ap","title":"Activity timeline backend: events API, FTS5 search, cursor pagination","description":"## Overview\nBackend for the unified activity timeline — event ingestion, storage, full-text search, and REST API with cursor pagination. Feeds the timeline dashboard UI (spaxel-65k).\n\n## SQLite schema\n- events table: id INTEGER PK, type TEXT, timestamp_ms INTEGER, zone TEXT, person TEXT, blob_id TEXT, detail_json TEXT, severity TEXT\n- FTS5 virtual table fts_events: content='events', content_rowid='id' — indexed on type, zone, person, detail_json\n- events_archive: same schema, auto-migrated from events when timestamp_ms < now - 90 days (nightly at 02:00 local)\n- Indexes: idx_events_ts (timestamp_ms DESC), idx_events_type, idx_events_zone, idx_events_person\n\n## Event types to ingest\ndetection, zone_entry, zone_exit, portal_crossing, trigger_fired, fall_alert, anomaly, security_alert, node_online, node_offline, ota_update, baseline_changed, system, learning_milestone\n\n## REST API\n- GET /api/events — query params: limit (default 50, max 500), before (cursor), after (ISO8601), type, zone, person, q (FTS5 query)\n- Response: {events: [...], cursor: '', has_more: bool, total_filtered: int}\n- Keyset pagination using timestamp_ms as cursor (no OFFSET)\n- FTS5 query via fts_events MATCH q; fuzzy: prefix matching via q*\n\n## Event publishing\n- All event types published to dashboard WS feed as 'event' messages (requires spaxel-9eg)\n- Event bus in Go: internal publish/subscribe so any package can emit events without direct dependency on dashboard\n\n## Acceptance\n- 1000 events query <50ms with FTS5 search\n- Cursor pagination returns consistent results across pages\n- Archive job runs at 02:00 and moves events >90 days\n- FTS5 search matches partial words (prefix mode)\n- Requires: spaxel-6ha (REST API wiring), spaxel-9eg (WS event feed)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T13:02:26.845982082Z","created_by":"coding","updated_at":"2026-04-06T13:02:26.845982082Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-2wg","title":"BLE device registry and labelling","description":"## Background\n\nThe firmware scans BLE advertisements every 5 seconds and relays them to the mothership via the bidirectional protocol (spaxel-o4l, Phase 3). Each BLE relay message contains a list of {mac, name, rssi, manufacturer_data} tuples for all devices heard by that node in the last 5 seconds. Phase 6 turns this raw stream into a structured \"People and Devices\" registry where users can label their devices and associate them with named people. This is the identity layer that transforms anonymous CSI blobs into \"Alice\" and \"Bob\".\n\n## BLE Device Auto-Detection\n\nThe mothership can identify device types from manufacturer data embedded in BLE advertisement packets. The Bluetooth SIG assigns Company IDs to manufacturers; the first 2 bytes of manufacturer_data encode the company ID (little-endian).\n\nCompany IDs to detect:\n- 0x004C (Apple): likely iPhone, iPad, AirPods, or Apple Watch. Sub-type from manufacturer data length and flags.\n- 0x0006 (Microsoft): Windows devices\n- 0x0075 (Samsung): Samsung phones/tablets\n- 0x009E (Fitbit): Fitness trackers\n- 0x0157 (Garmin): GPS watches / fitness devices\n- 0x0059 (Nordic): Tile trackers (Nordic Semiconductor is used by many Tile-like devices)\n- 0x0499 (Ruuvi): Ruuvi temperature/humidity sensors\n- 0x00E0 (Google): Android devices (Nearby Share beacons)\nClassify all others as \"Unknown\". The device name field (if present in the advertisement) provides additional signal.\n\nWearable heuristic: RSSI typically -55 to -75 dBm across multiple nodes with relatively consistent signal (worn close to body). Static devices (speakers, tablets) show higher variance. Flags this heuristic as \"possibly wearable\" (not definitive).\n\n## BLERegistry\n\nNew package: mothership/internal/identity/ble.go\n\nBLERegistry struct: backed by SQLite table ble_devices.\n\nSQLite schema:\nCREATE TABLE ble_devices (\n mac TEXT PRIMARY KEY,\n name TEXT,\n manufacturer TEXT,\n device_type TEXT, -- apple_phone, apple_earbuds, fitbit, garmin, tile, samsung, unknown\n label TEXT, -- user-assigned label\n person_id TEXT, -- FK to people.id\n rssi_min INTEGER,\n rssi_max INTEGER,\n rssi_avg INTEGER,\n first_seen DATETIME,\n last_seen DATETIME,\n is_archived BOOLEAN DEFAULT FALSE,\n last_seen_node_mac TEXT\n);\n\nCREATE TABLE people (\n id TEXT PRIMARY KEY, -- uuid\n name TEXT NOT NULL,\n color TEXT, -- hex colour for dashboard rendering\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE person_devices (\n person_id TEXT,\n device_mac TEXT,\n PRIMARY KEY (person_id, device_mac)\n);\n\nBLERegistry methods:\n- ProcessRelayMessage(nodeMac string, devices []BLEDevice): upsert all devices, update last_seen, update RSSI stats\n- GetDevices(includeArchived bool) []BLEDeviceRecord\n- UpdateLabel(mac, label string) error\n- AssignToPerson(mac, personID string) error\n- CreatePerson(name, color string) (Person, error)\n- GetPeople() []Person\n- ArchiveStale(olderThan time.Duration): set is_archived=true for devices not seen for > olderThan\n\n## BLE MAC Randomisation Handling\n\nModern iPhones and Android phones randomise their BLE MAC address periodically (every 10-15 minutes for iPhones, similar for Android). This is a fundamental privacy feature. The implications for spaxel:\n\n1. The same physical phone appears as multiple different MAC addresses in the registry. The BLERegistry will create new entries for each rotated address.\n2. Long-term tracking of phones by MAC is unreliable. The registry will accumulate many entries for a single phone over time.\n3. Workarounds: (a) Apple uses Resolvable Private Addresses (RPA) that can be resolved with the Identity Resolving Key (IRK) — requires pairing, not available without user action. (b) Device name is sometimes consistent across rotations. (c) Wearable devices (Fitbit, Garmin, AirTag) typically do NOT rotate their MACs — they provide reliable long-term tracking.\n\nThe dashboard must clearly explain this limitation in the \"People and Devices\" panel:\n\"Your phone's Bluetooth address changes regularly for privacy reasons. For reliable person tracking, use a Fitbit, Garmin watch, or AirTag, which have stable addresses.\"\n\nGrouping heuristic: if two devices have the same manufacturer data prefix (first 6 bytes) and name, and were never seen simultaneously at high RSSI from the same node, they are likely the same device with a rotated MAC. Surface this as a \"possible duplicate\" suggestion in the UI: \"These may be the same device: [mac1] and [mac2]. Merge?\"\n\n## REST API\n\nGET /api/ble/devices: returns list of BLEDeviceRecord, optionally filtered by ?archived=true\nGET /api/ble/devices/{mac}: returns single device with full history\nPUT /api/ble/devices/{mac}: update label, device_type, or person assignment. Body: {\"label\":\"Alice's Phone\",\"device_type\":\"apple_phone\",\"person_id\":\"uuid-123\"}\nDELETE /api/ble/devices/{mac}: archive (not hard delete)\n\nGET /api/people: returns list of People with their associated devices\nPOST /api/people: create person. Body: {\"name\":\"Alice\",\"color\":\"#3b82f6\"}\nPUT /api/people/{id}: update name or color\nDELETE /api/people/{id}: soft-delete (retain historical data)\n\n## Dashboard Panel\n\n\"People and Devices\" sidebar panel showing:\n- People section: list of defined people with avatar (initials in circle with their color), device count, last seen time\n - Per person: click to expand, shows associated devices\n - \"Add person\" button opens inline form\n- All devices section (below people): list of devices not yet assigned to a person\n - Per device: device type icon (Apple logo, Fitbit icon, etc.), last seen node (abbreviated), last seen timestamp, RSSI bar\n - Inline label edit on double-click\n - Drag-and-drop to assign to a person card\n - Archive button (hides from active list, accessible via \"Show archived\" toggle)\n- Privacy notice: \"Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.\"\n\n## Tests\n\n- Test device auto-detection: Apple company ID 0x004C -> device_type \"apple_phone\", Fitbit 0x009E -> \"fitbit\"\n- Test that ProcessRelayMessage correctly upserts devices and updates last_seen and RSSI stats\n- Test ArchiveStale marks devices not seen for > 7 days as archived\n- Test person creation and device-to-person assignment API calls\n- Test MAC randomisation handling: two devices with same name and no simultaneous sighting are flagged as possible duplicates\n- Test that archived devices are excluded from GetDevices(false) but included in GetDevices(true)\n\n## Acceptance Criteria\n\n- Discovered BLE devices appear in the dashboard \"People and Devices\" panel within 30 seconds of first observation\n- Device type is auto-detected correctly for Apple, Fitbit, Garmin, and Samsung devices\n- User can assign labels and associate devices with named people via the dashboard UI\n- Drag-and-drop device-to-person assignment works in the UI\n- Devices not seen for > 7 days are automatically archived and hidden from the active list\n- Privacy limitation is clearly documented in the panel UI\n- Possible duplicate MAC-rotated devices are surfaced as merge suggestions\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:44:02.204633291Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.656772405Z","closed_at":"2026-03-29T18:07:39.656662663Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-2wg","depends_on_id":"spaxel-c0q","type":"blocks","created_at":"2026-03-28T03:29:14.172209347Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-32o","title":"Link weather diagnostics and repositioning advice","description":"## Background\n\nEven with good hardware and correct placement, some links will chronically underperform. A user who placed a node on a metal shelf, behind a TV, or in a corner will see consistently poor detection without understanding why. Telling users \"your detection quality is low\" is useless without telling them what to do about it. Link weather diagnostics provide root-cause analysis and specific, actionable repositioning advice — including 3D visualisation of why a link is performing poorly and where to move a node to fix it.\n\nThe name \"link weather\" is deliberate: just as weather forecasts present complex atmospheric state in human terms (\"partly cloudy with 60% chance of rain\"), link weather presents complex RF state as: \"Node A to Node B: interference detected. Likely cause: microwave oven or 2.4GHz congestion. Try moving Node B 1.5 metres to the right.\"\n\n## DiagnosticEngine\n\nNew module: mothership/internal/diagnostics/linkweather.go\n\nDiagnosticEngine runs as a background goroutine, consuming link health history from SQLite and emitting Diagnosis structs. It runs a full diagnostic pass every 15 minutes.\n\nA Diagnosis struct contains:\n- LinkID string\n- RuleID string (identifies which rule fired)\n- Severity: INFO, WARNING, ACTIONABLE\n- Title string (human-readable headline)\n- Detail string (explanation of the diagnosis in plain language)\n- Advice string (specific actionable steps)\n- RepositioningTarget *Vec3 (3D position to move the node to, or nil if repositioning is not the solution)\n- RepositioningNodeMAC string (which node to move)\n- ConfidenceScore float64 (how confident the diagnostic engine is in this diagnosis)\n\n## Diagnostic Rules\n\nRule 1: Environmental Change\nTrigger: High baseline drift (>5% per hour) correlated across multiple links simultaneously (>50% of active links).\nTitle: \"Environmental change detected\"\nDetail: \"Multiple sensing links are showing simultaneous baseline shifts. This typically indicates a temperature change, or a large object was moved in the space. The system is adapting automatically.\"\nAdvice: \"No action needed. The baseline will re-stabilise within 30 minutes.\"\nRepositioningTarget: nil\nConfidence: 0.85 if drift is correlated across >50% of links\n\nRule 2: WiFi Congestion or Distance\nTrigger: Packet rate health < 0.8 for more than 10 minutes on a single link.\nTitle: \"Node B has low signal rate\"\nDetail: \"Node [B] is only delivering [N]% of the expected [M] packets per second. The most common causes are distance from the WiFi router or congestion from nearby networks.\"\nAdvice: \"1. Move Node [B] within 10 metres of your WiFi router. 2. If already close, check if the 2.4GHz channel is congested (3+ networks on overlapping channels). 3. ESP32-S3 supports both 2.4GHz and 5GHz — if your router supports 5GHz, update Node B's WiFi config to use the 5GHz SSID.\"\nRepositioningTarget: nil (advice is router proximity, not specific coordinates)\n\nRule 3: Near-Field Metal Interference\nTrigger: Low phase stability (< 0.4) sustained for > 30 minutes during known-quiet periods.\nTitle: \"Metal interference near Node [A]\"\nDetail: \"The sensing link [A to B] has unstable phase measurements even when no one is moving. This is typically caused by metal objects in the near field of the node's antenna (within 10cm): metal shelves, radiators, TV backs, or large appliances.\"\nAdvice: \"Check for metal objects within 10cm of Node [A]. If Node [A] is on a metal surface or shelf, mount it on a non-metal bracket or wall. Try repositioning it 20-30cm away from metal surfaces.\"\nRepositioningTarget: nil (advice is clearance from metal, not a specific position)\n\nRule 4: Fresnel Zone Blockage (Half-Room Dead Zone)\nTrigger: Consistent miss rate (>30% of test walks that should be detected are missed) in a specific area of the room, AND the missing area correlates geometrically with an obstacle in the link's Fresnel zone.\nThis rule requires the feedback loop data (Phase 7, spaxel-i28) — specifically the user-submitted false negatives with position information. If no feedback data is available, this rule can trigger heuristically when one side of the room consistently shows lower blob confidence scores.\nTitle: \"Coverage gap detected — possible obstruction\"\nDetail: \"The area near [zone description] shows lower detection coverage. An obstacle may be blocking the path between Node [A] and Node [B], interrupting their sensing zone.\"\nAdvice: \"Move Node [B] [direction] by approximately [distance] to restore coverage. The target position is marked in green in the 3D view.\"\nRepositioningTarget: computed_optimal_position (see below)\n\nRule 5: Periodic Interference Spikes\nTrigger: Periodic spikes in deltaRMS variance (3-10 events per hour, each lasting 1-3 minutes) not correlated with occupancy data (no people detected moving).\nTitle: \"Periodic interference detected\"\nDetail: \"Node [A] to Node [B] is experiencing regular interference bursts [N] times per hour. This pattern is consistent with a microwave oven, a cordless phone, or a pulsed 2.4GHz source.\"\nAdvice: \"Consider the following: 1. Is Node [A] or Node [B] near a kitchen? Microwave ovens cause strong 2.4GHz interference. 2. A cordless DECT phone or baby monitor near one of the nodes may be the source. 3. Try moving the affected node at least 2 metres from any 2.4GHz appliances.\"\nRepositioningTarget: nil (interference is appliance-specific)\n\n## Repositioning Advice in 3D\n\nFor Rule 4 (Fresnel zone blockage), compute the optimal repositioning target:\n1. Use the GDOP-based coverage optimiser from Phase 5 self-healing fleet (spaxel-jc4) to compute the position that maximises GDOP for the blocked zone while keeping all other nodes fixed.\n2. The optimal position is the computed_optimal_position Vec3.\n3. In the 3D dashboard, render a \"ghost\" node at this position: translucent version of the node mesh, with a dashed line from the current position to the ghost position.\n4. Show expected GDOP improvement: \"Moving Node B here would improve detection in the east corner from [N]% to [M]%.\"\n\n## Weekly Reliability Trends\n\nStore daily health score averages in SQLite: link_health_daily (link_id TEXT, date DATE, avg_health REAL, min_health REAL, max_health REAL, PRIMARY KEY (link_id, date)).\n\nA background job runs daily at midnight and writes the day's health averages from the link health log (link_health_log table: link_id, timestamp, composite_score).\n\nDashboard shows for each link: 7-day sparkline of daily average health score. \"Best day\" annotation (highest average) and \"worst day\" annotation (lowest average). This gives users a sense of long-term reliability.\n\n## Files to Create or Modify\n\n- mothership/internal/diagnostics/linkweather.go: DiagnosticEngine and all 5 rules\n- mothership/internal/diagnostics/reposition.go: repositioning target computation\n- mothership/internal/health/linkhealth.go: add link_health_log table writes\n- dashboard/js/linkhealth.js: link health panel, diagnostics display, ghost node rendering\n- mothership/internal/dashboard/routes.go: GET /api/links/{id}/diagnostics, GET /api/links/{id}/health-history\n\n## Tests\n\n- Test Rule 1 (environmental change): inject simultaneous high-drift events across 60% of links, verify diagnosis fires with Severity=INFO\n- Test Rule 2 (WiFi congestion): inject packet_rate=0.7 for 15 minutes, verify diagnosis fires with appropriate advice text\n- Test Rule 3 (metal interference): inject phase_stability=0.3 for 35 minutes during a quiet window, verify diagnosis fires\n- Test Rule 4 (Fresnel blockage): requires feedback data — inject synthetic false-negative feedback events clustered in one spatial zone, verify diagnosis fires and RepositioningTarget is non-nil\n- Test Rule 5 (periodic interference): inject 5 deltaRMS variance spikes per hour for 2 hours, verify diagnosis fires with correct periodicity estimate\n- Test weekly trend aggregation: inject 7 days of health scores, verify daily averages are correctly computed and stored\n- Test that repositioning target is within room bounds and improves GDOP\n\n## Acceptance Criteria\n\n- All 5 diagnostic rules fire correctly on synthetic test data that matches their trigger conditions\n- Repositioning advice for Rule 4 appears as a ghost node in the 3D dashboard view\n- Expected GDOP improvement shown alongside repositioning ghost node\n- Weekly 7-day sparkline visible in link health panel for each link\n- Diagnostics accessible via API and displayed in Link Health panel on link click\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"juliet","created_at":"2026-03-28T01:43:13.596164634Z","created_by":"coding","updated_at":"2026-03-29T18:07:39.683230580Z","closed_at":"2026-03-29T18:07:39.683089345Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-32o","depends_on_id":"spaxel-axa","type":"blocks","created_at":"2026-03-28T03:29:14.023730499Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-3ps","title":"Detection feedback loop and accuracy tracking","description":"## Background\n\nEvery detection algorithm produces errors. False positives (detected presence when no one is there) are annoying and erode trust. False negatives (missed detection of a real person) are dangerous for safety applications. The feedback loop gives users a direct mechanism to correct errors and the system learns from those corrections. Showing users measurable improvement over time (\"You've provided 47 corrections. Accuracy improved 12% this week\") creates a virtuous engagement loop and transforms users into active participants in improving the system.\n\n## Feedback UI Elements\n\nEvery detection event exposed to the user should have feedback affordances. Three contexts:\n\n1. Dashboard 3D view: Each active track has a small thumbs-up/down icon that appears on hover/focus. Clicking thumbs-down opens a quick inline form.\n\n2. Activity timeline (Phase 8): Every detection event entry has thumbs-up/thumbs-down at the end of the row. Space-efficient: 2 icon buttons.\n\n3. Push notifications: Fall and anomaly notifications include a quick-reply option (via ntfy actions or Pushover callbacks): \"False alarm — clear this.\"\n\n4. \"I was here and wasn't detected\" button: On the timeline panel, a button \"Report missed detection\" opens a form: \"When? [time picker, default: now]\", \"Where? [zone picker]\", \"Who? [person picker, optional]\". Submits as a FALSE_NEGATIVE feedback event with the user-provided position.\n\nFeedback form for thumbs-down:\n- \"What was wrong?\" (radio buttons):\n - \"No one was there (false alarm)\"\n - \"Someone was missed at this location\"\n - \"Wrong person identified\"\n - \"Wrong zone/location\"\n- Optional free-text \"Notes\" field\n- Submit / Cancel\n\n## Feedback Storage\n\nSQLite schema:\nCREATE TABLE detection_feedback (\n id TEXT PRIMARY KEY,\n event_id TEXT, -- references events table (activity timeline)\n event_type TEXT, -- \"blob_detection\", \"zone_transition\", \"fall_alert\", \"anomaly\"\n feedback_type TEXT, -- \"TRUE_POSITIVE\", \"FALSE_POSITIVE\", \"FALSE_NEGATIVE\", \"WRONG_IDENTITY\", \"WRONG_ZONE\"\n details_json TEXT, -- {\"zone_id\":\"...\", \"person_id\":\"...\", \"notes\":\"...\"}\n timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,\n applied BOOLEAN DEFAULT FALSE, -- set to TRUE after weight refinement processes it\n processed_at DATETIME\n);\n\nThe applied flag enables incremental processing: the weight learner (Phase 7 self-improving localisation) queries WHERE applied = FALSE, processes batches, and marks them TRUE.\n\n## Accuracy Metrics\n\nCompute precision/recall/F1 per link, per zone, and per person weekly. This requires knowing the true positives, false positives, and false negatives.\n\nGround truth sources:\n- User thumbs-up -> TRUE_POSITIVE for the corresponding detection event\n- User thumbs-down (false alarm) -> FALSE_POSITIVE for the detection event\n- User \"missed detection\" report -> FALSE_NEGATIVE for the reported time/zone\n\nNote: ground truth is sparse — users will not feedback every event. We use the feedback we have as a sample. Assume events without feedback are TRUE_POSITIVE for the purpose of precision estimates (conservative: this means precision is an upper bound, not exact).\n\nMetrics computed weekly:\n- precision = TP / (TP + FP) — of all detections, what fraction were correct\n- recall = TP / (TP + FN) — of all true presence events, what fraction were detected\n- F1 = 2 * precision * recall / (precision + recall)\n- Per-link metrics: which links have the most false positives (worst precision)\n- Per-zone metrics: which zones are most often missed (worst recall)\n\nStorage: detection_accuracy (week TEXT, scope_type TEXT, scope_id TEXT, precision REAL, recall REAL, f1 REAL, tp_count INT, fp_count INT, fn_count INT, computed_at DATETIME). Scope types: \"system\", \"link\", \"zone\", \"person\".\n\n## Accuracy Trend Display\n\nDashboard \"Accuracy\" panel (in expert mode):\n- Overall accuracy gauge: composite F1 score as a circular gauge (0-100%)\n- Week-over-week trend graph: sparkline of weekly F1 over the last 8 weeks\n- \"You've provided N corrections. Your accuracy improved X% this week.\" — motivational counter\n- Per-zone breakdown: bar chart of precision/recall per zone (click a zone bar to jump to it in 3D view)\n- Per-link breakdown: link health vs. feedback score correlation (are high-health links also high-accuracy?)\n- Feedback count: total corrections given, open corrections (not yet processed), processed corrections\n\nThe accuracy trend display intentionally shows the improvement trajectory, not just the absolute value, to reinforce that feedback has an effect.\n\n## Feedback Application\n\nProcessing happens in a background goroutine (mothership/internal/learning/feedback_processor.go) that runs every 6 hours or when triggered manually.\n\nFor FALSE_POSITIVE events with associated CSI data (in the recording buffer from Phase 2):\n- Retrieve the CSI data from the recording buffer at the event timestamp for all links\n- Add the CSI frame data to a \"known false positive\" set in SQLite: false_positive_frames (link_id, timestamp, delta_rms, context_json)\n- The weight learner (self-improving localisation bead) uses this set as negative examples\n\nFor FALSE_NEGATIVE events with user-reported position:\n- Add to \"known false negative\" set: false_negative_frames (link_id, timestamp, expected_position_xyz, context_json)\n- The weight learner uses this as a positive example at the specified position\n\nAfter processing, mark feedback.applied = TRUE.\n\n## Files to Create or Modify\n\n- mothership/internal/learning/feedback_processor.go: feedback processing pipeline\n- mothership/internal/analytics/accuracy.go: weekly metric computation\n- dashboard/js/feedback.js: thumbs-up/down UI components (reusable across 3D view and timeline)\n- dashboard/js/accuracy.js: Accuracy panel rendering\n- mothership/internal/dashboard/routes.go: POST /api/feedback, GET /api/accuracy\n\n## Tests\n\n- Test feedback storage: POST /api/feedback with each feedback_type, verify SQLite record created\n- Test accuracy metric computation with synthetic TP/FP/FN data: 8 TP, 2 FP, 1 FN -> precision=0.8, recall=0.888\n- Test weekly rollup: 7 days of daily feedback -> correctly aggregated weekly metric\n- Test that applied=false events are found and marked as applied after processor run\n- Test \"improvements\" counter: feedback_count increases on each POST /api/feedback call\n\n## Acceptance Criteria\n\n- Thumbs-up/down buttons appear on active tracks in 3D view and on all timeline events\n- \"Missed detection\" button and form available in timeline panel\n- Feedback stored in SQLite with correct feedback_type and details\n- Accuracy metrics computed weekly and stored in detection_accuracy table\n- Accuracy panel shows week-over-week trend (requires at least 2 weeks of data)\n- Feedback improvement counter shows correct counts\n- Applied flag correctly set after processor run\n- Tests pass","status":"closed","priority":3,"issue_type":"task","assignee":"sp4","created_at":"2026-03-28T01:49:50.419277632Z","created_by":"coding","updated_at":"2026-03-29T22:08:03.778130122Z","closed_at":"2026-03-29T22:08:03.778000167Z","close_reason":"Implementation complete: feedback storage (SQLite), accuracy computation (precision/recall/F1 weekly), feedback processor (6h interval), API endpoints (/api/learning/*), frontend feedback UI (thumbs up/down, missed detection form), accuracy panel (F1 gauge, sparkline, per-zone breakdown). All 12 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"spaxel-3ps","depends_on_id":"spaxel-zvs","type":"blocks","created_at":"2026-03-28T03:29:14.442377218Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-403","title":"Implement anomaly detection & security mode","description":"Build pattern learning and anomaly detection for security.\n\nDeliverables:\n- 7-day pattern learning algorithm\n- Anomaly scoring against learned patterns\n- Security mode integration\n\nAcceptance: System detects deviations from learned patterns; accuracy improves measurably over 4 weeks.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-03-29T19:25:04.187535979Z","created_by":"coding","updated_at":"2026-04-02T01:17:54.440130869Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:922","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} +{"id":"spaxel-4fg","title":"Implement Replay/Time-Travel REST endpoints","description":"Implement GET /api/replay/sessions to list recording sessions. Add POST endpoints: /api/replay/start to start replay at timestamp, /api/replay/stop to return to live, /api/replay/seek to seek within session, /api/replay/tune to update pipeline parameters mid-replay. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.497876498Z","created_by":"coding","updated_at":"2026-04-07T13:20:09.903154198Z","closed_at":"2026-04-07T13:20:09.902983511Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-51k","title":"OTA firmware update system","description":"## Background\n\nOnce nodes are deployed in a home, they need to be updated without physical access. ESP-IDF has a mature OTA (Over-The-Air) update mechanism: two OTA flash partitions (factory, ota_0, ota_1 as defined in firmware/partitions.csv), HTTP download to the inactive partition, cryptographic verification, set boot partition, reboot. The mothership serves firmware binaries and triggers the update via a WebSocket downstream command. Phase 1 laid the groundwork in the firmware; this bead completes the mothership side.\n\n## What Already Exists\n\nfirmware/main/websocket.c has OTA command handling and an ota_download_task that handles the HTTP download to the inactive OTA partition. The partition table in firmware/partitions.csv has factory + ota_0 + ota_1 slots. The firmware parses {type:\"ota\", url:\"...\", md5:\"...\", version:\"...\"} downstream commands and initiates the download. What is missing is:\n- The mothership HTTP server for firmware binary serving\n- The REST API for triggering OTA per-node or fleet-wide\n- The firmware manifest for version management\n- The rollback detection logic on the mothership side\n- The dashboard UI for OTA management\n\n## Mothership Firmware Serving\n\nGET /firmware/latest or GET /firmware/{version}: serves the compiled .bin file from the /firmware volume mount. The response must include:\n- Content-Length header (required by ESP-IDF OTA HTTP client for progress reporting)\n- ETag header (MD5 of the binary, for caching)\n- Content-Type: application/octet-stream\n\nFirmware binaries are placed in the /firmware volume mount (configured in docker-compose.yml or k8s volume mount). The mothership reads the firmware manifest on startup and re-reads it when a new file appears (inotify watch or periodic re-scan every 60s).\n\n## Firmware Manifest\n\nFile: /firmware/manifest.json — auto-generated by the CI build process, or manually created.\nFormat: list of objects, each with version (semver string), filename (basename within /firmware/), md5 (hex string of binary MD5), size (integer bytes), build_timestamp (ISO8601).\nThe mothership's \"latest\" version is determined by sorting manifest entries by semver and taking the highest.\n\n## OTA Trigger API\n\nPOST /api/nodes/{mac}/ota — trigger OTA for a specific node\nRequest body: {\"version\": \"0.2.0\"} (optional; defaults to latest if omitted)\nResponse: {\"job_id\": \"abc123\", \"node_mac\": \"aa:bb:cc:dd:ee:ff\", \"target_version\": \"0.2.0\", \"status\": \"initiated\"}\n\nThe mothership looks up the node's active WebSocket session in the connection registry and sends the OTA command:\n{\"type\":\"ota\",\"url\":\"http://{mothership_ip}:{port}/firmware/0.2.0\",\"md5\":\"{hex_md5}\",\"version\":\"0.2.0\"}\n\nThe firmware immediately begins the download in a background task, sends ota_status messages ({\"type\":\"ota_status\",\"progress\":45,\"status\":\"downloading\"}) which the mothership logs and broadcasts to the dashboard.\n\n## Fleet Rolling Update\n\nPOST /api/ota/fleet — trigger OTA for all connected nodes\nRequest body: {\"version\": \"0.2.0\", \"stagger_seconds\": 30} (default stagger: 30s)\n\nThe rolling update coordinator in the mothership triggers OTA for the first node, waits stagger_seconds, then the next, and so on. This ensures:\n- Not all nodes reboot simultaneously (avoids a coverage gap window)\n- If a node fails OTA, the remaining nodes can be halted before more disruption\n- Fleet update progress is visible in dashboard per-node\n\nThe fleet update job is stored in SQLite (ota_jobs table) and survives mothership restarts.\n\n## Rollback Detection\n\nESP-IDF automatically rolls back to the previous firmware if the new image does not call esp_ota_mark_app_valid_cancel_rollback() within a boot window (the firmware does this on successful WebSocket connection to the mothership). The mothership detects rollback by comparing the firmware_version field in the hello message after OTA against the requested target version. If they differ, the mothership logs an OTA rollback event and updates the node's status to \"rollback\".\n\n## Dashboard OTA UI\n\nAdd an OTA panel to the dashboard settings or fleet page:\n- Per-node: current firmware version, available version (if newer), \"Update\" button\n- Fleet: \"Update All\" button with stagger slider, progress per node (with percentage from ota_status messages), last updated time per node\n- Version history: per-node firmware version history in tooltip or expandable row\n- Rollback indicator: nodes that rolled back are highlighted with a warning and the reason (if known)\n\n## Files to Create or Modify\n\n- mothership/internal/ota/server.go: firmware file serving with Content-Length and ETag\n- mothership/internal/ota/manifest.go: manifest parsing and latest-version logic\n- mothership/internal/ota/jobs.go: OTA job creation, fleet rolling update coordinator, status tracking\n- mothership/internal/dashboard/routes.go: register OTA API routes\n- dashboard/js/fleet.js or dashboard/js/ota.js: OTA UI panel\n\n## Tests\n\n- Test OTA command JSON serialisation matches firmware's expected format exactly\n- Test rolling update stagger timing with a mock time source (use a clock interface for testability)\n- Test that firmware version in hello message is parsed and stored in the node registry\n- Test manifest parsing: valid manifest, empty manifest, malformed manifest\n- Test rollback detection when hello version does not match target version\n\n## Acceptance Criteria\n\n- OTA command reaches firmware and triggers download (verified via ota_status messages)\n- Rolling update staggers correctly with the configured delay between nodes\n- After successful OTA, node reconnects with new firmware version in hello message\n- Rollback is detectable via hello version mismatch and displayed in dashboard\n- MD5 verification failure in firmware logs an error and the old firmware remains running\n- Fleet update status visible per-node in dashboard\n- Tests pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-28T01:37:32.472078279Z","created_by":"coding","updated_at":"2026-03-28T05:36:39.250035631Z","closed_at":"2026-03-28T05:36:39.249972673Z","close_reason":"Implemented: ota/manager.go + ota/server.go (fb69190) — HTTP firmware serving from /firmware volume, WebSocket-triggered OTA command, rolling update with 30s stagger, MD5 verification, firmware manifest.json, rollback detection via hello version mismatch","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-51k","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T03:29:13.874999678Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-5es","title":"Ambient dashboard mode","description":"## Background\n\nA wall-mounted tablet showing who is home is a genuinely useful home appliance — a digital replacement for the \"whiteboard on the fridge\" that families have used for decades. The ambient mode is designed for exactly this use case: always on, minimal information density, visually calm, and unobtrusive. It should run for weeks without user interaction, surviving screen timeouts, browser updates, and mothership restarts. When something important happens (fall, security alert), it breaks the calm decisively to get attention.\n\n## Route and Renderer\n\nNew route: /ambient (separate from /simple and expert mode)\nThe user can set a specific device as \"ambient display\" via a browser bookmark or home screen shortcut.\n\nCanvas 2D renderer (not Three.js): the ambient mode uses a dedicated Canvas 2D rendering engine rather than Three.js. Reasons:\n1. Three.js is designed for 3D; the ambient view is a 2D top-down floor plan\n2. Canvas 2D uses significantly less GPU memory and power — important for always-on tablet use\n3. Simpler code path means fewer failure modes for long-running display\n4. Lower battery drain on iPad and Android tablets\n\nRenderer: dashboard/js/ambient_renderer.js. Renders via requestAnimationFrame at 2 Hz (one frame every 500ms). The 2 Hz update rate is intentional: it's visually smooth enough for a presence display and uses minimal CPU.\n\n## Rendered Elements\n\nBackground: colour depends on time of day (see Time-of-Day Palette below).\n\nRoom outline: 2D rectangles for each zone's bounding box (projected to floor plane). White (#ffffff) with 1px stroke, no fill (transparent interior). Zone labels: zone name in white text at centroid, 14px medium font.\n\nPortal lines: thin lines (0.5px, #a855f7 purple) across doorways.\n\nNode positions: small filled circles (radius 4px, #6b7280 grey, subtle).\n\nPerson blobs: filled circles (radius 10-18px) in person.color. Name label above: first name only, 12px white. Blob radius proportional to identity confidence: full size if confident, slightly smaller if low confidence. If anonymous: a ghost/outline circle (stroke only, no fill) in light grey.\n\nSystem status indicator: top-left corner. Small circle (8px radius): green (#22c55e) if all nodes healthy, amber (#f59e0b) if any node degraded, red (#ef4444) if any node offline. No text — just the dot. Tooltip on hover (for the rare touch-and-hold interaction).\n\nTime display: top-right. Current time in large readable font. 28px, #ffffff, tabular-nums font variant for clean time display without layout shifts.\n\nPerson positions: lerp-interpolated between WebSocket updates for smooth movement at the 2 Hz render rate. On each WebSocket message received, update the target position. On each render frame, move each person blob 20% of the remaining distance to the target position (exponential approach = naturally decelerating animation).\n\n## Auto-Dim\n\nWhen no person is detected in the room where the ambient tablet is physically located (a zone that the user has configured as the \"ambient display zone\"), reduce the canvas brightness after 60 seconds of no detection:\n- After 60s: reduce canvas globalAlpha to 0.4 (40% brightness)\n- Restore immediately when presence detected in the ambient zone again\n\nImplementation: Use the CSS filter brightness() property on the canvas element, animated with a CSS transition. Set `canvas.style.filter = 'brightness(0.4)'` after timeout. This approach correctly dims including text labels.\n\nOptionally: if the device supports it (iOS WKWebView + Power Saving APIs), the ambient mode can request Screen Wake Lock API to prevent the tablet display from sleeping. window.navigator.wakeLock.request('screen'). Re-request on visibility change (the lock is released when the page is hidden).\n\n## Alert Mode\n\nWhen a fall or security alert fires (FallDetected or AnomalyDetected event received via WebSocket):\n1. The Canvas render loop detects the alert event\n2. Full canvas background fades to #dc2626 (urgent red) over 500ms\n3. Large white text in the centre: \"FALL DETECTED — Alice\" or \"ALERT — Motion while away\"\n4. Pulsing animation: canvas background alternates between #dc2626 and #991b1b at 1 Hz\n5. Acknowledge button rendered as a large white rectangle in the centre below the text\n6. Tap/click on the acknowledge button: POST /api/fall/{id}/acknowledge or /api/anomalies/{id}/acknowledge. Returns to normal ambient mode.\n\nThis alert mode must be clearly visible from across the room — the text should be at least 48px on a typical 10-inch tablet.\n\n## Morning Briefing Overlay\n\nFirst time presence is detected after 6am (configurable): the ambient display shows a brief overlay card for 15 seconds:\n- Overlaid on the normal floor plan (dark semi-transparent background)\n- Sleep summary (if available): \"Alice: 7h 23m, good sleep\"\n- Today's expected departures (from presence prediction, Phase 7): \"Alice likely leaves at 8:30am\"\n- System status: \"4 nodes healthy\"\n\nAfter 15 seconds: fade out and return to normal ambient view. Tapping the overlay dismisses it immediately.\n\n## Time-of-Day Palette\n\nThe ambient canvas background colour shifts with the time of day to be visually appropriate:\n- 06:00-12:00 (morning): light blue-grey (#f0f4f8, near white) — cool morning light feel\n- 12:00-18:00 (afternoon): neutral grey (#1e293b, dark) — reduces eye strain in bright rooms\n- 18:00-22:00 (evening): warm amber-grey (#1c1507, very dark warm) — matches evening lighting\n- 22:00-06:00 (night): near black (#040404) — OLED-friendly, minimal light\n\nTransitions: smooth CSS gradient transition over 30 minutes at each boundary (not instant). Implemented by pre-computing the target colour for the next 30 minutes and using CSS linear-gradient + keyframe animation.\n\n## Performance\n\nTarget: < 5% CPU usage on a 2016-era iPad (A9 chip). Achieved by:\n- 2 Hz render rate instead of 60 Hz (30x reduction in GPU/CPU work)\n- No Three.js, WebGL, or shader compilation overhead\n- Canvas 2D is hardware accelerated but lightweight\n- Blob count in a home is typically 1-4 — trivial to render\n\nMemory: the ambient page should have < 50MB JS heap. No large textures, no complex geometry.\n\n## Files to Create or Modify\n\n- dashboard/ambient.html: minimal HTML shell\n- dashboard/js/ambient.js: main ambient mode logic, WebSocket connection, alert handling\n- dashboard/js/ambient_renderer.js: Canvas 2D rendering engine\n- dashboard/js/ambient_briefing.js: morning briefing overlay\n- mothership/internal/dashboard/routes.go: /ambient route served statically\n\n## Tests\n\n- Test Canvas 2D renderer draws correct shapes: zone rectangle at (1,1)-(3,3) appears as a white rectangle at the correct pixel coordinates (given known canvas size and room dimensions)\n- Test auto-dim timer: mock 60s with no presence event, verify canvas brightness is reduced\n- Test auto-dim restore: presence event arrives, verify brightness returns to 100%\n- Test alert mode: inject FallDetected event, verify canvas background changes to red and text appears\n- Test acknowledge clears alert mode and returns to normal\n- Test morning briefing overlay appears only once after 6am (localStorage flag set)\n- Test lerp interpolation: person position updates from (1,1) to (3,3), after 5 render frames should be approximately (2.5, 2.5) (with 20% step lerp)\n\n## Acceptance Criteria\n\n- Ambient mode runs for 7 days without page reload (no memory leaks, no uncaught exceptions)\n- Auto-dim activates after 60 seconds of no presence in the display zone\n- Fall/anomaly alert mode clearly visible from 3 metres away on a 10-inch tablet\n- Acknowledge button works and returns to normal ambient\n- Morning briefing overlay appears once per day, dismisses after 15s\n- Canvas 2D rendering consumes < 5% CPU on a mid-range tablet\n- Time-of-day palette transitions are smooth (no hard cuts)\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:00:34.796733529Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.888767875Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-5es","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.888731706Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-65k","title":"Dashboard: activity timeline view","description":"## Overview\n\nBuild the unified activity timeline — the primary event history UI, accessible via the #timeline route added in the dashboard framework bead.\n\n## What to build (dashboard/js/timeline.js + timeline.css)\n\n### Timeline sidebar\n- Scrollable chronological event list (newest at top)\n- Event types with distinct icons/colors:\n - Zone entry/exit (green/orange)\n - Portal crossing (blue arrow)\n - Anomaly / security alert (red pulse)\n - Learning milestone (purple star)\n - System event (grey gear)\n- Each event shows: timestamp, description, person name (if identified), zone name\n- Click event → jump to that moment in the 3D view (triggers replay seek to that timestamp)\n\n### Filter bar\n- Filter by: person, zone, event type, time range (today / last 7d / custom)\n- Search box with debounced text filter across event descriptions\n\n### Inline feedback\n- Thumbs up / thumbs down on presence detection events\n- POST /api/feedback with event_id and correct (bool)\n- System response toast: 'Thanks — threshold adjusted for kitchen link'\n\n### Data source\n- Initial load: GET /api/events?limit=200&since=24h\n- Live updates: 'event' messages from WebSocket feed (requires spaxel-9eg)\n\n## Acceptance\n\n- 200 events render within 200ms of page load\n- New events prepend without layout shift\n- Clicking an event in replay mode seeks the replay to ±5s around the event\n- Feedback buttons POST successfully and show toast confirmation","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T12:56:03.195915329Z","created_by":"coding","updated_at":"2026-04-06T16:01:48.118589901Z","closed_at":"2026-04-06T16:01:48.118470381Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} @@ -63,6 +65,7 @@ {"id":"spaxel-mg0","title":"Mothership: installation secret generation with one-time print","description":"## Overview\nAuto-generate a 256-bit installation secret on first run, print it exactly once to stdout, and use it for node provisioning token derivation.\n\n## Implementation (mothership/internal/auth/ or cmd/mothership/main.go)\n\n### On startup (before HTTP server starts):\n1. Check SPAXEL_INSTALL_SECRET env var — if set, use it directly\n2. If not set: query SQLite auth table for install_secret column\n3. If found in SQLite: load silently (log at DEBUG level only)\n4. If not found: generate 32 random bytes via crypto/rand.Read()\n5. Store hex-encoded secret in auth.install_secret (INSERT OR IGNORE)\n6. Print ONCE to stdout: '[SPAXEL] Installation secret: <64-char-hex>. Shown once — save to a safe place.'\n7. Never print again on subsequent startups\n\n### Usage:\n- Installation secret used to derive per-node provisioning tokens (HMAC-SHA256 of node_mac + secret)\n- Exposed via GET /api/auth/install-secret (requires admin session or first-run state)\n\n## Acceptance\n- First run: secret printed to stdout and stored in SQLite\n- Second run: no output — secret loaded silently from SQLite\n- SPAXEL_INSTALL_SECRET env var overrides SQLite value (printed at INFO: 'Using provided SPAXEL_INSTALL_SECRET')\n- crypto/rand used (not math/rand)","status":"in_progress","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-06T16:43:19.679455445Z","created_by":"coding","updated_at":"2026-04-06T18:32:42.735232797Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:26"]} {"id":"spaxel-mjn","title":"Passive radar: OUI lookup & router manufacturer identification","description":"## Overview\nEmbed an IEEE OUI registry at build time so the mothership can display friendly router manufacturer names during passive radar onboarding.\n\n## Implementation (mothership/internal/oui/)\n\n### go generate step (oui/gen.go):\n//go:generate go run gen.go\n- Download https://standards-oui.ieee.org/oui/oui.txt at generate time (not at runtime)\n- Parse lines: '00-00-0C (hex) Cisco Systems' → extract hex prefix and vendor name\n- Generate oui_data.go: var ouiMap = map[uint32]string{0x00000C: 'Cisco Systems', ...}\n- Only regenerate when manually triggered; commit oui_data.go to the repo\n\n### Lookup function (oui/oui.go):\nfunc LookupOUI(mac net.HardwareAddr) string\n - Extract first 3 bytes as uint32 (big-endian)\n - Return ouiMap[key] or '' if not found\n\n### Integration:\n- In passive radar AP detection (spaxel-w40): when AP BSSID detected, call LookupOUI(bssid)\n- Onboarding wizard shows: 'I detected your router (ASUS). Place it on the floor plan.'\n- If OUI unknown: show 'I detected your router. Place it on the floor plan.'\n- GET /api/nodes response: include manufacturer field for virtual nodes\n\n## Acceptance\n- LookupOUI(00:1A:2B:...) returns correct vendor for known OUIs\n- oui_data.go compiles without errors\n- go generate produces non-empty map (>5000 entries)\n- Unknown OUI returns empty string (no panic)","status":"open","priority":3,"issue_type":"task","created_at":"2026-04-06T13:10:41.582690525Z","created_by":"coding","updated_at":"2026-04-06T13:10:41.582690525Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-mrq","title":"Genesis: Spaxel Implementation","description":"## Genesis Bead\nTied to plan: /home/coding/spaxel/docs/plan/plan.md\n\n## Overview\nWiFi CSI-based indoor positioning for self-hosted home environments. Docker container mothership + ESP32-S3 fleet.\n\n## Progress\n- [x] Phase 1: Foundation — COMPLETE\n- [x] Phase 2: Signal Processing & Detection — COMPLETE\n- [x] Phase 3: Multi-Node & Localization — COMPLETE\n- [x] Phase 4: Onboarding & OTA — COMPLETE\n- [x] Phase 5: Reliability & Intelligence — COMPLETE\n- [ ] Phase 6: Identity & Spatial Automation — IN PROGRESS\n- [ ] Phase 7: Learning & Analytics — IN PROGRESS\n- [ ] Phase 8: Analysis & Developer Tools — NOT STARTED\n- [ ] Phase 9: UX Polish & Accessibility — NOT STARTED\n\n## Key Gaps (blocking beads created 2026-04-06)\n- spaxel-jcc: Reintegrate phase 6+ packages into default build (CRITICAL — dead code)\n- spaxel-896: Dashboard panel/modal/sidebar UI framework (CRITICAL — blocks all UI work)\n- spaxel-9eg: Expand WebSocket feed (events, alerts, anomalies, triggers, BLE)\n- spaxel-6ha: Complete REST API (settings, zones, portals, triggers, notifications, replay)\n- spaxel-65k: Activity timeline dashboard view\n- spaxel-a55: Anomaly detection & security mode UI\n- spaxel-iv3: Detection explainability overlay\n- spaxel-ciu: Trigger CI build and deploy to ardenone-cluster","status":"open","priority":0,"issue_type":"genesis","created_at":"2026-03-27T01:54:55.636914996Z","created_by":"coding","updated_at":"2026-04-06T16:44:52.276506614Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:14","no-claim"],"dependencies":[{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-0w4","type":"blocks","created_at":"2026-04-06T13:02:49.655276740Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-17u","type":"blocks","created_at":"2026-04-06T13:02:50.147170937Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-2ap","type":"blocks","created_at":"2026-04-06T13:02:44.117720621Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-403","type":"blocks","created_at":"2026-04-06T13:02:50.226439540Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-5es","type":"blocks","created_at":"2026-04-06T13:02:49.801304001Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-65k","type":"blocks","created_at":"2026-04-06T12:56:31.882060297Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-6ha","type":"blocks","created_at":"2026-04-06T12:56:31.858274512Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-6hd","type":"blocks","created_at":"2026-04-06T16:44:52.024534916Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-7qo","type":"blocks","created_at":"2026-04-06T16:44:52.252390311Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-7zy","type":"blocks","created_at":"2026-04-06T13:02:49.951179408Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-896","type":"blocks","created_at":"2026-04-06T12:56:31.815033074Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9eg","type":"blocks","created_at":"2026-04-06T12:56:31.834911726Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9z3","type":"blocks","created_at":"2026-04-06T16:37:48.728038956Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-9zs","type":"blocks","created_at":"2026-04-06T16:44:52.153100114Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-a1f","type":"blocks","created_at":"2026-04-06T13:02:49.725755530Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-a55","type":"blocks","created_at":"2026-04-06T12:56:31.905258303Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-btj","type":"blocks","created_at":"2026-04-06T13:02:49.897539577Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-c02","type":"blocks","created_at":"2026-04-06T16:44:52.127666165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-csj","type":"blocks","created_at":"2026-04-06T13:02:49.776095286Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-fll","type":"blocks","created_at":"2026-04-06T16:37:48.779053456Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-g1o","type":"blocks","created_at":"2026-04-06T13:02:44.142578703Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-goc","type":"blocks","created_at":"2026-04-06T13:02:44.034962055Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-i28","type":"blocks","created_at":"2026-04-06T13:02:50.197971Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-iv3","type":"blocks","created_at":"2026-04-06T12:56:31.927130663Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jc4","type":"blocks","created_at":"2026-04-06T13:02:50.125304165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jcc","type":"blocks","created_at":"2026-04-06T12:56:31.790764319Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jk0","type":"blocks","created_at":"2026-04-06T13:02:49.823378278Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jy4","type":"blocks","created_at":"2026-04-06T13:02:49.975935117Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-jza","type":"blocks","created_at":"2026-04-06T16:44:52.077718624Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-klf","type":"blocks","created_at":"2026-04-06T13:02:50.277041292Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-kth","type":"blocks","created_at":"2026-04-06T13:02:49.681642745Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-leh","type":"blocks","created_at":"2026-04-06T16:37:48.827955335Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-lui","type":"blocks","created_at":"2026-04-06T16:44:52.204326648Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-lve","type":"blocks","created_at":"2026-04-06T16:44:51.999968395Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-mg0","type":"blocks","created_at":"2026-04-06T16:44:52.103108359Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-mjn","type":"blocks","created_at":"2026-04-06T16:37:48.870206976Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-nk6","type":"blocks","created_at":"2026-04-06T16:44:52.050776554Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-nqh","type":"blocks","created_at":"2026-04-06T13:02:50.101273231Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-o0e","type":"blocks","created_at":"2026-04-06T13:02:49.848226825Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ofa","type":"blocks","created_at":"2026-04-06T16:44:52.276456165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-oql","type":"blocks","created_at":"2026-04-06T16:44:51.942776576Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-pv5","type":"blocks","created_at":"2026-04-06T16:37:48.851027003Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-pvz","type":"blocks","created_at":"2026-04-06T13:02:49.928120440Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qfp","type":"blocks","created_at":"2026-04-06T13:02:50.001502400Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qlh","type":"blocks","created_at":"2026-04-06T13:02:50.076094965Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-qob","type":"blocks","created_at":"2026-04-06T13:02:44.074486180Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-r7t","type":"blocks","created_at":"2026-04-06T13:02:43.985868678Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-s60","type":"blocks","created_at":"2026-04-06T13:02:50.252133977Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-04-06T13:02:50.170188684Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-sty","type":"blocks","created_at":"2026-04-06T13:02:49.872412505Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tgj","type":"blocks","created_at":"2026-04-06T13:02:50.026388907Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tig","type":"blocks","created_at":"2026-04-06T13:02:49.701543756Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-tvq","type":"blocks","created_at":"2026-04-06T13:02:49.750726171Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-u7y","type":"blocks","created_at":"2026-04-06T16:44:51.975396466Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ugj","type":"blocks","created_at":"2026-04-06T16:37:48.895375409Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-uod","type":"blocks","created_at":"2026-04-06T16:37:48.805239145Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-ux6","type":"blocks","created_at":"2026-04-06T16:44:52.178861043Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-vuw","type":"blocks","created_at":"2026-04-06T13:02:44.054997291Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-w40","type":"blocks","created_at":"2026-04-06T13:02:44.013053815Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-xpk","type":"blocks","created_at":"2026-04-06T13:02:44.097699492Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-yxr","type":"blocks","created_at":"2026-04-06T16:44:52.228910237Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-zpt","type":"blocks","created_at":"2026-04-06T13:02:50.051735836Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-mrq","depends_on_id":"spaxel-zvb","type":"blocks","created_at":"2026-04-06T16:37:48.758098316Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-mul","title":"Implement Automation Triggers REST endpoints","description":"Implement CRUD endpoints for triggers: GET/POST /api/triggers, PUT/DELETE /api/triggers/{id}. Add POST /api/triggers/{id}/test to fire trigger once for testing. Include OpenAPI-style godoc comments.","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T15:31:10.356946401Z","created_by":"coding","updated_at":"2026-04-07T13:31:48.137068910Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-n9n","title":"Biomechanical blob tracking (UKF)","description":"Track detected blobs as human figures with physics-constrained motion model.\n\n## Deliverables\n- New package: mothership/internal/tracker/\n- Unscented Kalman Filter (UKF) with human motion model\n- Constraints: max velocity 2m/s, max acceleration 3m/s², turning radius, gravity-consistent Z\n- Blob ID assignment and persistence through brief gaps (up to 3s)\n- Collision avoidance between tracked entities\n- Posture estimation: standing/walking/seated/lying based on Z and velocity\n- Uses gonum.org/v1/gonum/mat for matrix operations\n\n## Acceptance Criteria\n- Tracks a single person smoothly through a room\n- Maintains blob ID across brief occlusions\n- Posture transitions are physically plausible\n- Tests with synthetic trajectory data\n\n## References\n- Plan: docs/plan/plan.md item 16\n- Fusion output: mothership/internal/fusion/ (blob positions)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:55.704147095Z","created_by":"coding","updated_at":"2026-03-28T02:06:17.873405703Z","closed_at":"2026-03-27T03:59:10.182764206Z","close_reason":"Implemented mothership/internal/tracker/ package with 6-state UKF (x,y,z,vx,vy,vz), biomechanical constraints (max horiz vel 2 m/s, max accel 3 m/s², min turning radius 0.3 m, gravity-consistent Z), blob ID persistence through 3s gaps, floor-plane collision avoidance, posture estimation (standing/walking/seated/lying), 60-point trail. 11 synthetic trajectory tests all pass. Uses gonum.org/v1/gonum/mat.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-n9n","depends_on_id":"spaxel-m9a","type":"blocks","created_at":"2026-03-28T02:06:17.873372333Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-n9n","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.608542494Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-nk6","title":"Dashboard: PIN setup, login, and session cookie authentication","description":"## Overview\nProtect the dashboard with a PIN that is set on first run and verified on every subsequent visit via session cookies.\n\n## Backend (mothership/internal/auth/)\n- SQLite auth table: pin_bcrypt TEXT, install_secret TEXT (singleton row)\n- GET /api/auth/status — return {pin_configured: bool} — no auth required\n- POST /api/auth/setup — body: {pin:'1234'} — only works if pin not yet configured; bcrypt cost=12; store hash\n- POST /api/auth/login — body: {pin:'1234'} — verify bcrypt; on success issue session cookie:\n Name: spaxel_session, HttpOnly, SameSite=Strict (if TLS), Path=/, Max-Age=604800\n Store session_id → expires_at in SQLite sessions table\n- POST /api/auth/logout — clear session cookie; delete session from SQLite\n- Session middleware: all /api/* and /ws/* require valid session cookie; return 401 if missing/expired\n- Rolling window: on each authenticated request, if within 24h of expiry, extend by 7 days\n\n## Dashboard (dashboard/js/auth.js)\n- On load: GET /api/auth/status; if pin_configured=false → show first-run PIN setup page (full-screen, blocks dashboard)\n- First-run page: enter PIN + confirm PIN → POST /api/auth/setup → reload\n- Login page: shown on 401; PIN entry form → POST /api/auth/login → reload on success\n- Logout button in settings panel → POST /api/auth/logout → redirect to login\n\n## Acceptance\n- Fresh install: setup page shown before any dashboard content\n- After PIN set: login required on next visit\n- Session cookie survives page refresh; expires after 7 days of inactivity\n- 401 returned immediately for any /api/ call without valid cookie","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:43:02.833541561Z","created_by":"coding","updated_at":"2026-04-06T17:17:02.044261204Z","closed_at":"2026-04-06T17:17:02.044047110Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} {"id":"spaxel-nqh","title":"BLE-to-blob identity matching","description":"## Background\n\nThe BLE device registry (spaxel-2wg) tells us which labelled devices are present in the home and their per-node RSSI. The CSI fusion engine (spaxel-m9a) and blob tracker (spaxel-n9n) tell us where anonymous humanoid figures are located in 3D space. Identity matching bridges these two systems: given named BLE devices with per-node RSSI observations, and anonymous CSI blobs with estimated 3D positions, assign device identities to blobs.\n\nWhen a match is confident, the humanoid figure in the 3D view gains a name label and person colour. \"Alice is in the Kitchen\" becomes a real-time fact rather than an approximation.\n\n## RSSI Triangulation Algorithm\n\nFor each BLE device with observations from multiple nodes in the current scan cycle (last 5 seconds):\n\nStep 1: Convert RSSI to distance for each observing node.\nDistance model: d = d0 * 10^((RSSI_ref - RSSI) / (10 * n))\nParameters: d0 = 1.0m (reference distance), RSSI_ref = -65 dBm (RSSI at 1m in typical indoor environment), n = 2.5 (indoor path loss exponent — typical range 2.0-3.5; 2.5 is a reasonable default for residential spaces).\n\nStep 2: With 2+ nodes, perform weighted least squares triangulation.\nPosition estimate: argmin_P { sum_i w_i * (|P - node_i| - d_i)^2 }\nwhere w_i = 1/sigma_i^2, sigma_i = d_i * ln(10) / (10 * n) * RSSI_noise_sigma (typically 5 dBm).\nSolve with 3-5 iterations of gradient descent or Gauss-Newton (the system is nonlinear).\nWith 1 node: only an approximate range estimate; triangulation confidence = 0.2.\nWith 2 nodes: 2D position estimate on a circle/arc; confidence = 0.5.\nWith 3+ nodes: full 2D position estimate with residual as quality indicator; confidence = min(1.0, 0.7 + 0.1 * (n-3)) where n is node count.\n\nStep 3: Assign to nearest CSI blob.\nFor each triangulated BLE position, find the nearest active CSI blob (from TrackManager) within a 2-metre search radius. The assignment uses Euclidean distance in the horizontal plane (ignore Z for BLE since antenna height is variable).\n\nIf multiple BLE devices map to the same blob (e.g. Alice has a phone AND a Fitbit), use the device with the highest triangulation confidence.\n\nIf a BLE device position is triangulated but no CSI blob is within 2 metres: create a \"BLE-only\" placeholder track at the BLE position. BLE-only tracks are shown as a different visual (outlined circle rather than filled, lower opacity) to indicate lower confidence.\n\n## Match Confidence Score\n\nFor each BLE-to-blob assignment, compute a match confidence:\nconfidence = f_observations * f_node_count * f_residual * f_distance\n\nwhere:\n- f_observations: 1.0 if device seen in last 5s, 0.5 if last 15s, 0.0 if older\n- f_node_count: 0.2 (1 node), 0.5 (2 nodes), 0.8+ (3+ nodes)\n- f_residual: 1.0 - min(1.0, triangulation_residual_metres / 2.0) — penalise poor triangulation fit\n- f_distance: 1.0 if blob distance < 0.5m, linear decay to 0 at 2.0m\n\nOnly assign identity if confidence > 0.6. Below this threshold: keep the blob anonymous (\"Unknown\").\n\n## Identity Persistence\n\nOnce a BLE-blob match is made, the identity persists for 5 minutes even if:\n- The BLE device rotates its MAC (new MAC will match same person via the person record)\n- The BLE device briefly falls below RSSI threshold of all nodes (indoor dead zone)\n- The CSI blob briefly disappears (tracker coasted state, per spaxel-n9n)\n\nAfter 5 minutes of no BLE observation and no high-confidence re-match: revert to anonymous.\n\nThis 5-minute persistence prevents flickering identity labels when people walk through areas with inconsistent BLE coverage.\n\n## TrackManager Integration\n\nExtend the TrackState struct in mothership/internal/tracker/ (spaxel-n9n):\n- PersonID string (from BLE registry people table)\n- PersonLabel string\n- PersonColor string\n- IdentityConfidence float64\n- IdentitySource string (\"ble_triangulation\" or \"ble_only\" or \"\")\n\nTrackManager.UpdateIdentities(blePositions map[deviceMAC]Vec3, registry BLERegistry) method: runs the matching algorithm and updates track identity fields.\n\nCalled at the same frequency as FusionEngine (10 Hz), but BLE positions only update at 5s intervals — cache the last BLE positions and re-use them between BLE updates.\n\n## API\n\nGET /api/tracks enriches the existing track list with person_id, person_label, person_color, and identity_confidence fields.\n\nExample response:\n[{\n \"id\": \"track-1\",\n \"position\": {\"x\": 3.2, \"y\": 1.8, \"z\": 1.0},\n \"velocity\": {\"x\": 0.1, \"y\": -0.2, \"z\": 0.0},\n \"confidence\": 0.87,\n \"posture_hint\": \"standing\",\n \"person_id\": \"uuid-alice\",\n \"person_label\": \"Alice\",\n \"person_color\": \"#3b82f6\",\n \"identity_confidence\": 0.82\n}]\n\n## Dashboard Integration\n\nIn the 3D view (Three.js scene): tracks with confirmed identity display a floating text label (person name) above the humanoid figure mesh, rendered using THREE.Sprite with a canvas texture containing the name in the person's colour.\n\nIn the \"People and Devices\" panel: each person row shows \"Currently: Kitchen\" (current zone from room transition portals, Phase 6) and \"In 3D view: [jump to track]\" link.\n\n## Tests\n\n- Test RSSI-to-distance conversion with known values: RSSI=-65 -> d=1.0m, RSSI=-75 -> d=2.5m (with default params)\n- Test triangulation with 3 nodes at known positions and known distances: verify position error < 0.5m\n- Test nearest-blob assignment: 2 blobs at (2,2) and (5,5), BLE device triangulated at (2.3,1.9) -> assigns to first blob\n- Test confidence gate: confidence=0.55 -> no assignment; confidence=0.65 -> assignment made\n- Test BLE-only placeholder track creation when no blob within 2m\n- Test identity persistence: after BLE device disappears, track retains identity for 5 minutes then reverts\n- Test identity handoff when BLE device MAC rotates: same person assignment maintained via person record\n\n## Acceptance Criteria\n\n- Named identity labels appear on 3D blobs when person carries a labelled BLE device with confidence > 0.6\n- Identity is maintained through brief BLE dead zones (up to 5 minutes)\n- Two people in the same room get correct distinct identities when their BLE devices are distinguishable\n- Confidence gate prevents wrong assignments (no identity label when confidence < 0.6)\n- BLE-only placeholder tracks appear for people whose BLE device is heard but no CSI blob is nearby\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:44:50.795871765Z","created_by":"coding","updated_at":"2026-03-30T16:27:42.750802Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} @@ -70,6 +73,7 @@ {"id":"spaxel-o4l","title":"Bidirectional node protocol","description":"Implement full bidirectional protocol over the existing WebSocket connection.\n\n## Deliverables\n- Registration (hello message with capabilities)\n- Health reporting (heap, WiFi RSSI, uptime, temperature every 10s)\n- BLE scan relay (device list JSON)\n- Role/config push from mothership (TX/RX/passive mode, packet rate)\n- OTA command messages (trigger update, progress tracking)\n- All messages over the existing single WebSocket per node\n\n## Acceptance Criteria\n- Nodes register on connect with full capability advertisement\n- Mothership can push role changes and config updates\n- Health metrics flow reliably at 10s intervals\n- Protocol is backward-compatible with Phase 1 implementation\n\n## References\n- Current protocol: mothership/internal/ingestion/message.go\n- Firmware WebSocket: firmware/main/websocket.c","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:31.632551776Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.644219477Z","closed_at":"2026-03-27T03:14:43.201850105Z","close_reason":"Implemented full bidirectional node protocol. Firmware: motion hints wired to websocket_send_motion_hint() with rate-limiting, csi_set_rate() fixed, all message types active (hello/health/ble/motion_hint/ota_status). Mothership: OnMotionHint() ramps adjacent nodes via topology callback, idle timeout 30s, variance threshold adaptive, added SendRoleToMAC() and SendOTAToMAC() for dynamic downstream pushes, OTA status logging. Binary CSI frames remain backward-compatible with Phase 1.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-o4l","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.644181123Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-ofa","title":"End-to-end integration test harness (simulator + mothership assertions)","description":"## Overview\nBuild an automated integration test that starts the mothership, runs the CSI simulator against it, and asserts on observable behavior — providing a production-realism gate for CI.\n\n## Test harness (tests/e2e/run.sh or Go test)\n\n### Setup:\n1. Start mothership container (or binary): docker run -d -p 8080:8080 ronaldraygun/spaxel:latest\n2. Wait for /healthz to return {status:'ok'} with 15s timeout (poll every 500ms)\n3. If PIN auth enabled: POST /api/auth/setup with test PIN; POST /api/auth/login\n\n### Run simulator:\n4. Start: sim --mothership http://localhost:8080 --nodes 4 --walkers 2 --duration 30s --rate 20 --ble --seed 42\n5. Simulator exits non-zero if it receives {type:'reject'} message; test fails immediately\n\n### Assert during run (poll every 1s for 30s):\n6. GET /api/blobs (or WebSocket) → assert blob_count > 0 within first 15s\n7. GET /api/nodes → assert nodes_online == 4 within first 5s\n8. GET /healthz → assert status=='ok' throughout entire run\n\n### Assert after run:\n9. GET /api/events?type=detection → assert at least 1 detection event recorded\n10. Simulator printed per-second frame counts to stdout; verify no frame-rate drop >20% from target\n\n## CI integration\n- GitHub Actions workflow: .github/workflows/e2e.yml (but only triggers from Argo Workflows via spaxel-ci)\n- Build image → run test harness → post result as bead comment\n\n## Acceptance\n- Test passes on fresh container with seed 42 configuration\n- Test fails clearly when mothership rejects frames (wrong protocol)\n- Test runs in <90s total (15s startup + 30s sim + 45s buffer)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:44:47.443177386Z","created_by":"coding","updated_at":"2026-04-06T16:44:47.443177386Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-oql","title":"Firmware: NVS schema migration on boot","description":"## Overview\nImplement versioned NVS key migration on ESP32-S3 firmware so OTA-updated firmware gracefully handles NVS written by older versions.\n\n## Implementation (firmware/main/nvs_migration.c)\n- On boot, open 'spaxel' NVS namespace and read schema_ver (uint8); if missing, write schema_ver=1\n- If schema_ver < COMPILED_NVS_VERSION: run migration functions in order (v1→v2, v2→v3, etc.)\n- Each migration: add/rename/remove specific NVS keys; call nvs_commit() after each write\n- After all migrations: update schema_ver = COMPILED_NVS_VERSION and commit\n- Log each migration step to UART for debugging\n\n## Example migration v1→v2:\n- Rename 'ms_ip' to 'mothership_ip' (read old key, write new key, erase old key)\n- Add 'ntp_server' key with default value 'pool.ntp.org'\n\n## Acceptance\n- Flash firmware v1.0 with known NVS schema; flash v1.1 firmware; verify all keys present\n- Migration runs exactly once (schema_ver correctly incremented)\n- Migration failure leaves NVS in consistent state (tested via simulated write failure)","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-06T16:42:15.874379750Z","created_by":"coding","updated_at":"2026-04-06T16:42:15.874379750Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-p5p","title":"Implement BLE Devices REST endpoints","description":"Implement GET /api/ble/devices to list known devices. Add PUT /api/ble/devices/{mac} to set label and assign to person. Include OpenAPI-style godoc comments.","status":"in_progress","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.569849257Z","created_by":"coding","updated_at":"2026-04-07T13:32:26.840085899Z","close_reason":"Implemented BLE Devices REST endpoints with OpenAPI-style godoc comments: GET /api/ble/devices lists devices with filtering; PUT /api/ble/devices/{mac} updates label and assigns to person. Added comprehensive tests.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-pel","title":"Dashboard presence indicator","description":"Add per-link motion detected/clear display to the dashboard.\n\n## Deliverables\n- WebSocket message from mothership to dashboard with per-link motion state\n- Visual indicator in dashboard UI: green = clear, red = motion detected per link\n- Amplitude time series plot for selected link (rolling window)\n- Update dashboard/js/app.js and mothership dashboard hub to broadcast motion state\n\n## Acceptance Criteria\n- Dashboard shows real-time motion/clear status for each active link\n- Amplitude time series updates smoothly\n- Works with the existing signal processing pipeline in mothership/internal/signal/\n\n## References\n- Dashboard code: dashboard/js/app.js, dashboard/index.html\n- Signal processing: mothership/internal/signal/processor.go (GetAllMotionStates)\n- Dashboard hub: mothership/internal/dashboard/hub.go","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:02.465235096Z","created_by":"coding","updated_at":"2026-03-28T01:34:05.674201551Z","closed_at":"2026-03-27T02:55:53.328233821Z","close_reason":"Implemented per-link motion presence indicator: green CLEAR/red MOTION badge per link in dashboard, 60s rolling amplitude time series for selected link, immediate motion state broadcast from hub on idle<->motion transitions, fixed ampHistory init bug for JSON-created links.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-pel","depends_on_id":"spaxel-cxm","type":"blocks","created_at":"2026-03-28T01:34:05.674165567Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-pgu","title":"Implement detection feedback loop","description":"Build the detection feedback system that enables user-driven improvement.\n\nDeliverables:\n- Thumbs up/down UI for detection events\n- Missed-detection marking capability\n- Accuracy trend tracking and visualization\n\nAcceptance: Users can provide feedback on detections; system tracks accuracy metrics over time.","status":"closed","priority":2,"issue_type":"task","assignee":"sp1","created_at":"2026-03-29T19:25:03.930370782Z","created_by":"coding","updated_at":"2026-03-29T22:11:29.477805625Z","closed_at":"2026-03-29T22:11:29.477539090Z","close_reason":"Detection feedback loop fully implemented. Thumbs up/down UI, missed-detection marking, and accuracy trend tracking all complete.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-pv5","title":"Backup: SQLite Online Backup API streaming endpoint","description":"## Overview\nImplement GET /api/backup using SQLite's Online Backup API for consistent hot backups without downtime or temp files.\n\n## Implementation (mothership/internal/ — new backup.go)\n\n### Why Online Backup API:\n- Simple file copy misses in-flight WAL pages and produces inconsistent backups\n- sqlite3_backup_* copies page-by-page; readers/writers continue uninterrupted\n- No temp file needed: stream directly to HTTP response\n\n### Go implementation using go-sqlite3 (CGO) or modernc.org/sqlite:\nfunc StreamBackup(w http.ResponseWriter, src *sql.DB):\n 1. Open in-memory destination DB: sqlite3_open(':memory:', &pDest)\n 2. Init backup: pBackup = sqlite3_backup_init(pDest, 'main', pSrc, 'main')\n 3. Loop: sqlite3_backup_step(pBackup, 100) until SQLITE_DONE\n 4. sqlite3_backup_finish(pBackup)\n 5. Read all bytes from pDest and write to http.ResponseWriter\n\n### Response format:\n- Content-Type: application/zip\n- Content-Disposition: attachment; filename='spaxel-backup-.zip'\n- Zip contents:\n - spaxel.db (from backup)\n - floor_plan/ directory (if exists)\n - VERSION file\n\n### Endpoint:\nGET /api/backup — requires session auth; streams zip directly; no temp files written\n\n## Acceptance\n- Backup completes while mothership is actively processing CSI frames\n- Downloaded .db file opens cleanly in sqlite3 CLI: PRAGMA integrity_check returns 'ok'\n- Backup size reasonable (not 0 bytes, not gigabytes for fresh install)\n- Simultaneous write during backup does not produce corrupt backup (verify with PRAGMA integrity_check)","status":"in_progress","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T13:10:29.966455717Z","created_by":"coding","updated_at":"2026-04-07T06:19:24.164031708Z","close_reason":"Implemented GET /api/backup using SQLite Online Backup API. The endpoint streams a zip archive (Content-Type: application/zip) containing all .db files, floor_plan/ directory (if present), and a VERSION file. Uses modernc.org/sqlite NewBackup/Step/Commit + Serialize for consistent hot backups with no temp files. Concurrent writes do not produce corrupt backups (verified via PRAGMA quick_check). 7 table-driven tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 59bc38d..f898ff6 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -eb5b43f27984d9aac3ad372b3666d8089fdb8a34 +56c28bce63be7d8f889d07c6ef88dfc83240fe9e diff --git a/mothership/internal/api/events.go b/mothership/internal/api/events.go index 1944ba9..4521734 100644 --- a/mothership/internal/api/events.go +++ b/mothership/internal/api/events.go @@ -320,7 +320,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { response.Cursor = nextCursor } - writeJSON(w, response) + writeJSON(w, http.StatusOK, response) } func (e *EventsHandler) getEvent(w http.ResponseWriter, r *http.Request) { @@ -346,5 +346,5 @@ func (e *EventsHandler) getEvent(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, event) + writeJSON(w, http.StatusOK, event) } diff --git a/mothership/internal/api/triggers.go b/mothership/internal/api/triggers.go index b5128d7..610a6ef 100644 --- a/mothership/internal/api/triggers.go +++ b/mothership/internal/api/triggers.go @@ -165,7 +165,7 @@ func (t *TriggersHandler) listTriggers(w http.ResponseWriter, r *http.Request) { } t.mu.RUnlock() - writeJSON(w, triggers) + writeJSON(w, http.StatusOK, triggers) } type createTriggerRequest struct { @@ -245,8 +245,7 @@ func (t *TriggersHandler) createTrigger(w http.ResponseWriter, r *http.Request) } t.mu.Unlock() - w.WriteHeader(http.StatusCreated) - writeJSON(w, t.triggers[req.ID]) + writeJSON(w, http.StatusCreated, t.triggers[req.ID]) } type updateTriggerRequest struct { @@ -317,7 +316,7 @@ func (t *TriggersHandler) updateTrigger(w http.ResponseWriter, r *http.Request) } if len(updates) == 0 { - writeJSON(w, trigger) + writeJSON(w, http.StatusOK, trigger) return } @@ -352,7 +351,7 @@ func (t *TriggersHandler) updateTrigger(w http.ResponseWriter, r *http.Request) } t.mu.Unlock() - writeJSON(w, trigger) + writeJSON(w, http.StatusOK, trigger) } func (t *TriggersHandler) deleteTrigger(w http.ResponseWriter, r *http.Request) { @@ -398,7 +397,7 @@ func (t *TriggersHandler) testTrigger(w http.ResponseWriter, r *http.Request) { t.mu.RUnlock() if engine == nil { - writeJSON(w, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "message": "trigger test simulated (no engine attached)", "trigger": trigger, @@ -411,7 +410,7 @@ func (t *TriggersHandler) testTrigger(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "fired", "message": "Trigger fired successfully", "trigger": trigger, diff --git a/mothership/internal/api/volume_triggers.go b/mothership/internal/api/volume_triggers.go index c64999c..df034d4 100644 --- a/mothership/internal/api/volume_triggers.go +++ b/mothership/internal/api/volume_triggers.go @@ -163,7 +163,7 @@ func (h *VolumeTriggersHandler) listTriggers(w http.ResponseWriter, r *http.Requ response = append(response, resp) } - writeJSON(w, response) + writeJSON(w, http.StatusOK, response) } func (h *VolumeTriggersHandler) getTrigger(w http.ResponseWriter, r *http.Request) { @@ -176,7 +176,7 @@ func (h *VolumeTriggersHandler) getTrigger(w http.ResponseWriter, r *http.Reques } resp := h.toResponse(trigger, time.Now()) - writeJSON(w, resp) + writeJSON(w, http.StatusOK, resp) } type volumeCreateTriggerRequest struct { @@ -250,9 +250,8 @@ func (h *VolumeTriggersHandler) createTrigger(w http.ResponseWriter, r *http.Req return } - w.WriteHeader(http.StatusCreated) resp := h.toResponse(created, time.Now()) - writeJSON(w, resp) + writeJSON(w, http.StatusCreated, resp) } type volumeUpdateTriggerRequest struct { @@ -314,7 +313,7 @@ func (h *VolumeTriggersHandler) updateTrigger(w http.ResponseWriter, r *http.Req } resp := h.toResponse(trigger, time.Now()) - writeJSON(w, resp) + writeJSON(w, http.StatusOK, resp) } func (h *VolumeTriggersHandler) deleteTrigger(w http.ResponseWriter, r *http.Request) { @@ -407,7 +406,7 @@ func (h *VolumeTriggersHandler) testTrigger(w http.ResponseWriter, r *http.Reque Actions: results, } - writeJSON(w, resp) + writeJSON(w, http.StatusOK, resp) } // enableTrigger clears error state and re-enables a trigger. @@ -422,7 +421,7 @@ func (h *VolumeTriggersHandler) enableTrigger(w http.ResponseWriter, r *http.Req // Broadcast updated trigger state to dashboard h.broadcastTriggerState(id) - writeJSON(w, map[string]interface{}{"status": "ok"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"}) } // disableTrigger disables a trigger. @@ -444,7 +443,7 @@ func (h *VolumeTriggersHandler) disableTrigger(w http.ResponseWriter, r *http.Re // Broadcast updated trigger state to dashboard h.broadcastTriggerState(id) - writeJSON(w, map[string]interface{}{"status": "ok"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"}) } // getWebhookLog returns the last N webhook firings for a specific trigger. @@ -460,7 +459,7 @@ func (h *VolumeTriggersHandler) getWebhookLog(w http.ResponseWriter, r *http.Req } entries := h.store.GetWebhookLog(id, limit) - writeJSON(w, entries) + writeJSON(w, http.StatusOK, entries) } func (h *VolumeTriggersHandler) getTriggerLog(w http.ResponseWriter, r *http.Request) { @@ -486,7 +485,7 @@ func (h *VolumeTriggersHandler) getTriggerLog(w http.ResponseWriter, r *http.Req }) } - writeJSON(w, response) + writeJSON(w, http.StatusOK, response) } // isValidShape validates that a shape has all required fields. diff --git a/mothership/internal/api/zones.go b/mothership/internal/api/zones.go index ba409c3..28a3da6 100644 --- a/mothership/internal/api/zones.go +++ b/mothership/internal/api/zones.go @@ -2,453 +2,385 @@ package api import ( - "database/sql" "encoding/json" "log" "net/http" - "os" - "path/filepath" "strconv" "sync" "time" "github.com/go-chi/chi" - _ "modernc.org/sqlite" + "github.com/spaxel/mothership/internal/zones" ) -// ZonesHandler manages zones and portals. +// ZonesHandler manages zones and portals via the zones.Manager. +// Changes to zones and portals are automatically reflected in the live 3D view +// within one WebSocket cycle because the dashboard hub polls the manager at 10 Hz. type ZonesHandler struct { - mu sync.RWMutex - db *sql.DB - zones map[string]*Zone - portals map[string]*Portal + mu sync.RWMutex + mgr *zones.Manager } -// Zone represents a spatial region. -type Zone struct { - ID string `json:"id"` - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - Z float64 `json:"z"` - W float64 `json:"w"` - D float64 `json:"d"` - H float64 `json:"h"` - ZoneType string `json:"zone_type"` - Occupancy int `json:"occupancy"` - People []string `json:"people"` - CreatedAt time.Time `json:"created_at"` +// zoneWithOcc extends a zone with current occupancy and people list for API responses. +type zoneWithOcc struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` + MinX float64 `json:"x"` + MinY float64 `json:"y"` + MinZ float64 `json:"z"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + MaxZ float64 `json:"max_z"` + Width float64 `json:"w"` + Depth float64 `json:"d"` + Height float64 `json:"h"` + Enabled bool `json:"enabled"` + ZoneType string `json:"zone_type"` + Occupancy int `json:"occupancy"` + People []string `json:"people"` + CreatedAt time.Time `json:"created_at"` } -// Portal represents a doorway between zones. -type Portal struct { - ID string `json:"id"` - Name string `json:"name"` - ZoneA string `json:"zone_a"` - ZoneB string `json:"zone_b"` - Points [2][2]float64 `json:"points"` // [[x1,y1], [x2,y2]] - Crossings int `json:"crossings"` - CreatedAt time.Time `json:"created_at"` +// portalWithZones extends a portal with resolved zone names for API responses. +type portalWithZones struct { + ID string `json:"id"` + Name string `json:"name"` + ZoneA string `json:"zone_a"` + ZoneB string `json:"zone_b"` + P1X float64 `json:"p1_x"` + P1Y float64 `json:"p1_y"` + P1Z float64 `json:"p1_z"` + P2X float64 `json:"p2_x"` + P2Y float64 `json:"p2_y"` + P2Z float64 `json:"p2_z"` + P3X float64 `json:"p3_x"` + P3Y float64 `json:"p3_y"` + P3Z float64 `json:"p3_z"` + NX float64 `json:"n_x"` + NY float64 `json:"n_y"` + NZ float64 `json:"n_z"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` } -// NewZonesHandler creates a new zones handler. -func NewZonesHandler(dbPath string) (*ZonesHandler, error) { - if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { - return nil, err - } - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(1) - - z := &ZonesHandler{ - db: db, - zones: make(map[string]*Zone), - portals: make(map[string]*Portal), - } - - if err := z.migrate(); err != nil { - db.Close() - return nil, err - } - - if err := z.loadZones(); err != nil { - log.Printf("[WARN] Failed to load zones: %v", err) - } - if err := z.loadPortals(); err != nil { - log.Printf("[WARN] Failed to load portals: %v", err) - } - - return z, nil +// crossingResponse is a single crossing event as returned by the API. +type crossingResponse struct { + ID int64 `json:"id"` + PortalID string `json:"portal_id"` + BlobID int `json:"blob_id"` + Direction string `json:"direction"` + FromZone string `json:"from_zone"` + ToZone string `json:"to_zone"` + Timestamp time.Time `json:"timestamp"` + Person string `json:"person,omitempty"` } -func (z *ZonesHandler) migrate() error { - _, err := z.db.Exec(` - CREATE TABLE IF NOT EXISTS zones ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - x REAL NOT NULL DEFAULT 0, - y REAL NOT NULL DEFAULT 0, - z REAL NOT NULL DEFAULT 0, - w REAL NOT NULL DEFAULT 1, - d REAL NOT NULL DEFAULT 1, - h REAL NOT NULL DEFAULT 1, - zone_type TEXT NOT NULL DEFAULT 'general', - created_at INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS portals ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - zone_a_id TEXT NOT NULL DEFAULT '', - zone_b_id TEXT NOT NULL DEFAULT '', - points_json TEXT NOT NULL DEFAULT '[]', - created_at INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS portal_crossings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - portal_id TEXT NOT NULL, - timestamp_ms INTEGER NOT NULL, - direction TEXT NOT NULL, - blob_id INTEGER, - person TEXT DEFAULT '', - FOREIGN KEY (portal_id) REFERENCES portals(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_portal_crossings_portal ON portal_crossings(portal_id); - CREATE INDEX IF NOT EXISTS idx_portal_crossings_time ON portal_crossings(timestamp_ms); - `) - return err +// NewZonesHandler creates a new zones handler backed by a zones.Manager. +func NewZonesHandler(mgr *zones.Manager) *ZonesHandler { + return &ZonesHandler{mgr: mgr} } -func (z *ZonesHandler) loadZones() error { - rows, err := z.db.Query(`SELECT id, name, x, y, z, w, d, h, zone_type, created_at FROM zones`) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var zone Zone - var createdNS int64 - if err := rows.Scan(&zone.ID, &zone.Name, &zone.X, &zone.Y, &zone.Z, - &zone.W, &zone.D, &zone.H, &zone.ZoneType, &createdNS); err != nil { - continue - } - zone.CreatedAt = time.Unix(0, createdNS) - zone.People = []string{} - z.zones[zone.ID] = &zone - } - return nil -} - -func (z *ZonesHandler) loadPortals() error { - rows, err := z.db.Query(`SELECT id, name, zone_a_id, zone_b_id, points_json, created_at FROM portals`) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var portal Portal - var pointsJSON string - var createdNS int64 - if err := rows.Scan(&portal.ID, &portal.Name, &portal.ZoneA, &portal.ZoneB, - &pointsJSON, &createdNS); err != nil { - continue - } - - if err := json.Unmarshal([]byte(pointsJSON), &portal.Points); err != nil { - log.Printf("[WARN] Failed to parse portal points: %v", err) - continue - } - - portal.CreatedAt = time.Unix(0, createdNS) - z.portals[portal.ID] = &portal - } - return nil -} - -// Close closes the database. -func (z *ZonesHandler) Close() error { - return z.db.Close() +// Close closes the underlying manager. +func (h *ZonesHandler) Close() error { + return h.mgr.Close() } // RegisterRoutes registers zones and portals endpoints. // // Zones: -// GET /api/zones — list all zones -// POST /api/zones — create zone -// PUT /api/zones/{id} — update zone -// DELETE /api/zones/{id} — delete zone -// GET /api/zones/{id}/history — zone occupancy history +// +// GET /api/zones +// +// @Summary List all zones +// @Description Returns all defined spatial zones with current occupancy counts. +// @Tags zones +// @Produce json +// @Success 200 {array} zoneWithOcc "List of zones" +// @Router /api/zones [get] +// +// POST /api/zones +// +// @Summary Create a zone +// @Description Creates a new spatial zone. If no ID is provided, one is auto-generated. +// @Tags zones +// @Accept json +// @Produce json +// @Param zone body zones.Zone true "Zone definition" +// @Success 201 {object} zones.Zone "Created zone" +// @Failure 400 {object} map[string]string "Invalid request body" +// @Router /api/zones [post] +// +// PUT /api/zones/{id} +// +// @Summary Update a zone +// @Description Updates an existing zone's properties. All fields are replaced. +// @Tags zones +// @Accept json +// @Produce json +// @Param id path string true "Zone ID" +// @Param zone body zones.Zone true "Updated zone definition" +// @Success 200 {object} zones.Zone "Updated zone" +// @Failure 404 {object} map[string]string "Zone not found" +// @Router /api/zones/{id} [put] +// +// DELETE /api/zones/{id} +// +// @Summary Delete a zone +// @Description Deletes a zone and removes it from the floor plan. +// @Tags zones +// @Param id path string true "Zone ID" +// @Success 204 "Zone deleted" +// @Router /api/zones/{id} [delete] +// +// GET /api/zones/{id}/history +// +// @Summary Zone occupancy history +// @Description Returns hourly occupancy history for a zone. +// @Tags zones +// @Produce json +// @Param id path string true "Zone ID" +// @Param period query string false "Time period: 24h (default), 7d, 30d" +// @Success 200 {array} historyEntry "Hourly occupancy buckets" +// @Failure 404 {object} map[string]string "Zone not found" +// @Router /api/zones/{id}/history [get] // // Portals: -// GET /api/portals — list all portals -// POST /api/portals — create portal -// PUT /api/portals/{id} — update -// DELETE /api/portals/{id} — delete -// GET /api/portals/{id}/crossings — portal crossing log -func (z *ZonesHandler) RegisterRoutes(r chi.Router) { +// +// GET /api/portals +// +// @Summary List all portals +// @Description Returns all doorway portals with computed normal vectors. +// @Tags portals +// @Produce json +// @Success 200 {array} portalWithZones "List of portals" +// @Router /api/portals [get] +// +// POST /api/portals +// +// @Summary Create a portal +// @Description Creates a new doorway portal between two zones. Normal vector is auto-computed from the three defining points. +// @Tags portals +// @Accept json +// @Produce json +// @Param portal body zones.Portal true "Portal definition" +// @Success 201 {object} zones.Portal "Created portal" +// @Failure 400 {object} map[string]string "Invalid request body" +// @Router /api/portals [post] +// +// PUT /api/portals/{id} +// +// @Summary Update a portal +// @Description Updates an existing portal's properties. Normal vector is recomputed if points change. +// @Tags portals +// @Accept json +// @Produce json +// @Param id path string true "Portal ID" +// @Param portal body zones.Portal true "Updated portal definition" +// @Success 200 {object} zones.Portal "Updated portal" +// @Failure 404 {object} map[string]string "Portal not found" +// @Router /api/portals/{id} [put] +// +// DELETE /api/portals/{id} +// +// @Summary Delete a portal +// @Description Deletes a portal and its crossing history. +// @Tags portals +// @Param id path string true "Portal ID" +// @Success 204 "Portal deleted" +// @Router /api/portals/{id} [delete] +// +// GET /api/portals/{id}/crossings +// +// @Summary Portal crossing log +// @Description Returns recent directional crossings for a portal. +// @Tags portals +// @Produce json +// @Param id path string true "Portal ID" +// @Param limit query int false "Max crossings to return (default: 50)" +// @Success 200 {array} crossingResponse "Crossing events" +// @Failure 404 {object} map[string]string "Portal not found" +// @Router /api/portals/{id}/crossings [get] +func (h *ZonesHandler) RegisterRoutes(r chi.Router) { // Zones - r.Get("/api/zones", z.listZones) - r.Post("/api/zones", z.createZone) - r.Put("/api/zones/{id}", z.updateZone) - r.Delete("/api/zones/{id}", z.deleteZone) - r.Get("/api/zones/{id}/history", z.getZoneHistory) + r.Get("/api/zones", h.listZones) + r.Post("/api/zones", h.createZone) + r.Put("/api/zones/{id}", h.updateZone) + r.Delete("/api/zones/{id}", h.deleteZone) + r.Get("/api/zones/{id}/history", h.getZoneHistory) // Portals - r.Get("/api/portals", z.listPortals) - r.Post("/api/portals", z.createPortal) - r.Put("/api/portals/{id}", z.updatePortal) - r.Delete("/api/portals/{id}", z.deletePortal) - r.Get("/api/portals/{id}/crossings", z.getPortalCrossings) + r.Get("/api/portals", h.listPortals) + r.Post("/api/portals", h.createPortal) + r.Put("/api/portals/{id}", h.updatePortal) + r.Delete("/api/portals/{id}", h.deletePortal) + r.Get("/api/portals/{id}/crossings", h.getPortalCrossings) +} + +// toZoneResponse converts a zones.Zone to the API response format with occupancy. +func (h *ZonesHandler) toZoneResponse(z *zones.Zone) zoneWithOcc { + occ := h.mgr.GetZoneOccupancy(z.ID) + count := 0 + if occ != nil { + count = occ.Count + } + return zoneWithOcc{ + ID: z.ID, + Name: z.Name, + Color: z.Color, + MinX: z.MinX, + MinY: z.MinY, + MinZ: z.MinZ, + MaxX: z.MaxX, + MaxY: z.MaxY, + MaxZ: z.MaxZ, + Width: z.MaxX - z.MinX, + Depth: z.MaxY - z.MinY, + Height: z.MaxZ - z.MinZ, + Enabled: z.Enabled, + ZoneType: string(z.ZoneType), + Occupancy: count, + People: []string{}, + CreatedAt: z.CreatedAt, + } +} + +// toPortalResponse converts a zones.Portal to the API response format. +func toPortalResponse(p *zones.Portal) portalWithZones { + return portalWithZones{ + ID: p.ID, + Name: p.Name, + ZoneA: p.ZoneAID, + ZoneB: p.ZoneBID, + P1X: p.P1X, + P1Y: p.P1Y, + P1Z: p.P1Z, + P2X: p.P2X, + P2Y: p.P2Y, + P2Z: p.P2Z, + P3X: p.P3X, + P3Y: p.P3Y, + P3Z: p.P3Z, + NX: p.NX, + NY: p.NY, + NZ: p.NZ, + Width: p.Width, + Height: p.Height, + Enabled: p.Enabled, + CreatedAt: p.CreatedAt, + } } // ── Zones ─────────────────────────────────────────────────────────────────────── -func (z *ZonesHandler) listZones(w http.ResponseWriter, r *http.Request) { - z.mu.RLock() - zones := make([]*Zone, 0, len(z.zones)) - for _, zone := range z.zones { - zones = append(zones, zone) +// listZones returns all zones with current occupancy. +func (h *ZonesHandler) listZones(w http.ResponseWriter, r *http.Request) { + allZones := h.mgr.GetAllZones() + h.mu.RLock() + defer h.mu.RUnlock() + + response := make([]zoneWithOcc, 0, len(allZones)) + for _, z := range allZones { + response = append(response, h.toZoneResponse(z)) } - z.mu.RUnlock() - writeJSON(w, zones) + writeJSON(w, http.StatusOK, response) } -type createZoneRequest struct { - ID string `json:"id"` - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - Z float64 `json:"z"` - W float64 `json:"w"` - D float64 `json:"d"` - H float64 `json:"h"` - ZoneType string `json:"zone_type,omitempty"` -} - -func (z *ZonesHandler) createZone(w http.ResponseWriter, r *http.Request) { - var req createZoneRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { +// createZone creates a new zone. Auto-generates an ID if none is provided. +func (h *ZonesHandler) createZone(w http.ResponseWriter, r *http.Request) { + var zone zones.Zone + if err := json.NewDecoder(r.Body).Decode(&zone); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } - if req.ID == "" { - http.Error(w, "id is required", http.StatusBadRequest) - return + if zone.ID == "" { + zone.ID = "zone_" + time.Now().Format("20060102-150405") } - if req.Name == "" { + + if zone.Name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } - if req.W <= 0 || req.D <= 0 || req.H <= 0 { - http.Error(w, "dimensions must be positive", http.StatusBadRequest) + + // Set defaults for color + if zone.Color == "" { + zone.Color = "#4fc3f7" + } + + if err := h.mgr.CreateZone(&zone); err != nil { + http.Error(w, "failed to create zone: "+err.Error(), http.StatusInternalServerError) return } - zoneType := req.ZoneType - if zoneType == "" { - zoneType = "general" - } - - now := time.Now().UnixNano() - _, err := z.db.Exec(` - INSERT INTO zones (id, name, x, y, z, w, d, h, zone_type, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, req.ID, req.Name, req.X, req.Y, req.Z, req.W, req.D, req.H, zoneType, now) - if err != nil { - http.Error(w, "failed to create zone", http.StatusInternalServerError) - return - } - - z.mu.Lock() - z.zones[req.ID] = &Zone{ - ID: req.ID, - Name: req.Name, - X: req.X, - Y: req.Y, - Z: req.Z, - W: req.W, - D: req.D, - H: req.H, - ZoneType: zoneType, - CreatedAt: time.Unix(0, now), - People: []string{}, - } - z.mu.Unlock() + h.mu.RLock() + resp := h.toZoneResponse(h.mgr.GetZone(zone.ID)) + h.mu.RUnlock() + log.Printf("[INFO] Zone created: %s (%s)", zone.ID, zone.Name) w.WriteHeader(http.StatusCreated) - writeJSON(w, z.zones[req.ID]) + writeJSON(w, http.StatusCreated, resp) } -type updateZoneRequest struct { - Name *string `json:"name,omitempty"` - X *float64 `json:"x,omitempty"` - Y *float64 `json:"y,omitempty"` - Z *float64 `json:"z,omitempty"` - W *float64 `json:"w,omitempty"` - D *float64 `json:"d,omitempty"` - H *float64 `json:"h,omitempty"` - ZoneType *string `json:"zone_type,omitempty"` -} - -func (z *ZonesHandler) updateZone(w http.ResponseWriter, r *http.Request) { +// updateZone updates an existing zone. +func (h *ZonesHandler) updateZone(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - z.mu.RLock() - zone, exists := z.zones[id] - z.mu.RUnlock() - - if !exists { + if h.mgr.GetZone(id) == nil { http.Error(w, "zone not found", http.StatusNotFound) return } - var req updateZoneRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var zone zones.Zone + if err := json.NewDecoder(r.Body).Decode(&zone); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } - updates := []string{} - args := []interface{}{} + // Preserve the ID from the URL param + zone.ID = id - if req.Name != nil { - updates = append(updates, "name = ?") - args = append(args, *req.Name) - } - if req.X != nil { - updates = append(updates, "x = ?") - args = append(args, *req.X) - } - if req.Y != nil { - updates = append(updates, "y = ?") - args = append(args, *req.Y) - } - if req.Z != nil { - updates = append(updates, "z = ?") - args = append(args, *req.Z) - } - if req.W != nil { - if *req.W <= 0 { - http.Error(w, "width must be positive", http.StatusBadRequest) - return - } - updates = append(updates, "w = ?") - args = append(args, *req.W) - } - if req.D != nil { - if *req.D <= 0 { - http.Error(w, "depth must be positive", http.StatusBadRequest) - return - } - updates = append(updates, "d = ?") - args = append(args, *req.D) - } - if req.H != nil { - if *req.H <= 0 { - http.Error(w, "height must be positive", http.StatusBadRequest) - return - } - updates = append(updates, "h = ?") - args = append(args, *req.H) - } - if req.ZoneType != nil { - updates = append(updates, "zone_type = ?") - args = append(args, *req.ZoneType) - } - - if len(updates) == 0 { - writeJSON(w, zone) + if err := h.mgr.UpdateZone(&zone); err != nil { + http.Error(w, "failed to update zone: "+err.Error(), http.StatusInternalServerError) return } - args = append(args, id) - query := "UPDATE zones SET " + joinComma(updates) + " WHERE id = ?" + h.mu.RLock() + resp := h.toZoneResponse(h.mgr.GetZone(zone.ID)) + h.mu.RUnlock() - _, err := z.db.Exec(query, args...) - if err != nil { - http.Error(w, "failed to update zone", http.StatusInternalServerError) - return - } - - // Update in-memory copy - z.mu.Lock() - if req.Name != nil { - zone.Name = *req.Name - } - if req.X != nil { - zone.X = *req.X - } - if req.Y != nil { - zone.Y = *req.Y - } - if req.Z != nil { - zone.Z = *req.Z - } - if req.W != nil { - zone.W = *req.W - } - if req.D != nil { - zone.D = *req.D - } - if req.H != nil { - zone.H = *req.H - } - if req.ZoneType != nil { - zone.ZoneType = *req.ZoneType - } - z.mu.Unlock() - - writeJSON(w, zone) + log.Printf("[INFO] Zone updated: %s (%s)", zone.ID, zone.Name) + writeJSON(w, http.StatusOK, resp) } -func (z *ZonesHandler) deleteZone(w http.ResponseWriter, r *http.Request) { +// deleteZone removes a zone by ID. +func (h *ZonesHandler) deleteZone(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - z.mu.RLock() - _, exists := z.zones[id] - z.mu.RUnlock() - - if !exists { - http.Error(w, "zone not found", http.StatusNotFound) + if err := h.mgr.DeleteZone(id); err != nil { + http.Error(w, "failed to delete zone: "+err.Error(), http.StatusInternalServerError) return } - _, err := z.db.Exec(`DELETE FROM zones WHERE id = ?`, id) - if err != nil { - http.Error(w, "failed to delete zone", http.StatusInternalServerError) - return - } - - z.mu.Lock() - delete(z.zones, id) - z.mu.Unlock() - + log.Printf("[INFO] Zone deleted: %s", id) w.WriteHeader(http.StatusNoContent) } +// historyEntry represents an hourly occupancy bucket for the zone history API. type historyEntry struct { Timestamp int64 `json:"timestamp"` - Count int `json:"count"` - People []string `json:"people"` + Count int `json:"count"` + People []string `json:"people"` } -func (z *ZonesHandler) getZoneHistory(w http.ResponseWriter, r *http.Request) { +// getZoneHistory returns hourly occupancy history for a zone. +func (h *ZonesHandler) getZoneHistory(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - z.mu.RLock() - _, exists := z.zones[id] - z.mu.RUnlock() - - if !exists { + if h.mgr.GetZone(id) == nil { http.Error(w, "zone not found", http.StatusNotFound) return } @@ -465,209 +397,119 @@ func (z *ZonesHandler) getZoneHistory(w http.ResponseWriter, r *http.Request) { history := make([]historyEntry, limit) now := time.Now() for i := range history { - h := historyEntry{ + history[i] = historyEntry{ Timestamp: now.Add(-time.Duration(i) * time.Hour).UnixNano() / 1e6, Count: 0, People: []string{}, } - history[i] = h } - writeJSON(w, history) + writeJSON(w, http.StatusOK, history) } // ── Portals ───────────────────────────────────────────────────────────────────── -func (z *ZonesHandler) listPortals(w http.ResponseWriter, r *http.Request) { - z.mu.RLock() - portals := make([]*Portal, 0, len(z.portals)) - for _, portal := range z.portals { - portals = append(portals, portal) +// listPortals returns all portals. +func (h *ZonesHandler) listPortals(w http.ResponseWriter, r *http.Request) { + allPortals := h.mgr.GetAllPortals() + + response := make([]portalWithZones, 0, len(allPortals)) + for _, p := range allPortals { + response = append(response, toPortalResponse(p)) } - z.mu.RUnlock() - writeJSON(w, portals) + writeJSON(w, http.StatusOK, response) } -type createPortalRequest struct { - ID string `json:"id"` - Name string `json:"name"` - ZoneA string `json:"zone_a"` - ZoneB string `json:"zone_b"` - Points [2][2]float64 `json:"points"` -} - -func (z *ZonesHandler) createPortal(w http.ResponseWriter, r *http.Request) { - var req createPortalRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { +// createPortal creates a new portal. Auto-generates an ID if none is provided. +func (h *ZonesHandler) createPortal(w http.ResponseWriter, r *http.Request) { + var portal zones.Portal + if err := json.NewDecoder(r.Body).Decode(&portal); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } - if req.ID == "" { - http.Error(w, "id is required", http.StatusBadRequest) + if portal.ID == "" { + portal.ID = "portal_" + time.Now().Format("20060102-150405") + } + + // Validate zone references + if portal.ZoneAID != "" && h.mgr.GetZone(portal.ZoneAID) == nil { + http.Error(w, "zone_a not found", http.StatusBadRequest) return } - if req.ZoneA == "" || req.ZoneB == "" { - http.Error(w, "zone_a and zone_b are required", http.StatusBadRequest) + if portal.ZoneBID != "" && h.mgr.GetZone(portal.ZoneBID) == nil { + http.Error(w, "zone_b not found", http.StatusBadRequest) return } - z.mu.RLock() - _, zoneAExists := z.zones[req.ZoneA] - _, zoneBExists := z.zones[req.ZoneB] - z.mu.RUnlock() - - if !zoneAExists || !zoneBExists { - http.Error(w, "one or both zones not found", http.StatusBadRequest) + if err := h.mgr.CreatePortal(&portal); err != nil { + http.Error(w, "failed to create portal: "+err.Error(), http.StatusInternalServerError) return } - pointsJSON, _ := json.Marshal(req.Points) - now := time.Now().UnixNano() - _, err := z.db.Exec(` - INSERT INTO portals (id, name, zone_a_id, zone_b_id, points_json, created_at) - VALUES (?, ?, ?, ?, ?, ?) - `, req.ID, req.Name, req.ZoneA, req.ZoneB, string(pointsJSON), now) - if err != nil { - http.Error(w, "failed to create portal", http.StatusInternalServerError) - return - } - - z.mu.Lock() - z.portals[req.ID] = &Portal{ - ID: req.ID, - Name: req.Name, - ZoneA: req.ZoneA, - ZoneB: req.ZoneB, - Points: req.Points, - CreatedAt: time.Unix(0, now), - } - z.mu.Unlock() - + resp := toPortalResponse(h.mgr.GetPortal(portal.ID)) + log.Printf("[INFO] Portal created: %s (%s)", portal.ID, portal.Name) w.WriteHeader(http.StatusCreated) - writeJSON(w, z.portals[req.ID]) + writeJSON(w, http.StatusCreated, resp) } -type updatePortalRequest struct { - Name *string `json:"name,omitempty"` - ZoneA *string `json:"zone_a,omitempty"` - ZoneB *string `json:"zone_b,omitempty"` - Points *[2][2]float64 `json:"points,omitempty"` -} - -func (z *ZonesHandler) updatePortal(w http.ResponseWriter, r *http.Request) { +// updatePortal updates an existing portal. +func (h *ZonesHandler) updatePortal(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - z.mu.RLock() - portal, exists := z.portals[id] - z.mu.RUnlock() - - if !exists { + if h.mgr.GetPortal(id) == nil { http.Error(w, "portal not found", http.StatusNotFound) return } - var req updatePortalRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var portal zones.Portal + if err := json.NewDecoder(r.Body).Decode(&portal); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } - updates := []string{} - args := []interface{}{} + // Preserve the ID from the URL param + portal.ID = id - if req.Name != nil { - updates = append(updates, "name = ?") - args = append(args, *req.Name) + // Validate zone references if changed + if portal.ZoneAID != "" && h.mgr.GetZone(portal.ZoneAID) == nil { + http.Error(w, "zone_a not found", http.StatusBadRequest) + return } - if req.ZoneA != nil { - updates = append(updates, "zone_a_id = ?") - args = append(args, *req.ZoneA) - } - if req.ZoneB != nil { - updates = append(updates, "zone_b_id = ?") - args = append(args, *req.ZoneB) - } - if req.Points != nil { - pointsJSON, _ := json.Marshal(req.Points) - updates = append(updates, "points_json = ?") - args = append(args, string(pointsJSON)) - } - - if len(updates) == 0 { - writeJSON(w, portal) + if portal.ZoneBID != "" && h.mgr.GetZone(portal.ZoneBID) == nil { + http.Error(w, "zone_b not found", http.StatusBadRequest) return } - args = append(args, id) - query := "UPDATE portals SET " + joinComma(updates) + " WHERE id = ?" - - _, err := z.db.Exec(query, args...) - if err != nil { - http.Error(w, "failed to update portal", http.StatusInternalServerError) + if err := h.mgr.UpdatePortal(&portal); err != nil { + http.Error(w, "failed to update portal: "+err.Error(), http.StatusInternalServerError) return } - // Update in-memory copy - z.mu.Lock() - if req.Name != nil { - portal.Name = *req.Name - } - if req.ZoneA != nil { - portal.ZoneA = *req.ZoneA - } - if req.ZoneB != nil { - portal.ZoneB = *req.ZoneB - } - if req.Points != nil { - portal.Points = *req.Points - } - z.mu.Unlock() - - writeJSON(w, portal) + resp := toPortalResponse(h.mgr.GetPortal(portal.ID)) + log.Printf("[INFO] Portal updated: %s (%s)", portal.ID, portal.Name) + writeJSON(w, http.StatusOK, resp) } -func (z *ZonesHandler) deletePortal(w http.ResponseWriter, r *http.Request) { +// deletePortal removes a portal by ID. +func (h *ZonesHandler) deletePortal(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - z.mu.RLock() - _, exists := z.portals[id] - z.mu.RUnlock() - - if !exists { - http.Error(w, "portal not found", http.StatusNotFound) + if err := h.mgr.DeletePortal(id); err != nil { + http.Error(w, "failed to delete portal: "+err.Error(), http.StatusInternalServerError) return } - _, err := z.db.Exec(`DELETE FROM portals WHERE id = ?`, id) - if err != nil { - http.Error(w, "failed to delete portal", http.StatusInternalServerError) - return - } - - z.mu.Lock() - delete(z.portals, id) - z.mu.Unlock() - + log.Printf("[INFO] Portal deleted: %s", id) w.WriteHeader(http.StatusNoContent) } -type crossingEntry struct { - ID string `json:"id"` - Timestamp int64 `json:"timestamp_ms"` - Direction string `json:"direction"` - Person string `json:"person,omitempty"` -} - -func (z *ZonesHandler) getPortalCrossings(w http.ResponseWriter, r *http.Request) { +// getPortalCrossings returns recent crossing events for a portal. +func (h *ZonesHandler) getPortalCrossings(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - z.mu.RLock() - _, exists := z.portals[id] - z.mu.RUnlock() - - if !exists { + if h.mgr.GetPortal(id) == nil { http.Error(w, "portal not found", http.StatusNotFound) return } @@ -675,56 +517,11 @@ func (z *ZonesHandler) getPortalCrossings(w http.ResponseWriter, r *http.Request limitStr := r.URL.Query().Get("limit") limit := 50 if limitStr != "" { - if n, err := strconv.Atoi(limitStr); err == nil && n > 0 { + if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 200 { limit = n } } - rows, err := z.db.Query(` - SELECT id, timestamp_ms, direction, person - FROM portal_crossings - WHERE portal_id = ? - ORDER BY timestamp_ms DESC - LIMIT ? - `, id, limit) - if err != nil { - http.Error(w, "failed to query crossings", http.StatusInternalServerError) - return - } - defer rows.Close() - - var crossings []crossingEntry - for rows.Next() { - var c crossingEntry - if err := rows.Scan(&c.ID, &c.Timestamp, &c.Direction, &c.Person); err != nil { - continue - } - crossings = append(crossings, c) - } - - writeJSON(w, crossings) -} - -// ── Occupancy updates (called by fusion engine) ─────────────────────────────────── - -// UpdateOccupancy updates the current occupancy for all zones. -func (z *ZonesHandler) UpdateOccupancy(occupancy map[string]int) { - z.mu.Lock() - defer z.mu.Unlock() - - for id, zone := range z.zones { - count := occupancy[id] - zone.Occupancy = count - } -} - -func joinComma(parts []string) string { - result := "" - for i, p := range parts { - if i > 0 { - result += ", " - } - result += p - } - return result + events := h.mgr.GetRecentCrossings(limit) + writeJSON(w, http.StatusOK, events) } diff --git a/mothership/internal/ble/handler.go b/mothership/internal/ble/handler.go index 2b354ca..70765c0 100644 --- a/mothership/internal/ble/handler.go +++ b/mothership/internal/ble/handler.go @@ -23,20 +23,178 @@ func NewHandler(registry *Registry) *Handler { // RegisterRoutes mounts BLE endpoints on r. // -// GET /api/ble/devices — list all BLE devices -// GET /api/ble/devices/{mac} — get single device -// GET /api/ble/devices/{mac}/aliases — get alias history for device -// PUT /api/ble/devices/{mac} — update device (label, device_type, person_id) -// DELETE /api/ble/devices/{mac} — archive device (soft delete) -// POST /api/ble/devices/preregister — manually register a device by MAC address -// GET /api/ble/duplicates — list possible duplicate devices -// POST /api/ble/merge — merge two devices (MAC rotation) -// POST /api/ble/split — split alias from canonical device -// GET /api/people — list all people with device counts -// POST /api/people — create new person -// GET /api/people/{id} — get single person with devices -// PUT /api/people/{id} — update person name/color -// DELETE /api/people/{id} — delete person +// GET /api/ble/devices +// +// @Summary List BLE devices +// @Description Returns a list of all BLE devices seen by the system. Devices can be filtered by registration status (registered/discovered), time window (hours parameter), and archival status. +// @Tags ble +// @Produce json +// @Param registered query bool false "Filter to only devices assigned to a person" +// @Param discovered query bool false "Filter to only unassigned devices" +// @Param archived query bool false "Include archived (soft-deleted) devices" +// @Param hours query int false "Time window in hours (default: 24)" +// @Success 200 {object} map[string]interface{} "List of devices with privacy_notice" +// @Router /api/ble/devices [get] +// +// GET /api/ble/devices/{mac} +// +// @Summary Get BLE device +// @Description Returns detailed information about a single BLE device by its MAC address. +// @Tags ble +// @Produce json +// @Param mac path string true "BLE device MAC address (uppercase colon-separated hex)" +// @Success 200 {object} DeviceRecord "Device details" +// @Failure 404 {object} map[string]string "Device not found" +// @Router /api/ble/devices/{mac} [get] +// +// PUT /api/ble/devices/{mac} +// +// @Summary Update BLE device +// @Description Updates a BLE device's properties. Used to set a human-readable label and/or assign it to a person for identity tracking. +// @Tags ble +// @Accept json +// @Produce json +// @Param mac path string true "BLE device MAC address" +// @Param request body updateDeviceRequest true "Device update fields" +// @Success 200 {object} DeviceRecord "Updated device" +// @Failure 400 {object} map[string]string "Invalid request or person not found" +// @Failure 404 {object} map[string]string "Device not found" +// @Router /api/ble/devices/{mac} [put] +// +// DELETE /api/ble/devices/{mac} +// +// @Summary Archive BLE device +// @Description Soft-deletes a BLE device by marking it as archived. +// @Tags ble +// @Param mac path string true "BLE device MAC address" +// @Success 204 "No content" +// @Failure 404 {object} map[string]string "Device not found" +// @Router /api/ble/devices/{mac} [delete] +// +// GET /api/ble/devices/{mac}/history +// +// @Summary Get device sighting history +// @Description Returns the sighting history for a specific BLE device including RSSI observations from nodes. +// @Tags ble +// @Produce json +// @Param mac path string true "BLE device MAC address" +// @Param limit query int false "Maximum history entries (default: 100, max: 1000)" +// @Success 200 {object} map[string]interface{} "Device sighting history" +// @Failure 404 {object} map[string]string "Device not found" +// @Router /api/ble/devices/{mac}/history [get] +// +// GET /api/ble/devices/{mac}/aliases +// +// @Summary Get device aliases +// @Description Returns the alias history for a device, including all rotated addresses merged to this canonical device. +// @Tags ble +// @Produce json +// @Param mac path string true "BLE device MAC address" +// @Success 200 {object} map[string]interface{} "Device aliases" +// @Router /api/ble/devices/{mac}/aliases [get] +// +// POST /api/ble/devices/preregister +// +// @Summary Preregister BLE device +// @Description Manually creates a device entry for a known MAC address. Useful for pre-registering tracker tags. +// @Tags ble +// @Accept json +// @Produce json +// @Param request body preregisterDeviceRequest true "Device MAC and optional label" +// @Success 201 {object} DeviceRecord "Created device" +// @Failure 400 {object} map[string]string "Invalid request" +// @Router /api/ble/devices/preregister [post] +// +// GET /api/ble/duplicates +// +// @Summary List possible duplicate devices +// @Description Returns device pairs that may be the same device with rotated MAC addresses. +// @Tags ble +// @Produce json +// @Success 200 {object} map[string]interface{} "List of possible duplicates" +// @Router /api/ble/duplicates [get] +// +// POST /api/ble/merge +// +// @Summary Merge BLE devices +// @Description Merges two devices, keeping mac1 and removing mac2. Used for MAC rotation consolidation. +// @Tags ble +// @Accept json +// @Produce json +// @Param request body mergeDevicesRequest true "Two MAC addresses to merge" +// @Success 200 {object} map[string]interface{} "Merged device" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 404 {object} map[string]string "Device not found" +// @Router /api/ble/merge [post] +// +// POST /api/ble/split +// +// @Summary Split device alias +// @Description Splits an alias from its canonical device, creating a separate device entry. +// @Tags ble +// @Accept json +// @Produce json +// @Param request body splitDeviceRequest true "Canonical and alias addresses" +// @Success 200 {object} map[string]interface{} "Split devices" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 404 {object} map[string]string "Device not found" +// @Router /api/ble/split [post] +// +// GET /api/people +// +// @Summary List people +// @Description Returns all people with their associated device counts and devices. +// @Tags people +// @Produce json +// @Success 200 {array} map[string]interface{} "List of people with devices" +// @Router /api/people [get] +// +// POST /api/people +// +// @Summary Create person +// @Description Creates a new person for BLE device assignment. +// @Tags people +// @Accept json +// @Produce json +// @Param request body createPersonRequest true "Person name and optional color" +// @Success 201 {object} Person "Created person" +// @Failure 400 {object} map[string]string "Invalid request" +// @Router /api/people [post] +// +// GET /api/people/{id} +// +// @Summary Get person +// @Description Returns a single person with their associated devices. +// @Tags people +// @Produce json +// @Param id path string true "Person UUID" +// @Success 200 {object} map[string]interface{} "Person with devices" +// @Failure 404 {object} map[string]string "Person not found" +// @Router /api/people/{id} [get] +// +// PUT /api/people/{id} +// +// @Summary Update person +// @Description Updates a person's name and/or color. +// @Tags people +// @Accept json +// @Produce json +// @Param id path string true "Person UUID" +// @Param request body updatePersonRequest true "Person update fields" +// @Success 200 {object} Person "Updated person" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 404 {object} map[string]string "Person not found" +// @Router /api/people/{id} [put] +// +// DELETE /api/people/{id} +// +// @Summary Delete person +// @Description Deletes a person and unassigns all their devices. +// @Tags people +// @Param id path string true "Person UUID" +// @Success 204 "No content" +// @Failure 404 {object} map[string]string "Person not found" +// @Router /api/people/{id} [delete] func (h *Handler) RegisterRoutes(r chi.Router) { // Device endpoints r.Get("/api/ble/devices", h.listDevices)