Merge 9fecc029bc into 4713a30402
This commit is contained in:
commit
fdcbfbef62
|
|
@ -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 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 <iface> scan` parser with freq-to-channel conversion and `scan dump` (no-root) mode
|
||||
- ADR-025: macOS CoreWLAN WiFi Sensing (ORCA)
|
||||
|
||||
|
|
|
|||
|
|
@ -1700,7 +1700,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>
|
||||
|
|
|
|||
|
|
@ -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,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 |
|
||||
|----------|-------|----------|
|
||||
|
|
|
|||
|
|
@ -258,14 +258,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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
See [ADR-025](adr/ADR-025-macos-corewlan-wifi-sensing.md) for details.
|
||||
|
|
|
|||
|
|
@ -7876,6 +7876,8 @@ name = "wifi-densepose-wifiscan"
|
|||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue