//! WiFi-DensePose Sensing Server //! //! Lightweight Axum server that: //! - Receives ESP32 CSI frames via UDP (port 5005) //! - Processes signals using RuVector-powered wifi-densepose-signal crate //! - Broadcasts sensing updates via WebSocket (ws://localhost:8765/ws/sensing) //! - Serves the static UI files (port 8080) //! //! Replaces both ws_server.py and the Python HTTP server. #![allow(dead_code)] mod adaptive_classifier; pub mod cli; pub mod csi; mod field_bridge; mod multistatic_bridge; pub mod pose; mod rvf_container; mod rvf_pipeline; mod tracker_bridge; pub mod types; mod vital_signs; // Training pipeline modules (exposed via lib.rs) use wifi_densepose_sensing_server::{graph_transformer, trainer, dataset, embedding}; use std::collections::{HashMap, VecDeque}; use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, Path, State, }, response::{Html, IntoResponse, Json}, routing::{delete, get, post}, Router, }; use clap::Parser; use serde::{Deserialize, Serialize}; use tokio::net::UdpSocket; use tokio::sync::{broadcast, RwLock}; use tower_http::services::ServeDir; use tower_http::set_header::SetResponseHeaderLayer; use axum::http::HeaderValue; use tracing::{info, warn, debug, error}; use rvf_container::{RvfBuilder, RvfContainerInfo, RvfReader, VitalSignConfig}; use rvf_pipeline::ProgressiveLoader; use vital_signs::{VitalSignDetector, VitalSigns}; // ADR-022 Phase 3: Multi-BSSID pipeline integration use wifi_densepose_wifiscan::{ BssidRegistry, WindowsWifiPipeline, }; use wifi_densepose_wifiscan::parse_netsh_output as parse_netsh_bssid_output; // Accuracy sprint: Kalman tracker, multistatic fusion, field model use wifi_densepose_signal::ruvsense::pose_tracker::PoseTracker; use wifi_densepose_signal::ruvsense::multistatic::{MultistaticFuser, MultistaticConfig}; use wifi_densepose_signal::ruvsense::field_model::{FieldModel, CalibrationStatus}; // ── CLI ────────────────────────────────────────────────────────────────────── #[derive(Parser, Debug)] #[command(name = "sensing-server", about = "WiFi-DensePose sensing server")] struct Args { /// HTTP port for UI and REST API #[arg(long, default_value = "8080")] http_port: u16, /// WebSocket port for sensing stream #[arg(long, default_value = "8765")] ws_port: u16, /// UDP port for ESP32 CSI frames #[arg(long, default_value = "5005")] udp_port: u16, /// Path to UI static files #[arg(long, default_value = "../../ui")] ui_path: PathBuf, /// Tick interval in milliseconds (default 100 ms = 10 fps for smooth pose animation) #[arg(long, default_value = "100")] tick_ms: u64, /// Bind address (default 127.0.0.1; set to 0.0.0.0 for network access) #[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")] bind_addr: String, /// Data source: auto, wifi, esp32, simulate #[arg(long, default_value = "auto")] source: String, /// Run vital sign detection benchmark (1000 frames) and exit #[arg(long)] benchmark: bool, /// Load model config from an RVF container at startup #[arg(long, value_name = "PATH")] load_rvf: Option, /// Save current model state as an RVF container on shutdown #[arg(long, value_name = "PATH")] save_rvf: Option, /// Load a trained .rvf model for inference #[arg(long, value_name = "PATH")] model: Option, /// Enable progressive loading (Layer A instant start) #[arg(long)] progressive: bool, /// Export an RVF container package and exit (no server) #[arg(long, value_name = "PATH")] export_rvf: Option, /// Run training mode (train a model and exit) #[arg(long)] train: bool, /// Path to dataset directory (MM-Fi or Wi-Pose) #[arg(long, value_name = "PATH")] dataset: Option, /// Dataset type: "mmfi" or "wipose" #[arg(long, value_name = "TYPE", default_value = "mmfi")] dataset_type: String, /// Number of training epochs #[arg(long, default_value = "100")] epochs: usize, /// Directory for training checkpoints #[arg(long, value_name = "DIR")] checkpoint_dir: Option, /// Run self-supervised contrastive pretraining (ADR-024) #[arg(long)] pretrain: bool, /// Number of pretraining epochs (default 50) #[arg(long, default_value = "50")] pretrain_epochs: usize, /// Extract embeddings mode: load model and extract CSI embeddings #[arg(long)] embed: bool, /// Build fingerprint index from embeddings (env|activity|temporal|person) #[arg(long, value_name = "TYPE")] build_index: Option, /// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...") #[arg(long, env = "SENSING_NODE_POSITIONS")] node_positions: Option, /// Start field model calibration on boot (empty room required) #[arg(long)] calibrate: bool, } // ── Data types ─────────────────────────────────────────────────────────────── /// ADR-018 ESP32 CSI binary frame header (20 bytes) #[derive(Debug, Clone)] #[allow(dead_code)] struct Esp32Frame { magic: u32, node_id: u8, n_antennas: u8, n_subcarriers: u8, freq_mhz: u16, sequence: u32, rssi: i8, noise_floor: i8, amplitudes: Vec, phases: Vec, } /// Sensing update broadcast to WebSocket clients #[derive(Debug, Clone, Serialize, Deserialize)] struct SensingUpdate { #[serde(rename = "type")] msg_type: String, timestamp: f64, source: String, tick: u64, nodes: Vec, features: FeatureInfo, classification: ClassificationInfo, signal_field: SignalField, /// Vital sign estimates (breathing rate, heart rate, confidence). #[serde(skip_serializing_if = "Option::is_none")] vital_signs: Option, // ── ADR-022 Phase 3: Enhanced multi-BSSID pipeline fields ── /// Enhanced motion estimate from multi-BSSID pipeline. #[serde(skip_serializing_if = "Option::is_none")] enhanced_motion: Option, /// Enhanced breathing estimate from multi-BSSID pipeline. #[serde(skip_serializing_if = "Option::is_none")] enhanced_breathing: Option, /// Posture classification from BSSID fingerprint matching. #[serde(skip_serializing_if = "Option::is_none")] posture: Option, /// Signal quality score from multi-BSSID quality gate [0.0, 1.0]. #[serde(skip_serializing_if = "Option::is_none")] signal_quality_score: Option, /// Quality gate verdict: "Permit", "Warn", or "Deny". #[serde(skip_serializing_if = "Option::is_none")] quality_verdict: Option, /// Number of BSSIDs used in the enhanced sensing cycle. #[serde(skip_serializing_if = "Option::is_none")] bssid_count: Option, // ── ADR-023 Phase 7-8: Model inference fields ── /// Pose keypoints when a trained model is loaded (x, y, z, confidence). #[serde(skip_serializing_if = "Option::is_none")] pose_keypoints: Option>, /// Model status when a trained model is loaded. #[serde(skip_serializing_if = "Option::is_none")] model_status: Option, // ── Multi-person detection (issue #97) ── /// Detected persons from WiFi sensing (multi-person support). #[serde(skip_serializing_if = "Option::is_none")] persons: Option>, /// Estimated person count from CSI feature heuristics (1-3 for single ESP32). #[serde(skip_serializing_if = "Option::is_none")] estimated_persons: Option, /// Per-node feature breakdown for multi-node deployments. #[serde(skip_serializing_if = "Option::is_none")] node_features: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct NodeInfo { node_id: u8, rssi_dbm: f64, position: [f64; 3], amplitude: Vec, subcarrier_count: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] struct FeatureInfo { mean_rssi: f64, variance: f64, motion_band_power: f64, breathing_band_power: f64, dominant_freq_hz: f64, change_points: usize, spectral_power: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ClassificationInfo { motion_level: String, presence: bool, confidence: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] struct SignalField { grid_size: [usize; 3], values: Vec, } /// WiFi-derived pose keypoint (17 COCO keypoints) #[derive(Debug, Clone, Serialize, Deserialize)] struct PoseKeypoint { name: String, x: f64, y: f64, z: f64, confidence: f64, } /// Person detection from WiFi sensing #[derive(Debug, Clone, Serialize, Deserialize)] struct PersonDetection { id: u32, confidence: f64, keypoints: Vec, bbox: BoundingBox, zone: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct BoundingBox { x: f64, y: f64, width: f64, height: f64, } /// Per-node sensing state for multi-node deployments (issue #249). /// Each ESP32 node gets its own frame history, smoothing buffers, and vital /// sign detector so that data from different nodes is never mixed. struct NodeState { pub(crate) frame_history: VecDeque>, smoothed_person_score: f64, pub(crate) prev_person_count: usize, smoothed_motion: f64, current_motion_level: String, debounce_counter: u32, debounce_candidate: String, baseline_motion: f64, baseline_frames: u64, smoothed_hr: f64, smoothed_br: f64, smoothed_hr_conf: f64, smoothed_br_conf: f64, hr_buffer: VecDeque, br_buffer: VecDeque, rssi_history: VecDeque, vital_detector: VitalSignDetector, latest_vitals: VitalSigns, pub(crate) last_frame_time: Option, edge_vitals: Option, /// Latest extracted features for cross-node fusion. latest_features: Option, // ── RuVector Phase 2: Temporal smoothing & coherence gating ── /// Previous frame's smoothed keypoint positions for EMA temporal smoothing. prev_keypoints: Option>, /// Rolling buffer of motion_energy values for coherence scoring (last 20 frames). motion_energy_history: VecDeque, /// Coherence score [0.0, 1.0]: low variance in motion_energy = high coherence. coherence_score: f64, /// ADR-084 Pass 3 cluster-Pi novelty sensor — per-node sketch bank of /// recent CSI feature vectors. Populated by `update_novelty` on each /// frame; left `None` to disable the sensor on a per-node basis. feature_history: Option, /// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank, /// 1 = no overlap). Consumed by the model-wake gate downstream. pub(crate) last_novelty_score: Option, } /// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2). /// Lower = smoother (more history, less jitter). 0.15 balances responsiveness /// with stability for WiFi CSI where per-frame noise is high. const TEMPORAL_EMA_ALPHA_DEFAULT: f64 = 0.15; /// Reduced EMA alpha when coherence is low (trust measurements less). const TEMPORAL_EMA_ALPHA_LOW_COHERENCE: f64 = 0.05; /// Coherence threshold below which we reduce EMA alpha. const COHERENCE_LOW_THRESHOLD: f64 = 0.3; /// Maximum allowed bone-length change ratio between frames (20%). const MAX_BONE_CHANGE_RATIO: f64 = 0.20; /// Number of motion_energy frames to track for coherence scoring. const COHERENCE_WINDOW: usize = 20; /// ADR-084 Pass 3 — per-node novelty sketch dimension (56 subcarriers, /// the dominant ESP32-S3 capture configuration). const NOVELTY_VECTOR_DIM: usize = 56; /// ADR-084 Pass 3 — number of past sketches retained per-node for /// novelty comparison. 64 frames ≈ 6.4 s at 10 Hz. const NOVELTY_HISTORY_CAPACITY: usize = 64; /// ADR-084 Pass 3 — feature-vector schema version. Bump on changes to /// subcarrier ordering / normalisation so banks reject stale data. const NOVELTY_SKETCH_VERSION: u16 = 1; impl NodeState { pub(crate) fn new() -> Self { Self { frame_history: VecDeque::new(), smoothed_person_score: 0.0, prev_person_count: 0, smoothed_motion: 0.0, current_motion_level: "absent".to_string(), debounce_counter: 0, debounce_candidate: "absent".to_string(), baseline_motion: 0.0, baseline_frames: 0, smoothed_hr: 0.0, smoothed_br: 0.0, smoothed_hr_conf: 0.0, smoothed_br_conf: 0.0, hr_buffer: VecDeque::with_capacity(8), br_buffer: VecDeque::with_capacity(8), rssi_history: VecDeque::new(), vital_detector: VitalSignDetector::new(10.0), latest_vitals: VitalSigns::default(), last_frame_time: None, edge_vitals: None, latest_features: None, prev_keypoints: None, motion_energy_history: VecDeque::with_capacity(COHERENCE_WINDOW), coherence_score: 1.0, // assume stable initially feature_history: Some( wifi_densepose_signal::ruvsense::longitudinal::EmbeddingHistory::with_sketch( NOVELTY_VECTOR_DIM, NOVELTY_HISTORY_CAPACITY, NOVELTY_SKETCH_VERSION, ), ), last_novelty_score: None, } } /// ADR-084 cluster-Pi novelty step. Truncates / zero-pads the /// incoming amplitude vector to `NOVELTY_VECTOR_DIM`, scores its /// novelty against the per-node bank, then inserts it. The novelty /// score is computed *before* the insert so a frame doesn't see /// itself in the bank. pub(crate) fn update_novelty(&mut self, amplitudes: &[f64]) { let history = match &mut self.feature_history { Some(h) => h, None => return, }; let mut feature: Vec = amplitudes .iter() .take(NOVELTY_VECTOR_DIM) .map(|&v| v as f32) .collect(); feature.resize(NOVELTY_VECTOR_DIM, 0.0); // Score before insert so a query doesn't see itself. self.last_novelty_score = history.novelty(&feature); let _ = history.push( wifi_densepose_signal::ruvsense::longitudinal::EmbeddingEntry { person_id: 0, day_us: 0, embedding: feature, }, ); } /// Update the coherence score from the latest motion_energy value. /// /// Coherence is computed as 1.0 / (1.0 + running_variance) so that /// low motion-energy variance maps to high coherence ([0, 1]). fn update_coherence(&mut self, motion_energy: f64) { if self.motion_energy_history.len() >= COHERENCE_WINDOW { self.motion_energy_history.pop_front(); } self.motion_energy_history.push_back(motion_energy); let n = self.motion_energy_history.len(); if n < 2 { self.coherence_score = 1.0; return; } let mean: f64 = self.motion_energy_history.iter().sum::() / n as f64; let variance: f64 = self.motion_energy_history.iter() .map(|v| (v - mean) * (v - mean)) .sum::() / (n - 1) as f64; // Map variance to [0, 1] coherence: higher variance = lower coherence. self.coherence_score = (1.0 / (1.0 + variance)).clamp(0.0, 1.0); } /// Choose the EMA alpha based on current coherence score. fn ema_alpha(&self) -> f64 { if self.coherence_score < COHERENCE_LOW_THRESHOLD { TEMPORAL_EMA_ALPHA_LOW_COHERENCE } else { TEMPORAL_EMA_ALPHA_DEFAULT } } } /// Per-node feature info for WebSocket broadcasts (multi-node support). #[derive(Debug, Clone, Serialize, Deserialize)] struct PerNodeFeatureInfo { node_id: u8, features: FeatureInfo, classification: ClassificationInfo, rssi_dbm: f64, last_seen_ms: u64, frame_rate_hz: f64, stale: bool, /// ADR-084 Pass 3 cluster-Pi novelty score in `[0.0, 1.0]`. /// `0.0` = exact-match-in-bank, `1.0` = no overlap with recent /// per-node frame history. `None` until the first /// `update_novelty()` call. Consumers (model-wake gate, anomaly /// emit, UI heatmap) read this to decide whether to escalate. #[serde(skip_serializing_if = "Option::is_none")] novelty_score: Option, } /// Build a per-node feature snapshot for the WebSocket envelope. /// /// ADR-084 Pass 3.6 — exposes `last_novelty_score` from each /// `NodeState` to the WebSocket consumer. Returns `None` when the /// node map is empty (no live ESP32 frames have been ingested yet), /// so the existing `node_features: None` semantics on cold-start are /// preserved. /// /// Stale flag uses 5-second threshold matching `ESP32_OFFLINE_TIMEOUT`. fn build_node_features( node_states: &std::collections::HashMap, now: std::time::Instant, ) -> Option> { if node_states.is_empty() { return None; } let entries: Vec = node_states .iter() .map(|(&node_id, ns)| { let last_seen_ms = ns .last_frame_time .map(|t| now.saturating_duration_since(t).as_millis() as u64) .unwrap_or(u64::MAX); let stale = ns .last_frame_time .map(|t| now.saturating_duration_since(t) > ESP32_OFFLINE_TIMEOUT) .unwrap_or(true); let features = ns.latest_features.clone().unwrap_or(FeatureInfo { mean_rssi: 0.0, variance: 0.0, motion_band_power: 0.0, breathing_band_power: 0.0, dominant_freq_hz: 0.0, change_points: 0, spectral_power: 0.0, }); PerNodeFeatureInfo { node_id, features, classification: ClassificationInfo { motion_level: ns.current_motion_level.clone(), presence: !matches!(ns.current_motion_level.as_str(), "absent"), confidence: ns.smoothed_person_score.clamp(0.0, 1.0), }, rssi_dbm: ns.rssi_history.back().copied().unwrap_or(0.0), last_seen_ms, frame_rate_hz: 0.0, // Computed elsewhere; not yet plumbed here. stale, novelty_score: ns.last_novelty_score, } }) .collect(); Some(entries) } /// Shared application state struct AppStateInner { latest_update: Option, rssi_history: VecDeque, /// Circular buffer of recent CSI amplitude vectors for temporal analysis. /// Each entry is the full subcarrier amplitude vector for one frame. /// Capacity: FRAME_HISTORY_CAPACITY frames. frame_history: VecDeque>, tick: u64, source: String, /// Instant of the last ESP32 UDP frame received (for offline detection). last_esp32_frame: Option, tx: broadcast::Sender, // ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state + // a parallel broadcast topic (`/ws/introspection`) running alongside // (not replacing) the window-aggregated `tx` / `/ws/sensing` pipeline. intro: wifi_densepose_sensing_server::introspection::IntrospectionState, intro_tx: broadcast::Sender, total_detections: u64, start_time: std::time::Instant, /// Vital sign detector (processes CSI frames to estimate HR/RR). vital_detector: VitalSignDetector, /// Most recent vital sign reading for the REST endpoint. latest_vitals: VitalSigns, /// RVF container info if a model was loaded via `--load-rvf`. rvf_info: Option, /// Path to save RVF container on shutdown (set via `--save-rvf`). save_rvf_path: Option, /// Progressive loader for a trained model (set via `--model`). progressive_loader: Option, /// Active SONA profile name. active_sona_profile: Option, /// Whether a trained model is loaded. model_loaded: bool, /// Smoothed person count (EMA) for hysteresis — prevents frame-to-frame jumping. smoothed_person_score: f64, /// Previous person count for hysteresis (asymmetric up/down thresholds). prev_person_count: usize, // ── Motion smoothing & adaptive baseline (ADR-047 tuning) ──────────── /// EMA-smoothed motion score (alpha ~0.15 for ~10 FPS → ~1s time constant). smoothed_motion: f64, /// Current classification state for hysteresis debounce. current_motion_level: String, /// How many consecutive frames the *raw* classification has agreed with a /// *candidate* new level. State only changes after DEBOUNCE_FRAMES. debounce_counter: u32, /// The candidate motion level that the debounce counter is tracking. debounce_candidate: String, /// Adaptive baseline: EMA of motion score when room is "quiet" (low motion). /// Subtracted from raw score so slow environmental drift doesn't inflate readings. baseline_motion: f64, /// Number of frames processed so far (for baseline warm-up). baseline_frames: u64, // ── Vital signs smoothing ──────────────────────────────────────────── /// EMA-smoothed heart rate (BPM). smoothed_hr: f64, /// EMA-smoothed breathing rate (BPM). smoothed_br: f64, /// EMA-smoothed HR confidence. smoothed_hr_conf: f64, /// EMA-smoothed BR confidence. smoothed_br_conf: f64, /// Median filter buffer for HR (last N raw values for outlier rejection). hr_buffer: VecDeque, /// Median filter buffer for BR. br_buffer: VecDeque, /// ADR-039: Latest edge vitals packet from ESP32. edge_vitals: Option, /// ADR-040: Latest WASM output packet from ESP32. latest_wasm_events: Option, // ── Model management fields ───────────────────────────────────────────── /// Discovered RVF model files from `data/models/`. discovered_models: Vec, /// ID of the currently loaded model, if any. active_model_id: Option, // ── Recording fields ──────────────────────────────────────────────────── /// Metadata for recorded CSI data files. recordings: Vec, /// Whether CSI recording is currently in progress. recording_active: bool, /// When the current recording started. recording_start_time: Option, /// ID of the current recording (used for filename). recording_current_id: Option, /// Shutdown signal for the recording writer task. recording_stop_tx: Option>, // ── Training fields ───────────────────────────────────────────────────── /// Training status: "idle", "running", "completed", "failed". training_status: String, /// Training configuration, if any. training_config: Option, // ── Adaptive classifier (environment-tuned) ────────────────────────── /// Trained adaptive model (loaded from data/adaptive_model.json or trained at runtime). adaptive_model: Option, // ── Per-node state (issue #249) ───────────────────────────────────── /// Per-node sensing state for multi-node deployments. /// Keyed by `node_id` from the ESP32 frame header. node_states: HashMap, // ── Accuracy sprint: Kalman tracker, multistatic fusion, eigenvalue counting ── /// Global Kalman-based pose tracker for stable person IDs and smoothed keypoints. pose_tracker: PoseTracker, /// Instant of last tracker update (for computing dt). last_tracker_instant: Option, /// Attention-weighted multi-node CSI fusion engine. multistatic_fuser: MultistaticFuser, /// SVD-based room field model for eigenvalue person counting (None until calibration). field_model: Option, } /// If no ESP32 frame arrives within this duration, source reverts to offline. const ESP32_OFFLINE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); impl AppStateInner { /// Return the effective data source, accounting for ESP32 frame timeout. /// If the source is "esp32" but no frame has arrived in 5 seconds, returns /// "esp32:offline" so the UI can distinguish active vs stale connections. /// Person count: eigenvalue-based if field model is calibrated, else heuristic. /// Uses global frame_history if populated, otherwise the freshest per-node history. fn person_count(&self) -> usize { match self.field_model.as_ref() { Some(fm) => { // Prefer global frame_history (populated by wifi/simulate paths). // Fall back to freshest per-node history (populated by ESP32 paths). let history = if !self.frame_history.is_empty() { &self.frame_history } else { // Find the node with the most recent frame self.node_states.values() .filter(|ns| !ns.frame_history.is_empty()) .max_by_key(|ns| ns.last_frame_time) .map(|ns| &ns.frame_history) .unwrap_or(&self.frame_history) }; field_bridge::occupancy_or_fallback( fm, history, self.smoothed_person_score, self.prev_person_count, ) } None => score_to_person_count(self.smoothed_person_score, self.prev_person_count), } } fn effective_source(&self) -> String { if self.source == "esp32" { if let Some(last) = self.last_esp32_frame { if last.elapsed() > ESP32_OFFLINE_TIMEOUT { return "esp32:offline".to_string(); } } } self.source.clone() } } /// Number of frames retained in `frame_history` for temporal analysis. /// At 500 ms ticks this covers ~50 seconds; at 100 ms ticks ~10 seconds. const FRAME_HISTORY_CAPACITY: usize = 100; type SharedState = Arc>; // ── ESP32 Edge Vitals Packet (ADR-039, magic 0xC511_0002) ──────────────────── /// Decoded vitals packet from ESP32 edge processing pipeline. #[derive(Debug, Clone, Serialize)] struct Esp32VitalsPacket { node_id: u8, presence: bool, fall_detected: bool, motion: bool, breathing_rate_bpm: f64, heartrate_bpm: f64, rssi: i8, n_persons: u8, motion_energy: f32, presence_score: f32, timestamp_ms: u32, } /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). fn parse_esp32_vitals(buf: &[u8]) -> Option { if buf.len() < 32 { return None; } let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); if magic != 0xC511_0002 { return None; } let node_id = buf[4]; let flags = buf[5]; let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]); let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); let rssi = buf[12] as i8; let n_persons = buf[13]; let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); Some(Esp32VitalsPacket { node_id, presence: (flags & 0x01) != 0, fall_detected: (flags & 0x02) != 0, motion: (flags & 0x04) != 0, breathing_rate_bpm: breathing_raw as f64 / 100.0, heartrate_bpm: heartrate_raw as f64 / 10000.0, rssi, n_persons, motion_energy, presence_score, timestamp_ms, }) } // ── ADR-040: WASM Output Packet (magic 0xC511_0004) ─────────────────────────── /// Single WASM event (type + value). #[derive(Debug, Clone, Serialize)] struct WasmEvent { event_type: u8, value: f32, } /// Decoded WASM output packet from ESP32 Tier 3 runtime. #[derive(Debug, Clone, Serialize)] struct WasmOutputPacket { node_id: u8, module_id: u8, events: Vec, } /// Parse a WASM output packet (magic 0xC511_0004). fn parse_wasm_output(buf: &[u8]) -> Option { if buf.len() < 8 { return None; } let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); if magic != 0xC511_0004 { return None; } let node_id = buf[4]; let module_id = buf[5]; let event_count = u16::from_le_bytes([buf[6], buf[7]]) as usize; let mut events = Vec::with_capacity(event_count); let mut offset = 8; for _ in 0..event_count { if offset + 5 > buf.len() { break; } let event_type = buf[offset]; let value = f32::from_le_bytes([ buf[offset + 1], buf[offset + 2], buf[offset + 3], buf[offset + 4], ]); events.push(WasmEvent { event_type, value }); offset += 5; } Some(WasmOutputPacket { node_id, module_id, events, }) } // ── ESP32 UDP frame parser ─────────────────────────────────────────────────── fn parse_esp32_frame(buf: &[u8]) -> Option { if buf.len() < 20 { return None; } let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); if magic != 0xC511_0001 { return None; } // Frame layout (must match firmware csi_collector.c): // [0..3] magic (u32 LE) // [4] node_id (u8) // [5] n_antennas (u8) // [6..7] n_subcarriers (u16 LE) // [8..11] freq_mhz (u32 LE) // [12..15] sequence (u32 LE) // [16] rssi (i8) // [17] noise_floor (i8) // [18..19] reserved // [20..] I/Q data let node_id = buf[4]; let n_antennas = buf[5]; let n_subcarriers = buf[6]; let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]); let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]); let rssi_raw = buf[14] as i8; // Fix RSSI sign: ensure it's always negative (dBm convention). let rssi = if rssi_raw > 0 { rssi_raw.saturating_neg() } else { rssi_raw }; let noise_floor = buf[15] as i8; let iq_start = 20; let n_pairs = n_antennas as usize * n_subcarriers as usize; let expected_len = iq_start + n_pairs * 2; if buf.len() < expected_len { return None; } let mut amplitudes = Vec::with_capacity(n_pairs); let mut phases = Vec::with_capacity(n_pairs); for k in 0..n_pairs { let i_val = buf[iq_start + k * 2] as i8 as f64; let q_val = buf[iq_start + k * 2 + 1] as i8 as f64; amplitudes.push((i_val * i_val + q_val * q_val).sqrt()); phases.push(q_val.atan2(i_val)); } Some(Esp32Frame { magic, node_id, n_antennas, n_subcarriers, freq_mhz, sequence, rssi, noise_floor, amplitudes, phases, }) } // ── Signal field generation ────────────────────────────────────────────────── /// Generate a signal field that reflects where motion and signal changes are occurring. /// /// Instead of a fixed-animation circle, this function uses the actual sensing data: /// - `subcarrier_variances`: per-subcarrier variance computed from the frame history. /// High-variance subcarriers indicate spatial directions where the signal is disrupted. /// - `motion_score`: overall motion intensity [0, 1]. /// - `breathing_rate_hz`: estimated breathing rate in Hz; if > 0, adds a breathing ring. /// - `signal_quality`: overall quality metric [0, 1] modulates field brightness. /// /// The field grid is 20×20 cells representing a top-down view of the room. /// Hotspots are derived from the subcarrier index (treated as an angular bin) so that /// subcarriers with the highest variance produce peaks at the corresponding directions. fn generate_signal_field( _mean_rssi: f64, motion_score: f64, breathing_rate_hz: f64, signal_quality: f64, subcarrier_variances: &[f64], ) -> SignalField { let grid = 20usize; let mut values = vec![0.0f64; grid * grid]; let center = (grid as f64 - 1.0) / 2.0; // Normalise subcarrier variances to [0, 1]. let max_var = subcarrier_variances.iter().cloned().fold(0.0f64, f64::max); let norm_factor = if max_var > 1e-9 { max_var } else { 1.0 }; // For each cell, accumulate contributions from all subcarriers. // Each subcarrier k is assigned an angular direction proportional to its index // so that different subcarriers illuminate different regions of the room. let n_sub = subcarrier_variances.len().max(1); for (k, &var) in subcarrier_variances.iter().enumerate() { let weight = (var / norm_factor) * motion_score; if weight < 1e-6 { continue; } // Map subcarrier index to an angle across the full 2π sweep. let angle = (k as f64 / n_sub as f64) * 2.0 * std::f64::consts::PI; // Place the hotspot at a distance proportional to the weight, capped at 40% of // the grid radius so it stays within the room model. let radius = center * 0.8 * weight.sqrt(); let hx = center + radius * angle.cos(); let hz = center + radius * angle.sin(); for z in 0..grid { for x in 0..grid { let dx = x as f64 - hx; let dz = z as f64 - hz; let dist2 = dx * dx + dz * dz; // Gaussian blob centred on the hotspot; spread scales with weight. let spread = (0.5 + weight * 2.0).max(0.5); values[z * grid + x] += weight * (-dist2 / (2.0 * spread * spread)).exp(); } } } // Base radial attenuation from the router assumed at grid centre. for z in 0..grid { for x in 0..grid { let dx = x as f64 - center; let dz = z as f64 - center; let dist = (dx * dx + dz * dz).sqrt(); let base = signal_quality * (-dist * 0.12).exp(); values[z * grid + x] += base * 0.3; } } // Breathing ring: if a breathing rate was estimated add a faint annular highlight // at a radius corresponding to typical chest-wall displacement range. if breathing_rate_hz > 0.05 { let ring_r = center * 0.55; let ring_width = 1.8f64; for z in 0..grid { for x in 0..grid { let dx = x as f64 - center; let dz = z as f64 - center; let dist = (dx * dx + dz * dz).sqrt(); let ring_val = 0.08 * (-(dist - ring_r).powi(2) / (2.0 * ring_width * ring_width)).exp(); values[z * grid + x] += ring_val; } } } // Clamp and normalise to [0, 1]. let field_max = values.iter().cloned().fold(0.0f64, f64::max); let scale = if field_max > 1e-9 { 1.0 / field_max } else { 1.0 }; for v in &mut values { *v = (*v * scale).clamp(0.0, 1.0); } SignalField { grid_size: [grid, 1, grid], values, } } // ── Feature extraction from ESP32 frame ────────────────────────────────────── /// Estimate breathing rate in Hz from the amplitude time series stored in `frame_history`. /// /// Approach: /// 1. Build a scalar time series by computing the mean amplitude of each historical frame. /// 2. Run a peak-detection pass: count rising-edge zero-crossings of the de-meaned signal. /// 3. Convert the crossing rate to Hz, clipped to the physiological range 0.1–0.5 Hz /// (12–30 breaths/min). /// /// For accuracy the function additionally applies a simple 3-tap Goertzel-style power /// estimate at evenly-spaced candidate frequencies in the breathing band and returns /// the candidate with the highest energy. fn estimate_breathing_rate_hz(frame_history: &VecDeque>, sample_rate_hz: f64) -> f64 { let n = frame_history.len(); if n < 6 { return 0.0; } // Build scalar time series: mean amplitude per frame. let series: Vec = frame_history.iter() .map(|amps| { if amps.is_empty() { 0.0 } else { amps.iter().sum::() / amps.len() as f64 } }) .collect(); let mean_s = series.iter().sum::() / n as f64; // De-mean. let detrended: Vec = series.iter().map(|x| x - mean_s).collect(); // Goertzel power at candidate frequencies in the breathing band [0.1, 0.5] Hz. // We evaluate 9 candidate frequencies uniformly spaced in that band. let n_candidates = 9usize; let f_low = 0.1f64; let f_high = 0.5f64; let mut best_freq = 0.0f64; let mut best_power = 0.0f64; for i in 0..n_candidates { let freq = f_low + (f_high - f_low) * i as f64 / (n_candidates - 1).max(1) as f64; let omega = 2.0 * std::f64::consts::PI * freq / sample_rate_hz; let coeff = 2.0 * omega.cos(); let mut s_prev2 = 0.0f64; let mut s_prev1 = 0.0f64; for &x in &detrended { let s = x + coeff * s_prev1 - s_prev2; s_prev2 = s_prev1; s_prev1 = s; } // Goertzel magnitude squared. let power = s_prev2 * s_prev2 + s_prev1 * s_prev1 - coeff * s_prev1 * s_prev2; if power > best_power { best_power = power; best_freq = freq; } } // Only report a breathing rate if the Goertzel energy is meaningfully above noise. // Threshold: power must exceed 10× the average power across all candidates. let avg_power = { let mut total = 0.0f64; for i in 0..n_candidates { let freq = f_low + (f_high - f_low) * i as f64 / (n_candidates - 1).max(1) as f64; let omega = 2.0 * std::f64::consts::PI * freq / sample_rate_hz; let coeff = 2.0 * omega.cos(); let mut s_prev2 = 0.0f64; let mut s_prev1 = 0.0f64; for &x in &detrended { let s = x + coeff * s_prev1 - s_prev2; s_prev2 = s_prev1; s_prev1 = s; } total += s_prev2 * s_prev2 + s_prev1 * s_prev1 - coeff * s_prev1 * s_prev2; } total / n_candidates as f64 }; if best_power > avg_power * 3.0 { best_freq.clamp(f_low, f_high) } else { 0.0 } } /// Compute per-subcarrier variance across the sliding window of `frame_history`. /// /// For each subcarrier index `k`, returns `Var[A_k]` over all stored frames. /// This captures spatial signal variation; subcarriers whose amplitude fluctuates /// heavily across time correspond to directions with motion. /// Compute per-subcarrier importance weights using a simple sensitivity split. /// /// Subcarriers whose sensitivity (amplitude magnitude) is above the median are /// considered "sensitive" and receive weight `1.0 + (sens / max_sens)` (range 1.0–2.0). /// The rest receive a baseline weight of 0.5. This mirrors the RuVector mincut /// partition logic without requiring the graph dependency. fn compute_subcarrier_importance_weights(sensitivity: &[f64]) -> Vec { let n = sensitivity.len(); if n == 0 { return vec![]; } let max_sens = sensitivity.iter().cloned().fold(f64::NEG_INFINITY, f64::max).max(1e-9); // Compute median via a sorted copy. let mut sorted = sensitivity.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let median = if n % 2 == 0 { (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 } else { sorted[n / 2] }; sensitivity .iter() .map(|&s| { if s >= median { 1.0 + (s / max_sens).min(1.0) } else { 0.5 } }) .collect() } fn compute_subcarrier_variances(frame_history: &VecDeque>, n_sub: usize) -> Vec { if frame_history.is_empty() || n_sub == 0 { return vec![0.0; n_sub]; } let n_frames = frame_history.len() as f64; let mut means = vec![0.0f64; n_sub]; let mut sq_means = vec![0.0f64; n_sub]; for frame in frame_history.iter() { for k in 0..n_sub { let a = if k < frame.len() { frame[k] } else { 0.0 }; means[k] += a; sq_means[k] += a * a; } } (0..n_sub) .map(|k| { let mean = means[k] / n_frames; let sq_mean = sq_means[k] / n_frames; (sq_mean - mean * mean).max(0.0) }) .collect() } /// Extract features from the current ESP32 frame, enhanced with temporal context from /// `frame_history`. /// /// Improvements over the previous single-frame approach: /// /// - **Variance**: computed as the mean of per-subcarrier temporal variance across the /// sliding window, not just the intra-frame spatial variance. /// - **Motion detection**: uses frame-to-frame temporal difference (mean L2 change /// between the current frame and the previous frame) normalised by signal amplitude, /// so that actual changes are detected rather than just a threshold on the current frame. /// - **Breathing rate**: estimated via Goertzel filter bank on the 0.1–0.5 Hz band of /// the amplitude time series. /// - **Signal quality**: based on SNR estimate (RSSI – noise floor) and subcarrier /// variance stability. /// Returns (features, raw_classification, breathing_rate_hz, sub_variances, raw_motion_score). fn extract_features_from_frame( frame: &Esp32Frame, frame_history: &VecDeque>, sample_rate_hz: f64, ) -> (FeatureInfo, ClassificationInfo, f64, Vec, f64) { let n_sub = frame.amplitudes.len().max(1); let n = n_sub as f64; let mean_rssi = frame.rssi as f64; // ── RuVector Phase 1: subcarrier importance weighting ── // Compute per-subcarrier sensitivity from amplitude magnitude, then weight // sensitive subcarriers higher (>1.0) and insensitive ones lower (0.5). // This emphasises body-motion-correlated subcarriers in all downstream metrics. let sub_sensitivity: Vec = frame.amplitudes.iter().map(|a| a.abs()).collect(); let importance_weights = compute_subcarrier_importance_weights(&sub_sensitivity); let weight_sum: f64 = importance_weights.iter().sum::(); let mean_amp: f64 = if weight_sum > 0.0 { frame.amplitudes.iter().zip(importance_weights.iter()) .map(|(a, w)| a * w) .sum::() / weight_sum } else { frame.amplitudes.iter().sum::() / n }; // ── Intra-frame subcarrier variance (weighted by importance) ── let intra_variance: f64 = if weight_sum > 0.0 { frame.amplitudes.iter().zip(importance_weights.iter()) .map(|(a, w)| w * (a - mean_amp).powi(2)) .sum::() / weight_sum } else { frame.amplitudes.iter() .map(|a| (a - mean_amp).powi(2)) .sum::() / n }; // ── Temporal (sliding-window) per-subcarrier variance ── let sub_variances = compute_subcarrier_variances(frame_history, n_sub); let temporal_variance: f64 = if sub_variances.is_empty() { intra_variance } else { sub_variances.iter().sum::() / sub_variances.len() as f64 }; // Use the larger of intra-frame and temporal variance as the reported variance. let variance = intra_variance.max(temporal_variance); // ── Spectral power ── let spectral_power: f64 = frame.amplitudes.iter().map(|a| a * a).sum::() / n; // ── Motion band power (upper half of subcarriers, high spatial frequency) ── let half = frame.amplitudes.len() / 2; let motion_band_power = if half > 0 { frame.amplitudes[half..].iter() .map(|a| (a - mean_amp).powi(2)) .sum::() / (frame.amplitudes.len() - half) as f64 } else { 0.0 }; // ── Breathing band power (lower half of subcarriers, low spatial frequency) ── let breathing_band_power = if half > 0 { frame.amplitudes[..half].iter() .map(|a| (a - mean_amp).powi(2)) .sum::() / half as f64 } else { 0.0 }; // ── Dominant frequency via peak subcarrier index ── let peak_idx = frame.amplitudes.iter() .enumerate() .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(i, _)| i) .unwrap_or(0); let dominant_freq_hz = peak_idx as f64 * 0.05; // ── Change point detection (threshold-crossing count in current frame) ── let threshold = mean_amp * 1.2; let change_points = frame.amplitudes.windows(2) .filter(|w| (w[0] < threshold) != (w[1] < threshold)) .count(); // ── Motion score: sliding-window temporal difference ── // Compare current frame against the most recent historical frame. // The difference is normalised by the mean amplitude to be scale-invariant. let temporal_motion_score = if let Some(prev_frame) = frame_history.back() { let n_cmp = n_sub.min(prev_frame.len()); if n_cmp > 0 { let diff_energy: f64 = (0..n_cmp) .map(|k| (frame.amplitudes[k] - prev_frame[k]).powi(2)) .sum::() / n_cmp as f64; // Normalise by mean squared amplitude to get a dimensionless ratio. let ref_energy = mean_amp * mean_amp + 1e-9; (diff_energy / ref_energy).sqrt().clamp(0.0, 1.0) } else { 0.0 } } else { // No history yet — fall back to intra-frame variance-based estimate. (intra_variance / (mean_amp * mean_amp + 1e-9)).sqrt().clamp(0.0, 1.0) }; // Blend temporal motion with variance-based motion for robustness. // Also factor in motion_band_power and change_points for ESP32 real-world sensitivity. let variance_motion = (temporal_variance / 10.0).clamp(0.0, 1.0); let mbp_motion = (motion_band_power / 25.0).clamp(0.0, 1.0); let cp_motion = (change_points as f64 / 15.0).clamp(0.0, 1.0); let motion_score = (temporal_motion_score * 0.4 + variance_motion * 0.2 + mbp_motion * 0.25 + cp_motion * 0.15).clamp(0.0, 1.0); // ── Signal quality metric ── // Based on estimated SNR (RSSI relative to noise floor) and subcarrier consistency. let snr_db = (frame.rssi as f64 - frame.noise_floor as f64).max(0.0); let snr_quality = (snr_db / 40.0).clamp(0.0, 1.0); // 40 dB → quality = 1.0 // Penalise quality when temporal variance is very high (unstable signal). let stability = (1.0 - (temporal_variance / (mean_amp * mean_amp + 1e-9)).clamp(0.0, 1.0)).max(0.0); let signal_quality = (snr_quality * 0.6 + stability * 0.4).clamp(0.0, 1.0); // ── Breathing rate estimation ── let breathing_rate_hz = estimate_breathing_rate_hz(frame_history, sample_rate_hz); let features = FeatureInfo { mean_rssi, variance, motion_band_power, breathing_band_power, dominant_freq_hz, change_points, spectral_power, }; // Return raw motion_score and signal_quality — classification is done by // `smooth_and_classify()` which has access to EMA state and hysteresis. let raw_classification = ClassificationInfo { motion_level: raw_classify(motion_score), presence: motion_score > 0.04, confidence: (0.4 + signal_quality * 0.3 + motion_score * 0.3).clamp(0.0, 1.0), }; (features, raw_classification, breathing_rate_hz, sub_variances, motion_score) } /// Simple threshold classification (no smoothing) — used as the "raw" input. fn raw_classify(score: f64) -> String { if score > 0.25 { "active".into() } else if score > 0.12 { "present_moving".into() } else if score > 0.04 { "present_still".into() } else { "absent".into() } } /// Debounce frames required before state transition (at ~10 FPS = ~0.4s). const DEBOUNCE_FRAMES: u32 = 4; /// EMA alpha for motion smoothing (~1s time constant at 10 FPS). const MOTION_EMA_ALPHA: f64 = 0.15; /// EMA alpha for slow-adapting baseline (~30s time constant at 10 FPS). const BASELINE_EMA_ALPHA: f64 = 0.003; /// Number of warm-up frames before baseline subtraction kicks in. const BASELINE_WARMUP: u64 = 50; /// Apply EMA smoothing, adaptive baseline subtraction, and hysteresis debounce /// to the raw classification. Mutates the smoothing state in `AppStateInner`. fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo, raw_motion: f64) { // 1. Adaptive baseline: slowly track the "quiet room" floor. // Only update baseline when raw score is below the current smoothed level // (i.e. during calm periods) so walking doesn't inflate the baseline. state.baseline_frames += 1; if state.baseline_frames < BASELINE_WARMUP { // During warm-up, aggressively learn the baseline. state.baseline_motion = state.baseline_motion * 0.9 + raw_motion * 0.1; } else if raw_motion < state.smoothed_motion + 0.05 { state.baseline_motion = state.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA; } // 2. Subtract baseline and clamp. let adjusted = (raw_motion - state.baseline_motion * 0.7).max(0.0); // 3. EMA smooth the adjusted score. state.smoothed_motion = state.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA; let sm = state.smoothed_motion; // 4. Classify from smoothed score. let candidate = raw_classify(sm); // 5. Hysteresis debounce: require N consecutive frames agreeing on a new state. if candidate == state.current_motion_level { // Already in this state — reset debounce. state.debounce_counter = 0; state.debounce_candidate = candidate; } else if candidate == state.debounce_candidate { state.debounce_counter += 1; if state.debounce_counter >= DEBOUNCE_FRAMES { // Transition accepted. state.current_motion_level = candidate; state.debounce_counter = 0; } } else { // New candidate — restart counter. state.debounce_candidate = candidate; state.debounce_counter = 1; } // 6. Write the smoothed result back into the classification. raw.motion_level = state.current_motion_level.clone(); raw.presence = sm > 0.03; raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0); } /// Per-node variant of `smooth_and_classify` that operates on a `NodeState` /// instead of `AppStateInner` (issue #249). fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo, raw_motion: f64) { ns.baseline_frames += 1; if ns.baseline_frames < BASELINE_WARMUP { ns.baseline_motion = ns.baseline_motion * 0.9 + raw_motion * 0.1; } else if raw_motion < ns.smoothed_motion + 0.05 { ns.baseline_motion = ns.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA; } let adjusted = (raw_motion - ns.baseline_motion * 0.7).max(0.0); ns.smoothed_motion = ns.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA; let sm = ns.smoothed_motion; let candidate = raw_classify(sm); if candidate == ns.current_motion_level { ns.debounce_counter = 0; ns.debounce_candidate = candidate; } else if candidate == ns.debounce_candidate { ns.debounce_counter += 1; if ns.debounce_counter >= DEBOUNCE_FRAMES { ns.current_motion_level = candidate; ns.debounce_counter = 0; } } else { ns.debounce_candidate = candidate; ns.debounce_counter = 1; } raw.motion_level = ns.current_motion_level.clone(); raw.presence = sm > 0.03; raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0); } /// If an adaptive model is loaded, override the classification with the /// model's prediction. Uses the full 15-feature vector for higher accuracy. fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classification: &mut ClassificationInfo) { if let Some(ref model) = state.adaptive_model { // Get current frame amplitudes from the latest history entry. let amps = state.frame_history.back() .map(|v| v.as_slice()) .unwrap_or(&[]); let feat_arr = adaptive_classifier::features_from_runtime( &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, "dominant_freq_hz": features.dominant_freq_hz, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), amps, ); let (label, conf) = model.classify(&feat_arr); classification.motion_level = label.to_string(); classification.presence = label != "absent"; // Blend model confidence with existing smoothed confidence. classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0); } } /// Size of the median filter window for vital signs outlier rejection. const VITAL_MEDIAN_WINDOW: usize = 21; /// EMA alpha for vital signs (~5s time constant at 10 FPS). const VITAL_EMA_ALPHA: f64 = 0.02; /// Maximum BPM jump per frame before a value is rejected as an outlier. const HR_MAX_JUMP: f64 = 8.0; const BR_MAX_JUMP: f64 = 2.0; /// Minimum change from current smoothed value before EMA updates (dead-band). /// Prevents micro-drift from creeping in. const HR_DEAD_BAND: f64 = 2.0; const BR_DEAD_BAND: f64 = 0.5; /// Smooth vital signs using median-filter outlier rejection + EMA. /// Mutates `state.smoothed_hr`, `state.smoothed_br`, etc. /// Returns the smoothed VitalSigns to broadcast. fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns { let raw_hr = raw.heart_rate_bpm.unwrap_or(0.0); let raw_br = raw.breathing_rate_bpm.unwrap_or(0.0); // -- Outlier rejection: skip values that jump too far from current EMA -- let hr_ok = state.smoothed_hr < 1.0 || (raw_hr - state.smoothed_hr).abs() < HR_MAX_JUMP; let br_ok = state.smoothed_br < 1.0 || (raw_br - state.smoothed_br).abs() < BR_MAX_JUMP; // Push into buffer (only non-outlier values) if hr_ok && raw_hr > 0.0 { state.hr_buffer.push_back(raw_hr); if state.hr_buffer.len() > VITAL_MEDIAN_WINDOW { state.hr_buffer.pop_front(); } } if br_ok && raw_br > 0.0 { state.br_buffer.push_back(raw_br); if state.br_buffer.len() > VITAL_MEDIAN_WINDOW { state.br_buffer.pop_front(); } } // Compute trimmed mean: drop top/bottom 25% then average the middle 50%. // This is more stable than pure median and less noisy than raw mean. let trimmed_hr = trimmed_mean(&state.hr_buffer); let trimmed_br = trimmed_mean(&state.br_buffer); // EMA smooth with dead-band: only update if the trimmed mean differs // from the current smoothed value by more than the dead-band. // This prevents the display from constantly creeping by tiny amounts. if trimmed_hr > 0.0 { if state.smoothed_hr < 1.0 { state.smoothed_hr = trimmed_hr; } else if (trimmed_hr - state.smoothed_hr).abs() > HR_DEAD_BAND { state.smoothed_hr = state.smoothed_hr * (1.0 - VITAL_EMA_ALPHA) + trimmed_hr * VITAL_EMA_ALPHA; } // else: within dead-band, hold current value } if trimmed_br > 0.0 { if state.smoothed_br < 1.0 { state.smoothed_br = trimmed_br; } else if (trimmed_br - state.smoothed_br).abs() > BR_DEAD_BAND { state.smoothed_br = state.smoothed_br * (1.0 - VITAL_EMA_ALPHA) + trimmed_br * VITAL_EMA_ALPHA; } } // Smooth confidence state.smoothed_hr_conf = state.smoothed_hr_conf * 0.92 + raw.heartbeat_confidence * 0.08; state.smoothed_br_conf = state.smoothed_br_conf * 0.92 + raw.breathing_confidence * 0.08; VitalSigns { breathing_rate_bpm: if state.smoothed_br > 1.0 { Some(state.smoothed_br) } else { None }, heart_rate_bpm: if state.smoothed_hr > 1.0 { Some(state.smoothed_hr) } else { None }, breathing_confidence: state.smoothed_br_conf, heartbeat_confidence: state.smoothed_hr_conf, signal_quality: raw.signal_quality, } } /// Per-node variant of `smooth_vitals` that operates on a `NodeState` (issue #249). fn smooth_vitals_node(ns: &mut NodeState, raw: &VitalSigns) -> VitalSigns { let raw_hr = raw.heart_rate_bpm.unwrap_or(0.0); let raw_br = raw.breathing_rate_bpm.unwrap_or(0.0); let hr_ok = ns.smoothed_hr < 1.0 || (raw_hr - ns.smoothed_hr).abs() < HR_MAX_JUMP; let br_ok = ns.smoothed_br < 1.0 || (raw_br - ns.smoothed_br).abs() < BR_MAX_JUMP; if hr_ok && raw_hr > 0.0 { ns.hr_buffer.push_back(raw_hr); if ns.hr_buffer.len() > VITAL_MEDIAN_WINDOW { ns.hr_buffer.pop_front(); } } if br_ok && raw_br > 0.0 { ns.br_buffer.push_back(raw_br); if ns.br_buffer.len() > VITAL_MEDIAN_WINDOW { ns.br_buffer.pop_front(); } } let trimmed_hr = trimmed_mean(&ns.hr_buffer); let trimmed_br = trimmed_mean(&ns.br_buffer); if trimmed_hr > 0.0 { if ns.smoothed_hr < 1.0 { ns.smoothed_hr = trimmed_hr; } else if (trimmed_hr - ns.smoothed_hr).abs() > HR_DEAD_BAND { ns.smoothed_hr = ns.smoothed_hr * (1.0 - VITAL_EMA_ALPHA) + trimmed_hr * VITAL_EMA_ALPHA; } } if trimmed_br > 0.0 { if ns.smoothed_br < 1.0 { ns.smoothed_br = trimmed_br; } else if (trimmed_br - ns.smoothed_br).abs() > BR_DEAD_BAND { ns.smoothed_br = ns.smoothed_br * (1.0 - VITAL_EMA_ALPHA) + trimmed_br * VITAL_EMA_ALPHA; } } ns.smoothed_hr_conf = ns.smoothed_hr_conf * 0.92 + raw.heartbeat_confidence * 0.08; ns.smoothed_br_conf = ns.smoothed_br_conf * 0.92 + raw.breathing_confidence * 0.08; VitalSigns { breathing_rate_bpm: if ns.smoothed_br > 1.0 { Some(ns.smoothed_br) } else { None }, heart_rate_bpm: if ns.smoothed_hr > 1.0 { Some(ns.smoothed_hr) } else { None }, breathing_confidence: ns.smoothed_br_conf, heartbeat_confidence: ns.smoothed_hr_conf, signal_quality: raw.signal_quality, } } /// Trimmed mean: sort, drop top/bottom 25%, average the middle 50%. /// More robust than median (uses more data) and less noisy than raw mean. fn trimmed_mean(buf: &VecDeque) -> f64 { if buf.is_empty() { return 0.0; } let mut sorted: Vec = buf.iter().copied().collect(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let n = sorted.len(); let trim = n / 4; // drop 25% from each end let middle = &sorted[trim..n - trim.max(0)]; if middle.is_empty() { sorted[n / 2] // fallback to median if too few samples } else { middle.iter().sum::() / middle.len() as f64 } } // ── Windows WiFi RSSI collector ────────────────────────────────────────────── /// Parse `netsh wlan show interfaces` output for RSSI and signal quality fn parse_netsh_interfaces_output(output: &str) -> Option<(f64, f64, String)> { let mut rssi = None; let mut signal = None; let mut ssid = None; for line in output.lines() { let line = line.trim(); if line.starts_with("Signal") { // "Signal : 89%" if let Some(pct) = line.split(':').nth(1) { let pct = pct.trim().trim_end_matches('%'); if let Ok(v) = pct.parse::() { signal = Some(v); // Convert signal% to approximate dBm: -100 + (signal% * 0.6) rssi = Some(-100.0 + v * 0.6); } } } if line.starts_with("SSID") && !line.starts_with("BSSID") { if let Some(s) = line.split(':').nth(1) { ssid = Some(s.trim().to_string()); } } } match (rssi, signal, ssid) { (Some(r), Some(_s), Some(name)) => Some((r, _s, name)), (Some(r), Some(_s), None) => Some((r, _s, "Unknown".into())), _ => None, } } async fn windows_wifi_task(state: SharedState, tick_ms: u64) { let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); let mut seq: u32 = 0; // ADR-022 Phase 3: Multi-BSSID pipeline state (kept across ticks) let mut registry = BssidRegistry::new(32, 30); let mut pipeline = WindowsWifiPipeline::new(); info!( "Windows WiFi multi-BSSID pipeline active (tick={}ms, max_bssids=32)", tick_ms ); loop { interval.tick().await; seq += 1; // ── Step 1: Run multi-BSSID scan via spawn_blocking ────────── // NetshBssidScanner is not Send, so we run `netsh` and parse // the output inside a blocking closure. let bssid_scan_result = tokio::task::spawn_blocking(|| { let output = std::process::Command::new("netsh") .args(["wlan", "show", "networks", "mode=bssid"]) .output() .map_err(|e| format!("netsh bssid scan failed: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "netsh exited with {}: {}", output.status, stderr.trim() )); } let stdout = String::from_utf8_lossy(&output.stdout); parse_netsh_bssid_output(&stdout).map_err(|e| format!("parse error: {e}")) }) .await; // Unwrap the JoinHandle result, then the inner Result. let observations = match bssid_scan_result { Ok(Ok(obs)) if !obs.is_empty() => obs, Ok(Ok(_empty)) => { debug!("Multi-BSSID scan returned 0 observations, falling back"); windows_wifi_fallback_tick(&state, seq).await; continue; } Ok(Err(e)) => { warn!("Multi-BSSID scan error: {e}, falling back"); windows_wifi_fallback_tick(&state, seq).await; continue; } Err(join_err) => { error!("spawn_blocking panicked: {join_err}"); continue; } }; let obs_count = observations.len(); // Derive SSID from the first observation for the source label. let ssid = observations .first() .map(|o| o.ssid.clone()) .unwrap_or_else(|| "Unknown".into()); // ── Step 2: Feed observations into registry ────────────────── registry.update(&observations); let multi_ap_frame = registry.to_multi_ap_frame(); // ── Step 3: Run enhanced pipeline ──────────────────────────── let enhanced = pipeline.process(&multi_ap_frame); // ── Step 4: Build backward-compatible Esp32Frame ───────────── let first_rssi = observations .first() .map(|o| o.rssi_dbm) .unwrap_or(-80.0); let _first_signal_pct = observations .first() .map(|o| o.signal_pct) .unwrap_or(40.0); let frame = Esp32Frame { magic: 0xC511_0001, node_id: 0, n_antennas: 1, n_subcarriers: obs_count.min(255) as u8, freq_mhz: 2437, sequence: seq, rssi: first_rssi.clamp(-128.0, 127.0) as i8, noise_floor: -90, amplitudes: multi_ap_frame.amplitudes.clone(), phases: multi_ap_frame.phases.clone(), }; // ── Step 4b: Update frame history and extract features ─────── let mut s_write_pre = state.write().await; s_write_pre.frame_history.push_back(frame.amplitudes.clone()); if s_write_pre.frame_history.len() > FRAME_HISTORY_CAPACITY { s_write_pre.frame_history.pop_front(); } let sample_rate_hz = 1000.0 / tick_ms as f64; let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s_write_pre.frame_history, sample_rate_hz); smooth_and_classify(&mut s_write_pre, &mut classification, raw_motion); adaptive_override(&s_write_pre, &features, &mut classification); drop(s_write_pre); // ── Step 5: Build enhanced fields from pipeline result ─────── let enhanced_motion = Some(serde_json::json!({ "score": enhanced.motion.score, "level": format!("{:?}", enhanced.motion.level), "contributing_bssids": enhanced.motion.contributing_bssids, })); let enhanced_breathing = enhanced.breathing.as_ref().map(|b| { serde_json::json!({ "rate_bpm": b.rate_bpm, "confidence": b.confidence, "bssid_count": b.bssid_count, }) }); let posture_str = enhanced.posture.map(|p| format!("{p:?}")); let sig_quality_score = Some(enhanced.signal_quality.score); let verdict_str = Some(format!("{:?}", enhanced.verdict)); let bssid_n = Some(enhanced.bssid_count); // ── Step 6: Update shared state ────────────────────────────── let mut s = state.write().await; s.source = format!("wifi:{ssid}"); s.rssi_history.push_back(first_rssi); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } s.tick += 1; let tick = s.tick; let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; let raw_vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); let feat_variance = features.variance; // Multi-person estimation with temporal smoothing (EMA α=0.10). let raw_score = compute_person_score(&features); s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10; let est_persons = if classification.presence { let count = s.person_count(); s.prev_person_count = count; count } else { s.prev_person_count = 0; 0 }; let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: format!("wifi:{ssid}"), tick, nodes: vec![NodeInfo { node_id: 0, rssi_dbm: first_rssi, position: [0.0, 0.0, 0.0], amplitude: multi_ap_frame.amplitudes, subcarrier_count: obs_count, }], features, classification, signal_field: generate_signal_field( first_rssi, motion_score, breathing_rate_hz, feat_variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion, enhanced_breathing, posture: posture_str, signal_quality_score: sig_quality_score, quality_verdict: verdict_str, bssid_count: bssid_n, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, node_features: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); debug!( "Multi-BSSID tick #{tick}: {obs_count} BSSIDs, quality={:.2}, verdict={:?}", enhanced.signal_quality.score, enhanced.verdict ); } } /// Fallback: single-RSSI collection via `netsh wlan show interfaces`. /// /// Used when the multi-BSSID scan fails or returns 0 observations. async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { let output = match tokio::process::Command::new("netsh") .args(["wlan", "show", "interfaces"]) .output() .await { Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(), Err(e) => { warn!("netsh interfaces fallback failed: {e}"); return; } }; let (rssi_dbm, signal_pct, ssid) = match parse_netsh_interfaces_output(&output) { Some(v) => v, None => { debug!("Fallback: no WiFi interface connected"); return; } }; let frame = Esp32Frame { magic: 0xC511_0001, node_id: 0, n_antennas: 1, n_subcarriers: 1, freq_mhz: 2437, sequence: seq, rssi: rssi_dbm as i8, noise_floor: -90, amplitudes: vec![signal_pct], phases: vec![0.0], }; let mut s = state.write().await; // Update frame history before extracting features. s.frame_history.push_back(frame.amplitudes.clone()); if s.frame_history.len() > FRAME_HISTORY_CAPACITY { s.frame_history.pop_front(); } let sample_rate_hz = 2.0_f64; // fallback tick ~ 500 ms => 2 Hz let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); smooth_and_classify(&mut s, &mut classification, raw_motion); adaptive_override(&s, &features, &mut classification); s.source = format!("wifi:{ssid}"); s.rssi_history.push_back(rssi_dbm); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } s.tick += 1; let tick = s.tick; let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; let raw_vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); let feat_variance = features.variance; // Multi-person estimation with temporal smoothing (EMA α=0.10). let raw_score = compute_person_score(&features); s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10; let est_persons = if classification.presence { let count = s.person_count(); s.prev_person_count = count; count } else { s.prev_person_count = 0; 0 }; let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: format!("wifi:{ssid}"), tick, nodes: vec![NodeInfo { node_id: 0, rssi_dbm, position: [0.0, 0.0, 0.0], amplitude: vec![signal_pct], subcarrier_count: 1, }], features, classification, signal_field: generate_signal_field( rssi_dbm, motion_score, breathing_rate_hz, feat_variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, node_features: None, }; let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); } /// Probe if Windows WiFi is connected async fn probe_windows_wifi() -> bool { match tokio::process::Command::new("netsh") .args(["wlan", "show", "interfaces"]) .output() .await { Ok(o) => { let out = String::from_utf8_lossy(&o.stdout); parse_netsh_interfaces_output(&out).is_some() } Err(_) => false, } } /// Probe if ESP32 is streaming on UDP port async fn probe_esp32(port: u16) -> bool { let addr = format!("0.0.0.0:{port}"); match UdpSocket::bind(&addr).await { Ok(sock) => { let mut buf = [0u8; 256]; match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await { Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(), _ => false, } } Err(_) => false, } } // ── Simulated data generator ───────────────────────────────────────────────── fn generate_simulated_frame(tick: u64) -> Esp32Frame { let t = tick as f64 * 0.1; let n_sub = 56usize; let mut amplitudes = Vec::with_capacity(n_sub); let mut phases = Vec::with_capacity(n_sub); for i in 0..n_sub { let base = 15.0 + 5.0 * (i as f64 * 0.1 + t * 0.3).sin(); let noise = (i as f64 * 7.3 + t * 13.7).sin() * 2.0; amplitudes.push((base + noise).max(0.1)); phases.push((i as f64 * 0.2 + t * 0.5).sin() * std::f64::consts::PI); } Esp32Frame { magic: 0xC511_0001, node_id: 1, n_antennas: 1, n_subcarriers: n_sub as u8, freq_mhz: 2437, sequence: tick as u32, rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8, noise_floor: -90, amplitudes, phases, } } // ── WebSocket handler ──────────────────────────────────────────────────────── async fn ws_sensing_handler( ws: WebSocketUpgrade, State(state): State, ) -> impl IntoResponse { ws.on_upgrade(|socket| handle_ws_client(socket, state)) } async fn handle_ws_client(mut socket: WebSocket, state: SharedState) { let mut rx = { let s = state.read().await; s.tx.subscribe() }; info!("WebSocket client connected (sensing)"); loop { tokio::select! { msg = rx.recv() => { match msg { Ok(json) => { if socket.send(Message::Text(json.into())).await.is_err() { break; } } Err(_) => break, } } msg = socket.recv() => { match msg { Some(Ok(Message::Close(_))) | None => break, _ => {} // ignore client messages } } } } info!("WebSocket client disconnected (sensing)"); } // ── ADR-099: real-time CSI introspection — WS topic + REST snapshot ────────── // // Parallel to the window-aggregated `/ws/sensing` topic. Subscribers see a // fresh `IntrospectionSnapshot` JSON frame on every accepted CSI frame // (regime / Lyapunov exponent / top-k DTW similarity), no window-close delay. async fn ws_introspection_handler( ws: WebSocketUpgrade, State(state): State, ) -> impl IntoResponse { ws.on_upgrade(|socket| handle_ws_introspection_client(socket, state)) } async fn handle_ws_introspection_client(mut socket: WebSocket, state: SharedState) { let mut rx = { let s = state.read().await; s.intro_tx.subscribe() }; info!("WebSocket client connected (introspection)"); loop { tokio::select! { msg = rx.recv() => { match msg { Ok(json) => { if socket.send(Message::Text(json.into())).await.is_err() { break; } } Err(_) => break, } } msg = socket.recv() => { match msg { Some(Ok(Message::Close(_))) | None => break, _ => {} // ignore client messages } } } } info!("WebSocket client disconnected (introspection)"); } /// `GET /api/v1/introspection/snapshot` — one-shot poll for the latest /// per-frame snapshot (regime, Lyapunov, top-k similarity). Mirrors the shape /// of `/api/v1/sensing/latest` for the dashboard one-shot path. async fn api_introspection_snapshot(State(state): State) -> impl IntoResponse { let s = state.read().await; Json(s.intro.snapshot().clone()) } // ── Pose WebSocket handler (sends pose_data messages for Live Demo) ────────── async fn ws_pose_handler( ws: WebSocketUpgrade, State(state): State, ) -> impl IntoResponse { ws.on_upgrade(|socket| handle_ws_pose_client(socket, state)) } async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) { let mut rx = { let s = state.read().await; s.tx.subscribe() }; info!("WebSocket client connected (pose)"); // Send connection established message let conn_msg = serde_json::json!({ "type": "connection_established", "payload": { "status": "connected", "backend": "rust+ruvector" } }); let _ = socket.send(Message::Text(conn_msg.to_string().into())).await; loop { tokio::select! { msg = rx.recv() => { match msg { Ok(json) => { // Parse the sensing update and convert to pose format if let Ok(sensing) = serde_json::from_str::(&json) { if sensing.msg_type == "sensing_update" { // Determine pose estimation mode for the UI indicator. // "model_inference" — a trained RVF model is loaded. // "signal_derived" — keypoints estimated from raw CSI features. let model_loaded = { let s = state.read().await; s.model_loaded }; let pose_source = if model_loaded { "model_inference" } else { "signal_derived" }; let persons = if model_loaded { // When a trained model is loaded, prefer its keypoints if present. sensing.pose_keypoints.as_ref().map(|kps| { let kp_names = [ "nose","left_eye","right_eye","left_ear","right_ear", "left_shoulder","right_shoulder","left_elbow","right_elbow", "left_wrist","right_wrist","left_hip","right_hip", "left_knee","right_knee","left_ankle","right_ankle", ]; let keypoints: Vec = kps.iter() .enumerate() .map(|(i, kp)| PoseKeypoint { name: kp_names.get(i).unwrap_or(&"unknown").to_string(), x: kp[0], y: kp[1], z: kp[2], confidence: kp[3], }) .collect(); vec![PersonDetection { id: 1, confidence: sensing.classification.confidence, bbox: BoundingBox { x: 260.0, y: 150.0, width: 120.0, height: 220.0 }, keypoints, zone: "zone_1".into(), }] }).unwrap_or_else(|| { // Prefer tracked persons from broadcast if available sensing.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(&sensing)) }) } else { // Prefer tracked persons from broadcast if available sensing.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(&sensing)) }; let pose_msg = serde_json::json!({ "type": "pose_data", "zone_id": "zone_1", "timestamp": sensing.timestamp, "payload": { "pose": { "persons": persons, }, "confidence": if sensing.classification.presence { sensing.classification.confidence } else { 0.0 }, "activity": sensing.classification.motion_level, // pose_source tells the UI which estimation mode is active. "pose_source": pose_source, "metadata": { "frame_id": format!("rust_frame_{}", sensing.tick), "processing_time_ms": 1, "source": sensing.source, "tick": sensing.tick, "signal_strength": sensing.features.mean_rssi, "motion_band_power": sensing.features.motion_band_power, "breathing_band_power": sensing.features.breathing_band_power, "estimated_persons": persons.len(), } } }); if socket.send(Message::Text(pose_msg.to_string().into())).await.is_err() { break; } } } } Err(_) => break, } } msg = socket.recv() => { match msg { Some(Ok(Message::Text(text))) => { // Handle ping/pong if let Ok(v) = serde_json::from_str::(&text) { if v.get("type").and_then(|t| t.as_str()) == Some("ping") { let pong = serde_json::json!({"type": "pong"}); let _ = socket.send(Message::Text(pong.to_string().into())).await; } } } Some(Ok(Message::Close(_))) | None => break, _ => {} } } } } info!("WebSocket client disconnected (pose)"); } // ── REST endpoints ─────────────────────────────────────────────────────────── async fn health(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": "ok", "source": s.effective_source(), "tick": s.tick, "clients": s.tx.receiver_count(), })) } async fn latest(State(state): State) -> Json { let s = state.read().await; match &s.latest_update { Some(update) => Json(serde_json::to_value(update).unwrap_or_default()), None => Json(serde_json::json!({"status": "no data yet"})), } } /// Generate WiFi-derived pose keypoints from sensing data. /// /// Keypoint positions are modulated by real signal features rather than a pure /// time-based sine/cosine loop: /// /// - `motion_band_power` drives whole-body translation and limb splay /// - `variance` seeds per-frame noise so the skeleton never freezes /// - `breathing_band_power` expands/contracts torso keypoints (shoulders, hips) /// - `dominant_freq_hz` tilts the upper body laterally (lean direction) /// - `change_points` adds burst jitter to extremities (wrists, ankles) /// /// When `presence == false` no persons are returned (empty room). /// When walking is detected (`motion_score > 0.55`) the figure shifts laterally /// with a stride-swing pattern applied to arms and legs. // ── Multi-person estimation (issue #97) ────────────────────────────────────── /// Fuse features across all active nodes for higher SNR. /// /// When multiple ESP32 nodes observe the same room, their CSI features /// can be combined: /// - Variance: use max (most sensitive node dominates) /// - Motion/breathing/spectral power: weighted average by RSSI (closer node = higher weight) /// - Dominant frequency: weighted average /// - Change points: keep current node's value (not meaningful to average) /// - Mean RSSI: use max (best signal) fn fuse_multi_node_features( current_features: &FeatureInfo, node_states: &HashMap, ) -> FeatureInfo { let now = std::time::Instant::now(); let active: Vec<(&FeatureInfo, f64)> = node_states.values() .filter(|ns| ns.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10)) .filter_map(|ns| { let feat = ns.latest_features.as_ref()?; let rssi = ns.rssi_history.back().copied().unwrap_or(-80.0); Some((feat, rssi)) }) .collect(); if active.len() <= 1 { return current_features.clone(); } // RSSI-based weights: higher RSSI = closer to person = more weight. // Map RSSI relative to best node into [0.1, 1.0]. let max_rssi = active.iter().map(|(_, r)| *r).fold(f64::NEG_INFINITY, f64::max); let weights: Vec = active.iter() .map(|(_, r)| (1.0 + (r - max_rssi + 20.0) / 20.0).clamp(0.1, 1.0)) .collect(); let w_sum: f64 = weights.iter().sum::().max(1e-9); FeatureInfo { // Weighted average variance (not max — max inflates person score // and causes count flips between 1↔2 persons). variance: active.iter().zip(&weights) .map(|((f, _), w)| f.variance * w).sum::() / w_sum, // Weighted average for motion/breathing/spectral motion_band_power: active.iter().zip(&weights) .map(|((f, _), w)| f.motion_band_power * w).sum::() / w_sum, breathing_band_power: active.iter().zip(&weights) .map(|((f, _), w)| f.breathing_band_power * w).sum::() / w_sum, spectral_power: active.iter().zip(&weights) .map(|((f, _), w)| f.spectral_power * w).sum::() / w_sum, dominant_freq_hz: active.iter().zip(&weights) .map(|((f, _), w)| f.dominant_freq_hz * w).sum::() / w_sum, change_points: current_features.change_points, // keep current node's value // Best RSSI across nodes mean_rssi: active.iter().map(|(f, _)| f.mean_rssi).fold(f64::NEG_INFINITY, f64::max), } } /// Estimate person count from CSI features using a weighted composite heuristic. /// /// Single ESP32 link limitations: variance-based detection can reliably detect /// 1-2 persons. 3+ is speculative and requires ≥3 nodes for spatial resolution. /// /// Returns a raw score (0.0..1.0) that the caller converts to person count /// after temporal smoothing. fn compute_person_score(feat: &FeatureInfo) -> f64 { // Normalize each feature to [0, 1] using ranges calibrated from real // ESP32 hardware (COM6/COM9 on ruv.net, March 2026). let var_norm = (feat.variance / 300.0).clamp(0.0, 1.0); let cp_norm = (feat.change_points as f64 / 30.0).clamp(0.0, 1.0); let motion_norm = (feat.motion_band_power / 250.0).clamp(0.0, 1.0); let sp_norm = (feat.spectral_power / 500.0).clamp(0.0, 1.0); var_norm * 0.40 + cp_norm * 0.20 + motion_norm * 0.25 + sp_norm * 0.15 } /// Estimate person count via ruvector DynamicMinCut on the subcarrier /// temporal correlation graph. /// /// Builds a graph where: /// - Nodes = active subcarriers (variance > noise floor) /// - Edges = Pearson correlation between subcarrier time series /// (weight = correlation coefficient; high correlation = heavy edge) /// - Source = virtual node connected to the most active subcarrier /// - Sink = virtual node connected to the least correlated subcarrier /// /// The min-cut value indicates how many independent motion clusters exist: /// - High min-cut (relative to total edge weight) → one tightly coupled /// group → 1 person /// - Low min-cut → two loosely coupled groups → 2 persons /// /// Uses `ruvector_mincut::DynamicMinCut` for O(V²E) exact max-flow. fn estimate_persons_from_correlation(frame_history: &VecDeque>) -> usize { let n_frames = frame_history.len(); if n_frames < 10 { return 1; } let window: Vec<&Vec> = frame_history.iter().rev().take(20).collect(); let n_sub = window[0].len().min(56); if n_sub < 4 { return 1; } let k = window.len() as f64; // Per-subcarrier mean and variance let mut means = vec![0.0f64; n_sub]; let mut variances = vec![0.0f64; n_sub]; for frame in &window { for sc in 0..n_sub.min(frame.len()) { means[sc] += frame[sc] / k; } } for frame in &window { for sc in 0..n_sub.min(frame.len()) { variances[sc] += (frame[sc] - means[sc]).powi(2) / k; } } // Active subcarriers: variance above noise floor let noise_floor = 1.0; let active: Vec = (0..n_sub).filter(|&sc| variances[sc] > noise_floor).collect(); let m = active.len(); if m < 3 { return if m == 0 { 0 } else { 1 }; } // Build correlation graph edges between active subcarriers. // Edge weight = |Pearson correlation|. High correlation → same person. let mut edges: Vec<(u64, u64, f64)> = Vec::new(); let source = m as u64; let sink = (m + 1) as u64; // Precompute std devs let stds: Vec = active.iter().map(|&sc| variances[sc].sqrt().max(1e-9)).collect(); for i in 0..m { for j in (i + 1)..m { // Pearson correlation between subcarriers i and j let mut cov = 0.0f64; for frame in &window { let si = active[i]; let sj = active[j]; if si < frame.len() && sj < frame.len() { cov += (frame[si] - means[si]) * (frame[sj] - means[sj]) / k; } } let corr = (cov / (stds[i] * stds[j])).abs(); if corr > 0.1 { // Bidirectional edges for flow network let weight = corr * 10.0; // Scale up for integer-like flow edges.push((i as u64, j as u64, weight)); edges.push((j as u64, i as u64, weight)); } } } // Source → highest-variance subcarrier, Sink → lowest-variance let (max_var_idx, _) = active.iter().enumerate() .max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap()) .unwrap_or((0, &0)); let (min_var_idx, _) = active.iter().enumerate() .min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap()) .unwrap_or((0, &0)); if max_var_idx == min_var_idx { return 1; } edges.push((source, max_var_idx as u64, 100.0)); edges.push((min_var_idx as u64, sink, 100.0)); // Run min-cut let mc: DynamicMinCut = match MinCutBuilder::new().exact().with_edges(edges.clone()).build() { Ok(mc) => mc, Err(_) => return 1, }; let cut_value = mc.min_cut_value(); let total_edge_weight: f64 = edges.iter() .filter(|(s, t, _)| *s != source && *s != sink && *t != source && *t != sink) .map(|(_, _, w)| w) .sum::() / 2.0; // bidirectional → halve if total_edge_weight < 1e-9 { return 1; } // Normalized cut ratio: low = easy to split = multiple people let cut_ratio = cut_value / total_edge_weight; if cut_ratio > 0.4 { 1 // Tightly coupled — one person } else if cut_ratio > 0.15 { 2 // Moderately separable — two people } else { 3 // Highly separable — three+ people } } /// Convert smoothed person score to discrete count with hysteresis. /// /// Uses asymmetric thresholds: higher threshold to *add* a person, lower to /// *drop* one. This prevents flickering when the score hovers near a boundary /// (the #1 user-reported issue — see #237, #249, #280, #292). fn score_to_person_count(smoothed_score: f64, prev_count: usize) -> usize { // Up-thresholds (must exceed to increase count): // 1→2: 0.80 (raised from 0.65 — single-person movement in multipath // rooms easily hits 0.65, causing false 2-person detection) // 2→3: 0.92 (raised from 0.85 — 3 persons needs very strong signal) // Down-thresholds (must drop below to decrease count): // 2→1: 0.55 (hysteresis gap of 0.25) // 3→2: 0.78 (hysteresis gap of 0.14) match prev_count { 0 | 1 => { if smoothed_score > 0.85 { 3 } else if smoothed_score > 0.70 { 2 } else { 1 } } 2 => { if smoothed_score > 0.92 { 3 } else if smoothed_score < 0.55 { 1 } else { 2 // hold — within hysteresis band } } _ => { // prev_count >= 3 if smoothed_score < 0.55 { 1 } else if smoothed_score < 0.78 { 2 } else { 3 // hold } } } } /// Generate a single person's skeleton with per-person spatial offset and phase stagger. /// /// `person_idx`: 0-based index of this person. /// `total_persons`: total number of detected persons (for spacing calculation). fn derive_single_person_pose( update: &SensingUpdate, person_idx: usize, total_persons: usize, ) -> PersonDetection { let cls = &update.classification; let feat = &update.features; // Per-person phase offset: ~120 degrees apart so they don't move in sync. let phase_offset = person_idx as f64 * 2.094; // Spatial spread: persons distributed symmetrically around center. let half = (total_persons as f64 - 1.0) / 2.0; let person_x_offset = (person_idx as f64 - half) * 120.0; // 120px spacing // Confidence decays for additional persons (less certain about person 2, 3). let conf_decay = 1.0 - person_idx as f64 * 0.15; // ── Signal-derived scalars ──────────────────────────────────────────────── let motion_score = (feat.motion_band_power / 15.0).clamp(0.0, 1.0); let is_walking = motion_score > 0.55; let breath_amp = (feat.breathing_band_power * 4.0).clamp(0.0, 12.0); let breath_phase = if let Some(ref vs) = update.vital_signs { let bpm = vs.breathing_rate_bpm.unwrap_or(15.0); let freq = (bpm / 60.0).clamp(0.1, 0.5); // Slow tick rate (0.02) for gentle breathing, not jerky oscillation. (update.tick as f64 * freq * 0.02 * std::f64::consts::TAU + phase_offset).sin() } else { (update.tick as f64 * 0.02 + phase_offset).sin() }; let lean_x = (feat.dominant_freq_hz / 5.0 - 1.0).clamp(-1.0, 1.0) * 18.0; let stride_x = if is_walking { let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.06 + phase_offset).sin(); stride_phase * 20.0 * motion_score } else { 0.0 }; // Dampen burst and noise to reduce jitter. The original used // tick*17.3 which changed wildly every frame. Now use slow tick // rate and minimal burst scaling for a stable skeleton. let burst = (feat.change_points as f64 / 20.0).clamp(0.0, 0.3); let noise_seed = person_idx as f64 * 97.1; // stable per-person, no tick let noise_val = (noise_seed.sin() * 43758.545).fract(); let snr_factor = ((feat.variance - 0.5) / 10.0).clamp(0.0, 1.0); let base_confidence = cls.confidence * (0.6 + 0.4 * snr_factor) * conf_decay; // ── Skeleton base position ──────────────────────────────────────────────── let base_x = 320.0 + stride_x + lean_x * 0.5 + person_x_offset; let base_y = 240.0 - motion_score * 8.0; // ── COCO 17-keypoint offsets from hip-center ────────────────────────────── let kp_names = [ "nose", "left_eye", "right_eye", "left_ear", "right_ear", "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", "left_wrist", "right_wrist", "left_hip", "right_hip", "left_knee", "right_knee", "left_ankle", "right_ankle", ]; let kp_offsets: [(f64, f64); 17] = [ ( 0.0, -80.0), // 0 nose ( -8.0, -88.0), // 1 left_eye ( 8.0, -88.0), // 2 right_eye (-16.0, -82.0), // 3 left_ear ( 16.0, -82.0), // 4 right_ear (-30.0, -50.0), // 5 left_shoulder ( 30.0, -50.0), // 6 right_shoulder (-45.0, -15.0), // 7 left_elbow ( 45.0, -15.0), // 8 right_elbow (-50.0, 20.0), // 9 left_wrist ( 50.0, 20.0), // 10 right_wrist (-20.0, 20.0), // 11 left_hip ( 20.0, 20.0), // 12 right_hip (-22.0, 70.0), // 13 left_knee ( 22.0, 70.0), // 14 right_knee (-24.0, 120.0), // 15 left_ankle ( 24.0, 120.0), // 16 right_ankle ]; const TORSO_KP: [usize; 4] = [5, 6, 11, 12]; const EXTREMITY_KP: [usize; 4] = [9, 10, 15, 16]; let keypoints: Vec = kp_names.iter().zip(kp_offsets.iter()) .enumerate() .map(|(i, (name, (dx, dy)))| { let breath_dx = if TORSO_KP.contains(&i) { let sign = if *dx < 0.0 { -1.0 } else { 1.0 }; sign * breath_amp * breath_phase * 0.5 } else { 0.0 }; let breath_dy = if TORSO_KP.contains(&i) { let sign = if *dy < 0.0 { -1.0 } else { 1.0 }; sign * breath_amp * breath_phase * 0.3 } else { 0.0 }; let extremity_jitter = if EXTREMITY_KP.contains(&i) { let phase = noise_seed + i as f64 * 2.399; // Dampened from 12/8 to 4/3 to reduce visual jumping. ( phase.sin() * burst * motion_score * 4.0, (phase * 1.31).cos() * burst * motion_score * 3.0, ) } else { (0.0, 0.0) }; let kp_noise_x = ((noise_seed + i as f64 * 1.618).sin() * 43758.545).fract() * feat.variance.sqrt().clamp(0.0, 3.0) * motion_score; let kp_noise_y = ((noise_seed + i as f64 * 2.718).cos() * 31415.926).fract() * feat.variance.sqrt().clamp(0.0, 3.0) * motion_score * 0.6; let swing_dy = if is_walking { let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12 + phase_offset).sin(); match i { 7 | 9 => -stride_phase * 20.0 * motion_score, 8 | 10 => stride_phase * 20.0 * motion_score, 13 | 15 => stride_phase * 25.0 * motion_score, 14 | 16 => -stride_phase * 25.0 * motion_score, _ => 0.0, } } else { 0.0 }; let final_x = base_x + dx + breath_dx + extremity_jitter.0 + kp_noise_x; let final_y = base_y + dy + breath_dy + extremity_jitter.1 + kp_noise_y + swing_dy; let kp_conf = if EXTREMITY_KP.contains(&i) { base_confidence * (0.7 + 0.3 * snr_factor) * (0.85 + 0.15 * noise_val) } else { base_confidence * (0.88 + 0.12 * ((i as f64 * 0.7 + noise_seed).cos())) }; PoseKeypoint { name: name.to_string(), x: final_x, y: final_y, z: lean_x * 0.02, confidence: kp_conf.clamp(0.1, 1.0), } }) .collect(); let xs: Vec = keypoints.iter().map(|k| k.x).collect(); let ys: Vec = keypoints.iter().map(|k| k.y).collect(); let min_x = xs.iter().cloned().fold(f64::MAX, f64::min) - 10.0; let min_y = ys.iter().cloned().fold(f64::MAX, f64::min) - 10.0; let max_x = xs.iter().cloned().fold(f64::MIN, f64::max) + 10.0; let max_y = ys.iter().cloned().fold(f64::MIN, f64::max) + 10.0; PersonDetection { id: (person_idx + 1) as u32, confidence: cls.confidence * conf_decay, keypoints, bbox: BoundingBox { x: min_x, y: min_y, width: (max_x - min_x).max(80.0), height: (max_y - min_y).max(160.0), }, zone: format!("zone_{}", person_idx + 1), } } fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec { let cls = &update.classification; if !cls.presence { return vec![]; } // Use estimated_persons if set by the tick loop; otherwise default to 1. let person_count = update.estimated_persons.unwrap_or(1).max(1); (0..person_count) .map(|idx| derive_single_person_pose(update, idx, person_count)) .collect() } // ── RuVector Phase 2: Temporal EMA smoothing for keypoints ────────────────── /// Expected bone lengths in pixel-space for the COCO-17 skeleton as used by /// `derive_single_person_pose`. Pairs are (parent_idx, child_idx). const POSE_BONE_PAIRS: &[(usize, usize)] = &[ (5, 7), (7, 9), (6, 8), (8, 10), // arms (5, 11), (6, 12), // torso (11, 13), (13, 15), (12, 14), (14, 16), // legs (5, 6), (11, 12), // shoulders, hips ]; /// Apply temporal EMA smoothing and bone-length clamping to person detections. /// /// For the *first* person (index 0) this uses the per-node `prev_keypoints` /// state. Multi-person smoothing is left for a future phase. fn apply_temporal_smoothing(persons: &mut [PersonDetection], ns: &mut NodeState) { if persons.is_empty() { return; } let alpha = ns.ema_alpha(); let person = &mut persons[0]; // smooth primary person only let current_kps: Vec<[f64; 3]> = person.keypoints.iter() .map(|kp| [kp.x, kp.y, kp.z]) .collect(); let smoothed = if let Some(ref prev) = ns.prev_keypoints { let mut out = Vec::with_capacity(current_kps.len()); for (cur, prv) in current_kps.iter().zip(prev.iter()) { out.push([ alpha * cur[0] + (1.0 - alpha) * prv[0], alpha * cur[1] + (1.0 - alpha) * prv[1], alpha * cur[2] + (1.0 - alpha) * prv[2], ]); } // Clamp bone lengths to ±20% of previous frame. clamp_bone_lengths_f64(&mut out, prev); out } else { current_kps.clone() }; // Write smoothed keypoints back into the person detection. for (kp, s) in person.keypoints.iter_mut().zip(smoothed.iter()) { kp.x = s[0]; kp.y = s[1]; kp.z = s[2]; } ns.prev_keypoints = Some(smoothed); } /// Clamp bone lengths so no bone changes by more than MAX_BONE_CHANGE_RATIO /// compared to the previous frame. fn clamp_bone_lengths_f64(pose: &mut Vec<[f64; 3]>, prev: &[[f64; 3]]) { for &(p, c) in POSE_BONE_PAIRS { if p >= pose.len() || c >= pose.len() { continue; } let prev_len = dist_f64(&prev[p], &prev[c]); if prev_len < 1e-6 { continue; } let cur_len = dist_f64(&pose[p], &pose[c]); if cur_len < 1e-6 { continue; } let ratio = cur_len / prev_len; let lo = 1.0 - MAX_BONE_CHANGE_RATIO; let hi = 1.0 + MAX_BONE_CHANGE_RATIO; if ratio < lo || ratio > hi { let target = prev_len * ratio.clamp(lo, hi); let scale = target / cur_len; for dim in 0..3 { let diff = pose[c][dim] - pose[p][dim]; pose[c][dim] = pose[p][dim] + diff * scale; } } } } fn dist_f64(a: &[f64; 3], b: &[f64; 3]) -> f64 { let dx = b[0] - a[0]; let dy = b[1] - a[1]; let dz = b[2] - a[2]; (dx * dx + dy * dy + dz * dz).sqrt() } // ── DensePose-compatible REST endpoints ───────────────────────────────────── async fn health_live(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": "alive", "uptime": s.start_time.elapsed().as_secs(), })) } async fn health_ready(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": "ready", "source": s.effective_source(), })) } async fn health_system(State(state): State) -> Json { let s = state.read().await; let uptime = s.start_time.elapsed().as_secs(); Json(serde_json::json!({ "status": "healthy", "components": { "api": { "status": "healthy", "message": "Rust Axum server" }, "hardware": { "status": if s.effective_source().ends_with(":offline") { "degraded" } else { "healthy" }, "message": format!("Source: {}", s.effective_source()) }, "pose": { "status": "healthy", "message": "WiFi-derived pose estimation" }, "stream": { "status": if s.tx.receiver_count() > 0 { "healthy" } else { "idle" }, "message": format!("{} client(s)", s.tx.receiver_count()) }, }, "metrics": { "cpu_percent": 2.5, "memory_percent": 1.8, "disk_percent": 15.0, "uptime_seconds": uptime, } })) } async fn health_version() -> Json { Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "name": "wifi-densepose-sensing-server", "backend": "rust+axum+ruvector", })) } async fn health_metrics(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "system_metrics": { "cpu": { "percent": 2.5 }, "memory": { "percent": 1.8, "used_mb": 5 }, "disk": { "percent": 15.0 }, }, "tick": s.tick, })) } async fn api_info(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "environment": "production", "backend": "rust", "source": s.effective_source(), "features": { "wifi_sensing": true, "pose_estimation": true, "signal_processing": true, "ruvector": true, "streaming": true, } })) } async fn pose_current(State(state): State) -> Json { let s = state.read().await; let persons = match &s.latest_update { Some(update) => update.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(update)), None => vec![], }; Json(serde_json::json!({ "timestamp": chrono::Utc::now().timestamp_millis() as f64 / 1000.0, "persons": persons, "total_persons": persons.len(), "source": s.effective_source(), })) } async fn pose_stats(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "total_detections": s.total_detections, "average_confidence": 0.87, "frames_processed": s.tick, "source": s.effective_source(), })) } async fn pose_zones_summary(State(state): State) -> Json { let s = state.read().await; let presence = s.latest_update.as_ref() .map(|u| u.classification.presence).unwrap_or(false); Json(serde_json::json!({ "zones": { "zone_1": { "person_count": if presence { 1 } else { 0 }, "status": "monitored" }, "zone_2": { "person_count": 0, "status": "clear" }, "zone_3": { "person_count": 0, "status": "clear" }, "zone_4": { "person_count": 0, "status": "clear" }, } })) } async fn stream_status(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "active": true, "clients": s.tx.receiver_count(), "fps": if s.tick > 1 { 10u64 } else { 0u64 }, "source": s.effective_source(), })) } // ── Model Management Endpoints ────────────────────────────────────────────── /// GET /api/v1/models — list discovered RVF model files. async fn list_models(State(state): State) -> Json { // Re-scan directory each call so newly-added files are visible. let models = scan_model_files(); let total = models.len(); { let mut s = state.write().await; s.discovered_models = models.clone(); } Json(serde_json::json!({ "models": models, "total": total })) } /// GET /api/v1/models/active — return currently loaded model or null. async fn get_active_model(State(state): State) -> Json { let s = state.read().await; match &s.active_model_id { Some(id) => { let model = s.discovered_models.iter().find(|m| { m.get("id").and_then(|v| v.as_str()) == Some(id.as_str()) }); Json(serde_json::json!({ "active": model.cloned().unwrap_or_else(|| serde_json::json!({ "id": id })), })) } None => Json(serde_json::json!({ "active": serde_json::Value::Null })), } } /// POST /api/v1/models/load — load a model by ID. async fn load_model( State(state): State, Json(body): Json, ) -> Json { let model_id = body.get("id") .or_else(|| body.get("model_id")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if model_id.is_empty() { return Json(serde_json::json!({ "error": "missing 'id' field", "success": false })); } let mut s = state.write().await; s.active_model_id = Some(model_id.clone()); s.model_loaded = true; info!("Model loaded: {model_id}"); Json(serde_json::json!({ "success": true, "model_id": model_id })) } /// POST /api/v1/models/unload — unload the current model. async fn unload_model(State(state): State) -> Json { let mut s = state.write().await; let prev = s.active_model_id.take(); s.model_loaded = false; info!("Model unloaded (was: {:?})", prev); Json(serde_json::json!({ "success": true, "previous": prev })) } /// DELETE /api/v1/models/:id — delete a model file. async fn delete_model( State(state): State, Path(id): Path, ) -> Json { // ADR-050: Sanitize path to prevent directory traversal let safe_id = std::path::Path::new(&id) .file_name() .and_then(|f| f.to_str()) .unwrap_or(""); if safe_id.is_empty() || safe_id != id { return Json(serde_json::json!({ "error": "invalid model id", "success": false })); } let path = effective_models_dir().join(format!("{}.rvf", safe_id)); if path.exists() { if let Err(e) = std::fs::remove_file(&path) { warn!("Failed to delete model file {:?}: {}", path, e); return Json(serde_json::json!({ "error": format!("delete failed: {e}"), "success": false })); } // If this was the active model, unload it let mut s = state.write().await; if s.active_model_id.as_deref() == Some(id.as_str()) { s.active_model_id = None; s.model_loaded = false; } s.discovered_models.retain(|m| { m.get("id").and_then(|v| v.as_str()) != Some(id.as_str()) }); info!("Model deleted: {id}"); Json(serde_json::json!({ "success": true, "deleted": id })) } else { Json(serde_json::json!({ "error": "model not found", "success": false })) } } /// GET /api/v1/models/lora/profiles — list LoRA adapter profiles. async fn list_lora_profiles() -> Json { // LoRA profiles are discovered from data/models/*.lora.json let profiles = scan_lora_profiles(); Json(serde_json::json!({ "profiles": profiles })) } /// POST /api/v1/models/lora/activate — activate a LoRA adapter profile. async fn activate_lora_profile( Json(body): Json, ) -> Json { let profile = body.get("profile") .or_else(|| body.get("name")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if profile.is_empty() { return Json(serde_json::json!({ "error": "missing 'profile' field", "success": false })); } info!("LoRA profile activated: {profile}"); Json(serde_json::json!({ "success": true, "profile": profile })) } /// Return the effective models directory, respecting the `MODELS_DIR` /// environment variable. Defaults to `data/models`. fn effective_models_dir() -> PathBuf { PathBuf::from( std::env::var("MODELS_DIR").unwrap_or_else(|_| "data/models".to_string()), ) } /// Scan the models directory for `.rvf` files and return metadata. /// Respects the `MODELS_DIR` environment variable. fn scan_model_files() -> Vec { let dir = effective_models_dir(); let mut models = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("rvf") { let name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let modified = entry.metadata().ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); models.push(serde_json::json!({ "id": name, "name": name, "path": path.display().to_string(), "size_bytes": size, "format": "rvf", "modified_epoch": modified, })); } } } models } /// Scan the models directory for `.lora.json` LoRA profile files. /// Respects the `MODELS_DIR` environment variable. fn scan_lora_profiles() -> Vec { let dir = effective_models_dir(); let mut profiles = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.ends_with(".lora.json") { let profile_name = name.trim_end_matches(".lora.json").to_string(); // Try to read the profile JSON let config = std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .unwrap_or_else(|| serde_json::json!({})); profiles.push(serde_json::json!({ "name": profile_name, "path": path.display().to_string(), "config": config, })); } } } profiles } // ── Recording Endpoints ───────────────────────────────────────────────────── /// GET /api/v1/recording/list — list CSI recordings. async fn list_recordings() -> Json { let recordings = scan_recording_files(); Json(serde_json::json!({ "recordings": recordings })) } /// POST /api/v1/recording/start — start recording CSI data. async fn start_recording( State(state): State, Json(body): Json, ) -> Json { let mut s = state.write().await; if s.recording_active { return Json(serde_json::json!({ "error": "recording already in progress", "success": false, "recording_id": s.recording_current_id, })); } let id = body.get("id") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| { format!("rec_{}", chrono_timestamp()) }); // Create the recording file let rec_path = PathBuf::from("data/recordings").join(format!("{}.jsonl", id)); let file = match std::fs::File::create(&rec_path) { Ok(f) => f, Err(e) => { warn!("Failed to create recording file {:?}: {}", rec_path, e); return Json(serde_json::json!({ "error": format!("cannot create file: {e}"), "success": false, })); } }; // Create a stop signal channel let (stop_tx, mut stop_rx) = tokio::sync::watch::channel(false); s.recording_active = true; s.recording_start_time = Some(std::time::Instant::now()); s.recording_current_id = Some(id.clone()); s.recording_stop_tx = Some(stop_tx); // Subscribe to the broadcast channel to capture CSI frames let mut rx = s.tx.subscribe(); // Add initial recording entry s.recordings.push(serde_json::json!({ "id": id, "path": rec_path.display().to_string(), "status": "recording", "started_at": chrono_timestamp(), "frames": 0, })); let rec_id = id.clone(); // Spawn writer task in background tokio::spawn(async move { use std::io::Write; let mut writer = std::io::BufWriter::new(file); let mut frame_count: u64 = 0; loop { tokio::select! { result = rx.recv() => { match result { Ok(frame_json) => { if writeln!(writer, "{}", frame_json).is_err() { warn!("Recording {rec_id}: write error, stopping"); break; } frame_count += 1; // Flush every 100 frames if frame_count % 100 == 0 { let _ = writer.flush(); } } Err(broadcast::error::RecvError::Lagged(n)) => { debug!("Recording {rec_id}: lagged {n} frames"); } Err(broadcast::error::RecvError::Closed) => { info!("Recording {rec_id}: broadcast closed, stopping"); break; } } } _ = stop_rx.changed() => { if *stop_rx.borrow() { info!("Recording {rec_id}: stop signal received ({frame_count} frames)"); break; } } } } let _ = writer.flush(); info!("Recording {rec_id} finished: {frame_count} frames written"); }); info!("Recording started: {id}"); Json(serde_json::json!({ "success": true, "recording_id": id })) } /// POST /api/v1/recording/stop — stop recording CSI data. async fn stop_recording(State(state): State) -> Json { let mut s = state.write().await; if !s.recording_active { return Json(serde_json::json!({ "error": "no recording in progress", "success": false, })); } // Signal the writer task to stop if let Some(tx) = s.recording_stop_tx.take() { let _ = tx.send(true); } let duration_secs = s.recording_start_time .map(|t| t.elapsed().as_secs()) .unwrap_or(0); let rec_id = s.recording_current_id.take().unwrap_or_default(); s.recording_active = false; s.recording_start_time = None; // Update the recording entry status for rec in s.recordings.iter_mut() { if rec.get("id").and_then(|v| v.as_str()) == Some(rec_id.as_str()) { rec["status"] = serde_json::json!("completed"); rec["duration_secs"] = serde_json::json!(duration_secs); } } info!("Recording stopped: {rec_id} ({duration_secs}s)"); Json(serde_json::json!({ "success": true, "recording_id": rec_id, "duration_secs": duration_secs, })) } /// DELETE /api/v1/recording/:id — delete a recording file. async fn delete_recording( State(state): State, Path(id): Path, ) -> Json { // ADR-050: Sanitize path to prevent directory traversal let safe_id = std::path::Path::new(&id) .file_name() .and_then(|f| f.to_str()) .unwrap_or(""); if safe_id.is_empty() || safe_id != id { return Json(serde_json::json!({ "error": "invalid recording id", "success": false })); } let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", safe_id)); if path.exists() { if let Err(e) = std::fs::remove_file(&path) { warn!("Failed to delete recording {:?}: {}", path, e); return Json(serde_json::json!({ "error": format!("delete failed: {e}"), "success": false })); } let mut s = state.write().await; s.recordings.retain(|r| { r.get("id").and_then(|v| v.as_str()) != Some(id.as_str()) }); info!("Recording deleted: {id}"); Json(serde_json::json!({ "success": true, "deleted": id })) } else { Json(serde_json::json!({ "error": "recording not found", "success": false })) } } /// Scan `data/recordings/` for `.jsonl` files and return metadata. fn scan_recording_files() -> Vec { let dir = PathBuf::from("data/recordings"); let mut recordings = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { let name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let modified = entry.metadata().ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); // Count lines (frames) — approximate for large files let frame_count = std::fs::read_to_string(&path) .map(|s| s.lines().count()) .unwrap_or(0); recordings.push(serde_json::json!({ "id": name, "name": name, "path": path.display().to_string(), "size_bytes": size, "frames": frame_count, "modified_epoch": modified, "status": "completed", })); } } } recordings } // ── Training Endpoints ────────────────────────────────────────────────────── /// GET /api/v1/train/status — get training status. async fn train_status(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": s.training_status, "config": s.training_config, })) } /// POST /api/v1/train/start — start a training run. async fn train_start( State(state): State, Json(body): Json, ) -> Json { let mut s = state.write().await; if s.training_status == "running" { return Json(serde_json::json!({ "error": "training already running", "success": false, })); } s.training_status = "running".to_string(); s.training_config = Some(body.clone()); info!("Training started with config: {}", body); Json(serde_json::json!({ "success": true, "status": "running", "message": "Training pipeline started. Use GET /api/v1/train/status to monitor.", })) } /// POST /api/v1/train/stop — stop the current training run. async fn train_stop(State(state): State) -> Json { let mut s = state.write().await; if s.training_status != "running" { return Json(serde_json::json!({ "error": "no training in progress", "success": false, })); } s.training_status = "idle".to_string(); info!("Training stopped"); Json(serde_json::json!({ "success": true, "status": "idle", })) } // ── Adaptive classifier endpoints ──────────────────────────────────────────── /// POST /api/v1/adaptive/train — train the adaptive classifier from recordings. async fn adaptive_train(State(state): State) -> Json { let rec_dir = PathBuf::from("data/recordings"); eprintln!("=== Adaptive Classifier Training ==="); match adaptive_classifier::train_from_recordings(&rec_dir) { Ok(model) => { let accuracy = model.training_accuracy; let frames = model.trained_frames; let stats: Vec<_> = model.class_stats.iter().map(|cs| { serde_json::json!({ "class": cs.label, "samples": cs.count, "feature_means": cs.mean, }) }).collect(); // Save to disk. if let Err(e) = model.save(&adaptive_classifier::model_path()) { warn!("Failed to save adaptive model: {e}"); } else { info!("Adaptive model saved to {}", adaptive_classifier::model_path().display()); } // Load into runtime state. let mut s = state.write().await; s.adaptive_model = Some(model); Json(serde_json::json!({ "success": true, "trained_frames": frames, "accuracy": accuracy, "class_stats": stats, })) } Err(e) => { Json(serde_json::json!({ "success": false, "error": e, })) } } } /// GET /api/v1/adaptive/status — check adaptive model status. async fn adaptive_status(State(state): State) -> Json { let s = state.read().await; match &s.adaptive_model { Some(model) => Json(serde_json::json!({ "loaded": true, "trained_frames": model.trained_frames, "accuracy": model.training_accuracy, "version": model.version, "classes": model.class_names, "class_stats": model.class_stats, })), None => Json(serde_json::json!({ "loaded": false, "message": "No adaptive model. POST /api/v1/adaptive/train to train one.", })), } } /// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds). async fn adaptive_unload(State(state): State) -> Json { let mut s = state.write().await; s.adaptive_model = None; Json(serde_json::json!({ "success": true, "message": "Adaptive model unloaded." })) } // ── Field model calibration endpoints (eigenvalue person counting) ────────── async fn calibration_start(State(state): State) -> Json { let mut s = state.write().await; // Guard: don't discard an in-progress or fresh calibration if let Some(ref fm) = s.field_model { match fm.status() { CalibrationStatus::Collecting => { return Json(serde_json::json!({ "success": false, "error": "Calibration already in progress. Call /calibration/stop first.", "frame_count": fm.calibration_frame_count(), })); } CalibrationStatus::Fresh => { return Json(serde_json::json!({ "success": false, "error": "A fresh calibration already exists. Call /calibration/stop or wait for expiry.", })); } _ => {} // Stale/Expired/Uncalibrated — ok to recalibrate } } match FieldModel::new(field_bridge::single_link_config()) { Ok(fm) => { s.field_model = Some(fm); Json(serde_json::json!({ "success": true, "message": "Calibration started — keep room empty while frames accumulate.", })) } Err(e) => Json(serde_json::json!({ "success": false, "error": format!("{e}"), })), } } async fn calibration_stop(State(state): State) -> Json { let mut s = state.write().await; if let Some(ref mut fm) = s.field_model { let ts = chrono::Utc::now().timestamp_micros() as u64; match fm.finalize_calibration(ts, 0) { Ok(modes) => { let baseline = modes.baseline_eigenvalue_count; let variance_explained = modes.variance_explained; info!("Field model calibrated: baseline_eigenvalues={baseline}, variance_explained={variance_explained:.2}"); Json(serde_json::json!({ "success": true, "baseline_eigenvalue_count": baseline, "variance_explained": variance_explained, "frame_count": fm.calibration_frame_count(), })) } Err(e) => Json(serde_json::json!({ "success": false, "error": format!("{e}"), })), } } else { Json(serde_json::json!({ "success": false, "error": "No field model active — call /calibration/start first.", })) } } async fn calibration_status(State(state): State) -> Json { let s = state.read().await; match s.field_model.as_ref() { Some(fm) => Json(serde_json::json!({ "active": true, "status": format!("{:?}", fm.status()), "frame_count": fm.calibration_frame_count(), })), None => Json(serde_json::json!({ "active": false, "status": "none", })), } } /// Generate a simple timestamp string (epoch seconds) for recording IDs. fn chrono_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } async fn vital_signs_endpoint(State(state): State) -> Json { let s = state.read().await; let vs = &s.latest_vitals; let (br_len, br_cap, hb_len, hb_cap) = s.vital_detector.buffer_status(); Json(serde_json::json!({ "vital_signs": { "breathing_rate_bpm": vs.breathing_rate_bpm, "heart_rate_bpm": vs.heart_rate_bpm, "breathing_confidence": vs.breathing_confidence, "heartbeat_confidence": vs.heartbeat_confidence, "signal_quality": vs.signal_quality, }, "buffer_status": { "breathing_samples": br_len, "breathing_capacity": br_cap, "heartbeat_samples": hb_len, "heartbeat_capacity": hb_cap, }, "source": s.effective_source(), "tick": s.tick, })) } /// GET /api/v1/edge-vitals — latest edge vitals from ESP32 (ADR-039). async fn edge_vitals_endpoint(State(state): State) -> Json { let s = state.read().await; match &s.edge_vitals { Some(v) => Json(serde_json::json!({ "status": "ok", "edge_vitals": v, })), None => Json(serde_json::json!({ "status": "no_data", "edge_vitals": null, "message": "No edge vitals packet received yet. Ensure ESP32 edge_tier >= 1.", })), } } /// GET /api/v1/wasm-events — latest WASM events from ESP32 (ADR-040). async fn wasm_events_endpoint(State(state): State) -> Json { let s = state.read().await; match &s.latest_wasm_events { Some(w) => Json(serde_json::json!({ "status": "ok", "wasm_events": w, })), None => Json(serde_json::json!({ "status": "no_data", "wasm_events": null, "message": "No WASM output packet received yet. Upload and start a .wasm module on the ESP32.", })), } } async fn model_info(State(state): State) -> Json { let s = state.read().await; match &s.rvf_info { Some(info) => Json(serde_json::json!({ "status": "loaded", "container": info, })), None => Json(serde_json::json!({ "status": "no_model", "message": "No RVF container loaded. Use --load-rvf to load one.", })), } } async fn model_layers(State(state): State) -> Json { let s = state.read().await; match &s.progressive_loader { Some(loader) => { let (a, b, c) = loader.layer_status(); Json(serde_json::json!({ "layer_a": a, "layer_b": b, "layer_c": c, "progress": loader.loading_progress(), })) } None => Json(serde_json::json!({ "layer_a": false, "layer_b": false, "layer_c": false, "progress": 0.0, "message": "No model loaded with progressive loading", })), } } async fn model_segments(State(state): State) -> Json { let s = state.read().await; match &s.progressive_loader { Some(loader) => Json(serde_json::json!({ "segments": loader.segment_list() })), None => Json(serde_json::json!({ "segments": [] })), } } async fn sona_profiles(State(state): State) -> Json { let s = state.read().await; let names = s .progressive_loader .as_ref() .map(|l| l.sona_profile_names()) .unwrap_or_default(); let active = s.active_sona_profile.clone().unwrap_or_default(); Json(serde_json::json!({ "profiles": names, "active": active })) } async fn sona_activate( State(state): State, Json(body): Json, ) -> Json { let profile = body .get("profile") .and_then(|p| p.as_str()) .unwrap_or("") .to_string(); let mut s = state.write().await; let available = s .progressive_loader .as_ref() .map(|l| l.sona_profile_names()) .unwrap_or_default(); if available.contains(&profile) { s.active_sona_profile = Some(profile.clone()); Json(serde_json::json!({ "status": "activated", "profile": profile })) } else { Json(serde_json::json!({ "status": "error", "message": format!("Profile '{}' not found. Available: {:?}", profile, available), })) } } /// GET /api/v1/nodes — per-node health and feature info. async fn nodes_endpoint(State(state): State) -> Json { let s = state.read().await; let now = std::time::Instant::now(); let nodes: Vec = s.node_states.iter() .map(|(&id, ns)| { let elapsed_ms = ns.last_frame_time .map(|t| now.duration_since(t).as_millis() as u64) .unwrap_or(999999); let stale = elapsed_ms > 5000; let status = if stale { "stale" } else { "active" }; let rssi = ns.rssi_history.back().copied().unwrap_or(-90.0); serde_json::json!({ "node_id": id, "status": status, "last_seen_ms": elapsed_ms, "rssi_dbm": rssi, "motion_level": &ns.current_motion_level, "person_count": ns.prev_person_count, }) }) .collect(); Json(serde_json::json!({ "nodes": nodes, "total": nodes.len(), })) } async fn info_page() -> Html { Html(format!( "\

WiFi-DensePose Sensing Server

\

Rust + Axum + RuVector

\ \ " )) } // ── UDP receiver task ──────────────────────────────────────────────────────── async fn udp_receiver_task(state: SharedState, udp_port: u16) { let addr = format!("0.0.0.0:{udp_port}"); let socket = match UdpSocket::bind(&addr).await { Ok(s) => { info!("UDP listening on {addr} for ESP32 CSI frames"); s } Err(e) => { error!("Failed to bind UDP {addr}: {e}"); return; } }; let mut buf = [0u8; 2048]; loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { // ADR-039: Try edge vitals packet first (magic 0xC511_0002). if let Some(vitals) = parse_esp32_vitals(&buf[..len]) { debug!("ESP32 vitals from {src}: node={} br={:.1} hr={:.1} pres={}", vitals.node_id, vitals.breathing_rate_bpm, vitals.heartrate_bpm, vitals.presence); let mut s = state.write().await; // Broadcast vitals via WebSocket. if let Ok(json) = serde_json::to_string(&serde_json::json!({ "type": "edge_vitals", "node_id": vitals.node_id, "presence": vitals.presence, "fall_detected": vitals.fall_detected, "motion": vitals.motion, "breathing_rate_bpm": vitals.breathing_rate_bpm, "heartrate_bpm": vitals.heartrate_bpm, "n_persons": vitals.n_persons, "motion_energy": vitals.motion_energy, "presence_score": vitals.presence_score, "rssi": vitals.rssi, })) { let _ = s.tx.send(json); } // Issue #323: Also emit a sensing_update so the UI renders // detections for ESP32 nodes running the edge DSP pipeline // (Tier 2+). Without this, vitals arrive but the UI shows // "no detection" because it only renders sensing_update msgs. s.source = "esp32".to_string(); s.last_esp32_frame = Some(std::time::Instant::now()); // ── Per-node state for edge vitals (issue #249) ────── let node_id = vitals.node_id; let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new); ns.last_frame_time = Some(std::time::Instant::now()); ns.edge_vitals = Some(vitals.clone()); ns.rssi_history.push_back(vitals.rssi as f64); if ns.rssi_history.len() > 60 { ns.rssi_history.pop_front(); } // Store per-node person count from edge vitals. let node_est = if vitals.presence { (vitals.n_persons as usize).max(1) } else { 0 }; ns.prev_person_count = node_est; s.tick += 1; let tick = s.tick; let motion_level = if vitals.motion { "present_moving" } else if vitals.presence { "present_still" } else { "absent" }; let motion_score = if vitals.motion { 0.8 } else if vitals.presence { 0.3 } else { 0.05 }; // Aggregate person count: gate on presence first (matching WiFi path). let now = std::time::Instant::now(); let total_persons = if vitals.presence { let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback( &s.multistatic_fuser, &s.node_states, ); match fused { Some(ref f) => { let score = multistatic_bridge::compute_person_score_from_amplitudes(&f.fused_amplitude); s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10; let count = s.person_count(); s.prev_person_count = count; count.max(1) // presence=true => at least 1 } None => fallback_count.unwrap_or(0).max(1), } } else { s.prev_person_count = 0; 0 }; // Feed field model calibration if active (use per-node history for ESP32). if let Some(frame_history) = s.node_states.get(&node_id).map(|ns| ns.frame_history.clone()) { if let Some(ref mut fm) = s.field_model { field_bridge::maybe_feed_calibration(fm, &frame_history); } } // Build nodes array with all active nodes. let active_nodes: Vec = s.node_states.iter() .filter(|(_, n)| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10)) .map(|(&id, n)| NodeInfo { node_id: id, rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0), position: [2.0, 0.0, 1.5], amplitude: vec![], subcarrier_count: 0, }) .collect(); let features = FeatureInfo { mean_rssi: vitals.rssi as f64, variance: vitals.motion_energy as f64, motion_band_power: vitals.motion_energy as f64, breathing_band_power: if vitals.presence { 0.5 } else { 0.0 }, dominant_freq_hz: vitals.breathing_rate_bpm / 60.0, change_points: 0, spectral_power: vitals.motion_energy as f64, }; // Store latest features on node for cross-node fusion. s.node_states.get_mut(&node_id) .map(|ns| ns.latest_features = Some(features.clone())); // Cross-node fusion: combine features from all active nodes. let fused_features = fuse_multi_node_features(&features, &s.node_states); let mut classification = ClassificationInfo { motion_level: motion_level.to_string(), presence: vitals.presence, confidence: vitals.presence_score as f64, }; // Boost classification confidence with multi-node coverage. let n_active = s.node_states.values() .filter(|ns| ns.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10)) .count(); if n_active > 1 { classification.confidence = (classification.confidence * (1.0 + 0.15 * (n_active as f64 - 1.0))).clamp(0.0, 1.0); } let signal_field = generate_signal_field( fused_features.mean_rssi, motion_score, vitals.breathing_rate_bpm / 60.0, (vitals.presence_score as f64).min(1.0), &[], ); let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: "esp32".to_string(), tick, nodes: active_nodes, features: fused_features.clone(), classification, signal_field, vital_signs: Some(VitalSigns { breathing_rate_bpm: if vitals.breathing_rate_bpm > 0.0 { Some(vitals.breathing_rate_bpm) } else { None }, heart_rate_bpm: if vitals.heartrate_bpm > 0.0 { Some(vitals.heartrate_bpm) } else { None }, breathing_confidence: if vitals.presence { 0.7 } else { 0.0 }, heartbeat_confidence: if vitals.presence { 0.7 } else { 0.0 }, signal_quality: vitals.presence_score as f64, }), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if total_persons > 0 { Some(total_persons) } else { None }, // ADR-084 Pass 3.6: surface per-node novelty_score // (and the rest of the per-node feature snapshot) // on the WebSocket envelope so cluster-Pi consumers // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), }; let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); s.edge_vitals = Some(vitals); continue; } // ADR-040: Try WASM output packet (magic 0xC511_0004). if let Some(wasm_output) = parse_wasm_output(&buf[..len]) { debug!("WASM output from {src}: node={} module={} events={}", wasm_output.node_id, wasm_output.module_id, wasm_output.events.len()); let mut s = state.write().await; // Broadcast WASM events via WebSocket. if let Ok(json) = serde_json::to_string(&serde_json::json!({ "type": "wasm_event", "node_id": wasm_output.node_id, "module_id": wasm_output.module_id, "events": wasm_output.events, })) { let _ = s.tx.send(json); } s.latest_wasm_events = Some(wasm_output); continue; } if let Some(frame) = parse_esp32_frame(&buf[..len]) { debug!("ESP32 frame from {src}: node={}, subs={}, seq={}", frame.node_id, frame.n_subcarriers, frame.sequence); let mut s = state.write().await; s.source = "esp32".to_string(); s.last_esp32_frame = Some(std::time::Instant::now()); // Also maintain global frame_history for backward compat // (simulation path, REST endpoints, etc.). s.frame_history.push_back(frame.amplitudes.clone()); if s.frame_history.len() > FRAME_HISTORY_CAPACITY { s.frame_history.pop_front(); } // ── ADR-099: real-time introspection tap ──────────────── // Per-frame update of the attractor / DTW pipeline running // parallel to the window-aggregated event path. Placed // BEFORE the per-node `&mut` borrow of `s.node_states` so // `s.intro` / `s.intro_tx` stay reachable. Never window- // blocked; `/ws/introspection` sees a fresh snapshot on // every accepted frame. { let intro_feature = if frame.amplitudes.is_empty() { 0.0 } else { frame.amplitudes.iter().copied().sum::() / frame.amplitudes.len() as f64 }; let intro_ts_ns = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); let _ = s.intro.update(intro_ts_ns, intro_feature); if let Ok(intro_json) = serde_json::to_string(s.intro.snapshot()) { let _ = s.intro_tx.send(intro_json); } } // ── Per-node processing (issue #249) ────────────────── // Process entirely within per-node state so different // ESP32 nodes never mix their smoothing/vitals buffers. // We scope the mutable borrow of node_states so we can // access other AppStateInner fields afterward. let node_id = frame.node_id; // Clone adaptive model before mutable borrow of node_states // to avoid unsafe raw pointer (review finding #2). let adaptive_model_clone = s.adaptive_model.clone(); let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new); ns.last_frame_time = Some(std::time::Instant::now()); // ADR-084 Pass 3: cluster-Pi novelty sensor. // Score this frame's feature vector against the per-node // sketch bank *before* pushing it (so the score reflects // pre-insert state). Result lands in `ns.last_novelty_score` // for downstream model-wake gating. ns.update_novelty(&frame.amplitudes); ns.frame_history.push_back(frame.amplitudes.clone()); if ns.frame_history.len() > FRAME_HISTORY_CAPACITY { ns.frame_history.pop_front(); } let sample_rate_hz = 1000.0 / 500.0_f64; let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &ns.frame_history, sample_rate_hz); smooth_and_classify_node(ns, &mut classification, raw_motion); // Adaptive override using cloned model (safe, no raw pointers). if let Some(ref model) = adaptive_model_clone { let amps = ns.frame_history.back() .map(|v| v.as_slice()) .unwrap_or(&[]); let feat_arr = adaptive_classifier::features_from_runtime( &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, "dominant_freq_hz": features.dominant_freq_hz, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), amps, ); let (label, conf) = model.classify(&feat_arr); classification.motion_level = label.to_string(); classification.presence = label != "absent"; classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0); } ns.rssi_history.push_back(features.mean_rssi); if ns.rssi_history.len() > 60 { ns.rssi_history.pop_front(); } let raw_vitals = ns.vital_detector.process_frame( &frame.amplitudes, &frame.phases, ); let vitals = smooth_vitals_node(ns, &raw_vitals); ns.latest_vitals = vitals.clone(); // DynamicMinCut person estimation from subcarrier correlation. let corr_persons = estimate_persons_from_correlation(&ns.frame_history); let raw_score = corr_persons as f64 / 3.0; ns.smoothed_person_score = ns.smoothed_person_score * 0.92 + raw_score * 0.08; if classification.presence { let count = score_to_person_count(ns.smoothed_person_score, ns.prev_person_count); ns.prev_person_count = count; } else { ns.prev_person_count = 0; } // Store latest features on node for cross-node fusion. ns.latest_features = Some(features.clone()); // Done with per-node mutable borrow; now read aggregated // state from all nodes (the borrow of `ns` ends here). // (We re-borrow node_states immutably via `s` below.) s.rssi_history.push_back(features.mean_rssi); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } s.latest_vitals = vitals.clone(); // Cross-node fusion: combine features from all active nodes. let fused_features = fuse_multi_node_features(&features, &s.node_states); s.tick += 1; let tick = s.tick; let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; // Aggregate person count: gate on presence first (matching WiFi path). let now = std::time::Instant::now(); let total_persons = if classification.presence { let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback( &s.multistatic_fuser, &s.node_states, ); match fused { Some(ref f) => { let score = multistatic_bridge::compute_person_score_from_amplitudes(&f.fused_amplitude); s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10; let count = s.person_count(); s.prev_person_count = count; count.max(1) } None => fallback_count.unwrap_or(0).max(1), } } else { s.prev_person_count = 0; 0 }; // Feed field model calibration if active (use per-node history for ESP32). if let Some(frame_history) = s.node_states.get(&node_id).map(|ns| ns.frame_history.clone()) { if let Some(ref mut fm) = s.field_model { field_bridge::maybe_feed_calibration(fm, &frame_history); } } // Build nodes array with all active nodes. let active_nodes: Vec = s.node_states.iter() .filter(|(_, n)| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10)) .map(|(&id, n)| NodeInfo { node_id: id, rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0), position: [2.0, 0.0, 1.5], amplitude: n.frame_history.back() .map(|a| a.iter().take(56).cloned().collect()) .unwrap_or_default(), subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()), }) .collect(); let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: "esp32".to_string(), tick, nodes: active_nodes, features: fused_features.clone(), classification, signal_field: generate_signal_field( fused_features.mean_rssi, motion_score, breathing_rate_hz, fused_features.variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if total_persons > 0 { Some(total_persons) } else { None }, // ADR-084 Pass 3.6: surface per-node novelty_score // (and the rest of the per-node feature snapshot) // on the WebSocket envelope so cluster-Pi consumers // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), }; let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); // Evict stale nodes every 100 ticks to prevent memory leak. if tick % 100 == 0 { let stale = Duration::from_secs(60); let before = s.node_states.len(); s.node_states.retain(|_id, ns| { ns.last_frame_time.map_or(false, |t| now.duration_since(t) < stale) }); let evicted = before - s.node_states.len(); if evicted > 0 { info!("Evicted {} stale node(s), {} active", evicted, s.node_states.len()); } } } } Err(e) => { warn!("UDP recv error: {e}"); tokio::time::sleep(Duration::from_millis(100)).await; } } } } // ── Simulated data task ────────────────────────────────────────────────────── 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); loop { interval.tick().await; let mut s = state.write().await; s.tick += 1; let tick = s.tick; let frame = generate_simulated_frame(tick); // Append current amplitudes to history before feature extraction. s.frame_history.push_back(frame.amplitudes.clone()); if s.frame_history.len() > FRAME_HISTORY_CAPACITY { s.frame_history.pop_front(); } let sample_rate_hz = 1000.0 / tick_ms as f64; let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); smooth_and_classify(&mut s, &mut classification, raw_motion); adaptive_override(&s, &features, &mut classification); s.rssi_history.push_back(features.mean_rssi); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; let raw_vitals = s.vital_detector.process_frame( &frame.amplitudes, &frame.phases, ); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); let frame_amplitudes = frame.amplitudes.clone(); let frame_n_sub = frame.n_subcarriers; // Multi-person estimation with temporal smoothing (EMA α=0.10). let raw_score = compute_person_score(&features); s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10; let est_persons = if classification.presence { let count = s.person_count(); s.prev_person_count = count; count } else { s.prev_person_count = 0; 0 }; let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: "simulated".to_string(), tick, nodes: vec![NodeInfo { node_id: 1, rssi_dbm: features.mean_rssi, position: [2.0, 0.0, 1.5], amplitude: frame_amplitudes, subcarrier_count: frame_n_sub as usize, }], features: features.clone(), classification, signal_field: generate_signal_field( features.mean_rssi, motion_score, breathing_rate_hz, features.variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: if s.model_loaded { Some(serde_json::json!({ "loaded": true, "layers": s.progressive_loader.as_ref() .map(|l| { let (a,b,c) = l.layer_status(); a as u8 + b as u8 + c as u8 }) .unwrap_or(0), "sona_profile": s.active_sona_profile.as_deref().unwrap_or("default"), })) } else { None }, persons: None, estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, node_features: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if update.classification.presence { s.total_detections += 1; } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); } } // ── Broadcast tick task (for ESP32 mode, sends buffered state) ─────────────── async fn broadcast_tick_task(state: SharedState, tick_ms: u64) { let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); loop { interval.tick().await; let s = state.read().await; if let Some(ref update) = s.latest_update { if s.tx.receiver_count() > 0 { // Re-broadcast the latest sensing_update so pose WS clients // always get data even when ESP32 pauses between frames. if let Ok(json) = serde_json::to_string(update) { let _ = s.tx.send(json); } } } } } // ── Main ───────────────────────────────────────────────────────────────────── #[tokio::main] async fn main() { // Initialize tracing tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,tower_http=debug".into()), ) .init(); let args = Args::parse(); // Handle --benchmark mode: run vital sign benchmark and exit if args.benchmark { eprintln!("Running vital sign detection benchmark (1000 frames)..."); let (total, per_frame) = vital_signs::run_benchmark(1000); eprintln!(); eprintln!("Summary: {} total, {} per frame", format!("{total:?}"), format!("{per_frame:?}")); return; } // Handle --export-rvf mode: build an RVF container package and exit if let Some(ref rvf_path) = args.export_rvf { eprintln!("Exporting RVF container package..."); use rvf_pipeline::RvfModelBuilder; let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0"); // Vital sign config (default breathing 0.1-0.5 Hz, heartbeat 0.8-2.0 Hz) builder.set_vital_config(0.1, 0.5, 0.8, 2.0); // Model profile (input/output spec) builder.set_model_profile( "56-subcarrier CSI amplitude/phase @ 10-100 Hz", "17 COCO keypoints + body part UV + vital signs", "ESP32-S3 or Windows WiFi RSSI, Rust 1.85+", ); // Placeholder weights (17 keypoints × 56 subcarriers × 3 dims = 2856 params) let placeholder_weights: Vec = (0..2856).map(|i| (i as f32 * 0.001).sin()).collect(); builder.set_weights(&placeholder_weights); // Training provenance builder.set_training_proof( "wifi-densepose-rs-v1.0.0", serde_json::json!({ "pipeline": "ADR-023 8-phase", "test_count": 229, "benchmark_fps": 9520, "framework": "wifi-densepose-rs", }), ); // SONA default environment profile let default_lora: Vec = vec![0.0; 64]; builder.add_sona_profile("default", &default_lora, &default_lora); match builder.build() { Ok(rvf_bytes) => { if let Err(e) = std::fs::write(rvf_path, &rvf_bytes) { eprintln!("Error writing RVF: {e}"); std::process::exit(1); } eprintln!("Wrote {} bytes to {}", rvf_bytes.len(), rvf_path.display()); eprintln!("RVF container exported successfully."); } Err(e) => { eprintln!("Error building RVF: {e}"); std::process::exit(1); } } return; } // Handle --pretrain mode: self-supervised contrastive pretraining (ADR-024) if args.pretrain { eprintln!("=== WiFi-DensePose Contrastive Pretraining (ADR-024) ==="); let ds_path = args.dataset.clone().unwrap_or_else(|| PathBuf::from("data")); let source = match args.dataset_type.as_str() { "wipose" => dataset::DataSource::WiPose(ds_path.clone()), _ => dataset::DataSource::MmFi(ds_path.clone()), }; let pipeline = dataset::DataPipeline::new(dataset::DataConfig { source, ..Default::default() }); // Generate synthetic or load real CSI windows let generate_synthetic_windows = || -> Vec>> { (0..50).map(|i| { (0..4).map(|a| { (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() }).collect() }).collect() }; let csi_windows: Vec>> = match pipeline.load() { Ok(s) if !s.is_empty() => { eprintln!("Loaded {} samples from {}", s.len(), ds_path.display()); s.into_iter().map(|s| s.csi_window).collect() } _ => { eprintln!("Using synthetic data for pretraining."); generate_synthetic_windows() } }; let n_subcarriers = csi_windows.first() .and_then(|w| w.first()) .map(|f| f.len()) .unwrap_or(56); let tf_config = graph_transformer::TransformerConfig { n_subcarriers, n_keypoints: 17, d_model: 64, n_heads: 4, n_gnn_layers: 2, }; let transformer = graph_transformer::CsiToPoseTransformer::new(tf_config); eprintln!("Transformer params: {}", transformer.param_count()); let trainer_config = trainer::TrainerConfig { epochs: args.pretrain_epochs, batch_size: 8, lr: 0.001, warmup_epochs: 2, min_lr: 1e-6, early_stop_patience: args.pretrain_epochs + 1, pretrain_temperature: 0.07, ..Default::default() }; let mut t = trainer::Trainer::with_transformer(trainer_config, transformer); let e_config = embedding::EmbeddingConfig { d_model: 64, d_proj: 128, temperature: 0.07, normalize: true, }; let mut projection = embedding::ProjectionHead::new(e_config.clone()); let augmenter = embedding::CsiAugmenter::new(); eprintln!("Starting contrastive pretraining for {} epochs...", args.pretrain_epochs); let start = std::time::Instant::now(); for epoch in 0..args.pretrain_epochs { let loss = t.pretrain_epoch(&csi_windows, &augmenter, &mut projection, 0.07, epoch); if epoch % 10 == 0 || epoch == args.pretrain_epochs - 1 { eprintln!(" Epoch {epoch}: contrastive loss = {loss:.4}"); } } let elapsed = start.elapsed().as_secs_f64(); eprintln!("Pretraining complete in {elapsed:.1}s"); // Save pretrained model as RVF with embedding segment if let Some(ref save_path) = args.save_rvf { eprintln!("Saving pretrained model to RVF: {}", save_path.display()); t.sync_transformer_weights(); let weights = t.params().to_vec(); let mut proj_weights = Vec::new(); projection.flatten_into(&mut proj_weights); let mut builder = RvfBuilder::new(); builder.add_manifest( "wifi-densepose-pretrained", env!("CARGO_PKG_VERSION"), "WiFi DensePose contrastive pretrained model (ADR-024)", ); builder.add_weights(&weights); builder.add_embedding( &serde_json::json!({ "d_model": e_config.d_model, "d_proj": e_config.d_proj, "temperature": e_config.temperature, "normalize": e_config.normalize, "pretrain_epochs": args.pretrain_epochs, }), &proj_weights, ); match builder.write_to_file(save_path) { Ok(()) => eprintln!("RVF saved ({} transformer + {} projection params)", weights.len(), proj_weights.len()), Err(e) => eprintln!("Failed to save RVF: {e}"), } } return; } // Handle --embed mode: extract embeddings from CSI data if args.embed { eprintln!("=== WiFi-DensePose Embedding Extraction (ADR-024) ==="); let model_path = match &args.model { Some(p) => p.clone(), None => { eprintln!("Error: --embed requires --model to a pretrained .rvf file"); std::process::exit(1); } }; let reader = match RvfReader::from_file(&model_path) { Ok(r) => r, Err(e) => { eprintln!("Failed to load model: {e}"); std::process::exit(1); } }; let weights = reader.weights().unwrap_or_default(); let (embed_config_json, proj_weights) = reader.embedding().unwrap_or_else(|| { eprintln!("Warning: no embedding segment in RVF, using defaults"); (serde_json::json!({"d_model":64,"d_proj":128,"temperature":0.07,"normalize":true}), Vec::new()) }); let d_model = embed_config_json["d_model"].as_u64().unwrap_or(64) as usize; let d_proj = embed_config_json["d_proj"].as_u64().unwrap_or(128) as usize; let tf_config = graph_transformer::TransformerConfig { n_subcarriers: 56, n_keypoints: 17, d_model, n_heads: 4, n_gnn_layers: 2, }; let e_config = embedding::EmbeddingConfig { d_model, d_proj, temperature: 0.07, normalize: true, }; let mut extractor = embedding::EmbeddingExtractor::new(tf_config, e_config.clone()); // Load transformer weights if !weights.is_empty() { if let Err(e) = extractor.transformer.unflatten_weights(&weights) { eprintln!("Warning: failed to load transformer weights: {e}"); } } // Load projection weights if !proj_weights.is_empty() { let (proj, _) = embedding::ProjectionHead::unflatten_from(&proj_weights, &e_config); extractor.projection = proj; } // Load dataset and extract embeddings let _ds_path = args.dataset.clone().unwrap_or_else(|| PathBuf::from("data")); let csi_windows: Vec>> = (0..10).map(|i| { (0..4).map(|a| { (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() }).collect() }).collect(); eprintln!("Extracting embeddings from {} CSI windows...", csi_windows.len()); let embeddings = extractor.extract_batch(&csi_windows); for (i, emb) in embeddings.iter().enumerate() { let norm: f32 = emb.iter().map(|x| x * x).sum::().sqrt(); eprintln!(" Window {i}: {d_proj}-dim embedding, ||e|| = {norm:.4}"); } eprintln!("Extracted {} embeddings of dimension {d_proj}", embeddings.len()); return; } // Handle --build-index mode: build a fingerprint index from embeddings if let Some(ref index_type_str) = args.build_index { eprintln!("=== WiFi-DensePose Fingerprint Index Builder (ADR-024) ==="); let index_type = match index_type_str.as_str() { "env" | "environment" => embedding::IndexType::EnvironmentFingerprint, "activity" => embedding::IndexType::ActivityPattern, "temporal" => embedding::IndexType::TemporalBaseline, "person" => embedding::IndexType::PersonTrack, _ => { eprintln!("Unknown index type '{}'. Use: env, activity, temporal, person", index_type_str); std::process::exit(1); } }; let tf_config = graph_transformer::TransformerConfig::default(); let e_config = embedding::EmbeddingConfig::default(); let mut extractor = embedding::EmbeddingExtractor::new(tf_config, e_config); // Generate synthetic CSI windows for demo let csi_windows: Vec>> = (0..20).map(|i| { (0..4).map(|a| { (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() }).collect() }).collect(); let mut index = embedding::FingerprintIndex::new(index_type); for (i, window) in csi_windows.iter().enumerate() { let emb = extractor.extract(window); index.insert(emb, format!("window_{i}"), i as u64 * 100); } eprintln!("Built {:?} index with {} entries", index_type, index.len()); // Test a query let query_emb = extractor.extract(&csi_windows[0]); let results = index.search(&query_emb, 5); eprintln!("Top-5 nearest to window_0:"); for r in &results { eprintln!(" entry={}, distance={:.4}, metadata={}", r.entry, r.distance, r.metadata); } return; } // Handle --train mode: train a model and exit if args.train { eprintln!("=== WiFi-DensePose Training Mode ==="); // Build data pipeline let ds_path = args.dataset.clone().unwrap_or_else(|| PathBuf::from("data")); let source = match args.dataset_type.as_str() { "wipose" => dataset::DataSource::WiPose(ds_path.clone()), _ => dataset::DataSource::MmFi(ds_path.clone()), }; let pipeline = dataset::DataPipeline::new(dataset::DataConfig { source, ..Default::default() }); // Generate synthetic training data (50 samples with deterministic CSI + keypoints) let generate_synthetic = || -> Vec { (0..50).map(|i| { let csi: Vec> = (0..4).map(|a| { (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() }).collect(); let mut kps = [(0.0f32, 0.0f32, 1.0f32); 17]; for (k, kp) in kps.iter_mut().enumerate() { kp.0 = (k as f32 * 0.1 + i as f32 * 0.02).sin() * 100.0 + 320.0; kp.1 = (k as f32 * 0.15 + i as f32 * 0.03).cos() * 80.0 + 240.0; } dataset::TrainingSample { csi_window: csi, pose_label: dataset::PoseLabel { keypoints: kps, body_parts: Vec::new(), confidence: 1.0, }, source: "synthetic", } }).collect() }; // Load samples (fall back to synthetic if dataset missing/empty) let samples = match pipeline.load() { Ok(s) if !s.is_empty() => { eprintln!("Loaded {} samples from {}", s.len(), ds_path.display()); s } Ok(_) => { eprintln!("No samples found at {}. Using synthetic data.", ds_path.display()); generate_synthetic() } Err(e) => { eprintln!("Failed to load dataset: {e}. Using synthetic data."); generate_synthetic() } }; // Convert dataset samples to trainer format let trainer_samples: Vec = samples.iter() .map(trainer::from_dataset_sample) .collect(); // Split 80/20 train/val let split = (trainer_samples.len() * 4) / 5; let (train_data, val_data) = trainer_samples.split_at(split.max(1)); eprintln!("Train: {} samples, Val: {} samples", train_data.len(), val_data.len()); // Create transformer + trainer let n_subcarriers = train_data.first() .and_then(|s| s.csi_features.first()) .map(|f| f.len()) .unwrap_or(56); let tf_config = graph_transformer::TransformerConfig { n_subcarriers, n_keypoints: 17, d_model: 64, n_heads: 4, n_gnn_layers: 2, }; let transformer = graph_transformer::CsiToPoseTransformer::new(tf_config); eprintln!("Transformer params: {}", transformer.param_count()); let trainer_config = trainer::TrainerConfig { epochs: args.epochs, batch_size: 8, lr: 0.001, warmup_epochs: 5, min_lr: 1e-6, early_stop_patience: 20, checkpoint_every: 10, ..Default::default() }; let mut t = trainer::Trainer::with_transformer(trainer_config, transformer); // Run training eprintln!("Starting training for {} epochs...", args.epochs); let result = t.run_training(train_data, val_data); eprintln!("Training complete in {:.1}s", result.total_time_secs); eprintln!(" Best epoch: {}, PCK@0.2: {:.4}, OKS mAP: {:.4}", result.best_epoch, result.best_pck, result.best_oks); // Save checkpoint if let Some(ref ckpt_dir) = args.checkpoint_dir { let _ = std::fs::create_dir_all(ckpt_dir); let ckpt_path = ckpt_dir.join("best_checkpoint.json"); let ckpt = t.checkpoint(); match ckpt.save_to_file(&ckpt_path) { Ok(()) => eprintln!("Checkpoint saved to {}", ckpt_path.display()), Err(e) => eprintln!("Failed to save checkpoint: {e}"), } } // Sync weights back to transformer and save as RVF t.sync_transformer_weights(); if let Some(ref save_path) = args.save_rvf { eprintln!("Saving trained model to RVF: {}", save_path.display()); let weights = t.params().to_vec(); let mut builder = RvfBuilder::new(); builder.add_manifest( "wifi-densepose-trained", env!("CARGO_PKG_VERSION"), "WiFi DensePose trained model weights", ); builder.add_metadata(&serde_json::json!({ "training": { "epochs": args.epochs, "best_epoch": result.best_epoch, "best_pck": result.best_pck, "best_oks": result.best_oks, "n_train_samples": train_data.len(), "n_val_samples": val_data.len(), "n_subcarriers": n_subcarriers, "param_count": weights.len(), }, })); builder.add_vital_config(&VitalSignConfig::default()); builder.add_weights(&weights); match builder.write_to_file(save_path) { Ok(()) => eprintln!("RVF saved ({} params, {} bytes)", weights.len(), weights.len() * 4), Err(e) => eprintln!("Failed to save RVF: {e}"), } } return; } info!("WiFi-DensePose Sensing Server (Rust + Axum + RuVector)"); info!(" HTTP: http://localhost:{}", args.http_port); info!(" WebSocket: ws://localhost:{}/ws/sensing", args.ws_port); info!(" UDP: 0.0.0.0:{} (ESP32 CSI)", args.udp_port); info!(" UI path: {}", args.ui_path.display()); info!(" Source: {}", args.source); // Auto-detect data source let source = match args.source.as_str() { "auto" => { info!("Auto-detecting data source..."); if probe_esp32(args.udp_port).await { info!(" ESP32 CSI detected on UDP :{}", args.udp_port); "esp32" } else if probe_windows_wifi().await { info!(" Windows WiFi detected"); "wifi" } else { info!(" No hardware detected, using simulation"); "simulate" } } other => other, }; info!("Data source: {source}"); // Shared state // Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz) let vital_sample_rate = 1000.0 / args.tick_ms as f64; info!("Vital sign detector sample rate: {vital_sample_rate:.1} Hz"); // Load RVF container if --load-rvf was specified let rvf_info = if let Some(ref rvf_path) = args.load_rvf { info!("Loading RVF container from {}", rvf_path.display()); match RvfReader::from_file(rvf_path) { Ok(reader) => { let info = reader.info(); info!( " RVF loaded: {} segments, {} bytes", info.segment_count, info.total_size ); if let Some(ref manifest) = info.manifest { if let Some(model_id) = manifest.get("model_id") { info!(" Model ID: {model_id}"); } if let Some(version) = manifest.get("version") { info!(" Version: {version}"); } } if info.has_weights { if let Some(w) = reader.weights() { info!(" Weights: {} parameters", w.len()); } } if info.has_vital_config { info!(" Vital sign config: present"); } if info.has_quant_info { info!(" Quantization info: present"); } if info.has_witness { info!(" Witness/proof: present"); } Some(info) } Err(e) => { error!("Failed to load RVF container: {e}"); None } } } else { None }; // Load trained model via --model (uses progressive loading if --progressive set) let model_path = args.model.as_ref().or(args.load_rvf.as_ref()); let mut progressive_loader: Option = None; let mut model_loaded = false; if let Some(mp) = model_path { if args.progressive || args.model.is_some() { info!("Loading trained model (progressive) from {}", mp.display()); match std::fs::read(mp) { Ok(data) => match ProgressiveLoader::new(&data) { Ok(mut loader) => { if let Ok(la) = loader.load_layer_a() { info!(" Layer A ready: model={} v{} ({} segments)", la.model_name, la.version, la.n_segments); } model_loaded = true; progressive_loader = Some(loader); } Err(e) => error!("Progressive loader init failed: {e}"), }, Err(e) => error!("Failed to read model file: {e}"), } } } // Ensure data directories exist for models and recordings let models_dir = effective_models_dir(); let _ = std::fs::create_dir_all(&models_dir); let _ = std::fs::create_dir_all("data/recordings"); // Discover model and recording files on startup let initial_models = scan_model_files(); let initial_recordings = scan_recording_files(); info!("Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len()); let (tx, _) = broadcast::channel::(256); // ADR-099: parallel broadcast for the per-frame introspection snapshot stream // consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow // clients drop oldest, identical backpressure shape. let (intro_tx, _) = broadcast::channel::(256); 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: None, tx, intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(), intro_tx, total_detections: 0, start_time: std::time::Instant::now(), vital_detector: VitalSignDetector::new(vital_sample_rate), latest_vitals: VitalSigns::default(), rvf_info, save_rvf_path: args.save_rvf.clone(), progressive_loader, active_sona_profile: None, model_loaded, smoothed_person_score: 0.0, prev_person_count: 0, smoothed_motion: 0.0, current_motion_level: "absent".to_string(), debounce_counter: 0, debounce_candidate: "absent".to_string(), baseline_motion: 0.0, baseline_frames: 0, smoothed_hr: 0.0, smoothed_br: 0.0, smoothed_hr_conf: 0.0, smoothed_br_conf: 0.0, hr_buffer: VecDeque::with_capacity(8), br_buffer: VecDeque::with_capacity(8), edge_vitals: None, latest_wasm_events: None, // Model management discovered_models: initial_models, active_model_id: None, // Recording recordings: initial_recordings, recording_active: false, recording_start_time: None, recording_current_id: None, recording_stop_tx: None, // Training training_status: "idle".to_string(), training_config: None, adaptive_model: adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path()).ok().map(|m| { info!("Loaded adaptive classifier: {} frames, {:.1}% accuracy", m.trained_frames, m.training_accuracy * 100.0); m }), node_states: HashMap::new(), // Accuracy sprint pose_tracker: PoseTracker::new(), last_tracker_instant: None, multistatic_fuser: { let mut fuser = MultistaticFuser::with_config(MultistaticConfig { min_nodes: 1, // single-node passthrough ..Default::default() }); if let Some(ref pos_str) = args.node_positions { let positions = field_bridge::parse_node_positions(pos_str); if !positions.is_empty() { info!("Configured {} node positions for multistatic fusion", positions.len()); fuser.set_node_positions(positions); } } fuser }, field_model: if args.calibrate { info!("Field model calibration enabled — room should be empty during startup"); FieldModel::new(field_bridge::single_link_config()).ok() } else { None }, })); // 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)); } "wifi" => { tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms)); } _ => { tokio::spawn(simulated_data_task(state.clone(), args.tick_ms)); } } // ADR-050: Parse bind address once, use for all listeners let bind_ip: std::net::IpAddr = args.bind_addr.parse() .expect("Invalid --bind-addr (use 127.0.0.1 or 0.0.0.0)"); // #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN` // unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒ // every `/api/v1/*` request must carry `Authorization: Bearer `. let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env(); if bearer_auth_state.is_enabled() { info!( "API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)" ); if bind_ip.is_unspecified() { warn!( "API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments", bind_ip ); } } else { info!( "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= to enforce bearer auth." ); } // WebSocket server on dedicated port (8765) let ws_state = state.clone(); let ws_app = Router::new() .route("/ws/sensing", get(ws_sensing_handler)) .route("/health", get(health)) .with_state(ws_state); let ws_addr = SocketAddr::from((bind_ip, args.ws_port)); let ws_listener = tokio::net::TcpListener::bind(ws_addr).await .expect("Failed to bind WebSocket port"); info!("WebSocket server listening on {ws_addr}"); tokio::spawn(async move { axum::serve(ws_listener, ws_app).await.unwrap(); }); // HTTP server (serves UI + full DensePose-compatible REST API) let ui_path = args.ui_path.clone(); let http_app = Router::new() .route("/", get(info_page)) // Health endpoints (DensePose-compatible) .route("/health", get(health)) .route("/health/health", get(health_system)) .route("/health/live", get(health_live)) .route("/health/ready", get(health_ready)) .route("/health/version", get(health_version)) .route("/health/metrics", get(health_metrics)) // API info .route("/api/v1/info", get(api_info)) .route("/api/v1/status", get(health_ready)) .route("/api/v1/metrics", get(health_metrics)) // Sensing endpoints .route("/api/v1/sensing/latest", get(latest)) // Per-node health endpoint .route("/api/v1/nodes", get(nodes_endpoint)) // Vital sign endpoints .route("/api/v1/vital-signs", get(vital_signs_endpoint)) .route("/api/v1/edge-vitals", get(edge_vitals_endpoint)) .route("/api/v1/wasm-events", get(wasm_events_endpoint)) // RVF model container info .route("/api/v1/model/info", get(model_info)) // Progressive loading & SONA endpoints (Phase 7-8) .route("/api/v1/model/layers", get(model_layers)) .route("/api/v1/model/segments", get(model_segments)) .route("/api/v1/model/sona/profiles", get(sona_profiles)) .route("/api/v1/model/sona/activate", post(sona_activate)) // Pose endpoints (WiFi-derived) .route("/api/v1/pose/current", get(pose_current)) .route("/api/v1/pose/stats", get(pose_stats)) .route("/api/v1/pose/zones/summary", get(pose_zones_summary)) // Stream endpoints .route("/api/v1/stream/status", get(stream_status)) .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-099: real-time introspection — per-frame attractor + DTW snapshot. .route("/ws/introspection", get(ws_introspection_handler)) .route("/api/v1/introspection/snapshot", get(api_introspection_snapshot)) // Model management endpoints (UI compatibility) .route("/api/v1/models", get(list_models)) .route("/api/v1/models/active", get(get_active_model)) .route("/api/v1/models/load", post(load_model)) .route("/api/v1/models/unload", post(unload_model)) .route("/api/v1/models/{id}", delete(delete_model)) .route("/api/v1/models/lora/profiles", get(list_lora_profiles)) .route("/api/v1/models/lora/activate", post(activate_lora_profile)) // Recording endpoints .route("/api/v1/recording/list", get(list_recordings)) .route("/api/v1/recording/start", post(start_recording)) .route("/api/v1/recording/stop", post(stop_recording)) .route("/api/v1/recording/{id}", delete(delete_recording)) // Training endpoints .route("/api/v1/train/status", get(train_status)) .route("/api/v1/train/start", post(train_start)) .route("/api/v1/train/stop", post(train_stop)) // Adaptive classifier endpoints .route("/api/v1/adaptive/train", post(adaptive_train)) .route("/api/v1/adaptive/status", get(adaptive_status)) .route("/api/v1/adaptive/unload", post(adaptive_unload)) // Field model calibration (eigenvalue-based person counting) .route("/api/v1/calibration/start", post(calibration_start)) .route("/api/v1/calibration/stop", post(calibration_stop)) .route("/api/v1/calibration/status", get(calibration_status)) // Static UI files .nest_service("/ui", ServeDir::new(&ui_path)) .layer(SetResponseHeaderLayer::overriding( axum::http::header::CACHE_CONTROL, HeaderValue::from_static("no-cache, no-store, must-revalidate"), )) // Opt-in bearer-token auth on `/api/v1/*` (#443). When `RUVIEW_API_TOKEN` // is unset/empty the middleware is a no-op — the default stays // LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never // gated (orchestrator probes + local browsers). .layer(axum::middleware::from_fn_with_state( bearer_auth_state.clone(), wifi_densepose_sensing_server::bearer_auth::require_bearer, )) .with_state(state.clone()); let http_addr = SocketAddr::from((bind_ip, args.http_port)); let http_listener = tokio::net::TcpListener::bind(http_addr).await .expect("Failed to bind HTTP port"); info!("HTTP server listening on {http_addr}"); info!("Open http://localhost:{}/ui/index.html in your browser", args.http_port); // Run the HTTP server with graceful shutdown support let shutdown_state = state.clone(); let server = axum::serve(http_listener, http_app) .with_graceful_shutdown(async { tokio::signal::ctrl_c() .await .expect("failed to install CTRL+C handler"); info!("Shutdown signal received"); }); server.await.unwrap(); // Save RVF container on shutdown if --save-rvf was specified let s = shutdown_state.read().await; if let Some(ref save_path) = s.save_rvf_path { info!("Saving RVF container to {}", save_path.display()); let mut builder = RvfBuilder::new(); builder.add_manifest( "wifi-densepose-sensing", env!("CARGO_PKG_VERSION"), "WiFi DensePose sensing model state", ); builder.add_metadata(&serde_json::json!({ "source": s.effective_source(), "total_ticks": s.tick, "total_detections": s.total_detections, "uptime_secs": s.start_time.elapsed().as_secs(), })); builder.add_vital_config(&VitalSignConfig::default()); // Save transformer weights if a model is loaded, otherwise empty let weights: Vec = if s.model_loaded { // If we loaded via --model, the progressive loader has the weights // For now, save runtime state placeholder let tf = graph_transformer::CsiToPoseTransformer::new(Default::default()); tf.flatten_weights() } else { Vec::new() }; builder.add_weights(&weights); match builder.write_to_file(save_path) { Ok(()) => info!(" RVF saved ({} weight params)", weights.len()), Err(e) => error!(" Failed to save RVF: {e}"), } } info!("Server shut down cleanly"); } #[cfg(test)] mod novelty_tests { use super::*; /// First call to `update_novelty` must produce *some* score /// (`Some(_)` not `None`) — proves the per-node sketch bank is /// initialised by `NodeState::new()` and the novelty path is /// actually being exercised. With an empty bank the score is 1.0 /// (max novelty). #[test] fn first_frame_yields_max_novelty_then_zero_on_repeat() { let mut ns = NodeState::new(); let amplitudes: Vec = (0..NOVELTY_VECTOR_DIM) .map(|i| (i as f64).sin()) .collect(); ns.update_novelty(&litudes); let first = ns.last_novelty_score.expect("sketch bank initialised"); assert!( (first - 1.0).abs() < 1e-6, "empty bank → max novelty 1.0, got {first}" ); // Repeat the exact same frame — bank now contains it, so the // novelty score must be 0.0 (the score is computed before the // second insert, against the post-first-insert bank). ns.update_novelty(&litudes); let second = ns.last_novelty_score.expect("score stays Some"); assert_eq!(second, 0.0, "exact-repeat frame → novelty 0.0"); } /// `update_novelty` must tolerate amplitude vectors of unexpected /// length — short ones zero-padded, long ones truncated — without /// panicking. ESP32-S3 boards report 56 subcarriers but other /// hardware variants ship 52 or 64; the schema-locked sketch bank /// requires exactly NOVELTY_VECTOR_DIM. #[test] fn handles_short_and_long_amplitude_vectors() { let mut ns = NodeState::new(); ns.update_novelty(&[1.0, 2.0]); // way short assert!(ns.last_novelty_score.is_some()); let too_long: Vec = (0..NOVELTY_VECTOR_DIM * 2).map(|i| i as f64).collect(); ns.update_novelty(&too_long); // way long assert!(ns.last_novelty_score.is_some()); } }