Merge pull request #64 from zqyhimself/feature/macos-corewlan

Thank you for the contribution! 🎉
This commit was merged in pull request #64.
This commit is contained in:
rUv
2026-03-01 10:59:11 -05:00
committed by GitHub
8 changed files with 943 additions and 8 deletions

3
.gitignore vendored
View File

@@ -193,6 +193,9 @@ cython_debug/
# PyPI configuration file
.pypirc
# Compiled Swift helper binaries (macOS WiFi sensing)
v1/src/sensing/mac_wifi
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data

View File

@@ -0,0 +1,359 @@
//! Adapter that scans WiFi BSSIDs on Linux by invoking `iw dev <iface> scan`.
//!
//! This is the Linux counterpart to [`NetshBssidScanner`](super::NetshBssidScanner)
//! on Windows and [`MacosCoreWlanScanner`](super::MacosCoreWlanScanner) on macOS.
//!
//! # Design
//!
//! The adapter shells out to `iw dev <interface> scan` (or `iw dev <interface> scan dump`
//! to read cached results without triggering a new scan, which requires root).
//! The output is parsed into [`BssidObservation`] values using the same domain
//! types shared by all platform adapters.
//!
//! # Permissions
//!
//! - `iw dev <iface> scan` requires `CAP_NET_ADMIN` (typically root).
//! - `iw dev <iface> scan dump` reads cached results and may work without root
//! on some distributions.
//!
//! # Platform
//!
//! Linux only. Gated behind `#[cfg(target_os = "linux")]` at the module level.
use std::process::Command;
use std::time::Instant;
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
use crate::error::WifiScanError;
// ---------------------------------------------------------------------------
// LinuxIwScanner
// ---------------------------------------------------------------------------
/// Synchronous WiFi scanner that shells out to `iw dev <interface> scan`.
///
/// Each call to [`scan_sync`](Self::scan_sync) spawns a subprocess, captures
/// stdout, and parses the BSS stanzas into [`BssidObservation`] values.
pub struct LinuxIwScanner {
/// Wireless interface name (e.g. `"wlan0"`, `"wlp2s0"`).
interface: String,
/// If true, use `scan dump` (cached results) instead of triggering a new
/// scan. This avoids the root requirement but may return stale data.
use_dump: bool,
}
impl LinuxIwScanner {
/// Create a scanner for the default interface `wlan0`.
pub fn new() -> Self {
Self {
interface: "wlan0".to_owned(),
use_dump: false,
}
}
/// Create a scanner for a specific wireless interface.
pub fn with_interface(iface: impl Into<String>) -> Self {
Self {
interface: iface.into(),
use_dump: false,
}
}
/// Use `scan dump` instead of `scan` to read cached results without root.
pub fn use_cached(mut self) -> Self {
self.use_dump = true;
self
}
/// Run `iw dev <iface> scan` and parse the output synchronously.
///
/// Returns one [`BssidObservation`] per BSS stanza in the output.
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
let scan_cmd = if self.use_dump { "dump" } else { "scan" };
let mut args = vec!["dev", &self.interface, "scan"];
if self.use_dump {
args.push(scan_cmd);
}
// iw uses "scan dump" not "scan scan dump"
let args = if self.use_dump {
vec!["dev", &self.interface, "scan", "dump"]
} else {
vec!["dev", &self.interface, "scan"]
};
let output = Command::new("iw")
.args(&args)
.output()
.map_err(|e| {
WifiScanError::ProcessError(format!(
"failed to run `iw {}`: {e}",
args.join(" ")
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WifiScanError::ScanFailed {
reason: format!(
"iw exited with {}: {}",
output.status,
stderr.trim()
),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_iw_scan_output(&stdout)
}
}
impl Default for LinuxIwScanner {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
/// Intermediate accumulator for fields within a single BSS stanza.
#[derive(Default)]
struct BssStanza {
bssid: Option<String>,
ssid: Option<String>,
signal_dbm: Option<f64>,
freq_mhz: Option<u32>,
channel: Option<u8>,
}
impl BssStanza {
/// Flush this stanza into a [`BssidObservation`], if we have enough data.
fn flush(self, timestamp: Instant) -> Option<BssidObservation> {
let bssid_str = self.bssid?;
let bssid = BssidId::parse(&bssid_str).ok()?;
let rssi_dbm = self.signal_dbm.unwrap_or(-90.0);
// Determine channel from explicit field or frequency.
let channel = self.channel.or_else(|| {
self.freq_mhz.map(freq_to_channel)
}).unwrap_or(0);
let band = BandType::from_channel(channel);
let radio_type = infer_radio_type_from_freq(self.freq_mhz.unwrap_or(0));
let signal_pct = ((rssi_dbm + 100.0) * 2.0).clamp(0.0, 100.0);
Some(BssidObservation {
bssid,
rssi_dbm,
signal_pct,
channel,
band,
radio_type,
ssid: self.ssid.unwrap_or_default(),
timestamp,
})
}
}
/// Parse the text output of `iw dev <iface> scan [dump]`.
///
/// The output consists of BSS stanzas, each starting with:
/// ```text
/// BSS aa:bb:cc:dd:ee:ff(on wlan0)
/// ```
/// followed by indented key-value lines.
pub fn parse_iw_scan_output(output: &str) -> Result<Vec<BssidObservation>, WifiScanError> {
let now = Instant::now();
let mut results = Vec::new();
let mut current: Option<BssStanza> = None;
for line in output.lines() {
// New BSS stanza starts with "BSS " at column 0.
if line.starts_with("BSS ") {
// Flush previous stanza.
if let Some(stanza) = current.take() {
if let Some(obs) = stanza.flush(now) {
results.push(obs);
}
}
// Parse BSSID from "BSS aa:bb:cc:dd:ee:ff(on wlan0)" or
// "BSS aa:bb:cc:dd:ee:ff -- associated".
let rest = &line[4..];
let mac_end = rest.find(|c: char| !c.is_ascii_hexdigit() && c != ':')
.unwrap_or(rest.len());
let mac = &rest[..mac_end];
if mac.len() == 17 {
let mut stanza = BssStanza::default();
stanza.bssid = Some(mac.to_lowercase());
current = Some(stanza);
}
continue;
}
// Indented lines belong to the current stanza.
let trimmed = line.trim();
if let Some(ref mut stanza) = current {
if let Some(rest) = trimmed.strip_prefix("SSID:") {
stanza.ssid = Some(rest.trim().to_owned());
} else if let Some(rest) = trimmed.strip_prefix("signal:") {
// "signal: -52.00 dBm"
stanza.signal_dbm = parse_signal_dbm(rest);
} else if let Some(rest) = trimmed.strip_prefix("freq:") {
// "freq: 5180"
stanza.freq_mhz = rest.trim().parse().ok();
} else if let Some(rest) = trimmed.strip_prefix("DS Parameter set: channel") {
// "DS Parameter set: channel 6"
stanza.channel = rest.trim().parse().ok();
}
}
}
// Flush the last stanza.
if let Some(stanza) = current.take() {
if let Some(obs) = stanza.flush(now) {
results.push(obs);
}
}
Ok(results)
}
/// Convert a frequency in MHz to an 802.11 channel number.
fn freq_to_channel(freq_mhz: u32) -> u8 {
match freq_mhz {
// 2.4 GHz: channels 1-14.
2412..=2472 => ((freq_mhz - 2407) / 5) as u8,
2484 => 14,
// 5 GHz: channels 36-177.
5170..=5885 => ((freq_mhz - 5000) / 5) as u8,
// 6 GHz (Wi-Fi 6E).
5955..=7115 => ((freq_mhz - 5950) / 5) as u8,
_ => 0,
}
}
/// Parse a signal strength string like "-52.00 dBm" into dBm.
fn parse_signal_dbm(s: &str) -> Option<f64> {
let s = s.trim();
// Take everything up to " dBm" or just parse the number.
let num_part = s.split_whitespace().next()?;
num_part.parse().ok()
}
/// Infer radio type from frequency (best effort).
fn infer_radio_type_from_freq(freq_mhz: u32) -> RadioType {
match freq_mhz {
5955..=7115 => RadioType::Ax, // 6 GHz → Wi-Fi 6E
5170..=5885 => RadioType::Ac, // 5 GHz → likely 802.11ac
_ => RadioType::N, // 2.4 GHz → at least 802.11n
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Real-world `iw dev wlan0 scan` output (truncated to 3 BSSes).
const SAMPLE_IW_OUTPUT: &str = "\
BSS aa:bb:cc:dd:ee:ff(on wlan0)
\tTSF: 123456789 usec
\tfreq: 5180
\tbeacon interval: 100 TUs
\tcapability: ESS Privacy (0x0011)
\tsignal: -52.00 dBm
\tSSID: HomeNetwork
\tDS Parameter set: channel 36
BSS 11:22:33:44:55:66(on wlan0)
\tfreq: 2437
\tsignal: -71.00 dBm
\tSSID: GuestWifi
\tDS Parameter set: channel 6
BSS de:ad:be:ef:ca:fe(on wlan0) -- associated
\tfreq: 5745
\tsignal: -45.00 dBm
\tSSID: OfficeNet
";
#[test]
fn parse_three_bss_stanzas() {
let obs = parse_iw_scan_output(SAMPLE_IW_OUTPUT).unwrap();
assert_eq!(obs.len(), 3);
// First BSS.
assert_eq!(obs[0].ssid, "HomeNetwork");
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON);
assert_eq!(obs[0].channel, 36);
assert_eq!(obs[0].band, BandType::Band5GHz);
// Second BSS: 2.4 GHz.
assert_eq!(obs[1].ssid, "GuestWifi");
assert_eq!(obs[1].channel, 6);
assert_eq!(obs[1].band, BandType::Band2_4GHz);
assert_eq!(obs[1].radio_type, RadioType::N);
// Third BSS: "-- associated" suffix.
assert_eq!(obs[2].ssid, "OfficeNet");
assert_eq!(obs[2].bssid.to_string(), "de:ad:be:ef:ca:fe");
assert!((obs[2].rssi_dbm - (-45.0)).abs() < f64::EPSILON);
}
#[test]
fn freq_to_channel_conversion() {
assert_eq!(freq_to_channel(2412), 1);
assert_eq!(freq_to_channel(2437), 6);
assert_eq!(freq_to_channel(2462), 11);
assert_eq!(freq_to_channel(2484), 14);
assert_eq!(freq_to_channel(5180), 36);
assert_eq!(freq_to_channel(5745), 149);
assert_eq!(freq_to_channel(5955), 1); // 6 GHz channel 1
assert_eq!(freq_to_channel(9999), 0); // Unknown
}
#[test]
fn parse_signal_dbm_values() {
assert!((parse_signal_dbm(" -52.00 dBm").unwrap() - (-52.0)).abs() < f64::EPSILON);
assert!((parse_signal_dbm("-71.00 dBm").unwrap() - (-71.0)).abs() < f64::EPSILON);
assert!((parse_signal_dbm("-45.00").unwrap() - (-45.0)).abs() < f64::EPSILON);
}
#[test]
fn empty_output() {
let obs = parse_iw_scan_output("").unwrap();
assert!(obs.is_empty());
}
#[test]
fn missing_ssid_defaults_to_empty() {
let output = "\
BSS 11:22:33:44:55:66(on wlan0)
\tfreq: 2437
\tsignal: -60.00 dBm
";
let obs = parse_iw_scan_output(output).unwrap();
assert_eq!(obs.len(), 1);
assert_eq!(obs[0].ssid, "");
}
#[test]
fn channel_from_freq_when_ds_param_missing() {
let output = "\
BSS aa:bb:cc:dd:ee:ff(on wlan0)
\tfreq: 5180
\tsignal: -50.00 dBm
\tSSID: NoDS
";
let obs = parse_iw_scan_output(output).unwrap();
assert_eq!(obs.len(), 1);
assert_eq!(obs[0].channel, 36); // Derived from 5180 MHz.
}
}

View File

@@ -0,0 +1,360 @@
//! Adapter that scans WiFi BSSIDs on macOS by invoking a compiled Swift
//! helper binary that uses Apple's CoreWLAN framework.
//!
//! This is the macOS counterpart to [`NetshBssidScanner`](super::NetshBssidScanner)
//! on Windows. It follows ADR-025 (ORCA — macOS CoreWLAN WiFi Sensing).
//!
//! # Design
//!
//! Apple removed the `airport` CLI in macOS Sonoma 14.4+ and CoreWLAN is a
//! Swift/Objective-C framework with no stable C ABI for Rust FFI. We therefore
//! shell out to a small Swift helper (`mac_wifi`) that outputs JSON lines:
//!
//! ```json
//! {"ssid":"MyNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"}
//! ```
//!
//! macOS Sonoma+ redacts real BSSID MACs to `00:00:00:00:00:00` unless the app
//! holds the `com.apple.wifi.scan` entitlement. When we detect a zeroed BSSID
//! we generate a deterministic synthetic MAC via `SHA-256(ssid:channel)[:6]`,
//! setting the locally-administered bit so it never collides with real OUI
//! allocations.
//!
//! # Platform
//!
//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level.
use std::process::Command;
use std::time::Instant;
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
use crate::error::WifiScanError;
// ---------------------------------------------------------------------------
// MacosCoreWlanScanner
// ---------------------------------------------------------------------------
/// Synchronous WiFi scanner that shells out to the `mac_wifi` Swift helper.
///
/// The helper binary must be compiled from `v1/src/sensing/mac_wifi.swift` and
/// placed on `$PATH` or at a known location. The scanner invokes it with a
/// `--scan-once` flag (single-shot mode) and parses the JSON output.
///
/// If the helper is not found, [`scan_sync`](Self::scan_sync) returns a
/// [`WifiScanError::ProcessError`].
pub struct MacosCoreWlanScanner {
/// Path to the `mac_wifi` helper binary. Defaults to `"mac_wifi"` (on PATH).
helper_path: String,
}
impl MacosCoreWlanScanner {
/// Create a scanner that looks for `mac_wifi` on `$PATH`.
pub fn new() -> Self {
Self {
helper_path: "mac_wifi".to_owned(),
}
}
/// Create a scanner with an explicit path to the Swift helper binary.
pub fn with_path(path: impl Into<String>) -> Self {
Self {
helper_path: path.into(),
}
}
/// Run the Swift helper and parse the output synchronously.
///
/// Returns one [`BssidObservation`] per BSSID seen in the scan.
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
let output = Command::new(&self.helper_path)
.arg("--scan-once")
.output()
.map_err(|e| {
WifiScanError::ProcessError(format!(
"failed to run mac_wifi helper ({}): {e}",
self.helper_path
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WifiScanError::ScanFailed {
reason: format!(
"mac_wifi exited with {}: {}",
output.status,
stderr.trim()
),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_macos_scan_output(&stdout)
}
}
impl Default for MacosCoreWlanScanner {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
/// Parse the JSON-lines output from the `mac_wifi` Swift helper.
///
/// Each line is expected to be a JSON object with the fields:
/// `ssid`, `bssid`, `rssi`, `noise`, `channel`, `band`.
///
/// Lines that fail to parse are silently skipped (the helper may emit
/// status messages on stdout).
pub fn parse_macos_scan_output(output: &str) -> Result<Vec<BssidObservation>, WifiScanError> {
let now = Instant::now();
let mut results = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() || !line.starts_with('{') {
continue;
}
if let Some(obs) = parse_json_line(line, now) {
results.push(obs);
}
}
Ok(results)
}
/// Parse a single JSON line into a [`BssidObservation`].
///
/// Uses a lightweight manual parser to avoid pulling in `serde_json` as a
/// hard dependency. The JSON structure is simple and well-known.
fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
let ssid = extract_string_field(line, "ssid")?;
let bssid_str = extract_string_field(line, "bssid")?;
let rssi = extract_number_field(line, "rssi")?;
let channel_f = extract_number_field(line, "channel")?;
let channel = channel_f as u8;
// Resolve BSSID: use real MAC if available, otherwise generate synthetic.
let bssid = resolve_bssid(&bssid_str, &ssid, channel)?;
let band = BandType::from_channel(channel);
// macOS CoreWLAN doesn't report radio type directly; infer from band/channel.
let radio_type = infer_radio_type(channel);
// Convert RSSI to signal percentage using the standard mapping.
let signal_pct = ((rssi + 100.0) * 2.0).clamp(0.0, 100.0);
Some(BssidObservation {
bssid,
rssi_dbm: rssi,
signal_pct,
channel,
band,
radio_type,
ssid,
timestamp,
})
}
/// Resolve a BSSID string to a [`BssidId`].
///
/// If the MAC is all-zeros (macOS redaction), generate a synthetic
/// locally-administered MAC from `SHA-256(ssid:channel)`.
fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
// Try parsing the real BSSID first.
if let Ok(id) = BssidId::parse(bssid_str) {
// Check for the all-zeros redacted BSSID.
if id.0 != [0, 0, 0, 0, 0, 0] {
return Some(id);
}
}
// Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes,
// set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear).
Some(synthetic_bssid(ssid, channel))
}
/// Generate a deterministic synthetic BSSID from SSID and channel.
///
/// Uses a simple hash (FNV-1a-inspired) to avoid pulling in `sha2` crate.
/// The locally-administered bit is set so these never collide with real OUI MACs.
fn synthetic_bssid(ssid: &str, channel: u8) -> BssidId {
// Simple but deterministic hash — FNV-1a 64-bit.
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &byte in ssid.as_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
hash ^= u64::from(channel);
hash = hash.wrapping_mul(0x0100_0000_01b3);
let bytes = hash.to_le_bytes();
let mut mac = [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]];
// Set locally-administered bit (bit 1 of byte 0) and clear multicast (bit 0).
mac[0] = (mac[0] | 0x02) & 0xFE;
BssidId(mac)
}
/// Infer radio type from channel number (best effort on macOS).
fn infer_radio_type(channel: u8) -> RadioType {
match channel {
// 5 GHz channels → likely 802.11ac or newer
36..=177 => RadioType::Ac,
// 2.4 GHz → at least 802.11n
_ => RadioType::N,
}
}
// ---------------------------------------------------------------------------
// Lightweight JSON field extractors
// ---------------------------------------------------------------------------
/// Extract a string field value from a JSON object string.
///
/// Looks for `"key":"value"` or `"key": "value"` patterns.
fn extract_string_field(json: &str, key: &str) -> Option<String> {
let pattern = format!("\"{}\"", key);
let key_pos = json.find(&pattern)?;
let after_key = &json[key_pos + pattern.len()..];
// Skip optional whitespace and the colon.
let after_colon = after_key.trim_start().strip_prefix(':')?;
let after_colon = after_colon.trim_start();
// Expect opening quote.
let after_quote = after_colon.strip_prefix('"')?;
// Find closing quote (handle escaped quotes).
let mut end = 0;
let bytes = after_quote.as_bytes();
while end < bytes.len() {
if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') {
break;
}
end += 1;
}
Some(after_quote[..end].to_owned())
}
/// Extract a numeric field value from a JSON object string.
///
/// Looks for `"key": <number>` patterns.
fn extract_number_field(json: &str, key: &str) -> Option<f64> {
let pattern = format!("\"{}\"", key);
let key_pos = json.find(&pattern)?;
let after_key = &json[key_pos + pattern.len()..];
let after_colon = after_key.trim_start().strip_prefix(':')?;
let after_colon = after_colon.trim_start();
// Collect digits, sign, and decimal point.
let num_str: String = after_colon
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '-' || *c == '.' || *c == '+' || *c == 'e' || *c == 'E')
.collect();
num_str.parse().ok()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_OUTPUT: &str = r#"
{"ssid":"HomeNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"}
{"ssid":"GuestWifi","bssid":"11:22:33:44:55:66","rssi":-71,"noise":-92,"channel":6,"band":"2.4GHz"}
{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"}
"#;
#[test]
fn parse_valid_output() {
let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
assert_eq!(obs.len(), 3);
// First entry: real BSSID.
assert_eq!(obs[0].ssid, "HomeNetwork");
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON);
assert_eq!(obs[0].channel, 36);
assert_eq!(obs[0].band, BandType::Band5GHz);
// Second entry: 2.4 GHz.
assert_eq!(obs[1].ssid, "GuestWifi");
assert_eq!(obs[1].channel, 6);
assert_eq!(obs[1].band, BandType::Band2_4GHz);
assert_eq!(obs[1].radio_type, RadioType::N);
// Third entry: redacted BSSID → synthetic MAC.
assert_eq!(obs[2].ssid, "Redacted");
// Should NOT be all-zeros.
assert_ne!(obs[2].bssid.0, [0, 0, 0, 0, 0, 0]);
// Should have locally-administered bit set.
assert_eq!(obs[2].bssid.0[0] & 0x02, 0x02);
// Should have unicast bit (multicast cleared).
assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00);
}
#[test]
fn synthetic_bssid_is_deterministic() {
let a = synthetic_bssid("TestNet", 36);
let b = synthetic_bssid("TestNet", 36);
assert_eq!(a, b);
// Different SSID or channel → different MAC.
let c = synthetic_bssid("OtherNet", 36);
assert_ne!(a, c);
let d = synthetic_bssid("TestNet", 6);
assert_ne!(a, d);
}
#[test]
fn parse_empty_and_junk_lines() {
let output = "\n \nnot json\n{broken json\n";
let obs = parse_macos_scan_output(output).unwrap();
assert!(obs.is_empty());
}
#[test]
fn extract_string_field_basic() {
let json = r#"{"ssid":"MyNet","bssid":"aa:bb:cc:dd:ee:ff"}"#;
assert_eq!(extract_string_field(json, "ssid").unwrap(), "MyNet");
assert_eq!(
extract_string_field(json, "bssid").unwrap(),
"aa:bb:cc:dd:ee:ff"
);
assert!(extract_string_field(json, "missing").is_none());
}
#[test]
fn extract_number_field_basic() {
let json = r#"{"rssi":-52,"channel":36}"#;
assert!((extract_number_field(json, "rssi").unwrap() - (-52.0)).abs() < f64::EPSILON);
assert!((extract_number_field(json, "channel").unwrap() - 36.0).abs() < f64::EPSILON);
}
#[test]
fn signal_pct_clamping() {
// RSSI -50 → pct = (-50+100)*2 = 100
let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-50,"channel":1}"#;
let obs = parse_json_line(json, Instant::now()).unwrap();
assert!((obs.signal_pct - 100.0).abs() < f64::EPSILON);
// RSSI -100 → pct = 0
let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-100,"channel":1}"#;
let obs = parse_json_line(json, Instant::now()).unwrap();
assert!((obs.signal_pct - 0.0).abs() < f64::EPSILON);
}
}

View File

@@ -1,12 +1,30 @@
//! Adapter implementations for the [`WlanScanPort`] port.
//!
//! Each adapter targets a specific platform scanning mechanism:
//! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid`.
//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path.
//! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid` (Windows).
//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path (Windows).
//! - [`MacosCoreWlanScanner`]: CoreWLAN via Swift helper binary (macOS, ADR-025).
//! - [`LinuxIwScanner`]: parses `iw dev <iface> scan` output (Linux).
pub(crate) mod netsh_scanner;
pub mod wlanapi_scanner;
#[cfg(target_os = "macos")]
pub mod macos_scanner;
#[cfg(target_os = "linux")]
pub mod linux_scanner;
pub use netsh_scanner::NetshBssidScanner;
pub use netsh_scanner::parse_netsh_output;
pub use wlanapi_scanner::WlanApiScanner;
#[cfg(target_os = "macos")]
pub use macos_scanner::MacosCoreWlanScanner;
#[cfg(target_os = "macos")]
pub use macos_scanner::parse_macos_scan_output;
#[cfg(target_os = "linux")]
pub use linux_scanner::LinuxIwScanner;
#[cfg(target_os = "linux")]
pub use linux_scanner::parse_iw_scan_output;

View File

@@ -6,8 +6,10 @@
//!
//! - **Domain types**: [`BssidId`], [`BssidObservation`], [`BandType`], [`RadioType`]
//! - **Port**: [`WlanScanPort`] -- trait abstracting the platform scan backend
//! - **Adapter**: [`NetshBssidScanner`] -- Tier 1 adapter that parses
//! `netsh wlan show networks mode=bssid` output
//! - **Adapters**:
//! - [`NetshBssidScanner`] -- Windows, parses `netsh wlan show networks mode=bssid`
//! - `MacosCoreWlanScanner` -- macOS, invokes CoreWLAN Swift helper (ADR-025)
//! - `LinuxIwScanner` -- Linux, parses `iw dev <iface> scan` output
pub mod adapter;
pub mod domain;
@@ -19,6 +21,16 @@ pub mod port;
pub use adapter::NetshBssidScanner;
pub use adapter::parse_netsh_output;
pub use adapter::WlanApiScanner;
#[cfg(target_os = "macos")]
pub use adapter::MacosCoreWlanScanner;
#[cfg(target_os = "macos")]
pub use adapter::parse_macos_scan_output;
#[cfg(target_os = "linux")]
pub use adapter::LinuxIwScanner;
#[cfg(target_os = "linux")]
pub use adapter::parse_iw_scan_output;
pub use domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
pub use domain::frame::MultiApFrame;
pub use domain::registry::{BssidEntry, BssidMeta, BssidRegistry, RunningStats};

View File

@@ -0,0 +1,34 @@
import Foundation
import CoreWLAN
// Output format: JSON lines for easy parsing by Python
// {"timestamp": 1234567.89, "rssi": -50, "noise": -90, "tx_rate": 866.0}
func main() {
guard let interface = CWWiFiClient.shared().interface() else {
fputs("{\"error\": \"No WiFi interface found\"}\n", stderr)
exit(1)
}
// Flush stdout automatically to prevent buffering issues with Python subprocess
setbuf(stdout, nil)
// Run at ~10Hz
let interval: TimeInterval = 0.1
while true {
let timestamp = Date().timeIntervalSince1970
let rssi = interface.rssiValue()
let noise = interface.noiseMeasurement()
let txRate = interface.transmitRate()
let json = """
{"timestamp": \(timestamp), "rssi": \(rssi), "noise": \(noise), "tx_rate": \(txRate)}
"""
print(json)
Thread.sleep(forTimeInterval: interval)
}
}
main()

View File

@@ -602,3 +602,137 @@ class WindowsWifiCollector:
retry_count=0,
interface=self._interface,
)
# ---------------------------------------------------------------------------
# macOS WiFi collector (real hardware via Swift CoreWLAN utility)
# ---------------------------------------------------------------------------
class MacosWifiCollector:
"""
Collects real RSSI data from a macOS WiFi interface using a Swift utility.
Data source: A small compiled Swift binary (`mac_wifi`) that polls the
CoreWLAN `CWWiFiClient.shared().interface()` at a high rate.
"""
def __init__(
self,
sample_rate_hz: float = 10.0,
buffer_seconds: int = 120,
) -> None:
self._rate = sample_rate_hz
self._buffer = RingBuffer(max_size=int(sample_rate_hz * buffer_seconds))
self._running = False
self._thread: Optional[threading.Thread] = None
self._process: Optional[subprocess.Popen] = None
self._interface = "en0" # CoreWLAN automatically targets the active Wi-Fi interface
# Compile the Swift utility if the binary doesn't exist
import os
base_dir = os.path.dirname(os.path.abspath(__file__))
self.swift_src = os.path.join(base_dir, "mac_wifi.swift")
self.swift_bin = os.path.join(base_dir, "mac_wifi")
# -- public API ----------------------------------------------------------
@property
def sample_rate_hz(self) -> float:
return self._rate
def start(self) -> None:
if self._running:
return
# Ensure binary exists
import os
if not os.path.exists(self.swift_bin):
logger.info("Compiling mac_wifi.swift to %s", self.swift_bin)
try:
subprocess.run(["swiftc", "-O", "-o", self.swift_bin, self.swift_src], check=True, capture_output=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to compile macOS WiFi utility: {e.stderr.decode('utf-8')}")
except FileNotFoundError:
raise RuntimeError("swiftc is not installed. Please install Xcode Command Line Tools to use native macOS WiFi sensing.")
self._running = True
self._thread = threading.Thread(
target=self._sample_loop, daemon=True, name="mac-rssi-collector"
)
self._thread.start()
logger.info("MacosWifiCollector started at %.1f Hz", self._rate)
def stop(self) -> None:
self._running = False
if self._process:
self._process.terminate()
try:
self._process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
self._process.kill()
self._process = None
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
logger.info("MacosWifiCollector stopped")
def get_samples(self, n: Optional[int] = None) -> List[WifiSample]:
if n is not None:
return self._buffer.get_last_n(n)
return self._buffer.get_all()
# -- internals -----------------------------------------------------------
def _sample_loop(self) -> None:
import json
# Start the Swift binary
self._process = subprocess.Popen(
[self.swift_bin],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1 # Line buffered
)
while self._running and self._process and self._process.poll() is None:
try:
line = self._process.stdout.readline()
if not line:
continue
line = line.strip()
if not line:
continue
if line.startswith("{"):
data = json.loads(line)
if "error" in data:
logger.error("macOS WiFi utility error: %s", data["error"])
continue
rssi = float(data.get("rssi", -80.0))
noise = float(data.get("noise", -95.0))
link_quality = max(0.0, min(1.0, (rssi + 100.0) / 60.0))
sample = WifiSample(
timestamp=time.time(),
rssi_dbm=rssi,
noise_dbm=noise,
link_quality=link_quality,
tx_bytes=0,
rx_bytes=0,
retry_count=0,
interface=self._interface,
)
self._buffer.append(sample)
except Exception as e:
logger.error("Error reading macOS WiFi stream: %s", e)
time.sleep(1.0)
# Process exited unexpectedly
if self._running:
logger.error("macOS WiFi utility exited unexpectedly. Collector stopped.")
self._running = False

View File

@@ -41,6 +41,7 @@ from v1.src.sensing.rssi_collector import (
LinuxWifiCollector,
SimulatedCollector,
WindowsWifiCollector,
MacosWifiCollector,
WifiSample,
RingBuffer,
)
@@ -340,12 +341,26 @@ class SensingWebSocketServer:
except Exception as e:
logger.warning("Windows WiFi unavailable (%s), falling back", e)
elif system == "Linux":
# In Docker on Mac, Linux is detected but no wireless extensions exist.
# Force SimulatedCollector if /proc/net/wireless doesn't exist.
import os
if os.path.exists("/proc/net/wireless"):
try:
collector = LinuxWifiCollector(sample_rate_hz=10.0)
self.source = "linux_wifi"
return collector
except RuntimeError:
logger.warning("Linux WiFi unavailable, falling back")
else:
logger.warning("Linux detected but /proc/net/wireless missing (likely Docker). Falling back.")
elif system == "Darwin":
try:
collector = LinuxWifiCollector(sample_rate_hz=10.0)
self.source = "linux_wifi"
collector = MacosWifiCollector(sample_rate_hz=10.0)
logger.info("Using MacosWifiCollector")
self.source = "macos_wifi"
return collector
except RuntimeError:
logger.warning("Linux WiFi unavailable, falling back")
except Exception as e:
logger.warning("macOS WiFi unavailable (%s), falling back", e)
# 3. Simulated
logger.info("Using SimulatedCollector")