diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-geo/src/osm.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-geo/src/osm.rs index 1548093e..143511f9 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-geo/src/osm.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-geo/src/osm.rs @@ -166,3 +166,51 @@ fn parse_roads(data: &serde_json::Value) -> Result> { Ok(roads) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_overpass_json_accepts_minimal_fixture() { + // Minimal fixture: three nodes forming a triangular building. + let j = serde_json::json!({ + "elements": [ + { "type": "node", "id": 1, "lat": 43.0, "lon": -79.0 }, + { "type": "node", "id": 2, "lat": 43.0001, "lon": -79.0 }, + { "type": "node", "id": 3, "lat": 43.0, "lon": -79.0001 }, + { + "type": "way", "id": 100, + "nodes": [1, 2, 3, 1], + "tags": { "building": "yes", "name": "Test Hall" } + } + ] + }); + let features = parse_overpass_json(&j).expect("minimal payload should parse"); + assert_eq!(features.len(), 1); + match &features[0] { + OsmFeature::Building { outline, name, .. } => { + assert_eq!(outline.len(), 4); + assert_eq!(name.as_deref(), Some("Test Hall")); + } + _ => panic!("expected a Building"), + } + } + + #[test] + fn parse_overpass_json_rejects_malformed() { + // Missing the `elements` array entirely. + let j = serde_json::json!({ "version": 0.6 }); + assert!(parse_overpass_json(&j).is_err()); + // Not even an object. + let arr = serde_json::json!([1, 2, 3]); + assert!(parse_overpass_json(&arr).is_err()); + } + + #[tokio::test] + async fn fetch_buildings_rejects_oversized_radius() { + let center = GeoPoint { lat: 43.0, lon: -79.0, alt: 0.0 }; + let err = fetch_buildings(¢er, MAX_RADIUS_M + 1.0).await.err(); + assert!(err.is_some(), "should reject radius > MAX_RADIUS_M"); + } +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/brain_bridge.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/brain_bridge.rs index ac2de71e..45c9e9e7 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/brain_bridge.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/brain_bridge.rs @@ -90,20 +90,3 @@ pub async fn sync_to_brain(pipeline: &PipelineOutput, camera_frames: u64) { } } -/// Check if brain is reachable. -pub async fn brain_available() -> bool { - // Must .await directly — calling `Handle::current().block_on(...)` from - // inside an async fn panics with "Cannot start a runtime from within a - // runtime" because a worker thread is already driving a runtime. - let Ok(client) = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .build() - else { - return false; - }; - client - .get(format!("{}/health", brain_url())) - .send() - .await - .is_ok() -} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/camera.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/camera.rs index cefb6bc9..c8e3a8eb 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/camera.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/camera.rs @@ -1,5 +1,4 @@ //! Camera capture — cross-platform frame grabber. -#![allow(dead_code)] //! //! macOS: uses `screencapture` or `ffmpeg -f avfoundation` for camera frames //! Linux: uses `v4l2-ctl` or `ffmpeg -f v4l2` for camera frames @@ -14,7 +13,6 @@ pub struct Frame { pub width: u32, pub height: u32, pub rgb: Vec, // row-major [height * width * 3] - pub timestamp_ms: i64, } /// Camera source configuration. @@ -96,7 +94,6 @@ fn capture_ffmpeg(config: &CameraConfig, tmp: &PathBuf) -> Result { width: config.width, height: config.height, rgb: rgb[..expected].to_vec(), - timestamp_ms: chrono::Utc::now().timestamp_millis(), }) } @@ -170,7 +167,6 @@ fn decode_jpeg_to_rgb(path: &PathBuf, _width: u32, _height: u32) -> Result, - /// Optional: secondary stream (imaginary part) - #[serde(default)] - pub csi_imag: Vec, -} - -/// CSI link — a pair of TX/RX nodes with accumulated frames. -#[derive(Debug)] -pub struct CsiLink { - pub tx_mac: String, - pub rx_mac: String, - pub frames: VecDeque, - pub attenuation: f64, // current estimated attenuation -} - -/// CSI receiver — listens on UDP and accumulates frames. -pub struct CsiReceiver { - pub links: Arc>>, - pub frame_count: Arc>, - bind_addr: String, -} - -impl CsiReceiver { - pub fn new(bind_addr: &str) -> Self { - Self { - links: Arc::new(Mutex::new(Vec::new())), - frame_count: Arc::new(Mutex::new(0)), - bind_addr: bind_addr.to_string(), - } - } - - /// Start receiving CSI frames in a background thread. - pub fn start(&self) -> Result<()> { - let socket = UdpSocket::bind(&self.bind_addr)?; - socket.set_read_timeout(Some(std::time::Duration::from_secs(1)))?; - eprintln!(" CSI receiver listening on {}", self.bind_addr); - - let links = self.links.clone(); - let count = self.frame_count.clone(); - - std::thread::spawn(move || { - let mut buf = [0u8; 4096]; - loop { - match socket.recv_from(&mut buf) { - Ok((n, _addr)) => { - if let Ok(frame) = serde_json::from_slice::(&buf[..n]) { - process_frame(&links, &count, frame); - } - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, - Err(_) => continue, - } - } - }); - - Ok(()) - } - - /// Get the current occupancy volume from accumulated CSI data. - pub fn get_occupancy(&self) -> OccupancyVolume { - let links = self.links.lock().unwrap(); - - if links.is_empty() { - return crate::fusion::demo_occupancy(); - } - - // Extract per-link attenuations for tomography - let attenuations: Vec = links.iter().map(|l| l.attenuation).collect(); - let _n_links = attenuations.len(); - - // Simple grid-based tomography (ISTA solver would go here) - let nx = 8; - let ny = 8; - let nz = 4; - let total = nx * ny * nz; - let mut densities = vec![0.0f64; total]; - - // For each link, distribute attenuation along the line between TX and RX - // This is a simplified backprojection — real tomography uses ISTA L1 solver - for (_i, atten) in attenuations.iter().enumerate() { - // Distribute attenuation uniformly across voxels - // (in production, use link geometry for proper ray tracing) - let contribution = atten / total as f64; - for d in &mut densities { - *d += contribution; - } - } - - // Normalize - let max = densities.iter().cloned().fold(0.0f64, f64::max); - if max > 0.0 { - for d in &mut densities { *d /= max; } - } - - let occupied_count = densities.iter().filter(|&&d| d > 0.3).count(); - - OccupancyVolume { - densities, - nx, ny, nz, - bounds: [0.0, 0.0, 0.0, 5.0, 5.0, 3.0], - occupied_count, - } - } - - pub fn frame_count(&self) -> u64 { - *self.frame_count.lock().unwrap() - } -} - -fn process_frame( - links: &Arc>>, - count: &Arc>, - frame: CsiFrame, -) { - // Calculate attenuation from RSSI + CSI amplitude - let csi_power: f64 = frame.csi_data.iter() - .map(|&v| (v as f64).powi(2)) - .sum::() / frame.csi_data.len().max(1) as f64; - let attenuation = -(frame.rssi as f64) + csi_power.sqrt() * 0.1; - - let mut links = links.lock().unwrap(); - - // Find or create link for this MAC - let link = links.iter_mut().find(|l| l.tx_mac == frame.mac); - if let Some(link) = link { - link.attenuation = link.attenuation * 0.9 + attenuation * 0.1; // EMA - link.frames.push_back(frame); - if link.frames.len() > 100 { link.frames.pop_front(); } - } else { - let mut frames = VecDeque::new(); - frames.push_back(frame.clone()); - links.push(CsiLink { - tx_mac: frame.mac, - rx_mac: "receiver".to_string(), - frames, - attenuation, - }); - } - - *count.lock().unwrap() += 1; -} - -/// Send CSI frames via UDP (for testing — simulates ESP32 nodes). -pub fn send_test_frames(target: &str, count: usize) -> Result<()> { - let socket = UdpSocket::bind("0.0.0.0:0")?; - - for i in 0..count { - let frame = CsiFrame { - mac: format!("AA:BB:CC:DD:EE:{:02X}", i % 4), - rssi: -40 - (i % 30) as i8, - timestamp_ms: chrono::Utc::now().timestamp_millis(), - channel: 6, - bandwidth: 20, - csi_data: (0..56).map(|j| ((i + j) % 128) as i8 - 64).collect(), - csi_imag: Vec::new(), - }; - - let json = serde_json::to_vec(&frame)?; - socket.send_to(&json, target)?; - std::thread::sleep(std::time::Duration::from_millis(10)); - } - - Ok(()) -} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/depth.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/depth.rs index 2f5759d5..bfca60af 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/depth.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/depth.rs @@ -210,6 +210,46 @@ pub fn demo_depth_cloud() -> PointCloud { backproject_depth(&depth, &scaled_intrinsics, None, 1) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backproject_2x2_depth_yields_four_points() { + // 2x2 image, depth=1m everywhere; trivial intrinsics. + let intr = CameraIntrinsics { + fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5, + width: 2, height: 2, + }; + let depth = vec![1.0f32; 4]; + let cloud = backproject_depth(&depth, &intr, None, 1); + assert_eq!(cloud.points.len(), 4, "2x2 depth → 4 backprojected points"); + // Every point should be at z=1.0. + for p in &cloud.points { + assert!((p.z - 1.0).abs() < 1e-6, "z should be 1.0, got {}", p.z); + } + // With cx=0.5, cy=0.5 the four pixel centers backproject symmetrically + // about the optical axis: x in {-0.5, 0.5}, y in {-0.5, 0.5}. + let mut xs: Vec = cloud.points.iter().map(|p| p.x).collect(); + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert!((xs[0] + 0.5).abs() < 1e-6); + assert!((xs.last().unwrap() - 0.5).abs() < 1e-6); + } + + #[test] + fn backproject_rejects_invalid_depth() { + let intr = CameraIntrinsics { + fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5, + width: 2, height: 2, + }; + // All pixels NaN → no points. + let depth = vec![f32::NAN; 4]; + let cloud = backproject_depth(&depth, &intr, None, 1); + assert_eq!(cloud.points.len(), 0); + } +} + +#[allow(dead_code)] fn find_midas_model() -> Result { let paths = [ dirs::home_dir().unwrap_or_default().join(".local/share/ruview/midas_v21_small_256.onnx"), diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/fusion.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/fusion.rs index 18365917..d3fb00ac 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/fusion.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/fusion.rs @@ -1,5 +1,4 @@ //! Multi-modal fusion: camera depth + WiFi RF tomography → unified point cloud. -#![allow(dead_code)] use crate::pointcloud::{PointCloud, ColorPoint}; use std::collections::HashMap; @@ -94,36 +93,6 @@ pub fn fuse_clouds(clouds: &[&PointCloud], voxel_size: f32) -> PointCloud { fused } -/// Fetch WiFi occupancy from a remote RuView/brain endpoint. -pub async fn fetch_wifi_occupancy(url: &str) -> anyhow::Result { - let client = reqwest::Client::new(); - let resp: serde_json::Value = client.get(url).send().await?.json().await?; - - let nx = resp.get("nx").and_then(|v| v.as_u64()).unwrap_or(8) as usize; - let ny = resp.get("ny").and_then(|v| v.as_u64()).unwrap_or(8) as usize; - let nz = resp.get("nz").and_then(|v| v.as_u64()).unwrap_or(4) as usize; - - let densities: Vec = resp.get("densities") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect()) - .unwrap_or_else(|| vec![0.0; nx * ny * nz]); - - let bounds = resp.get("bounds") - .and_then(|v| v.as_array()) - .map(|arr| { - let mut b = [0.0f64; 6]; - for (i, v) in arr.iter().enumerate().take(6) { - b[i] = v.as_f64().unwrap_or(0.0); - } - b - }) - .unwrap_or([0.0, 0.0, 0.0, 5.0, 5.0, 3.0]); - - let occupied_count = densities.iter().filter(|&&d| d > 0.3).count(); - - Ok(OccupancyVolume { densities, nx, ny, nz, bounds, occupied_count }) -} - /// Generate a demo occupancy volume (room with person). pub fn demo_occupancy() -> OccupancyVolume { let nx = 10; @@ -159,3 +128,36 @@ pub fn demo_occupancy() -> OccupancyVolume { occupied_count, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn cloud_with(name: &str, pts: &[(f32, f32, f32)]) -> PointCloud { + let mut c = PointCloud::new(name); + for &(x, y, z) in pts { + c.points.push(ColorPoint { x, y, z, r: 10, g: 20, b: 30, intensity: 0.5 }); + } + c + } + + #[test] + fn fuse_clouds_merges_non_overlapping() { + let a = cloud_with("a", &[(0.0, 0.0, 0.0)]); + let b = cloud_with("b", &[(5.0, 5.0, 5.0)]); + let fused = fuse_clouds(&[&a, &b], 0.1); + assert_eq!(fused.points.len(), 2, "two far-apart points should yield two voxels"); + } + + #[test] + fn fuse_clouds_voxel_dedup() { + // Points all within one voxel must collapse to a single averaged point. + let a = cloud_with("a", &[ + (0.01, 0.02, 0.03), + (0.04, 0.01, 0.02), + (0.03, 0.03, 0.01), + ]); + let fused = fuse_clouds(&[&a], 0.5); + assert_eq!(fused.points.len(), 1, "three close points → one voxel"); + } +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/serial_csi.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/serial_csi.rs deleted file mode 100644 index 7bcb7c5e..00000000 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/serial_csi.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Serial CSI reader — parse ESP32 CSI data from /dev/ttyACM0 and /dev/ttyUSB0. -//! -//! ESP32 firmware outputs lines like: -//! I (56994) csi_collector: CSI cb #2900: len=256 rssi=-32 ch=5 -//! -//! This module reads those lines, extracts RSSI, and tracks signal changes -//! to detect motion and presence. - -use std::io::{BufRead, BufReader}; -use std::sync::{Arc, Mutex}; - -#[derive(Clone, Debug)] -pub struct CsiReading { - pub port: String, - pub rssi: i32, - pub len: u32, - pub channel: u8, - pub callback_num: u64, - pub timestamp_ms: i64, -} - -#[derive(Clone, Debug)] -pub struct CsiState { - /// Latest readings from each port - pub readings: Vec, - /// RSSI history for motion detection (last 20 values per port) - pub rssi_history: Vec>, - /// Motion score (0.0 = still, 1.0 = strong motion) - pub motion_score: f32, - /// Estimated presence distance (from RSSI) - pub presence_distance_m: f32, - /// Total frames received - pub total_frames: u64, -} - -impl Default for CsiState { - fn default() -> Self { - Self { - readings: Vec::new(), - rssi_history: Vec::new(), - motion_score: 0.0, - presence_distance_m: 3.0, - total_frames: 0, - } - } -} - -/// Start reading CSI from serial ports in background threads. -/// Returns shared state that updates as frames arrive. -pub fn start_serial_readers(ports: &[&str]) -> Arc> { - let state = Arc::new(Mutex::new(CsiState::default())); - - for (idx, port) in ports.iter().enumerate() { - let port_path = port.to_string(); - let st = state.clone(); - - std::thread::spawn(move || { - loop { - if let Ok(file) = std::fs::File::open(&port_path) { - let reader = BufReader::new(file); - for line in reader.lines() { - if let Ok(line) = line { - if let Some(reading) = parse_csi_line(&line, &port_path) { - update_state(&st, idx, reading); - } - } - } - } - // Retry if port disconnects - std::thread::sleep(std::time::Duration::from_secs(2)); - eprintln!(" CSI: reconnecting {port_path}..."); - } - }); - - eprintln!(" CSI: reading {port}"); - } - - state -} - -fn parse_csi_line(line: &str, port: &str) -> Option { - // Parse: I (56994) csi_collector: CSI cb #2900: len=256 rssi=-32 ch=5 - if !line.contains("csi_collector") || !line.contains("CSI cb") { - return None; - } - - let rssi = line.split("rssi=").nth(1)? - .split_whitespace().next()? - .parse::().ok()?; - - let len = line.split("len=").nth(1)? - .split_whitespace().next()? - .parse::().ok()?; - - let channel = line.split("ch=").nth(1)? - .split_whitespace().next() - .unwrap_or("0") - .parse::().unwrap_or(0); - - let cb_num = line.split('#').nth(1)? - .split(':').next()? - .parse::().ok()?; - - Some(CsiReading { - port: port.to_string(), - rssi, - len, - channel, - callback_num: cb_num, - timestamp_ms: chrono::Utc::now().timestamp_millis(), - }) -} - -fn update_state(state: &Arc>, port_idx: usize, reading: CsiReading) { - let mut st = state.lock().unwrap(); - - // Ensure vectors are big enough - while st.readings.len() <= port_idx { - st.readings.push(reading.clone()); - st.rssi_history.push(Vec::new()); - } - - st.readings[port_idx] = reading.clone(); - st.total_frames += 1; - - // Track RSSI history - let hist = &mut st.rssi_history[port_idx]; - hist.push(reading.rssi); - if hist.len() > 20 { hist.remove(0); } - - // Motion detection: RSSI variance over last 20 readings - if hist.len() >= 5 { - let mean: f32 = hist.iter().map(|&r| r as f32).sum::() / hist.len() as f32; - let variance: f32 = hist.iter().map(|&r| (r as f32 - mean).powi(2)).sum::() / hist.len() as f32; - // High variance = motion (someone moving changes signal reflections) - st.motion_score = (variance / 50.0).min(1.0); // normalize: variance of 50 = full motion - } - - // Estimate presence distance from RSSI (path loss model) - // Free space: RSSI = -10 * n * log10(d) + A - // n ≈ 2.5 for indoor, A ≈ -30 (1m reference) - let avg_rssi: f32 = st.readings.iter().map(|r| r.rssi as f32).sum::() - / st.readings.len().max(1) as f32; - let d = 10.0f32.powf((-30.0 - avg_rssi) / (10.0 * 2.5)); - st.presence_distance_m = d.clamp(0.3, 10.0); -} - -/// Convert CSI state to occupancy influence on the point cloud. -/// Returns (motion_score, presence_distance, total_frames). -pub fn get_csi_influence(state: &Arc>) -> (f32, f32, u64) { - let st = state.lock().unwrap(); - (st.motion_score, st.presence_distance_m, st.total_frames) -} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/training.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/training.rs index bd14de91..bf0c725a 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/training.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/training.rs @@ -458,3 +458,40 @@ pub struct PreferencePair { pub chosen: String, pub rejected: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_rejects_parent_dir_traversal() { + assert!(sanitize_data_path("../etc/passwd").is_err()); + assert!(sanitize_data_path("foo/../bar").is_err()); + assert!(sanitize_data_path("/tmp/.. /evil").is_ok(), "`.. ` is not ParentDir"); + } + + #[test] + fn sanitize_accepts_relative_child() { + assert!(sanitize_data_path("data/ruview").is_ok()); + assert!(sanitize_data_path("./foo").is_ok()); + } + + #[test] + fn training_session_new_rejects_traversal() { + // Even if the filesystem has such a path, TrainingSession should refuse. + let err = TrainingSession::new("../etc/passwd").err(); + assert!(err.is_some(), "traversal path must be rejected"); + } + + #[test] + fn training_session_new_accepts_child_path() { + // Use a unique tmpdir to avoid cross-test interference. + let tmp = std::env::temp_dir().join(format!("ruview-train-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let sess = TrainingSession::new(tmp.to_str().unwrap()) + .expect("TrainingSession should accept a clean tmpdir"); + // data_dir should have been canonicalised to an absolute path. + assert!(sess.data_dir.is_absolute()); + let _ = std::fs::remove_dir_all(&tmp); + } +}