This commit is contained in:
AIflow Labs Limited 2026-03-21 15:57:43 +00:00 committed by GitHub
commit 9e894084c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2115 additions and 761 deletions

View File

@ -76,8 +76,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 bridge tooling with canonical Swift helper (`tools/macos-wifi-scan/main.swift`) and explicit `macos-bridge` UDP source
- macOS synthetic BSSID generation (SHA-256 with locally administered MACs) for Sonoma 14.4+ BSSID redaction
- Linux `iw dev <iface> scan` parser with freq-to-channel conversion and `scan dump` (no-root) mode
- ADR-025: macOS CoreWLAN WiFi Sensing (ORCA)

View File

@ -1640,6 +1640,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
@ -1655,7 +1659,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) |
@ -1700,7 +1704,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` |
</details>

View File

@ -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<BssidObservation>`, 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 <N> → 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<Self, WifiScanError> // 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<Vec<BssidObservation>, WifiScanError>
pub fn connected_sync(&self) -> Result<Option<BssidObservation>, 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,21 +230,30 @@ 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 |
### 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 |
@ -270,7 +266,15 @@ 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`.
- `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
| Platform | Build | Expected |
|----------|-------|----------|

View File

@ -258,14 +258,23 @@ 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
# Run natively
./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500
# 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 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.
@ -493,7 +502,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 |
@ -522,6 +531,10 @@ The Rust sensing server binary accepts the following flags:
# Windows WiFi RSSI
./target/release/sensing-server --source wifi --tick-ms 500
# Experimental macOS bridge fallback
python3 scripts/macos_wifi_bridge.py --interval-ms 100 &
./target/release/sensing-server --source macos-bridge --tick-ms 100
# Run benchmark
./target/release/sensing-server --benchmark

View File

@ -7876,6 +7876,8 @@ name = "wifi-densepose-wifiscan"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]

View File

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

View File

@ -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;
// ---------------------------------------------------------------------------
// MacosCoreWlanScanner
// ---------------------------------------------------------------------------
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";
/// 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<String>,
}
#[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<String>) -> Self {
/// Create a scanner with an explicit helper path.
pub fn with_path(path: impl Into<PathBuf>) -> 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<Vec<BssidObservation>, 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<Option<BssidObservation>, 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<const N: usize>(&self, args: [&str; N]) -> Result<String, WifiScanError> {
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
// ---------------------------------------------------------------------------
impl WlanScanPort for MacosCoreWlanScanner {
fn scan(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
self.scan_sync()
}
/// 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).
fn connected(&self) -> Result<Option<BssidObservation>, WifiScanError> {
self.connected_sync()
}
}
/// Parse the NDJSON output from the canonical macOS helper.
pub fn parse_macos_scan_output(output: &str) -> Result<Vec<BssidObservation>, 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<BssidObservation> {
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;
fn helper_observation_to_domain(
record: HelperObservation,
timestamp: Instant,
) -> Result<BssidObservation, WifiScanError> {
if record.channel == 0 {
return Err(WifiScanError::ParseError(
"field `channel` must be greater than 0".to_string(),
));
}
// Resolve BSSID: use real MAC if available, otherwise generate synthetic.
let bssid = resolve_bssid(&bssid_str, &ssid, channel)?;
let _ = (
record.timestamp,
record.interface.as_str(),
record.bssid_synthetic,
record.noise,
record.tx_rate_mbps,
record.is_connected,
);
let band = BandType::from_channel(channel);
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)?;
// 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);
Some(BssidObservation {
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<BssidId> {
// 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);
}
}
// 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);
}
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 parse_band_label(label: &str, channel: u8) -> Result<BandType, WifiScanError> {
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}'"
))),
}
}
// ---------------------------------------------------------------------------
// Lightweight JSON field extractors
// ---------------------------------------------------------------------------
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,
}
}
/// 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<String> {
let pattern = format!("\"{}\"", key);
let key_pos = json.find(&pattern)?;
let after_key = &json[key_pos + pattern.len()..];
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")
}
// Skip optional whitespace and the colon.
let after_colon = after_key.trim_start().strip_prefix(':')?;
let after_colon = after_colon.trim_start();
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()
}
// 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;
fn resolve_helper_path_for(workspace_root: &Path, env_override: Option<OsString>) -> PathBuf {
if let Some(env_override) = env_override.filter(|value| !value.is_empty()) {
return PathBuf::from(env_override);
}
Some(after_quote[..end].to_owned())
let repo_local = workspace_root.join(REPO_LOCAL_HELPER_REL);
if repo_local.is_file() {
return repo_local;
}
PathBuf::from(HELPER_BINARY_NAME)
}
/// Extract a numeric field value from a JSON object string.
///
/// Looks for `"key": <number>` patterns.
fn extract_number_field(json: &str, key: &str) -> Option<f64> {
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();
// 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();
num_str.parse().ok()
}
// ---------------------------------------------------------------------------
// 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();
// Different SSID or channel → different MAC.
let c = synthetic_bssid("OtherNet", 36);
assert_ne!(a, c);
let d = synthetic_bssid("TestNet", 6);
assert_ne!(a, d);
assert!(err.to_string().contains("line 1"));
assert!(err.to_string().contains("bssid"));
}
#[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 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");
}
#[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_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 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 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 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);
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();
}
// 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);
#[test]
fn compile_time_trait_check() {
fn assert_port<T: WlanScanPort>() {}
assert_port::<MacosCoreWlanScanner>();
}
#[test]
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"
));
}
}

View File

@ -6,25 +6,20 @@
//! - [`MacosCoreWlanScanner`]: CoreWLAN via Swift helper binary (macOS, ADR-025).
//! - [`LinuxIwScanner`]: parses `iw dev <iface> 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;

View File

@ -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::MacosCoreWlanScanner;
pub use adapter::NetshBssidScanner;
pub use adapter::WlanApiScanner;
#[cfg(target_os = "macos")]
pub use adapter::MacosCoreWlanScanner;
#[cfg(target_os = "macos")]
pub use adapter::parse_macos_scan_output;
#[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};

23
scripts/build-mac-wifi.sh Executable file
View File

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

149
scripts/macos_wifi_bridge.py Executable file
View File

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Forward the canonical macOS CoreWLAN helper stream to the explicit bridge UDP source."""
from __future__ import annotations
import argparse
import json
import os
import socket
import subprocess
import sys
from pathlib import Path
BRIDGE_KIND = "connected_rssi"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 5006
DEFAULT_INTERVAL_MS = 100
HELPER_ENV_VAR = "RUVIEW_MAC_WIFI_HELPER"
REPO_HELPER_REL = Path("rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan")
REQUIRED_FIELDS = {
"timestamp",
"interface",
"ssid",
"bssid",
"bssid_synthetic",
"rssi",
"noise",
"channel",
"band",
"tx_rate_mbps",
"is_connected",
}
def repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def positive_int(value: str) -> int:
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("value must be a positive integer")
return parsed
def resolve_helper(explicit: str | None) -> str:
if explicit:
return explicit
env_override = os.environ.get(HELPER_ENV_VAR)
if env_override:
return env_override
repo_helper = repo_root() / REPO_HELPER_REL
if repo_helper.is_file():
return str(repo_helper)
return "macos-wifi-scan"
def validate_record(record: object) -> dict[str, object]:
if not isinstance(record, dict):
raise ValueError("helper output must be a JSON object")
missing = sorted(REQUIRED_FIELDS.difference(record))
if missing:
raise ValueError(f"helper output missing required fields: {', '.join(missing)}")
if not record.get("is_connected", False):
raise ValueError("helper stream record is not marked as connected")
bridged = dict(record)
bridged["bridge_kind"] = BRIDGE_KIND
return bridged
def main() -> int:
parser = argparse.ArgumentParser(
description="Forward macOS CoreWLAN helper records to the explicit RuView macOS bridge source."
)
parser.add_argument("--helper", help="Path to the macOS Wi-Fi helper binary")
parser.add_argument("--host", default=DEFAULT_HOST, help="Bridge receiver host (default: 127.0.0.1)")
parser.add_argument(
"--port",
type=positive_int,
default=DEFAULT_PORT,
help="Bridge receiver UDP port (default: 5006)",
)
parser.add_argument(
"--interval-ms",
type=positive_int,
default=DEFAULT_INTERVAL_MS,
help="Polling interval passed to the helper stream mode (default: 100)",
)
args = parser.parse_args()
helper = resolve_helper(args.helper)
command = [helper, "--stream", "--interval-ms", str(args.interval_ms)]
try:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=sys.stderr,
text=True,
bufsize=1,
)
except OSError as exc:
print(
f"failed to start macOS Wi-Fi helper '{helper}': {exc}. "
f"Build it with scripts/build-mac-wifi.sh or set {HELPER_ENV_VAR}.",
file=sys.stderr,
)
return 1
destination = (args.host, args.port)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
assert process.stdout is not None
for line in process.stdout:
line = line.strip()
if not line:
continue
try:
record = validate_record(json.loads(line))
except (json.JSONDecodeError, ValueError) as exc:
print(f"skipping helper record: {exc}", file=sys.stderr)
continue
payload = json.dumps(record, separators=(",", ":")).encode("utf-8")
sock.sendto(payload, destination)
except KeyboardInterrupt:
print("stopping macOS Wi-Fi bridge", file=sys.stderr)
finally:
sock.close()
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
return process.returncode or 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -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<CWNetwork>
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 ? "<hidden>" : 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())