Status: Accepted Date: 2026-03-03 Deciders: @ruvnet Supersedes: None Related: ADR-034, ADR-036, ADR-039, ADR-040, ADR-041
The WiFi-DensePose sensing server (wifi-densepose-sensing-server) is a single-binary Axum server that receives ESP32 CSI frames via UDP, processes them through the RuVector signal pipeline, and serves both a web UI at /ui/ and a REST/WebSocket API. The UI provides tabs for live sensing visualization, model management, CSI recording, and training -- all designed to operate without external dependencies.
However, the UI's JavaScript expected several backend endpoints that were not yet implemented in the Rust server. Opening the browser console revealed persistent 404 errors for model, recording, and training API routes. Three categories of functionality were broken:
The Models tab calls GET /api/v1/models to list available .rvf model files, GET /api/v1/models/active to show the currently loaded model, POST /api/v1/models/load and POST /api/v1/models/unload to control the model lifecycle, and DELETE /api/v1/models/:id to remove models from disk. LoRA fine-tuning profiles are managed via GET /api/v1/models/lora/profiles and POST /api/v1/models/lora/activate. All of these returned 404.
The Recording tab calls POST /api/v1/recording/start and POST /api/v1/recording/stop to capture CSI frames to .csi.jsonl files for later training. GET /api/v1/recording/list enumerates stored sessions. DELETE /api/v1/recording/:id removes recordings. None of these were wired into the server's router.
The Training tab calls POST /api/v1/train/start to launch a background training run against recorded CSI data, POST /api/v1/train/stop to abort, and GET /api/v1/train/status to poll progress. Contrastive pretraining (POST /api/v1/train/pretrain) and LoRA fine-tuning (POST /api/v1/train/lora) endpoints were also unavailable. A WebSocket endpoint at /ws/train/progress streams epoch-level progress updates to the UI.
The web UI's sensingService singleton (which manages the WebSocket connection to /ws/sensing) was only started lazily when the user navigated to the Sensing tab (SensingTab.js:182). However, the Dashboard and Live Demo tabs both read sensingService.dataSource at load time — and since the service was never started, the status permanently showed "RECONNECTING" with no WebSocket connection attempt and no console errors. This silent failure affected the first-load experience for every user.
The Expo React Native mobile companion (ADR-034) had two integration defects:
- WebSocket URL builder:
ws.service.tshardcoded port3001for the WebSocket connection instead of using the same-origin port derived from the REST API URL. When the sensing server runs on a different port (e.g.,8080or3000), the mobile app could not connect. - Test configuration:
jest.config.jscontained atestPathIgnorePatternsentry that effectively excluded the entire test directory, causing all 25 tests to be skipped silently. - Placeholder tests: All 25 mobile test files contained
it.todo()stubs with no assertions, providing false confidence in test coverage.
Implement the complete model management, CSI recording, and training API directly in the sensing server's main.rs as inline handler functions sharing AppStateInner via Arc<RwLock<…>>. Wire all 14 routes into the server's main router so the UI loads without any 404 console errors. Start the sensing WebSocket service on application init (not lazily on tab visit) so Dashboard and Live Demo tabs connect immediately. Fix the mobile app WebSocket URL builder, test configuration, and replace placeholder tests with real implementations.
All 14 new handler functions are implemented directly in main.rs as async functions taking State<AppState> extractors, sharing the existing AppStateInner via Arc<RwLock<…>>. This avoids introducing new module files and keeps all API routes in one place alongside the existing sensing and pose handlers.
┌───────────────────────────────────────────────────────────────────────┐
│ Sensing Server (main.rs) │
│ │
│ Router::new() │
│ ├── /api/v1/sensing/* (existing — CSI streaming) │
│ ├── /api/v1/pose/* (existing — pose estimation) │
│ ├── /api/v1/models GET list_models (NEW) │
│ ├── /api/v1/models/active GET get_active_model (NEW) │
│ ├── /api/v1/models/load POST load_model (NEW) │
│ ├── /api/v1/models/unload POST unload_model (NEW) │
│ ├── /api/v1/models/:id DELETE delete_model (NEW) │
│ ├── /api/v1/models/lora/profiles GET list_lora (NEW) │
│ ├── /api/v1/models/lora/activate POST activate_lora (NEW) │
│ ├── /api/v1/recording/list GET list_recordings (NEW) │
│ ├── /api/v1/recording/start POST start_recording (NEW) │
│ ├── /api/v1/recording/stop POST stop_recording (NEW) │
│ ├── /api/v1/recording/:id DELETE delete_recording (NEW) │
│ ├── /api/v1/train/status GET train_status (NEW) │
│ ├── /api/v1/train/start POST train_start (NEW) │
│ ├── /api/v1/train/stop POST train_stop (NEW) │
│ ├── /ws/sensing (existing — sensing WebSocket) │
│ └── /ui/* (existing — static file serving) │
│ │
│ AppStateInner (new fields) │
│ ├── discovered_models: Vec<Value> │
│ ├── active_model_id: Option<String> │
│ ├── recordings: Vec<Value> │
│ ├── recording_active / recording_start_time / recording_current_id │
│ ├── recording_stop_tx: Option<watch::Sender<bool>> │
│ ├── training_status: Value │
│ └── training_config: Option<Value> │
│ │
│ data/ │
│ ├── models/ *.rvf files scanned at startup │
│ └── recordings/ *.jsonl files written by background task │
└───────────────────────────────────────────────────────────────────────┘
Routes are registered individually in the http_app Router before the static UI fallback handler.
| Method | Path | Request Body | Response | Description |
|---|---|---|---|---|
GET |
/api/v1/models |
-- | { models: ModelInfo[], count: usize } |
Scan data/models/ for .rvf files and return manifest metadata |
GET |
/api/v1/models/{id} |
-- | ModelInfo |
Detailed info for a single model (version, PCK score, LoRA profiles, segment count) |
GET |
/api/v1/models/active |
-- | ActiveModelInfo | { status: "no_model" } |
Active model with runtime stats (avg inference ms, frames processed) |
POST |
/api/v1/models/load |
{ model_id: string } |
{ status: "loaded", model_id, weight_count } |
Load model weights into memory via RvfReader, set model_loaded = true |
POST |
/api/v1/models/unload |
-- | { status: "unloaded", model_id } |
Drop loaded weights, set model_loaded = false |
POST |
/api/v1/models/lora/activate |
{ model_id, profile_name } |
{ status: "activated", profile_name } |
Activate a LoRA adapter profile on the loaded model |
GET |
/api/v1/models/lora/profiles |
-- | { model_id, profiles: string[], active } |
List LoRA profiles available in the loaded model |
| Method | Path | Request Body | Response | Description |
|---|---|---|---|---|
POST |
/api/v1/recording/start |
{ session_name, label?, duration_secs? } |
{ status: "recording", session_id, file_path } |
Create a new .csi.jsonl file and begin appending frames |
POST |
/api/v1/recording/stop |
-- | { status: "stopped", session_id, frame_count } |
Stop the active recording, write companion .meta.json |
GET |
/api/v1/recording/list |
-- | { recordings: RecordingSession[], count } |
List all recordings by scanning .meta.json files |
GET |
/api/v1/recording/download/{id} |
-- | application/x-ndjson file |
Download the raw JSONL recording file |
DELETE |
/api/v1/recording/{id} |
-- | { status: "deleted", deleted_files } |
Remove .csi.jsonl and .meta.json files |
| Method | Path | Request Body | Response | Description |
|---|---|---|---|---|
POST |
/api/v1/train/start |
TrainingConfig { epochs, batch_size, learning_rate, ... } |
{ status: "started", run_id } |
Launch background training task against recorded CSI data |
POST |
/api/v1/train/stop |
-- | { status: "stopped", run_id } |
Cancel the active training run via a stop signal |
GET |
/api/v1/train/status |
-- | TrainingStatus { phase, epoch, loss, ... } |
Current training state (idle, training, complete, failed) |
POST |
/api/v1/train/pretrain |
{ epochs?, learning_rate? } |
{ status: "started", mode: "pretrain" } |
Start self-supervised contrastive pretraining (ADR-024) |
POST |
/api/v1/train/lora |
{ profile_name, epochs?, rank? } |
{ status: "started", mode: "lora" } |
Start LoRA fine-tuning on a loaded base model |
WS |
/ws/train/progress |
-- | Streaming TrainingProgress JSON |
Epoch-level progress with loss, metrics, and ETA |
All three modules share the server's AppStateInner via Arc<RwLock<AppStateInner>>. New fields added to AppStateInner:
/// Runtime state for a loaded RVF model (None if no model loaded).
pub loaded_model: Option<LoadedModelState>,
/// Runtime state for the active CSI recording session.
pub recording_state: RecordingState,
/// Runtime state for the active training run.
pub training_state: TrainingState,
/// Broadcast channel for training progress updates (consumed by WebSocket).
pub train_progress_tx: broadcast::Sender<TrainingProgress>,Key design constraints:
- Single writer: Only one recording session can be active at a time. Starting a new recording while one is active returns an error.
- Single model: Only one model can be loaded at a time. Loading a new model implicitly unloads the previous one.
- Background training: Training runs in a spawned
tokio::task. Progress is broadcast via atokio::sync::broadcastchannel. The WebSocket handler subscribes to this channel. - Auto-stop: Recordings with a
duration_secsparameter automatically stop after the specified elapsed time.
The training pipeline is implemented entirely in Rust without PyTorch or tch dependencies. The pipeline:
- Loads data: Reads
.csi.jsonlrecording files fromdata/recordings/ - Extracts features: Subcarrier variance (sliding window), temporal gradients, Goertzel frequency-domain power across 9 bands, and 3 global scalar features (mean amplitude, std, motion score)
- Trains model: Regularised linear model via batch gradient descent targeting 17 COCO keypoints x 3 dimensions = 51 output targets
- Exports model: Best checkpoint exported as
.rvfcontainer usingRvfBuilder, stored indata/models/
This design means the sensing server is fully self-contained: a field operator can record CSI data, train a model, and load it for inference without any external tooling.
data/
├── models/ # RVF model files
│ ├── wifi-densepose-v1.rvf # Trained model container
│ └── wifi-densepose-v1.rvf # (additional models...)
└── recordings/ # CSI recording sessions
├── walking-20260303_140000.csi.jsonl # Raw CSI frames (JSONL)
├── walking-20260303_140000.csi.meta.json # Session metadata
├── standing-20260303_141500.csi.jsonl
└── standing-20260303_141500.csi.meta.json
Three defects were corrected in the Expo React Native mobile companion (ui/mobile/):
-
WebSocket URL builder (
src/services/ws.service.ts): The URL construction logic previously hardcoded port3001for WebSocket connections. This was changed to derive the WebSocket port from the same-origin HTTP URL, usingwindow.location.porton web and the configured server URL on native platforms. This ensures the mobile app connects to whatever port the sensing server is actually running on. -
Jest configuration (
jest.config.js): ThetestPathIgnorePatternsarray previously contained an entry that matched the test directory itself, causing Jest to silently skip all test files. The pattern was corrected to only ignorenode_modules/. -
Placeholder tests replaced: All 25 mobile test files contained only
it.todo()stubs. These were replaced with real test implementations covering:Category Test Files Coverage Utils format.test.ts,validation.test.tsNumber formatting, URL validation, input sanitization Services ws.service.test.ts,api.service.test.tsWebSocket connection lifecycle, REST API calls, error handling Stores poseStore.test.ts,settingsStore.test.ts,matStore.test.tsZustand state transitions, persistence, selector memoization Components BreathingGauge.test.tsx,HeartRateGauge.test.tsx,MetricCard.test.tsx,ConnectionBanner.test.tsxRendering, prop validation, theme compliance Hooks useConnection.test.ts,useSensing.test.tsHook lifecycle, cleanup, error states Screens LiveScreen.test.tsx,VitalsScreen.test.tsx,SettingsScreen.test.tsxScreen rendering, navigation, data binding
The alternative would be to run a separate Python training service and proxy requests. This was rejected for three reasons:
-
Single-binary deployment: WiFi-DensePose targets edge deployments (disaster response, building security, healthcare monitoring per ADR-034) where installing Python, pip, and PyTorch is impractical. A single Rust binary that handles sensing, recording, training, and inference is the correct architecture for field use.
-
Zero-configuration UI: The web UI is served by the same binary that exposes the API. When a user opens
http://server:8080/, everything works -- no additional services to start, no ports to configure, no CORS to manage. -
Data locality: CSI frames arrive via UDP, are processed for real-time display, and can simultaneously be written to disk for training. The recording module hooks directly into the CSI processing loop via
maybe_record_frame(), avoiding any serialization overhead or inter-process communication.
The mobile app's WebSocket failure was caused by the same root problem -- assumptions about server port layout that did not match reality. Fixing the server API without fixing the mobile client would leave a broken user experience. The test fixes were included because the placeholder tests masked the WebSocket URL bug during development.
- UI loads with zero console errors: All model, recording, and training tabs render correctly and receive real data from the server
- End-to-end workflow: Users can record CSI data, train a model, load it, and see pose estimation results -- all from the web UI without any external tools
- LoRA fine-tuning support: Users can adapt a base model to new environments via LoRA profiles, activated through the UI
- Mobile app connects reliably: The WebSocket URL builder uses same-origin port derivation, working correctly regardless of which port the server runs on
- 25 real mobile tests: Provide actual regression protection for utils, services, stores, components, hooks, and screens
- Self-contained sensing server: No Python, PyTorch, or external training infrastructure required
- Sensing server binary grows: The three new modules add approximately 2,000 lines of Rust to the sensing server crate, increasing compile time marginally
- Training is lightweight: The built-in training pipeline uses regularised linear regression, not deep learning. For production-grade pose estimation models, the full Python training pipeline (
wifi-densepose-train) with PyTorch is still needed. The in-server training is designed for quick field calibration, not SOTA accuracy. - File-based storage: Models and recordings are stored as files on the local filesystem (
data/models/,data/recordings/). There is no database, no replication, and no access control. This is acceptable for single-node edge deployments but not for multi-user production environments.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Disk fills up during long recording sessions | Medium | Medium | duration_secs auto-stop parameter; UI shows file size; manual DELETE endpoint |
| Concurrent model load/unload during inference causes race | Low | High | RwLock on AppStateInner serializes all state mutations; inference path acquires read lock |
| Training on insufficient data produces poor model | Medium | Low | Training API validates minimum frame count before starting; UI shows dataset statistics |
| JSONL recording format is inefficient for large datasets | Low | Low | Acceptable for field calibration (minutes of data); production datasets use the Python pipeline with HDF5 |
All 14 new handler functions were added directly to main.rs (~400 lines of new code). Key additions:
| Handler | Method | Path | Description |
|---|---|---|---|
list_models |
GET | /api/v1/models |
Scans data/models/ for .rvf files at startup, returns cached list |
get_active_model |
GET | /api/v1/models/active |
Returns currently loaded model or null |
load_model |
POST | /api/v1/models/load |
Sets active_model_id in state |
unload_model |
POST | /api/v1/models/unload |
Clears active_model_id |
delete_model |
DELETE | /api/v1/models/:id |
Removes model from disk and state |
list_lora_profiles |
GET | /api/v1/models/lora/profiles |
Scans data/models/lora/ directory |
activate_lora_profile |
POST | /api/v1/models/lora/activate |
Activates a LoRA adapter |
list_recordings |
GET | /api/v1/recording/list |
Scans data/recordings/ for .jsonl files with frame counts |
start_recording |
POST | /api/v1/recording/start |
Spawns tokio background task writing CSI frames to .jsonl |
stop_recording |
POST | /api/v1/recording/stop |
Sends stop signal via tokio::sync::watch, returns duration |
delete_recording |
DELETE | /api/v1/recording/:id |
Removes recording file from disk |
train_status |
GET | /api/v1/train/status |
Returns training phase (idle/running/complete/failed) |
train_start |
POST | /api/v1/train/start |
Sets training status to running with config |
train_stop |
POST | /api/v1/train/stop |
Sets training status to idle |
Helper functions: scan_model_files(), scan_lora_profiles(), scan_recording_files(), chrono_timestamp().
Startup creates data/models/ and data/recordings/ directories and populates initial state with scanned files.
| File | Change | Description |
|---|---|---|
ui/app.js |
Modified | Import sensingService and call sensingService.start() in initializeServices() after backend health check, so Dashboard and Live Demo tabs connect to /ws/sensing immediately on load instead of waiting for Sensing tab visit |
ui/services/sensing.service.js |
Comment | Updated comment documenting that /ws/sensing is on the same HTTP port |
| File | Change | Description |
|---|---|---|
ui/mobile/src/services/ws.service.ts |
Modified | buildWsUrl() uses parsed.host directly with /ws/sensing path instead of hardcoded port 3001 |
ui/mobile/jest.config.js |
Modified | testPathIgnorePatterns corrected to only ignore node_modules/ |
ui/mobile/src/__tests__/*.test.ts{x} |
Replaced | 25 placeholder it.todo() tests replaced with real implementations |
# 1. Start sensing server with auto source (simulated fallback)
cd rust-port/wifi-densepose-rs
cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto
# 2. Verify model endpoints return 200
curl -s http://localhost:3000/api/v1/models | jq '.count'
curl -s http://localhost:3000/api/v1/models/active | jq '.status'
# 3. Verify recording endpoints return 200
curl -s http://localhost:3000/api/v1/recording/list | jq '.count'
curl -s -X POST http://localhost:3000/api/v1/recording/start \
-H 'Content-Type: application/json' \
-d '{"session_name":"test","duration_secs":5}' | jq '.status'
# 4. Verify training endpoint returns 200
curl -s http://localhost:3000/api/v1/train/status | jq '.phase'
# 5. Verify LoRA endpoints return 200
curl -s http://localhost:3000/api/v1/models/lora/profiles | jq '.'
# 6. Open UI — check browser console for zero 404 errors
# Navigate to http://localhost:3000/ui/
# 7. Run mobile tests
cd ../../ui/mobile
npx jest --no-coverage
# 8. Run Rust workspace tests (must pass, 1031+ tests)
cd ../../rust-port/wifi-densepose-rs
cargo test --workspace --no-default-features- ADR-034: Expo React Native Mobile Application (mobile companion architecture)
- ADR-036: RVF Training Pipeline UI (training pipeline design)
- ADR-039: ESP32-S3 Edge Intelligence Pipeline (CSI frame format and processing tiers)
- ADR-040: WASM Programmable Sensing (Tier 3 edge compute)
- ADR-041: WASM Module Collection (module catalog)
crates/wifi-densepose-sensing-server/src/main.rs-- all 14 new handler functions (model, recording, training)ui/app.js-- sensing service early initialization fixui/mobile/src/services/ws.service.ts-- mobile WebSocket URL fix