mirror of
https://github.com/ruvnet/RuView
synced 2026-08-05 19:41:44 +00:00
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:
@@ -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(
|
||||
|
||||
+482
@@ -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()));
|
||||
}
|
||||
}
|
||||
+1946
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user