diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bac6a5a..0a93625c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `CrossDomainEvaluator` — 6-metric evaluation protocol (MPJPE in-domain/cross-domain/few-shot/cross-hardware, domain gap ratio, adaptation speedup) - ADR-027: Cross-Environment Domain Generalization — 10 SOTA citations (PerceptAlign, X-Fi ICLR 2025, AM-FM, DGSense, CVPR 2024) - **Cross-platform RSSI adapters** — macOS CoreWLAN (`MacosCoreWlanScanner`) and Linux `iw` (`LinuxIwScanner`) Rust adapters with `#[cfg(target_os)]` gating -- macOS CoreWLAN tooling with canonical Swift helper (`tools/macos-wifi-scan/main.swift`) +- macOS CoreWLAN bridge tooling with canonical Swift helper (`tools/macos-wifi-scan/main.swift`) and explicit `macos-bridge` UDP source - macOS synthetic BSSID generation (SHA-256 with locally administered MACs) for Sonoma 14.4+ BSSID redaction - Linux `iw dev scan` parser with freq-to-channel conversion and `scan dump` (no-root) mode - ADR-025: macOS CoreWLAN WiFi Sensing (ORCA) diff --git a/README.md b/README.md index 212b4cac..4194143c 100644 --- a/README.md +++ b/README.md @@ -1614,6 +1614,10 @@ graph TB # Start with Windows WiFi RSSI ./target/release/sensing-server --source wifi +# Start with the experimental macOS bridge fallback +python3 scripts/macos_wifi_bridge.py --interval-ms 100 & +./target/release/sensing-server --source macos-bridge --tick-ms 100 + # Run vital sign benchmark ./target/release/sensing-server --benchmark @@ -1629,7 +1633,7 @@ graph TB | Flag | Description | |------|-------------| -| `--source` | Data source: `auto`, `wifi`, `esp32`, `simulate` | +| `--source` | Data source: `auto`, `wifi`, `esp32`, `simulate`, `macos-bridge` | | `--http-port` | HTTP port for UI and REST API (default: 8080) | | `--ws-port` | WebSocket port (default: 8765) | | `--udp-port` | UDP port for ESP32 CSI frames (default: 5005) | diff --git a/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md b/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md index 6d9ef363..6bf1fb52 100644 --- a/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md +++ b/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md @@ -245,6 +245,15 @@ All verification on Mac Mini (M2 Pro, macOS 26.3). | Unit: helper not found | `#[test]` with bad path | `WifiScanError::ProcessError` | | Integration: real scan | `cargo test` on Mac Mini | Live observations from CoreWLAN | +### 5.2.1 Bridge Fallback + +| Test | Command | Expected | +|------|---------|----------| +| Bridge CLI validation | `python3 scripts/macos_wifi_bridge.py --help` | Shows helper/host/port/interval arguments | +| Bridge syntax | `python3 -m py_compile scripts/macos_wifi_bridge.py` | Passes | +| Bridge startup order | `python3 scripts/macos_wifi_bridge.py --interval-ms 100 &` then `./target/release/sensing-server --source macos-bridge --tick-ms 100` | Server binds and labels source `wifi-bridge:macos` | +| Bridge payload rejection | Send ESP32 binary or malformed JSON | Server logs rejection and keeps waiting | + ### 5.3 End-to-End | Step | Command | Verify | @@ -262,6 +271,7 @@ All verification on Mac Mini (M2 Pro, macOS 26.3). - No compiled helper binaries are committed. - Public docs describe macOS as RSSI-only presence/coarse-motion sensing, not CSI parity. - Helper discovery order is documented as env override, repo-local build output, then `PATH`. +- `macos-bridge` stays explicit-only and is never auto-selected. - PR includes manual macOS QA evidence because CI is Linux-centric. ### 5.5 Cross-Platform Regression diff --git a/docs/user-guide.md b/docs/user-guide.md index 60c248d5..efcf731d 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -251,6 +251,10 @@ export RUVIEW_MAC_WIFI_HELPER="$PWD/rust-port/wifi-densepose-rs/target/tools/mac # Run native macOS Wi-Fi sensing ./target/release/sensing-server --source wifi --http-port 3000 --ws-port 3001 --tick-ms 500 + +# Experimental fallback bridge (explicit, never auto-selected) +python3 scripts/macos_wifi_bridge.py --interval-ms 100 & +./target/release/sensing-server --source macos-bridge --http-port 3000 --ws-port 3001 --tick-ms 100 ``` See [ADR-025](adr/ADR-025-macos-corewlan-wifi-sensing.md) for details. @@ -478,7 +482,7 @@ The Rust sensing server binary accepts the following flags: | Flag | Default | Description | |------|---------|-------------| -| `--source` | `auto` | Data source: `auto`, `simulate`, `wifi`, `esp32` | +| `--source` | `auto` | Data source: `auto`, `simulate`, `wifi`, `esp32`, `macos-bridge` | | `--http-port` | `8080` | HTTP port for REST API and UI | | `--ws-port` | `8765` | WebSocket port | | `--udp-port` | `5005` | UDP port for ESP32 CSI frames | @@ -507,6 +511,10 @@ The Rust sensing server binary accepts the following flags: # Windows WiFi RSSI ./target/release/sensing-server --source wifi --tick-ms 500 +# Experimental macOS bridge fallback +python3 scripts/macos_wifi_bridge.py --interval-ms 100 & +./target/release/sensing-server --source macos-bridge --tick-ms 100 + # Run benchmark ./target/release/sensing-server --benchmark diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs index 1e90ce40..8aeabd80 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs @@ -83,7 +83,7 @@ struct Args { #[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")] bind_addr: String, - /// Data source: auto, wifi, esp32, simulate + /// Data source: auto, wifi, esp32, simulate, macos-bridge #[arg(long, default_value = "auto")] source: String, @@ -148,12 +148,15 @@ struct Args { build_index: Option, } +const MACOS_BRIDGE_UDP_PORT: u16 = 5006; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RequestedSource { Auto, Wifi, Esp32, Simulate, + MacosBridge, } impl RequestedSource { @@ -163,8 +166,9 @@ impl RequestedSource { "wifi" => Ok(Self::Wifi), "esp32" => Ok(Self::Esp32), "simulate" | "simulated" => Ok(Self::Simulate), + "macos-bridge" => Ok(Self::MacosBridge), other => Err(format!( - "unsupported source '{other}'. Expected one of: auto, wifi, esp32, simulate" + "unsupported source '{other}'. Expected one of: auto, wifi, esp32, simulate, macos-bridge" )), } } @@ -175,6 +179,7 @@ enum ResolvedSource { Wifi, Esp32, Simulate, + MacosBridge, } impl ResolvedSource { @@ -183,6 +188,7 @@ impl ResolvedSource { Self::Wifi => "wifi", Self::Esp32 => "esp32", Self::Simulate => "simulate", + Self::MacosBridge => "macos-bridge", } } } @@ -194,6 +200,22 @@ enum PlatformFlavor { Other, } +#[derive(Debug, Deserialize)] +struct MacosBridgeRecord { + bridge_kind: String, + timestamp: f64, + interface: String, + ssid: String, + bssid: String, + bssid_synthetic: bool, + rssi: f64, + noise: f64, + channel: u8, + band: String, + tx_rate_mbps: f64, + is_connected: bool, +} + // ── Data types ─────────────────────────────────────────────────────────────── /// ADR-018 ESP32 CSI binary frame header (20 bytes) @@ -1248,6 +1270,14 @@ fn resolve_explicit_source( "`--source wifi` is only supported on Windows and macOS in this server".to_string(), ), }, + RequestedSource::MacosBridge => match platform { + PlatformFlavor::Macos => wifi_available + .then_some(ResolvedSource::MacosBridge) + .ok_or_else(|| { + "`--source macos-bridge` requires a working macOS helper and active Wi-Fi association".to_string() + }), + _ => Err("`--source macos-bridge` is only supported on macOS".to_string()), + }, RequestedSource::Auto => Err("internal error: auto source must be resolved separately".into()), } } @@ -1277,6 +1307,7 @@ async fn resolve_source(args: &Args) -> Result { PlatformFlavor::Other => {} }, ResolvedSource::Simulate => info!(" No hardware detected, using simulation"), + ResolvedSource::MacosBridge => {} } Ok(resolved) } @@ -1288,10 +1319,78 @@ async fn resolve_source(args: &Args) -> Result { }; resolve_explicit_source(requested, platform, wifi_available) } + RequestedSource::MacosBridge => { + if !matches!(platform, PlatformFlavor::Macos) { + return Err("`--source macos-bridge` is only supported on macOS".to_string()); + } + if !probe_macos_wifi().await { + return Err( + "`--source macos-bridge` requires a working macOS helper and active Wi-Fi association" + .to_string(), + ); + } + if !probe_macos_bridge().await { + return Err(format!( + "`--source macos-bridge` requires a local NDJSON bridge sender on udp://127.0.0.1:{MACOS_BRIDGE_UDP_PORT}" + )); + } + Ok(ResolvedSource::MacosBridge) + } other => resolve_explicit_source(other, platform, false), } } +fn parse_macos_bridge_datagram(buf: &[u8]) -> Result { + if parse_esp32_frame(buf).is_some() { + return Err("macos-bridge expects JSON datagrams, not ESP32 binary frames".to_string()); + } + + let text = std::str::from_utf8(buf) + .map_err(|e| format!("macos-bridge datagram must be UTF-8 JSON: {e}"))?; + serde_json::from_str::(text.trim()) + .map_err(|e| format!("invalid macos-bridge JSON payload: {e}")) +} + +fn macos_bridge_to_observation(record: MacosBridgeRecord) -> Result { + if record.bridge_kind != "connected_rssi" { + return Err(format!( + "field `bridge_kind` must equal 'connected_rssi'; got '{}'", + record.bridge_kind + )); + } + if record.interface.trim().is_empty() { + return Err("field `interface` must not be empty".to_string()); + } + if !record.is_connected { + return Err("field `is_connected` must be true for macos-bridge records".to_string()); + } + if record.channel == 0 { + return Err("field `channel` must be greater than 0".to_string()); + } + + let band = parse_band_label(&record.band, record.channel)?; + let bssid = BssidId::parse(&record.bssid) + .map_err(|_| format!("field `bssid` is not a valid MAC address: {}", record.bssid))?; + let signal_pct = ((record.rssi + 100.0) * 2.0).clamp(0.0, 100.0); + let _ = ( + record.timestamp, + record.bssid_synthetic, + record.noise, + record.tx_rate_mbps, + ); + + Ok(BssidObservation { + bssid, + rssi_dbm: record.rssi, + signal_pct, + channel: record.channel, + band, + radio_type: infer_wifi_radio_type(record.channel), + ssid: record.ssid, + timestamp: Instant::now(), + }) +} + async fn scan_with_port(scanner: Arc) -> Result, String> where T: WlanScanPort + 'static, @@ -1744,6 +1843,83 @@ async fn macos_wifi_task(_state: SharedState, _tick_ms: u64) { error!("macOS Wi-Fi task requested on a non-macOS build"); } +async fn macos_bridge_task(state: SharedState, tick_ms: u64) { + let addr = SocketAddr::from(([127, 0, 0, 1], MACOS_BRIDGE_UDP_PORT)); + let socket = match UdpSocket::bind(addr).await { + Ok(socket) => socket, + Err(e) => { + error!( + "Failed to bind macOS bridge UDP port {}: {e}", + MACOS_BRIDGE_UDP_PORT + ); + return; + } + }; + + let mut seq: u32 = 0; + let mut registry = BssidRegistry::new(8, 16); + let mut pipeline = WindowsWifiPipeline::new(); + let mut buf = vec![0u8; 4096]; + + info!( + "macOS bridge listener active on udp://127.0.0.1:{} (expected interval ≈ {} ms)", + MACOS_BRIDGE_UDP_PORT, tick_ms + ); + state.write().await.source = "wifi-bridge:macos".to_string(); + + loop { + let (len, peer) = + match tokio::time::timeout(Duration::from_secs(5), socket.recv_from(&mut buf)).await { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + warn!("macOS bridge UDP receive failed: {e}"); + continue; + } + Err(_) => { + warn!( + "macOS bridge listener is waiting for NDJSON packets on udp://127.0.0.1:{}", + MACOS_BRIDGE_UDP_PORT + ); + continue; + } + }; + + if !peer.ip().is_loopback() { + warn!("Ignoring non-loopback macOS bridge sender: {peer}"); + continue; + } + + let record = match parse_macos_bridge_datagram(&buf[..len]) { + Ok(record) => record, + Err(e) => { + warn!("Rejected macOS bridge payload from {peer}: {e}"); + continue; + } + }; + + let observation = match macos_bridge_to_observation(record) { + Ok(observation) => observation, + Err(e) => { + warn!("Rejected macOS bridge observation from {peer}: {e}"); + continue; + } + }; + + seq = seq.wrapping_add(1); + publish_multi_bssid_tick( + &state, + &mut registry, + &mut pipeline, + vec![observation], + seq, + tick_ms, + "wifi-bridge:macos", + Some("wifi-bridge:macos"), + ) + .await; + } +} + /// Probe if Windows WiFi is connected async fn probe_windows_wifi() -> bool { match tokio::process::Command::new("netsh") @@ -1771,6 +1947,22 @@ async fn probe_macos_wifi() -> bool { false } +async fn probe_macos_bridge() -> bool { + let addr = SocketAddr::from(([127, 0, 0, 1], MACOS_BRIDGE_UDP_PORT)); + match UdpSocket::bind(addr).await { + Ok(socket) => { + let mut buf = [0u8; 4096]; + match tokio::time::timeout(Duration::from_secs(2), socket.recv_from(&mut buf)).await { + Ok(Ok((len, peer))) if peer.ip().is_loopback() => { + parse_macos_bridge_datagram(&buf[..len]).is_ok() + } + _ => false, + } + } + Err(_) => false, + } +} + /// Probe if ESP32 is streaming on UDP port async fn probe_esp32(port: u16) -> bool { let addr = format!("0.0.0.0:{port}"); @@ -4119,6 +4311,9 @@ async fn main() { std::process::exit(1); } } + ResolvedSource::MacosBridge => { + tokio::spawn(macos_bridge_task(state.clone(), args.tick_ms)); + } ResolvedSource::Simulate => { tokio::spawn(simulated_data_task(state.clone(), args.tick_ms)); } @@ -4307,4 +4502,40 @@ mod tests { assert!(err.contains("only supported on Windows and macOS")); } + #[test] + fn bridge_parser_accepts_ndjson_payload() { + let payload = br#"{"bridge_kind":"connected_rssi","timestamp":1.0,"interface":"en0","ssid":"Lab","bssid":"aa:bb:cc:dd:ee:ff","bssid_synthetic":false,"rssi":-51.0,"noise":-92.0,"channel":44,"band":"5ghz","tx_rate_mbps":400.0,"is_connected":true}"#; + let record = parse_macos_bridge_datagram(payload).expect("bridge payload should parse"); + let obs = macos_bridge_to_observation(record).expect("bridge record should map"); + + assert_eq!(obs.ssid, "Lab"); + assert_eq!(obs.bssid.to_string(), "aa:bb:cc:dd:ee:ff"); + assert_eq!(obs.channel, 44); + assert_eq!(obs.band, BandType::Band5GHz); + } + + #[test] + fn bridge_parser_rejects_esp32_frames() { + let mut frame = vec![0u8; 22]; + frame[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes()); + frame[4] = 1; + frame[5] = 1; + frame[6] = 1; + frame[8..10].copy_from_slice(&2437u16.to_le_bytes()); + frame[10..14].copy_from_slice(&1u32.to_le_bytes()); + frame[20] = 1; + frame[21] = 1; + + let err = parse_macos_bridge_datagram(&frame).expect_err("ESP32 binary should be rejected"); + assert!(err.contains("not ESP32 binary frames")); + } + + #[test] + fn bridge_parser_rejects_disconnected_records() { + let payload = br#"{"bridge_kind":"connected_rssi","timestamp":1.0,"interface":"en0","ssid":"Lab","bssid":"aa:bb:cc:dd:ee:ff","bssid_synthetic":false,"rssi":-51.0,"noise":-92.0,"channel":44,"band":"5ghz","tx_rate_mbps":400.0,"is_connected":false}"#; + let record = parse_macos_bridge_datagram(payload).expect("bridge payload should parse"); + let err = macos_bridge_to_observation(record) + .expect_err("disconnected bridge record should be rejected"); + assert!(err.contains("is_connected")); + } } diff --git a/scripts/macos_wifi_bridge.py b/scripts/macos_wifi_bridge.py new file mode 100755 index 00000000..97a226f1 --- /dev/null +++ b/scripts/macos_wifi_bridge.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Forward the canonical macOS CoreWLAN helper stream to the explicit bridge UDP source.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +from pathlib import Path + +BRIDGE_KIND = "connected_rssi" +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 5006 +DEFAULT_INTERVAL_MS = 100 +HELPER_ENV_VAR = "RUVIEW_MAC_WIFI_HELPER" +REPO_HELPER_REL = Path("rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan") + +REQUIRED_FIELDS = { + "timestamp", + "interface", + "ssid", + "bssid", + "bssid_synthetic", + "rssi", + "noise", + "channel", + "band", + "tx_rate_mbps", + "is_connected", +} + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def positive_int(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("value must be a positive integer") + return parsed + + +def resolve_helper(explicit: str | None) -> str: + if explicit: + return explicit + + env_override = os.environ.get(HELPER_ENV_VAR) + if env_override: + return env_override + + repo_helper = repo_root() / REPO_HELPER_REL + if repo_helper.is_file(): + return str(repo_helper) + + return "macos-wifi-scan" + + +def validate_record(record: object) -> dict[str, object]: + if not isinstance(record, dict): + raise ValueError("helper output must be a JSON object") + + missing = sorted(REQUIRED_FIELDS.difference(record)) + if missing: + raise ValueError(f"helper output missing required fields: {', '.join(missing)}") + + if not record.get("is_connected", False): + raise ValueError("helper stream record is not marked as connected") + + bridged = dict(record) + bridged["bridge_kind"] = BRIDGE_KIND + return bridged + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Forward macOS CoreWLAN helper records to the explicit RuView macOS bridge source." + ) + parser.add_argument("--helper", help="Path to the macOS Wi-Fi helper binary") + parser.add_argument("--host", default=DEFAULT_HOST, help="Bridge receiver host (default: 127.0.0.1)") + parser.add_argument( + "--port", + type=positive_int, + default=DEFAULT_PORT, + help="Bridge receiver UDP port (default: 5006)", + ) + parser.add_argument( + "--interval-ms", + type=positive_int, + default=DEFAULT_INTERVAL_MS, + help="Polling interval passed to the helper stream mode (default: 100)", + ) + args = parser.parse_args() + + helper = resolve_helper(args.helper) + command = [helper, "--stream", "--interval-ms", str(args.interval_ms)] + + try: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=sys.stderr, + text=True, + bufsize=1, + ) + except OSError as exc: + print( + f"failed to start macOS Wi-Fi helper '{helper}': {exc}. " + f"Build it with scripts/build-mac-wifi.sh or set {HELPER_ENV_VAR}.", + file=sys.stderr, + ) + return 1 + + destination = (args.host, args.port) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + try: + assert process.stdout is not None + for line in process.stdout: + line = line.strip() + if not line: + continue + + try: + record = validate_record(json.loads(line)) + except (json.JSONDecodeError, ValueError) as exc: + print(f"skipping helper record: {exc}", file=sys.stderr) + continue + + payload = json.dumps(record, separators=(",", ":")).encode("utf-8") + sock.sendto(payload, destination) + except KeyboardInterrupt: + print("stopping macOS Wi-Fi bridge", file=sys.stderr) + finally: + sock.close() + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + + return process.returncode or 0 + + +if __name__ == "__main__": + raise SystemExit(main())