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>
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user