feat(macos): add explicit wifi bridge source

This commit is contained in:
AIFlow_ML 2026-03-08 16:54:59 +01:00
parent 9fecc029bc
commit 9b5db0bead
6 changed files with 407 additions and 5 deletions

View File

@ -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 <iface> scan` parser with freq-to-channel conversion and `scan dump` (no-root) mode
- ADR-025: macOS CoreWLAN WiFi Sensing (ORCA)

View File

@ -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) |

View File

@ -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

View File

@ -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

View File

@ -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<String>,
}
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<ResolvedSource, String> {
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<ResolvedSource, String> {
};
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<MacosBridgeRecord, String> {
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::<MacosBridgeRecord>(text.trim())
.map_err(|e| format!("invalid macos-bridge JSON payload: {e}"))
}
fn macos_bridge_to_observation(record: MacosBridgeRecord) -> Result<BssidObservation, String> {
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<T>(scanner: Arc<T>) -> Result<Vec<BssidObservation>, 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"));
}
}

149
scripts/macos_wifi_bridge.py Executable file
View File

@ -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())