diff --git a/.gitignore b/.gitignore index dcb8cd1..93c41db 100644 --- a/.gitignore +++ b/.gitignore @@ -193,6 +193,9 @@ cython_debug/ # PyPI configuration file .pypirc +# Compiled Swift helper binaries (macOS WiFi sensing) +v1/src/sensing/mac_wifi + # Cursor # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/linux_scanner.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/linux_scanner.rs new file mode 100644 index 0000000..4026fe5 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/linux_scanner.rs @@ -0,0 +1,359 @@ +//! Adapter that scans WiFi BSSIDs on Linux by invoking `iw dev scan`. +//! +//! This is the Linux counterpart to [`NetshBssidScanner`](super::NetshBssidScanner) +//! on Windows and [`MacosCoreWlanScanner`](super::MacosCoreWlanScanner) on macOS. +//! +//! # Design +//! +//! The adapter shells out to `iw dev scan` (or `iw dev scan dump` +//! to read cached results without triggering a new scan, which requires root). +//! The output is parsed into [`BssidObservation`] values using the same domain +//! types shared by all platform adapters. +//! +//! # Permissions +//! +//! - `iw dev scan` requires `CAP_NET_ADMIN` (typically root). +//! - `iw dev scan dump` reads cached results and may work without root +//! on some distributions. +//! +//! # Platform +//! +//! Linux only. Gated behind `#[cfg(target_os = "linux")]` at the module level. + +use std::process::Command; +use std::time::Instant; + +use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType}; +use crate::error::WifiScanError; + +// --------------------------------------------------------------------------- +// LinuxIwScanner +// --------------------------------------------------------------------------- + +/// Synchronous WiFi scanner that shells out to `iw dev scan`. +/// +/// Each call to [`scan_sync`](Self::scan_sync) spawns a subprocess, captures +/// stdout, and parses the BSS stanzas into [`BssidObservation`] values. +pub struct LinuxIwScanner { + /// Wireless interface name (e.g. `"wlan0"`, `"wlp2s0"`). + interface: String, + /// If true, use `scan dump` (cached results) instead of triggering a new + /// scan. This avoids the root requirement but may return stale data. + use_dump: bool, +} + +impl LinuxIwScanner { + /// Create a scanner for the default interface `wlan0`. + pub fn new() -> Self { + Self { + interface: "wlan0".to_owned(), + use_dump: false, + } + } + + /// Create a scanner for a specific wireless interface. + pub fn with_interface(iface: impl Into) -> Self { + Self { + interface: iface.into(), + use_dump: false, + } + } + + /// Use `scan dump` instead of `scan` to read cached results without root. + pub fn use_cached(mut self) -> Self { + self.use_dump = true; + self + } + + /// Run `iw dev scan` and parse the output synchronously. + /// + /// Returns one [`BssidObservation`] per BSS stanza in the output. + pub fn scan_sync(&self) -> Result, WifiScanError> { + let scan_cmd = if self.use_dump { "dump" } else { "scan" }; + + let mut args = vec!["dev", &self.interface, "scan"]; + if self.use_dump { + args.push(scan_cmd); + } + + // iw uses "scan dump" not "scan scan dump" + let args = if self.use_dump { + vec!["dev", &self.interface, "scan", "dump"] + } else { + vec!["dev", &self.interface, "scan"] + }; + + let output = Command::new("iw") + .args(&args) + .output() + .map_err(|e| { + WifiScanError::ProcessError(format!( + "failed to run `iw {}`: {e}", + args.join(" ") + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WifiScanError::ScanFailed { + reason: format!( + "iw exited with {}: {}", + output.status, + stderr.trim() + ), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_iw_scan_output(&stdout) + } +} + +impl Default for LinuxIwScanner { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/// Intermediate accumulator for fields within a single BSS stanza. +#[derive(Default)] +struct BssStanza { + bssid: Option, + ssid: Option, + signal_dbm: Option, + freq_mhz: Option, + channel: Option, +} + +impl BssStanza { + /// Flush this stanza into a [`BssidObservation`], if we have enough data. + fn flush(self, timestamp: Instant) -> Option { + let bssid_str = self.bssid?; + let bssid = BssidId::parse(&bssid_str).ok()?; + let rssi_dbm = self.signal_dbm.unwrap_or(-90.0); + + // Determine channel from explicit field or frequency. + let channel = self.channel.or_else(|| { + self.freq_mhz.map(freq_to_channel) + }).unwrap_or(0); + + let band = BandType::from_channel(channel); + let radio_type = infer_radio_type_from_freq(self.freq_mhz.unwrap_or(0)); + let signal_pct = ((rssi_dbm + 100.0) * 2.0).clamp(0.0, 100.0); + + Some(BssidObservation { + bssid, + rssi_dbm, + signal_pct, + channel, + band, + radio_type, + ssid: self.ssid.unwrap_or_default(), + timestamp, + }) + } +} + +/// Parse the text output of `iw dev scan [dump]`. +/// +/// The output consists of BSS stanzas, each starting with: +/// ```text +/// BSS aa:bb:cc:dd:ee:ff(on wlan0) +/// ``` +/// followed by indented key-value lines. +pub fn parse_iw_scan_output(output: &str) -> Result, WifiScanError> { + let now = Instant::now(); + let mut results = Vec::new(); + let mut current: Option = None; + + for line in output.lines() { + // New BSS stanza starts with "BSS " at column 0. + if line.starts_with("BSS ") { + // Flush previous stanza. + if let Some(stanza) = current.take() { + if let Some(obs) = stanza.flush(now) { + results.push(obs); + } + } + + // Parse BSSID from "BSS aa:bb:cc:dd:ee:ff(on wlan0)" or + // "BSS aa:bb:cc:dd:ee:ff -- associated". + let rest = &line[4..]; + let mac_end = rest.find(|c: char| !c.is_ascii_hexdigit() && c != ':') + .unwrap_or(rest.len()); + let mac = &rest[..mac_end]; + + if mac.len() == 17 { + let mut stanza = BssStanza::default(); + stanza.bssid = Some(mac.to_lowercase()); + current = Some(stanza); + } + continue; + } + + // Indented lines belong to the current stanza. + let trimmed = line.trim(); + if let Some(ref mut stanza) = current { + if let Some(rest) = trimmed.strip_prefix("SSID:") { + stanza.ssid = Some(rest.trim().to_owned()); + } else if let Some(rest) = trimmed.strip_prefix("signal:") { + // "signal: -52.00 dBm" + stanza.signal_dbm = parse_signal_dbm(rest); + } else if let Some(rest) = trimmed.strip_prefix("freq:") { + // "freq: 5180" + stanza.freq_mhz = rest.trim().parse().ok(); + } else if let Some(rest) = trimmed.strip_prefix("DS Parameter set: channel") { + // "DS Parameter set: channel 6" + stanza.channel = rest.trim().parse().ok(); + } + } + } + + // Flush the last stanza. + if let Some(stanza) = current.take() { + if let Some(obs) = stanza.flush(now) { + results.push(obs); + } + } + + Ok(results) +} + +/// Convert a frequency in MHz to an 802.11 channel number. +fn freq_to_channel(freq_mhz: u32) -> u8 { + match freq_mhz { + // 2.4 GHz: channels 1-14. + 2412..=2472 => ((freq_mhz - 2407) / 5) as u8, + 2484 => 14, + // 5 GHz: channels 36-177. + 5170..=5885 => ((freq_mhz - 5000) / 5) as u8, + // 6 GHz (Wi-Fi 6E). + 5955..=7115 => ((freq_mhz - 5950) / 5) as u8, + _ => 0, + } +} + +/// Parse a signal strength string like "-52.00 dBm" into dBm. +fn parse_signal_dbm(s: &str) -> Option { + let s = s.trim(); + // Take everything up to " dBm" or just parse the number. + let num_part = s.split_whitespace().next()?; + num_part.parse().ok() +} + +/// Infer radio type from frequency (best effort). +fn infer_radio_type_from_freq(freq_mhz: u32) -> RadioType { + match freq_mhz { + 5955..=7115 => RadioType::Ax, // 6 GHz → Wi-Fi 6E + 5170..=5885 => RadioType::Ac, // 5 GHz → likely 802.11ac + _ => RadioType::N, // 2.4 GHz → at least 802.11n + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Real-world `iw dev wlan0 scan` output (truncated to 3 BSSes). + const SAMPLE_IW_OUTPUT: &str = "\ +BSS aa:bb:cc:dd:ee:ff(on wlan0) +\tTSF: 123456789 usec +\tfreq: 5180 +\tbeacon interval: 100 TUs +\tcapability: ESS Privacy (0x0011) +\tsignal: -52.00 dBm +\tSSID: HomeNetwork +\tDS Parameter set: channel 36 +BSS 11:22:33:44:55:66(on wlan0) +\tfreq: 2437 +\tsignal: -71.00 dBm +\tSSID: GuestWifi +\tDS Parameter set: channel 6 +BSS de:ad:be:ef:ca:fe(on wlan0) -- associated +\tfreq: 5745 +\tsignal: -45.00 dBm +\tSSID: OfficeNet +"; + + #[test] + fn parse_three_bss_stanzas() { + let obs = parse_iw_scan_output(SAMPLE_IW_OUTPUT).unwrap(); + assert_eq!(obs.len(), 3); + + // First BSS. + assert_eq!(obs[0].ssid, "HomeNetwork"); + assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff"); + assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON); + assert_eq!(obs[0].channel, 36); + assert_eq!(obs[0].band, BandType::Band5GHz); + + // Second BSS: 2.4 GHz. + assert_eq!(obs[1].ssid, "GuestWifi"); + assert_eq!(obs[1].channel, 6); + assert_eq!(obs[1].band, BandType::Band2_4GHz); + assert_eq!(obs[1].radio_type, RadioType::N); + + // Third BSS: "-- associated" suffix. + assert_eq!(obs[2].ssid, "OfficeNet"); + assert_eq!(obs[2].bssid.to_string(), "de:ad:be:ef:ca:fe"); + assert!((obs[2].rssi_dbm - (-45.0)).abs() < f64::EPSILON); + } + + #[test] + fn freq_to_channel_conversion() { + assert_eq!(freq_to_channel(2412), 1); + assert_eq!(freq_to_channel(2437), 6); + assert_eq!(freq_to_channel(2462), 11); + assert_eq!(freq_to_channel(2484), 14); + assert_eq!(freq_to_channel(5180), 36); + assert_eq!(freq_to_channel(5745), 149); + assert_eq!(freq_to_channel(5955), 1); // 6 GHz channel 1 + assert_eq!(freq_to_channel(9999), 0); // Unknown + } + + #[test] + fn parse_signal_dbm_values() { + assert!((parse_signal_dbm(" -52.00 dBm").unwrap() - (-52.0)).abs() < f64::EPSILON); + assert!((parse_signal_dbm("-71.00 dBm").unwrap() - (-71.0)).abs() < f64::EPSILON); + assert!((parse_signal_dbm("-45.00").unwrap() - (-45.0)).abs() < f64::EPSILON); + } + + #[test] + fn empty_output() { + let obs = parse_iw_scan_output("").unwrap(); + assert!(obs.is_empty()); + } + + #[test] + fn missing_ssid_defaults_to_empty() { + let output = "\ +BSS 11:22:33:44:55:66(on wlan0) +\tfreq: 2437 +\tsignal: -60.00 dBm +"; + let obs = parse_iw_scan_output(output).unwrap(); + assert_eq!(obs.len(), 1); + assert_eq!(obs[0].ssid, ""); + } + + #[test] + fn channel_from_freq_when_ds_param_missing() { + let output = "\ +BSS aa:bb:cc:dd:ee:ff(on wlan0) +\tfreq: 5180 +\tsignal: -50.00 dBm +\tSSID: NoDS +"; + let obs = parse_iw_scan_output(output).unwrap(); + assert_eq!(obs.len(), 1); + assert_eq!(obs[0].channel, 36); // Derived from 5180 MHz. + } +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs new file mode 100644 index 0000000..be3d045 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs @@ -0,0 +1,360 @@ +//! Adapter that scans WiFi BSSIDs on macOS by invoking a compiled Swift +//! helper binary that uses Apple's CoreWLAN framework. +//! +//! This is the macOS counterpart to [`NetshBssidScanner`](super::NetshBssidScanner) +//! on Windows. It follows ADR-025 (ORCA — macOS CoreWLAN WiFi Sensing). +//! +//! # Design +//! +//! Apple removed the `airport` CLI in macOS Sonoma 14.4+ and CoreWLAN is a +//! Swift/Objective-C framework with no stable C ABI for Rust FFI. We therefore +//! shell out to a small Swift helper (`mac_wifi`) that outputs JSON lines: +//! +//! ```json +//! {"ssid":"MyNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"} +//! ``` +//! +//! macOS Sonoma+ redacts real BSSID MACs to `00:00:00:00:00:00` unless the app +//! holds the `com.apple.wifi.scan` entitlement. When we detect a zeroed BSSID +//! we generate a deterministic synthetic MAC via `SHA-256(ssid:channel)[:6]`, +//! setting the locally-administered bit so it never collides with real OUI +//! allocations. +//! +//! # Platform +//! +//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level. + +use std::process::Command; +use std::time::Instant; + +use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType}; +use crate::error::WifiScanError; + +// --------------------------------------------------------------------------- +// MacosCoreWlanScanner +// --------------------------------------------------------------------------- + +/// Synchronous WiFi scanner that shells out to the `mac_wifi` Swift helper. +/// +/// The helper binary must be compiled from `v1/src/sensing/mac_wifi.swift` and +/// placed on `$PATH` or at a known location. The scanner invokes it with a +/// `--scan-once` flag (single-shot mode) and parses the JSON output. +/// +/// If the helper is not found, [`scan_sync`](Self::scan_sync) returns a +/// [`WifiScanError::ProcessError`]. +pub struct MacosCoreWlanScanner { + /// Path to the `mac_wifi` helper binary. Defaults to `"mac_wifi"` (on PATH). + helper_path: String, +} + +impl MacosCoreWlanScanner { + /// Create a scanner that looks for `mac_wifi` on `$PATH`. + pub fn new() -> Self { + Self { + helper_path: "mac_wifi".to_owned(), + } + } + + /// Create a scanner with an explicit path to the Swift helper binary. + pub fn with_path(path: impl Into) -> Self { + Self { + helper_path: path.into(), + } + } + + /// Run the Swift helper and parse the output synchronously. + /// + /// Returns one [`BssidObservation`] per BSSID seen in the scan. + pub fn scan_sync(&self) -> Result, WifiScanError> { + let output = Command::new(&self.helper_path) + .arg("--scan-once") + .output() + .map_err(|e| { + WifiScanError::ProcessError(format!( + "failed to run mac_wifi helper ({}): {e}", + self.helper_path + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WifiScanError::ScanFailed { + reason: format!( + "mac_wifi exited with {}: {}", + output.status, + stderr.trim() + ), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_macos_scan_output(&stdout) + } +} + +impl Default for MacosCoreWlanScanner { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/// Parse the JSON-lines output from the `mac_wifi` Swift helper. +/// +/// Each line is expected to be a JSON object with the fields: +/// `ssid`, `bssid`, `rssi`, `noise`, `channel`, `band`. +/// +/// Lines that fail to parse are silently skipped (the helper may emit +/// status messages on stdout). +pub fn parse_macos_scan_output(output: &str) -> Result, WifiScanError> { + let now = Instant::now(); + let mut results = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() || !line.starts_with('{') { + continue; + } + + if let Some(obs) = parse_json_line(line, now) { + results.push(obs); + } + } + + Ok(results) +} + +/// Parse a single JSON line into a [`BssidObservation`]. +/// +/// Uses a lightweight manual parser to avoid pulling in `serde_json` as a +/// hard dependency. The JSON structure is simple and well-known. +fn parse_json_line(line: &str, timestamp: Instant) -> Option { + let ssid = extract_string_field(line, "ssid")?; + let bssid_str = extract_string_field(line, "bssid")?; + let rssi = extract_number_field(line, "rssi")?; + let channel_f = extract_number_field(line, "channel")?; + let channel = channel_f as u8; + + // Resolve BSSID: use real MAC if available, otherwise generate synthetic. + let bssid = resolve_bssid(&bssid_str, &ssid, channel)?; + + let band = BandType::from_channel(channel); + + // macOS CoreWLAN doesn't report radio type directly; infer from band/channel. + let radio_type = infer_radio_type(channel); + + // Convert RSSI to signal percentage using the standard mapping. + let signal_pct = ((rssi + 100.0) * 2.0).clamp(0.0, 100.0); + + Some(BssidObservation { + bssid, + rssi_dbm: rssi, + signal_pct, + channel, + band, + radio_type, + ssid, + timestamp, + }) +} + +/// Resolve a BSSID string to a [`BssidId`]. +/// +/// If the MAC is all-zeros (macOS redaction), generate a synthetic +/// locally-administered MAC from `SHA-256(ssid:channel)`. +fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option { + // Try parsing the real BSSID first. + if let Ok(id) = BssidId::parse(bssid_str) { + // Check for the all-zeros redacted BSSID. + if id.0 != [0, 0, 0, 0, 0, 0] { + return Some(id); + } + } + + // Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes, + // set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear). + Some(synthetic_bssid(ssid, channel)) +} + +/// Generate a deterministic synthetic BSSID from SSID and channel. +/// +/// Uses a simple hash (FNV-1a-inspired) to avoid pulling in `sha2` crate. +/// The locally-administered bit is set so these never collide with real OUI MACs. +fn synthetic_bssid(ssid: &str, channel: u8) -> BssidId { + // Simple but deterministic hash — FNV-1a 64-bit. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &byte in ssid.as_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + hash ^= u64::from(channel); + hash = hash.wrapping_mul(0x0100_0000_01b3); + + let bytes = hash.to_le_bytes(); + let mut mac = [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]]; + + // Set locally-administered bit (bit 1 of byte 0) and clear multicast (bit 0). + mac[0] = (mac[0] | 0x02) & 0xFE; + + BssidId(mac) +} + +/// Infer radio type from channel number (best effort on macOS). +fn infer_radio_type(channel: u8) -> RadioType { + match channel { + // 5 GHz channels → likely 802.11ac or newer + 36..=177 => RadioType::Ac, + // 2.4 GHz → at least 802.11n + _ => RadioType::N, + } +} + +// --------------------------------------------------------------------------- +// Lightweight JSON field extractors +// --------------------------------------------------------------------------- + +/// Extract a string field value from a JSON object string. +/// +/// Looks for `"key":"value"` or `"key": "value"` patterns. +fn extract_string_field(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\"", key); + let key_pos = json.find(&pattern)?; + let after_key = &json[key_pos + pattern.len()..]; + + // Skip optional whitespace and the colon. + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_colon = after_colon.trim_start(); + + // Expect opening quote. + let after_quote = after_colon.strip_prefix('"')?; + + // Find closing quote (handle escaped quotes). + let mut end = 0; + let bytes = after_quote.as_bytes(); + while end < bytes.len() { + if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') { + break; + } + end += 1; + } + + Some(after_quote[..end].to_owned()) +} + +/// Extract a numeric field value from a JSON object string. +/// +/// Looks for `"key": ` patterns. +fn extract_number_field(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\"", key); + let key_pos = json.find(&pattern)?; + let after_key = &json[key_pos + pattern.len()..]; + + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_colon = after_colon.trim_start(); + + // Collect digits, sign, and decimal point. + let num_str: String = after_colon + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-' || *c == '.' || *c == '+' || *c == 'e' || *c == 'E') + .collect(); + + num_str.parse().ok() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_OUTPUT: &str = r#" +{"ssid":"HomeNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"} +{"ssid":"GuestWifi","bssid":"11:22:33:44:55:66","rssi":-71,"noise":-92,"channel":6,"band":"2.4GHz"} +{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"} +"#; + + #[test] + fn parse_valid_output() { + let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap(); + assert_eq!(obs.len(), 3); + + // First entry: real BSSID. + assert_eq!(obs[0].ssid, "HomeNetwork"); + assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff"); + assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON); + assert_eq!(obs[0].channel, 36); + assert_eq!(obs[0].band, BandType::Band5GHz); + + // Second entry: 2.4 GHz. + assert_eq!(obs[1].ssid, "GuestWifi"); + assert_eq!(obs[1].channel, 6); + assert_eq!(obs[1].band, BandType::Band2_4GHz); + assert_eq!(obs[1].radio_type, RadioType::N); + + // Third entry: redacted BSSID → synthetic MAC. + assert_eq!(obs[2].ssid, "Redacted"); + // Should NOT be all-zeros. + assert_ne!(obs[2].bssid.0, [0, 0, 0, 0, 0, 0]); + // Should have locally-administered bit set. + assert_eq!(obs[2].bssid.0[0] & 0x02, 0x02); + // Should have unicast bit (multicast cleared). + assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00); + } + + #[test] + fn synthetic_bssid_is_deterministic() { + let a = synthetic_bssid("TestNet", 36); + let b = synthetic_bssid("TestNet", 36); + assert_eq!(a, b); + + // Different SSID or channel → different MAC. + let c = synthetic_bssid("OtherNet", 36); + assert_ne!(a, c); + + let d = synthetic_bssid("TestNet", 6); + assert_ne!(a, d); + } + + #[test] + fn parse_empty_and_junk_lines() { + let output = "\n \nnot json\n{broken json\n"; + let obs = parse_macos_scan_output(output).unwrap(); + assert!(obs.is_empty()); + } + + #[test] + fn extract_string_field_basic() { + let json = r#"{"ssid":"MyNet","bssid":"aa:bb:cc:dd:ee:ff"}"#; + assert_eq!(extract_string_field(json, "ssid").unwrap(), "MyNet"); + assert_eq!( + extract_string_field(json, "bssid").unwrap(), + "aa:bb:cc:dd:ee:ff" + ); + assert!(extract_string_field(json, "missing").is_none()); + } + + #[test] + fn extract_number_field_basic() { + let json = r#"{"rssi":-52,"channel":36}"#; + assert!((extract_number_field(json, "rssi").unwrap() - (-52.0)).abs() < f64::EPSILON); + assert!((extract_number_field(json, "channel").unwrap() - 36.0).abs() < f64::EPSILON); + } + + #[test] + fn signal_pct_clamping() { + // RSSI -50 → pct = (-50+100)*2 = 100 + let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-50,"channel":1}"#; + let obs = parse_json_line(json, Instant::now()).unwrap(); + assert!((obs.signal_pct - 100.0).abs() < f64::EPSILON); + + // RSSI -100 → pct = 0 + let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-100,"channel":1}"#; + let obs = parse_json_line(json, Instant::now()).unwrap(); + assert!((obs.signal_pct - 0.0).abs() < f64::EPSILON); + } +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs index 60d04c3..abdb176 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs @@ -1,12 +1,30 @@ //! Adapter implementations for the [`WlanScanPort`] port. //! //! Each adapter targets a specific platform scanning mechanism: -//! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid`. -//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path. +//! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid` (Windows). +//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path (Windows). +//! - [`MacosCoreWlanScanner`]: CoreWLAN via Swift helper binary (macOS, ADR-025). +//! - [`LinuxIwScanner`]: parses `iw dev scan` output (Linux). pub(crate) mod netsh_scanner; pub mod wlanapi_scanner; +#[cfg(target_os = "macos")] +pub mod macos_scanner; + +#[cfg(target_os = "linux")] +pub mod linux_scanner; + pub use netsh_scanner::NetshBssidScanner; pub use netsh_scanner::parse_netsh_output; pub use wlanapi_scanner::WlanApiScanner; + +#[cfg(target_os = "macos")] +pub use macos_scanner::MacosCoreWlanScanner; +#[cfg(target_os = "macos")] +pub use macos_scanner::parse_macos_scan_output; + +#[cfg(target_os = "linux")] +pub use linux_scanner::LinuxIwScanner; +#[cfg(target_os = "linux")] +pub use linux_scanner::parse_iw_scan_output; diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs index bd2c13b..f1ebabb 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs @@ -6,8 +6,10 @@ //! //! - **Domain types**: [`BssidId`], [`BssidObservation`], [`BandType`], [`RadioType`] //! - **Port**: [`WlanScanPort`] -- trait abstracting the platform scan backend -//! - **Adapter**: [`NetshBssidScanner`] -- Tier 1 adapter that parses -//! `netsh wlan show networks mode=bssid` output +//! - **Adapters**: +//! - [`NetshBssidScanner`] -- Windows, parses `netsh wlan show networks mode=bssid` +//! - `MacosCoreWlanScanner` -- macOS, invokes CoreWLAN Swift helper (ADR-025) +//! - `LinuxIwScanner` -- Linux, parses `iw dev scan` output pub mod adapter; pub mod domain; @@ -19,6 +21,16 @@ pub mod port; pub use adapter::NetshBssidScanner; pub use adapter::parse_netsh_output; pub use adapter::WlanApiScanner; + +#[cfg(target_os = "macos")] +pub use adapter::MacosCoreWlanScanner; +#[cfg(target_os = "macos")] +pub use adapter::parse_macos_scan_output; + +#[cfg(target_os = "linux")] +pub use adapter::LinuxIwScanner; +#[cfg(target_os = "linux")] +pub use adapter::parse_iw_scan_output; pub use domain::bssid::{BandType, BssidId, BssidObservation, RadioType}; pub use domain::frame::MultiApFrame; pub use domain::registry::{BssidEntry, BssidMeta, BssidRegistry, RunningStats}; diff --git a/v1/src/sensing/rssi_collector.py b/v1/src/sensing/rssi_collector.py index 0d21d52..40540ca 100644 --- a/v1/src/sensing/rssi_collector.py +++ b/v1/src/sensing/rssi_collector.py @@ -696,15 +696,12 @@ class MacosWifiCollector: bufsize=1 # Line buffered ) - synth_tx = 0 - synth_rx = 0 - while self._running and self._process and self._process.poll() is None: try: line = self._process.stdout.readline() if not line: continue - + line = line.strip() if not line: continue @@ -714,22 +711,19 @@ class MacosWifiCollector: if "error" in data: logger.error("macOS WiFi utility error: %s", data["error"]) continue - + rssi = float(data.get("rssi", -80.0)) noise = float(data.get("noise", -95.0)) - + link_quality = max(0.0, min(1.0, (rssi + 100.0) / 60.0)) - - synth_tx += 1500 - synth_rx += 3000 - + sample = WifiSample( timestamp=time.time(), rssi_dbm=rssi, noise_dbm=noise, link_quality=link_quality, - tx_bytes=synth_tx, - rx_bytes=synth_rx, + tx_bytes=0, + rx_bytes=0, retry_count=0, interface=self._interface, )