//! Tier 2: Windows WLAN API adapter for higher scan rates. //! //! This module provides a higher-rate scanning interface that targets 10-20 Hz //! scan rates compared to the Tier 1 [`NetshBssidScanner`]'s ~2 Hz limitation //! (caused by subprocess spawn overhead per scan). //! //! # Current implementation //! //! The adapter currently wraps [`NetshBssidScanner`] and provides: //! //! - **Synchronous scanning** via [`WlanScanPort`] trait implementation //! - **Async scanning** (feature-gated behind `"wlanapi"`) via //! `tokio::task::spawn_blocking` //! - **Scan metrics** (count, timing) for performance monitoring //! - **Rate estimation** based on observed inter-scan intervals //! //! # Future: native `wlanapi.dll` FFI //! //! When native WLAN API bindings are available, this adapter will call: //! //! - `WlanOpenHandle` -- open a session to the WLAN service //! - `WlanEnumInterfaces` -- discover WLAN adapters //! - `WlanScan` -- trigger a fresh scan //! - `WlanGetNetworkBssList` -- retrieve raw BSS entries with RSSI //! - `WlanCloseHandle` -- clean up the session handle //! //! This eliminates the `netsh.exe` process-spawn bottleneck and enables //! true 10-20 Hz scan rates suitable for real-time sensing. //! //! # Platform //! //! Windows only. On other platforms this module is not compiled. use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use crate::adapter::netsh_scanner::NetshBssidScanner; use crate::domain::bssid::BssidObservation; use crate::error::WifiScanError; use crate::port::WlanScanPort; // --------------------------------------------------------------------------- // Scan metrics // --------------------------------------------------------------------------- /// Accumulated metrics from scan operations. #[derive(Debug, Clone)] pub struct ScanMetrics { /// Total number of scans performed since creation. pub scan_count: u64, /// Total number of BSSIDs observed across all scans. pub total_bssids_observed: u64, /// Duration of the most recent scan. pub last_scan_duration: Option, /// Estimated scan rate in Hz based on the last scan duration. /// Returns `None` if no scans have been performed yet. pub estimated_rate_hz: Option, } // --------------------------------------------------------------------------- // WlanApiScanner // --------------------------------------------------------------------------- /// Tier 2 WLAN API scanner with async support and scan metrics. /// /// Currently wraps [`NetshBssidScanner`] with performance instrumentation. /// When native WLAN API bindings become available, the inner implementation /// will switch to `WlanGetNetworkBssList` for approximately 10x higher scan /// rates without changing the public interface. /// /// # Example (sync) /// /// ```no_run /// use wifi_densepose_wifiscan::adapter::wlanapi_scanner::WlanApiScanner; /// use wifi_densepose_wifiscan::port::WlanScanPort; /// /// let scanner = WlanApiScanner::new(); /// let observations = scanner.scan().unwrap(); /// for obs in &observations { /// println!("{}: {} dBm", obs.bssid, obs.rssi_dbm); /// } /// println!("metrics: {:?}", scanner.metrics()); /// ``` pub struct WlanApiScanner { /// The underlying Tier 1 scanner. inner: NetshBssidScanner, /// Number of scans performed. scan_count: AtomicU64, /// Total BSSIDs observed across all scans. total_bssids: AtomicU64, /// Timestamp of the most recent scan start (for rate estimation). /// /// Uses `std::sync::Mutex` because `Instant` is not atomic but we need /// interior mutability. The lock duration is negligible (one write per /// scan) so contention is not a concern. last_scan_start: std::sync::Mutex>, /// Duration of the most recent scan. last_scan_duration: std::sync::Mutex>, } impl WlanApiScanner { /// Create a new Tier 2 scanner. pub fn new() -> Self { Self { inner: NetshBssidScanner::new(), scan_count: AtomicU64::new(0), total_bssids: AtomicU64::new(0), last_scan_start: std::sync::Mutex::new(None), last_scan_duration: std::sync::Mutex::new(None), } } /// Return accumulated scan metrics. pub fn metrics(&self) -> ScanMetrics { let scan_count = self.scan_count.load(Ordering::Relaxed); let total_bssids_observed = self.total_bssids.load(Ordering::Relaxed); let last_scan_duration = *self.last_scan_duration.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let estimated_rate_hz = last_scan_duration.map(|d| { let secs = d.as_secs_f64(); if secs > 0.0 { 1.0 / secs } else { f64::INFINITY } }); ScanMetrics { scan_count, total_bssids_observed, last_scan_duration, estimated_rate_hz, } } /// Return the number of scans performed so far. pub fn scan_count(&self) -> u64 { self.scan_count.load(Ordering::Relaxed) } /// Perform a synchronous scan with timing instrumentation. /// /// This is the core scan method that both the [`WlanScanPort`] trait /// implementation and the async wrapper delegate to. fn scan_instrumented(&self) -> Result, WifiScanError> { let start = Instant::now(); // Record scan start time. if let Ok(mut guard) = self.last_scan_start.lock() { *guard = Some(start); } // Delegate to the Tier 1 scanner. let results = self.inner.scan_sync()?; // Record metrics. let elapsed = start.elapsed(); if let Ok(mut guard) = self.last_scan_duration.lock() { *guard = Some(elapsed); } self.scan_count.fetch_add(1, Ordering::Relaxed); self.total_bssids .fetch_add(results.len() as u64, Ordering::Relaxed); tracing::debug!( scan_count = self.scan_count.load(Ordering::Relaxed), bssid_count = results.len(), elapsed_ms = elapsed.as_millis(), "Tier 2 scan complete" ); Ok(results) } /// Perform an async scan by offloading the blocking netsh call to /// a background thread. /// /// This is gated behind the `"wlanapi"` feature because it requires /// the `tokio` runtime dependency. /// /// # Errors /// /// Returns [`WifiScanError::ScanFailed`] if the background task panics /// or is cancelled, or propagates any error from the underlying scan. #[cfg(feature = "wlanapi")] pub async fn scan_async(&self) -> Result, WifiScanError> { // We need to create a fresh scanner for the blocking task because // `&self` is not `Send` across the spawn_blocking boundary. // `NetshBssidScanner` is cheap (zero-size struct) so this is fine. let inner = NetshBssidScanner::new(); let start = Instant::now(); let results = tokio::task::spawn_blocking(move || inner.scan_sync()) .await .map_err(|e| WifiScanError::ScanFailed { reason: format!("async scan task failed: {e}"), })??; // Record metrics. let elapsed = start.elapsed(); if let Ok(mut guard) = self.last_scan_duration.lock() { *guard = Some(elapsed); } self.scan_count.fetch_add(1, Ordering::Relaxed); self.total_bssids .fetch_add(results.len() as u64, Ordering::Relaxed); tracing::debug!( scan_count = self.scan_count.load(Ordering::Relaxed), bssid_count = results.len(), elapsed_ms = elapsed.as_millis(), "Tier 2 async scan complete" ); Ok(results) } } impl Default for WlanApiScanner { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // WlanScanPort implementation (sync) // --------------------------------------------------------------------------- impl WlanScanPort for WlanApiScanner { fn scan(&self) -> Result, WifiScanError> { self.scan_instrumented() } fn connected(&self) -> Result, WifiScanError> { // Not yet implemented for Tier 2 -- fall back to a full scan and // return the strongest signal (heuristic for "likely connected"). let mut results = self.scan_instrumented()?; if results.is_empty() { return Ok(None); } // Sort by signal strength descending; return the strongest. results.sort_by(|a, b| { b.rssi_dbm .partial_cmp(&a.rssi_dbm) .unwrap_or(std::cmp::Ordering::Equal) }); Ok(Some(results.swap_remove(0))) } } // --------------------------------------------------------------------------- // Native WLAN API constants and frequency utilities // --------------------------------------------------------------------------- /// Native WLAN API constants and frequency conversion utilities. /// /// When implemented, this will contain: /// /// ```ignore /// extern "system" { /// fn WlanOpenHandle( /// dwClientVersion: u32, /// pReserved: *const std::ffi::c_void, /// pdwNegotiatedVersion: *mut u32, /// phClientHandle: *mut HANDLE, /// ) -> u32; /// /// fn WlanEnumInterfaces( /// hClientHandle: HANDLE, /// pReserved: *const std::ffi::c_void, /// ppInterfaceList: *mut *mut WLAN_INTERFACE_INFO_LIST, /// ) -> u32; /// /// fn WlanGetNetworkBssList( /// hClientHandle: HANDLE, /// pInterfaceGuid: *const GUID, /// pDot11Ssid: *const DOT11_SSID, /// dot11BssType: DOT11_BSS_TYPE, /// bSecurityEnabled: BOOL, /// pReserved: *const std::ffi::c_void, /// ppWlanBssList: *mut *mut WLAN_BSS_LIST, /// ) -> u32; /// /// fn WlanCloseHandle( /// hClientHandle: HANDLE, /// pReserved: *const std::ffi::c_void, /// ) -> u32; /// } /// ``` /// /// The native API returns `WLAN_BSS_ENTRY` structs that include: /// - `dot11Bssid` (6-byte MAC) /// - `lRssi` (dBm as i32) /// - `ulChCenterFrequency` (kHz, from which channel/band are derived) /// - `dot11BssPhyType` (maps to `RadioType`) /// /// This eliminates the netsh subprocess overhead entirely. #[allow(dead_code)] mod wlan_ffi { /// WLAN API client version 2 (Vista+). pub const WLAN_CLIENT_VERSION_2: u32 = 2; /// BSS type for infrastructure networks. pub const DOT11_BSS_TYPE_INFRASTRUCTURE: u32 = 1; /// Convert a center frequency in kHz to an 802.11 channel number. /// /// Covers 2.4 GHz (ch 1-14), 5 GHz (ch 36-177), and 6 GHz bands. #[allow(clippy::cast_possible_truncation)] // Channel numbers always fit in u8 pub fn freq_khz_to_channel(frequency_khz: u32) -> u8 { let mhz = frequency_khz / 1000; match mhz { // 2.4 GHz band 2412..=2472 => ((mhz - 2407) / 5) as u8, 2484 => 14, // 5 GHz band 5170..=5825 => ((mhz - 5000) / 5) as u8, // 6 GHz band (Wi-Fi 6E) 5955..=7115 => ((mhz - 5950) / 5) as u8, _ => 0, } } /// Convert a center frequency in kHz to a band type discriminant. /// /// Returns 0 for 2.4 GHz, 1 for 5 GHz, 2 for 6 GHz. pub fn freq_khz_to_band(frequency_khz: u32) -> u8 { let mhz = frequency_khz / 1000; match mhz { 5000..=5900 => 1, // 5 GHz 5925..=7200 => 2, // 6 GHz _ => 0, // 2.4 GHz and unknown } } } // =========================================================================== // Tests // =========================================================================== #[cfg(test)] mod tests { use super::*; // -- construction --------------------------------------------------------- #[test] fn new_creates_scanner_with_zero_metrics() { let scanner = WlanApiScanner::new(); assert_eq!(scanner.scan_count(), 0); let m = scanner.metrics(); assert_eq!(m.scan_count, 0); assert_eq!(m.total_bssids_observed, 0); assert!(m.last_scan_duration.is_none()); assert!(m.estimated_rate_hz.is_none()); } #[test] fn default_creates_scanner() { let scanner = WlanApiScanner::default(); assert_eq!(scanner.scan_count(), 0); } // -- frequency conversion (FFI placeholder) -------------------------------- #[test] fn freq_khz_to_channel_2_4ghz() { assert_eq!(wlan_ffi::freq_khz_to_channel(2_412_000), 1); assert_eq!(wlan_ffi::freq_khz_to_channel(2_437_000), 6); assert_eq!(wlan_ffi::freq_khz_to_channel(2_462_000), 11); assert_eq!(wlan_ffi::freq_khz_to_channel(2_484_000), 14); } #[test] fn freq_khz_to_channel_5ghz() { assert_eq!(wlan_ffi::freq_khz_to_channel(5_180_000), 36); assert_eq!(wlan_ffi::freq_khz_to_channel(5_240_000), 48); assert_eq!(wlan_ffi::freq_khz_to_channel(5_745_000), 149); } #[test] fn freq_khz_to_channel_6ghz() { // 6 GHz channel 1 = 5955 MHz assert_eq!(wlan_ffi::freq_khz_to_channel(5_955_000), 1); // 6 GHz channel 5 = 5975 MHz assert_eq!(wlan_ffi::freq_khz_to_channel(5_975_000), 5); } #[test] fn freq_khz_to_channel_unknown_returns_zero() { assert_eq!(wlan_ffi::freq_khz_to_channel(900_000), 0); assert_eq!(wlan_ffi::freq_khz_to_channel(0), 0); } #[test] fn freq_khz_to_band_classification() { assert_eq!(wlan_ffi::freq_khz_to_band(2_437_000), 0); // 2.4 GHz assert_eq!(wlan_ffi::freq_khz_to_band(5_180_000), 1); // 5 GHz assert_eq!(wlan_ffi::freq_khz_to_band(5_975_000), 2); // 6 GHz } // -- WlanScanPort trait compliance ----------------------------------------- #[test] fn implements_wlan_scan_port() { // Compile-time check: WlanApiScanner implements WlanScanPort. fn assert_port() {} assert_port::(); } #[test] fn implements_send_and_sync() { fn assert_send_sync() {} assert_send_sync::(); } // -- metrics structure ----------------------------------------------------- #[test] fn scan_metrics_debug_display() { let m = ScanMetrics { scan_count: 42, total_bssids_observed: 126, last_scan_duration: Some(Duration::from_millis(150)), estimated_rate_hz: Some(1.0 / 0.15), }; let debug = format!("{m:?}"); assert!(debug.contains("42")); assert!(debug.contains("126")); } #[test] fn scan_metrics_clone() { let m = ScanMetrics { scan_count: 1, total_bssids_observed: 5, last_scan_duration: None, estimated_rate_hz: None, }; let m2 = m.clone(); assert_eq!(m2.scan_count, 1); assert_eq!(m2.total_bssids_observed, 5); } // -- rate estimation ------------------------------------------------------- #[test] fn estimated_rate_from_known_duration() { let scanner = WlanApiScanner::new(); // Manually set last_scan_duration to simulate a completed scan. { let mut guard = scanner.last_scan_duration.lock().unwrap(); *guard = Some(Duration::from_millis(100)); } let m = scanner.metrics(); let rate = m.estimated_rate_hz.unwrap(); // 100ms per scan => 10 Hz assert!((rate - 10.0).abs() < 0.01, "expected ~10 Hz, got {rate}"); } #[test] fn estimated_rate_none_before_first_scan() { let scanner = WlanApiScanner::new(); assert!(scanner.metrics().estimated_rate_hz.is_none()); } }