Third link of the bf-34lwt split. Pins down that the bf-3hji 'no REJECT,
sim nodes accepted' state is a side effect of the default 24h migration
window a fresh boot opens — not a token/auth fix. Tokens never enter the
admission decision: sim nodes are tokenless where checked (hello.Token
body field is empty; the X-Spaxel-Token header they set is unread per
bf-29wyl). The sole acceptor is the migration deadline.
Verified against current source:
- config.go:139 MigrationWindowHours defaults to 24 (config.go:43 field
comment '0 = disabled'); main.go:4495-4497 installs deadline=now+24h
only when >0, so a fresh boot opens the window.
- server.go:510-518 acceptance branch gates on server.go:515
!deadline.IsZero() && time.Now().Before(deadline) — tokenOK is
irrelevant here; tokenless node admitted as Unpaired.
- server.go:519-526 reject branch (else of 515) hits server.go:525
sendReject(conn,"invalid_token") + conn.Close(); sendReject writes
{"type":"reject","reason":"invalid_token"} (server.go:841-845).
Conclusion: 'no reject' is a 24h-window MASK. REJECT returns when
SPAXEL_MIGRATION_WINDOW_HOURS=0 (deadline never set -> IsZero() ->
reject) or uptime > 24h (Before(deadline) false -> reject). Real fix is
on the token/supply side, not the window.
Carries the adjacent bf-3hji section it builds on ('the bf-3hji no-reject
finding above'), also uncommitted docs. No Go code touched; build + unit
tests + go vet clean.
Co-Authored-By: Claude <noreply@anthropic.com>
39 KiB
Spaxel Implementation Progress
Phase 1 — Foundation
Goal: Bare-minimum loop from ESP32 to browser. Zero-config with passive radar and mDNS from day one.
Status: COMPLETE
| Item | Status | Notes |
|---|---|---|
| ESP32 firmware skeleton | Done | See iteration 2 below |
| Passive radar support | Done | BSSID filter in csi.c |
| BLE scanning | Done | Core 0 concurrent with WiFi |
| Mothership WebSocket ingestion | Done | See iteration 1 below |
| Dashboard skeleton | Done | See iteration 3 below |
| Docker packaging | Done | See iteration 4 below |
Phase 2 — Signal Processing & Detection
Goal: Detect presence on a single link.
Status
| Item | Status | Notes |
|---|---|---|
| Phase sanitisation | Done | See iteration 5 below |
| Baseline system | Done | EMA with motion-gated updates |
| Motion detection | Done | deltaRMS, NBVI selection |
| Dashboard presence indicator | Done | Implemented in dashboard/live.html and dashboard/js/app.js |
| CSI recording buffer | Done | Disk-backed circular buffer — mothership/internal/recording/buffer.go |
| Adaptive sensing rate | Done | RateController (2 Hz idle / 50 Hz active) — mothership/internal/ingestion/ratecontrol.go |
Iteration Log
Iteration 5 — 2026-03-26
Completed: Signal processing package (phase sanitisation, baseline, motion detection)
Implemented the core signal processing pipeline in Go with:
-
Phase sanitization (
signal/phase.go):- Complex CSI computation: int8 I/Q → float64 complex → amplitude/phase
- RSSI normalization (AGC compensation):
norm = 10^((rssi_ref - rssi)/20) - Spatial phase unwrapping: correct 2π jumps between adjacent subcarriers
- Linear regression (OLS): fit
phase = a*k + bover data subcarriers - STO/CFO removal: residual phase = unwrapped - (slope × k + intercept)
- HT20 subcarrier map: 64 total, 46 data (excluding null, guard, pilot)
-
Baseline system (
signal/baseline.go):- EMA baseline per link per subcarrier:
baseline = α×amplitude + (1-α)×baseline - α = dt / (τ + dt) ≈ 0.0033 for dt=0.1s, τ=30s
- Motion-gated updates: only update when smoothDeltaRMS < 0.05
- Confidence scoring: 0.3 for stale baselines (>7 days), asymptotically → 1.0
- Snapshot/restore for SQLite persistence
- BaselineManager for multi-link coordination
- EMA baseline per link per subcarrier:
-
Motion detection (
signal/features.go):- NBVI (Normalized Bandwidth Variance Index):
Var(amp) / Mean(amp)² - Welford's online algorithm for numerically stable variance
- Top-16 subcarrier selection by NBVI score
- deltaRMS:
sqrt(mean((amp - baseline)²))over selected subcarriers - Exponential smoothing:
smooth = 0.3×raw + 0.7×prev - Motion threshold: smoothDeltaRMS > 0.02
- NBVI (Normalized Bandwidth Variance Index):
-
Link processor (
signal/processor.go):- LinkProcessor: ties together phase sanitization, baseline, motion detection
- ProcessorManager: manages per-link processors with thread-safe access
- GetAllMotionStates(): returns motion state for all links
- GetAllBaselines()/RestoreBaseline(): for SQLite persistence
Constants used (from plan):
- RSSIRefdBm = -30.0
- DefaultMotionThreshold = 0.05
- DefaultDeltaRMSThreshold = 0.02
- NBVITopCount = 16
- NBVIMinThreshold = 0.001
- DeltaRMSSmoothingAlpha = 0.3
Tests: 37 tests covering phase sanitization, baseline, NBVI, motion detection. All pass.
Files created:
mothership/internal/signal/
├── phase.go — Phase sanitization algorithms
├── phase_test.go — 15 tests for phase processing
├── baseline.go — EMA baseline management
├── baseline_test.go — 9 tests for baseline
├── features.go — NBVI selection, deltaRMS, motion detection
├── features_test.go — 13 tests for features
└── processor.go — LinkProcessor, ProcessorManager
Phase 2 Status: COMPLETE
All Phase 2 items implemented — CSI recording buffer (mothership/internal/recording/buffer.go) and adaptive sensing rate (mothership/internal/ingestion/ratecontrol.go) landed after this iteration.
Iteration 4 — 2026-03-26
Completed: Docker packaging
Implemented container deployment with:
-
Dockerfile: Multi-stage build for minimal production image
- Stage 1:
golang:1.23-bookwormbuilder with module caching - Stage 2:
distroless/static-debian12:nonrootruntime (no shell, runs as UID 65532) - CGO_ENABLED=0 build (pure-Go SQLite compatible)
- Dashboard static files copied to
/dashboard/ - Firmware directory as volume mount point (
/firmware/) - Built-in healthcheck via wget
- Stage 1:
-
docker-compose.yml: Production-ready orchestration
network_mode: hostfor mDNS multicast discovery (required for ESP32 nodes)- Volume mounts:
spaxel-datafor persistence,./firmwarefor OTA binaries - Environment variables: TZ, mDNS config, optional MQTT
- Resource limits: 512m RAM, 2 CPUs (scalable to 1g for 16+ nodes)
- 35s stop_grace_period for graceful shutdown
- Ulimits: 4096/8192 file descriptors for node connections
- Traefik labels (disabled by default, enable for TLS/proxy)
-
Supporting files:
VERSION— 0.1.0 for image tagging.dockerignore— Excludes docs, build artifacts, IDE files
Key design decisions:
- Host networking is REQUIRED for mDNS — Docker bridge blocks multicast 224.0.0.251
- distroless image minimizes attack surface (no shell, no package manager)
- Firmware binaries not bundled — users mount their own
/firmwarevolume - Traefik labels included but disabled — enable for production TLS
Files created:
Dockerfile
docker-compose.yml
VERSION
.dockerignore
Phase 1 Status: COMPLETE
All Phase 1 items implemented:
- ✅ ESP32 firmware skeleton
- ✅ Passive radar support
- ✅ BLE scanning
- ✅ Mothership WebSocket ingestion
- ✅ Dashboard skeleton
- ✅ Docker packaging
Next: Phase 2 — Signal Processing (baseline, deltaRMS, Fresnel zones)
Iteration 3 — 2026-03-26
Completed: Dashboard skeleton with 3D visualization
Implemented the web dashboard in vanilla JS + Three.js with:
-
Static file structure:
dashboard/directory with HTML/JSindex.html— Dark theme UI with status bar, node/link panels, amplitude chartjs/app.js— Main application (~350 lines)
-
3D Scene (Three.js):
- Ground grid (10×10m, 20 divisions)
- OrbitControls for pan/zoom/rotate with damping
- Axes helper for orientation
- Responsive canvas with devicePixelRatio support
- FPS counter in status bar
-
WebSocket connection (
/ws/dashboard):- Auto-reconnect with 3s backoff
- JSON message handling for state, node events, link events
- Binary CSI frame parsing (24-byte header + I/Q payload)
- Connection status indicator (green/red dot)
-
Node/Link panels:
- Live list of connected nodes with MAC, firmware, chip
- Active links with node:peer MAC pairs
- Click-to-select for amplitude chart display
-
Amplitude chart (Canvas 2D):
- 64-bar visualization of subcarrier amplitudes
- Real-time update from selected link's CSI frames
- Color gradient based on amplitude intensity
- Channel and RSSI display overlay
-
Dashboard package (Go):
internal/dashboard/hub.go— Hub for client management and broadcastinginternal/dashboard/server.go— WebSocket handler with ping/pong keepaliveinternal/dashboard/hub_test.go— 5 tests for hub operations- IngestionState interface for querying node/link state
- CSIBroadcaster interface implementation
-
Main.go updates:
- Static file serving with SPA fallback
- Dashboard WebSocket endpoint at
/ws/dashboard - Auto-discovery of dashboard directory
- Wiring between ingestion and dashboard packages
Dependencies used (frontend):
- Three.js r128 (CDN)
- OrbitControls (CDN)
Tests: 5 new tests for dashboard hub. All 27 tests pass.
Files created:
dashboard/
├── index.html
└── js/
└── app.js
mothership/internal/dashboard/
├── hub.go
├── hub_test.go
└── server.go
Remaining for Phase 1:
- Docker packaging
Iteration 2 — 2026-03-26
Completed: ESP32-S3 firmware skeleton
Implemented the full ESP32 firmware in ESP-IDF C with:
-
Project structure: Standard ESP-IDF layout with
firmware/root- Top-level
CMakeLists.txt,sdkconfig.defaults,partitions.csv(factory + OTA slots) main/component with 5 source modules
- Top-level
-
State machine (main.c): 7-state node lifecycle
- BOOT → WIFI_CONNECTING → MOTHERSHIP_DISCOVERY → CONNECTED
- Degraded states: WIFI_LOST, MOTHERSHIP_UNAVAILABLE, CAPTIVE_PORTAL
- Exponential backoff on WiFi failures (1s → 30s max)
- 10-failure threshold before captive portal
-
WiFi module (wifi.c/h):
- STA connection with exponential backoff
- mDNS discovery for
_spaxel._tcp.localwith fallback to cached IP - Captive portal AP mode (
spaxel-XXXX) with HTTP config page - Event-driven connection state via FreeRTOS event group
-
WebSocket client (websocket.c/h):
- Bidirectional communication on single connection
- Binary CSI frame transmission (24-byte header + I/Q payload)
- JSON message handling: hello, health, ble, ota_status upstream
- Downstream command parsing: role, config, ota, reboot, identify, reject
- OTA download task with progress reporting and automatic reboot
-
CSI capture (csi.c/h):
- WiFi promiscuous mode with CSI callback
- Queue-based processing (32-frame buffer)
- Passive mode BSSID filtering for radar
- On-device amplitude variance tracking for motion hints (Welford's algorithm)
- TX task for active probing
-
BLE scanner (ble.c/h):
- Passive BLE scanning on Core 0 (concurrent with WiFi)
- Device cache (60 entries) with name and manufacturer data parsing
- 5-second reporting interval via WebSocket
- GAP event handling for advertisement processing
-
NVS persistence: Full schema with 15 keys
- WiFi credentials, node ID/token, mothership config
- Role/rate persistence for degraded mode operation
- Schema versioning for migration support
Files created:
firmware/
├── CMakeLists.txt
├── sdkconfig.defaults
├── partitions.csv
└── main/
├── CMakeLists.txt
├── spaxel.h
├── main.c
├── wifi.h / wifi.c
├── websocket.h / websocket.c
├── csi.h / csi.c
└── ble.h / ble.c
Phase 1 Status: COMPLETE
All Phase 1 items implemented:
- ✅ ESP32 firmware skeleton
- ✅ Passive radar support
- ✅ BLE scanning
- ✅ Mothership WebSocket ingestion
- ✅ Dashboard skeleton
- ✅ Docker packaging
Next: Phase 2 — Signal Processing (baseline, deltaRMS, Fresnel zones)
Iteration 1 — 2026-03-26
Completed: Mothership WebSocket ingestion server
Implemented the core ingestion server in Go with:
- Module structure:
mothership/withcmd/mothership/entrypoint andinternal/ingestion/package - WebSocket endpoint:
/ws/nodeaccepts bidirectional connections from ESP32 nodes - Binary frame parsing: 24-byte header + variable payload, per spec in plan.md
- Validation: min/max length, payload size match, channel validity (1-14), subcarrier limit (128)
- Malformed frame tracking with warn/close thresholds (100/1000 per minute)
- JSON message handling: Parses hello, health, ble, motion_hint, ota_status
- Per-link ring buffers: 256-sample circular buffers keyed by
nodeMAC:peerMAC - Connection lifecycle: Node registration via hello, ping/pong keepalive (30s/60s), graceful shutdown
- mDNS advertisement:
_spaxel._tcp.localvia github.com/hashicorp/mdns - Role/config push: Sends initial
rxrole and 20 Hz config on connect - Health endpoint:
GET /healthzreturns{"status":"ok","version":"..."}
Dependencies used:
github.com/go-chi/chi— HTTP routinggithub.com/gorilla/websocket— WebSocket servergithub.com/hashicorp/mdns— mDNS advertisement
Tests: 22 tests covering frame parsing, JSON messages, and ring buffer operations. All pass.
Files created:
mothership/
├── cmd/mothership/main.go
├── go.mod
├── go.sum
└── internal/ingestion/
├── frame.go
├── frame_test.go
├── message.go
├── message_test.go
├── ring.go
├── ring_test.go
└── server.go
Phase 7 — Learning & Analytics
Goal: The system gets smarter over time. User feedback drives improvement.
Status: COMPLETE
| Item | Status | Notes |
|---|---|---|
| Detection feedback loop | Done | Thumbs up/down on detections |
| Self-improving localization | Done | BLE ground truth drives weight refinement |
| Presence prediction | Done | See iteration 6 below |
| Sleep quality monitoring | Done | Breathing analysis + motion scoring |
| Crowd flow visualization | Done | Trajectory accumulation into directional flow map |
| Anomaly detection & security mode | Done | 7-day pattern learning |
Iteration 6 — 2026-04-09
Completed: Presence prediction for Home Assistant integration
Implemented the full presence prediction system with:
-
Per-person transition probability tracking (
prediction/model.go):zone_transitions_historytable with all zone transitions (person_id, from_zone, to_zone, hour_of_week, dwell_duration)transition_probabilitiestable with Laplace-smoothed probabilitiesdwell_timestable with mean/stddev dwell time per person/zone/hourperson_zone_entrytable for tracking current person positions- Zone transition recording via
PersonZoneChange()method - Automatic probability recomputation with Laplace smoothing
- Dwell time statistics with mean/stddev computation
-
Per-zone occupancy patterns (
prediction/accuracy.go):zone_occupancy_patternstable with occupancy_prob per zone/hour_of_weekzone_occupancy_historytable tracking entries/exits with timestampsrecorded_predictionstable for tracking prediction accuracyaccuracy_statstable with rolling 7-day accuracy metrics- Zone occupancy pattern computation from historical data
- Pattern-based occupancy prediction at target time
-
Time-slot based predictions (
prediction/horizon.go):- Monte Carlo simulation with 1000 runs for probabilistic predictions
- Multi-step path simulation accounting for dwell times
- Normal distribution sampling for dwell time variability
- Horizon predictions at 5, 15, and 30 minutes
- Returns probability distribution over all zones
- Confidence scoring based on simulation agreement
-
HA sensor exposure (
mqtt/client.go):PublishPredictionSensors()creates HA auto-discovery configsUpdatePredictionState()publishes current predictions to MQTT- Three sensors per person:
sensor.spaxel_<person>_predicted_zone- zone namesensor.spaxel_<person>_prediction_confidence- percentagesensor.spaxel_<person>_transition_minutes- estimated minutes
- Topics follow HA discovery pattern:
homeassistant/sensor/.../config
-
REST API endpoints (
api/prediction.go):GET /api/predictions- Get current predictions for all peopleGET /api/predictions?person=<id>&horizon=<min>- Filtered predictionsGET /api/predictions/stats- Transition count, data age, model readinessPOST /api/predictions/recompute- Force probability recomputationGET /api/predictions/accuracy- Per-person accuracy statsGET /api/predictions/accuracy/overall- Overall system accuracyGET /api/predictions/accuracy/{personID}- Person-specific accuracyGET /api/predictions/horizon- Monte Carlo horizon predictionsGET /api/predictions/horizon/{personID}- Person-specific horizon predictionGET /api/predictions/patterns/zones- Zone occupancy patternsGET /api/predictions/probabilities/{personID}- Transition probabilitiesGET /api/predictions/samples/{personID}/zone/{zoneID}- Sample counts
-
Main application wiring (
cmd/mothership/main.go):- Prediction module initialization (lines 492-534)
- Zone transition recording on portal crossings (lines 1579-1581)
- Provider wiring for zones, people, positions (lines 1831-1863)
- MQTT client integration for prediction publishing (lines 1846-1863)
- Periodic prediction update loop every 60 seconds (lines 1866-1888)
- Periodic prediction evaluation every 30 seconds (lines 1931-1983)
- REST API endpoint registration (lines 2527-2888)
Constants and thresholds:
- MinimumDataAge = 7 days (168 hours) before predictions activate
- MinimumSamplesPerSlot = 3 observations per time slot
- PredictionHorizon = 15 minutes (default)
- MonteCarloRuns = 1000 simulations
- TargetAccuracy = 75% at 15-minute horizon
Accuracy tracking:
- Records predictions when made (personID, currentZone, predictedZone, confidence, horizon)
- Evaluates pending predictions when target time is reached
- Compares predicted zone vs actual zone
- Computes rolling 7-day accuracy percentage
- Reports "meets_target" when accuracy ≥ 75% and min predictions threshold met
Model learning:
- Observations recorded every 5 minutes per person/zone
- EMA update:
p_new = p_old + α × (obs - p_old)where α = 0.03 - Cold start: 7 days of data required for model readiness
- Slot ready when sample_count ≥ 3
- Automatic recomputation triggered on zone transitions
Files created/modified:
mothership/internal/prediction/
├── model.go — ModelStore, transition probabilities, dwell times
├── predictor.go — Predictor for presence prediction
├── horizon.go — HorizonPredictor with Monte Carlo simulation
├── accuracy.go — AccuracyTracker for prediction evaluation
├── history.go — HistoryUpdater for zone transition recording
├── adapter.go — Provider adapters for zones, people, positions
├── model_test.go — Tests for model store operations
├── predictor_test.go — Tests for prediction logic
├── accuracy_test.go — Tests for accuracy tracking
└── horizon_test.go — Tests for horizon predictions
mothership/internal/api/
├── prediction.go — REST API handlers for predictions
└── prediction_test.go — Tests for prediction API endpoints
mothership/internal/mqtt/
└── client.go — MQTT client with prediction sensor publishing
Acceptance criteria met:
- ✅ Per-person transition probability tracking - Full implementation with Laplace smoothing
- ✅ Per-zone occupancy patterns - Historical patterns with probability computation
- ✅ Time-slot based predictions - Monte Carlo simulation at configurable horizons
- ✅ HA sensor exposure for predicted states - Full auto-discovery with 3 sensors per person
- ✅ >75% accuracy at 15-minute horizon - AccuracyTracker with rolling 7-day window
Phase 7 Status: COMPLETE
Phase 8 — Analysis & Developer Tools
Goal: Deep debugging, system tuning, and detection explainability.
Status: COMPLETE
| Item | Status | Notes |
|---|---|---|
| Activity timeline (Component 27) | Done | Universal event stream with tap-to-time-travel |
| Detection explainability (Component 28) | Done | X-ray overlay, per-link contributions, BLE match details |
| Time-travel debugging | Done | Pause live, scrub timeline, parameter tuning overlay |
| Pre-deployment simulator | Done | Virtual space + nodes + walkers, GDOP overlay |
CSI simulator (cmd/sim) |
Done | Go CLI for hardware-free testing |
| Fresnel zone debug overlay | Done | Toggle wireframe ellipsoids in 3D scene |
Implementation Summary
Activity timeline (internal/timeline/):
Storagesubscribes to EventBus and writes events to SQLite asynchronously via buffered queue- Handles all event types: detections, zone transitions, alerts, system events, learning milestones
- WebSocket push of new events to dashboard clients in real-time
- REST API:
GET /api/eventswith cursor-based pagination, filters (type/zone/person/time), and FTS5 search - Frontend:
sidebar-timeline.js— virtualized list, tap-to-time-travel, inline feedback buttons
Detection explainability (internal/explainability/):
Handlermaintains per-blob explanation data updated by the fusion engine- Per-link contribution table: deltaRMS, Fresnel zone number, learned weight, contribution amount
- BLE match details: per-node RSSI, triangulation position, confidence
- Fresnel zone ellipsoid geometry for 3D visualization
- Frontend:
explainability.js— X-ray overlay dims non-contributors, glowing contributing links
Time-travel debugging (internal/replay/):
- Append-only
csi_replay.binwith per-frame records (recv_time_ms + raw CSI binary) Workermanages replay sessions: play/pause/seek/speed control- Parameter tuning:
PATCH /api/replay/paramsre-runs pipeline on buffered data at max speed - "Apply to Live" copies tuned params to the running pipeline
- Dashboard: timeline scrubber integrated with activity timeline events
Pre-deployment simulator (internal/simulator/):
Spacedefines room geometry with wall segments and material propertiesNodeSetmanages virtual TX/RX node placementWalkerSetsimulates random-walk or path-following persons- Two-ray propagation model with path loss + wall penetration + first-order reflections
- GDOP computation per cell using Fisher information matrix
- REST API at
/api/simulator/*for space/node/walker management and GDOP computation
CSI simulator CLI (cmd/sim/):
- Connects to mothership as virtual nodes, sends synthetic CSI binary frames
- Walker random-walk model with Gaussian velocity updates, wall reflection
- Amplitude/phase generated from propagation model with Gaussian noise injection
- Optional BLE advertisement simulation (
--bleflag) - Verified authentication (exits non-zero on
{type:"reject"}) - Integration test support: polls
GET /api/blobsfor blob count assertions
Fresnel zone debug overlay (dashboard/js/fresnel.js):
- Toggle-able wireframe ellipsoids between active TX/RX link pairs
- Ellipsoid geometry computed from TX/RX positions and Fresnel zone number
- Color-coded by zone number (zone 1 = bright, outer zones = dimmer)
- Toolbar button integration with existing layers panel
Files created/modified:
mothership/internal/timeline/ — timeline.go, timeline_test.go, handler.go, buffer_adapter.go
mothership/internal/explainability/ — handler.go
mothership/internal/replay/ — engine.go, engine_test.go, pipeline.go, pipeline_test.go,
session.go, store.go, store_test.go, types.go, worker.go
mothership/internal/simulator/ — accuracy.go, engine.go, gdop.go, handler.go, node.go,
physics.go, propagation.go, registry_bridge.go, session.go,
space.go, virtual_state.go, walker.go + test files
mothership/cmd/sim/ — main.go, generator.go, verify.go, walker.go, main_test.go
mothership/internal/api/ — replay.go, replay_test.go, simulator.go
dashboard/js/ — explainability.js, replay.js, fresnel.js, sidebar-timeline.js
Phase 8 Status: COMPLETE
Phase 9 — UX Polish & Accessibility
Goal: Accessible to every household member. Power user efficiency. Always-on ambient display.
Status: COMPLETE
| Item | Status | Notes |
|---|---|---|
| Simple mode (progressive disclosure) | Done | Card-based mobile-first UI, room cards, activity feed |
| Ambient dashboard mode (Component 31) | Done | /ambient route, Canvas 2D, time-of-day palette, auto-dim |
| Spatial quick actions (Component 32) | Done | Right-click / long-press context menus on 3D elements |
| Command palette (Component 34) | Done | Ctrl+K / Cmd+K, fuzzy search, navigate time, execute commands |
| Morning briefing (Component 35) | Done | Daily summary card on first open, push notification support |
| Guided troubleshooting (Component 36) | Done | Proactive contextual help, dismissible, never repeats |
| Mobile-responsive expert mode | Done | Touch orbit/pan/zoom, hamburger menu, FXAA on mobile |
| Fleet status page | Done | Full table with OTA progress, bulk actions, camera fly-to |
Implementation Summary
Simple mode (dashboard/simple.html, dashboard/js/simple.js):
- Card-based layout: one room card per zone with occupancy count and person names
- Activity feed as chronological event list from timeline REST API
- Alert banner for fall detection, anomaly alerts, system warnings
- Sleep summary card for morning view
- No 3D scene — designed for non-technical household members
- Toggle button in toolbar; per-user default stored in
localStorage
Ambient dashboard (dashboard/ambient.html, dashboard/js/ambient.js):
- Served at
/ambient— separate lightweight route for wall-mounted tablets - Canvas 2D renderer: colored circles for people, zone labels, soft glow effects
- Time-of-day palette: morning (cool), day (neutral), evening (amber), night (very dim)
- Auto-dim when house empty for 30+ min; gentle fade-in on person detection
- Alert mode: pulsing red border + large text + action buttons on fall/security events
- Morning briefing integration: briefing text shown on first detection, fades after 30 s
- Reconnect backoff handles WebSocket drops gracefully
Spatial quick actions (dashboard/js/quick-actions.js):
- Three.js Raycaster determines target under cursor (person/node/zone/portal/trigger/empty)
- Context menu component renders appropriate options per target type
- Per-blob: "Who is this?", "Why?", "Follow" camera, "Create automation", "Mark incorrect"
- Per-node: "Diagnostics", "Blink LED", "Reposition", "Update firmware", "Show links"
- Per-empty: "What happened here?", "Add trigger zone", "Add virtual node", "Coverage quality"
- Per-zone: "Zone history", "Edit zone", "Create automation", "Crowd flow"
- "Follow" mode: camera smoothly tracks person, auto-orbiting to keep them centered
Command palette (dashboard/js/command-palette.js):
- Ctrl+K (Cmd+K on Mac) opens universal search and command interface
- Fuzzy matching across zones, persons, nodes, settings, help topics, events
- Navigate time: "last night 2am", "yesterday kitchen", "this morning"
- Execute commands: "update all nodes", "re-baseline kitchen", "arm security"
- Help: "help fall detection", "why false positive", "troubleshoot kitchen"
- Recently used commands surface first; expert mode only
Morning briefing (internal/briefing/, dashboard/js/briefing.js):
Generatorassembles briefing in priority order from sleep, events, anomalies, system health- Priority blocks: critical alerts → sleep summary → who is home → overnight anomalies → system health → predictions → learning progress
- Degenerate case: "All quiet last night. All systems healthy."
- Stored in
briefingsSQLite table (one per day per person) - REST API:
GET /api/briefing,GET /api/briefing/current - Dashboard: card overlay on first open, dismissible, slides away after 10 s
Guided troubleshooting (internal/guidedtroubleshoot/, dashboard/js/troubleshoot.js):
- Trigger conditions: detection quality drops, repeated setting changes, node offline >2 h
- Repeated-edit detection: per-key counter in memory; hint delivered in
GET /api/settingsresponse as"repeated_edit_hint": true - Proactive diagnostic flow: check connectivity → show link health → suggest repositioning → offer re-baseline
- First-time feature tooltips shown once, dismissed on click, stored in
localStorage - Post-feedback explanations in timeline after incorrect/missed detection feedback
Mobile-responsive expert mode (dashboard/js/mobile.js + CSS):
- Touch orbit (one finger), pan (two fingers), pinch-zoom (two fingers)
- FXAA anti-aliasing replaces MSAA on mobile (better performance on low-power GPUs)
- Hamburger menu for toolbar panels on small screens
- Viewport meta tag with
viewport-fit=coverfor notch-aware layout - CSS Grid layout collapses gracefully on narrow viewports
Fleet status page (dashboard/fleet.html, dashboard/js/fleet-page.js):
- Full table with sortable columns: Name, MAC, Role, Position (fly-to), Firmware, RSSI, Status, Uptime, Actions
- OTA progress bar per node during updates (PENDING → DOWNLOADING → REBOOTING → VERIFIED / FAILED)
- Bulk actions: Update All (rolling OTA), Re-baseline All, Export Config, Import Config
- Camera fly-to: clicking position coordinates in table jumps 3D camera to that node
- Responsive layout works on both desktop and tablet
Files created/modified:
dashboard/simple.html — Simple mode page
dashboard/ambient.html — Ambient dashboard page
dashboard/fleet.html — Fleet status page
dashboard/js/simple.js — Simple mode WebSocket + card rendering
dashboard/js/simplemode.js — Simple mode toggle and state management
dashboard/js/ambient.js — Ambient dashboard controller
dashboard/js/ambient_renderer.js — Canvas 2D renderer with time-of-day palette
dashboard/js/ambient_briefing.js — Morning briefing integration for ambient
dashboard/js/quick-actions.js — Context menu for 3D scene elements
dashboard/js/command-palette.js — Universal search and command interface
dashboard/js/briefing.js — Morning briefing card overlay
dashboard/js/troubleshoot.js — Guided troubleshooting UI
dashboard/js/guided-help.js — First-time feature discovery tooltips
dashboard/js/mobile.js — Mobile touch controls and FXAA
dashboard/js/fleet-page.js — Fleet status table and bulk actions
mothership/internal/briefing/ — briefing.go, briefing_test.go, scheduler.go,
dashboard_adapter.go, notify_adapter.go
mothership/internal/guidedtroubleshoot/ — discovery.go, notifier.go, quality.go,
linkweather.go, reposition.go, alert_handler.go
mothership/internal/api/ — briefing.go, briefing_test.go, guided.go
Phase 9 Status: COMPLETE
Hardware-Free Runtime Boot
Established 2026-07-07 (bead bf-3zll, decomposed from bf-40hc "hardware-free
runtime blob path"). The mothership builds and boots to a healthy state with
no ESP32 hardware and no Docker — the first link in the hardware-free
runtime chain. The canonical path is exercised by the e2e harness
(mothership/tests/e2e/e2e_test.go, TestHarness.Start); the commands below
are the human-runnable equivalent.
Build (from a clean checkout, no build tags)
cd mothership # module github.com/spaxel/mothership
go build -o /tmp/spaxel-mothership ./cmd/mothership
No -tags=embed is needed locally: that tag embeds cmd/mothership/dashboard/,
which is only populated during the Docker build (COPY dashboard/ ...). A plain
build omits the bundled dashboard ([WARN] Dashboard directory not found: /dashboard,
harmless) — /healthz and all /api/* endpoints work regardless.
Boot to /healthz
DATADIR=$(mktemp -d /tmp/spaxel-data-XXXX)
SPAXEL_BIND_ADDR=127.0.0.1:8080 \
SPAXEL_DATA_DIR="$DATADIR" \
SPAXEL_LOG_LEVEL=info \
TZ=UTC \
/tmp/spaxel-mothership &
# Verify (reaches ok in <1s; budget is ~15s per HealthTimeout):
curl -fsS http://localhost:8080/healthz
# -> {"status":"ok","uptime_s":0,"version":"dev","nodes_online":0,"db":"ok","shedding_level":0}
# Stop:
kill %1 # SIGINT triggers the ordered 30s graceful shutdown
Verified behavior
/healthzreturns{"status":"ok",...}in ~230 ms with the process alive.- All 7 startup phases complete (
[READY] All 7 phases completed in 231ms). - No panic/crash in stderr. Two benign WARN lines are expected on first boot:
the process's own startup self-probe can race the listener bind
(
Health check failed: ... connection refused (continuing anyway)) and the dashboard is unbundled without theembedtag.
Hardware-Free Simulator → CSI Streaming (no reject)
Established 2026-07-07 (bead bf-3hji, decomposed from bf-40hc; second link —
builds on the bf-3zll healthy-mothership boot above). With the mothership
healthy, spaxel-sim connects a 4-node fleet and streams synthetic CSI without
being rejected. Canonical path: TestHarness.RunSimulator in
mothership/tests/e2e/e2e_test.go; the commands below are the human-runnable
equivalent.
Build the simulator (from the mothership module root)
cd mothership # module github.com/spaxel/mothership
go build -o /tmp/spaxel-sim ./cmd/sim
./cmd/sim from the module root resolves to mothership/cmd/sim (the in-module
simulator that supports --ble/--seed/--scenario and the WebSocket hello +
CSI streaming path). It is the same binary the e2e harness builds
(exec.Dir = mothership/, ./cmd/sim).
Run against the healthy mothership (exact command)
/tmp/spaxel-sim \
--mothership ws://localhost:8080/ws/node \
--nodes 4 --walkers 1 --rate 20 --duration 30 --ble --seed 42
The sim auto-provisions a node token (POST /api/provision, falling back to a
synthetic HMAC token) and presents it as X-Spaxel-Token on each WS dial. On a
fresh boot the migration window is open, so the token-bearing virtual nodes are
accepted (no reject).
Verified behavior (4 nodes, 1 walker, 20 Hz, 30 s, --ble, --seed 42)
- All 4 nodes connect and receive roles (
tx/tx/rx/rx); sim exits 0. - No REJECT, no HTTP 401/403 anywhere — 0
rejectlines in the sim log and 0 auth/policy-rejection lines in the mothership log. The mothership logs[INFO] Node connected: MAC=AA:BB:CC:00:00:0[0-3] firmware=sim-1.0.0for all four and the fleet optimises roles (coverage 0.0% -> 23.1%). - frames/s > 0:
[SIM] Statsreportsfps=238.8(10s) ->239.6(30s); finalFrames sent: 7194, Average FPS: 239.5(= 12 node-pairs × 20 Hz). - /api/nodes lists the sim nodes online mid-run:
/healthzreportsnodes_online: 4;/api/nodesreturns 4 rows with freshlast_seen_atand a zerowent_offline_at(i.e. online), at real perimeter positions(0,0,2)/(5,0,2)/(5,5,2)/(0,5,2).
Out of scope (tracked separately)
- The sim reports
blobs detected: 0. Blob production is the next link in the chain — the fusionSetNodePositionwiring gap (bf-4q5w / IO-6 hard-gate). This bead proves connect + stream + no-reject, which is green; the blob gate is deliberately RED until bf-4q5w and is not weakened here.
bf-2hdbg — The migration window (not tokens) is what accepts sim nodes
Established 2026-07-07 (bead bf-2hdbg, third link of the bf-34lwt split; builds
on the bf-3hji "no reject" finding above). The bf-3hji "no REJECT, sim nodes
accepted" result is not a real fix — it is a side effect of the 24h
migration window that a fresh boot opens. Tokens have nothing to do with it:
sim nodes are genuinely tokenless from the validator's perspective (the token
they set is the header, which is the dead path per bf-29wyl; the validator
checks only hello.Token in the message body per bf-5ig3e, and the sim's hello
body carries no token field). The only thing admitting them is the migration
deadline. This pins down exactly when REJECT fires.
Sim nodes are tokenless to the validator
- The sim's hello body (
mothership/cmd/sim/main.go:652-664) carriestype/mac/firmware_version/capabilities/chip/flash_mb/uptime_ms/wifi_rssi/ip/pos_*and notokenfield. The token is set only as theX-Spaxel-TokenHTTP header (mothership/cmd/sim/main.go:633) — the unread header path. - The validator checks the body field only —
mothership/internal/ingestion/server.go:513:tokenOK := hello.Token != "" && validator(hello.MAC, hello.Token), wherehello.Tokenmapsjson:"token,omitempty"(message.go:22). With no body token,tokenOKisfalsefor every sim node, unconditionally.
Acceptance is the migration window, nothing else
-
mothership/internal/ingestion/server.go:510-518is the acceptance branch:510: deadline := s.migrationDeadline ... 513: tokenOK := hello.Token != "" && validator(hello.MAC, hello.Token) 514: if !tokenOK { 515: if !deadline.IsZero() && time.Now().Before(deadline) { 516: log.Printf("[WARN] Node %s connected without valid token (migration window open until %s)", 517: hello.MAC, deadline.Format(time.RFC3339)) 518: nc.Unpaired = trueThe
tokenOKvalue is irrelevant to admission here — a tokenless node is admitted as long as line 515 holds (deadlineset AND now-before-deadline). That is the bf-3hji "no reject" path: the node is accepted and flaggedUnpaired. -
The window defaults to 24h.
mothership/internal/config/config.go:139cfg.MigrationWindowHours = 24(default before env override), field comment atconfig.go:43"default 24, 0 = disabled". On a fresh boot the deadline is computed and installed atmothership/cmd/mothership/main.go:4495-4497:4495: if cfg.MigrationWindowHours > 0 { 4496: deadline := time.Now().Add(time.Duration(cfg.MigrationWindowHours) * time.Hour) 4497: ingestSrv.SetMigrationDeadline(deadline)Fresh boot + default 24 → deadline = now+24h → line 515 true for 24h → sim nodes accepted as
Unpaired, no reject (matches bf-3hji). Tokens never entered the picture.
REJECT fires only when the window is closed
-
The reject branch is the
elseof line 515 —mothership/internal/ingestion/server.go:519-526:519: } else { 520: if hello.Token == "" { 521: log.Printf("[WARN] Node %s rejected: missing token", hello.MAC) 522: } else { 523: log.Printf("[WARN] Node %s rejected: invalid token", hello.MAC) 524: } 525: s.sendReject(conn, "invalid_token") 526: conn.Close()sendRejectwrites{"type":"reject","reason":"invalid_token"}(mothership/internal/ingestion/server.go:841-845) and the caller closes the connection. -
This
elseis reached when line 515 is false, i.e. either:SPAXEL_MIGRATION_WINDOW_HOURS=0→main.go:4495guard is false →SetMigrationDeadlineis never called →migrationDeadlinestays at its zero value →deadline.IsZero()is true → line 515 false → reject branch (strict mode from startup, per theconfig.go:137comment "0 = disabled"); OR- uptime > 24h →
time.Now().Before(deadline)is false → line 515 false → reject branch.
In both cases every tokenless sim node logs "rejected: missing token" and is sent
invalid_tokenthen disconnected.
Conclusion — "no reject" is a 24h-window MASK, not a real fix
The current green "sim nodes connect without REJECT" state is entirely a
property of the default 24h migration window that a fresh boot opens. It is
not a fix to the token/auth path: sim nodes remain tokenless in the only
place checked (hello.Token), and the acceptance decision at server.go:515
is gated purely on the deadline, not on token validity. REJECT returns the
moment the window is closed — set SPAXEL_MIGRATION_WINDOW_HOURS=0, or let the
mothership run past 24h of uptime, and every tokenless sim node hits
server.go:525 sendReject(conn, "invalid_token") and is disconnected. The
real fix is on the token/supply side (provision a real token into the hello
body, or fix the dead header-read path), not on the window.