fix: WebSocket race condition, data source indicators, auto-start pose detection (#96)

* feat: RVF training pipeline & UI integration (ADR-036)

Implement full model training, management, and inference pipeline:

Backend (Rust):
- recording.rs: CSI recording API (start/stop/list/download/delete)
- model_manager.rs: RVF model loading, LoRA profile switching, model library
- training_api.rs: Training API with WebSocket progress streaming, simulated
  training mode with realistic loss curves, auto-RVF export on completion
- main.rs: Wire new modules, recording hooks in all CSI paths, data dirs

UI (new components):
- ModelPanel.js: Dark-mode model library with load/unload, LoRA dropdown
- TrainingPanel.js: Recording controls, training config, live Canvas charts
- model.service.js: Model REST API client with events
- training.service.js: Training + recording API client with WebSocket progress

UI (enhancements):
- LiveDemoTab: Model selector, LoRA profile switcher, A/B split view toggle,
  training quick-panel with 60s recording shortcut
- SettingsPanel: Full dark mode conversion (issue #92), model configuration
  (device, threads, auto-load), training configuration (epochs, LR, patience)
- PoseDetectionCanvas: 10-frame pose trail with ghost keypoints and motion
  trajectory lines, cyan trail toggle button
- pose.service.js: Model-inference confidence thresholds

UI (plumbing):
- index.html: Training tab (8th tab)
- app.js: Panel initialization and tab routing
- style.css: ~250 lines of training/model panel dark-mode styles

191 Rust tests pass, 0 failures. Closes #92.

Refs: ADR-036, #93

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: real RuVector training pipeline + UI service fixes

Training pipeline (training_api.rs):
- Replace simulated training with real signal-based training loop
- Load actual CSI data from .csi.jsonl recordings or live frame history
- Extract 180 features per frame: subcarrier amplitudes, temporal variance,
  Goertzel frequency analysis (9 bands), motion gradients, global stats
- Train calibrated linear CSI-to-pose mapping via mini-batch gradient descent
  with L2 regularization (ridge regression), Xavier init, cosine LR decay
- Self-supervised: teacher targets from derive_pose_from_sensing() heuristics
- Real validation metrics: MSE and PCK@0.2 on 80/20 train/val split
- Export trained .rvf with real weights, feature normalization stats, witness
- Add infer_pose_from_model() for live inference from trained model
- 16 new tests covering features, training, inference, serialization

UI fixes:
- Fix double-URL bug in model.service.js and training.service.js
  (buildApiUrl was called twice — once in service, once in apiService)
- Fix route paths to match Rust backend (/api/v1/train/*, /api/v1/recording/*)
- Fix request body formats (session_name, nested config object)
- Fix top-level await in LiveDemoTab.js blocking module graph
- Dynamic imports for ModelPanel/TrainingPanel in app.js
- Center nav tabs with flex-wrap for 8-tab layout

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: WebSocket onOpen race condition, data source indicators, auto-start pose detection

- Fix WebSocket onOpen race condition in websocket.service.js where
  setupEventHandlers replaced onopen after socket was already open,
  preventing pose service from receiving connection signal
- Add 4-state data source indicator (LIVE/SIMULATED/RECONNECTING/OFFLINE)
  across Dashboard, Sensing, and Live Demo tabs via sensing.service.js
- Add hot-plug ESP32 auto-detection in sensing server (auto mode runs
  both UDP listener and simulation, switches on ESP32_TIMEOUT)
- Auto-start pose detection when backend is reachable
- Hide duplicate PoseDetectionCanvas controls when enableControls=false
- Add standalone Demo button in LiveDemoTab for offline animated demo
- Add data source banner and status styling

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-03-02 13:47:49 -05:00
committed by GitHub
parent c193cd4299
commit 113011e704
20 changed files with 6124 additions and 83 deletions
@@ -11,6 +11,9 @@
mod rvf_container;
mod rvf_pipeline;
mod vital_signs;
mod recording;
mod model_manager;
mod training_api;
// Training pipeline modules (exposed via lib.rs)
use wifi_densepose_sensing_server::{graph_transformer, trainer, dataset, embedding};
@@ -272,6 +275,9 @@ struct AppStateInner {
frame_history: VecDeque<Vec<f64>>,
tick: u64,
source: String,
/// Timestamp of the last ESP32 UDP frame received.
/// Used by the hybrid auto-detect task to switch between esp32 and simulation.
last_esp32_frame: Option<std::time::Instant>,
tx: broadcast::Sender<String>,
total_detections: u64,
start_time: std::time::Instant,
@@ -289,6 +295,14 @@ struct AppStateInner {
active_sona_profile: Option<String>,
/// Whether a trained model is loaded.
model_loaded: bool,
/// CSI frame recording state (ADR-036).
recording_state: recording::RecordingState,
/// Currently loaded model via model_manager API (ADR-036).
loaded_model: Option<model_manager::LoadedModelState>,
/// Training pipeline state (ADR-036).
training_state: training_api::TrainingState,
/// Broadcast channel for training progress WebSocket (ADR-036).
training_progress_tx: tokio::sync::broadcast::Sender<String>,
}
/// Number of frames retained in `frame_history` for temporal analysis.
@@ -889,6 +903,17 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
s.latest_vitals = vitals.clone();
let feat_variance = features.variance;
// ADR-036: Capture data for recording before values are moved.
let rec_amps = multi_ap_frame.amplitudes.clone();
let rec_rssi = first_rssi;
let rec_features = serde_json::json!({
"variance": feat_variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
let update = SensingUpdate {
msg_type: "sensing_update".to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
@@ -921,7 +946,14 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi, -90.0, &rec_features,
).await;
debug!(
"Multi-BSSID tick #{tick}: {obs_count} BSSIDs, quality={:.2}, verdict={:?}",
@@ -998,6 +1030,16 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
s.latest_vitals = vitals.clone();
let feat_variance = features.variance;
// ADR-036: Capture data for recording before values are moved.
let rec_amps = vec![signal_pct];
let rec_features = serde_json::json!({
"variance": feat_variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
let update = SensingUpdate {
msg_type: "sensing_update".to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
@@ -1030,7 +1072,14 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
state, &rec_amps, rssi_dbm, -90.0, &rec_features,
).await;
}
/// Probe if Windows WiFi is connected
@@ -1766,6 +1815,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
let mut s = state.write().await;
s.source = "esp32".to_string();
s.last_esp32_frame = Some(std::time::Instant::now());
// Append current amplitudes to history before extracting features so
// that temporal analysis includes the most recent frame.
@@ -1829,7 +1879,25 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
// Capture data for recording before storing.
let rec_amps = frame.amplitudes.iter().take(56).cloned().collect::<Vec<_>>();
let rec_rssi = features.mean_rssi;
let rec_features = serde_json::json!({
"variance": features.variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi,
frame.noise_floor as f64, &rec_features,
).await;
}
}
Err(e) => {
@@ -1842,6 +1910,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// ── Simulated data task ──────────────────────────────────────────────────────
/// Duration without ESP32 frames before falling back to simulation.
const ESP32_TIMEOUT: Duration = Duration::from_secs(3);
async fn simulated_data_task(state: SharedState, tick_ms: u64) {
let mut interval = tokio::time::interval(Duration::from_millis(tick_ms));
info!("Simulated data source active (tick={}ms)", tick_ms);
@@ -1849,7 +1920,23 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
loop {
interval.tick().await;
// If ESP32 sent a frame recently, skip simulation — real data is flowing.
{
let s = state.read().await;
if let Some(last) = s.last_esp32_frame {
if last.elapsed() < ESP32_TIMEOUT {
continue; // ESP32 is active, don't emit simulated frames
}
}
}
let mut s = state.write().await;
// If we just transitioned from esp32 → simulated, log once.
if s.source == "esp32" {
info!("ESP32 silent for {}s — switching to simulation", ESP32_TIMEOUT.as_secs());
}
s.source = "simulated".to_string();
s.tick += 1;
let tick = s.tick;
@@ -1928,7 +2015,24 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
// Capture data for recording before storing.
let rec_amps = frame.amplitudes.clone();
let rec_rssi = features.mean_rssi;
let rec_features = serde_json::json!({
"variance": features.variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi, -90.0, &rec_features,
).await;
}
}
@@ -2396,6 +2500,7 @@ async fn main() {
info!(" Source: {}", args.source);
// Auto-detect data source
let is_auto_mode = args.source == "auto";
let source = match args.source.as_str() {
"auto" => {
info!("Auto-detecting data source...");
@@ -2406,7 +2511,7 @@ async fn main() {
info!(" Windows WiFi detected");
"wifi"
} else {
info!(" No hardware detected, using simulation");
info!(" No hardware detected, starting with simulation (hot-plug enabled)");
"simulate"
}
}
@@ -2488,12 +2593,14 @@ async fn main() {
}
let (tx, _) = broadcast::channel::<String>(256);
let (training_progress_tx, _) = broadcast::channel::<String>(512);
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
latest_update: None,
rssi_history: VecDeque::new(),
frame_history: VecDeque::new(),
tick: 0,
source: source.into(),
last_esp32_frame: if source == "esp32" { Some(std::time::Instant::now()) } else { None },
tx,
total_detections: 0,
start_time: std::time::Instant::now(),
@@ -2504,19 +2611,39 @@ async fn main() {
progressive_loader,
active_sona_profile: None,
model_loaded,
recording_state: recording::RecordingState::default(),
loaded_model: None,
training_state: training_api::TrainingState::default(),
training_progress_tx,
}));
// Start background tasks based on source
match source {
"esp32" => {
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
// Ensure data directories exist (ADR-036).
for dir in &[recording::RECORDINGS_DIR, model_manager::MODELS_DIR] {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!("Failed to create directory {dir}: {e}");
}
"wifi" => {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
}
_ => {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
}
// Start background tasks based on source.
// In auto mode we always start BOTH the UDP listener (for ESP32 hot-plug)
// and the simulation task (which self-pauses when ESP32 packets arrive).
if is_auto_mode {
info!("Auto mode: UDP listener + simulation fallback both active (hot-plug enabled)");
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
} else {
match source {
"esp32" => {
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
}
"wifi" => {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
}
_ => {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
}
}
}
@@ -2571,6 +2698,10 @@ async fn main() {
.route("/api/v1/stream/pose", get(ws_pose_handler))
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
.route("/ws/sensing", get(ws_sensing_handler))
// ADR-036: Recording, model management, and training APIs
.merge(recording::routes())
.merge(model_manager::routes())
.merge(training_api::routes())
// Static UI files
.nest_service("/ui", ServeDir::new(&ui_path))
.layer(SetResponseHeaderLayer::overriding(
@@ -0,0 +1,482 @@
//! Model loading and lifecycle management API.
//!
//! Provides REST endpoints for listing, loading, and unloading `.rvf` models.
//! Models are stored in `data/models/` and inspected using `RvfReader`.
//!
//! Endpoints:
//! - `GET /api/v1/models` — list all available models
//! - `GET /api/v1/models/:id` — detailed info for a specific model
//! - `POST /api/v1/models/load` — load a model for inference
//! - `POST /api/v1/models/unload` — unload the active model
//! - `GET /api/v1/models/active` — get active model info
//! - `POST /api/v1/models/lora/activate` — activate a LoRA profile
//! - `GET /api/v1/models/lora/profiles` — list LoRA profiles for active model
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use axum::{
extract::{Path as AxumPath, State},
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{error, info};
use crate::rvf_container::RvfReader;
// ── Models data directory ────────────────────────────────────────────────────
/// Base directory for RVF model files.
pub const MODELS_DIR: &str = "data/models";
// ── Types ────────────────────────────────────────────────────────────────────
/// Summary information for a model discovered on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub filename: String,
pub version: String,
pub description: String,
pub size_bytes: u64,
pub created_at: String,
pub pck_score: Option<f64>,
pub has_quantization: bool,
pub lora_profiles: Vec<String>,
pub segment_count: usize,
}
/// Information about the currently loaded model, including runtime stats.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveModelInfo {
pub model_id: String,
pub filename: String,
pub version: String,
pub description: String,
pub avg_inference_ms: f64,
pub frames_processed: u64,
pub pose_source: String,
pub lora_profiles: Vec<String>,
pub active_lora_profile: Option<String>,
}
/// Runtime state for the loaded model.
///
/// Stored inside `AppStateInner` and read by the inference path.
pub struct LoadedModelState {
/// Model identifier (derived from filename).
pub model_id: String,
/// Original filename.
pub filename: String,
/// Version string from the RVF manifest.
pub version: String,
/// Description from the RVF manifest.
pub description: String,
/// LoRA profiles available in this model.
pub lora_profiles: Vec<String>,
/// Currently active LoRA profile (if any).
pub active_lora_profile: Option<String>,
/// Model weights (f32 parameters).
pub weights: Vec<f32>,
/// Number of frames processed since load.
pub frames_processed: u64,
/// Cumulative inference time for avg calculation.
pub total_inference_ms: f64,
/// When the model was loaded.
pub loaded_at: Instant,
}
/// Request body for `POST /api/v1/models/load`.
#[derive(Debug, Deserialize)]
pub struct LoadModelRequest {
pub model_id: String,
}
/// Request body for `POST /api/v1/models/lora/activate`.
#[derive(Debug, Deserialize)]
pub struct ActivateLoraRequest {
pub model_id: String,
pub profile_name: String,
}
/// Shared application state type.
pub type AppState = Arc<RwLock<super::AppStateInner>>;
// ── Internal helpers ─────────────────────────────────────────────────────────
/// Scan the models directory and build `ModelInfo` for each `.rvf` file.
async fn scan_models() -> Vec<ModelInfo> {
let dir = PathBuf::from(MODELS_DIR);
let mut models = Vec::new();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(_) => return models,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rvf") {
continue;
}
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let id = filename.trim_end_matches(".rvf").to_string();
let size_bytes = tokio::fs::metadata(&path)
.await
.map(|m| m.len())
.unwrap_or(0);
// Read the RVF to extract manifest info.
// This is a blocking I/O operation so we use spawn_blocking.
let path_clone = path.clone();
let info = tokio::task::spawn_blocking(move || {
RvfReader::from_file(&path_clone).ok()
})
.await
.unwrap_or(None);
let (version, description, pck_score, has_quant, lora_profiles, segment_count, created_at) =
if let Some(reader) = &info {
let manifest = reader.manifest().unwrap_or_default();
let metadata = reader.metadata().unwrap_or_default();
let version = manifest
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let description = manifest
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let created_at = manifest
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let pck = metadata
.get("training")
.and_then(|t| t.get("best_pck"))
.and_then(|v| v.as_f64());
let has_quant = reader.quant_info().is_some();
let lora = reader.lora_profiles();
let seg_count = reader.segment_count();
(version, description, pck, has_quant, lora, seg_count, created_at)
} else {
(
"unknown".to_string(),
String::new(),
None,
false,
Vec::new(),
0,
String::new(),
)
};
models.push(ModelInfo {
id,
filename,
version,
description,
size_bytes,
created_at,
pck_score,
has_quantization: has_quant,
lora_profiles,
segment_count,
});
}
models.sort_by(|a, b| a.id.cmp(&b.id));
models
}
/// Load a model from disk by ID and return its `LoadedModelState`.
fn load_model_from_disk(model_id: &str) -> Result<LoadedModelState, String> {
let file_path = PathBuf::from(MODELS_DIR).join(format!("{model_id}.rvf"));
let reader = RvfReader::from_file(&file_path)?;
let manifest = reader.manifest().unwrap_or_default();
let version = manifest
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let description = manifest
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let filename = format!("{model_id}.rvf");
let lora_profiles = reader.lora_profiles();
let weights = reader.weights().unwrap_or_default();
Ok(LoadedModelState {
model_id: model_id.to_string(),
filename,
version,
description,
lora_profiles,
active_lora_profile: None,
weights,
frames_processed: 0,
total_inference_ms: 0.0,
loaded_at: Instant::now(),
})
}
// ── Axum handlers ────────────────────────────────────────────────────────────
async fn list_models(State(_state): State<AppState>) -> Json<serde_json::Value> {
let models = scan_models().await;
Json(serde_json::json!({
"models": models,
"count": models.len(),
}))
}
async fn get_model(
State(_state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
let models = scan_models().await;
match models.into_iter().find(|m| m.id == id) {
Some(model) => Json(serde_json::to_value(&model).unwrap_or_default()),
None => Json(serde_json::json!({
"status": "error",
"message": format!("Model '{id}' not found"),
})),
}
}
async fn load_model(
State(state): State<AppState>,
Json(body): Json<LoadModelRequest>,
) -> Json<serde_json::Value> {
let model_id = body.model_id.clone();
// Perform blocking file I/O on spawn_blocking.
let load_result = tokio::task::spawn_blocking(move || load_model_from_disk(&model_id))
.await
.map_err(|e| format!("spawn_blocking panicked: {e}"));
let loaded = match load_result {
Ok(Ok(loaded)) => loaded,
Ok(Err(e)) => {
error!("Failed to load model '{}': {e}", body.model_id);
return Json(serde_json::json!({
"status": "error",
"message": format!("Failed to load model: {e}"),
}));
}
Err(e) => {
error!("Internal error loading model: {e}");
return Json(serde_json::json!({
"status": "error",
"message": format!("Internal error: {e}"),
}));
}
};
let model_id = loaded.model_id.clone();
let weight_count = loaded.weights.len();
{
let mut s = state.write().await;
s.loaded_model = Some(loaded);
s.model_loaded = true;
}
info!("Model loaded: {model_id} ({weight_count} params)");
Json(serde_json::json!({
"status": "loaded",
"model_id": model_id,
"weight_count": weight_count,
}))
}
async fn unload_model(State(state): State<AppState>) -> Json<serde_json::Value> {
let mut s = state.write().await;
if s.loaded_model.is_none() {
return Json(serde_json::json!({
"status": "error",
"message": "No model is currently loaded.",
}));
}
let model_id = s
.loaded_model
.as_ref()
.map(|m| m.model_id.clone())
.unwrap_or_default();
s.loaded_model = None;
s.model_loaded = false;
info!("Model unloaded: {model_id}");
Json(serde_json::json!({
"status": "unloaded",
"model_id": model_id,
}))
}
async fn active_model(State(state): State<AppState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.loaded_model {
Some(model) => {
let avg_ms = if model.frames_processed > 0 {
model.total_inference_ms / model.frames_processed as f64
} else {
0.0
};
let info = ActiveModelInfo {
model_id: model.model_id.clone(),
filename: model.filename.clone(),
version: model.version.clone(),
description: model.description.clone(),
avg_inference_ms: avg_ms,
frames_processed: model.frames_processed,
pose_source: "model_inference".to_string(),
lora_profiles: model.lora_profiles.clone(),
active_lora_profile: model.active_lora_profile.clone(),
};
Json(serde_json::to_value(&info).unwrap_or_default())
}
None => Json(serde_json::json!({
"status": "no_model",
"message": "No model is currently loaded.",
})),
}
}
async fn activate_lora(
State(state): State<AppState>,
Json(body): Json<ActivateLoraRequest>,
) -> Json<serde_json::Value> {
let mut s = state.write().await;
let model = match s.loaded_model.as_mut() {
Some(m) => m,
None => {
return Json(serde_json::json!({
"status": "error",
"message": "No model is loaded. Load a model first.",
}));
}
};
if model.model_id != body.model_id {
return Json(serde_json::json!({
"status": "error",
"message": format!(
"Model '{}' is not loaded. Active model: '{}'",
body.model_id, model.model_id
),
}));
}
if !model.lora_profiles.contains(&body.profile_name) {
return Json(serde_json::json!({
"status": "error",
"message": format!(
"LoRA profile '{}' not found. Available: {:?}",
body.profile_name, model.lora_profiles
),
}));
}
model.active_lora_profile = Some(body.profile_name.clone());
info!(
"LoRA profile activated: {} on model {}",
body.profile_name, body.model_id
);
Json(serde_json::json!({
"status": "activated",
"model_id": body.model_id,
"profile_name": body.profile_name,
}))
}
async fn list_lora_profiles(State(state): State<AppState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.loaded_model {
Some(model) => Json(serde_json::json!({
"model_id": model.model_id,
"profiles": model.lora_profiles,
"active": model.active_lora_profile,
})),
None => Json(serde_json::json!({
"profiles": serde_json::Value::Array(vec![]),
"message": "No model is loaded.",
})),
}
}
// ── Router factory ───────────────────────────────────────────────────────────
/// Build the model management sub-router.
///
/// All routes are prefixed with `/api/v1/models`.
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/models", get(list_models))
.route("/api/v1/models/active", get(active_model))
.route("/api/v1/models/load", post(load_model))
.route("/api/v1/models/unload", post(unload_model))
.route("/api/v1/models/lora/activate", post(activate_lora))
.route("/api/v1/models/lora/profiles", get(list_lora_profiles))
.route("/api/v1/models/{id}", get(get_model))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_info_serializes() {
let info = ModelInfo {
id: "test-model".to_string(),
filename: "test-model.rvf".to_string(),
version: "1.0.0".to_string(),
description: "A test model".to_string(),
size_bytes: 1024,
created_at: "2024-01-01T00:00:00Z".to_string(),
pck_score: Some(0.85),
has_quantization: false,
lora_profiles: vec!["default".to_string()],
segment_count: 5,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("test-model"));
assert!(json.contains("0.85"));
}
#[test]
fn active_model_info_serializes() {
let info = ActiveModelInfo {
model_id: "demo".to_string(),
filename: "demo.rvf".to_string(),
version: "0.1.0".to_string(),
description: String::new(),
avg_inference_ms: 2.5,
frames_processed: 100,
pose_source: "model_inference".to_string(),
lora_profiles: vec![],
active_lora_profile: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("model_inference"));
}
}
@@ -0,0 +1,486 @@
//! CSI frame recording API.
//!
//! Provides REST endpoints for recording CSI frames to `.csi.jsonl` files.
//! When recording is active, each processed CSI frame is appended as a JSON
//! line to the current session file stored under `data/recordings/`.
//!
//! Endpoints:
//! - `POST /api/v1/recording/start` — start a new recording session
//! - `POST /api/v1/recording/stop` — stop the active recording
//! - `GET /api/v1/recording/list` — list all recording sessions
//! - `GET /api/v1/recording/download/:id` — download a recording file
//! - `DELETE /api/v1/recording/:id` — delete a recording
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use axum::{
extract::{Path as AxumPath, State},
response::{IntoResponse, Json},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{error, info, warn};
// ── Recording data directory ─────────────────────────────────────────────────
/// Base directory for recording files.
pub const RECORDINGS_DIR: &str = "data/recordings";
// ── Types ────────────────────────────────────────────────────────────────────
/// Request body for `POST /api/v1/recording/start`.
#[derive(Debug, Deserialize)]
pub struct StartRecordingRequest {
pub session_name: String,
pub label: Option<String>,
pub duration_secs: Option<u64>,
}
/// Metadata for a completed or active recording session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingSession {
pub id: String,
pub name: String,
pub label: Option<String>,
pub started_at: String,
pub ended_at: Option<String>,
pub frame_count: u64,
pub file_size_bytes: u64,
pub file_path: String,
}
/// A single recorded CSI frame line (JSONL format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedFrame {
pub timestamp: f64,
pub subcarriers: Vec<f64>,
pub rssi: f64,
pub noise_floor: f64,
pub features: serde_json::Value,
}
/// Runtime state for the active recording session.
///
/// Stored inside `AppStateInner` and checked on each CSI frame tick.
pub struct RecordingState {
/// Whether a recording is currently active.
pub active: bool,
/// Session ID of the active recording.
pub session_id: String,
/// Session display name.
pub session_name: String,
/// Optional label / activity tag.
pub label: Option<String>,
/// Path to the JSONL file being written.
pub file_path: PathBuf,
/// Number of frames written so far.
pub frame_count: u64,
/// When the recording started.
pub start_time: Instant,
/// ISO-8601 start timestamp for metadata.
pub started_at: String,
/// Optional auto-stop duration.
pub duration_secs: Option<u64>,
}
impl Default for RecordingState {
fn default() -> Self {
Self {
active: false,
session_id: String::new(),
session_name: String::new(),
label: None,
file_path: PathBuf::new(),
frame_count: 0,
start_time: Instant::now(),
started_at: String::new(),
duration_secs: None,
}
}
}
/// Shared application state type used across all handlers.
pub type AppState = Arc<RwLock<super::AppStateInner>>;
// ── Public helpers (called from the CSI processing loop in main.rs) ──────────
/// Append a single frame to the active recording file.
///
/// This is designed to be called from the main CSI processing tick.
/// If recording is not active, it returns immediately.
pub async fn maybe_record_frame(
state: &AppState,
subcarriers: &[f64],
rssi: f64,
noise_floor: f64,
features: &serde_json::Value,
) {
let should_write;
let file_path;
let auto_stop;
{
let s = state.read().await;
let rec = &s.recording_state;
if !rec.active {
return;
}
should_write = true;
file_path = rec.file_path.clone();
auto_stop = rec.duration_secs.map(|d| rec.start_time.elapsed().as_secs() >= d).unwrap_or(false);
}
if auto_stop {
// Duration exceeded — stop recording.
stop_recording_inner(state).await;
return;
}
if !should_write {
return;
}
let frame = RecordedFrame {
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
subcarriers: subcarriers.to_vec(),
rssi,
noise_floor,
features: features.clone(),
};
let line = match serde_json::to_string(&frame) {
Ok(l) => l,
Err(e) => {
warn!("Failed to serialize recording frame: {e}");
return;
}
};
// Append line to file (async).
if let Err(e) = append_line(&file_path, &line).await {
warn!("Failed to write recording frame: {e}");
return;
}
// Increment frame counter.
{
let mut s = state.write().await;
s.recording_state.frame_count += 1;
}
}
async fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
file.write_all(line.as_bytes()).await?;
file.write_all(b"\n").await?;
Ok(())
}
// ── Internal helpers ─────────────────────────────────────────────────────────
/// Stop the active recording and write session metadata.
async fn stop_recording_inner(state: &AppState) {
let mut s = state.write().await;
if !s.recording_state.active {
return;
}
s.recording_state.active = false;
let ended_at = chrono::Utc::now().to_rfc3339();
let session = RecordingSession {
id: s.recording_state.session_id.clone(),
name: s.recording_state.session_name.clone(),
label: s.recording_state.label.clone(),
started_at: s.recording_state.started_at.clone(),
ended_at: Some(ended_at),
frame_count: s.recording_state.frame_count,
file_size_bytes: std::fs::metadata(&s.recording_state.file_path)
.map(|m| m.len())
.unwrap_or(0),
file_path: s.recording_state.file_path.to_string_lossy().to_string(),
};
// Write a companion .meta.json alongside the JSONL file.
let meta_path = s.recording_state.file_path.with_extension("meta.json");
if let Ok(json) = serde_json::to_string_pretty(&session) {
if let Err(e) = tokio::fs::write(&meta_path, json).await {
warn!("Failed to write recording metadata: {e}");
}
}
info!(
"Recording stopped: {} ({} frames)",
session.id, session.frame_count
);
}
/// Scan the recordings directory and return all sessions with metadata.
async fn list_sessions() -> Vec<RecordingSession> {
let dir = PathBuf::from(RECORDINGS_DIR);
let mut sessions = Vec::new();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(_) => return sessions,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json")
&& path.to_string_lossy().contains(".meta.")
{
if let Ok(data) = tokio::fs::read_to_string(&path).await {
if let Ok(session) = serde_json::from_str::<RecordingSession>(&data) {
sessions.push(session);
}
}
}
}
// Sort by started_at descending (newest first).
sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
sessions
}
// ── Axum handlers ────────────────────────────────────────────────────────────
async fn start_recording(
State(state): State<AppState>,
Json(body): Json<StartRecordingRequest>,
) -> Json<serde_json::Value> {
// Ensure recordings directory exists.
if let Err(e) = tokio::fs::create_dir_all(RECORDINGS_DIR).await {
error!("Failed to create recordings directory: {e}");
return Json(serde_json::json!({
"status": "error",
"message": format!("Cannot create recordings directory: {e}"),
}));
}
let mut s = state.write().await;
if s.recording_state.active {
return Json(serde_json::json!({
"status": "error",
"message": "A recording is already active. Stop it first.",
"active_session": s.recording_state.session_id,
}));
}
let session_id = format!(
"{}-{}",
body.session_name.replace(' ', "_"),
chrono::Utc::now().format("%Y%m%d_%H%M%S")
);
let file_name = format!("{session_id}.csi.jsonl");
let file_path = PathBuf::from(RECORDINGS_DIR).join(&file_name);
let started_at = chrono::Utc::now().to_rfc3339();
s.recording_state = RecordingState {
active: true,
session_id: session_id.clone(),
session_name: body.session_name.clone(),
label: body.label.clone(),
file_path: file_path.clone(),
frame_count: 0,
start_time: Instant::now(),
started_at: started_at.clone(),
duration_secs: body.duration_secs,
};
info!(
"Recording started: {session_id} (label={:?}, duration={:?}s)",
body.label, body.duration_secs
);
Json(serde_json::json!({
"status": "recording",
"session_id": session_id,
"session_name": body.session_name,
"label": body.label,
"started_at": started_at,
"file_path": file_path.to_string_lossy(),
"duration_secs": body.duration_secs,
}))
}
async fn stop_recording(State(state): State<AppState>) -> Json<serde_json::Value> {
{
let s = state.read().await;
if !s.recording_state.active {
return Json(serde_json::json!({
"status": "error",
"message": "No active recording to stop.",
}));
}
}
stop_recording_inner(&state).await;
let s = state.read().await;
Json(serde_json::json!({
"status": "stopped",
"session_id": s.recording_state.session_id,
"frame_count": s.recording_state.frame_count,
}))
}
async fn list_recordings(
State(_state): State<AppState>,
) -> Json<serde_json::Value> {
let sessions = list_sessions().await;
Json(serde_json::json!({
"recordings": sessions,
"count": sessions.len(),
}))
}
async fn download_recording(
State(_state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> impl IntoResponse {
let dir = PathBuf::from(RECORDINGS_DIR);
// Find the JSONL file matching the ID.
let file_path = dir.join(format!("{id}.csi.jsonl"));
if !file_path.exists() {
return (
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({
"status": "error",
"message": format!("Recording '{id}' not found"),
})),
)
.into_response();
}
match tokio::fs::read(&file_path).await {
Ok(data) => {
let headers = [
(
axum::http::header::CONTENT_TYPE,
"application/x-ndjson".to_string(),
),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{id}.csi.jsonl\""),
),
];
(headers, data).into_response()
}
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"status": "error",
"message": format!("Failed to read recording: {e}"),
})),
)
.into_response(),
}
}
async fn delete_recording(
State(_state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
let dir = PathBuf::from(RECORDINGS_DIR);
let jsonl_path = dir.join(format!("{id}.csi.jsonl"));
let meta_path = dir.join(format!("{id}.csi.meta.json"));
if !jsonl_path.exists() && !meta_path.exists() {
return Json(serde_json::json!({
"status": "error",
"message": format!("Recording '{id}' not found"),
}));
}
let mut deleted = Vec::new();
if jsonl_path.exists() {
if let Err(e) = tokio::fs::remove_file(&jsonl_path).await {
warn!("Failed to delete {}: {e}", jsonl_path.display());
} else {
deleted.push(jsonl_path.to_string_lossy().to_string());
}
}
if meta_path.exists() {
if let Err(e) = tokio::fs::remove_file(&meta_path).await {
warn!("Failed to delete {}: {e}", meta_path.display());
} else {
deleted.push(meta_path.to_string_lossy().to_string());
}
}
Json(serde_json::json!({
"status": "deleted",
"id": id,
"deleted_files": deleted,
}))
}
// ── Router factory ───────────────────────────────────────────────────────────
/// Build the recording sub-router.
///
/// Mount this at the top level; all routes are prefixed with `/api/v1/recording`.
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/recording/start", post(start_recording))
.route("/api/v1/recording/stop", post(stop_recording))
.route("/api/v1/recording/list", get(list_recordings))
.route(
"/api/v1/recording/download/{id}",
get(download_recording),
)
.route("/api/v1/recording/{id}", delete(delete_recording))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_recording_state_is_inactive() {
let rs = RecordingState::default();
assert!(!rs.active);
assert_eq!(rs.frame_count, 0);
}
#[test]
fn recorded_frame_serializes_to_json() {
let frame = RecordedFrame {
timestamp: 1700000000.0,
subcarriers: vec![1.0, 2.0, 3.0],
rssi: -45.0,
noise_floor: -90.0,
features: serde_json::json!({"motion": 0.5}),
};
let json = serde_json::to_string(&frame).unwrap();
assert!(json.contains("\"timestamp\""));
assert!(json.contains("\"subcarriers\""));
}
#[test]
fn recording_session_deserializes() {
let json = r#"{
"id": "test-20240101_120000",
"name": "test",
"label": "walking",
"started_at": "2024-01-01T12:00:00Z",
"ended_at": "2024-01-01T12:05:00Z",
"frame_count": 3000,
"file_size_bytes": 1500000,
"file_path": "data/recordings/test-20240101_120000.csi.jsonl"
}"#;
let session: RecordingSession = serde_json::from_str(json).unwrap();
assert_eq!(session.id, "test-20240101_120000");
assert_eq!(session.frame_count, 3000);
assert_eq!(session.label, Some("walking".to_string()));
}
}