feat(macos-rssi-bridge): Mac WiFi card → sensing-server bridge

Swift CoreWLAN helper + Rust UDP emitter that turns any Mac into a
"node 0" for RuView's sensing-server while waiting on ESP32 hardware.
Wraps multi-BSSID scans into ESP32 CSI frame format (magic 0xC511_0001)
with per-AP RSSI as pseudo-subcarrier amplitude and rolling variance
as the Q channel — pipeline runs unmodified.

Standalone Cargo project (not a v2 workspace member, so the main build
is unaffected). Handles macOS 14.4+ BSSID redaction by synthesizing
stable per-AP identifiers from (channel, RSSI bucket, ordinal).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
griffin 2026-05-26 09:22:37 -07:00
parent f891329384
commit e325082e6d
No known key found for this signature in database
GPG Key ID: E2CBC5B01EFA35EB
10 changed files with 2010 additions and 0 deletions

3
.gitignore vendored
View File

@ -118,6 +118,9 @@ docs/_build/
.pybuilder/
target/
# macOS RSSI bridge — Swift-compiled helper binary
scripts/macos-rssi-bridge/mac_wifi
# Jupyter Notebook
.ipynb_checkpoints

370
scripts/macos-rssi-bridge/Cargo.lock generated Normal file
View File

@ -0,0 +1,370 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "macos-rssi-bridge"
version = "0.1.0"
dependencies = [
"clap",
"ctrlc",
"serde",
"serde_json",
"tiny_http",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@ -0,0 +1,24 @@
[package]
name = "macos-rssi-bridge"
version = "0.1.0"
edition = "2021"
publish = false
description = "Mac WiFi card → ESP32-format CSI UDP bridge for RuView's sensing-server"
[[bin]]
name = "macos-rssi-bridge"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
ctrlc = "3"
tiny_http = "0.12"
[profile.release]
opt-level = 3
lto = "thin"
# Detach from any ancestor workspace.
[workspace]

View File

@ -0,0 +1,68 @@
.PHONY: all build clean test scan run run-verbose listen install-helper help
HELPER := mac_wifi
BRIDGE := target/release/macos-rssi-bridge
# Sensing-server expectations.
TARGET_HOST ?= 127.0.0.1
TARGET_PORT ?= 5005
INTERVAL ?= 1.5
all: build
help:
@echo "macos-rssi-bridge — Mac WiFi card → RuView sensing-server bridge"
@echo ""
@echo " make build compile mac_wifi (Swift) + macos-rssi-bridge (Rust)"
@echo " make scan run a single multi-BSSID scan, print JSON"
@echo " make run start the bridge → udp://$(TARGET_HOST):$(TARGET_PORT) + http://localhost:9090"
@echo " make run-verbose same, with per-frame stats on stderr"
@echo " make dashboard open the multistatic RF tomography UI in your browser"
@echo " make listen debug: print frames arriving on $(TARGET_PORT) (no sensing-server)"
@echo " make test run Rust unit tests"
@echo " make clean delete build artifacts"
@echo ""
@echo "Variables: TARGET_HOST=$(TARGET_HOST) TARGET_PORT=$(TARGET_PORT) INTERVAL=$(INTERVAL)"
build: $(HELPER) $(BRIDGE)
$(HELPER): mac_wifi.swift
swiftc -O mac_wifi.swift -o $(HELPER)
$(BRIDGE): src/main.rs Cargo.toml
cargo build --release
test:
cargo test --release
scan: $(HELPER)
./$(HELPER) --scan-once
run: build
./$(BRIDGE) --helper ./$(HELPER) --target-host $(TARGET_HOST) \
--target-port $(TARGET_PORT) --interval $(INTERVAL)
run-verbose: build
./$(BRIDGE) --helper ./$(HELPER) --target-host $(TARGET_HOST) \
--target-port $(TARGET_PORT) --interval $(INTERVAL) --verbose
# Open the RF tomography dashboard. Assumes the bridge is already running
# (start it with `make run` or `make run-verbose` in another terminal).
dashboard:
@open http://localhost:9090/dashboard 2>/dev/null || \
echo "Open http://localhost:9090/dashboard in your browser"
# Debug helper — prints magic/seq/rssi for each frame the bridge emits.
# Doesn't require sensing-server.
listen:
@python3 listen.py $(TARGET_PORT)
# Optional: install the Swift helper system-wide so anything on $PATH can find it,
# matching the default lookup of `MacosCoreWlanScanner::new()` in the Rust crate.
install-helper: $(HELPER)
install -m 0755 $(HELPER) /usr/local/bin/mac_wifi
@echo "[install] /usr/local/bin/mac_wifi"
clean:
rm -f $(HELPER)
cargo clean

View File

@ -0,0 +1,129 @@
# macos-rssi-bridge
Mac WiFi card → RuView sensing-server bridge. Lets you do RSSI-based motion
sensing on a stock Mac with **no CSI hardware** while you wait for ESP32-S3
boards to arrive (or as a permanent extra "node" once they do).
## What it does
1. A small Swift helper (`mac_wifi`) wraps Apple's CoreWLAN framework and
prints one JSON line per visible BSSID, ~once per second. It replaces the
single-AP v1 helper at `archive/v1/src/sensing/mac_wifi.swift` with the
multi-BSSID format that `v2/.../macos_scanner.rs` expects.
2. A Rust binary (`macos-rssi-bridge`) spawns the helper, maintains a rolling
per-AP RSSI history, and packs each scan into an ESP32 CSI frame
(`magic = 0xC511_0001`) emitted over UDP — the same wire format
`wifi-densepose-sensing-server` expects from real ESP32 nodes.
The sensing-server pipeline runs unmodified. RSSI per AP becomes a
pseudo-subcarrier amplitude; per-AP variance becomes the Q channel.
## Why it works
Every AP in range beacons every ~100 ms. When a person moves, multipath
reflections shift and RSSI fluctuates. With 515 visible APs (your router +
neighbors'), running variance and cross-AP correlation gives a real motion
signal — coarser than CSI but useful for through-wall presence and
room-level activity. See [`README.md`](../../README.md) (line ~134, "Any WiFi:
RSSI-only") and [ADR-022](../../docs/adr/ADR-022-multi-bssid-scanning.md).
## Build
```bash
make build # compiles mac_wifi (Swift) + macos-rssi-bridge (Rust)
make scan # smoke test: print JSON for one scan
make test # unit tests (frame builder, variance, RSSI mapping)
```
Toolchain: `swiftc` 6.0+ and Cargo 1.80+. Tested on macOS 26 (Tahoe) arm64.
## Run
In one terminal, the sensing-server (provides UI + UDP receiver):
```bash
cd ../../v2 && cargo run --release -p wifi-densepose-sensing-server --no-default-features
# UI: http://localhost:8080
# WS: ws://localhost:8765/ws/sensing
```
In another, the bridge:
```bash
make run # default: target 127.0.0.1:5005, 1.5 s scan interval
# or: make run-verbose for per-frame stats on stderr
```
Open http://localhost:8080 and walk around. Motion should register within a
few seconds (the pipeline needs ~5 frames of baseline before it'll classify).
## Tuning
| Variable | Default | Notes |
|---------------|--------------|-------------------------------------------------------|
| `TARGET_HOST` | `127.0.0.1` | Where the sensing-server is listening |
| `TARGET_PORT` | `5005` | UDP port — matches `--udp-port` on sensing-server |
| `INTERVAL` | `1.5` | Seconds between active scans (helper enforces 0.5 floor) |
```bash
TARGET_HOST=192.168.1.50 TARGET_PORT=5005 make run
```
## macOS Location Services note
macOS Sonoma 14.4+ redacts BSSIDs to `00:00:00:00:00:00` and SSIDs to empty
strings unless the calling process holds the `com.apple.wifi.scan` entitlement
or has been granted Location Services authorization. The bridge handles the
redacted case automatically — the Swift helper synthesizes stable per-AP
identifiers from `(channel, RSSI bucket, ordinal)` so downstream code still
sees distinct virtual APs.
To get **real** SSIDs and BSSIDs (better tracking quality across scans):
1. **System Settings → Privacy & Security → Location Services → On**
2. Scroll to your terminal app (Terminal.app, iTerm2, Cursor, etc.) and toggle it on.
3. Re-run `make scan` — SSIDs and BSSIDs should now be populated.
This is optional. Sensing works without it; it's just less stable across scans
because the synthetic ordinal can shuffle.
## Limits vs. real CSI
| Capability | RSSI bridge | ESP32-S3 CSI |
|-------------------------------|-------------|--------------|
| Coarse presence / motion | Yes (~35 m) | Yes (~5 m) |
| Through-wall detection | Limited | Yes |
| Breathing rate | No (~0.5 Hz frame rate is too slow) | Yes (real-time) |
| Heart rate | No | Yes |
| 17-keypoint pose | No | Yes (with trained model) |
| Fall detection | Coarse | Yes (<200 ms) |
| Multi-person counting | No | Yes (35 per AP) |
Treat the bridge as a "node 0" you keep around for environmental fingerprinting
and motion baseline, alongside the real ESP32 mesh.
## Files
- `mac_wifi.swift` — CoreWLAN multi-BSSID scanner, JSON-lines output
- `src/main.rs` — Rust UDP emitter (ESP32 frame format `0xC511_0001`)
- `Cargo.toml` — standalone Cargo project; not a workspace member, so it
doesn't perturb the v2 build
- `Makefile``build`, `scan`, `run`, `listen`, `test`, `clean`
## Wire format
Reproduced from `v2/crates/wifi-densepose-sensing-server/src/csi.rs`:
```
bytes 0..4 u32 magic = 0xC511_0001 (LE)
byte 4 u8 node_id
byte 5 u8 n_antennas = 1
byte 6 u8 n_subcarriers = 56
byte 7 _ skipped
bytes 8..10 u16 freq_mhz (LE)
bytes 10..14 u32 sequence (LE)
byte 14 i8 rssi (strongest AP)
byte 15 i8 noise_floor (mean across APs)
bytes 16..20 _ header padding (iq_start = 20)
bytes 20.. i8 I/Q pairs (n_antennas * n_subcarriers, I=amp, Q=variance)
```

View File

@ -0,0 +1,497 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RuView — RSSI tomography</title>
<style>
:root {
--bg: #0a0c12;
--panel: #11141d;
--line: #1d2233;
--fg: #e7eaf3;
--muted: #8892a8;
--accent: #45e3d6;
--warn: #ffb454;
--hot: #ff5c5c;
--b24: #ff7a59;
--b5: #59a5ff;
--b6: #7be084;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
}
html, body { background: var(--bg); color: var(--fg); margin: 0; padding: 0; height: 100%; }
body { display: flex; flex-direction: column; height: 100vh; }
header {
padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 16px;
}
header h1 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); }
header .stat { font-size: 13px; color: var(--fg); }
header .stat b { color: var(--accent); font-weight: 500; }
header .pill {
padding: 2px 8px; border-radius: 4px; background: var(--line); font-size: 11px;
letter-spacing: 0.04em; text-transform: uppercase;
}
header .pill.live { color: var(--accent); }
header .pill.stale { color: var(--warn); }
header .pill.dead { color: var(--hot); }
main {
flex: 1; display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
gap: 1px;
background: var(--line);
min-height: 0;
}
section {
background: var(--panel); padding: 12px; display: flex; flex-direction: column; min-height: 0;
}
section h2 {
margin: 0 0 8px 0; font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--muted);
}
.canvas-wrap { flex: 1; position: relative; min-height: 0; }
canvas { width: 100%; height: 100%; display: block; }
.full-row { grid-column: 1 / -1; }
table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--line); }
td { padding: 3px 8px; border-bottom: 1px solid #15182280; }
.var-bar {
display: inline-block; height: 6px; background: var(--accent); border-radius: 2px;
vertical-align: middle; margin-right: 6px; min-width: 1px;
}
.band-24 { color: var(--b24); }
.band-5 { color: var(--b5); }
.band-6 { color: var(--b6); }
.ap-table-wrap { overflow-y: auto; flex: 1; min-height: 0; }
.legend { font-size: 11px; color: var(--muted); margin-top: 6px; }
.legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin: 0 4px 0 8px; vertical-align: middle; }
</style>
</head>
<body>
<header>
<h1>RuView · RSSI tomography</h1>
<span id="status" class="pill dead">offline</span>
<span class="stat">APs <b id="ap-count">0</b></span>
<span class="stat">strongest <b id="strongest">-</b> dBm</span>
<span class="stat">total motion <b id="total-var">0.0</b></span>
<span class="stat">seq <b id="seq">0</b></span>
<span style="flex:1"></span>
<span class="stat" style="color:var(--muted);font-size:11px">
laptop = center · APs auto-placed by RSSI distance + BSSID-hash bearing · drag laptop in tomography panel
</span>
</header>
<main>
<section>
<h2>Top-down RF tomography (multistatic Fresnel-zone fusion)</h2>
<div class="canvas-wrap"><canvas id="tomo"></canvas></div>
<div class="legend">
heatmap = sum of Gaussian perturbation strips along each AP↔laptop link, weighted by per-AP RSSI variance.
where a body crosses a link's first Fresnel zone, that strip lights up; intersections localize.
</div>
</section>
<section>
<h2>Per-AP polar radar (motion by direction)</h2>
<div class="canvas-wrap"><canvas id="polar"></canvas></div>
<div class="legend">
sector length ∝ recent RSSI variance · color = band
<span class="swatch" style="background:var(--b24)"></span>2.4 GHz
<span class="swatch" style="background:var(--b5)"></span>5 GHz
<span class="swatch" style="background:var(--b6)"></span>6 GHz
</div>
</section>
<section class="full-row" style="height: 38vh; display: grid; grid-template-columns: 2fr 3fr; gap: 12px;">
<div style="display: flex; flex-direction: column; min-height: 0;">
<h2>Total motion · last 60 s</h2>
<div class="canvas-wrap"><canvas id="timeline"></canvas></div>
</div>
<div style="display: flex; flex-direction: column; min-height: 0;">
<h2>APs (sorted by RSSI)</h2>
<div class="ap-table-wrap">
<table>
<thead><tr>
<th>id</th><th>ch</th><th>band</th>
<th style="text-align:right">rssi</th>
<th style="text-align:right">noise</th>
<th>variance</th>
<th style="text-align:right">age</th>
</tr></thead>
<tbody id="ap-rows"></tbody>
</table>
</div>
</div>
</section>
</main>
<script>
// ─────────────────────────────────────────────────────────────────────────────
// RuView RSSI tomography dashboard
//
// Polls macos-rssi-bridge's /aps endpoint at ~5 Hz, places APs around the
// laptop using a deterministic-by-id bearing and an RSSI-derived radius,
// then sums Gaussian perturbation strips along each AP↔laptop link weighted
// by per-AP variance to produce a real-time multistatic heatmap.
// ─────────────────────────────────────────────────────────────────────────────
const API = "/aps";
const POLL_HZ = 5;
const TIMELINE_SECS = 60;
const $ = (id) => document.getElementById(id);
const tomo = $("tomo"), tctx = tomo.getContext("2d");
const polar = $("polar"), pctx = polar.getContext("2d");
const tline = $("timeline"), lctx = tline.getContext("2d");
let laptop = { fx: 0.5, fy: 0.5 }; // fractional position in canvas
let timeline = []; // [{t, total_var}]
let dragging = false;
// Stable per-id angle so the AP doesn't jump around between polls.
function hashAngle(id) {
let h = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
h ^= id.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return ((h >>> 0) / 0x100000000) * Math.PI * 2;
}
function rssiToRadius(rssi, maxR) {
// -30 dBm → close, -100 dBm → max radius. Linear in dB.
const t = Math.max(0, Math.min(1, (-rssi - 30) / 70));
return 20 + t * (maxR - 20);
}
function bandClass(band) {
if (band.startsWith("2")) return "band-24";
if (band.startsWith("5")) return "band-5";
if (band.startsWith("6")) return "band-6";
return "";
}
function bandColor(band) {
if (band.startsWith("2")) return "#ff7a59";
if (band.startsWith("5")) return "#59a5ff";
if (band.startsWith("6")) return "#7be084";
return "#888";
}
// Viridis-ish palette: t∈[0,1] → rgb. Pre-sampled, lerped at runtime.
const PALETTE = [
[13, 8, 35], [50, 12, 88], [82, 18, 122], [114, 32, 130],
[148, 53, 130], [180, 82, 122], [210, 113, 105], [233, 152, 84],
[247, 197, 70], [252, 245, 92],
];
function colormap(t) {
t = Math.max(0, Math.min(0.999, t));
const i = t * (PALETTE.length - 1);
const a = PALETTE[Math.floor(i)], b = PALETTE[Math.ceil(i)];
const f = i - Math.floor(i);
return [
a[0] + (b[0] - a[0]) * f,
a[1] + (b[1] - a[1]) * f,
a[2] + (b[2] - a[2]) * f,
];
}
// ─── Tomography render ──────────────────────────────────────────────────────
function renderTomography(snap) {
const dpr = window.devicePixelRatio || 1;
const W = tomo.clientWidth | 0, H = tomo.clientHeight | 0;
if (tomo.width !== W * dpr) { tomo.width = W * dpr; tomo.height = H * dpr; }
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
tctx.clearRect(0, 0, W, H);
// background grid
tctx.strokeStyle = "#1d2233";
tctx.lineWidth = 1;
for (let i = 0; i < 12; i++) {
const r = i * 24;
tctx.beginPath();
tctx.arc(W * laptop.fx, H * laptop.fy, r, 0, Math.PI * 2);
tctx.stroke();
}
// place APs
const cx = W * laptop.fx, cy = H * laptop.fy;
const maxR = Math.min(W, H) * 0.45;
const aps = (snap?.aps ?? []).map(ap => ({
...ap,
angle: hashAngle(ap.id),
radius: rssiToRadius(ap.rssi_dbm, maxR),
})).map(ap => ({
...ap,
x: cx + Math.cos(ap.angle) * ap.radius,
y: cy + Math.sin(ap.angle) * ap.radius,
}));
// Heatmap: low-res grid, sum gaussian strips along each laptop↔AP link.
const gw = Math.max(20, Math.floor(W / 6));
const gh = Math.max(15, Math.floor(H / 6));
const grid = new Float32Array(gw * gh);
const sigma = Math.max(W, H) * 0.022;
const inv2s2 = 1 / (2 * sigma * sigma);
let maxVar = 0.001;
for (const ap of aps) maxVar = Math.max(maxVar, ap.variance);
for (const ap of aps) {
const w = ap.variance / maxVar;
if (w < 0.05) continue;
const dx = ap.x - cx, dy = ap.y - cy;
const len = Math.hypot(dx, dy);
if (len < 1) continue;
const ux = dx / len, uy = dy / len;
const samples = Math.max(8, Math.floor(len / 4));
for (let s = 0; s < samples; s++) {
const t = s / (samples - 1);
// Stronger weight near the midpoint (Fresnel zone is widest there).
const fresnel = 1 - Math.abs(t - 0.5) * 2;
const px = cx + ux * len * t;
const py = cy + uy * len * t;
const wt = w * (0.4 + 0.6 * fresnel);
// Splat a gaussian into the grid around (px, py).
const gxC = px / W * gw, gyC = py / H * gh;
const gxS = Math.max(0, Math.floor(gxC - 4));
const gxE = Math.min(gw, Math.ceil(gxC + 4));
const gyS = Math.max(0, Math.floor(gyC - 4));
const gyE = Math.min(gh, Math.ceil(gyC + 4));
for (let gy = gyS; gy < gyE; gy++) {
for (let gx = gxS; gx < gxE; gx++) {
const cx2 = (gx + 0.5) * W / gw;
const cy2 = (gy + 0.5) * H / gh;
const r2 = (cx2 - px) ** 2 + (cy2 - py) ** 2;
grid[gy * gw + gx] += wt * Math.exp(-r2 * inv2s2);
}
}
}
}
// Normalize & paint
let gMax = 0;
for (const v of grid) if (v > gMax) gMax = v;
if (gMax > 0) {
const img = tctx.createImageData(gw, gh);
for (let i = 0; i < grid.length; i++) {
const t = grid[i] / gMax;
const [r, g, b] = colormap(t);
const j = i * 4;
img.data[j] = r;
img.data[j+1] = g;
img.data[j+2] = b;
img.data[j+3] = Math.floor(t * 220);
}
// Upscale via offscreen canvas
const off = document.createElement("canvas");
off.width = gw; off.height = gh;
off.getContext("2d").putImageData(img, 0, 0);
tctx.imageSmoothingEnabled = true;
tctx.imageSmoothingQuality = "high";
tctx.globalCompositeOperation = "lighter";
tctx.drawImage(off, 0, 0, W, H);
tctx.globalCompositeOperation = "source-over";
}
// AP markers + link lines (faint)
for (const ap of aps) {
tctx.strokeStyle = "#2a3148";
tctx.lineWidth = 1;
tctx.beginPath();
tctx.moveTo(cx, cy);
tctx.lineTo(ap.x, ap.y);
tctx.stroke();
tctx.fillStyle = bandColor(ap.band);
const r = 3 + Math.min(7, ap.variance * 0.4);
tctx.beginPath();
tctx.arc(ap.x, ap.y, r, 0, Math.PI * 2);
tctx.fill();
}
// laptop marker
tctx.fillStyle = "#45e3d6";
tctx.beginPath();
tctx.arc(cx, cy, 6, 0, Math.PI * 2);
tctx.fill();
tctx.strokeStyle = "#0a0c12";
tctx.lineWidth = 2;
tctx.stroke();
tctx.fillStyle = "#8892a8";
tctx.font = "11px -apple-system, system-ui, sans-serif";
tctx.fillText("laptop", cx + 9, cy - 7);
}
// ─── Polar radar ────────────────────────────────────────────────────────────
function renderPolar(snap) {
const dpr = window.devicePixelRatio || 1;
const W = polar.clientWidth | 0, H = polar.clientHeight | 0;
if (polar.width !== W * dpr) { polar.width = W * dpr; polar.height = H * dpr; }
pctx.setTransform(dpr, 0, 0, dpr, 0, 0);
pctx.clearRect(0, 0, W, H);
const cx = W / 2, cy = H / 2;
const R = Math.min(W, H) * 0.45;
pctx.strokeStyle = "#1d2233";
for (let i = 1; i <= 4; i++) {
pctx.beginPath();
pctx.arc(cx, cy, R * i / 4, 0, Math.PI * 2);
pctx.stroke();
}
pctx.beginPath();
pctx.moveTo(cx, cy - R); pctx.lineTo(cx, cy + R);
pctx.moveTo(cx - R, cy); pctx.lineTo(cx + R, cy);
pctx.stroke();
const aps = snap?.aps ?? [];
if (!aps.length) return;
let maxVar = 0;
for (const ap of aps) if (ap.variance > maxVar) maxVar = ap.variance;
if (maxVar < 0.01) maxVar = 0.01;
for (const ap of aps) {
const a = hashAngle(ap.id);
const len = (ap.variance / maxVar) * R;
const wedge = 0.12;
pctx.fillStyle = bandColor(ap.band) + "cc";
pctx.beginPath();
pctx.moveTo(cx, cy);
pctx.arc(cx, cy, len, a - wedge, a + wedge);
pctx.closePath();
pctx.fill();
}
pctx.fillStyle = "#45e3d6";
pctx.beginPath();
pctx.arc(cx, cy, 4, 0, Math.PI * 2);
pctx.fill();
}
// ─── Motion timeline ────────────────────────────────────────────────────────
function renderTimeline() {
const dpr = window.devicePixelRatio || 1;
const W = tline.clientWidth | 0, H = tline.clientHeight | 0;
if (tline.width !== W * dpr) { tline.width = W * dpr; tline.height = H * dpr; }
lctx.setTransform(dpr, 0, 0, dpr, 0, 0);
lctx.clearRect(0, 0, W, H);
if (timeline.length < 2) return;
const now = timeline[timeline.length - 1].t;
const t0 = now - TIMELINE_SECS;
const visible = timeline.filter(p => p.t >= t0);
const max = Math.max(0.01, ...visible.map(p => p.total_var));
// baseline grid
lctx.strokeStyle = "#1d2233";
for (let i = 0; i < 4; i++) {
const y = H * (1 - i / 4);
lctx.beginPath();
lctx.moveTo(0, y); lctx.lineTo(W, y);
lctx.stroke();
}
// area
lctx.beginPath();
lctx.moveTo(0, H);
for (const p of visible) {
const x = ((p.t - t0) / TIMELINE_SECS) * W;
const y = H - (p.total_var / max) * H * 0.92 - 4;
lctx.lineTo(x, y);
}
lctx.lineTo(W, H);
lctx.closePath();
const grad = lctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, "#45e3d680");
grad.addColorStop(1, "#45e3d600");
lctx.fillStyle = grad;
lctx.fill();
lctx.strokeStyle = "#45e3d6";
lctx.lineWidth = 1.5;
lctx.beginPath();
for (let i = 0; i < visible.length; i++) {
const p = visible[i];
const x = ((p.t - t0) / TIMELINE_SECS) * W;
const y = H - (p.total_var / max) * H * 0.92 - 4;
if (i === 0) lctx.moveTo(x, y); else lctx.lineTo(x, y);
}
lctx.stroke();
lctx.fillStyle = "#8892a8";
lctx.font = "10px -apple-system, system-ui, sans-serif";
lctx.fillText(`max ≈ ${max.toFixed(1)}`, 4, 12);
}
// ─── AP table ───────────────────────────────────────────────────────────────
function renderTable(snap) {
const tbody = $("ap-rows");
const aps = snap?.aps ?? [];
const maxVar = Math.max(0.001, ...aps.map(a => a.variance));
tbody.innerHTML = aps.map(ap => `
<tr>
<td title="${ap.id}">${ap.id.length > 22 ? ap.id.slice(0, 22) + "…" : ap.id}</td>
<td>${ap.channel}</td>
<td class="${bandClass(ap.band)}">${ap.band}</td>
<td style="text-align:right">${ap.rssi_dbm.toFixed(0)}</td>
<td style="text-align:right">${ap.noise_dbm ? ap.noise_dbm.toFixed(0) : "—"}</td>
<td><span class="var-bar" style="width:${(ap.variance / maxVar * 80).toFixed(1)}px"></span>${ap.variance.toFixed(1)}</td>
<td style="text-align:right;color:${ap.age_ms < 3000 ? "var(--accent)" : "var(--warn)"}">${(ap.age_ms / 1000).toFixed(1)}s</td>
</tr>
`).join("");
}
// ─── Drag laptop in tomography canvas ───────────────────────────────────────
tomo.addEventListener("mousedown", e => {
const r = tomo.getBoundingClientRect();
laptop.fx = (e.clientX - r.left) / r.width;
laptop.fy = (e.clientY - r.top) / r.height;
dragging = true;
});
window.addEventListener("mouseup", () => dragging = false);
window.addEventListener("mousemove", e => {
if (!dragging) return;
const r = tomo.getBoundingClientRect();
laptop.fx = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
laptop.fy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
});
// ─── Poll loop ──────────────────────────────────────────────────────────────
let lastSeq = -1;
let lastSeqAt = 0;
async function poll() {
try {
const r = await fetch(API, { cache: "no-store" });
const snap = await r.json();
const now = performance.now();
if (snap.seq !== lastSeq) { lastSeq = snap.seq; lastSeqAt = now; }
const stale = now - lastSeqAt;
const status = $("status");
if (stale > 8000) { status.textContent = "offline"; status.className = "pill dead"; }
else if (stale > 3000) { status.textContent = "stale"; status.className = "pill stale"; }
else { status.textContent = "live"; status.className = "pill live"; }
$("ap-count").textContent = snap.aps.length;
$("strongest").textContent = snap.strongest_rssi_dbm?.toFixed(0) ?? "-";
$("seq").textContent = snap.seq ?? 0;
const totalVar = (snap.aps ?? []).reduce((s, a) => s + a.variance, 0);
$("total-var").textContent = totalVar.toFixed(1);
timeline.push({ t: snap.ts, total_var: totalVar });
if (timeline.length > 600) timeline.shift();
renderTomography(snap);
renderPolar(snap);
renderTimeline();
renderTable(snap);
} catch (e) {
const status = $("status");
status.textContent = "no bridge"; status.className = "pill dead";
}
}
poll();
setInterval(poll, 1000 / POLL_HZ);
</script>
</body>
</html>

View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Debug listener: prints frame headers for each ESP32-format CSI frame
the bridge emits. Reproduces only the fields needed for sanity checks.
Usage: python3 listen.py [port] (default port: 5005)
"""
import socket
import struct
import sys
import time
def main() -> int:
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5005
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", port))
print(f"[listen] udp://127.0.0.1:{port} — Ctrl-C to stop", flush=True)
n, t0 = 0, time.time()
try:
while True:
data, _ = sock.recvfrom(4096)
if len(data) < 20:
continue
magic, node_id, n_ant, n_sub = struct.unpack_from("<IBBB", data, 0)
seq = struct.unpack_from("<I", data, 10)[0]
rssi, noise = struct.unpack_from("bb", data, 14)
n += 1
print(
f"#{n:>4} t+{time.time() - t0:5.1f}s "
f"magic=0x{magic:08x} seq={seq:>4} node={node_id} "
f"ant={n_ant} sub={n_sub} rssi={rssi}dBm noise={noise}dBm",
flush=True,
)
except KeyboardInterrupt:
print(f"[listen] {n} frames in {time.time() - t0:.1f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,150 @@
// mac_wifi multi-BSSID WiFi scanner using CoreWLAN.
//
// Replaces the v1 single-AP helper at archive/v1/src/sensing/mac_wifi.swift.
// Emits one JSON object per visible BSSID, in the format expected by
// v2/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs:
//
// {"ssid":"<name>","bssid":"<aa:bb:cc:dd:ee:ff>","rssi":<int>,
// "noise":<int>,"channel":<int>,"band":"<2.4GHz|5GHz|6GHz>"}
//
// Modes:
// --scan-once : run a single active scan, print each BSSID as JSON, exit
// --watch : repeat --scan-once forever at --interval seconds (default 1.0)
// --interval N : seconds between scans in --watch mode
//
// Notes:
// - macOS Sonoma 14.4+ redacts BSSIDs to "00:00:00:00:00:00" unless the calling
// process holds the com.apple.wifi.scan entitlement OR Location Services is
// authorized for it. The Rust adapter handles the redacted case via a
// deterministic synthetic MAC; we just pass redacted MACs through.
// - Active scans take ~13 seconds. Don't run --watch faster than ~1 Hz.
import Foundation
import CoreWLAN
@inline(__always) func putLine(_ s: String) {
FileHandle.standardOutput.write(Data((s + "\n").utf8))
}
@inline(__always) func putErr(_ s: String) {
FileHandle.standardError.write(Data((s + "\n").utf8))
}
func bandLabel(_ band: CWChannelBand) -> String {
switch band {
case .band2GHz: return "2.4GHz"
case .band5GHz: return "5GHz"
case .band6GHz: return "6GHz"
case .bandUnknown: fallthrough
@unknown default: return "unknown"
}
}
func jsonEscape(_ s: String) -> String {
var out = ""
out.reserveCapacity(s.count + 2)
for ch in s.unicodeScalars {
switch ch {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
default:
if ch.value < 0x20 {
out += String(format: "\\u%04x", ch.value)
} else {
out += String(ch)
}
}
}
return out
}
struct ScanRecord {
let ssid: String
let bssid: String
let rssi: Int
let noise: Int
let channel: Int
let band: String
}
// Disambiguate redacted entries (empty SSID, zero BSSID) by computing an ordinal
// within each (channel, ~3 dBm RSSI bucket). The downstream Rust adapter hashes
// the SSID to derive a synthetic stable BSSID; making the SSID unique-per-AP
// recovers per-AP tracking even without Location Services authorization.
func disambiguate(_ raw: [ScanRecord]) -> [ScanRecord] {
var groups: [String: [Int]] = [:]
for (i, r) in raw.enumerated() where r.ssid.isEmpty && r.bssid.allSatisfy({ $0 == "0" || $0 == ":" }) {
let bucket = r.rssi / 3
groups["\(r.channel):\(bucket)", default: []].append(i)
}
var out = raw
for (key, idxs) in groups {
for (ord, i) in idxs.enumerated() {
let synthetic = "__redacted_\(key)_n\(ord)"
out[i] = ScanRecord(ssid: synthetic, bssid: out[i].bssid, rssi: out[i].rssi,
noise: out[i].noise, channel: out[i].channel, band: out[i].band)
}
}
return out
}
func emit(_ r: ScanRecord) {
let json = """
{"ssid":"\(jsonEscape(r.ssid))","bssid":"\(r.bssid)","rssi":\(r.rssi),"noise":\(r.noise),"channel":\(r.channel),"band":"\(r.band)"}
"""
putLine(json)
}
func scanOnce(_ iface: CWInterface) {
do {
let networks = try iface.scanForNetworks(withName: nil)
var raw: [ScanRecord] = []
raw.reserveCapacity(networks.count)
for n in networks {
raw.append(ScanRecord(
ssid: n.ssid ?? "",
bssid: n.bssid ?? "00:00:00:00:00:00",
rssi: n.rssiValue,
noise: n.noiseMeasurement,
channel: n.wlanChannel?.channelNumber ?? 0,
band: n.wlanChannel.map { bandLabel($0.channelBand) } ?? "unknown"
))
}
for r in disambiguate(raw) {
emit(r)
}
} catch let err as NSError {
putErr("{\"error\":\"scan_failed\",\"code\":\(err.code),\"domain\":\"\(jsonEscape(err.domain))\",\"message\":\"\(jsonEscape(err.localizedDescription))\"}")
exit(2)
}
}
func parseInterval(_ args: [String]) -> Double {
if let i = args.firstIndex(of: "--interval"), i + 1 < args.count,
let v = Double(args[i + 1]) {
return max(0.5, v)
}
return 1.0
}
let args = Array(CommandLine.arguments.dropFirst())
guard let iface = CWWiFiClient.shared().interface() else {
putErr("{\"error\":\"no_wifi_interface\"}")
exit(1)
}
setbuf(stdout, nil)
if args.contains("--watch") {
let interval = parseInterval(args)
while true {
scanOnce(iface)
Thread.sleep(forTimeInterval: interval)
}
} else {
scanOnce(iface)
}

View File

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
presence_to_pose.py RSSI tomography centroid sensing-server pose injector.
Polls the macos-rssi-bridge HTTP endpoint at /aps for live per-AP state,
computes the multistatic tomography centroid the same way dashboard.html
does (Gaussian perturbation strips along each APlaptop link, weighted by
per-AP RSSI variance), and POSTs the result as a single PersonDetection
to the sensing-server's /api/v1/pose/external endpoint.
The sensing-server bypasses its heuristic skeleton when external pose is
fresh (within EXTERNAL_POSE_TTL = 500ms), so the 3D Observatory renders
one honest figure standing where the heatmap localizes you instead of
five hallucinated skeletons.
Honest about what's real:
- Position: derived from real RSSI variance + Fresnel-zone geometry.
- Pose: STATIC standing skeleton (no joint estimation from RSSI; that
needs CSI hardware).
- Count: capped at 1 (single sensor, single observed environment).
"""
from __future__ import annotations
import argparse
import json
import math
import time
import urllib.error
import urllib.request
from typing import Any
# Coordinate frame: observatory uses meters with the laptop near origin.
# Standing keypoints in poseStanding() (figure-pool's pose-system.js) sit
# at y ≈ 0..1.72; we put the figure at the room's floor level (y=0) and
# let the pose-system raise the joints. Width of the playable area is
# roughly ±4m.
ROOM_HALF_X = 3.5
ROOM_HALF_Z = 3.5
# 17-point COCO standing-pose offsets, anchored at the figure's foot
# centroid (px, 0, pz). We don't actually need to send these — the
# observatory regenerates keypoints from `pose='standing'` + `position`.
# We include a sane skeleton anyway so the basic /ui/index.html viewer
# also renders something coherent.
def standing_keypoints(
px: float, pz: float, conf: float, motion: float, t: float
) -> list[dict[str, Any]]:
# In meters above floor. Mirrors PoseSystem.poseStanding() in
# ui/observatory/js/pose-system.js so both viewers agree.
#
# `motion` ∈ [0..1] modulates a subtle weight-shift sway so the figure
# looks alive when there's real RSSI motion. We're explicit that this
# is *not* derived pose — it's animation seeded by total motion energy
# so a frozen-looking figure doesn't suggest a frozen sensor.
sway_x = math.sin(t * 1.4) * 0.06 * motion
sway_z = math.cos(t * 0.9) * 0.04 * motion
head_turn = math.sin(t * 0.5) * 0.04 * (0.3 + motion)
shoulder_dip = math.sin(t * 1.4) * 0.025 * motion
knee_bend_l = max(0.0, math.sin(t * 1.4)) * 0.05 * motion
knee_bend_r = max(0.0, -math.sin(t * 1.4)) * 0.05 * motion
layout = [
("nose", (sway_x + head_turn, 1.72, sway_z)),
("left_eye", (-0.03 + sway_x + head_turn, 1.74, -0.02 + sway_z)),
("right_eye", (0.03 + sway_x + head_turn, 1.74, -0.02 + sway_z)),
("left_ear", (-0.07 + sway_x, 1.72, sway_z)),
("right_ear", (0.07 + sway_x, 1.72, sway_z)),
("left_shoulder", (-0.22 + sway_x * 0.7, 1.48 - shoulder_dip, sway_z * 0.7)),
("right_shoulder", (0.22 + sway_x * 0.7, 1.48 + shoulder_dip, sway_z * 0.7)),
("left_elbow", (-0.24 + sway_x * 0.5, 1.18 - shoulder_dip, 0.02 + sway_z * 0.5)),
("right_elbow", (0.24 + sway_x * 0.5, 1.18 + shoulder_dip, 0.02 + sway_z * 0.5)),
("left_wrist", (-0.26 + sway_x * 0.3, 0.92 - shoulder_dip, 0.04)),
("right_wrist", (0.26 + sway_x * 0.3, 0.92 + shoulder_dip, 0.04)),
("left_hip", (-0.14, 1.00, 0.00)),
("right_hip", (0.14, 1.00, 0.00)),
("left_knee", (-0.15, 0.55 + knee_bend_l, 0.00)),
("right_knee", (0.15, 0.55 + knee_bend_r, 0.00)),
("left_ankle", (-0.16, 0.10, 0.00)),
("right_ankle", (0.16, 0.10, 0.00)),
]
return [
{"name": name, "x": px + dx, "y": dy, "z": pz + dz, "confidence": conf}
for name, (dx, dy, dz) in layout
]
def stable_hash_angle(s: str) -> float:
"""FNV-1a → angle in [0, 2π). Same as dashboard.html so AP layouts agree."""
h = 0x811c9dc5
for c in s.encode("utf-8"):
h ^= c
h = (h * 0x01000193) & 0xFFFFFFFF
return (h / 0x100000000) * math.tau
def rssi_to_radius(rssi: float, max_r: float) -> float:
"""Linear in dB. -30 dBm → close, -100 dBm → max_r away."""
t = max(0.0, min(1.0, (-rssi - 30.0) / 70.0))
return 0.4 + t * (max_r - 0.4) # 0.4m floor so the figure isn't on top of the laptop
def compute_centroid(aps: list[dict[str, Any]]) -> tuple[float, float, float]:
"""Variance-weighted centroid in (x_m, z_m) plus a 'localization weight'."""
if not aps:
return 0.0, 0.0, 0.0
# 1. Place each AP in 2D using the same scheme as dashboard.html.
placed = []
for ap in aps:
angle = stable_hash_angle(ap["id"])
radius = rssi_to_radius(ap["rssi_dbm"], min(ROOM_HALF_X, ROOM_HALF_Z))
ax = math.cos(angle) * radius
az = math.sin(angle) * radius
placed.append((ax, az, ap.get("variance", 0.0)))
# 2. Centroid: along each AP↔laptop line, the most informative point is
# the midpoint (Fresnel zone widest there). Weight each midpoint by
# variance.
sum_w = 0.0
sum_x = 0.0
sum_z = 0.0
for ax, az, var in placed:
if var <= 0.01:
continue
# Midpoint between (0,0) (laptop) and (ax, az) (AP).
mx, mz = ax * 0.5, az * 0.5
sum_w += var
sum_x += var * mx
sum_z += var * mz
if sum_w < 1e-6:
# No motion — figure parks at the laptop.
return 0.0, 0.0, 0.0
return sum_x / sum_w, sum_z / sum_w, sum_w
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--bridge-url", default="http://127.0.0.1:9090/aps",
help="macos-rssi-bridge /aps endpoint")
ap.add_argument("--server-url", default="http://127.0.0.1:8080/api/v1/pose/external",
help="sensing-server pose ingestion endpoint")
ap.add_argument("--rate-hz", type=float, default=10.0,
help="how often to POST a pose (Hz)")
ap.add_argument("--smooth", type=float, default=0.6,
help="EMA factor for centroid smoothing (0..1, higher = snappier)")
ap.add_argument("--amplify", type=float, default=4.0,
help="multiplier on the centroid coordinates so small RSSI shifts produce visible figure motion in the room")
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
period = 1.0 / args.rate_hz
sx, sz = 0.0, 0.0
last_log = 0.0
n_posted = 0
n_fail = 0
print(f"[presence] {args.bridge_url}{args.server_url} @ {args.rate_hz:.1f} Hz",
flush=True)
while True:
loop_start = time.time()
try:
with urllib.request.urlopen(args.bridge_url, timeout=1.0) as r:
snap = json.loads(r.read())
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
n_fail += 1
if args.verbose:
print(f"[presence] bridge fetch failed: {e}", flush=True)
time.sleep(period)
continue
aps = snap.get("aps", [])
cx, cz, weight = compute_centroid(aps)
# Amplify the centroid so small RSSI shifts (a few cm of raw
# variance-weighted midpoint motion) produce visible motion in
# the room view. Without amplification the centroid only spans
# ~0.5m which is invisible at 7m room scale.
cx *= args.amplify
cz *= args.amplify
# Clamp to room half-extents so the figure never walks through walls.
cx = max(-ROOM_HALF_X + 0.5, min(ROOM_HALF_X - 0.5, cx))
cz = max(-ROOM_HALF_Z + 0.5, min(ROOM_HALF_Z - 0.5, cz))
# EMA smoothing — sensing-server runs at 10 Hz tick, so we want
# the figure to glide rather than jitter on every micro-fluctuation.
sx = sx * (1 - args.smooth) + cx * args.smooth
sz = sz * (1 - args.smooth) + cz * args.smooth
# Confidence ~ how much variance is present. Caps at ~1.0 once
# there's a few dB² of cumulative motion energy across APs.
conf = max(0.3, min(1.0, 0.3 + weight / 10.0))
# Normalize motion intensity from cumulative variance.
motion = max(0.0, min(1.0, weight / 5.0))
t = time.time()
person = {
"id": 1,
"confidence": conf,
"keypoints": standing_keypoints(sx, sz, conf, motion, t),
"bbox": {"x": sx - 0.4, "y": 0.0, "width": 0.8, "height": 1.85},
"zone": "rssi_localized",
}
body = json.dumps([person]).encode("utf-8")
try:
req = urllib.request.Request(
args.server_url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=1.0) as r:
_ = r.read()
n_posted += 1
except urllib.error.URLError as e:
n_fail += 1
if args.verbose:
print(f"[presence] post failed: {e}", flush=True)
now = time.time()
if args.verbose and now - last_log > 1.0:
print(
f"[presence] aps={len(aps):>2} centroid=({sx:+.2f}, {sz:+.2f})m "
f"weight={weight:>5.2f} conf={conf:.2f} posted={n_posted} fail={n_fail}",
flush=True,
)
last_log = now
elapsed = time.time() - loop_start
if elapsed < period:
time.sleep(period - elapsed)
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\n[presence] stopped")

View File

@ -0,0 +1,489 @@
//! macos-rssi-bridge
//!
//! Bridges a Mac's built-in WiFi card to the RuView sensing-server's UDP CSI
//! input. Spawns the `mac_wifi` Swift helper in `--watch` mode, reads its
//! JSON-lines output, maintains a per-BSSID RSSI ring buffer, and packs each
//! scan into an ESP32-format CSI frame (magic `0xC511_0001`) emitted over UDP.
//!
//! No CSI hardware is involved — RSSI from each visible AP is treated as a
//! pseudo-subcarrier amplitude. The sensing-server pipeline runs unmodified
//! against this synthetic CSI, giving you laptop-grade motion sensing while
//! you wait for ESP32 boards to arrive. See the v2/crates/wifi-densepose-wifiscan
//! crate (ADR-022) for the formal multi-AP sensing model.
//!
//! Wire format reproduced from
//! `v2/crates/wifi-densepose-sensing-server/src/csi.rs::parse_esp32_frame`:
//!
//! bytes 0..4 u32 magic = 0xC511_0001 (LE)
//! byte 4 u8 node_id
//! byte 5 u8 n_antennas
//! byte 6 u8 n_subcarriers
//! byte 7 _ (skipped)
//! bytes 8..10 u16 freq_mhz (LE)
//! bytes 10..14 u32 sequence (LE)
//! byte 14 i8 rssi
//! byte 15 i8 noise_floor
//! bytes 16..20 _ (header padding; iq_start = 20)
//! bytes 20.. i8 I/Q pairs, n_antennas * n_subcarriers entries
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::net::UdpSocket;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use clap::Parser;
use serde::{Deserialize, Serialize};
const FRAME_MAGIC: u32 = 0xC511_0001;
const N_ANTENNAS: u8 = 1;
const N_SUBCARRIERS: u8 = 56;
const FRAME_HEADER_LEN: usize = 20;
const HISTORY_LEN: usize = 16;
/// Cap the AP set to keep things bounded and roughly aligned with the
/// `WindowsWifiPipeline` default of `max_bssids: 32`. Without real BSSIDs
/// the ordinal in the synthetic SSID can shuffle scan-to-scan, so the live
/// set drifts; capping prevents that drift from polluting downstream stats.
const MAX_APS: usize = 32;
#[derive(Parser, Debug)]
#[command(name = "macos-rssi-bridge", about, long_about = None)]
struct Args {
/// Path to the compiled mac_wifi Swift helper.
#[arg(long, default_value = "./mac_wifi")]
helper: PathBuf,
/// UDP target host for the sensing-server.
#[arg(long, default_value = "127.0.0.1")]
target_host: String,
/// UDP target port (sensing-server default is 5005).
#[arg(long, default_value_t = 5005)]
target_port: u16,
/// Seconds between active scans (the helper enforces a 0.5s floor).
#[arg(long, default_value_t = 1.5)]
interval: f64,
/// node_id stamped into emitted frames.
#[arg(long, default_value_t = 1)]
node_id: u8,
/// Print each emitted frame to stdout.
#[arg(long)]
verbose: bool,
/// HTTP port for the per-AP state JSON + tomography dashboard.
/// Set to 0 to disable.
#[arg(long, default_value_t = 9090)]
http_port: u16,
/// Path to the static dashboard HTML to serve at GET /dashboard.
/// Defaults to dashboard.html next to the binary.
#[arg(long, default_value = "dashboard.html")]
dashboard: PathBuf,
}
#[derive(Debug, Deserialize)]
struct ScanLine {
ssid: String,
#[allow(dead_code)]
bssid: String,
rssi: i32,
noise: i32,
channel: u16,
band: String,
}
#[derive(Default, Debug, Clone)]
struct ApState {
rssi_history: Vec<f32>,
last_rssi: f32,
last_noise: f32,
channel: u16,
band: String,
last_seen: Option<Instant>,
}
impl ApState {
fn record(&mut self, rssi: f32, noise: f32, channel: u16, band: &str) {
self.rssi_history.push(rssi);
if self.rssi_history.len() > HISTORY_LEN {
self.rssi_history.remove(0);
}
self.last_rssi = rssi;
self.last_noise = noise;
self.channel = channel;
if self.band.is_empty() {
self.band = band.to_owned();
}
self.last_seen = Some(Instant::now());
}
/// Welford-ish variance over the rolling RSSI window.
fn variance(&self) -> f32 {
let n = self.rssi_history.len();
if n < 2 {
return 0.0;
}
let mean = self.rssi_history.iter().sum::<f32>() / n as f32;
self.rssi_history
.iter()
.map(|x| (x - mean).powi(2))
.sum::<f32>()
/ n as f32
}
}
/// JSON shape returned from `GET /aps`. Consumed by the tomography dashboard.
#[derive(Debug, Serialize, Clone)]
struct ApSnapshot {
/// Stable id (synthetic SSID or real one).
id: String,
channel: u16,
band: String,
rssi_dbm: f32,
noise_dbm: f32,
/// Rolling-window variance — high values mean a moving body is
/// modulating the AP↔laptop link.
variance: f32,
/// Most recent N RSSI samples (N = HISTORY_LEN). Newest last.
history: Vec<f32>,
/// Milliseconds since this AP was last seen in a scan.
age_ms: u64,
}
#[derive(Debug, Serialize, Clone, Default)]
struct StateSnapshot {
/// Wall-clock timestamp in seconds since UNIX epoch.
ts: f64,
/// Sequence number of the most recent emitted CSI frame.
seq: u32,
/// Strongest AP's last RSSI in dBm (a coarse proxy for proximity).
strongest_rssi_dbm: f32,
/// Sorted-by-RSSI snapshot of every tracked AP.
aps: Vec<ApSnapshot>,
}
/// Map a -100..-30 dBm RSSI value into a roughly full-range i8 magnitude.
/// We map dB linearly (1.27 LSB per dB) instead of using the linear
/// amplitude `10^((rssi+100)/20)` — that exponential saturates at the i8
/// ceiling for any AP stronger than ~-65 dBm, which is most modern indoor
/// environments. Linear-in-dB keeps the strongest APs informative.
fn rssi_to_i_byte(rssi: f32) -> i8 {
((rssi + 100.0) * 1.27).clamp(0.0, 127.0) as i8
}
/// Map per-BSSID RSSI variance into the Q channel — high variance = a
/// person modulating that AP's reflections. ~10 dB^2 lands near full-range.
fn variance_to_q_byte(var: f32) -> i8 {
(var * 12.0).clamp(0.0, 127.0) as i8
}
fn build_frame(seq: u32, node_id: u8, aps: &[(String, &ApState)]) -> Vec<u8> {
let mut buf = vec![0u8; FRAME_HEADER_LEN + 2 * N_ANTENNAS as usize * N_SUBCARRIERS as usize];
buf[0..4].copy_from_slice(&FRAME_MAGIC.to_le_bytes());
buf[4] = node_id;
buf[5] = N_ANTENNAS;
buf[6] = N_SUBCARRIERS;
buf[8..10].copy_from_slice(&2437u16.to_le_bytes()); // 2.4 GHz channel 6 reference
buf[10..14].copy_from_slice(&seq.to_le_bytes());
let strongest = aps.iter().map(|(_, s)| s.last_rssi).fold(-127.0f32, f32::max);
let avg_noise = if aps.is_empty() {
-90.0
} else {
aps.iter().map(|(_, s)| s.last_noise).sum::<f32>() / aps.len() as f32
};
buf[14] = (strongest as i32).clamp(-127, 0) as i8 as u8;
buf[15] = (avg_noise as i32).clamp(-127, 0) as i8 as u8;
// Pack the AP set into 56 pseudo-subcarriers as a *block* layout (not
// interleaved). Each AP gets a contiguous slab of subcarriers, so
// adjacent subcarriers are perfectly correlated — this produces a
// single coherent "observed body" pattern that the sensing-server's
// mincut person counter (estimate_persons_from_correlation) reads as
// one person instead of fragmenting our N visible APs into N synthetic
// people. Cap to 8 APs so each slab is wide enough (≥7 subcarriers)
// for the correlation window to register as one group.
let n_aps = aps.len().max(1).min(8);
let slab = N_SUBCARRIERS as usize / n_aps;
let leftover = N_SUBCARRIERS as usize - slab * n_aps;
for k in 0..N_SUBCARRIERS as usize {
// Map subcarrier index to AP index via slab boundaries; the last
// few subcarriers (leftover) cycle the strongest AP.
let ap_idx = if k < slab * n_aps {
(k / slab).min(n_aps - 1)
} else {
// tail subcarriers go to the strongest AP (index 0 after sort)
0
};
let _ = leftover; // explicit: we handle it via the else-branch above
let (_, st) = &aps[ap_idx];
let i_off = FRAME_HEADER_LEN + k * 2;
buf[i_off] = rssi_to_i_byte(st.last_rssi) as u8;
buf[i_off + 1] = variance_to_q_byte(st.variance()) as u8;
}
buf
}
fn spawn_helper(helper: &PathBuf, interval: f64) -> std::io::Result<Child> {
Command::new(helper)
.arg("--watch")
.arg("--interval")
.arg(interval.to_string())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
}
/// Build a `StateSnapshot` from the current AP map (already RSSI-sorted).
fn build_snapshot(seq: u32, aps: &[(String, &ApState)]) -> StateSnapshot {
let now = Instant::now();
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let strongest_rssi_dbm = aps.first().map(|(_, s)| s.last_rssi).unwrap_or(-100.0);
let snaps: Vec<ApSnapshot> = aps
.iter()
.map(|(id, s)| ApSnapshot {
id: id.clone(),
channel: s.channel,
band: s.band.clone(),
rssi_dbm: s.last_rssi,
noise_dbm: s.last_noise,
variance: s.variance(),
history: s.rssi_history.clone(),
age_ms: s
.last_seen
.map(|t| now.duration_since(t).as_millis() as u64)
.unwrap_or(u64::MAX),
})
.collect();
StateSnapshot {
ts,
seq,
strongest_rssi_dbm,
aps: snaps,
}
}
/// Lightweight HTTP server: GET /aps → JSON state, GET /dashboard → static
/// HTML, GET / → small index. Runs in its own thread, never blocks the
/// scanner. Permissive CORS so a dashboard.html opened via file:// can still
/// fetch /aps if the user prefers that path over the served version.
fn spawn_http_server(
port: u16,
dashboard_path: PathBuf,
snapshot: Arc<Mutex<StateSnapshot>>,
) -> std::io::Result<()> {
let server = tiny_http::Server::http(("0.0.0.0", port))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()))?;
eprintln!("[bridge] http://127.0.0.1:{port}/dashboard ← tomography UI");
eprintln!("[bridge] http://127.0.0.1:{port}/aps ← per-AP state JSON");
thread::spawn(move || {
for req in server.incoming_requests() {
let url = req.url().to_string();
let path = url.split('?').next().unwrap_or("/");
let (status, content_type, body): (u16, &str, Vec<u8>) = match path {
"/aps" => {
let snap = snapshot.lock().expect("snapshot mutex poisoned").clone();
let body = serde_json::to_vec(&snap).unwrap_or_else(|_| b"{}".to_vec());
(200, "application/json", body)
}
"/dashboard" | "/dashboard/" => match std::fs::read(&dashboard_path) {
Ok(html) => (200, "text/html; charset=utf-8", html),
Err(e) => (
404,
"text/plain",
format!("dashboard.html not found at {dashboard_path:?}: {e}").into_bytes(),
),
},
"/" => (
200,
"text/html; charset=utf-8",
b"<!doctype html><meta charset=utf-8><title>macos-rssi-bridge</title>\
<body style='font:14px system-ui;margin:2em'>\
<h1>macos-rssi-bridge</h1>\
<p><a href='/dashboard'>/dashboard</a> &mdash; live tomography UI</p>\
<p><a href='/aps'>/aps</a> &mdash; per-AP state JSON</p>\
</body>"
.to_vec(),
),
_ => (404, "text/plain", b"not found".to_vec()),
};
let response = tiny_http::Response::from_data(body)
.with_status_code(status)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
.expect("static header"),
)
.with_header(
tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..])
.expect("static header"),
);
let _ = req.respond(response);
}
});
Ok(())
}
fn main() -> std::io::Result<()> {
let args = Args::parse();
let socket = UdpSocket::bind("0.0.0.0:0")?;
let target = format!("{}:{}", args.target_host, args.target_port);
socket.connect(&target)?;
eprintln!("[bridge] sending CSI frames to udp://{}", target);
// Shared snapshot consumed by the HTTP /aps endpoint. Starts empty;
// populated on each emit tick. Mutex over a small struct — contention
// is negligible at single-digit Hz.
let snapshot = Arc::new(Mutex::new(StateSnapshot::default()));
if args.http_port != 0 {
// Resolve dashboard path relative to the binary if the user passed
// the default — keeps `make run` working regardless of cwd.
let mut dashboard_path = args.dashboard.clone();
if dashboard_path.is_relative() && !dashboard_path.exists() {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let candidate = dir.join(&args.dashboard);
if candidate.exists() {
dashboard_path = candidate;
}
}
}
}
spawn_http_server(args.http_port, dashboard_path, Arc::clone(&snapshot))?;
}
let mut child = spawn_helper(&args.helper, args.interval)?;
let stdout = child
.stdout
.take()
.expect("helper stdout was piped at spawn time");
let reader = BufReader::new(stdout);
let running = Arc::new(AtomicBool::new(true));
{
let running = running.clone();
let _ = ctrlc::set_handler(move || {
running.store(false, Ordering::SeqCst);
});
}
let mut aps: HashMap<String, ApState> = HashMap::new();
let mut seq: u32 = 0;
let mut last_emit = Instant::now() - Duration::from_secs(10);
let emit_interval = Duration::from_millis(100);
for line in reader.lines() {
if !running.load(Ordering::SeqCst) {
break;
}
let Ok(line) = line else { continue };
let line = line.trim();
if line.is_empty() || !line.starts_with('{') {
continue;
}
let Ok(scan) = serde_json::from_str::<ScanLine>(line) else {
continue;
};
let key = if scan.ssid.is_empty() {
format!("ch{}_n{}", scan.channel, scan.rssi)
} else {
scan.ssid.clone()
};
aps.entry(key)
.or_default()
.record(scan.rssi as f32, scan.noise as f32, scan.channel, &scan.band);
let now = Instant::now();
if now.duration_since(last_emit) < emit_interval {
continue;
}
last_emit = now;
// Drop APs we haven't seen in 30s so the picture stays current.
aps.retain(|_, s| s.last_seen.map_or(false, |t| now.duration_since(t) < Duration::from_secs(30)));
if aps.is_empty() {
continue;
}
// Sort by recent RSSI so the strongest APs lead the subcarrier
// layout — they're the most informative — then cap to MAX_APS.
let mut sorted: Vec<(String, &ApState)> =
aps.iter().map(|(k, v)| (k.clone(), v)).collect();
sorted.sort_by(|a, b| b.1.last_rssi.partial_cmp(&a.1.last_rssi).unwrap_or(std::cmp::Ordering::Equal));
sorted.truncate(MAX_APS);
seq = seq.wrapping_add(1);
let frame = build_frame(seq, args.node_id, &sorted);
// UDP sends can return ECONNREFUSED on macOS when the receiver
// emits an ICMP unreachable. Log and continue — the receiver may
// come back and we don't want a blip to kill the bridge.
if let Err(e) = socket.send(&frame) {
eprintln!("[bridge] udp send failed (continuing): {e}");
}
// Refresh the shared snapshot the HTTP /aps endpoint reads from.
if let Ok(mut s) = snapshot.lock() {
*s = build_snapshot(seq, &sorted);
}
if args.verbose {
let strongest = sorted[0].1.last_rssi;
let max_var = sorted.iter().map(|(_, s)| s.variance()).fold(0f32, f32::max);
eprintln!(
"[bridge] seq={} aps={:>2} strongest={:>4.0} dBm max_var={:>5.1}",
seq, sorted.len(), strongest, max_var
);
}
}
let _ = child.kill();
let _ = child.wait();
thread::sleep(Duration::from_millis(50));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rssi_mapping_baseline() {
assert_eq!(rssi_to_i_byte(-100.0), 0);
assert_eq!(rssi_to_i_byte(-30.0), 88);
assert_eq!(rssi_to_i_byte(-50.0), 63);
// Saturates cleanly past the calibrated range.
assert_eq!(rssi_to_i_byte(0.0), 127);
assert_eq!(rssi_to_i_byte(-200.0), 0);
}
#[test]
fn variance_starts_at_zero() {
let st = ApState::default();
assert_eq!(st.variance(), 0.0);
}
#[test]
fn variance_grows_with_jitter() {
let mut st = ApState::default();
for r in [-60.0, -64.0, -58.0, -65.0, -61.0, -67.0] {
st.record(r, -90.0, 6);
}
assert!(st.variance() > 1.0);
}
#[test]
fn frame_starts_with_magic() {
let mut st = ApState::default();
st.record(-50.0, -90.0, 6);
let frame = build_frame(42, 1, &[("test".into(), &st)]);
assert_eq!(&frame[0..4], &FRAME_MAGIC.to_le_bytes());
assert_eq!(frame[5], N_ANTENNAS);
assert_eq!(frame[6], N_SUBCARRIERS);
assert_eq!(frame.len(), 20 + 2 * 1 * 56);
}
}