docs: clarify macOS live sensing setup

This commit is contained in:
Matvii Krylov 2026-04-08 20:53:53 +01:00
parent 2a05378bd2
commit 95f2bd881e
4 changed files with 92 additions and 14 deletions

1
.gitignore vendored
View file

@ -170,6 +170,7 @@ celerybeat.pid
# Environments
.env
.venv
.venv-*/
env/
venv/
ENV/

View file

@ -92,6 +92,8 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion |
>
> **macOS host Wi-Fi note:** live macOS RSSI sensing currently uses the legacy CoreWLAN/WebSocket path (`python -m v1.src.sensing.ws_server`). The main Rust auto-detect server still falls back to simulation on macOS. See the [User Guide macOS section](docs/user-guide.md#macos-wifi-rssi-only).
>
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python v1/data/proof/verify.py`
>
---

View file

@ -261,16 +261,41 @@ 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 legacy `v1` Swift helper. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works.
> **Current status:** the main Rust `sensing-server --source auto` path still falls back to simulation on macOS. For live host Wi-Fi sensing on macOS, use the legacy Python/WebSocket path below until the ADR-025 integration lands fully.
> **Homebrew Python note:** if `pip` reports an `externally-managed-environment` error (PEP 668), create a virtual environment first instead of installing packages into the system interpreter.
```bash
# Compile the Swift helper (once)
swiftc -O v1/src/sensing/mac_wifi.swift -o mac_wifi
# Create a lightweight virtual environment that can still see the
# Homebrew-installed numpy/scipy already present on the machine.
python3 -m venv .venv-macos-sensing --system-site-packages
source .venv-macos-sensing/bin/activate
# Run natively
./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500
# Install the only missing runtime dependency for the legacy server.
python -m pip install websockets
# Compile the CoreWLAN helper (once). The explicit xcrun + module-cache path
# avoids toolchain/module-cache issues seen on recent macOS + CLT setups.
mkdir -p /tmp/swift-module-cache
CLANG_MODULE_CACHE_PATH=/tmp/swift-module-cache \
xcrun --sdk macosx swiftc -O -o v1/src/sensing/mac_wifi v1/src/sensing/mac_wifi.swift
# Optional sanity check: this should print JSON lines with rssi/noise/tx_rate.
./v1/src/sensing/mac_wifi | head -n 3
# Start the live sensing WebSocket server.
python -m v1.src.sensing.ws_server
# In another terminal, serve the UI static files.
python3 -m http.server 3000 --directory ui
```
Open `http://localhost:3000/observatory.html`, switch **Data Source** to **Live WebSocket**, and set **WS URL** to `ws://localhost:8765/ws/sensing`.
If the helper prints `{"error": "No WiFi interface found"}`, make sure the Mac is connected to Wi-Fi on `en0` and try again.
See [ADR-025](adr/ADR-025-macos-corewlan-wifi-sensing.md) for details.
### Linux WiFi (RSSI Only)
@ -465,7 +490,9 @@ Real-time sensing data is available via WebSocket.
**URL:** `ws://localhost:3000/ws/sensing` (same port as HTTP — recommended) or `ws://localhost:3001/ws/sensing` (dedicated WS port).
> **Note:** The `/ws/sensing` WebSocket endpoint is available on both the HTTP port (3000) and the dedicated WebSocket port (3001/8765). The web UI uses the HTTP port so only one port needs to be exposed. The dedicated WS port remains available for backward compatibility.
> **Legacy Python/macOS path:** when using `python -m v1.src.sensing.ws_server`, the WebSocket endpoint is `ws://localhost:8765/ws/sensing` and the UI is typically served separately on `http://localhost:3000/`.
> **Note:** The main Rust server exposes `/ws/sensing` on both the HTTP port (3000) and the dedicated WebSocket port (3001/8765). The web UI prefers the HTTP port when available and falls back to the legacy `:8765` endpoint for local macOS RSSI sensing.
### Python Example

View file

@ -1,19 +1,32 @@
/**
* Sensing WebSocket Service
*
* Manages the connection to the Python sensing WebSocket server
* (ws://localhost:8765) and provides a callback-based API for the UI.
* Manages the connection to the sensing WebSocket server and provides a
* callback-based API for the UI.
*
* Connection strategy:
* 1. Same-origin /ws/sensing (Rust server / reverse-proxy path)
* 2. localhost:8765/ws/sensing (legacy Python/macOS path)
*
* Falls back to simulated data only after MAX_RECONNECT_ATTEMPTS exhausted.
* While reconnecting the service stays in "reconnecting" state and does NOT
* emit simulated frames so the UI can clearly distinguish live vs. fallback data.
*/
// Derive WebSocket URL from the page origin so it works on any port.
// The /ws/sensing endpoint is available on the same HTTP port (3000).
// Derive candidate WebSocket URLs from the current origin.
const _wsProto = (typeof window !== 'undefined' && window.location.protocol === 'https:') ? 'wss:' : 'ws:';
const _wsHost = (typeof window !== 'undefined' && window.location.host) ? window.location.host : 'localhost:3000';
const SENSING_WS_URL = `${_wsProto}//${_wsHost}/ws/sensing`;
const _wsHostname = (typeof window !== 'undefined' && window.location.hostname) ? window.location.hostname : 'localhost';
function buildSensingWsUrls() {
const candidates = [
`${_wsProto}//${_wsHost}/ws/sensing`,
`${_wsProto}//${_wsHostname}:8765/ws/sensing`,
];
return [...new Set(candidates)];
}
const SENSING_WS_URLS = buildSensingWsUrls();
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
const MAX_RECONNECT_ATTEMPTS = 20;
// Number of failed attempts that must occur before simulation starts.
@ -41,6 +54,10 @@ class SensingService {
// The raw source string from the server (e.g. "esp32", "simulated", "simulate")
this._serverSource = null;
this._lastMessage = null;
this._wsCandidates = SENSING_WS_URLS;
this._candidateIndex = 0;
this._successfulWsUrl = null;
this._activeWsUrl = null;
// Ring buffer of recent RSSI values for sparkline
this._rssiHistory = [];
@ -110,9 +127,11 @@ class SensingService {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
this._setState('connecting');
const wsUrl = this._successfulWsUrl || this._wsCandidates[this._candidateIndex] || `${_wsProto}//${_wsHost}/ws/sensing`;
this._activeWsUrl = wsUrl;
try {
this._ws = new WebSocket(SENSING_WS_URL);
this._ws = new WebSocket(wsUrl);
} catch (err) {
console.warn('[Sensing] WebSocket constructor failed:', err.message);
this._fallbackToSimulation();
@ -120,8 +139,10 @@ class SensingService {
}
this._ws.onopen = () => {
console.info('[Sensing] Connected to', SENSING_WS_URL);
console.info('[Sensing] Connected to', this._activeWsUrl);
this._reconnectAttempt = 0;
this._successfulWsUrl = this._activeWsUrl;
this._candidateIndex = Math.max(0, this._wsCandidates.indexOf(this._successfulWsUrl));
this._stopSimulation();
this._setState('connected');
// Don't assume "live" yet — wait for first frame's source field.
@ -146,6 +167,9 @@ class SensingService {
console.info('[Sensing] Connection closed (code=%d)', evt.code);
this._ws = null;
if (evt.code !== 1000) {
if (!this._successfulWsUrl && this._wsCandidates.length > 1) {
this._candidateIndex = (this._candidateIndex + 1) % this._wsCandidates.length;
}
this._scheduleReconnect();
} else {
this._setState('disconnected');
@ -276,8 +300,14 @@ class SensingService {
* hardware or simulation. Called once on WebSocket open.
*/
async _detectServerSource() {
const statusUrl = this._statusUrlForActiveSocket();
if (!statusUrl) {
this._setDataSource('live');
return;
}
try {
const resp = await fetch('/api/v1/status');
const resp = await fetch(statusUrl);
if (resp.ok) {
const json = await resp.json();
this._applyServerSource(json.source);
@ -290,6 +320,24 @@ class SensingService {
}
}
_statusUrlForActiveSocket() {
if (!this._activeWsUrl || typeof window === 'undefined') {
return '/api/v1/status';
}
try {
const wsUrl = new URL(this._activeWsUrl, window.location.href);
if (wsUrl.port === '8765') {
// The legacy Python/macOS sensing path exposes only WebSocket frames.
return null;
}
const httpProto = wsUrl.protocol === 'wss:' ? 'https:' : 'http:';
return `${httpProto}//${wsUrl.host}/api/v1/status`;
} catch {
return '/api/v1/status';
}
}
/**
* Map a raw server source string to the UI data-source label.
*/