openentropy_core/sources/network/
wifi_rssi.rs1use std::process::Command;
9use std::thread;
10use std::time::{Duration, Instant};
11
12use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
13
14const MEASUREMENT_DELAY: Duration = Duration::from_millis(10);
15const SAMPLES_PER_COLLECT: usize = 8;
16
17const WIFI_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
19
20pub struct WiFiRSSISource;
34
35static WIFI_RSSI_INFO: SourceInfo = SourceInfo {
36 name: "wifi_rssi",
37 description: "WiFi signal strength (RSSI) and noise floor fluctuations",
38 physics: "Reads WiFi signal strength (RSSI) and noise floor via CoreWLAN \
39 framework. RSSI fluctuates due to: multipath fading (reflections \
40 off walls/objects), constructive/destructive interference at \
41 2.4/5/6 GHz, Rayleigh fading from moving objects, atmospheric \
42 absorption, and thermal noise in the radio receiver's LNA.",
43 category: SourceCategory::Network,
44 platform: Platform::MacOS,
45 requirements: &[Requirement::Wifi],
46 entropy_rate_estimate: 0.5,
47 composite: false,
48 is_fast: false,
49};
50
51impl WiFiRSSISource {
52 pub fn new() -> Self {
53 Self
54 }
55}
56
57impl Default for WiFiRSSISource {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63#[derive(Debug, Clone, Copy)]
65struct WifiMeasurement {
66 rssi: i32,
67 noise: i32,
68 timing_nanos: u128,
70}
71
72fn run_command_timed(cmd: &str, args: &[&str], timeout: Duration) -> (Option<String>, u64) {
75 let t0 = Instant::now();
76
77 let child = Command::new(cmd)
78 .args(args)
79 .stdout(std::process::Stdio::piped())
80 .stderr(std::process::Stdio::null())
81 .spawn();
82
83 let mut child = match child {
84 Ok(c) => c,
85 Err(_) => return (None, t0.elapsed().as_nanos() as u64),
86 };
87
88 let deadline = Instant::now() + timeout;
89 loop {
90 match child.try_wait() {
91 Ok(Some(status)) => {
92 let elapsed = t0.elapsed().as_nanos() as u64;
93 if !status.success() {
94 return (None, elapsed);
95 }
96 use std::io::Read;
99 let mut stdout_str = String::new();
100 if let Some(mut out) = child.stdout.take() {
101 let _ = out.read_to_string(&mut stdout_str);
102 }
103 let result = if stdout_str.is_empty() {
104 None
105 } else {
106 Some(stdout_str)
107 };
108 return (result, elapsed);
109 }
110 Ok(None) => {
111 if Instant::now() >= deadline {
112 let _ = child.kill();
113 let _ = child.wait();
114 return (None, t0.elapsed().as_nanos() as u64);
115 }
116 std::thread::sleep(Duration::from_millis(50));
117 }
118 Err(_) => return (None, t0.elapsed().as_nanos() as u64),
119 }
120 }
121}
122
123fn discover_wifi_device() -> Option<String> {
126 let (output, _) = run_command_timed(
127 "/usr/sbin/networksetup",
128 &["-listallhardwareports"],
129 WIFI_COMMAND_TIMEOUT,
130 );
131
132 let text = output?;
133 let mut found_wifi = false;
134
135 for line in text.lines() {
136 if line.contains("Wi-Fi") || line.contains("AirPort") {
137 found_wifi = true;
138 continue;
139 }
140 if found_wifi && line.starts_with("Device:") {
141 let device = line.trim_start_matches("Device:").trim();
142 if !device.is_empty() {
143 return Some(device.to_string());
144 }
145 }
146 if found_wifi && line.starts_with("Hardware Port:") {
148 found_wifi = false;
149 }
150 }
151 None
152}
153
154fn read_via_ipconfig(device: &str) -> (Option<(i32, i32)>, u64) {
157 let (output, elapsed) = run_command_timed(
158 "/usr/sbin/ipconfig",
159 &["getsummary", device],
160 WIFI_COMMAND_TIMEOUT,
161 );
162
163 let text = match output {
164 Some(t) => t,
165 None => return (None, elapsed),
166 };
167
168 let rssi = match parse_field_value(&text, "RSSI") {
169 Some(v) => v,
170 None => return (None, elapsed),
171 };
172 let noise = parse_field_value(&text, "Noise").unwrap_or(rssi - 30);
173 (Some((rssi, noise)), elapsed)
174}
175
176fn read_via_airport() -> (Option<(i32, i32)>, u64) {
179 let (output, elapsed) = run_command_timed(
180 "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport",
181 &["-I"],
182 WIFI_COMMAND_TIMEOUT,
183 );
184
185 let text = match output {
186 Some(t) => t,
187 None => return (None, elapsed),
188 };
189
190 let rssi = match parse_field_value(&text, "agrCtlRSSI") {
191 Some(v) => v,
192 None => return (None, elapsed),
193 };
194 let noise = parse_field_value(&text, "agrCtlNoise").unwrap_or(rssi - 30);
195 (Some((rssi, noise)), elapsed)
196}
197
198fn parse_field_value(text: &str, field: &str) -> Option<i32> {
201 for line in text.lines() {
202 let trimmed = line.trim();
203 if let Some(rest) = trimmed.strip_prefix(field) {
205 let rest = rest.trim_start();
206 if let Some(val_str) = rest.strip_prefix(':') {
207 let val_str = val_str.trim();
208 if let Ok(v) = val_str.parse::<i32>() {
209 return Some(v);
210 }
211 }
212 }
213 }
214 None
215}
216
217fn measure_once(device: &Option<String>) -> WifiMeasurement {
220 let start = Instant::now();
221
222 let result = if let Some(dev) = device {
223 let (ipconfig_result, elapsed1) = read_via_ipconfig(dev);
224 if let Some(vals) = ipconfig_result {
225 Some((vals, elapsed1))
226 } else {
227 let (airport_result, elapsed2) = read_via_airport();
228 airport_result.map(|vals| (vals, elapsed1 + elapsed2))
229 }
230 } else {
231 let (airport_result, elapsed) = read_via_airport();
232 airport_result.map(|vals| (vals, elapsed))
233 };
234
235 let timing_nanos = start.elapsed().as_nanos();
236 match result {
237 Some(((rssi, noise), _)) => WifiMeasurement {
238 rssi,
239 noise,
240 timing_nanos,
241 },
242 None => WifiMeasurement {
243 rssi: 0,
244 noise: 0,
245 timing_nanos,
246 },
247 }
248}
249
250impl EntropySource for WiFiRSSISource {
251 fn info(&self) -> &SourceInfo {
252 &WIFI_RSSI_INFO
253 }
254
255 fn is_available(&self) -> bool {
256 if !cfg!(target_os = "macos") {
257 return false;
258 }
259 let device = discover_wifi_device();
260 let m = measure_once(&device);
261 m.rssi != 0 || m.noise != 0
263 }
264
265 fn collect(&self, n_samples: usize) -> Vec<u8> {
266 let mut raw = Vec::with_capacity(n_samples * 4);
267 let device = discover_wifi_device();
268
269 let mut measurements = Vec::with_capacity(SAMPLES_PER_COLLECT);
270
271 let max_bursts = 4;
275 for _ in 0..max_bursts {
276 measurements.clear();
277
278 for _ in 0..SAMPLES_PER_COLLECT {
279 let m = measure_once(&device);
280 measurements.push(m);
281 thread::sleep(MEASUREMENT_DELAY);
282 }
283
284 for i in 0..measurements.len() {
285 let m = &measurements[i];
286
287 let t_bytes = m.timing_nanos.to_le_bytes();
289 raw.push(t_bytes[0]);
290 raw.push(t_bytes[1]);
291 raw.push(t_bytes[2]);
292 raw.push(t_bytes[3]);
293
294 if m.rssi != 0 || m.noise != 0 {
296 raw.push(m.rssi as u8);
297 raw.push(m.noise as u8);
298 }
299
300 if i > 0 {
302 let prev = &measurements[i - 1];
303 raw.push((m.rssi.wrapping_sub(prev.rssi)) as u8);
304 let timing_delta = m.timing_nanos.abs_diff(prev.timing_nanos);
305 raw.push(timing_delta.to_le_bytes()[0]);
306 raw.push((m.rssi ^ m.noise) as u8);
307 }
308 }
309
310 if raw.len() >= n_samples * 2 {
311 break;
312 }
313 }
314
315 raw.truncate(n_samples);
316 raw
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn parse_rssi_from_airport_output() {
326 let sample = "\
327 agrCtlRSSI: -62\n\
328 agrCtlNoise: -90\n\
329 state: running\n";
330 assert_eq!(parse_field_value(sample, "agrCtlRSSI"), Some(-62));
331 assert_eq!(parse_field_value(sample, "agrCtlNoise"), Some(-90));
332 }
333
334 #[test]
335 fn parse_rssi_from_ipconfig_output() {
336 let sample = "\
337 SSID : MyNetwork\n\
338 RSSI : -55\n\
339 Noise : -88\n";
340 assert_eq!(parse_field_value(sample, "RSSI"), Some(-55));
341 assert_eq!(parse_field_value(sample, "Noise"), Some(-88));
342 }
343
344 #[test]
345 fn parse_field_missing() {
346 assert_eq!(parse_field_value("nothing here", "RSSI"), None);
347 }
348
349 #[test]
350 fn source_info() {
351 let src = WiFiRSSISource::new();
352 assert_eq!(src.info().name, "wifi_rssi");
353 assert_eq!(src.info().category, SourceCategory::Network);
354 assert!((src.info().entropy_rate_estimate - 0.5).abs() < f64::EPSILON);
355 assert_eq!(src.info().platform, Platform::MacOS);
356 assert_eq!(src.info().requirements, &[Requirement::Wifi]);
357 assert!(!src.info().composite);
358 }
359
360 #[test]
361 #[cfg(target_os = "macos")]
362 #[ignore] fn wifi_rssi_collects_bytes() {
364 let src = WiFiRSSISource::new();
365 if src.is_available() {
366 let data = src.collect(32);
367 assert!(!data.is_empty());
368 assert!(data.len() <= 32);
369 }
370 }
371}