Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit e2320e8

Browse files
committed
feat(wifiscan): add Rust macOS + Linux adapters, fix Python byte counters
- Add MacosCoreWlanScanner (macOS): CoreWLAN Swift helper adapter with synthetic BSSID generation via FNV-1a hash for redacted MACs (ADR-025) - Add LinuxIwScanner (Linux): parses `iw dev <iface> scan` output with freq-to-channel conversion and BSS stanza parsing - Both adapters produce Vec<BssidObservation> compatible with the existing WindowsWifiPipeline 8-stage processing - Platform-gate modules with #[cfg(target_os)] so each adapter only compiles on its target OS - Fix Python MacosWifiCollector: remove synthetic byte counters that produced misleading tx_bytes/rx_bytes data (set to 0) - Add compiled Swift binary (mac_wifi) to .gitignore Co-Authored-By: claude-flow <[email protected]>
1 parent 09f01d5 commit e2320e8

6 files changed

Lines changed: 762 additions & 16 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ cython_debug/
193193
# PyPI configuration file
194194
.pypirc
195195

196+
# Compiled Swift helper binaries (macOS WiFi sensing)
197+
v1/src/sensing/mac_wifi
198+
196199
# Cursor
197200
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
198201
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
//! Adapter that scans WiFi BSSIDs on Linux by invoking `iw dev <iface> scan`.
2+
//!
3+
//! This is the Linux counterpart to [`NetshBssidScanner`](super::NetshBssidScanner)
4+
//! on Windows and [`MacosCoreWlanScanner`](super::MacosCoreWlanScanner) on macOS.
5+
//!
6+
//! # Design
7+
//!
8+
//! The adapter shells out to `iw dev <interface> scan` (or `iw dev <interface> scan dump`
9+
//! to read cached results without triggering a new scan, which requires root).
10+
//! The output is parsed into [`BssidObservation`] values using the same domain
11+
//! types shared by all platform adapters.
12+
//!
13+
//! # Permissions
14+
//!
15+
//! - `iw dev <iface> scan` requires `CAP_NET_ADMIN` (typically root).
16+
//! - `iw dev <iface> scan dump` reads cached results and may work without root
17+
//! on some distributions.
18+
//!
19+
//! # Platform
20+
//!
21+
//! Linux only. Gated behind `#[cfg(target_os = "linux")]` at the module level.
22+
23+
use std::process::Command;
24+
use std::time::Instant;
25+
26+
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
27+
use crate::error::WifiScanError;
28+
29+
// ---------------------------------------------------------------------------
30+
// LinuxIwScanner
31+
// ---------------------------------------------------------------------------
32+
33+
/// Synchronous WiFi scanner that shells out to `iw dev <interface> scan`.
34+
///
35+
/// Each call to [`scan_sync`](Self::scan_sync) spawns a subprocess, captures
36+
/// stdout, and parses the BSS stanzas into [`BssidObservation`] values.
37+
pub struct LinuxIwScanner {
38+
/// Wireless interface name (e.g. `"wlan0"`, `"wlp2s0"`).
39+
interface: String,
40+
/// If true, use `scan dump` (cached results) instead of triggering a new
41+
/// scan. This avoids the root requirement but may return stale data.
42+
use_dump: bool,
43+
}
44+
45+
impl LinuxIwScanner {
46+
/// Create a scanner for the default interface `wlan0`.
47+
pub fn new() -> Self {
48+
Self {
49+
interface: "wlan0".to_owned(),
50+
use_dump: false,
51+
}
52+
}
53+
54+
/// Create a scanner for a specific wireless interface.
55+
pub fn with_interface(iface: impl Into<String>) -> Self {
56+
Self {
57+
interface: iface.into(),
58+
use_dump: false,
59+
}
60+
}
61+
62+
/// Use `scan dump` instead of `scan` to read cached results without root.
63+
pub fn use_cached(mut self) -> Self {
64+
self.use_dump = true;
65+
self
66+
}
67+
68+
/// Run `iw dev <iface> scan` and parse the output synchronously.
69+
///
70+
/// Returns one [`BssidObservation`] per BSS stanza in the output.
71+
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
72+
let scan_cmd = if self.use_dump { "dump" } else { "scan" };
73+
74+
let mut args = vec!["dev", &self.interface, "scan"];
75+
if self.use_dump {
76+
args.push(scan_cmd);
77+
}
78+
79+
// iw uses "scan dump" not "scan scan dump"
80+
let args = if self.use_dump {
81+
vec!["dev", &self.interface, "scan", "dump"]
82+
} else {
83+
vec!["dev", &self.interface, "scan"]
84+
};
85+
86+
let output = Command::new("iw")
87+
.args(&args)
88+
.output()
89+
.map_err(|e| {
90+
WifiScanError::ProcessError(format!(
91+
"failed to run `iw {}`: {e}",
92+
args.join(" ")
93+
))
94+
})?;
95+
96+
if !output.status.success() {
97+
let stderr = String::from_utf8_lossy(&output.stderr);
98+
return Err(WifiScanError::ScanFailed {
99+
reason: format!(
100+
"iw exited with {}: {}",
101+
output.status,
102+
stderr.trim()
103+
),
104+
});
105+
}
106+
107+
let stdout = String::from_utf8_lossy(&output.stdout);
108+
parse_iw_scan_output(&stdout)
109+
}
110+
}
111+
112+
impl Default for LinuxIwScanner {
113+
fn default() -> Self {
114+
Self::new()
115+
}
116+
}
117+
118+
// ---------------------------------------------------------------------------
119+
// Parser
120+
// ---------------------------------------------------------------------------
121+
122+
/// Intermediate accumulator for fields within a single BSS stanza.
123+
#[derive(Default)]
124+
struct BssStanza {
125+
bssid: Option<String>,
126+
ssid: Option<String>,
127+
signal_dbm: Option<f64>,
128+
freq_mhz: Option<u32>,
129+
channel: Option<u8>,
130+
}
131+
132+
impl BssStanza {
133+
/// Flush this stanza into a [`BssidObservation`], if we have enough data.
134+
fn flush(self, timestamp: Instant) -> Option<BssidObservation> {
135+
let bssid_str = self.bssid?;
136+
let bssid = BssidId::parse(&bssid_str).ok()?;
137+
let rssi_dbm = self.signal_dbm.unwrap_or(-90.0);
138+
139+
// Determine channel from explicit field or frequency.
140+
let channel = self.channel.or_else(|| {
141+
self.freq_mhz.map(freq_to_channel)
142+
}).unwrap_or(0);
143+
144+
let band = BandType::from_channel(channel);
145+
let radio_type = infer_radio_type_from_freq(self.freq_mhz.unwrap_or(0));
146+
let signal_pct = ((rssi_dbm + 100.0) * 2.0).clamp(0.0, 100.0);
147+
148+
Some(BssidObservation {
149+
bssid,
150+
rssi_dbm,
151+
signal_pct,
152+
channel,
153+
band,
154+
radio_type,
155+
ssid: self.ssid.unwrap_or_default(),
156+
timestamp,
157+
})
158+
}
159+
}
160+
161+
/// Parse the text output of `iw dev <iface> scan [dump]`.
162+
///
163+
/// The output consists of BSS stanzas, each starting with:
164+
/// ```text
165+
/// BSS aa:bb:cc:dd:ee:ff(on wlan0)
166+
/// ```
167+
/// followed by indented key-value lines.
168+
pub fn parse_iw_scan_output(output: &str) -> Result<Vec<BssidObservation>, WifiScanError> {
169+
let now = Instant::now();
170+
let mut results = Vec::new();
171+
let mut current: Option<BssStanza> = None;
172+
173+
for line in output.lines() {
174+
// New BSS stanza starts with "BSS " at column 0.
175+
if line.starts_with("BSS ") {
176+
// Flush previous stanza.
177+
if let Some(stanza) = current.take() {
178+
if let Some(obs) = stanza.flush(now) {
179+
results.push(obs);
180+
}
181+
}
182+
183+
// Parse BSSID from "BSS aa:bb:cc:dd:ee:ff(on wlan0)" or
184+
// "BSS aa:bb:cc:dd:ee:ff -- associated".
185+
let rest = &line[4..];
186+
let mac_end = rest.find(|c: char| !c.is_ascii_hexdigit() && c != ':')
187+
.unwrap_or(rest.len());
188+
let mac = &rest[..mac_end];
189+
190+
if mac.len() == 17 {
191+
let mut stanza = BssStanza::default();
192+
stanza.bssid = Some(mac.to_lowercase());
193+
current = Some(stanza);
194+
}
195+
continue;
196+
}
197+
198+
// Indented lines belong to the current stanza.
199+
let trimmed = line.trim();
200+
if let Some(ref mut stanza) = current {
201+
if let Some(rest) = trimmed.strip_prefix("SSID:") {
202+
stanza.ssid = Some(rest.trim().to_owned());
203+
} else if let Some(rest) = trimmed.strip_prefix("signal:") {
204+
// "signal: -52.00 dBm"
205+
stanza.signal_dbm = parse_signal_dbm(rest);
206+
} else if let Some(rest) = trimmed.strip_prefix("freq:") {
207+
// "freq: 5180"
208+
stanza.freq_mhz = rest.trim().parse().ok();
209+
} else if let Some(rest) = trimmed.strip_prefix("DS Parameter set: channel") {
210+
// "DS Parameter set: channel 6"
211+
stanza.channel = rest.trim().parse().ok();
212+
}
213+
}
214+
}
215+
216+
// Flush the last stanza.
217+
if let Some(stanza) = current.take() {
218+
if let Some(obs) = stanza.flush(now) {
219+
results.push(obs);
220+
}
221+
}
222+
223+
Ok(results)
224+
}
225+
226+
/// Convert a frequency in MHz to an 802.11 channel number.
227+
fn freq_to_channel(freq_mhz: u32) -> u8 {
228+
match freq_mhz {
229+
// 2.4 GHz: channels 1-14.
230+
2412..=2472 => ((freq_mhz - 2407) / 5) as u8,
231+
2484 => 14,
232+
// 5 GHz: channels 36-177.
233+
5170..=5885 => ((freq_mhz - 5000) / 5) as u8,
234+
// 6 GHz (Wi-Fi 6E).
235+
5955..=7115 => ((freq_mhz - 5950) / 5) as u8,
236+
_ => 0,
237+
}
238+
}
239+
240+
/// Parse a signal strength string like "-52.00 dBm" into dBm.
241+
fn parse_signal_dbm(s: &str) -> Option<f64> {
242+
let s = s.trim();
243+
// Take everything up to " dBm" or just parse the number.
244+
let num_part = s.split_whitespace().next()?;
245+
num_part.parse().ok()
246+
}
247+
248+
/// Infer radio type from frequency (best effort).
249+
fn infer_radio_type_from_freq(freq_mhz: u32) -> RadioType {
250+
match freq_mhz {
251+
5955..=7115 => RadioType::Ax, // 6 GHz → Wi-Fi 6E
252+
5170..=5885 => RadioType::Ac, // 5 GHz → likely 802.11ac
253+
_ => RadioType::N, // 2.4 GHz → at least 802.11n
254+
}
255+
}
256+
257+
// ---------------------------------------------------------------------------
258+
// Tests
259+
// ---------------------------------------------------------------------------
260+
261+
#[cfg(test)]
262+
mod tests {
263+
use super::*;
264+
265+
/// Real-world `iw dev wlan0 scan` output (truncated to 3 BSSes).
266+
const SAMPLE_IW_OUTPUT: &str = "\
267+
BSS aa:bb:cc:dd:ee:ff(on wlan0)
268+
\tTSF: 123456789 usec
269+
\tfreq: 5180
270+
\tbeacon interval: 100 TUs
271+
\tcapability: ESS Privacy (0x0011)
272+
\tsignal: -52.00 dBm
273+
\tSSID: HomeNetwork
274+
\tDS Parameter set: channel 36
275+
BSS 11:22:33:44:55:66(on wlan0)
276+
\tfreq: 2437
277+
\tsignal: -71.00 dBm
278+
\tSSID: GuestWifi
279+
\tDS Parameter set: channel 6
280+
BSS de:ad:be:ef:ca:fe(on wlan0) -- associated
281+
\tfreq: 5745
282+
\tsignal: -45.00 dBm
283+
\tSSID: OfficeNet
284+
";
285+
286+
#[test]
287+
fn parse_three_bss_stanzas() {
288+
let obs = parse_iw_scan_output(SAMPLE_IW_OUTPUT).unwrap();
289+
assert_eq!(obs.len(), 3);
290+
291+
// First BSS.
292+
assert_eq!(obs[0].ssid, "HomeNetwork");
293+
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
294+
assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON);
295+
assert_eq!(obs[0].channel, 36);
296+
assert_eq!(obs[0].band, BandType::Band5GHz);
297+
298+
// Second BSS: 2.4 GHz.
299+
assert_eq!(obs[1].ssid, "GuestWifi");
300+
assert_eq!(obs[1].channel, 6);
301+
assert_eq!(obs[1].band, BandType::Band2_4GHz);
302+
assert_eq!(obs[1].radio_type, RadioType::N);
303+
304+
// Third BSS: "-- associated" suffix.
305+
assert_eq!(obs[2].ssid, "OfficeNet");
306+
assert_eq!(obs[2].bssid.to_string(), "de:ad:be:ef:ca:fe");
307+
assert!((obs[2].rssi_dbm - (-45.0)).abs() < f64::EPSILON);
308+
}
309+
310+
#[test]
311+
fn freq_to_channel_conversion() {
312+
assert_eq!(freq_to_channel(2412), 1);
313+
assert_eq!(freq_to_channel(2437), 6);
314+
assert_eq!(freq_to_channel(2462), 11);
315+
assert_eq!(freq_to_channel(2484), 14);
316+
assert_eq!(freq_to_channel(5180), 36);
317+
assert_eq!(freq_to_channel(5745), 149);
318+
assert_eq!(freq_to_channel(5955), 1); // 6 GHz channel 1
319+
assert_eq!(freq_to_channel(9999), 0); // Unknown
320+
}
321+
322+
#[test]
323+
fn parse_signal_dbm_values() {
324+
assert!((parse_signal_dbm(" -52.00 dBm").unwrap() - (-52.0)).abs() < f64::EPSILON);
325+
assert!((parse_signal_dbm("-71.00 dBm").unwrap() - (-71.0)).abs() < f64::EPSILON);
326+
assert!((parse_signal_dbm("-45.00").unwrap() - (-45.0)).abs() < f64::EPSILON);
327+
}
328+
329+
#[test]
330+
fn empty_output() {
331+
let obs = parse_iw_scan_output("").unwrap();
332+
assert!(obs.is_empty());
333+
}
334+
335+
#[test]
336+
fn missing_ssid_defaults_to_empty() {
337+
let output = "\
338+
BSS 11:22:33:44:55:66(on wlan0)
339+
\tfreq: 2437
340+
\tsignal: -60.00 dBm
341+
";
342+
let obs = parse_iw_scan_output(output).unwrap();
343+
assert_eq!(obs.len(), 1);
344+
assert_eq!(obs[0].ssid, "");
345+
}
346+
347+
#[test]
348+
fn channel_from_freq_when_ds_param_missing() {
349+
let output = "\
350+
BSS aa:bb:cc:dd:ee:ff(on wlan0)
351+
\tfreq: 5180
352+
\tsignal: -50.00 dBm
353+
\tSSID: NoDS
354+
";
355+
let obs = parse_iw_scan_output(output).unwrap();
356+
assert_eq!(obs.len(), 1);
357+
assert_eq!(obs[0].channel, 36); // Derived from 5180 MHz.
358+
}
359+
}

0 commit comments

Comments
 (0)