- 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>
475 lines
16 KiB
Rust
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());
|
|
}
|
|
}
|