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)
|
- `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)
|
- 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
|
- **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 CoreWLAN tooling with canonical Swift helper (`tools/macos-wifi-scan/main.swift`)
|
||||||
- macOS synthetic BSSID generation (FNV-1a hash) for Sonoma 14.4+ BSSID redaction
|
- 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
|
- Linux `iw dev <iface> scan` parser with freq-to-channel conversion and `scan dump` (no-root) mode
|
||||||
- ADR-025: macOS CoreWLAN WiFi Sensing (ORCA)
|
- 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` |
|
| Intel 5300 | Firmware mod | ~$15 | Linux `iwl-csi` |
|
||||||
| Atheros AR9580 | ath9k patch | ~$20 | Linux only |
|
| Atheros AR9580 | ath9k patch | ~$20 | Linux only |
|
||||||
| Any Windows WiFi | RSSI only | $0 | [Tutorial #36](https://github.com/ruvnet/RuView/issues/36) |
|
| 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` |
|
| Any Linux WiFi | RSSI only (`iw`) | $0 | Requires `iw` + `CAP_NET_ADMIN` |
|
||||||
|
|
||||||
</details>
|
</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.
|
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.
|
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.
|
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. **`#[cfg(target_os = "macos")]` gating** — macOS-specific code compiles only on macOS. Windows and Linux builds are unaffected.
|
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.
|
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
|
### 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
|
```swift
|
||||||
// Modes:
|
// Modes:
|
||||||
// (no args) → Full scan, output JSON array to stdout
|
// --probe → One NDJSON line for the current association, exits immediately
|
||||||
// --probe → Quick availability check, output {"available": true/false}
|
// --scan-once → One NDJSON line per visible AP
|
||||||
// --connected → Connected network info only
|
// --connected → One NDJSON line for the current association
|
||||||
|
// --stream --interval-ms <N> → Repeated connected-AP NDJSON records
|
||||||
//
|
//
|
||||||
// Output schema (scan mode):
|
// 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}
|
||||||
// {
|
|
||||||
// "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"
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Build:**
|
**Build:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Requires Xcode Command Line Tools (xcode-select --install)
|
# Requires Xcode Command Line Tools (xcode-select --install)
|
||||||
cd tools/macos-wifi-scan
|
./scripts/build-mac-wifi.sh
|
||||||
swiftc -framework CoreWLAN -framework Foundation -O -o macos-wifi-scan main.swift
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Build script:** `tools/macos-wifi-scan/build.sh`
|
**Build script:** `scripts/build-mac-wifi.sh`
|
||||||
|
|
||||||
### 3.3 Rust Adapter
|
### 3.3 Rust Adapter
|
||||||
|
|
||||||
**File:** `crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs`
|
**File:** `crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// #[cfg(target_os = "macos")]
|
|
||||||
|
|
||||||
pub struct MacosCoreWlanScanner {
|
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 {
|
impl MacosCoreWlanScanner {
|
||||||
pub fn new() -> Result<Self, WifiScanError> // Finds helper or errors
|
pub fn new() -> Self // Resolves helper path at construction
|
||||||
pub fn probe() -> bool // Runs --probe, returns availability
|
pub fn probe_sync(&self) -> Result<(), WifiScanError>
|
||||||
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError>
|
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError>
|
||||||
pub fn connected_sync(&self) -> Result<Option<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.) |
|
| File | Purpose | Lines (est.) |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `tools/macos-wifi-scan/main.swift` | CoreWLAN scanner, JSON output | ~120 |
|
| `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 |
|
| `crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs` | Rust adapter: spawn helper, parse JSON, produce `BssidObservation` | ~200 |
|
||||||
|
|
||||||
### 4.2 Modified Files
|
### 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)
|
- `std::process::Command` — subprocess spawning (stdlib)
|
||||||
- `serde_json` — JSON parsing (already in workspace)
|
- `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 |
|
| Test | Command | Expected |
|
||||||
|------|---------|----------|
|
|------|---------|----------|
|
||||||
| Build | `cd tools/macos-wifi-scan && ./build.sh` | Produces `macos-wifi-scan` binary |
|
| Build | `./scripts/build-mac-wifi.sh` | Produces `rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan` |
|
||||||
| Probe | `./macos-wifi-scan --probe` | `{"available": true}` |
|
| 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 | `./macos-wifi-scan` | JSON array with real SSIDs, RSSI in dBm, channels |
|
| Scan | `./rust-port/wifi-densepose-rs/target/tools/macos-wifi-scan/macos-wifi-scan --scan-once` | NDJSON records with SSID/RSSI/channel/band |
|
||||||
| Connected | `./macos-wifi-scan --connected` | Single JSON object for connected network |
|
| 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 → `./macos-wifi-scan` | `{"available": false}` or empty array |
|
| No WiFi | Disable WiFi → `... --probe` | Non-zero exit with clear stderr |
|
||||||
|
|
||||||
### 5.2 Rust Adapter
|
### 5.2 Rust Adapter
|
||||||
|
|
||||||
| Test | Method | Expected |
|
| Test | Method | Expected |
|
||||||
|------|--------|----------|
|
|------|--------|----------|
|
||||||
| Unit: JSON parsing | `#[test]` with fixture JSON | Correct `BssidObservation` values |
|
| 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` |
|
| Unit: helper not found | `#[test]` with bad path | `WifiScanError::ProcessError` |
|
||||||
| Integration: real scan | `cargo test` on Mac Mini | Live observations from CoreWLAN |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Platform | Build | Expected |
|
||||||
|----------|-------|----------|
|
|----------|-------|----------|
|
||||||
|
|
|
||||||
|
|
@ -258,14 +258,19 @@ docker run --network host ruvnet/wifi-densepose:latest --source wifi --tick-ms 5
|
||||||
|
|
||||||
### macOS WiFi (RSSI Only)
|
### 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
|
```bash
|
||||||
# Compile the Swift helper (once)
|
# Build the Swift helper (once, on macOS)
|
||||||
swiftc -O v1/src/sensing/mac_wifi.swift -o mac_wifi
|
./scripts/build-mac-wifi.sh
|
||||||
|
|
||||||
# Run natively
|
# Optional: point the server at a custom helper location
|
||||||
./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500
|
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.
|
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"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,6 +17,7 @@ tracing.workspace = true
|
||||||
|
|
||||||
# Serialization (optional, for domain types)
|
# Serialization (optional, for domain types)
|
||||||
serde = { workspace = true, optional = true }
|
serde = { workspace = true, optional = true }
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
# Async runtime (optional, for Tier 2 async scanning)
|
# Async runtime (optional, for Tier 2 async scanning)
|
||||||
tokio = { workspace = true, optional = true }
|
tokio = { workspace = true, optional = true }
|
||||||
|
|
@ -28,6 +29,9 @@ pipeline = []
|
||||||
## Tier 2: enables async scan_async() method on WlanApiScanner via tokio
|
## Tier 2: enables async scan_async() method on WlanApiScanner via tokio
|
||||||
wlanapi = ["dep:tokio"]
|
wlanapi = ["dep:tokio"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.10"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,134 @@
|
||||||
//! Adapter that scans WiFi BSSIDs on macOS by invoking a compiled Swift
|
//! Adapter that scans WiFi BSSIDs on macOS by invoking the canonical Swift
|
||||||
//! helper binary that uses Apple's CoreWLAN framework.
|
//! CoreWLAN helper.
|
||||||
//!
|
//!
|
||||||
//! This is the macOS counterpart to [`NetshBssidScanner`](super::NetshBssidScanner)
|
//! The helper lives at `tools/macos-wifi-scan/main.swift` and is built by
|
||||||
//! on Windows. It follows ADR-025 (ORCA — macOS CoreWLAN WiFi Sensing).
|
//! `scripts/build-mac-wifi.sh` into the Rust workspace target tree. This
|
||||||
|
//! adapter resolves the helper path in the following order:
|
||||||
//!
|
//!
|
||||||
//! # Design
|
//! 1. `RUVIEW_MAC_WIFI_HELPER`
|
||||||
//!
|
//! 2. `target/tools/macos-wifi-scan/macos-wifi-scan`
|
||||||
//! Apple removed the `airport` CLI in macOS Sonoma 14.4+ and CoreWLAN is a
|
//! 3. `macos-wifi-scan` on `PATH`
|
||||||
//! 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.
|
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||||
use crate::error::WifiScanError;
|
use crate::error::WifiScanError;
|
||||||
|
use crate::port::WlanScanPort;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const HELPER_ENV_VAR: &str = "RUVIEW_MAC_WIFI_HELPER";
|
||||||
// MacosCoreWlanScanner
|
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.
|
#[derive(Debug, Deserialize)]
|
||||||
///
|
struct ProbeStatus {
|
||||||
/// The helper binary must be compiled from `v1/src/sensing/mac_wifi.swift` and
|
ok: bool,
|
||||||
/// placed on `$PATH` or at a known location. The scanner invokes it with a
|
interface: String,
|
||||||
/// `--scan-once` flag (single-shot mode) and parses the JSON output.
|
message: Option<String>,
|
||||||
///
|
}
|
||||||
/// If the helper is not found, [`scan_sync`](Self::scan_sync) returns a
|
|
||||||
/// [`WifiScanError::ProcessError`].
|
#[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 {
|
pub struct MacosCoreWlanScanner {
|
||||||
/// Path to the `mac_wifi` helper binary. Defaults to `"mac_wifi"` (on PATH).
|
helper_path: PathBuf,
|
||||||
helper_path: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MacosCoreWlanScanner {
|
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 {
|
pub fn new() -> Self {
|
||||||
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.
|
/// Create a scanner with an explicit helper path.
|
||||||
pub fn with_path(path: impl Into<String>) -> Self {
|
pub fn with_path(path: impl Into<PathBuf>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
helper_path: path.into(),
|
helper_path: path.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the Swift helper and parse the output synchronously.
|
/// Return the resolved helper path.
|
||||||
///
|
pub fn helper_path(&self) -> &Path {
|
||||||
/// Returns one [`BssidObservation`] per BSSID seen in the scan.
|
&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> {
|
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)
|
let output = Command::new(&self.helper_path)
|
||||||
.arg("--scan-once")
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| {
|
.map_err(|err| {
|
||||||
WifiScanError::ProcessError(format!(
|
WifiScanError::ProcessError(format!(
|
||||||
"failed to run mac_wifi helper ({}): {e}",
|
"failed to run macOS Wi-Fi helper '{}': {err}. Build it with scripts/build-mac-wifi.sh or set {HELPER_ENV_VAR}.",
|
||||||
self.helper_path
|
self.helper_path.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -80,15 +136,18 @@ impl MacosCoreWlanScanner {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(WifiScanError::ScanFailed {
|
return Err(WifiScanError::ScanFailed {
|
||||||
reason: format!(
|
reason: format!(
|
||||||
"mac_wifi exited with {}: {}",
|
"macOS Wi-Fi helper '{}' exited {} while running {}: {}",
|
||||||
|
self.helper_path.display(),
|
||||||
output.status,
|
output.status,
|
||||||
|
args.join(" "),
|
||||||
stderr.trim()
|
stderr.trim()
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
String::from_utf8(output.stdout).map_err(|err| {
|
||||||
parse_macos_scan_output(&stdout)
|
WifiScanError::ParseError(format!("macOS Wi-Fi helper emitted invalid UTF-8: {err}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,263 +157,218 @@ impl Default for MacosCoreWlanScanner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
impl WlanScanPort for MacosCoreWlanScanner {
|
||||||
// Parser
|
fn scan(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||||
// ---------------------------------------------------------------------------
|
self.scan_sync()
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse the JSON-lines output from the `mac_wifi` Swift helper.
|
fn connected(&self) -> Result<Option<BssidObservation>, WifiScanError> {
|
||||||
///
|
self.connected_sync()
|
||||||
/// 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
|
/// Parse the NDJSON output from the canonical macOS helper.
|
||||||
/// status messages on stdout).
|
|
||||||
pub fn parse_macos_scan_output(output: &str) -> Result<Vec<BssidObservation>, WifiScanError> {
|
pub fn parse_macos_scan_output(output: &str) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||||
let now = Instant::now();
|
let timestamp = Instant::now();
|
||||||
let mut results = Vec::new();
|
let mut observations = Vec::new();
|
||||||
|
|
||||||
for line in output.lines() {
|
for (line_index, line) in output.lines().enumerate() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.is_empty() || !line.starts_with('{') {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(obs) = parse_json_line(line, now) {
|
let record: HelperObservation = serde_json::from_str(line).map_err(|err| {
|
||||||
results.push(obs);
|
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`].
|
fn helper_observation_to_domain(
|
||||||
///
|
record: HelperObservation,
|
||||||
/// Uses a lightweight manual parser to avoid pulling in `serde_json` as a
|
timestamp: Instant,
|
||||||
/// hard dependency. The JSON structure is simple and well-known.
|
) -> Result<BssidObservation, WifiScanError> {
|
||||||
fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
|
if record.channel == 0 {
|
||||||
let ssid = extract_string_field(line, "ssid")?;
|
return Err(WifiScanError::ParseError(
|
||||||
let bssid_str = extract_string_field(line, "bssid")?;
|
"field `channel` must be greater than 0".to_string(),
|
||||||
let rssi = extract_number_field(line, "rssi")?;
|
));
|
||||||
let channel_f = extract_number_field(line, "channel")?;
|
}
|
||||||
let channel = channel_f as u8;
|
|
||||||
|
|
||||||
// Resolve BSSID: use real MAC if available, otherwise generate synthetic.
|
let _ = (
|
||||||
let bssid = resolve_bssid(&bssid_str, &ssid, channel)?;
|
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.
|
Ok(BssidObservation {
|
||||||
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 {
|
|
||||||
bssid,
|
bssid,
|
||||||
rssi_dbm: rssi,
|
rssi_dbm: record.rssi,
|
||||||
signal_pct,
|
signal_pct: ((record.rssi + 100.0) * 2.0).clamp(0.0, 100.0),
|
||||||
channel,
|
channel: record.channel,
|
||||||
band,
|
band,
|
||||||
radio_type,
|
radio_type: infer_radio_type(record.channel, band),
|
||||||
ssid,
|
ssid: record.ssid,
|
||||||
timestamp,
|
timestamp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a BSSID string to a [`BssidId`].
|
fn parse_band_label(label: &str, channel: u8) -> Result<BandType, WifiScanError> {
|
||||||
///
|
let normalized = label.trim().to_ascii_lowercase();
|
||||||
/// If the MAC is all-zeros (macOS redaction), generate a synthetic
|
match normalized.as_str() {
|
||||||
/// locally-administered MAC from `SHA-256(ssid:channel)`.
|
"2.4ghz" | "2.4 ghz" | "2.4" => Ok(BandType::Band2_4GHz),
|
||||||
fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
"5ghz" | "5 ghz" | "5" => Ok(BandType::Band5GHz),
|
||||||
// Try parsing the real BSSID first.
|
"6ghz" | "6 ghz" | "6" => Ok(BandType::Band6GHz),
|
||||||
if let Ok(id) = BssidId::parse(bssid_str) {
|
"" => Ok(BandType::from_channel(channel)),
|
||||||
// Check for the all-zeros redacted BSSID.
|
_ => Err(WifiScanError::ParseError(format!(
|
||||||
if id.0 != [0, 0, 0, 0, 0, 0] {
|
"field `band` must be one of 2.4GHz, 5GHz, or 6GHz; got '{label}'"
|
||||||
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 infer_radio_type(channel: u8, band: BandType) -> RadioType {
|
||||||
// Lightweight JSON field extractors
|
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.
|
fn is_not_connected_reason(reason: &str) -> bool {
|
||||||
///
|
let lower = reason.to_ascii_lowercase();
|
||||||
/// Looks for `"key":"value"` or `"key": "value"` patterns.
|
lower.contains("not connected to an access point")
|
||||||
fn extract_string_field(json: &str, key: &str) -> Option<String> {
|
|| lower.contains("waiting for wi-fi association")
|
||||||
let pattern = format!("\"{}\"", key);
|
}
|
||||||
let key_pos = json.find(&pattern)?;
|
|
||||||
let after_key = &json[key_pos + pattern.len()..];
|
|
||||||
|
|
||||||
// Skip optional whitespace and the colon.
|
fn workspace_root() -> PathBuf {
|
||||||
let after_colon = after_key.trim_start().strip_prefix(':')?;
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
let after_colon = after_colon.trim_start();
|
.ancestors()
|
||||||
|
.nth(2)
|
||||||
|
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
|
||||||
|
.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
// Expect opening quote.
|
fn resolve_helper_path_for(workspace_root: &Path, env_override: Option<OsString>) -> PathBuf {
|
||||||
let after_quote = after_colon.strip_prefix('"')?;
|
if let Some(env_override) = env_override.filter(|value| !value.is_empty()) {
|
||||||
|
return PathBuf::from(env_override);
|
||||||
// Find closing quote (handle escaped quotes).
|
|
||||||
let mut end = 0;
|
|
||||||
let bytes = after_quote.as_bytes();
|
|
||||||
while end < bytes.len() {
|
|
||||||
if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
end += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(after_quote[..end].to_owned())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const SAMPLE_OUTPUT: &str = r#"
|
fn unique_temp_dir(name: &str) -> PathBuf {
|
||||||
{"ssid":"HomeNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"}
|
let path = std::env::temp_dir().join(format!(
|
||||||
{"ssid":"GuestWifi","bssid":"11:22:33:44:55:66","rssi":-71,"noise":-92,"channel":6,"band":"2.4GHz"}
|
"ruview-{name}-{}-{}",
|
||||||
{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"}
|
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]
|
#[test]
|
||||||
fn parse_valid_output() {
|
fn parse_helper_output_uses_contract_fields() {
|
||||||
let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
|
let observations = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
|
||||||
assert_eq!(obs.len(), 3);
|
assert_eq!(observations.len(), 2);
|
||||||
|
assert_eq!(observations[0].ssid, "Home");
|
||||||
// First entry: real BSSID.
|
assert_eq!(observations[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
|
||||||
assert_eq!(obs[0].ssid, "HomeNetwork");
|
assert_eq!(observations[0].band, BandType::Band5GHz);
|
||||||
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
|
assert_eq!(observations[0].radio_type, RadioType::Ac);
|
||||||
assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON);
|
assert_eq!(observations[1].band, BandType::Band2_4GHz);
|
||||||
assert_eq!(obs[0].channel, 36);
|
assert_eq!(observations[1].radio_type, RadioType::N);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn synthetic_bssid_is_deterministic() {
|
fn parse_helper_output_reports_missing_fields() {
|
||||||
let a = synthetic_bssid("TestNet", 36);
|
let err = parse_macos_scan_output(
|
||||||
let b = synthetic_bssid("TestNet", 36);
|
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}"#,
|
||||||
assert_eq!(a, b);
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
// Different SSID or channel → different MAC.
|
assert!(err.to_string().contains("line 1"));
|
||||||
let c = synthetic_bssid("OtherNet", 36);
|
assert!(err.to_string().contains("bssid"));
|
||||||
assert_ne!(a, c);
|
|
||||||
|
|
||||||
let d = synthetic_bssid("TestNet", 6);
|
|
||||||
assert_ne!(a, d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_empty_and_junk_lines() {
|
fn probe_status_json_is_supported() {
|
||||||
let output = "\n \nnot json\n{broken json\n";
|
let status: ProbeStatus =
|
||||||
let obs = parse_macos_scan_output(output).unwrap();
|
serde_json::from_str(r#"{"ok":true,"interface":"en0","message":"ready"}"#).unwrap();
|
||||||
assert!(obs.is_empty());
|
assert!(status.ok);
|
||||||
|
assert_eq!(status.interface, "en0");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_string_field_basic() {
|
fn helper_path_prefers_env_override() {
|
||||||
let json = r#"{"ssid":"MyNet","bssid":"aa:bb:cc:dd:ee:ff"}"#;
|
let workspace = unique_temp_dir("env-path");
|
||||||
assert_eq!(extract_string_field(json, "ssid").unwrap(), "MyNet");
|
let resolved =
|
||||||
assert_eq!(
|
resolve_helper_path_for(&workspace, Some(OsString::from("/tmp/custom-helper")));
|
||||||
extract_string_field(json, "bssid").unwrap(),
|
assert_eq!(resolved, PathBuf::from("/tmp/custom-helper"));
|
||||||
"aa:bb:cc:dd:ee:ff"
|
std::fs::remove_dir_all(workspace).unwrap();
|
||||||
);
|
|
||||||
assert!(extract_string_field(json, "missing").is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_number_field_basic() {
|
fn helper_path_uses_repo_local_binary_when_present() {
|
||||||
let json = r#"{"rssi":-52,"channel":36}"#;
|
let workspace = unique_temp_dir("repo-path");
|
||||||
assert!((extract_number_field(json, "rssi").unwrap() - (-52.0)).abs() < f64::EPSILON);
|
let helper = workspace.join(REPO_LOCAL_HELPER_REL);
|
||||||
assert!((extract_number_field(json, "channel").unwrap() - 36.0).abs() < f64::EPSILON);
|
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]
|
#[test]
|
||||||
fn signal_pct_clamping() {
|
fn helper_path_falls_back_to_path_binary() {
|
||||||
// RSSI -50 → pct = (-50+100)*2 = 100
|
let workspace = unique_temp_dir("path-fallback");
|
||||||
let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-50,"channel":1}"#;
|
let resolved = resolve_helper_path_for(&workspace, None);
|
||||||
let obs = parse_json_line(json, Instant::now()).unwrap();
|
assert_eq!(resolved, PathBuf::from(HELPER_BINARY_NAME));
|
||||||
assert!((obs.signal_pct - 100.0).abs() < f64::EPSILON);
|
std::fs::remove_dir_all(workspace).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// RSSI -100 → pct = 0
|
#[test]
|
||||||
let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-100,"channel":1}"#;
|
fn compile_time_trait_check() {
|
||||||
let obs = parse_json_line(json, Instant::now()).unwrap();
|
fn assert_port<T: WlanScanPort>() {}
|
||||||
assert!((obs.signal_pct - 0.0).abs() < f64::EPSILON);
|
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).
|
//! - [`MacosCoreWlanScanner`]: CoreWLAN via Swift helper binary (macOS, ADR-025).
|
||||||
//! - [`LinuxIwScanner`]: parses `iw dev <iface> scan` output (Linux).
|
//! - [`LinuxIwScanner`]: parses `iw dev <iface> scan` output (Linux).
|
||||||
|
|
||||||
|
pub mod macos_scanner;
|
||||||
pub(crate) mod netsh_scanner;
|
pub(crate) mod netsh_scanner;
|
||||||
pub mod wlanapi_scanner;
|
pub mod wlanapi_scanner;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub mod macos_scanner;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod linux_scanner;
|
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::parse_netsh_output;
|
||||||
|
pub use netsh_scanner::NetshBssidScanner;
|
||||||
pub use wlanapi_scanner::WlanApiScanner;
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use linux_scanner::parse_iw_scan_output;
|
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;
|
pub mod port;
|
||||||
|
|
||||||
// Re-export key types at the crate root for convenience.
|
// 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::parse_netsh_output;
|
||||||
|
pub use adapter::MacosCoreWlanScanner;
|
||||||
|
pub use adapter::NetshBssidScanner;
|
||||||
pub use adapter::WlanApiScanner;
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use adapter::parse_iw_scan_output;
|
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::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||||
pub use domain::frame::MultiApFrame;
|
pub use domain::frame::MultiApFrame;
|
||||||
pub use domain::registry::{BssidEntry, BssidMeta, BssidRegistry, RunningStats};
|
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