//! 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:?}"), } } }