diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 9f4e5bb..f91ab32 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -77,14 +77,14 @@ {"id":"spaxel-jk0","title":"Simple mode with progressive disclosure","description":"## Background\n\nThe 3D expert interface is powerful but overwhelming for casual household members who just want to know \"is anyone home?\" or \"is the baby still asleep?\". Simple mode is a card-based, mobile-first UI that surfaces the most important information without any 3D scene. It should be usable by anyone who can read a smartphone screen, including elderly family members and non-technical partners who did not install the system.\n\nProgressive disclosure means: start with the simplest possible view, and let users reach more complexity when they want it — without being exposed to complexity they don't need.\n\n## Auto-Detection of Simple Mode\n\nSimple mode is automatically selected as the default view based on:\n1. Screen width < 768px (phones, small tablets in portrait)\n2. User-agent contains \"Mobile\" (additional phone detection signal)\n3. User has previously selected simple mode (localStorage \"spaxel_mode\" = \"simple\")\n\nExpert mode is the default for desktop browsers. A user can override the auto-detection and save their preference.\n\n## Room Occupancy Cards\n\nThe main view is a card grid (CSS Grid, 1 column on phones, 2 columns on small tablets). One card per defined zone.\n\nEach occupancy card shows:\n- Zone name (large, readable typography, min 20px font size)\n- Zone colour as a left border accent\n- Occupant count: large number\n- Named occupants: first names in a row (e.g. \"Alice, Bob\"). Anonymous tracks: \"1 person\"\n- Status icon: person silhouette if occupied, empty room icon if vacant\n- \"Last activity\" time: \"3 minutes ago\" (time since last zone transition event in this zone)\n\nAuto-update: cards update in real-time via WebSocket. When occupancy changes, the card animates briefly (a gentle pulse or highlight) to draw attention to the change.\n\nEmpty state (no zones defined): show a \"Get started\" prompt: \"Set up your rooms to see who's home. [Go to setup]\"\n\n## Activity Feed\n\nScrollable list below the zone cards (or as a separate tab). Shows the last 20 person-relevant events, filtered to exclude system noise (no node_connected, no weight_update events — only ZoneTransition, FallDetected, AnomalyDetected).\n\nEvent items: icon + one-line description + timestamp. Examples:\n- \"Alice walked into the Kitchen — 2 minutes ago\"\n- \"Bob left the house — 14 minutes ago\"\n- \"No one home since 8:32am\"\n- \"Possible fall: Alice in Hallway — 3 minutes ago [View] [Acknowledge]\"\n\nPlain English descriptions — no jargon. \"Possible fall\" not \"FallDetected event in zone_hallway_01.\"\n\nTap any event: shows a brief detail popup (not a full detail view). For zone transitions: \"Alice (via Living Room door) at 14:23\". For falls: the fall alert card with acknowledge button.\n\n## Alert Banner\n\nWhen an active unacknowledged alert exists (fall, anomaly, node offline), show a full-width banner at the top of the simple mode view:\n- Fall alert: red background, \"Possible fall — Alice in Hallway. [Acknowledge]\"\n- Anomaly (away mode): orange background, \"Movement detected while away. [View details]\"\n- Node offline: yellow background, \"Node Living Room went offline. [Help]\"\n\nAlerts are ordered by severity (fall > anomaly > node offline) if multiple are active. The [Help] button links to the troubleshooting flow (spaxel-r0l Phase 4 bead).\n\n## Sleep Summary Card\n\nA morning-only card, shown only between 6am and 11am on the day after a sleep session:\n- Position: above zone cards (highest priority in morning)\n- Content: \"Alice slept 7h 23m last night. 2 brief wake-ups. [View details]\"\n- If multiple people: stacked cards or a compact multi-person summary\n- Dismiss button: card hidden for today (localStorage flag \"spaxel_sleep_summary_{date}_shown\")\n- \"View details\" navigates to the Sleep panel in expert mode\n\n## Navigation\n\nBottom navigation bar (mobile-standard pattern): five tabs with icons + labels:\n1. Home (house icon) — occupancy cards (default tab)\n2. Activity (clock icon) — activity feed\n3. (empty centre spot for potential quick-action FAB in future)\n4. Alerts (bell icon) — active alerts and history. Badge with alert count.\n5. Settings (gear icon) — simplified settings: notification channel, person names, mode toggle\n\nThe Settings tab in simple mode shows only: display name for each person (from BLE registry), notification channel status (green = configured, grey = not set), and \"Switch to expert mode\" at the bottom.\n\n## Expert Mode Toggle\n\nA clearly labelled button: \"Expert Mode\" in the settings tab and as a persistent bottom-nav item. Tapping it:\n- If a PIN is configured (expert mode lock, settable in expert mode settings): shows PIN entry pad\n- If no PIN: immediately switches to expert mode\n- Saves preference to localStorage\n\nThe mode toggle is intentionally in settings/bottom-nav rather than prominently on the home screen — to reduce accidental switches for non-technical users.\n\n## Night Mode (OLED Dark)\n\nAuto-active during configured quiet hours (e.g. 10pm-7am). Uses CSS media query prefers-color-scheme: dark plus a manual override. OLED optimised: true black background (#000000), not just dark grey. This saves battery on OLED screens and reduces light disruption in bedrooms.\n\nAll card backgrounds in night mode: #0a0a0a (near-black). Text: #ffffff. Zone colour accents remain colourful.\n\n## Design System\n\nSimple mode uses a separate CSS file (dashboard/css/simple.css) that does NOT import from the expert mode styles. No Three.js canvas, no OrbitControls, no shader materials. Pure HTML + CSS + vanilla JS.\n\nFont size hierarchy: 14px minimum for secondary text, 16px for primary, 24px+ for zone names and counts. All interactive targets: minimum 44px height for WCAG AA touch target compliance.\n\n## Files to Create or Modify\n\n- dashboard/simple.html: simple mode HTML shell with bottom nav\n- dashboard/js/simple.js: card rendering, WebSocket updates, event feed\n- dashboard/js/simplemode.js: mode detection, localStorage mode preference\n- dashboard/css/simple.css: simple mode styles, night mode\n- mothership/internal/dashboard/routes.go: ensure /simple route is served\n\n## Tests\n\n- Test that room occupancy cards correctly reflect current zone state from WebSocket messages\n- Test activity feed filtering: inject a node_connected event and a zone_transition event; only the zone_transition should appear in simple mode feed\n- Test alert banner appears and dismisses correctly on acknowledge\n- Test mode toggle correctly switches between simple and expert mode routes\n- Test sleep summary card appears only between 6am-11am on the morning after a session\n- Test that occupancy card updates show the animated pulse on change\n- Test night mode activates based on quiet hours configuration\n\n## Acceptance Criteria\n\n- Simple mode loads correctly on a 320px wide screen (iPhone SE) without horizontal scrolling\n- Occupancy cards update in real-time as people move between zones\n- Activity feed shows only person-relevant events in plain English\n- Alert banner appears prominently and dismisses on acknowledge\n- Sleep summary card shown between 6am-11am after sleep session, dismissed when tapped\n- Mode toggle to expert mode works and saves preference\n- Night mode activates during configured quiet hours\n- All tap targets are at least 44px in height\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T01:59:32.690434334Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.851752276Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-jk0","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.851714754Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-jkw","title":"Add Identify context menu to 3D view","description":"Add 'Identify (blink LED)' option to the right-click context menu in the 3D view that POSTs to /api/nodes/{mac}/identify.\n\n**Acceptance:**\n- 3D view right-click menu has 'Identify (blink LED)' option","status":"closed","priority":2,"issue_type":"task","assignee":"golf","created_at":"2026-04-09T11:11:50.047388206Z","created_by":"coding","updated_at":"2026-04-09T11:32:19.559003892Z","closed_at":"2026-04-09T11:32:19.558903935Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-h58"]} {"id":"spaxel-jy4","title":"Crowd flow visualisation","description":"## Background\n\nOver days and weeks, the movement patterns of household members accumulate into meaningful flows: the main corridor between bedroom and bathroom, the typical path from the front door to the kitchen, habitual dwell spots (the favourite chair, the home office desk, the kitchen counter). Visualising these as directional flow maps and dwell hotspot heatmaps provides useful insight into how the space is actually used — and can inform furniture placement, automation placement, and even architectural decisions. It's also a compelling visual that demonstrates the system's accumulated knowledge.\n\n## FlowAccumulator\n\nNew package: mothership/internal/analytics/flow.go\n\nFlowAccumulator subscribes to TrackManager updates (10 Hz) and accumulates trajectory data.\n\nTrajectory sampling: for each track update, if the track has moved > 0.2m since the last recorded waypoint (for that track), record the movement:\n- from_xyz: last waypoint position\n- to_xyz: current position\n- speed: metres per second at this step\n- person_id: if identity is known\n- timestamp\n\nThis 0.2m threshold prevents accumulating thousands of micro-samples for stationary people.\n\nSQLite table: trajectory_segments (id TEXT PRIMARY KEY, person_id TEXT, from_x REAL, from_y REAL, from_z REAL, to_x REAL, to_y REAL, to_z REAL, speed REAL, timestamp DATETIME). Only store ground plane (from_z and to_z floor-projected: set to 0 for the flow map, since we render on the ground plane).\n\nTable growth management: the table accumulates indefinitely. Prune segments older than 90 days (configurable) with a daily background job. With 4 people at typical home movement rates, 90 days generates approximately 50,000 segments — manageable for SQLite.\n\n## Flow Map Computation\n\nQuery: for each 0.25m grid cell (same resolution as OccupancyGrid in FusionEngine), average the movement vectors of all trajectory segments that pass through that cell.\n\nSQL approach: for each segment, determine which grid cells it passes through (Bresenham's line algorithm on the grid). Accumulate vector components (to_x - from_x, to_y - from_y) into per-cell accumulators.\n\nIn practice: compute on demand when requested (not continuously). Cache the result for up to 5 minutes (or until a \"flow dirty\" flag is set by new trajectory data).\n\nOutput: FlowMap struct with per-cell vectors (x_component, y_component) and a cell count. Serialised to JSON for the dashboard.\n\n## Dwell Hotspot Heatmap\n\nQuery: for each track update where speed < 0.1 m/s (stationary or near-stationary), increment the dwell counter for the corresponding 0.25m grid cell.\n\nSQLite table: dwell_accumulator (grid_x INT, grid_y INT, person_id TEXT, count INT, last_updated DATETIME, PRIMARY KEY (grid_x, grid_y, person_id)). Aggregated at the person+cell level for person-filtered views.\n\nOutput: DwellHeatmap struct mapping (grid_x, grid_y) to count. Normalised to [0, 1] by dividing by the max count across all cells.\n\n## Corridor Detection\n\nIdentify grid cells with consistently high flow volume AND low angular variance in their flow vectors. These are likely corridors or pathways.\n\nAlgorithm:\n1. For each cell, compute the circular variance of the flow vector angles across all segments that contributed. Low variance = directional consistency = corridor.\n2. Threshold: cells with segment_count > 10 AND circular_variance < 0.3 are candidate corridor cells.\n3. Connected component analysis: group adjacent corridor cells into corridor regions.\n4. Each corridor region is represented by its dominant direction and a bounding box.\n\nCorridor regions are stored in SQLite: detected_corridors (id, centroid_xyz, dominant_direction_xy, length_m, width_m, cell_count, last_computed). Recomputed weekly.\n\n## Time and Person Filters\n\nThe dashboard allows filtering flow data by:\n- Time range: \"Today\", \"This week\", \"This month\", custom date range. Implemented as SQL WHERE timestamp >= ? filters on the trajectory_segments table.\n- Person: filter to show only trajectories attributed to a specific person_id (or \"All people\").\n\nFiltered queries are run on-demand with SQL indices on (timestamp, person_id).\n\n## Dashboard Visualisation\n\nAdd two toggle-able layers to the 3D scene (in addition to existing layers):\n\n1. \"Flow\" layer: render flow vectors as animated arrows on the ground plane. Each arrow is positioned at the cell centre, oriented in the cell's average flow direction, and sized proportional to the flow volume (segment count). Use Three.js ArrowHelper for rendering. Animate: cycle the arrow colour from 0% to 100% opacity (flowing effect) on a 2-second loop. Only render cells with > 5 segments.\n\n2. \"Dwell Hotspot\" layer: render a heatmap on the ground plane as coloured rectangle patches (Three.js PlaneGeometry with MeshBasicMaterial, colour mapped from blue (low dwell) through green to red (high dwell)). Opacity 0.4. Only render cells with > 10 dwell samples.\n\n3. Corridor highlighting: detected corridors rendered as slightly raised platform geometry (extruded rectangle, height 0.01m) with a pathway colour (warm grey, opacity 0.3). Toggle-able as sub-option of the \"Flow\" layer.\n\nLayer controls: new \"Patterns\" section in the 3D layer control panel. Three checkboxes: \"Movement flows\", \"Dwell hotspots\", \"Corridors\". Time filter dropdown: \"All time / Last 7 days / Last 30 days\". Person filter dropdown.\n\n## REST API\n\nGET /api/analytics/flow?person_id=&since=&until= — returns FlowMap JSON\nGET /api/analytics/dwell?person_id=&since=&until= — returns DwellHeatmap JSON\nGET /api/analytics/corridors — returns list of DetectedCorridor\n\n## Tests\n\n- Test trajectory sampling: track moves 0.25m -> segment recorded; track moves 0.05m -> no segment\n- Test flow vector averaging: 5 segments all pointing East -> cell vector = (1, 0); 5 East + 5 North -> cell vector ~= (0.5, 0.5)\n- Test dwell accumulation: 100 track updates at speed=0 in cell (5, 7) -> dwell_accumulator[5][7] count = 100\n- Test corridor detection: 20 aligned segments in adjacent cells with angular_variance < 0.3 -> corridor detected\n- Test time-range filtering: insert segments at T-1day and T-8days; query since T-7days -> only T-1day segment returned\n- Test 90-day pruning job removes old segments\n\n## Acceptance Criteria\n\n- Flow layer renders correctly in 3D view with animated arrows for rooms with > 7 days of data\n- Dwell hotspot heatmap visible and renders high-use spots (favourite chair, kitchen counter) correctly\n- Corridor overlay visible with detected high-traffic pathways\n- Time and person filter controls update the rendered layers\n- Layer toggles show/hide each layer cleanly without scene rebuild\n- Tests pass","status":"in_progress","priority":3,"issue_type":"task","assignee":"echo","created_at":"2026-03-28T01:52:55.852672681Z","created_by":"coding","updated_at":"2026-04-09T08:50:53.910236304Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:325"]} -{"id":"spaxel-jza","title":"Dashboard: PIN change flow","description":"## Overview\nAllow authenticated users to change their dashboard PIN after first setup.\n\n## Backend\n- POST /api/auth/change-pin — requires valid session; body: {old_pin:'...', new_pin:'...'}\n- Verify old_pin against current bcrypt hash; return HTTP 403 if mismatch\n- Hash new_pin with bcrypt cost=12; update auth.pin_bcrypt\n- Existing sessions remain valid after PIN change (session tokens are independent of PIN)\n- Return {ok:true} on success\n\n## Dashboard\n- Settings panel: 'Security' section with 'Change PIN' button\n- Modal form: old PIN → new PIN → confirm new PIN → Submit\n- On 403: show 'Incorrect current PIN' error inline\n- On success: show 'PIN changed successfully' toast; close modal\n\n## Acceptance\n- Old PIN still works immediately after change attempt fails (403)\n- New PIN works on next login after successful change\n- Active session cookie remains valid after PIN change\n- Requires: spaxel-nk6 (PIN auth)","status":"open","priority":3,"issue_type":"task","created_at":"2026-04-06T16:43:09.899017181Z","created_by":"coding","updated_at":"2026-04-06T16:43:09.899017181Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"spaxel-jza","title":"Dashboard: PIN change flow","description":"## Overview\nAllow authenticated users to change their dashboard PIN after first setup.\n\n## Backend\n- POST /api/auth/change-pin — requires valid session; body: {old_pin:'...', new_pin:'...'}\n- Verify old_pin against current bcrypt hash; return HTTP 403 if mismatch\n- Hash new_pin with bcrypt cost=12; update auth.pin_bcrypt\n- Existing sessions remain valid after PIN change (session tokens are independent of PIN)\n- Return {ok:true} on success\n\n## Dashboard\n- Settings panel: 'Security' section with 'Change PIN' button\n- Modal form: old PIN → new PIN → confirm new PIN → Submit\n- On 403: show 'Incorrect current PIN' error inline\n- On success: show 'PIN changed successfully' toast; close modal\n\n## Acceptance\n- Old PIN still works immediately after change attempt fails (403)\n- New PIN works on next login after successful change\n- Active session cookie remains valid after PIN change\n- Requires: spaxel-nk6 (PIN auth)","status":"in_progress","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:43:09.899017181Z","created_by":"coding","updated_at":"2026-04-09T11:56:15.000064172Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"spaxel-klf","title":"Build self-improving localization","description":"Implement localization that learns from ground truth data.\n\nDeliverables:\n- BLE integration as ground truth source\n- Fresnel zone weight refinement algorithm\n- Continuous weight adjustment based on feedback\n\nAcceptance: Localization accuracy improves automatically as BLE ground truth data accumulates.","status":"in_progress","priority":2,"issue_type":"task","assignee":"delta","created_at":"2026-03-29T19:25:03.995110604Z","created_by":"coding","updated_at":"2026-04-02T01:19:06.575645095Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:924","mitosis-child","mitosis-depth:1","parent-spaxel-i28"]} {"id":"spaxel-klk","title":"Add floor plan backend API and storage","description":"## Backend (mothership/internal/floorplan.go)\n- POST /api/floorplan/image — multipart form; accept PNG/JPG max 10 MB; save to /data/floorplan/image.png\n- GET /api/floorplan/image — serve the stored image (200 or 404 if none)\n- POST /api/floorplan/calibrate — accept {ax,ay,bx,by,distance_m,rotation_deg}: two pixel coordinates and their real-world distance; compute and persist pixel-to-meter transform\n- GET /api/floorplan/calibrate — return current calibration or 404 if none\n- SQLite floorplan table: image_path TEXT, cal_ax,cal_ay,cal_bx,cal_by REAL, distance_m REAL, rotation_deg REAL, updated_at INT\n\n## Acceptance\n- Image upload saves file to /data/floorplan/image.png\n- Calibration data persists to SQLite\n- > 10 MB upload rejected with 413 error","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-07T14:46:37.281038019Z","created_by":"coding","updated_at":"2026-04-07T19:03:01.027553189Z","closed_at":"2026-04-07T19:03:01.027363382Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-spaxel-6hd"],"dependencies":[{"issue_id":"spaxel-klk","depends_on_id":"spaxel-05a","type":"blocks","created_at":"2026-04-07T17:55:53.393074362Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-b6a","type":"blocks","created_at":"2026-04-07T17:55:52.719854848Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-itf","type":"blocks","created_at":"2026-04-07T17:55:52.239848449Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-ts2","type":"blocks","created_at":"2026-04-07T17:55:50.722857752Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-klk","depends_on_id":"spaxel-xlo","type":"blocks","created_at":"2026-04-07T17:55:49.889540315Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-kth","title":"Mobile-responsive expert mode","description":"## Background\n\nThe expert mode 3D dashboard was built desktop-first: it assumes a large screen, mouse input, and keyboard shortcuts. On a tablet (10-inch iPad, Android tablet) or phone, the same interface needs adaptation: touch gestures instead of mouse, collapsible panels to preserve canvas space, responsive layout for portrait orientation, and appropriate touch target sizes. This bead systematically addresses all mobile-specific issues in the expert mode (simple mode and ambient mode already have their own mobile-optimised implementations).\n\n## Touch Controls for Three.js OrbitControls\n\nThree.js's OrbitControls already includes touch event handling:\n- Single-finger drag: orbit (rotate the camera around the scene centre)\n- Two-finger pinch: zoom (dollying)\n- Two-finger drag: pan (pan the camera laterally)\n\nHowever, several issues need to be resolved:\n\n1. Touch events from panel overlays propagating to the canvas: when a user touches a sidebar panel to scroll it, the touch event should not also orbit the scene. Fix: add touch event listeners on all panel elements with event.stopPropagation() to prevent bubbling to the canvas.\n\n2. iOS Safari passive event listener warning: OrbitControls uses non-passive touch listeners. iOS logs warnings about this. Fix: override event listener options in OrbitControls or configure the canvas touch-action CSS property: canvas { touch-action: none; }\n\n3. Double-tap to zoom conflict: iOS Safari intercepts double-taps as page zoom. Fix: meta viewport tag already has user-scalable=no (verify this is set in index.html). If not, add it.\n\n4. Pinch gesture accuracy: test on actual devices. If pinch feels imprecise, increase OrbitControls.zoomSpeed for touch input (separate from mouse zoomSpeed).\n\n5. Three-finger pan: useful on tablets. OrbitControls supports it but it may be disabled. Enable if not already active.\n\n## Hamburger Menu\n\nOn screens < 1024px width (tablets in portrait and all phones), replace the always-visible side panels with a hamburger menu:\n- Hamburger button: top-right of the header bar, next to the search icon. Three horizontal lines, 44px touch target.\n- Opening the menu: `transform: translateX(0)` CSS animation on the left sidebar panel. Duration: 200ms ease-out. Overlay backdrop: semi-transparent.\n- The menu contains: Node List, Link List, Presence Panel, Timeline (if visible), people and devices panel.\n- Active tab within the menu: the last-used panel opens first.\n- Close button inside the menu: top-right X, 44px. Also close on backdrop tap or Escape.\n\nCSS implementation: use `transform: translateX(-100%)` as the hidden state, `translateX(0)` as the shown state. Use CSS transitions (not JavaScript animation) for GPU-accelerated smoothness.\n\nMedia query breakpoints:\n- < 1024px: hamburger menu (single panel column replaces all sidebars)\n- < 768px: simple mode auto-activated by default (user can switch to expert)\n\n## Responsive Canvas\n\nThe Three.js canvas must fill the available space correctly at all screen sizes and orientations.\n\nOn orientation change:\n1. window.addEventListener('orientationchange', ...) — also listen to window.addEventListener('resize', ...)\n2. Update renderer.setSize(window.innerWidth, window.innerHeight) (or the canvas's container size)\n3. Update camera.aspect = window.innerWidth / window.innerHeight\n4. Call camera.updateProjectionMatrix()\n5. Trigger a re-render\n\niOS Safari specific: the visual viewport size can differ from window.innerWidth when the address bar is shown/hidden. Use visualViewport.width and visualViewport.height if available (iOS 13+), falling back to window.innerWidth/Height.\n\nBottom navigation bar (if simple mode is active): the Three.js canvas must not overlap the bottom nav. Use calc(100vh - 56px) as the canvas height (56px = nav bar height).\n\n## Touch-Friendly Targets\n\nAudit all interactive elements in the expert mode for touch target size compliance (WCAG 2.1 Success Criterion 2.5.5 Target Size: minimum 44x44px recommended):\n\nElements to resize:\n- Layer toggle checkboxes: increase clickable area with padding\n- Link list entries: ensure min 44px height\n- Panel close buttons: ensure 44px x 44px\n- Slider controls (baseline tau, threshold): ensure drag targets are at least 44px tall\n- Context menu items: min 44px height (should already be, verify)\n\nUse CSS padding to increase tap targets without changing visual size: add padding: 12px 8px to button elements, or use the :after pseudo-element trick for hitbox expansion.\n\n## Performance Optimisations for Mobile\n\nOn screens < 1024px width (treat as mobile/tablet):\n1. Cap devicePixelRatio at 2.0: `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2.0))`. This prevents 3x rendering on high-dpi displays which is unnecessary and expensive.\n2. Disable shadows: `renderer.shadowMap.enabled = false` on mobile. Shadow maps are expensive and home-scale scenes don't critically need them.\n3. Reduce maximum shadow map size to 512x512 if shadows remain enabled.\n4. Reduce antialias quality: use FXAA (Fast Approximate Anti-Aliasing as a post-process pass) instead of MSAA on mobile if needed.\n5. Cap frame rate at 30 fps on mobile (use `requestAnimationFrame` with a delta check) if the device is struggling.\n\n## iOS Safari Safe Area\n\nDevices with notches (iPhone X and later, newer iPads in landscape) have a \"safe area\" that content should not overlap. Use CSS environment variables:\n- body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }\n- The hamburger menu bottom should respect env(safe-area-inset-bottom) on iPhone home-button-less devices.\n\nThese CSS variables are zero on non-notched devices, so they are safe to apply universally.\n\nWebSocket behaviour: WebSocket works normally in iOS Safari, including when the app is backgrounded briefly (though connections may drop on long backgrounding — this is expected and the dashboard already has reconnection logic).\n\n## Files to Modify\n\n- dashboard/index.html: add meta viewport with user-scalable=no, verify safe-area meta tag\n- dashboard/css/expert.css: media queries for hamburger menu, responsive canvas, touch-friendly targets\n- dashboard/js/app.js: orientationchange and resize listeners, canvas resize handler\n- dashboard/js/controls.js (or wherever OrbitControls is initialised): touch event propagation fixes, canvas touch-action CSS\n\n## Tests\n\n- Test canvas resize handler: simulate a resize event with new width/height, verify renderer.setSize and camera.aspect are updated correctly\n- Test touch event propagation: touch event on a sidebar panel element does not reach the canvas (mock event bubbling)\n- Test hamburger menu open/close animation: mock CSS transition end event, verify panel reaches translateX(0) on open and translateX(-100%) on close\n- Test devicePixelRatio cap: mock window.devicePixelRatio = 3, verify renderer uses pixelRatio 2.0\n- Test safe-area CSS is applied: verify env() CSS variables are referenced in the stylesheet\n\n## Acceptance Criteria\n\n- 3D scene is navigable with touch gestures on iPad 10-inch and iPhone 15 (tested manually or via BrowserStack)\n- Pinch-to-zoom and single-finger orbit both work without conflicting with panel scrolling\n- All sidebar panels accessible via hamburger menu on screens < 1024px\n- Hamburger menu animation is smooth (CSS transform, not JavaScript)\n- Canvas responds correctly to orientation change (portrait <-> landscape) on both iOS and Android\n- No touch event propagation from panel overlays to the 3D scene\n- All interactive targets are at least 44px in their touch dimension\n- Tests pass","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-28T02:05:12.940221112Z","created_by":"coding","updated_at":"2026-03-28T03:29:14.992514770Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"spaxel-kth","depends_on_id":"spaxel-sl2","type":"blocks","created_at":"2026-03-28T03:29:14.992482460Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-kxf","title":"Implement Notifications REST endpoints","description":"Implement GET/POST /api/notifications/config to get/set delivery channel settings (Ntfy/Pushover/webhook). Add POST /api/notifications/test to send a test notification. Include OpenAPI-style godoc comments.","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T15:31:10.428129819Z","created_by":"coding","updated_at":"2026-04-07T13:11:56.263459570Z","closed_at":"2026-04-07T13:11:56.263241649Z","close_reason":"Implemented Notifications REST endpoints:\n\n- GET /api/notifications/config: Returns all notification channel configurations with enabled status and type-specific settings\n- POST /api/notifications/config: Updates one or more notification channels with config validation per type\n- POST /api/notifications/test: Sends a test notification via the specified channel\n\nSupported channel types:\n- ntfy: requires 'url', optional 'token'\n- pushover: requires 'app_token', 'user_key'\n- gotify: requires 'url', 'token'\n- webhook: requires 'url', optional 'method', optional 'headers'\n- mqtt: no config required (uses global connection)\n\nIncludes table-driven tests covering all endpoints and validation scenarios.","source_repo":".","compaction_level":0,"original_size":0,"labels":["mitosis-child","mitosis-depth:1","parent-spaxel-6ha"]} {"id":"spaxel-leh","title":"Sleep: breathing rate FFT extraction & anomaly flagging","description":"## Overview\nExtract breathing rate from CSI phase signal during sleep using FFT peak detection, and flag elevated breathing as a health anomaly in the morning briefing.\n\n## Algorithm (mothership/internal/sleep/ or signal/)\n\n### Breathing rate estimator (runs during ASLEEP state):\n1. Accumulate 512 phase samples at 20 Hz (25.6s window) from the most motion-sensitive link in sleep zone\n2. Zero-pad to 1024 points for FFT\n3. Run FFT (use Go's golang.org/x/signal/fft or implement Cooley-Tukey)\n4. Frequency resolution: 20 Hz / 1024 = 0.0195 Hz/bin\n5. Find dominant peak in bin range [0.1, 0.5] Hz (6-25 bpm) — ignoring DC and motion bands\n6. Convert bin index to bpm: bpm = bin_idx × (20.0/1024) × 60\n7. Apply 60-second EMA smoothing: ema = α × bpm + (1-α) × ema, α = 1/60\n\n### Per-night statistics:\n- Collect breathing_rate_samples[] throughout ASLEEP state (one per 60s window)\n- breathing_rate_avg = mean(breathing_rate_samples)\n- breathing_regularity = std(breathing_rate_samples) / mean(breathing_rate_samples)\n - Regular: CV < 0.10\n - Irregular: CV > 0.25\n\n### Anomaly detection:\n- Maintain rolling 30-day personal_avg_bpm per person (EMA α=0.05, updated on each night)\n- If breathing_rate_avg > personal_avg_bpm × 1.25: flag as elevated\n- Morning briefing includes: 'Breathing rate elevated (22 bpm vs. 16 bpm average)'\n- Store flag in sleep_records.breathing_anomaly BOOL\n\n## SQLite additions to sleep_records:\nAdd columns: breathing_rate_avg REAL, breathing_regularity REAL, breathing_anomaly BOOL, breathing_samples_json TEXT\n\n## Acceptance\n- FFT correctly identifies 0.25 Hz (15 bpm) dominant frequency in synthetic phase signal\n- EMA smoothing applied across nightly samples\n- Elevated anomaly triggers correctly at >25% above personal average\n- Anomaly appears in morning briefing and GET /api/sleep response","status":"closed","priority":2,"issue_type":"task","assignee":"foxtrot","created_at":"2026-04-06T13:10:18.033253141Z","created_by":"coding","updated_at":"2026-04-07T11:46:15.069928306Z","closed_at":"2026-04-07T11:46:15.069794336Z","close_reason":"Sleep breathing rate FFT extraction & anomaly flagging was already fully implemented. All components verified passing:\n\n1. FFT breathing rate estimator (breathing_estimator.go) - Uses gonum FFT, 512-sample window at 20Hz zero-padded to 1024, finds dominant peak in 0.1-0.5 Hz band (6-30 bpm), 60-second EMA smoothing\n\n2. Per-night breathing statistics (analyzer.go) - Collects breathing_rate_samples[] throughout ASLEEP state, computes avg/std/CV regularity (regular <0.10, irregular >0.25)\n\n3. Breathing anomaly detection (breathing_anomaly.go) - Per-person rolling 30-day EMA, flags if nightly avg > personal_avg x 1.25\n\n4. SQLite columns already in schema (migration_008) - breathing_rate_avg, breathing_regularity, breathing_anomaly, breathing_samples_json\n\n5. Morning briefing integration (briefing/briefing.go) - Includes breathing anomaly text\n\n6. API response (sleep/handler.go) - GET /api/sleep returns all breathing fields\n\nAll 55 tests pass including acceptance tests.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:26"]} {"id":"spaxel-lui","title":"Mothership: environment variable validation and documented defaults","description":"## Overview\nValidate all environment variables at startup with type checking, range validation, and clear error messages — fail fast rather than silently using bad config.\n\n## All env vars to validate (plan lines 3520-3542):\nSPAXEL_BIND_ADDR string default '0.0.0.0:8080'\nSPAXEL_DATA_DIR string default '/data'\nSPAXEL_STATIC_DIR string default '/dashboard'\nSPAXEL_MDNS_ENABLED bool default true\nSPAXEL_MDNS_NAME string default 'spaxel'\nSPAXEL_LOG_LEVEL enum default 'info' (debug|info|warn|error)\nSPAXEL_FUSION_RATE_HZ int default 10, range [1,20]\nSPAXEL_REPLAY_MAX_MB int default 360, range [10,10000]\nSPAXEL_INSTALL_SECRET string optional (32+ chars if set)\nSPAXEL_NTP_SERVER string default 'pool.ntp.org'\nSPAXEL_MQTT_BROKER string optional (must be valid URL if set)\nSPAXEL_MQTT_USERNAME string optional\nSPAXEL_MQTT_PASSWORD string optional (sensitive — never logged)\nTZ string default 'UTC'\n\n## Implementation (internal/config/config.go)\n- Parse and validate each env var; collect all errors before returning\n- Log all non-sensitive loaded values at INFO (MQTT_PASSWORD masked as '***')\n- Return error slice on validation failure; main() logs each error and exits(1)\n- Unit tests: valid config, invalid FUSION_RATE_HZ (25), invalid LOG_LEVEL ('verbose'), invalid MQTT_BROKER ('not-a-url')\n\n## Acceptance\n- SPAXEL_FUSION_RATE_HZ=25 → startup fails with 'SPAXEL_FUSION_RATE_HZ=25 invalid: must be in range [1,20]'\n- SPAXEL_LOG_LEVEL=verbose → startup fails with 'SPAXEL_LOG_LEVEL=verbose invalid: must be one of debug|info|warn|error'\n- Valid config → all values logged at INFO on startup\n- Sensitive values (MQTT_PASSWORD) never appear in logs","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-06T16:44:08.903774793Z","created_by":"coding","updated_at":"2026-04-07T16:16:06.183472132Z","closed_at":"2026-04-07T16:16:06.183396284Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2"]} -{"id":"spaxel-lve","title":"Firmware: LED identify blink command","description":"## Overview\nImplement LED blink on ESP32-S3 in response to the 'identify' downstream message, so users can physically locate a specific node.\n\n## Firmware (firmware/main/websocket.c or led.c)\n- Parse downstream JSON message: {type:'identify', duration_ms: 5000}\n- Implement LED blink handler: toggle GPIO LED pin at 100ms on/100ms off for duration_ms\n- LED GPIO: board-specific (default GPIO8 for ESP32-S3-DevKitC; configurable via sdkconfig)\n- Run blink in a FreeRTOS task so it doesn't block WebSocket processing\n- Cancel any running blink when new identify message received (or on disconnect)\n\n## Mothership REST API\n- POST /api/nodes/{mac}/identify — body: {duration_ms: 5000}\n- Forward as downstream WebSocket message to the target node\n- Return 404 if node not connected; 200 on success\n\n## Dashboard integration\n- Fleet status page: 'Identify' button per row → POST /api/nodes/{mac}/identify\n- 3D view: right-click node → context menu 'Identify (blink LED)'\n\n## Acceptance\n- LED blinks at ~5 Hz for the specified duration when identify message received\n- Blink stops automatically when duration_ms expires\n- REST endpoint returns 404 for disconnected nodes","status":"in_progress","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:42:36.430956056Z","created_by":"coding","updated_at":"2026-04-09T11:47:14.399463223Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-lve","depends_on_id":"spaxel-h58","type":"blocks","created_at":"2026-04-09T10:58:29.824284468Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-lve","depends_on_id":"spaxel-pnd","type":"blocks","created_at":"2026-04-09T10:58:29.768056674Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-lve","depends_on_id":"spaxel-qp3","type":"blocks","created_at":"2026-04-09T10:58:29.721943276Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"spaxel-lve","title":"Firmware: LED identify blink command","description":"## Overview\nImplement LED blink on ESP32-S3 in response to the 'identify' downstream message, so users can physically locate a specific node.\n\n## Firmware (firmware/main/websocket.c or led.c)\n- Parse downstream JSON message: {type:'identify', duration_ms: 5000}\n- Implement LED blink handler: toggle GPIO LED pin at 100ms on/100ms off for duration_ms\n- LED GPIO: board-specific (default GPIO8 for ESP32-S3-DevKitC; configurable via sdkconfig)\n- Run blink in a FreeRTOS task so it doesn't block WebSocket processing\n- Cancel any running blink when new identify message received (or on disconnect)\n\n## Mothership REST API\n- POST /api/nodes/{mac}/identify — body: {duration_ms: 5000}\n- Forward as downstream WebSocket message to the target node\n- Return 404 if node not connected; 200 on success\n\n## Dashboard integration\n- Fleet status page: 'Identify' button per row → POST /api/nodes/{mac}/identify\n- 3D view: right-click node → context menu 'Identify (blink LED)'\n\n## Acceptance\n- LED blinks at ~5 Hz for the specified duration when identify message received\n- Blink stops automatically when duration_ms expires\n- REST endpoint returns 404 for disconnected nodes","status":"closed","priority":3,"issue_type":"task","assignee":"golf","created_at":"2026-04-06T16:42:36.430956056Z","created_by":"coding","updated_at":"2026-04-09T11:55:58.252635927Z","closed_at":"2026-04-09T11:55:58.252506642Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"spaxel-lve","depends_on_id":"spaxel-h58","type":"blocks","created_at":"2026-04-09T10:58:29.824284468Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-lve","depends_on_id":"spaxel-pnd","type":"blocks","created_at":"2026-04-09T10:58:29.768056674Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-lve","depends_on_id":"spaxel-qp3","type":"blocks","created_at":"2026-04-09T10:58:29.721943276Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-m9a","title":"Multi-link Fresnel zone fusion","description":"Spatial localization using Fresnel zone weighted fusion across multiple links.\n\n## Deliverables\n- New package: mothership/internal/fusion/\n- Fresnel zone geometry computation for each TX-RX link pair\n- 3D grid-based localization: for each voxel, compute weighted sum of link activations\n- Weight = inverse distance from voxel center to nearest Fresnel ellipsoid surface\n- Peak extraction from the 3D activation grid\n- Output: list of detected blob positions with confidence scores\n\n## Acceptance Criteria\n- With 4+ links, produces 2D position estimates within ±1m for a single person\n- Handles varying link geometries (different node positions)\n- Performance: fusion completes within 50ms for up to 20 links\n- Tests with synthetic data verify position accuracy\n\n## References\n- Plan: docs/plan/plan.md item 15\n- Signal features: mothership/internal/signal/features.go (deltaRMS per link)","status":"closed","priority":2,"issue_type":"task","assignee":"spaxel-alpha","created_at":"2026-03-27T01:56:47.328316637Z","created_by":"coding","updated_at":"2026-03-28T02:06:14.280688374Z","closed_at":"2026-03-27T03:36:49.190412787Z","close_reason":"Implemented mothership/internal/fusion/ package with 3D Fresnel zone weighted multi-link localization. Grid3D voxel grid, Engine fusing LinkMotion slices, FresnelZoneRadius helper. All 15 tests pass: ±1m accuracy with 4+ links, <50ms for 20 links. Committed in 9c56a37.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"],"dependencies":[{"issue_id":"spaxel-m9a","depends_on_id":"spaxel-8u3","type":"blocks","created_at":"2026-03-28T02:06:14.280670195Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"spaxel-m9a","depends_on_id":"spaxel-uc9","type":"blocks","created_at":"2026-03-28T01:34:05.624567226Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"spaxel-mg0","title":"Mothership: installation secret generation with one-time print","description":"## Overview\nAuto-generate a 256-bit installation secret on first run, print it exactly once to stdout, and use it for node provisioning token derivation.\n\n## Implementation (mothership/internal/auth/ or cmd/mothership/main.go)\n\n### On startup (before HTTP server starts):\n1. Check SPAXEL_INSTALL_SECRET env var — if set, use it directly\n2. If not set: query SQLite auth table for install_secret column\n3. If found in SQLite: load silently (log at DEBUG level only)\n4. If not found: generate 32 random bytes via crypto/rand.Read()\n5. Store hex-encoded secret in auth.install_secret (INSERT OR IGNORE)\n6. Print ONCE to stdout: '[SPAXEL] Installation secret: <64-char-hex>. Shown once — save to a safe place.'\n7. Never print again on subsequent startups\n\n### Usage:\n- Installation secret used to derive per-node provisioning tokens (HMAC-SHA256 of node_mac + secret)\n- Exposed via GET /api/auth/install-secret (requires admin session or first-run state)\n\n## Acceptance\n- First run: secret printed to stdout and stored in SQLite\n- Second run: no output — secret loaded silently from SQLite\n- SPAXEL_INSTALL_SECRET env var overrides SQLite value (printed at INFO: 'Using provided SPAXEL_INSTALL_SECRET')\n- crypto/rand used (not math/rand)","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-06T16:43:19.679455445Z","created_by":"coding","updated_at":"2026-04-06T22:07:47.640654933Z","closed_at":"2026-04-06T22:07:47.640431956Z","close_reason":"Install secret generation with one-time print: already implemented in mothership/internal/auth/handler.go. Features: auto-generate 256-bit secret on first run via crypto/rand, print once to stdout, store in SQLite, SPAXEL_INSTALL_SECRET env var override, GET /api/auth/install-secret endpoint (admin or first-run), HMAC-SHA256 per-node token derivation. All 21 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:90"]} {"id":"spaxel-mjn","title":"Passive radar: OUI lookup & router manufacturer identification","description":"## Overview\nEmbed an IEEE OUI registry at build time so the mothership can display friendly router manufacturer names during passive radar onboarding.\n\n## Implementation (mothership/internal/oui/)\n\n### go generate step (oui/gen.go):\n//go:generate go run gen.go\n- Download https://standards-oui.ieee.org/oui/oui.txt at generate time (not at runtime)\n- Parse lines: '00-00-0C (hex) Cisco Systems' → extract hex prefix and vendor name\n- Generate oui_data.go: var ouiMap = map[uint32]string{0x00000C: 'Cisco Systems', ...}\n- Only regenerate when manually triggered; commit oui_data.go to the repo\n\n### Lookup function (oui/oui.go):\nfunc LookupOUI(mac net.HardwareAddr) string\n - Extract first 3 bytes as uint32 (big-endian)\n - Return ouiMap[key] or '' if not found\n\n### Integration:\n- In passive radar AP detection (spaxel-w40): when AP BSSID detected, call LookupOUI(bssid)\n- Onboarding wizard shows: 'I detected your router (ASUS). Place it on the floor plan.'\n- If OUI unknown: show 'I detected your router. Place it on the floor plan.'\n- GET /api/nodes response: include manufacturer field for virtual nodes\n\n## Acceptance\n- LookupOUI(00:1A:2B:...) returns correct vendor for known OUIs\n- oui_data.go compiles without errors\n- go generate produces non-empty map (>5000 entries)\n- Unknown OUI returns empty string (no panic)","status":"open","priority":3,"issue_type":"task","created_at":"2026-04-06T13:10:41.582690525Z","created_by":"coding","updated_at":"2026-04-06T13:10:41.582690525Z","source_repo":".","compaction_level":0,"original_size":0} diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 7eb4a7d..4a4351f 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -68334a9b7894907da80e42e6ebdb3deb0d9fbee2 +747940f9328660c49cb3add730005dd542b5ad4f diff --git a/dashboard/css/panels.css b/dashboard/css/panels.css index 90946d3..7ea5c35 100644 --- a/dashboard/css/panels.css +++ b/dashboard/css/panels.css @@ -2198,3 +2198,166 @@ font-size: 10px; flex-shrink: 0; } + +/* ============================================ + Change PIN Modal Styles + ============================================ */ + +.panel-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + animation: panel-fade-in 0.2s ease-out; +} + +@keyframes panel-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.panel-modal { + background: #1e1e3a; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + width: 400px; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: panel-slide-up 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes panel-slide-up { + from { + opacity: 0; + transform: scale(0.95) translateY(20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.panel-modal-header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.panel-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #eee; +} + +.panel-modal-close { + background: none; + border: none; + color: #888; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background 0.2s, color 0.2s; + flex-shrink: 0; +} + +.panel-modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #eee; +} + +.panel-modal-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; +} + +.panel-input { + width: 100%; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #eee; + font-size: 14px; + font-family: inherit; + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} + +.panel-input:focus { + outline: none; + border-color: #4fc3f7; + box-shadow: 0 0 0 3px rgba(79, 195, 247, 0.15); +} + +.panel-input::placeholder { + color: #555; +} + +.panel-modal-actions { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.panel-error { + background: rgba(239, 83, 80, 0.15); + border: 1px solid rgba(239, 83, 80, 0.3); + border-radius: 6px; + padding: 10px 16px; + font-size: 13px; + color: #ef5350; + margin-bottom: 16px; +} + +/* Responsive adjustments for change PIN modal */ +@media (max-width: 480px) { + .panel-modal { + width: 100%; + max-width: 100%; + border-radius: 12px 12px 0 0; + margin: 0 auto; + position: fixed; + bottom: 0; + transform: translateY(100%); + } + + .panel-modal-overlay.visible .panel-modal { + transform: translateY(0); + } + + .panel-modal-actions { + flex-direction: column; + } + + .panel-modal-actions .panel-btn { + width: 100%; + } +} diff --git a/dashboard/js/settings-panel.js b/dashboard/js/settings-panel.js index c70afb2..2203141 100644 --- a/dashboard/js/settings-panel.js +++ b/dashboard/js/settings-panel.js @@ -110,6 +110,7 @@ content.innerHTML = ` ${renderDetectionSettings(settings)} + ${renderSecuritySettings(settings)} ${renderNotificationSettings(settings)} ${renderSystemInfo()} `; @@ -199,6 +200,24 @@ `; } + function renderSecuritySettings(settings) { + return ` +
+
Security
+ +
+
Dashboard PIN
+
Configured
+
Protects access to your dashboard
+
+ + +
+ `; + } + function renderNotificationSettings(settings) { const notificationChannels = settings.notification_channels || {}; const ntfyEnabled = notificationChannels.ntfy && notificationChannels.ntfy.enabled; @@ -395,6 +414,12 @@ if (logoutBtn) { logoutBtn.addEventListener('click', handleLogout); } + + // Change PIN + const changePinBtn = document.getElementById('change-pin-btn'); + if (changePinBtn) { + changePinBtn.addEventListener('click', openChangePINModal); + } } /** @@ -516,6 +541,224 @@ .replace(/"/g, '"'); } + // ============================================ + // Change PIN Modal + // ============================================ + + const changePINState = { + oldPin: '', + newPin: '', + confirmPin: '', + error: '', + isSubmitting: false + }; + + function openChangePINModal() { + changePINState.oldPin = ''; + changePINState.newPin = ''; + changePINState.confirmPin = ''; + changePINState.error = ''; + changePINState.isSubmitting = false; + + const modal = document.createElement('div'); + modal.id = 'change-pin-modal'; + modal.className = 'panel-modal-overlay'; + modal.innerHTML = renderChangePINModal(); + document.body.appendChild(modal); + + attachChangePINEvents(modal); + + // Focus first input + setTimeout(function() { + const firstInput = modal.querySelector('#change-pin-old'); + if (firstInput) firstInput.focus(); + }, 10); + } + + function closeChangePINModal() { + const modal = document.getElementById('change-pin-modal'); + if (modal) { + modal.remove(); + } + } + + function renderChangePINModal() { + return ` +
+
+

Change PIN

+ +
+
+ ${changePINState.error ? ` +
${escapeHtml(changePINState.error)}
+ ` : ''} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ `; + } + + function attachChangePINEvents(modal) { + // Close button + const closeBtn = modal.querySelector('#change-pin-close'); + if (closeBtn) { + closeBtn.addEventListener('click', closeChangePINModal); + } + + // Cancel button + const cancelBtn = modal.querySelector('#change-pin-cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', closeChangePINModal); + } + + // Submit button + const submitBtn = modal.querySelector('#change-pin-submit'); + if (submitBtn) { + submitBtn.addEventListener('click', submitChangePIN); + } + + // Close on overlay click + modal.addEventListener('click', function(e) { + if (e.target === modal) { + closeChangePINModal(); + } + }); + + // Handle Enter key + const inputs = modal.querySelectorAll('.panel-input'); + inputs.forEach(function(input) { + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + submitChangePIN(); + } + }); + + // Only allow digits + input.addEventListener('input', function(e) { + const value = e.target.value; + if (!/^\d*$/.test(value)) { + e.target.value = value.replace(/\D/g, ''); + } + }); + }); + } + + function submitChangePIN() { + const oldPinInput = document.getElementById('change-pin-old'); + const newPinInput = document.getElementById('change-pin-new'); + const confirmPinInput = document.getElementById('change-pin-confirm'); + + if (!oldPinInput || !newPinInput || !confirmPinInput) { + return; + } + + const oldPin = oldPinInput.value.trim(); + const newPin = newPinInput.value.trim(); + const confirmPin = confirmPinInput.value.trim(); + + // Validation + if (!oldPin || oldPin.length < 4) { + changePINState.error = 'Please enter your current PIN (4-8 digits)'; + updateChangePINModal(); + return; + } + + if (!newPin || newPin.length < 4 || newPin.length > 8) { + changePINState.error = 'New PIN must be 4-8 digits'; + updateChangePINModal(); + return; + } + + if (newPin !== confirmPin) { + changePINState.error = 'New PINs do not match'; + updateChangePINModal(); + return; + } + + if (oldPin === newPin) { + changePINState.error = 'New PIN must be different from current PIN'; + updateChangePINModal(); + return; + } + + changePINState.oldPin = oldPin; + changePINState.newPin = newPin; + changePINState.confirmPin = confirmPin; + changePINState.error = ''; + changePINState.isSubmitting = true; + updateChangePINModal(); + + // Send change PIN request + fetch('/api/auth/change-pin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + old_pin: oldPin, + new_pin: newPin + }) + }) + .then(function(res) { + if (res.status === 403) { + throw new Error('Incorrect current PIN'); + } + if (!res.ok) { + return res.text().then(function(text) { + throw new Error(text || 'Failed to change PIN'); + }); + } + return res.json(); + }) + .then(function(data) { + closeChangePINModal(); + SpaxelPanels.showSuccess('PIN changed successfully'); + }) + .catch(function(err) { + changePINState.error = err.message || 'Failed to change PIN'; + changePINState.isSubmitting = false; + updateChangePINModal(); + }); + } + + function updateChangePINModal() { + const modal = document.getElementById('change-pin-modal'); + if (!modal) return; + + const modalContent = modal.querySelector('.panel-modal'); + if (modalContent) { + modalContent.innerHTML = renderChangePINModal().match(/
([\s\S]*)<\/div>/)[1]; + attachChangePINEvents(modal); + } + } + // ============================================ // Panel Registration // ============================================ diff --git a/mothership/internal/auth/handler.go b/mothership/internal/auth/handler.go index 1cf0c65..e6b89df 100644 --- a/mothership/internal/auth/handler.go +++ b/mothership/internal/auth/handler.go @@ -168,6 +168,7 @@ func (h *Handler) RegisterRoutes(mux interface{ HandleFunc(pattern string, handl mux.HandleFunc("POST /api/auth/setup", h.handleSetup) mux.HandleFunc("POST /api/auth/login", h.handleLogin) mux.HandleFunc("POST /api/auth/logout", h.handleLogout) + mux.HandleFunc("POST /api/auth/change-pin", h.RequireAuth(h.handleChangePIN)) } // handleStatus returns whether a PIN is configured. @@ -386,6 +387,89 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) } +// handleChangePIN changes the user's PIN. +// Requires valid session (authenticated). +func (h *Handler) handleChangePIN(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse request + var req struct { + OldPIN string `json:"old_pin"` + NewPIN string `json:"new_pin"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Get current PIN hash + var currentHash string + err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(¤tHash) + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + log.Printf("[ERROR] Failed to get current PIN: %v", err) + return + } + + if currentHash == "" { + http.Error(w, "PIN not configured", http.StatusNotFound) + return + } + + // Verify old PIN matches current hash + if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.OldPIN)); err != nil { + // Old PIN doesn't match + http.Error(w, "Incorrect current PIN", http.StatusForbidden) + log.Printf("[WARN] Failed PIN change attempt from %s: incorrect old PIN", r.RemoteAddr) + return + } + + // Validate new PIN + if len(req.NewPIN) < 4 || len(req.NewPIN) > 8 { + http.Error(w, "PIN must be 4-8 digits", http.StatusBadRequest) + return + } + + // Ensure new PIN is numeric + for _, c := range req.NewPIN { + if c < '0' || c > '9' { + http.Error(w, "PIN must contain only digits", http.StatusBadRequest) + return + } + } + + // Hash new PIN with bcrypt (cost 12) + newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPIN), 12) + if err != nil { + http.Error(w, "Failed to hash PIN", http.StatusInternalServerError) + log.Printf("[ERROR] Failed to hash new PIN: %v", err) + return + } + + // Update PIN in database + _, err = h.db.Exec(` + UPDATE auth + SET pin_bcrypt = ?, updated_at = ? + WHERE id = 1 + `, newHash, time.Now().UnixMilli()) + if err != nil { + http.Error(w, "Failed to update PIN", http.StatusInternalServerError) + log.Printf("[ERROR] Failed to update PIN: %v", err) + return + } + + log.Printf("[INFO] PIN changed successfully from %s", r.RemoteAddr) + + // Note: Existing sessions remain valid after PIN change + // (session tokens are independent of PIN) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) +} + // createSession creates a new session and returns the session ID. func (h *Handler) createSession() (string, error) { // Generate 32-byte random session ID (64 hex chars) diff --git a/mothership/internal/auth/handler_test.go b/mothership/internal/auth/handler_test.go index da3cf08..07fd14b 100644 --- a/mothership/internal/auth/handler_test.go +++ b/mothership/internal/auth/handler_test.go @@ -724,3 +724,241 @@ func TestHandleInstallSecret_AfterPINSet_Authorized(t *testing.T) { t.Errorf("Expected 64-char hex secret, got %d chars", len(resp["install_secret"])) } } + +func TestHandler_ChangePIN_Success(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Setup PIN first + reqBody := `{"pin": "1234"}` + req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSetup(w, req) + + // Get session cookie + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == "spaxel_session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("Session cookie not set") + } + + // Change PIN + changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}` + req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + h.handleChangePIN(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + + if resp["ok"] != "true" { + t.Error("Expected ok: true response") + } + + // Verify old PIN no longer works + loginReqBody := `{"pin": "1234"}` + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + h.handleLogin(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected old PIN to be invalid (401), got %d", w.Code) + } + + // Verify new PIN works + loginReqBody = `{"pin": "5678"}` + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + h.handleLogin(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected new PIN to work (200), got %d", w.Code) + } + + // Verify original session still works + req = httptest.NewRequest("GET", "/api/test", nil) + req.AddCookie(sessionCookie) + if !h.IsAuthenticated(req) { + t.Error("Expected original session to remain valid after PIN change") + } +} + +func TestHandler_ChangePIN_WrongOldPIN(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Setup PIN first + reqBody := `{"pin": "1234"}` + req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSetup(w, req) + + // Get session cookie + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == "spaxel_session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("Session cookie not set") + } + + // Try to change with wrong old PIN + changeReqBody := `{"old_pin": "9999", "new_pin": "5678"}` + req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + h.handleChangePIN(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("Expected status 403 for wrong old PIN, got %d", w.Code) + } + + // Verify original PIN still works + loginReqBody := `{"pin": "1234"}` + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + h.handleLogin(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected original PIN to still work after failed change, got %d", w.Code) + } +} + +func TestHandler_ChangePIN_Unauthenticated(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Setup PIN first + reqBody := `{"pin": "1234"}` + req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSetup(w, req) + + // Try to change PIN without authentication (no cookie) + changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}` + req := httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + // Use RequireAuth wrapper + wrappedHandler := h.RequireAuth(h.handleChangePIN) + wrappedHandler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401 for unauthenticated request, got %d", w.Code) + } +} + +func TestHandler_ChangePIN_InvalidNewPIN(t *testing.T) { + tests := []struct { + name string + oldPIN string + newPIN string + wantStatus int + }{ + {"too short", "1234", "123", http.StatusBadRequest}, + {"too long", "1234", "123456789", http.StatusBadRequest}, + {"non-numeric", "1234", "abcd", http.StatusBadRequest}, + {"mixed", "1234", "12a4", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + h, err := NewHandler(Config{DB: db}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Setup PIN first + reqBody := `{"pin": "1234"}` + req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSetup(w, req) + + // Get session cookie + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == "spaxel_session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("Session cookie not set") + } + + // Try to change with invalid new PIN + changeReqBody := `{"old_pin": "` + tt.oldPIN + `", "new_pin": "` + tt.newPIN + `"}` + req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + h.handleChangePIN(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +}