Files
wifi-densepose/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_scanner.rs
ruv 3e06970428 feat: Training mode, ADR docs, vitals and wifiscan crates
- Add --train CLI flag with dataset loading, graph transformer training,
  cosine-scheduled SGD, PCK/OKS validation, and checkpoint saving
- Refactor main.rs to import training modules from lib.rs instead of
  duplicating mod declarations
- Add ADR-021 (vital sign detection), ADR-022 (Windows WiFi enhanced
  fidelity), ADR-023 (trained DensePose pipeline) documentation
- Add wifi-densepose-vitals crate: breathing, heartrate, anomaly
  detection, preprocessor, and temporal store
- Add wifi-densepose-wifiscan crate: 8-stage signal intelligence
  pipeline with netsh/wlanapi adapters, multi-BSSID registry,
  attention weighting, spatial correlation, and breathing extraction

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-02-28 23:50:20 -05:00

475 lines
16 KiB
Rust

//! 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<Duration>,
/// 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<f64>,
}
// ---------------------------------------------------------------------------
// 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<Option<Instant>>,
/// Duration of the most recent scan.
last_scan_duration: std::sync::Mutex<Option<Duration>>,
}
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<Vec<BssidObservation>, 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<Vec<BssidObservation>, 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<Vec<BssidObservation>, WifiScanError> {
self.scan_instrumented()
}
fn connected(&self) -> Result<Option<BssidObservation>, 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<T: WlanScanPort>() {}
assert_port::<WlanApiScanner>();
}
#[test]
fn implements_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<WlanApiScanner>();
}
// -- 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());
}
}