mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
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:
1 parent
03a897938f
commit
f8ab204711
4 files changed
+117
-10
No files matched your search
@@ -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}"#;
|
||||
|
||||
Reference in new issue
Block a user