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)

+
+ + + + + + + + + +
idchbandrssinoisevarianceage
+
+
+
+
+ + + + 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); + } +}