diff --git a/.gitignore b/.gitignore
index 30f4a0eb..9eb8261a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/scripts/macos-rssi-bridge/Cargo.lock b/scripts/macos-rssi-bridge/Cargo.lock
new file mode 100644
index 00000000..ad4b5be3
--- /dev/null
+++ b/scripts/macos-rssi-bridge/Cargo.lock
@@ -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"
diff --git a/scripts/macos-rssi-bridge/Cargo.toml b/scripts/macos-rssi-bridge/Cargo.toml
new file mode 100644
index 00000000..ea2c6a72
--- /dev/null
+++ b/scripts/macos-rssi-bridge/Cargo.toml
@@ -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]
diff --git a/scripts/macos-rssi-bridge/Makefile b/scripts/macos-rssi-bridge/Makefile
new file mode 100644
index 00000000..e43d2e4b
--- /dev/null
+++ b/scripts/macos-rssi-bridge/Makefile
@@ -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
diff --git a/scripts/macos-rssi-bridge/README.md b/scripts/macos-rssi-bridge/README.md
new file mode 100644
index 00000000..5cc23c24
--- /dev/null
+++ b/scripts/macos-rssi-bridge/README.md
@@ -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 5–15 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 (~3–5 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 (3–5 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)
+```
diff --git a/scripts/macos-rssi-bridge/dashboard.html b/scripts/macos-rssi-bridge/dashboard.html
new file mode 100644
index 00000000..3801ee58
--- /dev/null
+++ b/scripts/macos-rssi-bridge/dashboard.html
@@ -0,0 +1,497 @@
+
+
+
+
+
+ RuView — RSSI tomography
+
+
+
+
+
+ RuView · RSSI tomography
+ offline
+ APs 0
+ strongest - dBm
+ total motion 0.0
+ seq 0
+
+
+ laptop = center · APs auto-placed by RSSI distance + BSSID-hash bearing · drag laptop in tomography panel
+
+
+
+
+
+ Top-down RF tomography (multistatic Fresnel-zone fusion)
+
+
+ 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.
+
+
+
+
+ Per-AP polar radar (motion by direction)
+
+
+ sector length ∝ recent RSSI variance · color = band
+ 2.4 GHz
+ 5 GHz
+ 6 GHz
+
+
+
+
+
+
Total motion · last 60 s
+
+
+
+
APs (sorted by RSSI)
+
+
+
+ | id | ch | band |
+ rssi |
+ noise |
+ variance |
+ age |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/macos-rssi-bridge/listen.py b/scripts/macos-rssi-bridge/listen.py
new file mode 100755
index 00000000..30ea1d90
--- /dev/null
+++ b/scripts/macos-rssi-bridge/listen.py
@@ -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("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())
diff --git a/scripts/macos-rssi-bridge/mac_wifi.swift b/scripts/macos-rssi-bridge/mac_wifi.swift
new file mode 100644
index 00000000..d5173648
--- /dev/null
+++ b/scripts/macos-rssi-bridge/mac_wifi.swift
@@ -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":"","bssid":"","rssi":,
+// "noise":,"channel":,"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 ~1–3 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)
+}
diff --git a/scripts/macos-rssi-bridge/presence_to_pose.py b/scripts/macos-rssi-bridge/presence_to_pose.py
new file mode 100755
index 00000000..b18397a6
--- /dev/null
+++ b/scripts/macos-rssi-bridge/presence_to_pose.py
@@ -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 AP↔laptop 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")
diff --git a/scripts/macos-rssi-bridge/src/main.rs b/scripts/macos-rssi-bridge/src/main.rs
new file mode 100644
index 00000000..8fa2d1f7
--- /dev/null
+++ b/scripts/macos-rssi-bridge/src/main.rs
@@ -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,
+ last_rssi: f32,
+ last_noise: f32,
+ channel: u16,
+ band: String,
+ last_seen: Option,
+}
+
+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::() / n as f32;
+ self.rssi_history
+ .iter()
+ .map(|x| (x - mean).powi(2))
+ .sum::()
+ / 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,
+ /// 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,
+}
+
+/// 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 {
+ 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::() / 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 {
+ 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 = 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>,
+) -> 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) = 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"macos-rssi-bridge\
+ \
+ macos-rssi-bridge
\
+ /dashboard — live tomography UI
\
+ /aps — per-AP state JSON
\
+ "
+ .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 = 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::(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);
+ }
+}