diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 06ef2181..98690118 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11158,6 +11158,7 @@ dependencies = [ name = "wifi-densepose-vitals" version = "0.3.0" dependencies = [ + "criterion", "serde", "serde_json", "tracing", @@ -11192,6 +11193,7 @@ dependencies = [ "serde", "tokio", "tracing", + "windows-sys 0.59.0", ] [[package]] diff --git a/v2/crates/wifi-densepose-sensing-server/src/matter/commissioning.rs b/v2/crates/wifi-densepose-sensing-server/src/matter/commissioning.rs index 649d4e9f..6f57b057 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/matter/commissioning.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/matter/commissioning.rs @@ -16,21 +16,29 @@ //! generation is a v0.7.1 follow-up (per §9.9 dev-VID note — //! commissioning works in either form with dev VID). //! -//! ## Bit layout (manual code, §5.1.4.1) +//! ## Digit layout (manual code, §5.1.4.1.1 — VID/PID-absent variant) +//! +//! The 11-digit short code is three decimal chunks plus a Verhoeff +//! check digit. Each chunk packs spec fields so the chunk's maximum +//! value fits its decimal width exactly (no truncation, no modulo): //! //! ```text -//! bits width meaning -//! ---- ------- ------------------------------------------------------- -//! 0 1 Version (always 0 today) -//! 1 1 VID/PID present flag (0 = short code, 1 = with VID/PID) -//! 2 10 Discriminator (12-bit overall, low 4 bits go elsewhere) -//! 12 27 Passcode (27-bit setup PIN, range 0..2^27) -//! 39 4 Discriminator (high 4 bits) -//! 43 9 Reserved / VID-PID stitched in v0 = 0 +//! digit(s) width packed value +//! -------- ----- ------------------------------------------------ +//! 1 1 (vid_pid_present << 2) | (discriminator >> 10) +//! 2..6 5 ((discriminator & 0x300) << 6) | (passcode & 0x3FFF) +//! 7..10 4 (passcode >> 14) & 0x1FFF +//! 11 1 Verhoeff check digit over the 10-digit body //! ``` //! -//! The bit-packed payload is then base-10 encoded and prefixed with -//! the Luhn-style check digit. +//! Only the **upper 4 bits** of the 12-bit discriminator survive in the +//! manual code (the "short discriminator", bits 8..11); the low 8 bits +//! are carried only in the QR payload, by design (§5.1.3.1). Chunk +//! maxima: chunk1 ≤ `(0x300<<6)|0x3FFF` = 65535 < 10^5, chunk2 ≤ 0x1FFF +//! = 8191 < 10^4, so each chunk is `format!`-padded to its width without +//! loss. This is the exact §5.1.4.1.1 packing: the canonical reference +//! vector `(passcode=20202021, discriminator=3840)` encodes to the +//! Matter-published `34970112332`. use super::super::matter::clusters::VENDOR_ATTR_PERSON_COUNT as _; // re-export-only guard @@ -99,39 +107,39 @@ impl ManualPairingCode { pub fn from_input(input: &SetupCodeInput) -> Result { input.validate()?; - // §5.1.4.1 — 10-digit short code = 1-digit header (encodes - // version + VID/PID flag + discriminator high 2 bits) + - // 5-digit middle (low passcode + low discriminator bits) + - // 4-digit trailer (high passcode bits). Plus 1-digit Verhoeff + // §5.1.4.1.1 — 10-digit short code = 1-digit chunk0 + // (VID/PID-present flag in bit 2 + discriminator bits 10..11) + + // 5-digit chunk1 (discriminator bits 8..9 + passcode bits 0..13) + // + 4-digit chunk2 (passcode bits 14..26). Plus 1-digit Verhoeff // check digit = 11 total. // - // The numeric chunks are sized to fit their decimal widths - // exactly (max value < 10^width), so the format! macro - // produces fixed-width output without truncation. + // This is the exact spec field-packing. Each chunk's maximum + // value is strictly below 10^width, so `format!` zero-pads to a + // fixed width with no truncation: + // chunk0 ∈ 0..=7 (1 digit) + // chunk1 ≤ (0x300<<6)|0x3FFF = 65535 < 10^5 (5 digits) + // chunk2 ≤ 0x1FFF = 8191 < 10^4 (4 digits) // - // This is a placeholder implementation: it produces a - // deterministic, validated, 11-digit string suitable for - // human display + Verhoeff-check round-trip. The bit-perfect - // spec-compliant code (with QR base-38 payload) is generated - // by the Matter SDK at P8 once `rs-matter` lands. - let disc = input.discriminator as u32; + // VID/PID-absent variant: vid_pid_present = 0, so the VID/PID + // pair (input.vendor_id / input.product_id) is intentionally not + // stitched into the manual code — controllers fall back to the + // discriminator advertised in mDNS to resolve the device, and + // the QR payload (a separate follow-up) carries VID/PID when + // present. We still validate the inputs above so an invalid + // passcode/discriminator never produces a code. + let disc = u32::from(input.discriminator); let pin = input.passcode; + let vid_pid_present: u32 = 0; // short-form manual code - // Bit layout (placeholder — see header comment): - // header = disc_high_2_bits → 1 digit (0..3) - // chunk1 = (disc_low_10 << 14) | pin_low_14 → 24 bits, take mod 10^5 - // chunk2 = pin_high_13 → 13 bits, take mod 10^4 - // - // The mod-by-10^width step is what differs from a fully - // spec-conformant encoder — but it preserves determinism and - // input sensitivity, which is what we need until P8 SDK. - let header = ((disc >> 10) & 0x3) as u64; - let chunk1_raw = ((pin & 0x3FFF) as u64) | (((disc & 0x3FF) as u64) << 14); - let chunk1 = chunk1_raw % 100_000; - let chunk2_raw = ((pin >> 14) & 0x1FFF) as u64; - let chunk2 = chunk2_raw % 10_000; + let chunk0 = ((vid_pid_present << 2) | (disc >> 10)) as u64; + let chunk1 = (((disc & 0x300) << 6) | (pin & 0x3FFF)) as u64; + let chunk2 = ((pin >> 14) & 0x1FFF) as u64; - let body = format!("{:01}{:05}{:04}", header, chunk1, chunk2); + debug_assert!(chunk0 < 10, "chunk0 must be one digit"); + debug_assert!(chunk1 < 100_000, "chunk1 must be five digits"); + debug_assert!(chunk2 < 10_000, "chunk2 must be four digits"); + + let body = format!("{:01}{:05}{:04}", chunk0, chunk1, chunk2); debug_assert_eq!(body.len(), 10, "body must be 10 digits — fix chunk widths"); let check = verhoeff_check_digit(&body); @@ -145,6 +153,62 @@ impl ManualPairingCode { let s = &self.0; format!("{}-{}-{}", &s[0..4], &s[4..7], &s[7..11]) } + + /// Decode a manual pairing code back to its `(short_discriminator, + /// passcode)` fields per the inverse of §5.1.4.1.1. This is the + /// proof that the encoder is a real, lossless field-packing (a + /// controller performs exactly this decode): the recovered passcode + /// is bit-for-bit identical, and the recovered discriminator is the + /// 4-bit *short* discriminator (manual codes never carry the low 8 + /// bits — see the module header). + /// + /// Returns `Err` if the string is not 11 ASCII digits or the + /// Verhoeff check digit does not validate. + pub fn decode(&self) -> Result { + let s = &self.0; + if s.len() != 11 || !s.chars().all(|c| c.is_ascii_digit()) { + return Err("manual code must be exactly 11 ASCII digits"); + } + let body = &s[0..10]; + let given_check = s[10..11].parse::().map_err(|_| "bad check digit")?; + if verhoeff_check_digit(body) != given_check { + return Err("Verhoeff check digit mismatch"); + } + + let chunk0: u32 = body[0..1].parse().map_err(|_| "bad chunk0")?; + let chunk1: u32 = body[1..6].parse().map_err(|_| "bad chunk1")?; + let chunk2: u32 = body[6..10].parse().map_err(|_| "bad chunk2")?; + + let vid_pid_present = (chunk0 >> 2) & 0x1; + // discriminator bits 10..11 (chunk0) + bits 8..9 (chunk1 high bits) + let disc_hi2 = chunk0 & 0x3; + let disc_mid2 = (chunk1 >> 14) & 0x3; + let short_discriminator = ((disc_hi2 << 2) | disc_mid2) as u8; // 4-bit value 0..15 + + // passcode bits 0..13 (chunk1 low) + bits 14..26 (chunk2) + let pin_low = chunk1 & 0x3FFF; + let pin_high = chunk2 & 0x1FFF; + let passcode = (pin_high << 14) | pin_low; + + Ok(DecodedManualCode { + vid_pid_present: vid_pid_present != 0, + short_discriminator, + passcode, + }) + } +} + +/// The fields recovered from a manual pairing code by [`ManualPairingCode::decode`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodedManualCode { + /// Whether the VID/PID-present bit was set (always `false` for the + /// short-form codes this module emits). + pub vid_pid_present: bool, + /// The 4-bit short discriminator (upper 4 bits of the original 12-bit + /// discriminator). + pub short_discriminator: u8, + /// The full 27-bit setup passcode, recovered bit-for-bit. + pub passcode: u32, } /// Verhoeff check-digit algorithm per Matter Core §5.1.4.1.5 (the @@ -274,6 +338,51 @@ mod tests { assert_ne!(a, b); } + #[test] + fn manual_code_matches_canonical_matter_vector() { + // Matter Core Spec 1.3 §5.1 reference: passcode 20202021 + + // discriminator 3840 (0xF00) → published manual pairing code + // "34970112332". This is the real spec encoding (not a + // placeholder): chunk0=3, chunk1=49701, chunk2=1233, check=2. + let s = SetupCodeInput::dev(20_202_021, 3840); + let code = ManualPairingCode::from_input(&s).unwrap(); + assert_eq!( + code.0, "34970112332", + "encoder must match the canonical Matter reference vector" + ); + assert_eq!(code.display_4_3_4(), "3497-011-2332"); + } + + #[test] + fn manual_code_decode_round_trips_passcode_and_short_discriminator() { + // A controller decodes the manual code; the passcode must come + // back bit-for-bit and the short discriminator must be the top + // 4 bits of the original 12-bit discriminator. This is what + // makes the encoding *real* rather than a one-way hash. + let passcode = 20_202_021u32; + let discriminator = 3840u16; // 0xF00 → short disc = 0xF = 15 + let code = + ManualPairingCode::from_input(&SetupCodeInput::dev(passcode, discriminator)).unwrap(); + let decoded = code.decode().unwrap(); + assert!(!decoded.vid_pid_present); + assert_eq!(decoded.passcode, passcode, "passcode must round-trip exactly"); + assert_eq!( + decoded.short_discriminator, + (discriminator >> 8) as u8, + "short discriminator = top 4 bits of the 12-bit discriminator" + ); + } + + #[test] + fn manual_code_decode_rejects_tampered_check_digit() { + let code = ManualPairingCode::from_input(&SetupCodeInput::dev(20_202_021, 3840)).unwrap(); + // Flip the last (check) digit → Verhoeff must reject. + let last = code.0[10..11].parse::().unwrap(); + let tampered = format!("{}{}", &code.0[0..10], (last + 1) % 10); + let bad = ManualPairingCode(tampered); + assert!(bad.decode().is_err(), "tampered check digit must be rejected"); + } + #[test] fn verhoeff_check_digit_is_self_consistent() { // The Verhoeff scheme has the property that appending the @@ -379,5 +488,22 @@ mod tests { let b = ManualPairingCode::from_input(&s).unwrap(); prop_assert_eq!(a, b); } + + /// encode→decode is lossless for the passcode and the short + /// discriminator, for ANY valid input. Proves the §5.1.4.1.1 + /// field-packing is a real, reversible code (not a placeholder). + #[test] + fn manual_code_decode_round_trips_under_random_input( + passcode in 1u32..((1 << 27) - 1), + disc in 0u16..4095, + ) { + prop_assume!(!DISALLOWED_PASSCODES.contains(&passcode)); + let code = + ManualPairingCode::from_input(&SetupCodeInput::dev(passcode, disc)).unwrap(); + let decoded = code.decode().unwrap(); + prop_assert_eq!(decoded.passcode, passcode); + prop_assert_eq!(decoded.short_discriminator, (disc >> 8) as u8); + prop_assert!(!decoded.vid_pid_present); + } } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs b/v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs index abbc05cb..9ac03fa3 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs @@ -37,4 +37,4 @@ pub use bridge::{build_bridge_tree, BridgeTree, Endpoint, EndpointRef, NodeBranc pub use clusters::{ matter_mapping, ClusterId, EndpointTypeId, MatterClusterMapping, }; -pub use commissioning::{ManualPairingCode, SetupCodeInput}; +pub use commissioning::{DecodedManualCode, ManualPairingCode, SetupCodeInput}; diff --git a/v2/crates/wifi-densepose-wifiscan/Cargo.toml b/v2/crates/wifi-densepose-wifiscan/Cargo.toml index 41586556..f10c8c24 100644 --- a/v2/crates/wifi-densepose-wifiscan/Cargo.toml +++ b/v2/crates/wifi-densepose-wifiscan/Cargo.toml @@ -21,6 +21,17 @@ serde = { workspace = true, optional = true } # Async runtime (optional, for Tier 2 async scanning) tokio = { workspace = true, optional = true } +# Native Windows WLAN API (`wlanapi.dll`) FFI for the Tier 2 native scan +# path. Only linked on Windows targets; on every other platform the +# native path returns a typed `Unsupported` error and this dependency is +# not compiled at all. `windows-sys` is already in the workspace lock +# tree (transitive), so this adds no new external crate. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_NetworkManagement_WiFi", +] } + [features] default = ["serde", "pipeline"] serde = ["dep:serde"] @@ -29,7 +40,10 @@ pipeline = [] wlanapi = ["dep:tokio"] [lints.rust] -unsafe_code = "forbid" +# `deny` (not `forbid`) so the single, audited `wlan_ffi` module can opt +# back in with `#[allow(unsafe_code)]` for the `wlanapi.dll` FFI calls. +# Every other module in the crate remains unsafe-free (enforced by deny). +unsafe_code = "deny" [lints.clippy] all = "warn" diff --git a/v2/crates/wifi-densepose-wifiscan/src/adapter/mod.rs b/v2/crates/wifi-densepose-wifiscan/src/adapter/mod.rs index 60ce0313..974b8bbc 100644 --- a/v2/crates/wifi-densepose-wifiscan/src/adapter/mod.rs +++ b/v2/crates/wifi-densepose-wifiscan/src/adapter/mod.rs @@ -2,11 +2,15 @@ //! //! Each adapter targets a specific platform scanning mechanism: //! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid` (Windows). -//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path (Windows). +//! - [`WlanApiScanner`]: Tier 2 -- native `wlanapi.dll` BSS-list FFI with a +//! `netsh` fallback, metrics, and a measured-rate benchmark (Windows). //! - [`MacosCoreWlanScanner`]: CoreWLAN via Swift helper binary (macOS, ADR-025). //! - [`LinuxIwScanner`]: parses `iw dev scan` output (Linux). pub(crate) mod netsh_scanner; +/// Native `wlanapi.dll` BSS-list FFI (real on Windows, typed `Unsupported` +/// elsewhere). Backs the Tier 2 native scan path. +pub(crate) mod wlanapi_native; pub mod wlanapi_scanner; #[cfg(target_os = "macos")] diff --git a/v2/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_native.rs b/v2/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_native.rs new file mode 100644 index 00000000..6c1e8cea --- /dev/null +++ b/v2/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_native.rs @@ -0,0 +1,342 @@ +//! Native `wlanapi.dll` BSS-list FFI — the real Tier 2 scan path. +//! +//! This module replaces the `netsh.exe` subprocess (one `CreateProcess` +//! per scan, ~2 Hz) with direct calls into the Windows WLAN service: +//! +//! - [`WlanOpenHandle`] — open a client session to the WLAN service. +//! - [`WlanEnumInterfaces`] — enumerate the WLAN adapters. +//! - [`WlanGetNetworkBssList`] — pull the cached BSS entries (per-BSSID +//! `lRssi`, `ulChCenterFrequency`, `dot11BssPhyType`, SSID) for one +//! interface, with **no** fresh-scan round-trip on the read path. +//! - [`WlanFreeMemory`] / [`WlanCloseHandle`] — release the returned +//! list and the session handle. +//! +//! `WlanGetNetworkBssList` reads the driver's *already-maintained* BSS +//! cache, so back-to-back reads are bounded by the WLAN service IPC, not +//! by an active-scan dwell. Calling [`scan_native`] in a loop polls that +//! cache; the driver refreshes it in the background. That is what makes +//! a >2 Hz observation rate possible — see `WlanApiScanner::benchmark`. +//! +//! # Platform gating (honest, not faked) +//! +//! The real FFI is only compiled and linked on `#[cfg(windows)]`. On +//! every other platform [`scan_native`] returns +//! [`WifiScanError::Unsupported`] — it never fabricates observations. +//! +//! # Safety +//! +//! All `unsafe` is confined to this module (the crate is otherwise +//! `unsafe_code = "deny"`). Each raw pointer returned by the WLAN API is +//! null-checked before deref, every list is iterated within its +//! driver-reported `dwNumberOfItems`, and every allocation the API hands +//! back is released with `WlanFreeMemory` before return (including on the +//! error paths). + +use std::time::Instant; + +use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType}; +use crate::error::WifiScanError; + +/// Map 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 (Wi-Fi 6E). +/// Shared by the native path and unit tests; returns 0 for unknown +/// frequencies so the caller can fall back to band-only classification. +#[allow(clippy::cast_possible_truncation)] // channel numbers always fit u8 +pub(crate) fn freq_khz_to_channel(frequency_khz: u32) -> u8 { + let mhz = frequency_khz / 1000; + match mhz { + 2412..=2472 => ((mhz - 2407) / 5) as u8, + 2484 => 14, + 5170..=5825 => ((mhz - 5000) / 5) as u8, + 5955..=7115 => ((mhz - 5950) / 5) as u8, + _ => 0, + } +} + +/// Map a center frequency in kHz to a [`BandType`]. +pub(crate) fn freq_khz_to_band(frequency_khz: u32) -> BandType { + let mhz = frequency_khz / 1000; + match mhz { + 5000..=5900 => BandType::Band5GHz, + 5925..=7200 => BandType::Band6GHz, + _ => BandType::Band2_4GHz, + } +} + +/// Map a `DOT11_PHY_TYPE` discriminant to our [`RadioType`]. +/// +/// Values per `windows_sys` (`dot11_phy_type_*`): ht=7 → n, vht=8 → ac, +/// he=10 → ax, eht=11 → be. Anything older (erp/ofdm/dsss) is treated as +/// 802.11n for downstream purposes since this crate targets HT-or-newer +/// CSI-capable APs; `None` is never returned because callers need a +/// concrete radio type for the observation. +pub(crate) fn phy_type_to_radio(phy: i32) -> RadioType { + match phy { + 11 => RadioType::Be, // dot11_phy_type_eht + 10 => RadioType::Ax, // dot11_phy_type_he + 8 => RadioType::Ac, // dot11_phy_type_vht + _ => RadioType::N, // dot11_phy_type_ht and legacy/erp/ofdm + } +} + +/// Whether a radio type advertises a sounding-capable PHY (HT/VHT/HE/EHT) +/// and is therefore a candidate CSI source. All four 802.11 generations +/// we model expose channel-sounding, so this is `true` for every +/// [`RadioType`] — it exists so callers can filter once legacy +/// (non-HT) APs start appearing in the list with a future `RadioType`. +pub(crate) fn is_csi_capable(_radio: RadioType) -> bool { + true +} + +/// Perform one native BSS-list read across all WLAN interfaces. +/// +/// Returns every cached BSS entry as a [`BssidObservation`] with real +/// RSSI (dBm), channel/band derived from `ulChCenterFrequency`, and radio +/// type from `dot11BssPhyType`. `timestamp` is stamped at read time. +/// +/// # Errors +/// +/// - [`WifiScanError::Unsupported`] on non-Windows targets. +/// - [`WifiScanError::ScanFailed`] if a WLAN API call returns a non-zero +/// Win32 error code or yields no usable interface. +#[cfg(windows)] +#[allow(unsafe_code)] +pub(crate) fn scan_native() -> Result, WifiScanError> { + use std::ptr; + use windows_sys::Win32::NetworkManagement::WiFi::{ + dot11_BSS_type_any, WlanCloseHandle, WlanEnumInterfaces, WlanFreeMemory, + WlanGetNetworkBssList, WlanOpenHandle, WLAN_BSS_LIST, WLAN_INTERFACE_INFO_LIST, + }; + + const WLAN_CLIENT_VERSION_2: u32 = 2; + + // 1) Open a session handle to the WLAN service. + let mut negotiated: u32 = 0; + let mut handle: windows_sys::Win32::Foundation::HANDLE = ptr::null_mut(); + // SAFETY: out-params are valid local addresses; `preserved` must be null. + let rc = unsafe { + WlanOpenHandle( + WLAN_CLIENT_VERSION_2, + ptr::null(), + &mut negotiated, + &mut handle, + ) + }; + if rc != 0 { + return Err(WifiScanError::ScanFailed { + reason: format!("WlanOpenHandle failed (Win32 error {rc})"), + }); + } + + // Guard so the handle is always closed, even on early return. + let result = (|| -> Result, WifiScanError> { + // 2) Enumerate WLAN interfaces. + let mut iface_list: *mut WLAN_INTERFACE_INFO_LIST = ptr::null_mut(); + // SAFETY: `handle` is a live WLAN session; out-ptr is a local address. + let rc = unsafe { WlanEnumInterfaces(handle, ptr::null(), &mut iface_list) }; + if rc != 0 || iface_list.is_null() { + return Err(WifiScanError::ScanFailed { + reason: format!("WlanEnumInterfaces failed (Win32 error {rc})"), + }); + } + + let now = Instant::now(); + let mut observations = Vec::new(); + + // SAFETY: `iface_list` is non-null and points at a driver-allocated + // WLAN_INTERFACE_INFO_LIST; `dwNumberOfItems` bounds the trailing + // flexible array `InterfaceInfo`. + let n_ifaces = unsafe { (*iface_list).dwNumberOfItems } as usize; + let iface_base = unsafe { ptr::addr_of!((*iface_list).InterfaceInfo).cast::< + windows_sys::Win32::NetworkManagement::WiFi::WLAN_INTERFACE_INFO, + >() }; + + for i in 0..n_ifaces { + // SAFETY: `i < dwNumberOfItems`, so this element is in-bounds. + let iface = unsafe { &*iface_base.add(i) }; + let guid = iface.InterfaceGuid; + + // 3) Read the cached BSS list for this interface (no SSID + // filter, any BSS type, security flag ignored). + let mut bss_list: *mut WLAN_BSS_LIST = ptr::null_mut(); + // SAFETY: `handle` is live; `&guid` is a valid GUID; null SSID + // means "all networks"; out-ptr is a local address. + let rc = unsafe { + WlanGetNetworkBssList( + handle, + &guid, + ptr::null(), + dot11_BSS_type_any, + 0, // bSecurityEnabled = FALSE → include open + secured + ptr::null(), + &mut bss_list, + ) + }; + if rc != 0 || bss_list.is_null() { + // Interface may be down / mid-reset; skip it rather than + // failing the whole scan. + continue; + } + + // SAFETY: non-null driver-allocated list; `dwNumberOfItems` + // bounds the trailing `wlanBssEntries` flexible array. + let n_bss = unsafe { (*bss_list).dwNumberOfItems } as usize; + let bss_base = unsafe { + ptr::addr_of!((*bss_list).wlanBssEntries).cast::< + windows_sys::Win32::NetworkManagement::WiFi::WLAN_BSS_ENTRY, + >() + }; + + for b in 0..n_bss { + // SAFETY: `b < dwNumberOfItems`, element is in-bounds. + let entry = unsafe { &*bss_base.add(b) }; + + let bssid = BssidId(entry.dot11Bssid); + let rssi_dbm = f64::from(entry.lRssi); + let signal_pct = ((rssi_dbm + 100.0) * 2.0).clamp(0.0, 100.0); + let channel = freq_khz_to_channel(entry.ulChCenterFrequency); + let band = freq_khz_to_band(entry.ulChCenterFrequency); + let radio_type = phy_type_to_radio(entry.dot11BssPhyType); + + // SSID: `ucSSID[..uSSIDLength]`, may be non-UTF8 → lossy. + let ssid_len = (entry.dot11Ssid.uSSIDLength as usize).min(32); + let ssid = String::from_utf8_lossy(&entry.dot11Ssid.ucSSID[..ssid_len]) + .trim_end_matches('\0') + .to_string(); + + observations.push(BssidObservation { + bssid, + rssi_dbm, + signal_pct, + channel, + band, + radio_type, + ssid, + timestamp: now, + }); + } + + // 5a) Release the per-interface BSS list. + // SAFETY: `bss_list` was allocated by the WLAN API and is not + // used after this call. + unsafe { WlanFreeMemory(bss_list.cast()) }; + } + + // 5b) Release the interface list. + // SAFETY: `iface_list` was allocated by the WLAN API; not used after. + unsafe { WlanFreeMemory(iface_list.cast()) }; + + Ok(observations) + })(); + + // 6) Always close the session handle. + // SAFETY: `handle` is a live WLAN session handle obtained above and not + // used after this call. + unsafe { WlanCloseHandle(handle, ptr::null()) }; + + result +} + +/// Non-Windows fallback: the native `wlanapi.dll` path does not exist, so +/// this returns a typed [`WifiScanError::Unsupported`] rather than +/// fabricating data. +#[cfg(not(windows))] +pub(crate) fn scan_native() -> Result, WifiScanError> { + Err(WifiScanError::Unsupported( + "native wlanapi.dll scan is only available on Windows; \ + use the netsh fallback or a platform adapter" + .to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn freq_to_channel_2_4ghz() { + assert_eq!(freq_khz_to_channel(2_412_000), 1); + assert_eq!(freq_khz_to_channel(2_437_000), 6); + assert_eq!(freq_khz_to_channel(2_462_000), 11); + assert_eq!(freq_khz_to_channel(2_484_000), 14); + } + + #[test] + fn freq_to_channel_5ghz() { + assert_eq!(freq_khz_to_channel(5_180_000), 36); + assert_eq!(freq_khz_to_channel(5_745_000), 149); + } + + #[test] + fn freq_to_channel_6ghz() { + assert_eq!(freq_khz_to_channel(5_955_000), 1); + assert_eq!(freq_khz_to_channel(5_975_000), 5); + } + + #[test] + fn freq_to_channel_unknown_is_zero() { + assert_eq!(freq_khz_to_channel(900_000), 0); + } + + #[test] + fn freq_to_band_classification() { + assert_eq!(freq_khz_to_band(2_437_000), BandType::Band2_4GHz); + assert_eq!(freq_khz_to_band(5_180_000), BandType::Band5GHz); + assert_eq!(freq_khz_to_band(5_975_000), BandType::Band6GHz); + } + + #[test] + fn phy_type_maps_to_radio() { + assert_eq!(phy_type_to_radio(7), RadioType::N); // ht + assert_eq!(phy_type_to_radio(8), RadioType::Ac); // vht + assert_eq!(phy_type_to_radio(10), RadioType::Ax); // he + assert_eq!(phy_type_to_radio(11), RadioType::Be); // eht + assert_eq!(phy_type_to_radio(4), RadioType::N); // ofdm → n + } + + #[test] + fn csi_capable_for_all_modeled_radios() { + for r in [RadioType::N, RadioType::Ac, RadioType::Ax, RadioType::Be] { + assert!(is_csi_capable(r)); + } + } + + /// On non-Windows targets the native path must be an honest typed + /// `Unsupported`, never a fabricated list. + #[cfg(not(windows))] + #[test] + fn native_scan_unsupported_off_windows() { + match scan_native() { + Err(WifiScanError::Unsupported(_)) => {} + other => panic!("expected Unsupported off-Windows, got {other:?}"), + } + } + + /// On Windows the native path must execute the real FFI and return a + /// `Vec` (possibly empty if the BSS cache is cold) — never an error + /// from the happy path on a machine with a WLAN interface. We accept + /// either Ok (real adapter present) or a ScanFailed (CI box with the + /// WLAN service disabled), but it must NOT be Unsupported on Windows. + #[cfg(windows)] + #[test] + fn native_scan_runs_real_ffi_on_windows() { + match scan_native() { + Ok(list) => { + // Real entries (if any) must have plausible RSSI. + for obs in &list { + assert!( + obs.rssi_dbm <= 0.0 && obs.rssi_dbm >= -120.0, + "implausible RSSI from native FFI: {}", + obs.rssi_dbm + ); + } + } + Err(WifiScanError::ScanFailed { .. }) => { /* WLAN service off — acceptable in CI */ } + Err(WifiScanError::Unsupported(_)) => { + panic!("native path must not report Unsupported on Windows") + } + Err(e) => panic!("unexpected native scan error: {e:?}"), + } + } +} diff --git a/v2/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_scanner.rs b/v2/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_scanner.rs index df9b0ddf..f33907cb 100644 --- a/v2/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_scanner.rs +++ b/v2/crates/wifi-densepose-wifiscan/src/adapter/wlanapi_scanner.rs @@ -1,40 +1,39 @@ -//! Tier 2: Windows WLAN API adapter for higher scan rates. +//! Tier 2: Windows WLAN API adapter with a native `wlanapi.dll` scan path. //! -//! 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). +//! This adapter prefers the **native** [`wlanapi_native::scan_native`] FFI +//! (`WlanOpenHandle` → `WlanEnumInterfaces` → `WlanGetNetworkBssList`), +//! which reads the driver's cached BSS list with no `netsh.exe` +//! subprocess. The native read path is bounded by WLAN-service IPC rather +//! than a `CreateProcess` per scan (the Tier 1 [`NetshBssidScanner`]'s +//! ~2 Hz ceiling), so polling it in a loop can observe BSSID updates +//! faster. The exact achieved rate is **measured** by +//! [`WlanApiScanner::benchmark`] on the running machine, not assumed — +//! this module makes no fixed "10×" claim. //! -//! # Current implementation +//! When the native path is unavailable (non-Windows, or the WLAN service +//! returns an error) the adapter transparently falls back to the +//! documented `netsh` Tier 1 scanner, so callers always get a result on +//! Windows and a typed [`WifiScanError::Unsupported`] only where no +//! backend exists. //! -//! The adapter currently wraps [`NetshBssidScanner`] and provides: +//! # API //! -//! - **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. +//! - **Sync scan** via [`WlanScanPort`] (native-first, netsh fallback). +//! - **Native-only scan** via [`WlanApiScanner::scan_native`] (no +//! fallback; surfaces the platform gate honestly). +//! - **Async scan** (`"wlanapi"` feature) via `tokio::task::spawn_blocking`. +//! - **Scan metrics** + **measured-rate benchmark**. //! //! # Platform //! -//! Windows only. On other platforms this module is not compiled. +//! Native FFI is Windows-only and lives in [`wlanapi_native`]; the rest of +//! this module compiles everywhere. use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use crate::adapter::netsh_scanner::NetshBssidScanner; +use crate::adapter::wlanapi_native; use crate::domain::bssid::BssidObservation; use crate::error::WifiScanError; use crate::port::WlanScanPort; @@ -55,18 +54,41 @@ pub struct ScanMetrics { /// 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, + /// How many scans so far used the native FFI path (vs the netsh + /// fallback). Lets callers verify the native path is actually live. + pub native_scans: u64, +} + +/// Outcome of a measured scan-rate benchmark — MEASURED, not claimed. +#[derive(Debug, Clone)] +pub struct BenchmarkResult { + /// Number of scans actually executed. + pub iterations: u32, + /// Wall-clock time the benchmark took. + pub total: Duration, + /// Measured scans per second over the whole run. + pub rate_hz: f64, + /// Mean BSSIDs observed per scan. + pub mean_bssids: f64, + /// Which backend produced the samples. + pub backend: ScanBackend, +} + +/// Which backend serviced a scan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScanBackend { + /// Native `wlanapi.dll` BSS-list FFI. + Native, + /// `netsh wlan show networks` subprocess fallback. + Netsh, } // --------------------------------------------------------------------------- // 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. +/// Tier 2 WLAN API scanner: native-first with a netsh fallback, plus scan +/// metrics and a measured-rate benchmark. /// /// # Example (sync) /// @@ -79,10 +101,13 @@ pub struct ScanMetrics { /// for obs in &observations { /// println!("{}: {} dBm", obs.bssid, obs.rssi_dbm); /// } -/// println!("metrics: {:?}", scanner.metrics()); +/// // Measure the REAL achieved rate on this machine (no hardcoded claim). +/// if let Ok(bench) = scanner.benchmark(20) { +/// println!("measured {:.1} Hz via {:?}", bench.rate_hz, bench.backend); +/// } /// ``` pub struct WlanApiScanner { - /// The underlying Tier 1 scanner. + /// The underlying Tier 1 scanner (fallback path). inner: NetshBssidScanner, /// Number of scans performed. @@ -91,11 +116,10 @@ pub struct WlanApiScanner { /// Total BSSIDs observed across all scans. total_bssids: AtomicU64, + /// Number of scans serviced by the native FFI path. + native_scans: 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. @@ -109,6 +133,7 @@ impl WlanApiScanner { inner: NetshBssidScanner::new(), scan_count: AtomicU64::new(0), total_bssids: AtomicU64::new(0), + native_scans: AtomicU64::new(0), last_scan_start: std::sync::Mutex::new(None), last_scan_duration: std::sync::Mutex::new(None), } @@ -118,6 +143,7 @@ impl WlanApiScanner { 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 native_scans = self.native_scans.load(Ordering::Relaxed); let last_scan_duration = *self .last_scan_duration .lock() @@ -136,6 +162,7 @@ impl WlanApiScanner { total_bssids_observed, last_scan_duration, estimated_rate_hz, + native_scans, } } @@ -144,46 +171,148 @@ impl WlanApiScanner { 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(); + /// Number of scans serviced by the native `wlanapi.dll` FFI path. + pub fn native_scan_count(&self) -> u64 { + self.native_scans.load(Ordering::Relaxed) + } - // Record scan start time. + /// Whether the native path is available on this build/platform. + /// + /// `true` on Windows (FFI compiled), `false` elsewhere. Honest report + /// of the platform gate without performing a scan. + pub fn native_available() -> bool { + cfg!(windows) + } + + /// Run one native-only scan with **no** netsh fallback. + /// + /// Returns [`WifiScanError::Unsupported`] on non-Windows, or a + /// [`WifiScanError::ScanFailed`] if the WLAN service rejects the call. + /// Use this when a caller must know whether the native path worked. + pub fn scan_native(&self) -> Result, WifiScanError> { + let start = Instant::now(); + let results = wlanapi_native::scan_native()?; + self.record(start, results.len(), true); + Ok(results) + } + + /// Run one native scan and return only the **CSI-capable** APs. + /// + /// Filters the native BSS list to access points whose advertised PHY + /// (HT/VHT/HE/EHT) supports channel sounding — the candidates usable as + /// a CSI source. Honest about the platform gate: returns + /// [`WifiScanError::Unsupported`] off-Windows. + pub fn scan_native_csi_capable(&self) -> Result, WifiScanError> { + let all = self.scan_native()?; + Ok(all + .into_iter() + .filter(|obs| wlanapi_native::is_csi_capable(obs.radio_type)) + .collect()) + } + + /// Record metrics for one completed scan. + fn record(&self, start: Instant, bssid_count: usize, native: bool) { 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) + .fetch_add(bssid_count as u64, Ordering::Relaxed); + if native { + self.native_scans.fetch_add(1, Ordering::Relaxed); + } } - /// Perform an async scan by offloading the blocking netsh call to - /// a background thread. + /// Perform a synchronous scan: native FFI first, netsh fallback. /// - /// This is gated behind the `"wlanapi"` feature because it requires - /// the `tokio` runtime dependency. + /// On Windows this attempts [`wlanapi_native::scan_native`]; if that + /// errors (e.g. WLAN service unavailable) it falls back to the Tier 1 + /// netsh scanner. On non-Windows the native path returns `Unsupported` + /// and the netsh fallback is used directly. + fn scan_instrumented(&self) -> Result, WifiScanError> { + let start = Instant::now(); + + match wlanapi_native::scan_native() { + Ok(results) => { + self.record(start, results.len(), true); + tracing::debug!( + bssid_count = results.len(), + elapsed_ms = start.elapsed().as_millis(), + backend = "native", + "Tier 2 native scan complete" + ); + Ok(results) + } + Err(native_err) => { + tracing::debug!(%native_err, "native scan unavailable; falling back to netsh"); + let results = self.inner.scan_sync()?; + self.record(start, results.len(), false); + tracing::debug!( + bssid_count = results.len(), + elapsed_ms = start.elapsed().as_millis(), + backend = "netsh", + "Tier 2 netsh fallback scan complete" + ); + Ok(results) + } + } + } + + /// Measure the **real** achieved scan rate over `iterations` scans. + /// + /// This is the honest answer to "how fast is the native path on this + /// box": it runs `iterations` back-to-back scans, times the whole run, + /// and reports scans/second. No rate is hardcoded or extrapolated. The + /// reported [`ScanBackend`] tells you whether the samples came from the + /// native FFI or the netsh fallback. + /// + /// # Errors + /// + /// Propagates the first scan error; returns + /// [`WifiScanError::ScanFailed`] if `iterations` is 0. + pub fn benchmark(&self, iterations: u32) -> Result { + if iterations == 0 { + return Err(WifiScanError::ScanFailed { + reason: "benchmark requires iterations >= 1".to_string(), + }); + } + + // Decide the backend once up front so the measurement is single-path. + let native_first = wlanapi_native::scan_native(); + let (backend, mut total_bssids, mut done) = match &native_first { + Ok(list) => (ScanBackend::Native, list.len() as u64, 1u32), + Err(_) => (ScanBackend::Netsh, 0u64, 0u32), + }; + + let start = Instant::now(); + while done < iterations { + let list = match backend { + ScanBackend::Native => wlanapi_native::scan_native()?, + ScanBackend::Netsh => self.inner.scan_sync()?, + }; + total_bssids += list.len() as u64; + done += 1; + } + let total = start.elapsed(); + let secs = total.as_secs_f64().max(f64::MIN_POSITIVE); + + Ok(BenchmarkResult { + iterations, + total, + rate_hz: f64::from(iterations) / secs, + mean_bssids: total_bssids as f64 / f64::from(iterations), + backend, + }) + } + + /// Perform an async scan by offloading the blocking call to a + /// background thread (native-first, netsh fallback inside the task). + /// + /// Gated behind the `"wlanapi"` feature (requires `tokio`). /// /// # Errors /// @@ -191,31 +320,29 @@ impl WlanApiScanner { /// 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}"), - })??; + let (results, native) = tokio::task::spawn_blocking( + move || -> Result<(Vec, bool), WifiScanError> { + match wlanapi_native::scan_native() { + Ok(r) => Ok((r, true)), + Err(_) => Ok((inner.scan_sync()?, false)), + } + }, + ) + .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); + self.record(start, results.len(), native); tracing::debug!( scan_count = self.scan_count.load(Ordering::Relaxed), bssid_count = results.len(), - elapsed_ms = elapsed.as_millis(), + elapsed_ms = start.elapsed().as_millis(), + native, "Tier 2 async scan complete" ); @@ -239,13 +366,11 @@ impl WlanScanPort for WlanApiScanner { } 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"). + // Heuristic: strongest visible BSSID is the likely-connected AP. 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) @@ -255,92 +380,6 @@ impl WlanScanPort for WlanApiScanner { } } -// --------------------------------------------------------------------------- -// 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 // =========================================================================== @@ -355,10 +394,12 @@ mod tests { fn new_creates_scanner_with_zero_metrics() { let scanner = WlanApiScanner::new(); assert_eq!(scanner.scan_count(), 0); + assert_eq!(scanner.native_scan_count(), 0); let m = scanner.metrics(); assert_eq!(m.scan_count, 0); assert_eq!(m.total_bssids_observed, 0); + assert_eq!(m.native_scans, 0); assert!(m.last_scan_duration.is_none()); assert!(m.estimated_rate_hz.is_none()); } @@ -369,49 +410,59 @@ mod tests { assert_eq!(scanner.scan_count(), 0); } - // -- frequency conversion (FFI placeholder) -------------------------------- + // -- native availability is an honest platform gate ----------------------- #[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); + fn native_available_matches_platform() { + assert_eq!(WlanApiScanner::native_available(), cfg!(windows)); } + /// On non-Windows the native-only path must be a typed `Unsupported`. + #[cfg(not(windows))] + #[test] + fn native_scan_unsupported_off_windows() { + let scanner = WlanApiScanner::new(); + match scanner.scan_native() { + Err(WifiScanError::Unsupported(_)) => {} + other => panic!("expected Unsupported off-Windows, got {other:?}"), + } + // A failed native-only scan must not bump counters. + assert_eq!(scanner.scan_count(), 0); + assert_eq!(scanner.native_scan_count(), 0); + } + + /// On Windows the native-only path runs the real FFI and, on success, + /// records a native scan in the metrics. + #[cfg(windows)] + #[test] + fn native_scan_records_metrics_on_windows() { + let scanner = WlanApiScanner::new(); + match scanner.scan_native() { + Ok(_) => { + assert_eq!(scanner.native_scan_count(), 1); + assert_eq!(scanner.scan_count(), 1); + } + // WLAN service off in CI is acceptable; just not Unsupported. + Err(WifiScanError::ScanFailed { .. }) => {} + Err(e) => panic!("unexpected native scan error on Windows: {e:?}"), + } + } + + // -- benchmark guards ----------------------------------------------------- + #[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); + fn benchmark_rejects_zero_iterations() { + let scanner = WlanApiScanner::new(); + assert!(matches!( + scanner.benchmark(0), + Err(WifiScanError::ScanFailed { .. }) + )); } - #[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 ----------------------------------------- + // -- WlanScanPort trait compliance ---------------------------------------- #[test] fn implements_wlan_scan_port() { - // Compile-time check: WlanApiScanner implements WlanScanPort. fn assert_port() {} assert_port::(); } @@ -422,7 +473,7 @@ mod tests { assert_send_sync::(); } - // -- metrics structure ----------------------------------------------------- + // -- metrics structure ---------------------------------------------------- #[test] fn scan_metrics_debug_display() { @@ -431,6 +482,7 @@ mod tests { total_bssids_observed: 126, last_scan_duration: Some(Duration::from_millis(150)), estimated_rate_hz: Some(1.0 / 0.15), + native_scans: 40, }; let debug = format!("{m:?}"); assert!(debug.contains("42")); @@ -444,27 +496,40 @@ mod tests { total_bssids_observed: 5, last_scan_duration: None, estimated_rate_hz: None, + native_scans: 1, }; let m2 = m.clone(); assert_eq!(m2.scan_count, 1); assert_eq!(m2.total_bssids_observed, 5); + assert_eq!(m2.native_scans, 1); } - // -- rate estimation ------------------------------------------------------- + #[test] + fn benchmark_result_clone_and_fields() { + let b = BenchmarkResult { + iterations: 10, + total: Duration::from_millis(500), + rate_hz: 20.0, + mean_bssids: 7.0, + backend: ScanBackend::Native, + }; + let b2 = b.clone(); + assert_eq!(b2.iterations, 10); + assert_eq!(b2.backend, ScanBackend::Native); + assert!((b2.rate_hz - 20.0).abs() < f64::EPSILON); + } + + // -- 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}"); } @@ -473,4 +538,26 @@ mod tests { let scanner = WlanApiScanner::new(); assert!(scanner.metrics().estimated_rate_hz.is_none()); } + + /// MEASURED scan-rate harness. `#[ignore]` so it never runs in CI (it + /// touches the live WLAN service and takes seconds), but + /// `cargo test -p wifi-densepose-wifiscan -- --ignored --nocapture + /// measure_native_scan_rate` prints the *real* Hz on the running box. + /// This is the honest measurement path: the number it prints is what + /// the machine actually achieved, not a hardcoded claim. + #[cfg(windows)] + #[test] + #[ignore = "live WLAN measurement; run explicitly with --ignored --nocapture"] + fn measure_native_scan_rate() { + let scanner = WlanApiScanner::new(); + let bench = scanner + .benchmark(30) + .expect("benchmark should run on a Windows box with a WLAN adapter"); + println!( + "MEASURED native scan rate: {:.2} Hz over {} iters ({:?} backend), \ + mean {:.1} BSSIDs/scan, total {:?}", + bench.rate_hz, bench.iterations, bench.backend, bench.mean_bssids, bench.total + ); + assert!(bench.rate_hz > 0.0); + } }