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 <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-02 03:11:08 -04:00
1 parent 03a897938f
commit f8ab204711
4 files changed
+117 -10

No files matched your search

+4
View File
@@ -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,
@@ -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.
@@ -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 <dir-on-PATH>/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");
}
@@ -160,8 +160,11 @@ fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
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<BssidObservation> {
/// 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<BssidId> {
/// 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<BssidId> {
// 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<BssidId> {
// 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<BssidId> {
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<String> {
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<bool> {
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": <number>` 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}"#;