From 9fecc029bcbeebd44d164d2d837d6148369a78bc Mon Sep 17 00:00:00 2001 From: AIFlow_ML Date: Sun, 8 Mar 2026 16:51:14 +0100 Subject: [PATCH 1/2] feat(macos): add native CoreWLAN wifi source --- CHANGELOG.md | 4 +- README.md | 2 +- .../ADR-025-macos-corewlan-wifi-sensing.md | 66 +- docs/user-guide.md | 15 +- rust-port/wifi-densepose-rs/Cargo.lock | 2 + .../wifi-densepose-sensing-server/src/main.rs | 1427 ++++++++++++----- .../crates/wifi-densepose-wifiscan/Cargo.toml | 4 + .../src/adapter/macos_scanner.rs | 524 +++--- .../src/adapter/mod.rs | 17 +- .../crates/wifi-densepose-wifiscan/src/lib.rs | 13 +- scripts/build-mac-wifi.sh | 23 + tools/macos-wifi-scan/main.swift | 373 +++++ 12 files changed, 1711 insertions(+), 759 deletions(-) create mode 100755 scripts/build-mac-wifi.sh create mode 100644 tools/macos-wifi-scan/main.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f59d53a19..1bac6a5a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,8 @@ 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 Python sensing adapter with Swift helper (`mac_wifi.swift`) -- macOS synthetic BSSID generation (FNV-1a hash) for Sonoma 14.4+ BSSID redaction +- macOS CoreWLAN tooling with canonical Swift helper (`tools/macos-wifi-scan/main.swift`) +- 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 2169ea9d20..212b4cacd0 100644 --- a/README.md +++ b/README.md @@ -1674,7 +1674,7 @@ WebSocket: `ws://localhost:3001/ws/sensing` (real-time sensing + vital signs) | Intel 5300 | Firmware mod | ~$15 | Linux `iwl-csi` | | Atheros AR9580 | ath9k patch | ~$20 | Linux only | | Any Windows WiFi | RSSI only | $0 | [Tutorial #36](https://github.com/ruvnet/RuView/issues/36) | -| Any macOS WiFi | RSSI only (CoreWLAN) | $0 | [ADR-025](docs/adr/ADR-025-macos-corewlan-wifi-sensing.md) | +| Any macOS WiFi | RSSI only (CoreWLAN, breathing experimental) | $0 | [ADR-025](docs/adr/ADR-025-macos-corewlan-wifi-sensing.md) | | Any Linux WiFi | RSSI only (`iw`) | $0 | Requires `iw` + `CAP_NET_ADMIN` | diff --git a/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md b/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md index 491ecea68b..6d9ef3632b 100644 --- a/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md +++ b/docs/adr/ADR-025-macos-corewlan-wifi-sensing.md @@ -70,8 +70,8 @@ Implement a **macOS CoreWLAN sensing adapter** as a Swift helper binary + Rust a 1. **Subprocess isolation** — Swift binary is a standalone tool, built and versioned independently of the Rust workspace. 2. **Same domain types** — macOS adapter produces `Vec`, identical to the Windows path. All downstream processing reuses as-is. -3. **SSID:channel as synthetic BSSID** — When real BSSIDs are redacted (no Location Services), `sha256(ssid + channel)[:12]` generates a stable pseudo-BSSID. Documented limitation: same-SSID same-channel APs collapse to one observation. -4. **`#[cfg(target_os = "macos")]` gating** — macOS-specific code compiles only on macOS. Windows and Linux builds are unaffected. +3. **Synthetic locally administered BSSID** — When real BSSIDs are redacted (no Location Services), the helper derives a stable synthetic MAC from `interface + ssid + channel`, marks `bssid_synthetic=true`, and keeps the observation usable by the Rust pipeline. Hidden SSIDs use a fixed placeholder in the hash input. +4. **Runtime gating with cross-platform tests** — macOS helper execution remains runtime-gated to macOS, but the scanner/parser code compiles cross-platform so Linux CI can exercise contract and source-selection tests. 5. **Graceful degradation** — If the Swift helper is not found or fails, `--source auto` skips macOS WiFi and falls back to simulated mode with a clear warning. --- @@ -107,53 +107,40 @@ Implement a **macOS CoreWLAN sensing adapter** as a Swift helper binary + Rust a ### 3.2 Swift Helper Binary -**File:** `rust-port/wifi-densepose-rs/tools/macos-wifi-scan/main.swift` +**File:** `tools/macos-wifi-scan/main.swift` ```swift // Modes: -// (no args) → Full scan, output JSON array to stdout -// --probe → Quick availability check, output {"available": true/false} -// --connected → Connected network info only +// --probe → One NDJSON line for the current association, exits immediately +// --scan-once → One NDJSON line per visible AP +// --connected → One NDJSON line for the current association +// --stream --interval-ms → Repeated connected-AP NDJSON records // -// Output schema (scan mode): -// [ -// { -// "ssid": "MyNetwork", -// "rssi": -52, -// "noise": -90, -// "channel": 36, -// "band": "5GHz", -// "phy_mode": "802.11ax", -// "bssid": "aa:bb:cc:dd:ee:ff" | null, -// "security": "wpa2_personal" -// } -// ] +// Output schema (scan/connected/stream): +// {"timestamp":1710000000.0,"interface":"en0","ssid":"MyNetwork","bssid":"aa:bb:cc:dd:ee:ff","bssid_synthetic":false,"rssi":-52,"noise":-90,"channel":36,"band":"5ghz","tx_rate_mbps":866.7,"is_connected":true} ``` **Build:** ```bash # Requires Xcode Command Line Tools (xcode-select --install) -cd tools/macos-wifi-scan -swiftc -framework CoreWLAN -framework Foundation -O -o macos-wifi-scan main.swift +./scripts/build-mac-wifi.sh ``` -**Build script:** `tools/macos-wifi-scan/build.sh` +**Build script:** `scripts/build-mac-wifi.sh` ### 3.3 Rust Adapter **File:** `crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs` ```rust -// #[cfg(target_os = "macos")] - pub struct MacosCoreWlanScanner { - helper_path: PathBuf, // Resolved at construction: $PATH or sibling of server binary + helper_path: PathBuf, // Resolved from env override, repo-local build output, then PATH } impl MacosCoreWlanScanner { - pub fn new() -> Result // Finds helper or errors - pub fn probe() -> bool // Runs --probe, returns availability + pub fn new() -> Self // Resolves helper path at construction + pub fn probe_sync(&self) -> Result<(), WifiScanError> pub fn scan_sync(&self) -> Result, WifiScanError> pub fn connected_sync(&self) -> Result, WifiScanError> } @@ -216,7 +203,7 @@ The existing 8-stage `WindowsWifiPipeline` (ADR-022) operates entirely on `Bssid | File | Purpose | Lines (est.) | |------|---------|-------------| | `tools/macos-wifi-scan/main.swift` | CoreWLAN scanner, JSON output | ~120 | -| `tools/macos-wifi-scan/build.sh` | Build script (`swiftc` invocation) | ~15 | +| `scripts/build-mac-wifi.sh` | Build script (`swiftc` invocation) | ~15 | | `crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs` | Rust adapter: spawn helper, parse JSON, produce `BssidObservation` | ~200 | ### 4.2 Modified Files @@ -231,7 +218,7 @@ The existing 8-stage `WindowsWifiPipeline` (ADR-022) operates entirely on `Bssid - `std::process::Command` — subprocess spawning (stdlib) - `serde_json` — JSON parsing (already in workspace) -- No changes to `Cargo.toml` +- `serde_json` in `wifi-densepose-wifiscan` for typed helper parsing --- @@ -243,18 +230,18 @@ All verification on Mac Mini (M2 Pro, macOS 26.3). | Test | Command | Expected | |------|---------|----------| -| Build | `cd tools/macos-wifi-scan && ./build.sh` | Produces `macos-wifi-scan` binary | -| Probe | `./macos-wifi-scan --probe` | `{"available": true}` | -| Scan | `./macos-wifi-scan` | JSON array with real SSIDs, RSSI in dBm, channels | -| Connected | `./macos-wifi-scan --connected` | Single JSON object for connected network | -| No WiFi | Disable WiFi → `./macos-wifi-scan` | `{"available": false}` or empty array | +| Build | `./scripts/build-mac-wifi.sh` | Produces `rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan` | +| Probe | `./rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan --probe` | One status JSON line (`ok`, `interface`, `message`) or non-zero with clear stderr if Wi-Fi is unavailable | +| Scan | `./rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan --scan-once` | NDJSON records with SSID/RSSI/channel/band | +| Connected | `./rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan --connected` | Single NDJSON object for connected network | +| No WiFi | Disable WiFi → `... --probe` | Non-zero exit with clear stderr | ### 5.2 Rust Adapter | Test | Method | Expected | |------|--------|----------| | Unit: JSON parsing | `#[test]` with fixture JSON | Correct `BssidObservation` values | -| Unit: synthetic BSSID | `#[test]` with nil bssid input | Stable `sha256(ssid:channel)[:12]` | +| Unit: helper discovery | `#[test]` with env override/repo-local/PATH fixtures | Resolution order matches contract | | Unit: helper not found | `#[test]` with bad path | `WifiScanError::ProcessError` | | Integration: real scan | `cargo test` on Mac Mini | Live observations from CoreWLAN | @@ -270,7 +257,14 @@ All verification on Mac Mini (M2 Pro, macOS 26.3). | 6 | Open UI at `http://localhost:8080` | Signal field updates with real RSSI variation | | 7 | `--source auto` | Auto-detects macOS WiFi, does not fall back to simulated | -### 5.4 Cross-Platform Regression +### 5.4 Review Checklist + +- 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`. +- PR includes manual macOS QA evidence because CI is Linux-centric. + +### 5.5 Cross-Platform Regression | Platform | Build | Expected | |----------|-------|----------| diff --git a/docs/user-guide.md b/docs/user-guide.md index 30c6e7b847..60c248d54a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -238,14 +238,19 @@ docker run --network host ruvnet/wifi-densepose:latest --source wifi --tick-ms 5 ### macOS WiFi (RSSI Only) -Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works. +Uses CoreWLAN via the canonical Swift helper built from `tools/macos-wifi-scan/main.swift`. macOS Sonoma 14.4+ redacts real BSSIDs; the helper emits deterministic synthetic MACs so the multi-BSSID pipeline still works. + +Native macOS mode is RSSI/scan-based sensing for presence and coarse motion. Breathing estimates are experimental because CoreWLAN scan rates are much slower than ESP32 CSI. This mode does not provide CSI parity and should not be described as pose-grade sensing. ```bash -# Compile the Swift helper (once) -swiftc -O v1/src/sensing/mac_wifi.swift -o mac_wifi +# Build the Swift helper (once, on macOS) +./scripts/build-mac-wifi.sh + +# Optional: point the server at a custom helper location +export RUVIEW_MAC_WIFI_HELPER="$PWD/rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan" -# Run natively -./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500 +# Run native macOS Wi-Fi sensing +./target/release/sensing-server --source wifi --http-port 3000 --ws-port 3001 --tick-ms 500 ``` See [ADR-025](adr/ADR-025-macos-corewlan-wifi-sensing.md) for details. diff --git a/rust-port/wifi-densepose-rs/Cargo.lock b/rust-port/wifi-densepose-rs/Cargo.lock index ff83c0c1a1..e1a7a5d00c 100644 --- a/rust-port/wifi-densepose-rs/Cargo.lock +++ b/rust-port/wifi-densepose-rs/Cargo.lock @@ -4777,6 +4777,8 @@ name = "wifi-densepose-wifiscan" version = "0.3.0" dependencies = [ "serde", + "serde_json", + "tempfile", "tokio", "tracing", ] 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 7497c95a05..1e90ce4026 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 @@ -14,19 +14,18 @@ mod rvf_pipeline; mod vital_signs; // Training pipeline modules (exposed via lib.rs) -use wifi_densepose_sensing_server::{graph_transformer, trainer, dataset, embedding}; +use wifi_densepose_sensing_server::{dataset, embedding, graph_transformer, trainer}; use std::collections::VecDeque; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, - Path, - State, + Path, State, }, response::{Html, IntoResponse, Json}, routing::{delete, get, post}, @@ -34,13 +33,13 @@ use axum::{ }; use clap::Parser; +use axum::http::HeaderValue; use serde::{Deserialize, Serialize}; use tokio::net::UdpSocket; use tokio::sync::{broadcast, RwLock}; use tower_http::services::ServeDir; use tower_http::set_header::SetResponseHeaderLayer; -use axum::http::HeaderValue; -use tracing::{info, warn, debug, error}; +use tracing::{debug, error, info, warn}; use rvf_container::{RvfBuilder, RvfContainerInfo, RvfReader, VitalSignConfig}; use rvf_pipeline::ProgressiveLoader; @@ -48,9 +47,12 @@ use vital_signs::{VitalSignDetector, VitalSigns}; // ADR-022 Phase 3: Multi-BSSID pipeline integration use wifi_densepose_wifiscan::{ - BssidRegistry, WindowsWifiPipeline, + BandType, BssidId, BssidObservation, BssidRegistry, RadioType, WindowsWifiPipeline, + WlanApiScanner, WlanScanPort, }; -use wifi_densepose_wifiscan::parse_netsh_output as parse_netsh_bssid_output; + +#[cfg(target_os = "macos")] +use wifi_densepose_wifiscan::MacosCoreWlanScanner; // ── CLI ────────────────────────────────────────────────────────────────────── @@ -146,6 +148,52 @@ struct Args { build_index: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RequestedSource { + Auto, + Wifi, + Esp32, + Simulate, +} + +impl RequestedSource { + fn parse(raw: &str) -> Result { + match raw { + "auto" => Ok(Self::Auto), + "wifi" => Ok(Self::Wifi), + "esp32" => Ok(Self::Esp32), + "simulate" | "simulated" => Ok(Self::Simulate), + other => Err(format!( + "unsupported source '{other}'. Expected one of: auto, wifi, esp32, simulate" + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResolvedSource { + Wifi, + Esp32, + Simulate, +} + +impl ResolvedSource { + fn as_str(self) -> &'static str { + match self { + Self::Wifi => "wifi", + Self::Esp32 => "esp32", + Self::Simulate => "simulate", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PlatformFlavor { + Macos, + Windows, + Other, +} + // ── Data types ─────────────────────────────────────────────────────────────── /// ADR-018 ESP32 CSI binary frame header (20 bytes) @@ -460,7 +508,10 @@ fn parse_wasm_output(buf: &[u8]) -> Option { } let event_type = buf[offset]; let value = f32::from_le_bytes([ - buf[offset + 1], buf[offset + 2], buf[offset + 3], buf[offset + 4], + buf[offset + 1], + buf[offset + 2], + buf[offset + 3], + buf[offset + 4], ]); events.push(WasmEvent { event_type, value }); offset += 5; @@ -604,7 +655,8 @@ fn generate_signal_field( let dx = x as f64 - center; let dz = z as f64 - center; let dist = (dx * dx + dz * dz).sqrt(); - let ring_val = 0.08 * (-(dist - ring_r).powi(2) / (2.0 * ring_width * ring_width)).exp(); + let ring_val = + 0.08 * (-(dist - ring_r).powi(2) / (2.0 * ring_width * ring_width)).exp(); values[z * grid + x] += ring_val; } } @@ -612,7 +664,11 @@ fn generate_signal_field( // Clamp and normalise to [0, 1]. let field_max = values.iter().cloned().fold(0.0f64, f64::max); - let scale = if field_max > 1e-9 { 1.0 / field_max } else { 1.0 }; + let scale = if field_max > 1e-9 { + 1.0 / field_max + } else { + 1.0 + }; for v in &mut values { *v = (*v * scale).clamp(0.0, 1.0); } @@ -643,9 +699,14 @@ fn estimate_breathing_rate_hz(frame_history: &VecDeque>, sample_rate_hz } // Build scalar time series: mean amplitude per frame. - let series: Vec = frame_history.iter() + let series: Vec = frame_history + .iter() .map(|amps| { - if amps.is_empty() { 0.0 } else { amps.iter().sum::() / amps.len() as f64 } + if amps.is_empty() { + 0.0 + } else { + amps.iter().sum::() / amps.len() as f64 + } }) .collect(); @@ -764,9 +825,12 @@ fn extract_features_from_frame( let mean_rssi = frame.rssi as f64; // ── Intra-frame subcarrier variance (spatial spread across subcarriers) ── - let intra_variance: f64 = frame.amplitudes.iter() + let intra_variance: f64 = frame + .amplitudes + .iter() .map(|a| (a - mean_amp).powi(2)) - .sum::() / n; + .sum::() + / n; // ── Temporal (sliding-window) per-subcarrier variance ── let sub_variances = compute_subcarrier_variances(frame_history, n_sub); @@ -785,24 +849,30 @@ fn extract_features_from_frame( // ── Motion band power (upper half of subcarriers, high spatial frequency) ── let half = frame.amplitudes.len() / 2; let motion_band_power = if half > 0 { - frame.amplitudes[half..].iter() + frame.amplitudes[half..] + .iter() .map(|a| (a - mean_amp).powi(2)) - .sum::() / (frame.amplitudes.len() - half) as f64 + .sum::() + / (frame.amplitudes.len() - half) as f64 } else { 0.0 }; // ── Breathing band power (lower half of subcarriers, low spatial frequency) ── let breathing_band_power = if half > 0 { - frame.amplitudes[..half].iter() + frame.amplitudes[..half] + .iter() .map(|a| (a - mean_amp).powi(2)) - .sum::() / half as f64 + .sum::() + / half as f64 } else { 0.0 }; // ── Dominant frequency via peak subcarrier index ── - let peak_idx = frame.amplitudes.iter() + let peak_idx = frame + .amplitudes + .iter() .enumerate() .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(i, _)| i) @@ -811,7 +881,9 @@ fn extract_features_from_frame( // ── Change point detection (threshold-crossing count in current frame) ── let threshold = mean_amp * 1.2; - let change_points = frame.amplitudes.windows(2) + let change_points = frame + .amplitudes + .windows(2) .filter(|w| (w[0] < threshold) != (w[1] < threshold)) .count(); @@ -823,7 +895,8 @@ fn extract_features_from_frame( if n_cmp > 0 { let diff_energy: f64 = (0..n_cmp) .map(|k| (frame.amplitudes[k] - prev_frame[k]).powi(2)) - .sum::() / n_cmp as f64; + .sum::() + / n_cmp as f64; // Normalise by mean squared amplitude to get a dimensionless ratio. let ref_energy = mean_amp * mean_amp + 1e-9; (diff_energy / ref_energy).sqrt().clamp(0.0, 1.0) @@ -832,7 +905,9 @@ fn extract_features_from_frame( } } else { // No history yet — fall back to intra-frame variance-based estimate. - (intra_variance / (mean_amp * mean_amp + 1e-9)).sqrt().clamp(0.0, 1.0) + (intra_variance / (mean_amp * mean_amp + 1e-9)) + .sqrt() + .clamp(0.0, 1.0) }; // Blend temporal motion with variance-based motion for robustness. @@ -840,14 +915,19 @@ fn extract_features_from_frame( let variance_motion = (temporal_variance / 10.0).clamp(0.0, 1.0); let mbp_motion = (motion_band_power / 25.0).clamp(0.0, 1.0); let cp_motion = (change_points as f64 / 15.0).clamp(0.0, 1.0); - let motion_score = (temporal_motion_score * 0.4 + variance_motion * 0.2 + mbp_motion * 0.25 + cp_motion * 0.15).clamp(0.0, 1.0); + let motion_score = (temporal_motion_score * 0.4 + + variance_motion * 0.2 + + mbp_motion * 0.25 + + cp_motion * 0.15) + .clamp(0.0, 1.0); // ── Signal quality metric ── // Based on estimated SNR (RSSI relative to noise floor) and subcarrier consistency. let snr_db = (frame.rssi as f64 - frame.noise_floor as f64).max(0.0); let snr_quality = (snr_db / 40.0).clamp(0.0, 1.0); // 40 dB → quality = 1.0 - // Penalise quality when temporal variance is very high (unstable signal). - let stability = (1.0 - (temporal_variance / (mean_amp * mean_amp + 1e-9)).clamp(0.0, 1.0)).max(0.0); + // Penalise quality when temporal variance is very high (unstable signal). + let stability = + (1.0 - (temporal_variance / (mean_amp * mean_amp + 1e-9)).clamp(0.0, 1.0)).max(0.0); let signal_quality = (snr_quality * 0.6 + stability * 0.4).clamp(0.0, 1.0); // ── Breathing rate estimation ── @@ -871,15 +951,26 @@ fn extract_features_from_frame( confidence: (0.4 + signal_quality * 0.3 + motion_score * 0.3).clamp(0.0, 1.0), }; - (features, raw_classification, breathing_rate_hz, sub_variances, motion_score) + ( + features, + raw_classification, + breathing_rate_hz, + sub_variances, + motion_score, + ) } /// Simple threshold classification (no smoothing) — used as the "raw" input. fn raw_classify(score: f64) -> String { - if score > 0.25 { "active".into() } - else if score > 0.12 { "present_moving".into() } - else if score > 0.04 { "present_still".into() } - else { "absent".into() } + if score > 0.25 { + "active".into() + } else if score > 0.12 { + "present_moving".into() + } else if score > 0.04 { + "present_still".into() + } else { + "absent".into() + } } /// Debounce frames required before state transition (at ~10 FPS = ~0.4s). @@ -902,16 +993,16 @@ fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo, // During warm-up, aggressively learn the baseline. state.baseline_motion = state.baseline_motion * 0.9 + raw_motion * 0.1; } else if raw_motion < state.smoothed_motion + 0.05 { - state.baseline_motion = state.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) - + raw_motion * BASELINE_EMA_ALPHA; + state.baseline_motion = + state.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA; } // 2. Subtract baseline and clamp. let adjusted = (raw_motion - state.baseline_motion * 0.7).max(0.0); // 3. EMA smooth the adjusted score. - state.smoothed_motion = state.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) - + adjusted * MOTION_EMA_ALPHA; + state.smoothed_motion = + state.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA; let sm = state.smoothed_motion; // 4. Classify from smoothed score. @@ -943,10 +1034,16 @@ fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo, /// If an adaptive model is loaded, override the classification with the /// model's prediction. Uses the full 15-feature vector for higher accuracy. -fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classification: &mut ClassificationInfo) { +fn adaptive_override( + state: &AppStateInner, + features: &FeatureInfo, + classification: &mut ClassificationInfo, +) { if let Some(ref model) = state.adaptive_model { // Get current frame amplitudes from the latest history entry. - let amps = state.frame_history.back() + let amps = state + .frame_history + .back() .map(|v| v.as_slice()) .unwrap_or(&[]); let feat_arr = adaptive_classifier::features_from_runtime( @@ -995,11 +1092,15 @@ fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns { // Push into buffer (only non-outlier values) if hr_ok && raw_hr > 0.0 { state.hr_buffer.push_back(raw_hr); - if state.hr_buffer.len() > VITAL_MEDIAN_WINDOW { state.hr_buffer.pop_front(); } + if state.hr_buffer.len() > VITAL_MEDIAN_WINDOW { + state.hr_buffer.pop_front(); + } } if br_ok && raw_br > 0.0 { state.br_buffer.push_back(raw_br); - if state.br_buffer.len() > VITAL_MEDIAN_WINDOW { state.br_buffer.pop_front(); } + if state.br_buffer.len() > VITAL_MEDIAN_WINDOW { + state.br_buffer.pop_front(); + } } // Compute trimmed mean: drop top/bottom 25% then average the middle 50%. @@ -1014,8 +1115,8 @@ fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns { if state.smoothed_hr < 1.0 { state.smoothed_hr = trimmed_hr; } else if (trimmed_hr - state.smoothed_hr).abs() > HR_DEAD_BAND { - state.smoothed_hr = state.smoothed_hr * (1.0 - VITAL_EMA_ALPHA) - + trimmed_hr * VITAL_EMA_ALPHA; + state.smoothed_hr = + state.smoothed_hr * (1.0 - VITAL_EMA_ALPHA) + trimmed_hr * VITAL_EMA_ALPHA; } // else: within dead-band, hold current value } @@ -1023,8 +1124,8 @@ fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns { if state.smoothed_br < 1.0 { state.smoothed_br = trimmed_br; } else if (trimmed_br - state.smoothed_br).abs() > BR_DEAD_BAND { - state.smoothed_br = state.smoothed_br * (1.0 - VITAL_EMA_ALPHA) - + trimmed_br * VITAL_EMA_ALPHA; + state.smoothed_br = + state.smoothed_br * (1.0 - VITAL_EMA_ALPHA) + trimmed_br * VITAL_EMA_ALPHA; } } @@ -1033,8 +1134,16 @@ fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns { state.smoothed_br_conf = state.smoothed_br_conf * 0.92 + raw.breathing_confidence * 0.08; VitalSigns { - breathing_rate_bpm: if state.smoothed_br > 1.0 { Some(state.smoothed_br) } else { None }, - heart_rate_bpm: if state.smoothed_hr > 1.0 { Some(state.smoothed_hr) } else { None }, + breathing_rate_bpm: if state.smoothed_br > 1.0 { + Some(state.smoothed_br) + } else { + None + }, + heart_rate_bpm: if state.smoothed_hr > 1.0 { + Some(state.smoothed_hr) + } else { + None + }, breathing_confidence: state.smoothed_br_conf, heartbeat_confidence: state.smoothed_hr_conf, signal_quality: raw.signal_quality, @@ -1044,7 +1153,9 @@ fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns { /// Trimmed mean: sort, drop top/bottom 25%, average the middle 50%. /// More robust than median (uses more data) and less noisy than raw mean. fn trimmed_mean(buf: &VecDeque) -> f64 { - if buf.is_empty() { return 0.0; } + if buf.is_empty() { + return 0.0; + } let mut sorted: Vec = buf.iter().copied().collect(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let n = sorted.len(); @@ -1057,6 +1168,148 @@ fn trimmed_mean(buf: &VecDeque) -> f64 { } } +fn current_platform() -> PlatformFlavor { + #[cfg(target_os = "macos")] + { + return PlatformFlavor::Macos; + } + #[cfg(target_os = "windows")] + { + return PlatformFlavor::Windows; + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + PlatformFlavor::Other + } +} + +fn infer_wifi_radio_type(channel: u8) -> RadioType { + match channel { + 1..=14 => RadioType::N, + 36..=177 => RadioType::Ac, + _ => RadioType::Ax, + } +} + +fn parse_band_label(band: &str, channel: u8) -> Result { + let normalized = band.trim().to_ascii_lowercase(); + match normalized.as_str() { + "2.4ghz" | "2.4 ghz" | "2ghz" | "2 ghz" | "2.4" => Ok(BandType::Band2_4GHz), + "5ghz" | "5 ghz" | "5" => Ok(BandType::Band5GHz), + "6ghz" | "6 ghz" | "6" => Ok(BandType::Band6GHz), + "" => Ok(BandType::from_channel(channel)), + _ => Err(format!( + "field `band` must be one of 2.4GHz, 5GHz, or 6GHz; got '{band}'" + )), + } +} + +fn derive_wifi_source_label(observations: &[BssidObservation], fallback: &str) -> String { + observations + .iter() + .find_map(|obs| { + let ssid = obs.ssid.trim(); + (!ssid.is_empty()).then(|| format!("wifi:{ssid}")) + }) + .unwrap_or_else(|| fallback.to_owned()) +} + +fn resolve_auto_source( + platform: PlatformFlavor, + esp32_available: bool, + wifi_available: bool, +) -> ResolvedSource { + if esp32_available { + ResolvedSource::Esp32 + } else if matches!(platform, PlatformFlavor::Macos | PlatformFlavor::Windows) && wifi_available + { + ResolvedSource::Wifi + } else { + ResolvedSource::Simulate + } +} + +fn resolve_explicit_source( + requested: RequestedSource, + platform: PlatformFlavor, + wifi_available: bool, +) -> Result { + match requested { + RequestedSource::Esp32 => Ok(ResolvedSource::Esp32), + RequestedSource::Simulate => Ok(ResolvedSource::Simulate), + RequestedSource::Wifi => match platform { + PlatformFlavor::Macos => wifi_available.then_some(ResolvedSource::Wifi).ok_or_else(|| { + "native macOS Wi-Fi is unavailable; build the helper, export `RUVIEW_MAC_WIFI_HELPER` if needed, then retry `--source wifi`".to_string() + }), + PlatformFlavor::Windows => wifi_available.then_some(ResolvedSource::Wifi).ok_or_else(|| { + "Windows Wi-Fi is unavailable; connect to Wi-Fi before starting `--source wifi`".to_string() + }), + PlatformFlavor::Other => Err( + "`--source wifi` is only supported on Windows and macOS in this server".to_string(), + ), + }, + RequestedSource::Auto => Err("internal error: auto source must be resolved separately".into()), + } +} + +async fn resolve_source(args: &Args) -> Result { + let requested = RequestedSource::parse(args.source.trim())?; + let platform = current_platform(); + + match requested { + RequestedSource::Auto => { + info!("Auto-detecting data source..."); + let esp32_available = probe_esp32(args.udp_port).await; + let wifi_available = match platform { + PlatformFlavor::Macos => probe_macos_wifi().await, + PlatformFlavor::Windows => probe_windows_wifi().await, + PlatformFlavor::Other => false, + }; + + let resolved = resolve_auto_source(platform, esp32_available, wifi_available); + match resolved { + ResolvedSource::Esp32 => { + info!(" ESP32 CSI detected on UDP :{}", args.udp_port); + } + ResolvedSource::Wifi => match platform { + PlatformFlavor::Macos => info!(" macOS WiFi detected"), + PlatformFlavor::Windows => info!(" Windows WiFi detected"), + PlatformFlavor::Other => {} + }, + ResolvedSource::Simulate => info!(" No hardware detected, using simulation"), + } + Ok(resolved) + } + RequestedSource::Wifi => { + let wifi_available = match platform { + PlatformFlavor::Macos => probe_macos_wifi().await, + PlatformFlavor::Windows => probe_windows_wifi().await, + PlatformFlavor::Other => false, + }; + resolve_explicit_source(requested, platform, wifi_available) + } + other => resolve_explicit_source(other, platform, false), + } +} + +async fn scan_with_port(scanner: Arc) -> Result, String> +where + T: WlanScanPort + 'static, +{ + tokio::task::spawn_blocking(move || scanner.scan().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("scan task failed: {e}"))? +} + +async fn connected_with_port(scanner: Arc) -> Result, String> +where + T: WlanScanPort + 'static, +{ + tokio::task::spawn_blocking(move || scanner.connected().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("connected task failed: {e}"))? +} + // ── Windows WiFi RSSI collector ────────────────────────────────────────────── /// Parse `netsh wlan show interfaces` output for RSSI and signal quality @@ -1092,9 +1345,168 @@ fn parse_netsh_interfaces_output(output: &str) -> Option<(f64, f64, String)> { } } +async fn publish_multi_bssid_tick( + state: &SharedState, + registry: &mut BssidRegistry, + pipeline: &mut WindowsWifiPipeline, + observations: Vec, + seq: u32, + tick_ms: u64, + fallback_source_label: &str, + source_override: Option<&str>, +) { + if observations.is_empty() { + return; + } + + let obs_count = observations.len(); + let source_label = source_override + .map(ToOwned::to_owned) + .unwrap_or_else(|| derive_wifi_source_label(&observations, fallback_source_label)); + + registry.update(&observations); + let multi_ap_frame = registry.to_multi_ap_frame(); + let enhanced = pipeline.process(&multi_ap_frame); + + let first_rssi = observations.first().map(|o| o.rssi_dbm).unwrap_or(-80.0); + + let frame = Esp32Frame { + magic: 0xC511_0001, + node_id: 0, + n_antennas: 1, + n_subcarriers: obs_count.min(255) as u8, + freq_mhz: 2437, + sequence: seq, + rssi: first_rssi.clamp(-128.0, 127.0) as i8, + noise_floor: -90, + amplitudes: multi_ap_frame.amplitudes.clone(), + phases: multi_ap_frame.phases.clone(), + }; + + let mut s_write_pre = state.write().await; + s_write_pre + .frame_history + .push_back(frame.amplitudes.clone()); + if s_write_pre.frame_history.len() > FRAME_HISTORY_CAPACITY { + s_write_pre.frame_history.pop_front(); + } + let sample_rate_hz = 1000.0 / tick_ms as f64; + let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = + extract_features_from_frame(&frame, &s_write_pre.frame_history, sample_rate_hz); + smooth_and_classify(&mut s_write_pre, &mut classification, raw_motion); + adaptive_override(&s_write_pre, &features, &mut classification); + drop(s_write_pre); + + let enhanced_motion = Some(serde_json::json!({ + "score": enhanced.motion.score, + "level": format!("{:?}", enhanced.motion.level), + "contributing_bssids": enhanced.motion.contributing_bssids, + })); + + let enhanced_breathing = enhanced.breathing.as_ref().map(|b| { + serde_json::json!({ + "rate_bpm": b.rate_bpm, + "confidence": b.confidence, + "bssid_count": b.bssid_count, + }) + }); + + let posture_str = enhanced.posture.map(|p| format!("{p:?}")); + let sig_quality_score = Some(enhanced.signal_quality.score); + let verdict_str = Some(format!("{:?}", enhanced.verdict)); + let bssid_n = Some(enhanced.bssid_count); + + let mut s = state.write().await; + s.source = source_label.clone(); + s.rssi_history.push_back(first_rssi); + if s.rssi_history.len() > 60 { + s.rssi_history.pop_front(); + } + + s.tick += 1; + let tick = s.tick; + + let motion_score = if classification.motion_level == "active" { + 0.8 + } else if classification.motion_level == "present_still" { + 0.3 + } else { + 0.05 + }; + + let raw_vitals = s + .vital_detector + .process_frame(&frame.amplitudes, &frame.phases); + let vitals = smooth_vitals(&mut s, &raw_vitals); + s.latest_vitals = vitals.clone(); + + let feat_variance = features.variance; + let raw_score = compute_person_score(&features); + s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15; + let est_persons = if classification.presence { + score_to_person_count(s.smoothed_person_score) + } else { + 0 + }; + + let mut update = SensingUpdate { + msg_type: "sensing_update".to_string(), + timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, + source: source_label.clone(), + tick, + nodes: vec![NodeInfo { + node_id: 0, + rssi_dbm: first_rssi, + position: [0.0, 0.0, 0.0], + amplitude: multi_ap_frame.amplitudes, + subcarrier_count: obs_count, + }], + features, + classification, + signal_field: generate_signal_field( + first_rssi, + motion_score, + breathing_rate_hz, + feat_variance.min(1.0), + &sub_variances, + ), + vital_signs: Some(vitals), + enhanced_motion, + enhanced_breathing, + posture: posture_str, + signal_quality_score: sig_quality_score, + quality_verdict: verdict_str, + bssid_count: bssid_n, + pose_keypoints: None, + model_status: None, + persons: None, + estimated_persons: if est_persons > 0 { + Some(est_persons) + } else { + None + }, + }; + + let persons = derive_pose_from_sensing(&update); + if !persons.is_empty() { + update.persons = Some(persons); + } + + if let Ok(json) = serde_json::to_string(&update) { + let _ = s.tx.send(json); + } + s.latest_update = Some(update); + + debug!( + "WiFi tick #{tick}: {obs_count} BSSIDs, quality={:.2}, verdict={:?}", + enhanced.signal_quality.score, enhanced.verdict + ); +} + async fn windows_wifi_task(state: SharedState, tick_ms: u64) { let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); let mut seq: u32 = 0; + let scanner = Arc::new(WlanApiScanner::new()); // ADR-022 Phase 3: Multi-BSSID pipeline state (kept across ticks) let mut registry = BssidRegistry::new(32, 30); @@ -1109,199 +1521,30 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { interval.tick().await; seq += 1; - // ── Step 1: Run multi-BSSID scan via spawn_blocking ────────── - // NetshBssidScanner is not Send, so we run `netsh` and parse - // the output inside a blocking closure. - let bssid_scan_result = tokio::task::spawn_blocking(|| { - let output = std::process::Command::new("netsh") - .args(["wlan", "show", "networks", "mode=bssid"]) - .output() - .map_err(|e| format!("netsh bssid scan failed: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "netsh exited with {}: {}", - output.status, - stderr.trim() - )); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - parse_netsh_bssid_output(&stdout).map_err(|e| format!("parse error: {e}")) - }) - .await; - - // Unwrap the JoinHandle result, then the inner Result. - let observations = match bssid_scan_result { - Ok(Ok(obs)) if !obs.is_empty() => obs, - Ok(Ok(_empty)) => { + let observations = match scan_with_port(scanner.clone()).await { + Ok(obs) if !obs.is_empty() => obs, + Ok(_) => { debug!("Multi-BSSID scan returned 0 observations, falling back"); windows_wifi_fallback_tick(&state, seq).await; continue; } - Ok(Err(e)) => { + Err(e) => { warn!("Multi-BSSID scan error: {e}, falling back"); windows_wifi_fallback_tick(&state, seq).await; continue; } - Err(join_err) => { - error!("spawn_blocking panicked: {join_err}"); - continue; - } - }; - - let obs_count = observations.len(); - - // Derive SSID from the first observation for the source label. - let ssid = observations - .first() - .map(|o| o.ssid.clone()) - .unwrap_or_else(|| "Unknown".into()); - - // ── Step 2: Feed observations into registry ────────────────── - registry.update(&observations); - let multi_ap_frame = registry.to_multi_ap_frame(); - - // ── Step 3: Run enhanced pipeline ──────────────────────────── - let enhanced = pipeline.process(&multi_ap_frame); - - // ── Step 4: Build backward-compatible Esp32Frame ───────────── - let first_rssi = observations - .first() - .map(|o| o.rssi_dbm) - .unwrap_or(-80.0); - let _first_signal_pct = observations - .first() - .map(|o| o.signal_pct) - .unwrap_or(40.0); - - let frame = Esp32Frame { - magic: 0xC511_0001, - node_id: 0, - n_antennas: 1, - n_subcarriers: obs_count.min(255) as u8, - freq_mhz: 2437, - sequence: seq, - rssi: first_rssi.clamp(-128.0, 127.0) as i8, - noise_floor: -90, - amplitudes: multi_ap_frame.amplitudes.clone(), - phases: multi_ap_frame.phases.clone(), }; - - // ── Step 4b: Update frame history and extract features ─────── - let mut s_write_pre = state.write().await; - s_write_pre.frame_history.push_back(frame.amplitudes.clone()); - if s_write_pre.frame_history.len() > FRAME_HISTORY_CAPACITY { - s_write_pre.frame_history.pop_front(); - } - let sample_rate_hz = 1000.0 / tick_ms as f64; - let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = - extract_features_from_frame(&frame, &s_write_pre.frame_history, sample_rate_hz); - smooth_and_classify(&mut s_write_pre, &mut classification, raw_motion); - adaptive_override(&s_write_pre, &features, &mut classification); - drop(s_write_pre); - - // ── Step 5: Build enhanced fields from pipeline result ─────── - let enhanced_motion = Some(serde_json::json!({ - "score": enhanced.motion.score, - "level": format!("{:?}", enhanced.motion.level), - "contributing_bssids": enhanced.motion.contributing_bssids, - })); - - let enhanced_breathing = enhanced.breathing.as_ref().map(|b| { - serde_json::json!({ - "rate_bpm": b.rate_bpm, - "confidence": b.confidence, - "bssid_count": b.bssid_count, - }) - }); - - let posture_str = enhanced.posture.map(|p| format!("{p:?}")); - let sig_quality_score = Some(enhanced.signal_quality.score); - let verdict_str = Some(format!("{:?}", enhanced.verdict)); - let bssid_n = Some(enhanced.bssid_count); - - // ── Step 6: Update shared state ────────────────────────────── - let mut s = state.write().await; - s.source = format!("wifi:{ssid}"); - s.rssi_history.push_back(first_rssi); - if s.rssi_history.len() > 60 { - s.rssi_history.pop_front(); - } - - s.tick += 1; - let tick = s.tick; - - let motion_score = if classification.motion_level == "active" { - 0.8 - } else if classification.motion_level == "present_still" { - 0.3 - } else { - 0.05 - }; - - let raw_vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases); - let vitals = smooth_vitals(&mut s, &raw_vitals); - s.latest_vitals = vitals.clone(); - - let feat_variance = features.variance; - - // Multi-person estimation with temporal smoothing (EMA α=0.15). - let raw_score = compute_person_score(&features); - s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15; - let est_persons = if classification.presence { - score_to_person_count(s.smoothed_person_score) - } else { - 0 - }; - - let mut update = SensingUpdate { - msg_type: "sensing_update".to_string(), - timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, - source: format!("wifi:{ssid}"), - tick, - nodes: vec![NodeInfo { - node_id: 0, - rssi_dbm: first_rssi, - position: [0.0, 0.0, 0.0], - amplitude: multi_ap_frame.amplitudes, - subcarrier_count: obs_count, - }], - features, - classification, - signal_field: generate_signal_field( - first_rssi, motion_score, breathing_rate_hz, - feat_variance.min(1.0), &sub_variances, - ), - vital_signs: Some(vitals), - enhanced_motion, - enhanced_breathing, - posture: posture_str, - signal_quality_score: sig_quality_score, - quality_verdict: verdict_str, - bssid_count: bssid_n, - pose_keypoints: None, - model_status: None, - persons: None, - estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, - }; - - // Populate persons from the sensing update. - let persons = derive_pose_from_sensing(&update); - if !persons.is_empty() { - update.persons = Some(persons); - } - - if let Ok(json) = serde_json::to_string(&update) { - let _ = s.tx.send(json); - } - s.latest_update = Some(update); - - debug!( - "Multi-BSSID tick #{tick}: {obs_count} BSSIDs, quality={:.2}, verdict={:?}", - enhanced.signal_quality.score, enhanced.verdict - ); + publish_multi_bssid_tick( + &state, + &mut registry, + &mut pipeline, + observations, + seq, + tick_ms, + "wifi:unknown", + None, + ) + .await; } } @@ -1371,7 +1614,9 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { 0.05 }; - let raw_vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases); + let raw_vitals = s + .vital_detector + .process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); @@ -1401,8 +1646,11 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { features, classification, signal_field: generate_signal_field( - rssi_dbm, motion_score, breathing_rate_hz, - feat_variance.min(1.0), &sub_variances, + rssi_dbm, + motion_score, + breathing_rate_hz, + feat_variance.min(1.0), + &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, @@ -1414,7 +1662,11 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { pose_keypoints: None, model_status: None, persons: None, - estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, + estimated_persons: if est_persons > 0 { + Some(est_persons) + } else { + None + }, }; let persons = derive_pose_from_sensing(&update); @@ -1428,6 +1680,70 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { s.latest_update = Some(update); } +#[cfg(target_os = "macos")] +async fn macos_wifi_task(state: SharedState, tick_ms: u64) { + let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); + let mut seq: u32 = 0; + let mut idle_ticks: u32 = 0; + let scanner = Arc::new(MacosCoreWlanScanner::new()); + let mut registry = BssidRegistry::new(32, 30); + let mut pipeline = WindowsWifiPipeline::new(); + + info!("macOS CoreWLAN pipeline active (tick={}ms)", tick_ms); + state.write().await.source = "wifi:macos".to_string(); + + loop { + interval.tick().await; + seq += 1; + + let observations = match scan_with_port(scanner.clone()).await { + Ok(obs) if !obs.is_empty() => obs, + Ok(_) => match connected_with_port(scanner.clone()).await { + Ok(Some(obs)) => vec![obs], + Ok(None) => { + idle_ticks = idle_ticks.saturating_add(1); + if idle_ticks == 1 || idle_ticks % 20 == 0 { + warn!( + "macOS Wi-Fi scan produced no visible or connected networks; waiting for CoreWLAN observations" + ); + } else { + debug!("macOS scan returned no connected observations"); + } + continue; + } + Err(e) => { + idle_ticks = idle_ticks.saturating_add(1); + warn!("macOS connected probe failed after empty scan: {e}"); + continue; + } + }, + Err(e) => { + idle_ticks = idle_ticks.saturating_add(1); + warn!("macOS Wi-Fi scan failed: {e}"); + continue; + } + }; + idle_ticks = 0; + + publish_multi_bssid_tick( + &state, + &mut registry, + &mut pipeline, + observations, + seq, + tick_ms, + "wifi:macos", + None, + ) + .await; + } +} + +#[cfg(not(target_os = "macos"))] +async fn macos_wifi_task(_state: SharedState, _tick_ms: u64) { + error!("macOS Wi-Fi task requested on a non-macOS build"); +} + /// Probe if Windows WiFi is connected async fn probe_windows_wifi() -> bool { match tokio::process::Command::new("netsh") @@ -1443,6 +1759,18 @@ async fn probe_windows_wifi() -> bool { } } +#[cfg(target_os = "macos")] +async fn probe_macos_wifi() -> bool { + tokio::task::spawn_blocking(|| MacosCoreWlanScanner::new().probe_sync().is_ok()) + .await + .unwrap_or(false) +} + +#[cfg(not(target_os = "macos"))] +async fn probe_macos_wifi() -> bool { + false +} + /// Probe if ESP32 is streaming on UDP port async fn probe_esp32(port: u16) -> bool { let addr = format!("0.0.0.0:{port}"); @@ -1550,7 +1878,9 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) { "type": "connection_established", "payload": { "status": "connected", "backend": "rust+ruvector" } }); - let _ = socket.send(Message::Text(conn_msg.to_string().into())).await; + let _ = socket + .send(Message::Text(conn_msg.to_string().into())) + .await; loop { tokio::select! { @@ -1778,7 +2108,8 @@ fn derive_single_person_pose( let lean_x = (feat.dominant_freq_hz / 5.0 - 1.0).clamp(-1.0, 1.0) * 18.0; let stride_x = if is_walking { - let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12 + phase_offset).sin(); + let stride_phase = + (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12 + phase_offset).sin(); stride_phase * 45.0 * motion_score } else { 0.0 @@ -1800,36 +2131,51 @@ fn derive_single_person_pose( // ── COCO 17-keypoint offsets from hip-center ────────────────────────────── let kp_names = [ - "nose", "left_eye", "right_eye", "left_ear", "right_ear", - "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", - "left_wrist", "right_wrist", "left_hip", "right_hip", - "left_knee", "right_knee", "left_ankle", "right_ankle", + "nose", + "left_eye", + "right_eye", + "left_ear", + "right_ear", + "left_shoulder", + "right_shoulder", + "left_elbow", + "right_elbow", + "left_wrist", + "right_wrist", + "left_hip", + "right_hip", + "left_knee", + "right_knee", + "left_ankle", + "right_ankle", ]; let kp_offsets: [(f64, f64); 17] = [ - ( 0.0, -80.0), // 0 nose - ( -8.0, -88.0), // 1 left_eye - ( 8.0, -88.0), // 2 right_eye - (-16.0, -82.0), // 3 left_ear - ( 16.0, -82.0), // 4 right_ear - (-30.0, -50.0), // 5 left_shoulder - ( 30.0, -50.0), // 6 right_shoulder - (-45.0, -15.0), // 7 left_elbow - ( 45.0, -15.0), // 8 right_elbow - (-50.0, 20.0), // 9 left_wrist - ( 50.0, 20.0), // 10 right_wrist - (-20.0, 20.0), // 11 left_hip - ( 20.0, 20.0), // 12 right_hip - (-22.0, 70.0), // 13 left_knee - ( 22.0, 70.0), // 14 right_knee - (-24.0, 120.0), // 15 left_ankle - ( 24.0, 120.0), // 16 right_ankle + (0.0, -80.0), // 0 nose + (-8.0, -88.0), // 1 left_eye + (8.0, -88.0), // 2 right_eye + (-16.0, -82.0), // 3 left_ear + (16.0, -82.0), // 4 right_ear + (-30.0, -50.0), // 5 left_shoulder + (30.0, -50.0), // 6 right_shoulder + (-45.0, -15.0), // 7 left_elbow + (45.0, -15.0), // 8 right_elbow + (-50.0, 20.0), // 9 left_wrist + (50.0, 20.0), // 10 right_wrist + (-20.0, 20.0), // 11 left_hip + (20.0, 20.0), // 12 right_hip + (-22.0, 70.0), // 13 left_knee + (22.0, 70.0), // 14 right_knee + (-24.0, 120.0), // 15 left_ankle + (24.0, 120.0), // 16 right_ankle ]; const TORSO_KP: [usize; 4] = [5, 6, 11, 12]; const EXTREMITY_KP: [usize; 4] = [9, 10, 15, 16]; - let keypoints: Vec = kp_names.iter().zip(kp_offsets.iter()) + let keypoints: Vec = kp_names + .iter() + .zip(kp_offsets.iter()) .enumerate() .map(|(i, (name, (dx, dy)))| { let breath_dx = if TORSO_KP.contains(&i) { @@ -1856,17 +2202,20 @@ fn derive_single_person_pose( }; let kp_noise_x = ((noise_seed + i as f64 * 1.618).sin() * 43758.545).fract() - * feat.variance.sqrt().clamp(0.0, 3.0) * motion_score; + * feat.variance.sqrt().clamp(0.0, 3.0) + * motion_score; let kp_noise_y = ((noise_seed + i as f64 * 2.718).cos() * 31415.926).fract() - * feat.variance.sqrt().clamp(0.0, 3.0) * motion_score * 0.6; + * feat.variance.sqrt().clamp(0.0, 3.0) + * motion_score + * 0.6; let swing_dy = if is_walking { let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12 + phase_offset).sin(); match i { - 7 | 9 => -stride_phase * 20.0 * motion_score, - 8 | 10 => stride_phase * 20.0 * motion_score, - 13 | 15 => stride_phase * 25.0 * motion_score, + 7 | 9 => -stride_phase * 20.0 * motion_score, + 8 | 10 => stride_phase * 20.0 * motion_score, + 13 | 15 => stride_phase * 25.0 * motion_score, 14 | 16 => -stride_phase * 25.0 * motion_score, _ => 0.0, } @@ -2030,8 +2379,11 @@ async fn pose_stats(State(state): State) -> Json async fn pose_zones_summary(State(state): State) -> Json { let s = state.read().await; - let presence = s.latest_update.as_ref() - .map(|u| u.classification.presence).unwrap_or(false); + let presence = s + .latest_update + .as_ref() + .map(|u| u.classification.presence) + .unwrap_or(false); Json(serde_json::json!({ "zones": { "zone_1": { "person_count": if presence { 1 } else { 0 }, "status": "monitored" }, @@ -2071,9 +2423,10 @@ async fn get_active_model(State(state): State) -> Json { - let model = s.discovered_models.iter().find(|m| { - m.get("id").and_then(|v| v.as_str()) == Some(id.as_str()) - }); + let model = s + .discovered_models + .iter() + .find(|m| m.get("id").and_then(|v| v.as_str()) == Some(id.as_str())); Json(serde_json::json!({ "active": model.cloned().unwrap_or_else(|| serde_json::json!({ "id": id })), })) @@ -2087,7 +2440,8 @@ async fn load_model( State(state): State, Json(body): Json, ) -> Json { - let model_id = body.get("id") + let model_id = body + .get("id") .or_else(|| body.get("model_id")) .and_then(|v| v.as_str()) .unwrap_or("") @@ -2128,7 +2482,9 @@ async fn delete_model( if path.exists() { if let Err(e) = std::fs::remove_file(&path) { warn!("Failed to delete model file {:?}: {}", path, e); - return Json(serde_json::json!({ "error": format!("delete failed: {e}"), "success": false })); + return Json( + serde_json::json!({ "error": format!("delete failed: {e}"), "success": false }), + ); } // If this was the active model, unload it let mut s = state.write().await; @@ -2136,9 +2492,8 @@ async fn delete_model( s.active_model_id = None; s.model_loaded = false; } - s.discovered_models.retain(|m| { - m.get("id").and_then(|v| v.as_str()) != Some(id.as_str()) - }); + s.discovered_models + .retain(|m| m.get("id").and_then(|v| v.as_str()) != Some(id.as_str())); info!("Model deleted: {id}"); Json(serde_json::json!({ "success": true, "deleted": id })) } else { @@ -2154,10 +2509,9 @@ async fn list_lora_profiles() -> Json { } /// POST /api/v1/models/lora/activate — activate a LoRA adapter profile. -async fn activate_lora_profile( - Json(body): Json, -) -> Json { - let profile = body.get("profile") +async fn activate_lora_profile(Json(body): Json) -> Json { + let profile = body + .get("profile") .or_else(|| body.get("name")) .and_then(|v| v.as_str()) .unwrap_or("") @@ -2177,12 +2531,15 @@ fn scan_model_files() -> Vec { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("rvf") { - let name = path.file_stem() + let name = path + .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); - let modified = entry.metadata().ok() + let modified = entry + .metadata() + .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) @@ -2248,12 +2605,11 @@ async fn start_recording( "recording_id": s.recording_current_id, })); } - let id = body.get("id") + let id = body + .get("id") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .unwrap_or_else(|| { - format!("rec_{}", chrono_timestamp()) - }); + .unwrap_or_else(|| format!("rec_{}", chrono_timestamp())); // Create the recording file let rec_path = PathBuf::from("data/recordings").join(format!("{}.jsonl", id)); @@ -2347,7 +2703,8 @@ async fn stop_recording(State(state): State) -> Json Vec { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { - let name = path.file_stem() + let name = path + .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); - let modified = entry.metadata().ok() + let modified = entry + .metadata() + .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) @@ -2497,19 +2858,26 @@ async fn adaptive_train(State(state): State) -> Json { let accuracy = model.training_accuracy; let frames = model.trained_frames; - let stats: Vec<_> = model.class_stats.iter().map(|cs| { - serde_json::json!({ - "class": cs.label, - "samples": cs.count, - "feature_means": cs.mean, + let stats: Vec<_> = model + .class_stats + .iter() + .map(|cs| { + serde_json::json!({ + "class": cs.label, + "samples": cs.count, + "feature_means": cs.mean, + }) }) - }).collect(); + .collect(); // Save to disk. if let Err(e) = model.save(&adaptive_classifier::model_path()) { warn!("Failed to save adaptive model: {e}"); } else { - info!("Adaptive model saved to {}", adaptive_classifier::model_path().display()); + info!( + "Adaptive model saved to {}", + adaptive_classifier::model_path().display() + ); } // Load into runtime state. @@ -2523,12 +2891,10 @@ async fn adaptive_train(State(state): State) -> Json { - Json(serde_json::json!({ - "success": false, - "error": e, - })) - } + Err(e) => Json(serde_json::json!({ + "success": false, + "error": e, + })), } } @@ -2741,9 +3107,13 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { Ok((len, src)) => { // ADR-039: Try edge vitals packet first (magic 0xC511_0002). if let Some(vitals) = parse_esp32_vitals(&buf[..len]) { - debug!("ESP32 vitals from {src}: node={} br={:.1} hr={:.1} pres={}", - vitals.node_id, vitals.breathing_rate_bpm, - vitals.heartrate_bpm, vitals.presence); + debug!( + "ESP32 vitals from {src}: node={} br={:.1} hr={:.1} pres={}", + vitals.node_id, + vitals.breathing_rate_bpm, + vitals.heartrate_bpm, + vitals.presence + ); let mut s = state.write().await; // Broadcast vitals via WebSocket. if let Ok(json) = serde_json::to_string(&serde_json::json!({ @@ -2767,9 +3137,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // ADR-040: Try WASM output packet (magic 0xC511_0004). if let Some(wasm_output) = parse_wasm_output(&buf[..len]) { - debug!("WASM output from {src}: node={} module={} events={}", - wasm_output.node_id, wasm_output.module_id, - wasm_output.events.len()); + debug!( + "WASM output from {src}: node={} module={} events={}", + wasm_output.node_id, + wasm_output.module_id, + wasm_output.events.len() + ); let mut s = state.write().await; // Broadcast WASM events via WebSocket. if let Ok(json) = serde_json::to_string(&serde_json::json!({ @@ -2785,8 +3158,10 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { } if let Some(frame) = parse_esp32_frame(&buf[..len]) { - debug!("ESP32 frame from {src}: node={}, subs={}, seq={}", - frame.node_id, frame.n_subcarriers, frame.sequence); + debug!( + "ESP32 frame from {src}: node={}, subs={}, seq={}", + frame.node_id, frame.n_subcarriers, frame.sequence + ); let mut s = state.write().await; s.source = "esp32".to_string(); @@ -2799,10 +3174,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { } let sample_rate_hz = 1000.0 / 500.0_f64; // default tick; ESP32 frames arrive as fast as they come - let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = - extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); + let ( + features, + mut classification, + breathing_rate_hz, + sub_variances, + raw_motion, + ) = extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); smooth_and_classify(&mut s, &mut classification, raw_motion); - adaptive_override(&s, &features, &mut classification); + adaptive_override(&s, &features, &mut classification); // Update RSSI history s.rssi_history.push_back(features.mean_rssi); @@ -2813,14 +3193,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { s.tick += 1; let tick = s.tick; - let motion_score = if classification.motion_level == "active" { 0.8 } - else if classification.motion_level == "present_still" { 0.3 } - else { 0.05 }; + let motion_score = if classification.motion_level == "active" { + 0.8 + } else if classification.motion_level == "present_still" { + 0.3 + } else { + 0.05 + }; - let raw_vitals = s.vital_detector.process_frame( - &frame.amplitudes, - &frame.phases, - ); + let raw_vitals = s + .vital_detector + .process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); @@ -2848,8 +3231,11 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { features: features.clone(), classification, signal_field: generate_signal_field( - features.mean_rssi, motion_score, breathing_rate_hz, - features.variance.min(1.0), &sub_variances, + features.mean_rssi, + motion_score, + breathing_rate_hz, + features.variance.min(1.0), + &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, @@ -2861,7 +3247,11 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { pose_keypoints: None, model_status: None, persons: None, - estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, + estimated_persons: if est_persons > 0 { + Some(est_persons) + } else { + None + }, }; let persons = derive_pose_from_sensing(&update); @@ -2908,21 +3298,24 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); smooth_and_classify(&mut s, &mut classification, raw_motion); - adaptive_override(&s, &features, &mut classification); + adaptive_override(&s, &features, &mut classification); s.rssi_history.push_back(features.mean_rssi); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } - let motion_score = if classification.motion_level == "active" { 0.8 } - else if classification.motion_level == "present_still" { 0.3 } - else { 0.05 }; + let motion_score = if classification.motion_level == "active" { + 0.8 + } else if classification.motion_level == "present_still" { + 0.3 + } else { + 0.05 + }; - let raw_vitals = s.vital_detector.process_frame( - &frame.amplitudes, - &frame.phases, - ); + let raw_vitals = s + .vital_detector + .process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); @@ -2953,8 +3346,11 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { features: features.clone(), classification, signal_field: generate_signal_field( - features.mean_rssi, motion_score, breathing_rate_hz, - features.variance.min(1.0), &sub_variances, + features.mean_rssi, + motion_score, + breathing_rate_hz, + features.variance.min(1.0), + &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, @@ -2976,7 +3372,11 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { None }, persons: None, - estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, + estimated_persons: if est_persons > 0 { + Some(est_persons) + } else { + None + }, }; // Populate persons from the sensing update. @@ -3034,8 +3434,11 @@ async fn main() { eprintln!("Running vital sign detection benchmark (1000 frames)..."); let (total, per_frame) = vital_signs::run_benchmark(1000); eprintln!(); - eprintln!("Summary: {} total, {} per frame", - format!("{total:?}"), format!("{per_frame:?}")); + eprintln!( + "Summary: {} total, {} per frame", + format!("{total:?}"), + format!("{per_frame:?}") + ); return; } @@ -3096,22 +3499,32 @@ async fn main() { if args.pretrain { eprintln!("=== WiFi-DensePose Contrastive Pretraining (ADR-024) ==="); - let ds_path = args.dataset.clone().unwrap_or_else(|| PathBuf::from("data")); + let ds_path = args + .dataset + .clone() + .unwrap_or_else(|| PathBuf::from("data")); let source = match args.dataset_type.as_str() { "wipose" => dataset::DataSource::WiPose(ds_path.clone()), _ => dataset::DataSource::MmFi(ds_path.clone()), }; let pipeline = dataset::DataPipeline::new(dataset::DataConfig { - source, ..Default::default() + source, + ..Default::default() }); // Generate synthetic or load real CSI windows let generate_synthetic_windows = || -> Vec>> { - (0..50).map(|i| { - (0..4).map(|a| { - (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() - }).collect() - }).collect() + (0..50) + .map(|i| { + (0..4) + .map(|a| { + (0..56) + .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) + .collect() + }) + .collect() + }) + .collect() }; let csi_windows: Vec>> = match pipeline.load() { @@ -3125,20 +3538,28 @@ async fn main() { } }; - let n_subcarriers = csi_windows.first() + let n_subcarriers = csi_windows + .first() .and_then(|w| w.first()) .map(|f| f.len()) .unwrap_or(56); let tf_config = graph_transformer::TransformerConfig { - n_subcarriers, n_keypoints: 17, d_model: 64, n_heads: 4, n_gnn_layers: 2, + n_subcarriers, + n_keypoints: 17, + d_model: 64, + n_heads: 4, + n_gnn_layers: 2, }; let transformer = graph_transformer::CsiToPoseTransformer::new(tf_config); eprintln!("Transformer params: {}", transformer.param_count()); let trainer_config = trainer::TrainerConfig { epochs: args.pretrain_epochs, - batch_size: 8, lr: 0.001, warmup_epochs: 2, min_lr: 1e-6, + batch_size: 8, + lr: 0.001, + warmup_epochs: 2, + min_lr: 1e-6, early_stop_patience: args.pretrain_epochs + 1, pretrain_temperature: 0.07, ..Default::default() @@ -3146,12 +3567,18 @@ async fn main() { let mut t = trainer::Trainer::with_transformer(trainer_config, transformer); let e_config = embedding::EmbeddingConfig { - d_model: 64, d_proj: 128, temperature: 0.07, normalize: true, + d_model: 64, + d_proj: 128, + temperature: 0.07, + normalize: true, }; let mut projection = embedding::ProjectionHead::new(e_config.clone()); let augmenter = embedding::CsiAugmenter::new(); - eprintln!("Starting contrastive pretraining for {} epochs...", args.pretrain_epochs); + eprintln!( + "Starting contrastive pretraining for {} epochs...", + args.pretrain_epochs + ); let start = std::time::Instant::now(); for epoch in 0..args.pretrain_epochs { let loss = t.pretrain_epoch(&csi_windows, &augmenter, &mut projection, 0.07, epoch); @@ -3188,8 +3615,11 @@ async fn main() { &proj_weights, ); match builder.write_to_file(save_path) { - Ok(()) => eprintln!("RVF saved ({} transformer + {} projection params)", - weights.len(), proj_weights.len()), + Ok(()) => eprintln!( + "RVF saved ({} transformer + {} projection params)", + weights.len(), + proj_weights.len() + ), Err(e) => eprintln!("Failed to save RVF: {e}"), } } @@ -3211,23 +3641,36 @@ async fn main() { let reader = match RvfReader::from_file(&model_path) { Ok(r) => r, - Err(e) => { eprintln!("Failed to load model: {e}"); std::process::exit(1); } + Err(e) => { + eprintln!("Failed to load model: {e}"); + std::process::exit(1); + } }; let weights = reader.weights().unwrap_or_default(); let (embed_config_json, proj_weights) = reader.embedding().unwrap_or_else(|| { eprintln!("Warning: no embedding segment in RVF, using defaults"); - (serde_json::json!({"d_model":64,"d_proj":128,"temperature":0.07,"normalize":true}), Vec::new()) + ( + serde_json::json!({"d_model":64,"d_proj":128,"temperature":0.07,"normalize":true}), + Vec::new(), + ) }); let d_model = embed_config_json["d_model"].as_u64().unwrap_or(64) as usize; let d_proj = embed_config_json["d_proj"].as_u64().unwrap_or(128) as usize; let tf_config = graph_transformer::TransformerConfig { - n_subcarriers: 56, n_keypoints: 17, d_model, n_heads: 4, n_gnn_layers: 2, + n_subcarriers: 56, + n_keypoints: 17, + d_model, + n_heads: 4, + n_gnn_layers: 2, }; let e_config = embedding::EmbeddingConfig { - d_model, d_proj, temperature: 0.07, normalize: true, + d_model, + d_proj, + temperature: 0.07, + normalize: true, }; let mut extractor = embedding::EmbeddingExtractor::new(tf_config, e_config.clone()); @@ -3244,20 +3687,35 @@ async fn main() { } // Load dataset and extract embeddings - let _ds_path = args.dataset.clone().unwrap_or_else(|| PathBuf::from("data")); - let csi_windows: Vec>> = (0..10).map(|i| { - (0..4).map(|a| { - (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() - }).collect() - }).collect(); - - eprintln!("Extracting embeddings from {} CSI windows...", csi_windows.len()); + let _ds_path = args + .dataset + .clone() + .unwrap_or_else(|| PathBuf::from("data")); + let csi_windows: Vec>> = (0..10) + .map(|i| { + (0..4) + .map(|a| { + (0..56) + .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) + .collect() + }) + .collect() + }) + .collect(); + + eprintln!( + "Extracting embeddings from {} CSI windows...", + csi_windows.len() + ); let embeddings = extractor.extract_batch(&csi_windows); for (i, emb) in embeddings.iter().enumerate() { let norm: f32 = emb.iter().map(|x| x * x).sum::().sqrt(); eprintln!(" Window {i}: {d_proj}-dim embedding, ||e|| = {norm:.4}"); } - eprintln!("Extracted {} embeddings of dimension {d_proj}", embeddings.len()); + eprintln!( + "Extracted {} embeddings of dimension {d_proj}", + embeddings.len() + ); return; } @@ -3272,7 +3730,10 @@ async fn main() { "temporal" => embedding::IndexType::TemporalBaseline, "person" => embedding::IndexType::PersonTrack, _ => { - eprintln!("Unknown index type '{}'. Use: env, activity, temporal, person", index_type_str); + eprintln!( + "Unknown index type '{}'. Use: env, activity, temporal, person", + index_type_str + ); std::process::exit(1); } }; @@ -3282,11 +3743,17 @@ async fn main() { let mut extractor = embedding::EmbeddingExtractor::new(tf_config, e_config); // Generate synthetic CSI windows for demo - let csi_windows: Vec>> = (0..20).map(|i| { - (0..4).map(|a| { - (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() - }).collect() - }).collect(); + let csi_windows: Vec>> = (0..20) + .map(|i| { + (0..4) + .map(|a| { + (0..56) + .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) + .collect() + }) + .collect() + }) + .collect(); let mut index = embedding::FingerprintIndex::new(index_type); for (i, window) in csi_windows.iter().enumerate() { @@ -3301,7 +3768,10 @@ async fn main() { let results = index.search(&query_emb, 5); eprintln!("Top-5 nearest to window_0:"); for r in &results { - eprintln!(" entry={}, distance={:.4}, metadata={}", r.entry, r.distance, r.metadata); + eprintln!( + " entry={}, distance={:.4}, metadata={}", + r.entry, r.distance, r.metadata + ); } return; @@ -3312,7 +3782,10 @@ async fn main() { eprintln!("=== WiFi-DensePose Training Mode ==="); // Build data pipeline - let ds_path = args.dataset.clone().unwrap_or_else(|| PathBuf::from("data")); + let ds_path = args + .dataset + .clone() + .unwrap_or_else(|| PathBuf::from("data")); let source = match args.dataset_type.as_str() { "wipose" => dataset::DataSource::WiPose(ds_path.clone()), _ => dataset::DataSource::MmFi(ds_path.clone()), @@ -3324,25 +3797,31 @@ async fn main() { // Generate synthetic training data (50 samples with deterministic CSI + keypoints) let generate_synthetic = || -> Vec { - (0..50).map(|i| { - let csi: Vec> = (0..4).map(|a| { - (0..56).map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5).collect() - }).collect(); - let mut kps = [(0.0f32, 0.0f32, 1.0f32); 17]; - for (k, kp) in kps.iter_mut().enumerate() { - kp.0 = (k as f32 * 0.1 + i as f32 * 0.02).sin() * 100.0 + 320.0; - kp.1 = (k as f32 * 0.15 + i as f32 * 0.03).cos() * 80.0 + 240.0; - } - dataset::TrainingSample { - csi_window: csi, - pose_label: dataset::PoseLabel { - keypoints: kps, - body_parts: Vec::new(), - confidence: 1.0, - }, - source: "synthetic", - } - }).collect() + (0..50) + .map(|i| { + let csi: Vec> = (0..4) + .map(|a| { + (0..56) + .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) + .collect() + }) + .collect(); + let mut kps = [(0.0f32, 0.0f32, 1.0f32); 17]; + for (k, kp) in kps.iter_mut().enumerate() { + kp.0 = (k as f32 * 0.1 + i as f32 * 0.02).sin() * 100.0 + 320.0; + kp.1 = (k as f32 * 0.15 + i as f32 * 0.03).cos() * 80.0 + 240.0; + } + dataset::TrainingSample { + csi_window: csi, + pose_label: dataset::PoseLabel { + keypoints: kps, + body_parts: Vec::new(), + confidence: 1.0, + }, + source: "synthetic", + } + }) + .collect() }; // Load samples (fall back to synthetic if dataset missing/empty) @@ -3352,7 +3831,10 @@ async fn main() { s } Ok(_) => { - eprintln!("No samples found at {}. Using synthetic data.", ds_path.display()); + eprintln!( + "No samples found at {}. Using synthetic data.", + ds_path.display() + ); generate_synthetic() } Err(e) => { @@ -3362,17 +3844,21 @@ async fn main() { }; // Convert dataset samples to trainer format - let trainer_samples: Vec = samples.iter() - .map(trainer::from_dataset_sample) - .collect(); + let trainer_samples: Vec = + samples.iter().map(trainer::from_dataset_sample).collect(); // Split 80/20 train/val let split = (trainer_samples.len() * 4) / 5; let (train_data, val_data) = trainer_samples.split_at(split.max(1)); - eprintln!("Train: {} samples, Val: {} samples", train_data.len(), val_data.len()); + eprintln!( + "Train: {} samples, Val: {} samples", + train_data.len(), + val_data.len() + ); // Create transformer + trainer - let n_subcarriers = train_data.first() + let n_subcarriers = train_data + .first() .and_then(|s| s.csi_features.first()) .map(|f| f.len()) .unwrap_or(56); @@ -3402,8 +3888,10 @@ async fn main() { eprintln!("Starting training for {} epochs...", args.epochs); let result = t.run_training(train_data, val_data); eprintln!("Training complete in {:.1}s", result.total_time_secs); - eprintln!(" Best epoch: {}, PCK@0.2: {:.4}, OKS mAP: {:.4}", - result.best_epoch, result.best_pck, result.best_oks); + eprintln!( + " Best epoch: {}, PCK@0.2: {:.4}, OKS mAP: {:.4}", + result.best_epoch, result.best_pck, result.best_oks + ); // Save checkpoint if let Some(ref ckpt_dir) = args.checkpoint_dir { @@ -3442,8 +3930,11 @@ async fn main() { builder.add_vital_config(&VitalSignConfig::default()); builder.add_weights(&weights); match builder.write_to_file(save_path) { - Ok(()) => eprintln!("RVF saved ({} params, {} bytes)", - weights.len(), weights.len() * 4), + Ok(()) => eprintln!( + "RVF saved ({} params, {} bytes)", + weights.len(), + weights.len() * 4 + ), Err(e) => eprintln!("Failed to save RVF: {e}"), } } @@ -3458,25 +3949,15 @@ async fn main() { info!(" UI path: {}", args.ui_path.display()); info!(" Source: {}", args.source); - // Auto-detect data source - let source = match args.source.as_str() { - "auto" => { - info!("Auto-detecting data source..."); - if probe_esp32(args.udp_port).await { - info!(" ESP32 CSI detected on UDP :{}", args.udp_port); - "esp32" - } else if probe_windows_wifi().await { - info!(" Windows WiFi detected"); - "wifi" - } else { - info!(" No hardware detected, using simulation"); - "simulate" - } + let source = match resolve_source(&args).await { + Ok(source) => source, + Err(e) => { + error!("{e}"); + std::process::exit(1); } - other => other, }; - info!("Data source: {source}"); + info!("Data source: {}", source.as_str()); // Shared state // Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz) @@ -3537,8 +4018,10 @@ async fn main() { Ok(data) => match ProgressiveLoader::new(&data) { Ok(mut loader) => { if let Ok(la) = loader.load_layer_a() { - info!(" Layer A ready: model={} v{} ({} segments)", - la.model_name, la.version, la.n_segments); + info!( + " Layer A ready: model={} v{} ({} segments)", + la.model_name, la.version, la.n_segments + ); } model_loaded = true; progressive_loader = Some(loader); @@ -3557,7 +4040,11 @@ async fn main() { // Discover model and recording files on startup let initial_models = scan_model_files(); let initial_recordings = scan_recording_files(); - info!("Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len()); + info!( + "Discovered {} model files, {} recording files", + initial_models.len(), + initial_recordings.len() + ); let (tx, _) = broadcast::channel::(256); let state: SharedState = Arc::new(RwLock::new(AppStateInner { @@ -3565,7 +4052,7 @@ async fn main() { rssi_history: VecDeque::new(), frame_history: VecDeque::new(), tick: 0, - source: source.into(), + source: source.as_str().into(), tx, total_detections: 0, start_time: std::time::Instant::now(), @@ -3603,29 +4090,44 @@ async fn main() { // Training training_status: "idle".to_string(), training_config: None, - adaptive_model: adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path()).ok().map(|m| { - info!("Loaded adaptive classifier: {} frames, {:.1}% accuracy", - m.trained_frames, m.training_accuracy * 100.0); - m - }), + adaptive_model: + adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path()) + .ok() + .map(|m| { + info!( + "Loaded adaptive classifier: {} frames, {:.1}% accuracy", + m.trained_frames, + m.training_accuracy * 100.0 + ); + m + }), })); // Start background tasks based on source match source { - "esp32" => { + ResolvedSource::Esp32 => { tokio::spawn(udp_receiver_task(state.clone(), args.udp_port)); tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms)); } - "wifi" => { - tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms)); + ResolvedSource::Wifi => { + if matches!(current_platform(), PlatformFlavor::Windows) { + tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms)); + } else if matches!(current_platform(), PlatformFlavor::Macos) { + tokio::spawn(macos_wifi_task(state.clone(), args.tick_ms)); + } else { + error!("Wi-Fi source selected on unsupported platform"); + std::process::exit(1); + } } - _ => { + ResolvedSource::Simulate => { tokio::spawn(simulated_data_task(state.clone(), args.tick_ms)); } } // ADR-050: Parse bind address once, use for all listeners - let bind_ip: std::net::IpAddr = args.bind_addr.parse() + let bind_ip: std::net::IpAddr = args + .bind_addr + .parse() .expect("Invalid --bind-addr (use 127.0.0.1 or 0.0.0.0)"); // WebSocket server on dedicated port (8765) @@ -3636,7 +4138,8 @@ async fn main() { .with_state(ws_state); let ws_addr = SocketAddr::from((bind_ip, args.ws_port)); - let ws_listener = tokio::net::TcpListener::bind(ws_addr).await + let ws_listener = tokio::net::TcpListener::bind(ws_addr) + .await .expect("Failed to bind WebSocket port"); info!("WebSocket server listening on {ws_addr}"); @@ -3711,20 +4214,23 @@ async fn main() { .with_state(state.clone()); let http_addr = SocketAddr::from((bind_ip, args.http_port)); - let http_listener = tokio::net::TcpListener::bind(http_addr).await + let http_listener = tokio::net::TcpListener::bind(http_addr) + .await .expect("Failed to bind HTTP port"); info!("HTTP server listening on {http_addr}"); - info!("Open http://localhost:{}/ui/index.html in your browser", args.http_port); + info!( + "Open http://localhost:{}/ui/index.html in your browser", + args.http_port + ); // Run the HTTP server with graceful shutdown support let shutdown_state = state.clone(); - let server = axum::serve(http_listener, http_app) - .with_graceful_shutdown(async { - tokio::signal::ctrl_c() - .await - .expect("failed to install CTRL+C handler"); - info!("Shutdown signal received"); - }); + let server = axum::serve(http_listener, http_app).with_graceful_shutdown(async { + tokio::signal::ctrl_c() + .await + .expect("failed to install CTRL+C handler"); + info!("Shutdown signal received"); + }); server.await.unwrap(); @@ -3763,3 +4269,42 @@ async fn main() { info!("Server shut down cleanly"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_source_prefers_esp32() { + let resolved = resolve_auto_source(PlatformFlavor::Macos, true, true); + assert_eq!(resolved, ResolvedSource::Esp32); + } + + #[test] + fn auto_source_uses_wifi_on_supported_platforms() { + assert_eq!( + resolve_auto_source(PlatformFlavor::Macos, false, true), + ResolvedSource::Wifi + ); + assert_eq!( + resolve_auto_source(PlatformFlavor::Windows, false, true), + ResolvedSource::Wifi + ); + } + + #[test] + fn auto_source_skips_wifi_on_unsupported_platforms() { + assert_eq!( + resolve_auto_source(PlatformFlavor::Other, false, true), + ResolvedSource::Simulate + ); + } + + #[test] + fn explicit_wifi_rejects_unsupported_platforms() { + let err = resolve_explicit_source(RequestedSource::Wifi, PlatformFlavor::Other, false) + .expect_err("unsupported platforms should reject wifi"); + assert!(err.contains("only supported on Windows and macOS")); + } + +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/Cargo.toml b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/Cargo.toml index 4158655653..bfc36f3247 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/Cargo.toml +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/Cargo.toml @@ -17,6 +17,7 @@ tracing.workspace = true # Serialization (optional, for domain types) serde = { workspace = true, optional = true } +serde_json.workspace = true # Async runtime (optional, for Tier 2 async scanning) tokio = { workspace = true, optional = true } @@ -28,6 +29,9 @@ pipeline = [] ## Tier 2: enables async scan_async() method on WlanApiScanner via tokio wlanapi = ["dep:tokio"] +[dev-dependencies] +tempfile = "3.10" + [lints.rust] unsafe_code = "forbid" diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs index be3d045e80..1654018c42 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs @@ -1,78 +1,134 @@ -//! Adapter that scans WiFi BSSIDs on macOS by invoking a compiled Swift -//! helper binary that uses Apple's CoreWLAN framework. +//! Adapter that scans WiFi BSSIDs on macOS by invoking the canonical Swift +//! CoreWLAN helper. //! -//! This is the macOS counterpart to [`NetshBssidScanner`](super::NetshBssidScanner) -//! on Windows. It follows ADR-025 (ORCA — macOS CoreWLAN WiFi Sensing). +//! The helper lives at `tools/macos-wifi-scan/main.swift` and is built by +//! `scripts/build-mac-wifi.sh` into the Rust workspace target tree. This +//! adapter resolves the helper path in the following order: //! -//! # Design -//! -//! Apple removed the `airport` CLI in macOS Sonoma 14.4+ and CoreWLAN is a -//! Swift/Objective-C framework with no stable C ABI for Rust FFI. We therefore -//! shell out to a small Swift helper (`mac_wifi`) that outputs JSON lines: -//! -//! ```json -//! {"ssid":"MyNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"} -//! ``` -//! -//! macOS Sonoma+ redacts real BSSID MACs to `00:00:00:00:00:00` unless the app -//! holds the `com.apple.wifi.scan` entitlement. When we detect a zeroed BSSID -//! we generate a deterministic synthetic MAC via `SHA-256(ssid:channel)[:6]`, -//! setting the locally-administered bit so it never collides with real OUI -//! allocations. -//! -//! # Platform -//! -//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level. +//! 1. `RUVIEW_MAC_WIFI_HELPER` +//! 2. `target/tools/macos-wifi-scan/macos-wifi-scan` +//! 3. `macos-wifi-scan` on `PATH` +use std::ffi::OsString; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Instant; +use serde::Deserialize; + use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType}; use crate::error::WifiScanError; +use crate::port::WlanScanPort; + +const HELPER_ENV_VAR: &str = "RUVIEW_MAC_WIFI_HELPER"; +const HELPER_BINARY_NAME: &str = "macos-wifi-scan"; +const REPO_LOCAL_HELPER_REL: &str = "target/tools/macos-wifi-scan/macos-wifi-scan"; -// --------------------------------------------------------------------------- -// MacosCoreWlanScanner -// --------------------------------------------------------------------------- - -/// Synchronous WiFi scanner that shells out to the `mac_wifi` Swift helper. -/// -/// The helper binary must be compiled from `v1/src/sensing/mac_wifi.swift` and -/// placed on `$PATH` or at a known location. The scanner invokes it with a -/// `--scan-once` flag (single-shot mode) and parses the JSON output. -/// -/// If the helper is not found, [`scan_sync`](Self::scan_sync) returns a -/// [`WifiScanError::ProcessError`]. +#[derive(Debug, Deserialize)] +struct ProbeStatus { + ok: bool, + interface: String, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct HelperObservation { + 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, +} + +/// Synchronous WiFi scanner that shells out to the Swift helper. +#[derive(Debug, Clone)] pub struct MacosCoreWlanScanner { - /// Path to the `mac_wifi` helper binary. Defaults to `"mac_wifi"` (on PATH). - helper_path: String, + helper_path: PathBuf, } impl MacosCoreWlanScanner { - /// Create a scanner that looks for `mac_wifi` on `$PATH`. + /// Create a scanner using the standard helper resolution order. pub fn new() -> Self { Self { - helper_path: "mac_wifi".to_owned(), + helper_path: resolve_helper_path_for( + workspace_root().as_path(), + std::env::var_os(HELPER_ENV_VAR), + ), } } - /// Create a scanner with an explicit path to the Swift helper binary. - pub fn with_path(path: impl Into) -> Self { + /// Create a scanner with an explicit helper path. + pub fn with_path(path: impl Into) -> Self { Self { helper_path: path.into(), } } - /// Run the Swift helper and parse the output synchronously. - /// - /// Returns one [`BssidObservation`] per BSSID seen in the scan. + /// Return the resolved helper path. + pub fn helper_path(&self) -> &Path { + &self.helper_path + } + + /// Verify that the helper can reach CoreWLAN and report interface readiness. + pub fn probe_sync(&self) -> Result<(), WifiScanError> { + let output = self.run_helper(["--probe"])?; + let line = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .ok_or_else(|| { + WifiScanError::ParseError("macOS helper probe returned no JSON status".to_string()) + })?; + let status: ProbeStatus = serde_json::from_str(line).map_err(|err| { + WifiScanError::ParseError(format!("probe output is not valid JSON: {err}")) + })?; + + if status.ok { + Ok(()) + } else { + Err(WifiScanError::ScanFailed { + reason: format!( + "probe failed on interface {}: {}", + status.interface, + status + .message + .unwrap_or_else(|| "helper reported Wi-Fi unavailable".to_string()) + ), + }) + } + } + + /// Run one visible-network scan. pub fn scan_sync(&self) -> Result, WifiScanError> { + let output = self.run_helper(["--scan-once"])?; + parse_macos_scan_output(&output) + } + + /// Return the currently connected AP, if any. + pub fn connected_sync(&self) -> Result, WifiScanError> { + match self.run_helper(["--connected"]) { + Ok(output) => Ok(parse_macos_scan_output(&output)?.into_iter().next()), + Err(WifiScanError::ScanFailed { reason }) if is_not_connected_reason(&reason) => { + Ok(None) + } + Err(err) => Err(err), + } + } + + fn run_helper(&self, args: [&str; N]) -> Result { let output = Command::new(&self.helper_path) - .arg("--scan-once") + .args(args) .output() - .map_err(|e| { + .map_err(|err| { WifiScanError::ProcessError(format!( - "failed to run mac_wifi helper ({}): {e}", - self.helper_path + "failed to run macOS Wi-Fi helper '{}': {err}. Build it with scripts/build-mac-wifi.sh or set {HELPER_ENV_VAR}.", + self.helper_path.display() )) })?; @@ -80,15 +136,18 @@ impl MacosCoreWlanScanner { let stderr = String::from_utf8_lossy(&output.stderr); return Err(WifiScanError::ScanFailed { reason: format!( - "mac_wifi exited with {}: {}", + "macOS Wi-Fi helper '{}' exited {} while running {}: {}", + self.helper_path.display(), output.status, + args.join(" "), stderr.trim() ), }); } - let stdout = String::from_utf8_lossy(&output.stdout); - parse_macos_scan_output(&stdout) + String::from_utf8(output.stdout).map_err(|err| { + WifiScanError::ParseError(format!("macOS Wi-Fi helper emitted invalid UTF-8: {err}")) + }) } } @@ -98,263 +157,218 @@ impl Default for MacosCoreWlanScanner { } } -// --------------------------------------------------------------------------- -// Parser -// --------------------------------------------------------------------------- - -/// Parse the JSON-lines output from the `mac_wifi` Swift helper. -/// -/// Each line is expected to be a JSON object with the fields: -/// `ssid`, `bssid`, `rssi`, `noise`, `channel`, `band`. -/// -/// Lines that fail to parse are silently skipped (the helper may emit -/// status messages on stdout). +impl WlanScanPort for MacosCoreWlanScanner { + fn scan(&self) -> Result, WifiScanError> { + self.scan_sync() + } + + fn connected(&self) -> Result, WifiScanError> { + self.connected_sync() + } +} + +/// Parse the NDJSON output from the canonical macOS helper. pub fn parse_macos_scan_output(output: &str) -> Result, WifiScanError> { - let now = Instant::now(); - let mut results = Vec::new(); + let timestamp = Instant::now(); + let mut observations = Vec::new(); - for line in output.lines() { + for (line_index, line) in output.lines().enumerate() { let line = line.trim(); - if line.is_empty() || !line.starts_with('{') { + if line.is_empty() { continue; } - if let Some(obs) = parse_json_line(line, now) { - results.push(obs); - } + let record: HelperObservation = serde_json::from_str(line).map_err(|err| { + WifiScanError::ParseError(format!( + "line {} is not valid helper JSON: {err}", + line_index + 1 + )) + })?; + observations.push(helper_observation_to_domain(record, timestamp)?); } - Ok(results) + Ok(observations) } -/// Parse a single JSON line into a [`BssidObservation`]. -/// -/// Uses a lightweight manual parser to avoid pulling in `serde_json` as a -/// hard dependency. The JSON structure is simple and well-known. -fn parse_json_line(line: &str, timestamp: Instant) -> Option { - let ssid = extract_string_field(line, "ssid")?; - let bssid_str = extract_string_field(line, "bssid")?; - let rssi = extract_number_field(line, "rssi")?; - let channel_f = extract_number_field(line, "channel")?; - let channel = channel_f as u8; - - // Resolve BSSID: use real MAC if available, otherwise generate synthetic. - let bssid = resolve_bssid(&bssid_str, &ssid, channel)?; - - let band = BandType::from_channel(channel); - - // macOS CoreWLAN doesn't report radio type directly; infer from band/channel. - let radio_type = infer_radio_type(channel); - - // Convert RSSI to signal percentage using the standard mapping. - let signal_pct = ((rssi + 100.0) * 2.0).clamp(0.0, 100.0); +fn helper_observation_to_domain( + record: HelperObservation, + timestamp: Instant, +) -> Result { + if record.channel == 0 { + return Err(WifiScanError::ParseError( + "field `channel` must be greater than 0".to_string(), + )); + } - Some(BssidObservation { + let _ = ( + record.timestamp, + record.interface.as_str(), + record.bssid_synthetic, + record.noise, + record.tx_rate_mbps, + record.is_connected, + ); + + let bssid = BssidId::parse(&record.bssid).map_err(|_| { + WifiScanError::ParseError(format!( + "field `bssid` is not a valid MAC address: {}", + record.bssid + )) + })?; + let band = parse_band_label(&record.band, record.channel)?; + + Ok(BssidObservation { bssid, - rssi_dbm: rssi, - signal_pct, - channel, + rssi_dbm: record.rssi, + signal_pct: ((record.rssi + 100.0) * 2.0).clamp(0.0, 100.0), + channel: record.channel, band, - radio_type, - ssid, + radio_type: infer_radio_type(record.channel, band), + ssid: record.ssid, timestamp, }) } -/// Resolve a BSSID string to a [`BssidId`]. -/// -/// If the MAC is all-zeros (macOS redaction), generate a synthetic -/// locally-administered MAC from `SHA-256(ssid:channel)`. -fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option { - // Try parsing the real BSSID first. - if let Ok(id) = BssidId::parse(bssid_str) { - // Check for the all-zeros redacted BSSID. - if id.0 != [0, 0, 0, 0, 0, 0] { - return Some(id); - } +fn parse_band_label(label: &str, channel: u8) -> Result { + let normalized = label.trim().to_ascii_lowercase(); + match normalized.as_str() { + "2.4ghz" | "2.4 ghz" | "2.4" => Ok(BandType::Band2_4GHz), + "5ghz" | "5 ghz" | "5" => Ok(BandType::Band5GHz), + "6ghz" | "6 ghz" | "6" => Ok(BandType::Band6GHz), + "" => Ok(BandType::from_channel(channel)), + _ => Err(WifiScanError::ParseError(format!( + "field `band` must be one of 2.4GHz, 5GHz, or 6GHz; got '{label}'" + ))), } - - // Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes, - // set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear). - Some(synthetic_bssid(ssid, channel)) } -/// Generate a deterministic synthetic BSSID from SSID and channel. -/// -/// Uses a simple hash (FNV-1a-inspired) to avoid pulling in `sha2` crate. -/// The locally-administered bit is set so these never collide with real OUI MACs. -fn synthetic_bssid(ssid: &str, channel: u8) -> BssidId { - // Simple but deterministic hash — FNV-1a 64-bit. - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for &byte in ssid.as_bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0100_0000_01b3); +fn infer_radio_type(channel: u8, band: BandType) -> RadioType { + match band { + BandType::Band6GHz => RadioType::Ax, + BandType::Band5GHz if channel >= 149 => RadioType::Ax, + BandType::Band5GHz => RadioType::Ac, + BandType::Band2_4GHz => RadioType::N, } - hash ^= u64::from(channel); - hash = hash.wrapping_mul(0x0100_0000_01b3); - - let bytes = hash.to_le_bytes(); - let mut mac = [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]]; - - // Set locally-administered bit (bit 1 of byte 0) and clear multicast (bit 0). - mac[0] = (mac[0] | 0x02) & 0xFE; - - BssidId(mac) } -/// Infer radio type from channel number (best effort on macOS). -fn infer_radio_type(channel: u8) -> RadioType { - match channel { - // 5 GHz channels → likely 802.11ac or newer - 36..=177 => RadioType::Ac, - // 2.4 GHz → at least 802.11n - _ => RadioType::N, - } +fn is_not_connected_reason(reason: &str) -> bool { + let lower = reason.to_ascii_lowercase(); + lower.contains("not connected to an access point") + || lower.contains("waiting for wi-fi association") } -// --------------------------------------------------------------------------- -// Lightweight JSON field extractors -// --------------------------------------------------------------------------- - -/// Extract a string field value from a JSON object string. -/// -/// Looks for `"key":"value"` or `"key": "value"` patterns. -fn extract_string_field(json: &str, key: &str) -> Option { - let pattern = format!("\"{}\"", key); - let key_pos = json.find(&pattern)?; - let after_key = &json[key_pos + pattern.len()..]; - - // Skip optional whitespace and the colon. - let after_colon = after_key.trim_start().strip_prefix(':')?; - let after_colon = after_colon.trim_start(); - - // Expect opening quote. - let after_quote = after_colon.strip_prefix('"')?; - - // Find closing quote (handle escaped quotes). - let mut end = 0; - let bytes = after_quote.as_bytes(); - while end < bytes.len() { - if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') { - break; - } - end += 1; - } - - Some(after_quote[..end].to_owned()) +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR"))) + .to_path_buf() } -/// Extract a numeric field value from a JSON object string. -/// -/// Looks for `"key": ` patterns. -fn extract_number_field(json: &str, key: &str) -> Option { - let pattern = format!("\"{}\"", key); - let key_pos = json.find(&pattern)?; - let after_key = &json[key_pos + pattern.len()..]; - - let after_colon = after_key.trim_start().strip_prefix(':')?; - let after_colon = after_colon.trim_start(); +fn resolve_helper_path_for(workspace_root: &Path, env_override: Option) -> PathBuf { + if let Some(env_override) = env_override.filter(|value| !value.is_empty()) { + return PathBuf::from(env_override); + } - // Collect digits, sign, and decimal point. - let num_str: String = after_colon - .chars() - .take_while(|c| c.is_ascii_digit() || *c == '-' || *c == '.' || *c == '+' || *c == 'e' || *c == 'E') - .collect(); + let repo_local = workspace_root.join(REPO_LOCAL_HELPER_REL); + if repo_local.is_file() { + return repo_local; + } - num_str.parse().ok() + PathBuf::from(HELPER_BINARY_NAME) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; - const SAMPLE_OUTPUT: &str = r#" -{"ssid":"HomeNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"} -{"ssid":"GuestWifi","bssid":"11:22:33:44:55:66","rssi":-71,"noise":-92,"channel":6,"band":"2.4GHz"} -{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"} -"#; + fn unique_temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "ruview-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + path + } + + const SAMPLE_OUTPUT: &str = r#"{"timestamp":1710000000.0,"interface":"en0","ssid":"Home","bssid":"aa:bb:cc:dd:ee:ff","bssid_synthetic":false,"rssi":-52.0,"noise":-90.0,"channel":36,"band":"5GHz","tx_rate_mbps":866.7,"is_connected":true} +{"timestamp":1710000001.0,"interface":"en0","ssid":"Guest","bssid":"11:22:33:44:55:66","bssid_synthetic":false,"rssi":-71.0,"noise":-92.0,"channel":6,"band":"2.4GHz","tx_rate_mbps":144.0,"is_connected":false}"#; #[test] - fn parse_valid_output() { - let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap(); - assert_eq!(obs.len(), 3); - - // First entry: real BSSID. - assert_eq!(obs[0].ssid, "HomeNetwork"); - assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff"); - assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON); - assert_eq!(obs[0].channel, 36); - assert_eq!(obs[0].band, BandType::Band5GHz); - - // Second entry: 2.4 GHz. - assert_eq!(obs[1].ssid, "GuestWifi"); - assert_eq!(obs[1].channel, 6); - assert_eq!(obs[1].band, BandType::Band2_4GHz); - assert_eq!(obs[1].radio_type, RadioType::N); - - // Third entry: redacted BSSID → synthetic MAC. - assert_eq!(obs[2].ssid, "Redacted"); - // Should NOT be all-zeros. - assert_ne!(obs[2].bssid.0, [0, 0, 0, 0, 0, 0]); - // Should have locally-administered bit set. - assert_eq!(obs[2].bssid.0[0] & 0x02, 0x02); - // Should have unicast bit (multicast cleared). - assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00); + fn parse_helper_output_uses_contract_fields() { + let observations = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap(); + assert_eq!(observations.len(), 2); + assert_eq!(observations[0].ssid, "Home"); + assert_eq!(observations[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff"); + assert_eq!(observations[0].band, BandType::Band5GHz); + assert_eq!(observations[0].radio_type, RadioType::Ac); + assert_eq!(observations[1].band, BandType::Band2_4GHz); + assert_eq!(observations[1].radio_type, RadioType::N); } #[test] - fn synthetic_bssid_is_deterministic() { - let a = synthetic_bssid("TestNet", 36); - let b = synthetic_bssid("TestNet", 36); - assert_eq!(a, b); + fn parse_helper_output_reports_missing_fields() { + let err = parse_macos_scan_output( + r#"{"timestamp":1710000000.0,"interface":"en0","ssid":"Home","rssi":-52.0,"noise":-90.0,"channel":36,"band":"5GHz","tx_rate_mbps":866.7,"is_connected":true}"#, + ) + .unwrap_err(); + + assert!(err.to_string().contains("line 1")); + assert!(err.to_string().contains("bssid")); + } - // Different SSID or channel → different MAC. - let c = synthetic_bssid("OtherNet", 36); - assert_ne!(a, c); + #[test] + fn probe_status_json_is_supported() { + let status: ProbeStatus = + serde_json::from_str(r#"{"ok":true,"interface":"en0","message":"ready"}"#).unwrap(); + assert!(status.ok); + assert_eq!(status.interface, "en0"); + } - let d = synthetic_bssid("TestNet", 6); - assert_ne!(a, d); + #[test] + fn helper_path_prefers_env_override() { + let workspace = unique_temp_dir("env-path"); + let resolved = + resolve_helper_path_for(&workspace, Some(OsString::from("/tmp/custom-helper"))); + assert_eq!(resolved, PathBuf::from("/tmp/custom-helper")); + std::fs::remove_dir_all(workspace).unwrap(); } #[test] - fn parse_empty_and_junk_lines() { - let output = "\n \nnot json\n{broken json\n"; - let obs = parse_macos_scan_output(output).unwrap(); - assert!(obs.is_empty()); + fn helper_path_uses_repo_local_binary_when_present() { + let workspace = unique_temp_dir("repo-path"); + let helper = workspace.join(REPO_LOCAL_HELPER_REL); + std::fs::create_dir_all(helper.parent().unwrap()).unwrap(); + std::fs::write(&helper, b"binary").unwrap(); + + let resolved = resolve_helper_path_for(&workspace, None); + assert_eq!(resolved, helper); + + std::fs::remove_dir_all(workspace).unwrap(); } #[test] - fn extract_string_field_basic() { - let json = r#"{"ssid":"MyNet","bssid":"aa:bb:cc:dd:ee:ff"}"#; - assert_eq!(extract_string_field(json, "ssid").unwrap(), "MyNet"); - assert_eq!( - extract_string_field(json, "bssid").unwrap(), - "aa:bb:cc:dd:ee:ff" - ); - assert!(extract_string_field(json, "missing").is_none()); + fn helper_path_falls_back_to_path_binary() { + let workspace = unique_temp_dir("path-fallback"); + let resolved = resolve_helper_path_for(&workspace, None); + assert_eq!(resolved, PathBuf::from(HELPER_BINARY_NAME)); + std::fs::remove_dir_all(workspace).unwrap(); } #[test] - fn extract_number_field_basic() { - let json = r#"{"rssi":-52,"channel":36}"#; - assert!((extract_number_field(json, "rssi").unwrap() - (-52.0)).abs() < f64::EPSILON); - assert!((extract_number_field(json, "channel").unwrap() - 36.0).abs() < f64::EPSILON); + fn compile_time_trait_check() { + fn assert_port() {} + assert_port::(); } #[test] - fn signal_pct_clamping() { - // RSSI -50 → pct = (-50+100)*2 = 100 - let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-50,"channel":1}"#; - let obs = parse_json_line(json, Instant::now()).unwrap(); - assert!((obs.signal_pct - 100.0).abs() < f64::EPSILON); - - // RSSI -100 → pct = 0 - let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-100,"channel":1}"#; - let obs = parse_json_line(json, Instant::now()).unwrap(); - assert!((obs.signal_pct - 0.0).abs() < f64::EPSILON); + fn connected_failure_reason_is_recognized() { + assert!(is_not_connected_reason( + "macOS Wi-Fi helper '/tmp/helper' exited 1 while running --connected: Wi-Fi interface en0 is not connected to an access point" + )); } } diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs index abdb176a56..c71e4f6f2c 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/adapter/mod.rs @@ -6,25 +6,20 @@ //! - [`MacosCoreWlanScanner`]: CoreWLAN via Swift helper binary (macOS, ADR-025). //! - [`LinuxIwScanner`]: parses `iw dev scan` output (Linux). +pub mod macos_scanner; pub(crate) mod netsh_scanner; pub mod wlanapi_scanner; -#[cfg(target_os = "macos")] -pub mod macos_scanner; - #[cfg(target_os = "linux")] pub mod linux_scanner; -pub use netsh_scanner::NetshBssidScanner; +pub use macos_scanner::parse_macos_scan_output; +pub use macos_scanner::MacosCoreWlanScanner; pub use netsh_scanner::parse_netsh_output; +pub use netsh_scanner::NetshBssidScanner; pub use wlanapi_scanner::WlanApiScanner; -#[cfg(target_os = "macos")] -pub use macos_scanner::MacosCoreWlanScanner; -#[cfg(target_os = "macos")] -pub use macos_scanner::parse_macos_scan_output; - -#[cfg(target_os = "linux")] -pub use linux_scanner::LinuxIwScanner; #[cfg(target_os = "linux")] pub use linux_scanner::parse_iw_scan_output; +#[cfg(target_os = "linux")] +pub use linux_scanner::LinuxIwScanner; diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs index f1ebabbbc9..01cd0c3cf5 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-wifiscan/src/lib.rs @@ -18,19 +18,16 @@ pub mod pipeline; pub mod port; // Re-export key types at the crate root for convenience. -pub use adapter::NetshBssidScanner; +pub use adapter::parse_macos_scan_output; pub use adapter::parse_netsh_output; -pub use adapter::WlanApiScanner; - -#[cfg(target_os = "macos")] pub use adapter::MacosCoreWlanScanner; -#[cfg(target_os = "macos")] -pub use adapter::parse_macos_scan_output; +pub use adapter::NetshBssidScanner; +pub use adapter::WlanApiScanner; -#[cfg(target_os = "linux")] -pub use adapter::LinuxIwScanner; #[cfg(target_os = "linux")] pub use adapter::parse_iw_scan_output; +#[cfg(target_os = "linux")] +pub use adapter::LinuxIwScanner; pub use domain::bssid::{BandType, BssidId, BssidObservation, RadioType}; pub use domain::frame::MultiApFrame; pub use domain::registry::{BssidEntry, BssidMeta, BssidRegistry, RunningStats}; diff --git a/scripts/build-mac-wifi.sh b/scripts/build-mac-wifi.sh new file mode 100755 index 0000000000..7ab2c04431 --- /dev/null +++ b/scripts/build-mac-wifi.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "build-mac-wifi.sh must be run on macOS" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_PATH="${REPO_ROOT}/tools/macos-wifi-scan/main.swift" +OUTPUT_DIR="${REPO_ROOT}/rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan" +OUTPUT_PATH="${OUTPUT_DIR}/macos-wifi-scan" + +mkdir -p "${OUTPUT_DIR}" + +swiftc \ + -O \ + -framework Foundation \ + -framework CoreWLAN \ + "${SOURCE_PATH}" \ + -o "${OUTPUT_PATH}" + +echo "Built macOS Wi-Fi helper: ${OUTPUT_PATH}" diff --git a/tools/macos-wifi-scan/main.swift b/tools/macos-wifi-scan/main.swift new file mode 100644 index 0000000000..5a9130e1a8 --- /dev/null +++ b/tools/macos-wifi-scan/main.swift @@ -0,0 +1,373 @@ +import Foundation +import CoreWLAN +import CryptoKit + +private enum HelperError: Error, CustomStringConvertible { + case usage(String) + case noInterface + case wifiPoweredOff(String) + case notConnected(String) + case scanFailed(String) + + var description: String { + switch self { + case let .usage(message): + return message + case .noInterface: + return "No active Wi-Fi interface found" + case let .wifiPoweredOff(name): + return "Wi-Fi interface \(name) is powered off" + case let .notConnected(name): + return "Wi-Fi interface \(name) is not connected to an access point" + case let .scanFailed(message): + return "CoreWLAN scan failed: \(message)" + } + } +} + +private enum Mode { + case probe + case scanOnce + case connected + case stream(intervalMs: UInt64) +} + +private struct Observation: Encodable { + let timestamp: Double + let interface: String + let ssid: String + let bssid: String + let bssidSynthetic: Bool + let rssi: Int + let noise: Int + let channel: Int + let band: String + let txRateMbps: Double + let isConnected: Bool + + enum CodingKeys: String, CodingKey { + case timestamp + case interface + case ssid + case bssid + case bssidSynthetic = "bssid_synthetic" + case rssi + case noise + case channel + case band + case txRateMbps = "tx_rate_mbps" + case isConnected = "is_connected" + } +} + +private struct ProbeStatus: Encodable { + let ok: Bool + let interface: String + let message: String? +} + +private struct Arguments { + let mode: Mode + + static func parse(_ argv: [String]) throws -> Arguments { + var mode: Mode? + var intervalMs: UInt64 = 200 + + var index = 1 + while index < argv.count { + switch argv[index] { + case "--probe": + mode = try assign(mode, value: .probe, flag: "--probe") + case "--scan-once": + mode = try assign(mode, value: .scanOnce, flag: "--scan-once") + case "--connected": + mode = try assign(mode, value: .connected, flag: "--connected") + case "--stream": + mode = try assign(mode, value: .stream(intervalMs: intervalMs), flag: "--stream") + case "--interval-ms": + index += 1 + guard index < argv.count, let parsed = UInt64(argv[index]), parsed > 0 else { + throw HelperError.usage("Expected a positive integer after --interval-ms") + } + intervalMs = parsed + case "--help", "-h": + throw HelperError.usage(""" + Usage: macos-wifi-scan [--probe|--scan-once|--connected|--stream] [--interval-ms N] + --probe Verify CoreWLAN access and emit one status JSON line. + --scan-once Scan visible networks and emit one JSON line per BSSID. + --connected Emit one JSON line for the currently associated network. + --stream Emit repeated connected-network observations. + --interval-ms Stream interval in milliseconds (default: 200). + """) + default: + throw HelperError.usage("Unknown argument: \(argv[index])") + } + index += 1 + } + + switch mode { + case .stream?: + return Arguments(mode: .stream(intervalMs: intervalMs)) + case let selected?: + return Arguments(mode: selected) + case nil: + throw HelperError.usage("Expected one of --probe, --scan-once, --connected, or --stream") + } + } + + private static func assign(_ current: Mode?, value: Mode, flag: String) throws -> Mode { + guard current == nil else { + throw HelperError.usage("Specify only one mode flag; duplicate or conflicting flag: \(flag)") + } + return value + } +} + +private final class WifiHelper { + private let encoder: JSONEncoder + + init() { + encoder = JSONEncoder() + } + + func run(_ mode: Mode) throws { + switch mode { + case .probe: + try emitProbeStatus() + case .scanOnce: + let observations = try scanObservations() + guard !observations.isEmpty else { + throw HelperError.scanFailed("no visible networks returned by CoreWLAN") + } + try observations.forEach(emit) + case .connected: + let observation = try connectedObservation() + try emit(observation) + case let .stream(intervalMs): + try streamObservations(intervalMs: intervalMs) + } + } + + private func emitProbeStatus() throws { + let interface = try requireInterface() + let interfaceName = interface.interfaceName ?? "unknown" + let message: String? + + if let ssid = interface.ssid(), !ssid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + message = "connected:\(ssid)" + } else { + message = "ready" + } + + let payload = ProbeStatus(ok: true, interface: interfaceName, message: message) + let data = try encoder.encode(payload) + guard let line = String(data: data, encoding: .utf8) else { + throw HelperError.scanFailed("failed to encode probe JSON output") + } + print(line) + } + + private func streamObservations(intervalMs: UInt64) throws { + setbuf(stdout, nil) + + while true { + do { + let observation = try connectedObservation() + try emit(observation) + } catch HelperError.notConnected { + fputs("macos-wifi-scan: waiting for Wi-Fi association\n", stderr) + } catch { + throw error + } + + Thread.sleep(forTimeInterval: TimeInterval(intervalMs) / 1000.0) + } + } + + private func scanObservations() throws -> [Observation] { + let interface = try requireInterface() + let interfaceName = interface.interfaceName ?? "unknown" + + let networks: Set + do { + networks = try interface.scanForNetworks(withName: nil) + } catch { + throw HelperError.scanFailed(error.localizedDescription) + } + + let connectedBssid = normalizedRealBssid(interface.bssid()) + let connectedSsid = interface.ssid() ?? "" + let txRate = interface.transmitRate() + + let observations = networks + .map { network in + makeObservation( + interfaceName: interfaceName, + ssid: network.ssid ?? "", + rawBssid: network.bssid, + rssi: network.rssiValue, + noise: network.noiseMeasurement, + channelNumber: Int(network.wlanChannel?.channelNumber ?? 0), + channelBand: network.wlanChannel?.channelBand, + txRateMbps: txRate, + isConnected: connectedBssid != nil && normalizedRealBssid(network.bssid) == connectedBssid + || (!connectedSsid.isEmpty && connectedSsid == (network.ssid ?? "")) + ) + } + .sorted { + if $0.isConnected != $1.isConnected { + return $0.isConnected && !$1.isConnected + } + if $0.rssi != $1.rssi { + return $0.rssi > $1.rssi + } + return $0.ssid.localizedCaseInsensitiveCompare($1.ssid) == .orderedAscending + } + + return observations + } + + private func connectedObservation() throws -> Observation { + let interface = try requireInterface() + let interfaceName = interface.interfaceName ?? "unknown" + + guard let ssid = interface.ssid(), !ssid.isEmpty else { + throw HelperError.notConnected(interfaceName) + } + + let channel = interface.wlanChannel() + return makeObservation( + interfaceName: interfaceName, + ssid: ssid, + rawBssid: interface.bssid(), + rssi: interface.rssiValue(), + noise: interface.noiseMeasurement(), + channelNumber: Int(channel?.channelNumber ?? 0), + channelBand: channel?.channelBand, + txRateMbps: interface.transmitRate(), + isConnected: true + ) + } + + private func requireInterface() throws -> CWInterface { + guard let interface = CWWiFiClient.shared().interface() else { + throw HelperError.noInterface + } + let interfaceName = interface.interfaceName ?? "unknown" + if !interface.powerOn() { + throw HelperError.wifiPoweredOff(interfaceName) + } + return interface + } + + private func emit(_ observation: Observation) throws { + let data = try encoder.encode(observation) + guard let line = String(data: data, encoding: .utf8) else { + throw HelperError.scanFailed("failed to encode JSON output") + } + print(line) + } + + private func makeObservation( + interfaceName: String, + ssid: String, + rawBssid: String?, + rssi: Int, + noise: Int, + channelNumber: Int, + channelBand: CWChannelBand?, + txRateMbps: Double, + isConnected: Bool + ) -> Observation { + let normalizedSsid = ssid.trimmingCharacters(in: .whitespacesAndNewlines) + let realBssid = normalizedRealBssid(rawBssid) + let resolvedBssid: String + let synthetic: Bool + + if let realBssid { + resolvedBssid = realBssid + synthetic = false + } else { + resolvedBssid = syntheticBssid( + interfaceName: interfaceName, + ssid: normalizedSsid, + channel: channelNumber + ) + synthetic = true + } + + return Observation( + timestamp: Date().timeIntervalSince1970, + interface: interfaceName, + ssid: normalizedSsid, + bssid: resolvedBssid, + bssidSynthetic: synthetic, + rssi: rssi, + noise: noise, + channel: channelNumber, + band: stringifyBand(channelBand), + txRateMbps: txRateMbps, + isConnected: isConnected + ) + } + + private func normalizedRealBssid(_ rawValue: String?) -> String? { + guard let rawValue else { + return nil + } + let normalized = rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard normalized.count == 17 else { + return nil + } + guard normalized != "00:00:00:00:00:00" else { + return nil + } + let parts = normalized.split(separator: ":") + guard parts.count == 6 else { + return nil + } + for part in parts where part.count != 2 || UInt8(part, radix: 16) == nil { + return nil + } + return normalized + } + + private func syntheticBssid(interfaceName: String, ssid: String, channel: Int) -> String { + let material = "\(interfaceName)|\(ssid.isEmpty ? "" : ssid)|\(channel)" + let digest = SHA256.hash(data: Data(material.utf8)) + var bytes = Array(digest.prefix(6)) + bytes[0] = (bytes[0] | 0x02) & 0xFE + return bytes.map { String(format: "%02x", $0) }.joined(separator: ":") + } + + private func stringifyBand(_ band: CWChannelBand?) -> String { + switch band { + case .band2GHz: + return "2.4ghz" + case .band5GHz: + return "5ghz" + case .band6GHz: + return "6ghz" + default: + return "" + } + } +} + +private func main() -> Int32 { + do { + let args = try Arguments.parse(CommandLine.arguments) + try WifiHelper().run(args.mode) + return EXIT_SUCCESS + } catch let error as HelperError { + fputs("macos-wifi-scan: \(error.description)\n", stderr) + return EXIT_FAILURE + } catch { + fputs("macos-wifi-scan: \(error.localizedDescription)\n", stderr) + return EXIT_FAILURE + } +} + +exit(main()) From 9b5db0beadc600cda23787313c06692e60b21137 Mon Sep 17 00:00:00 2001 From: AIFlow_ML Date: Sun, 8 Mar 2026 16:54:59 +0100 Subject: [PATCH 2/2] feat(macos): add explicit wifi bridge source --- CHANGELOG.md | 2 +- README.md | 6 +- .../ADR-025-macos-corewlan-wifi-sensing.md | 10 + docs/user-guide.md | 10 +- .../wifi-densepose-sensing-server/src/main.rs | 235 +++++++++++++++++- scripts/macos_wifi_bridge.py | 149 +++++++++++ 6 files changed, 407 insertions(+), 5 deletions(-) create mode 100755 scripts/macos_wifi_bridge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bac6a5a12..0a93625c44 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 212b4cacd0..4194143ce9 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 6d9ef3632b..6bf1fb52d3 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 60c248d54a..efcf731ddb 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 1e90ce4026..8aeabd803d 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 0000000000..97a226f1e6 --- /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())