feat(wifiscan,sensing): native wlanapi.dll FFI + real Matter manual code

wifiscan (Tier 2 wlanapi adapter ONLY):
- Real native wlanapi.dll BSS-list FFI (new adapter/wlanapi_native.rs):
  WlanOpenHandle -> WlanEnumInterfaces -> WlanGetNetworkBssList ->
  WlanFreeMemory/WlanCloseHandle via windows-sys 0.59 (already in lock
  tree). Per-BSSID RSSI(dBm)/channel/band/radio-type/SSID + CSI-capable
  filter. #[cfg(windows)] real path; #[cfg(not(windows))] returns typed
  WifiScanError::Unsupported (honest, never fabricated).
- wlanapi_scanner now native-first with documented netsh fallback,
  native_scans metric, scan_native()/scan_native_csi_capable(), and a
  benchmark() that MEASURES real Hz (no hardcoded "10x" claim).
- MEASURED 9.74 Hz native on ruvzen (30 iters, Native backend) vs netsh
  ~2 Hz baseline. Live measurement kept as an #[ignore] test.
- Cargo.toml: unsafe_code forbid->deny so only the audited wlan_ffi
  module opts into unsafe; all unsafe confined + null-checked + freed.

sensing-server (Matter commissioning):
- Replaced the lossy modulo placeholder in matter/commissioning.rs with
  the real Matter Core Spec 1.3 §5.1.4.1.1 field-packing. Canonical
  vector (20202021, 3840) now encodes to the published 34970112332.
- Added ManualPairingCode::decode + DecodedManualCode proving the code
  is real/lossless (passcode round-trips bit-for-bit; short
  discriminator = top 4 bits) with Verhoeff integrity, incl. proptest.

Tests: wifi-densepose-wifiscan 145 passed (real FFI exercised on
Windows); wifi-densepose-sensing-server 614 passed. 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-06-11 21:37:09 -04:00
parent b0ee2a4aaf
commit a0e72eef50
7 changed files with 825 additions and 250 deletions

2
v2/Cargo.lock generated
View File

@ -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]]

View File

@ -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<Self, &'static str> {
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<DecodedManualCode, &'static str> {
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::<u8>().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::<u8>().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);
}
}
}

View File

@ -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};

View File

@ -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"

View File

@ -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 <iface> 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")]

View File

@ -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<Vec<BssidObservation>, 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<Vec<BssidObservation>, 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<Vec<BssidObservation>, 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:?}"),
}
}
}

View File

@ -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<f64>,
/// 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<Option<Instant>>,
/// 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<Vec<BssidObservation>, 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<Vec<BssidObservation>, 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<Vec<BssidObservation>, 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<Vec<BssidObservation>, 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<BenchmarkResult, WifiScanError> {
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<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}"),
})??;
let (results, native) = tokio::task::spawn_blocking(
move || -> Result<(Vec<BssidObservation>, 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<Option<BssidObservation>, 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<T: WlanScanPort>() {}
assert_port::<WlanApiScanner>();
}
@ -422,7 +473,7 @@ mod tests {
assert_send_sync::<WlanApiScanner>();
}
// -- 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);
}
}