From f8ab2047119f64f33eac2add54a23f05b232ad69 Mon Sep 17 00:00:00 2001 From: Mathew Beane Date: Thu, 1 Oct 2026 17:42:20 -0400 Subject: [PATCH] fix(wifiscan): keep the redacted macOS connected link; auto+wifi keeps UDP bound On macOS 27, without Location Services, CoreWLAN redacts both the SSID and the BSSID, while rssi, noise and channel stay real. resolve_bssid abstained when both identities were blank, so every macOS scan was dropped and `--source wifi` silently produced nothing. - mac_wifi --scan-once marks its sample `"connected": true`. It is always the single connected link, so a synthetic id cannot merge two networks. - A redacted connected sample gets a stable locally administered synthetic BSSID; the SSID stays empty. Unmarked redacted lines still abstain. - `--source auto` that resolves to host WiFi now also binds UDP, and wifi_task yields while the source is a live esp32, so ESP32 nodes that start after the boot probe still promote the source (the #1004 rule). Without this, the fix would make stock Macs ignore late-starting nodes. - A missing helper is reported once, with the build command. - ADR-025: Updated row + Amendment 1 recording the measured behaviour. Measured on macOS 27: --source wifi gives source wifi:unnamed with sensing ticks advancing; --source auto on a quiet port starts on wifi with udp_receiver=true and promotes to esp32 (5 nodes) when real frames arrive. Tests: wifiscan 158+1, sensing-server bin 332, all passing. Co-Authored-By: claude-flow --- archive/v1/src/sensing/mac_wifi.swift | 4 ++ .../ADR-025-macos-corewlan-wifi-sensing.md | 20 ++++++ .../wifi-densepose-sensing-server/src/main.rs | 34 +++++++-- .../src/adapter/macos_scanner.rs | 69 +++++++++++++++++-- 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/archive/v1/src/sensing/mac_wifi.swift b/archive/v1/src/sensing/mac_wifi.swift index f9b0d1569..88fc25764 100644 --- a/archive/v1/src/sensing/mac_wifi.swift +++ b/archive/v1/src/sensing/mac_wifi.swift @@ -20,6 +20,10 @@ func main() { exit(1) } let sample: [String: Any] = [ + // Always the connected link. macOS redacts ssid/bssid without + // Location Services; rssi/noise/channel stay real, so mark the + // sample as the connected link for the Rust adapter. + "connected": true, "ssid": interface.ssid() ?? "", "bssid": interface.bssid() ?? "00:00:00:00:00:00", "channel": channel.channelNumber, diff --git a/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md b/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md index ba0c885a3..4d3ef0333 100644 --- a/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md +++ b/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md @@ -4,6 +4,7 @@ |-------|-------| | **Status** | Proposed | | **Date** | 2026-03-01 | +| **Updated** | 2026-10-01: macOS 27 also redacts SSID without Location Services; the connected link is kept with a synthetic id, and `auto`+wifi keeps UDP bound (see Amendment 1) | | **Deciders** | ruv | | **Codename** | **ORCA** — OS-native Radio Channel Acquisition | | **Relates to** | ADR-013 (Feature-Level Sensing Commodity Gear), ADR-022 (Windows WiFi Enhanced Fidelity), ADR-014 (SOTA Signal Processing), ADR-018 (ESP32 Dev Implementation) | @@ -313,3 +314,22 @@ All verification on Mac Mini (M2 Pro, macOS 26.3). - ADR-022: Windows WiFi Enhanced Fidelity (analogous platform adapter) - ADR-013: Feature-Level Sensing from Commodity Gear - Issue [#56](https://github.com/ruvnet/wifi-densepose/issues/56): macOS support request + +--- + +## Amendment 1 (2026-10-01): SSID is also redacted; keep the connected link + +**Observed on macOS 27 (M-series Mac, CoreWLAN, unsigned CLI helper).** Without Location Services, `mac_wifi --scan-once` returns `"ssid":""` and `"bssid":"00:00:00:00:00:00"`, while `rssi`, `noise`, `channel` and `tx_rate` are real. The §1 assumption that "SSID + RSSI + channel" survive no longer holds. An unsigned CLI helper never calls `CLLocationManager`, so it never appears in Location Services and cannot be granted access. + +Before this amendment, `resolve_bssid` abstained when both identities were blank (to avoid merging unrelated networks), so every macOS scan was dropped and `--source wifi` produced nothing, with no error. + +**Change.** +1. `--scan-once` marks its single sample `"connected": true`. It is always the one connected link, so it cannot collide with another network. +2. A redacted sample marked `connected` gets a stable, locally administered synthetic BSSID derived from a reserved key plus the channel. The SSID stays empty; none is invented. Unmarked redacted lines still abstain. +3. `--source auto` that resolves to host WiFi now also binds the UDP receiver, and `wifi_task` yields while the source is a live `esp32`. ESP32 nodes that start after the 2 s boot probe still promote the source, matching the #1004 rule for the simulator. +4. A missing helper logs one clear error with the build command instead of a warning on every tick. + +**Measured (2026-10-01, this change):** `--source wifi` → source `wifi:unnamed`, sensing ticks advance. `--source auto` on a quiet port → `wifi` with `udp_receiver=true`; relaying real ADR-018 frames promoted the source to `esp32`, with all 5 nodes listed. + +**Still open:** real BSSID/SSID would need a signed `.app` helper with `NSLocationWhenInUseUsageDescription` that requests authorization. The helper still lives under `archive/v1` with no build step. + diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 180ae6f53..77e5633a3 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -4149,6 +4149,7 @@ async fn wifi_task(state: SharedState, tick_ms: u64) { // ADR-022 Phase 3: Multi-BSSID pipeline state (kept across ticks) let mut registry = BssidRegistry::new(32, 30); let mut pipeline = WindowsWifiPipeline::new(); + let mut reported_missing_helper = false; info!( "WiFi RSSI pipeline active (platform={}, tick={}ms, max_bssids=32)", @@ -4160,6 +4161,13 @@ async fn wifi_task(state: SharedState, tick_ms: u64) { interval.tick().await; seq += 1; + // In `auto`, a live ESP32 stream outranks host WiFi: once the UDP + // receiver promotes the source, stop overwriting it (resumes if the + // nodes age out to esp32:offline), mirroring the simulator. + if state.read().await.effective_source() == "esp32" { + continue; + } + // ── Step 1: Run multi-BSSID scan via spawn_blocking ────────── // Keep platform subprocess calls off the async runtime workers. #[cfg(target_os = "macos")] @@ -4201,7 +4209,17 @@ async fn wifi_task(state: SharedState, tick_ms: u64) { continue; } Ok(Err(e)) => { - warn!("WiFi scan error: {e}"); + if cfg!(target_os = "macos") && e.contains("failed to run mac_wifi helper") { + if !reported_missing_helper { + error!( + "--source wifi on macOS needs the mac_wifi helper on PATH: \ + swiftc -O archive/v1/src/sensing/mac_wifi.swift -o /mac_wifi ({e})" + ); + reported_missing_helper = true; + } + } else { + warn!("WiFi scan error: {e}"); + } #[cfg(not(target_os = "macos"))] windows_wifi_fallback_tick(&state, seq).await; continue; @@ -4218,7 +4236,8 @@ async fn wifi_task(state: SharedState, tick_ms: u64) { let ssid = observations .first() .map(|o| o.ssid.clone()) - .unwrap_or_else(|| "Unknown".into()); + .filter(|ssid| !ssid.trim().is_empty()) + .unwrap_or_else(|| "unnamed".into()); // ── Step 2: Feed observations into registry ────────────────── registry.update(&observations); @@ -4651,9 +4670,13 @@ fn plan_source(requested: &str, esp32_detected: bool, wifi_detected: bool) -> So run_wifi: false, } } else if wifi_detected { + // Host WiFi first, but keep the UDP receiver bound so ESP32 + // nodes that start after the 2 s boot probe still promote + // the source to esp32 (same rule as #1004). Without this, a + // Mac whose connected-link probe succeeds would ignore nodes. SourcePlan { initial_source: "wifi".to_string(), - bind_udp: false, + bind_udp: true, run_simulator: false, run_wifi: true, } @@ -4733,11 +4756,12 @@ mod issue_1004_source_plan_tests { assert_eq!(plan.initial_source, "esp32"); } + // Host WiFi must not shut out ESP32 nodes that start after the boot probe. #[test] - fn auto_with_wifi_detected_runs_wifi_no_udp() { + fn auto_with_wifi_detected_runs_wifi_and_still_binds_udp() { let plan = plan_source("auto", false, true); assert!(plan.run_wifi); - assert!(!plan.bind_udp); + assert!(plan.bind_udp, "auto+wifi must keep UDP bound so ESP32 can promote"); assert!(!plan.run_simulator); assert_eq!(plan.initial_source, "wifi"); } diff --git a/v2/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs b/v2/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs index 24b58ed67..0c3ddcd74 100644 --- a/v2/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs +++ b/v2/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs @@ -160,8 +160,11 @@ fn parse_json_line(line: &str, timestamp: Instant) -> Option { let channel_f = extract_number_field(line, "channel")?; let channel = channel_f as u8; + // `--scan-once` reports only the connected link and marks it so. + let connected = extract_bool_field(line, "connected").unwrap_or(false); + // Resolve BSSID: use real MAC if available, otherwise generate synthetic. - let bssid = resolve_bssid(&bssid_str, &ssid, channel)?; + let bssid = resolve_bssid(&bssid_str, &ssid, channel, connected)?; let band = BandType::from_channel(channel); @@ -186,9 +189,11 @@ fn parse_json_line(line: &str, timestamp: Instant) -> Option { /// Resolve a BSSID string to a [`BssidId`]. /// /// If the MAC is all-zeros (macOS redaction), generate a synthetic -/// locally-administered MAC from the SSID and channel. Abstain when the -/// real BSSID is unavailable and the SSID is blank. -fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option { +/// locally-administered MAC from the SSID and channel. When both are redacted +/// (no Location Services permission), keep the observation only if the helper +/// marked it as the connected link: there is exactly one, so it cannot collide +/// with another network. Otherwise abstain. +fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8, connected: bool) -> Option { // Try parsing the real BSSID first. if let Ok(id) = BssidId::parse(bssid_str) { // Check for the all-zeros redacted BSSID. @@ -200,7 +205,7 @@ fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option { // Without either identity, unrelated networks on the same channel would // collapse to one synthetic BSSID. Do not emit an observation in that case. if ssid.trim().is_empty() { - return None; + return connected.then(|| synthetic_bssid(REDACTED_CONNECTED_LINK, channel)); } // Generate synthetic BSSID from SSID and channel, take first 6 bytes, @@ -208,6 +213,10 @@ fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option { Some(synthetic_bssid(ssid, channel)) } +/// Hash key for the connected link when macOS redacts both SSID and BSSID. +/// The NUL prefix keeps it from matching any real SSID. +const REDACTED_CONNECTED_LINK: &str = "\u{0}redacted-connected-link"; + /// Generate a deterministic synthetic BSSID from SSID and channel. /// /// Uses a simple hash (FNV-1a-inspired) to avoid pulling in `sha2` crate. @@ -273,6 +282,20 @@ fn extract_string_field(json: &str, key: &str) -> Option { Some(after_quote[..end].to_owned()) } +/// Extract a boolean field value (`"key": true|false`) from a JSON object string. +fn extract_bool_field(json: &str, key: &str) -> Option { + let pattern = format!("\"{key}\""); + let key_pos = json.find(&pattern)?; + let after = json[key_pos + pattern.len()..].trim_start().strip_prefix(':')?.trim_start(); + if after.starts_with("true") { + Some(true) + } else if after.starts_with("false") { + Some(false) + } else { + None + } +} + /// Extract a numeric field value from a JSON object string. /// /// Looks for `"key": ` patterns. @@ -375,6 +398,42 @@ mod tests { } } + // macOS without Location Services: the helper's connected-link sample has + // a blank SSID and zero BSSID but real rssi/channel. Keep it (#H15). + #[test] + fn redacted_connected_link_is_kept() { + let output = r#"{"bssid":"00:00:00:00:00:00","channel":40,"connected":true,"noise":-94,"rssi":-57,"ssid":"","timestamp":1790888839.88,"tx_rate":576}"#; + let obs = parse_macos_scan_output(output).unwrap(); + assert_eq!(obs.len(), 1); + assert!((obs[0].rssi_dbm - (-57.0)).abs() < f64::EPSILON); + assert_eq!(obs[0].channel, 40); + assert!(obs[0].ssid.is_empty(), "never invent an SSID"); + assert_ne!(obs[0].bssid.0, [0; 6]); + assert_eq!(obs[0].bssid.0[0] & 0x03, 0x02, "locally administered unicast"); + // Stable across samples, distinct from any real SSID's synthetic id. + let again = parse_macos_scan_output(output).unwrap(); + assert_eq!(obs[0].bssid, again[0].bssid); + assert_ne!(obs[0].bssid, synthetic_bssid("", 40)); + } + + #[test] + fn redacted_line_not_marked_connected_still_abstains() { + for connected in ["", r#","connected":false"#] { + let output = format!( + r#"{{"ssid":"","bssid":"00:00:00:00:00:00","rssi":-65,"channel":36{connected}}}"# + ); + assert!(parse_macos_scan_output(&output).unwrap().is_empty()); + } + } + + #[test] + fn extract_bool_field_basic() { + assert_eq!(extract_bool_field(r#"{"connected":true}"#, "connected"), Some(true)); + assert_eq!(extract_bool_field(r#"{"connected" : false}"#, "connected"), Some(false)); + assert_eq!(extract_bool_field(r#"{"connected":1}"#, "connected"), None); + assert_eq!(extract_bool_field(r#"{"x":true}"#, "connected"), None); + } + #[test] fn real_bssid_without_ssid_is_preserved() { let output = r#"{"ssid":"","bssid":"aa:bb:cc:dd:ee:ff","rssi":-65,"noise":-88,"channel":36}"#;