feat(macos-rssi-bridge): Mac WiFi card → sensing-server bridge
Swift CoreWLAN helper + Rust UDP emitter that turns any Mac into a "node 0" for RuView's sensing-server while waiting on ESP32 hardware. Wraps multi-BSSID scans into ESP32 CSI frame format (magic 0xC511_0001) with per-AP RSSI as pseudo-subcarrier amplitude and rolling variance as the Q channel — pipeline runs unmodified. Standalone Cargo project (not a v2 workspace member, so the main build is unaffected). Handles macOS 14.4+ BSSID redaction by synthesizing stable per-AP identifiers from (channel, RSSI bucket, ordinal). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f891329384
commit
e325082e6d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -0,0 +1,497 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>RuView — RSSI tomography</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0c12;
|
||||
--panel: #11141d;
|
||||
--line: #1d2233;
|
||||
--fg: #e7eaf3;
|
||||
--muted: #8892a8;
|
||||
--accent: #45e3d6;
|
||||
--warn: #ffb454;
|
||||
--hot: #ff5c5c;
|
||||
--b24: #ff7a59;
|
||||
--b5: #59a5ff;
|
||||
--b6: #7be084;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--fg); margin: 0; padding: 0; height: 100%; }
|
||||
body { display: flex; flex-direction: column; height: 100vh; }
|
||||
header {
|
||||
padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); }
|
||||
header .stat { font-size: 13px; color: var(--fg); }
|
||||
header .stat b { color: var(--accent); font-weight: 500; }
|
||||
header .pill {
|
||||
padding: 2px 8px; border-radius: 4px; background: var(--line); font-size: 11px;
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
}
|
||||
header .pill.live { color: var(--accent); }
|
||||
header .pill.stale { color: var(--warn); }
|
||||
header .pill.dead { color: var(--hot); }
|
||||
main {
|
||||
flex: 1; display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
min-height: 0;
|
||||
}
|
||||
section {
|
||||
background: var(--panel); padding: 12px; display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
section h2 {
|
||||
margin: 0 0 8px 0; font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--muted);
|
||||
}
|
||||
.canvas-wrap { flex: 1; position: relative; min-height: 0; }
|
||||
canvas { width: 100%; height: 100%; display: block; }
|
||||
.full-row { grid-column: 1 / -1; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--line); }
|
||||
td { padding: 3px 8px; border-bottom: 1px solid #15182280; }
|
||||
.var-bar {
|
||||
display: inline-block; height: 6px; background: var(--accent); border-radius: 2px;
|
||||
vertical-align: middle; margin-right: 6px; min-width: 1px;
|
||||
}
|
||||
.band-24 { color: var(--b24); }
|
||||
.band-5 { color: var(--b5); }
|
||||
.band-6 { color: var(--b6); }
|
||||
.ap-table-wrap { overflow-y: auto; flex: 1; min-height: 0; }
|
||||
.legend { font-size: 11px; color: var(--muted); margin-top: 6px; }
|
||||
.legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin: 0 4px 0 8px; vertical-align: middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>RuView · RSSI tomography</h1>
|
||||
<span id="status" class="pill dead">offline</span>
|
||||
<span class="stat">APs <b id="ap-count">0</b></span>
|
||||
<span class="stat">strongest <b id="strongest">-</b> dBm</span>
|
||||
<span class="stat">total motion <b id="total-var">0.0</b></span>
|
||||
<span class="stat">seq <b id="seq">0</b></span>
|
||||
<span style="flex:1"></span>
|
||||
<span class="stat" style="color:var(--muted);font-size:11px">
|
||||
laptop = center · APs auto-placed by RSSI distance + BSSID-hash bearing · drag laptop in tomography panel
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2>Top-down RF tomography (multistatic Fresnel-zone fusion)</h2>
|
||||
<div class="canvas-wrap"><canvas id="tomo"></canvas></div>
|
||||
<div class="legend">
|
||||
heatmap = sum of Gaussian perturbation strips along each AP↔laptop link, weighted by per-AP RSSI variance.
|
||||
where a body crosses a link's first Fresnel zone, that strip lights up; intersections localize.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Per-AP polar radar (motion by direction)</h2>
|
||||
<div class="canvas-wrap"><canvas id="polar"></canvas></div>
|
||||
<div class="legend">
|
||||
sector length ∝ recent RSSI variance · color = band
|
||||
<span class="swatch" style="background:var(--b24)"></span>2.4 GHz
|
||||
<span class="swatch" style="background:var(--b5)"></span>5 GHz
|
||||
<span class="swatch" style="background:var(--b6)"></span>6 GHz
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="full-row" style="height: 38vh; display: grid; grid-template-columns: 2fr 3fr; gap: 12px;">
|
||||
<div style="display: flex; flex-direction: column; min-height: 0;">
|
||||
<h2>Total motion · last 60 s</h2>
|
||||
<div class="canvas-wrap"><canvas id="timeline"></canvas></div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; min-height: 0;">
|
||||
<h2>APs (sorted by RSSI)</h2>
|
||||
<div class="ap-table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>id</th><th>ch</th><th>band</th>
|
||||
<th style="text-align:right">rssi</th>
|
||||
<th style="text-align:right">noise</th>
|
||||
<th>variance</th>
|
||||
<th style="text-align:right">age</th>
|
||||
</tr></thead>
|
||||
<tbody id="ap-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RuView RSSI tomography dashboard
|
||||
//
|
||||
// Polls macos-rssi-bridge's /aps endpoint at ~5 Hz, places APs around the
|
||||
// laptop using a deterministic-by-id bearing and an RSSI-derived radius,
|
||||
// then sums Gaussian perturbation strips along each AP↔laptop link weighted
|
||||
// by per-AP variance to produce a real-time multistatic heatmap.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API = "/aps";
|
||||
const POLL_HZ = 5;
|
||||
const TIMELINE_SECS = 60;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const tomo = $("tomo"), tctx = tomo.getContext("2d");
|
||||
const polar = $("polar"), pctx = polar.getContext("2d");
|
||||
const tline = $("timeline"), lctx = tline.getContext("2d");
|
||||
|
||||
let laptop = { fx: 0.5, fy: 0.5 }; // fractional position in canvas
|
||||
let timeline = []; // [{t, total_var}]
|
||||
let dragging = false;
|
||||
|
||||
// Stable per-id angle so the AP doesn't jump around between polls.
|
||||
function hashAngle(id) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
h ^= id.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return ((h >>> 0) / 0x100000000) * Math.PI * 2;
|
||||
}
|
||||
|
||||
function rssiToRadius(rssi, maxR) {
|
||||
// -30 dBm → close, -100 dBm → max radius. Linear in dB.
|
||||
const t = Math.max(0, Math.min(1, (-rssi - 30) / 70));
|
||||
return 20 + t * (maxR - 20);
|
||||
}
|
||||
|
||||
function bandClass(band) {
|
||||
if (band.startsWith("2")) return "band-24";
|
||||
if (band.startsWith("5")) return "band-5";
|
||||
if (band.startsWith("6")) return "band-6";
|
||||
return "";
|
||||
}
|
||||
function bandColor(band) {
|
||||
if (band.startsWith("2")) return "#ff7a59";
|
||||
if (band.startsWith("5")) return "#59a5ff";
|
||||
if (band.startsWith("6")) return "#7be084";
|
||||
return "#888";
|
||||
}
|
||||
|
||||
// Viridis-ish palette: t∈[0,1] → rgb. Pre-sampled, lerped at runtime.
|
||||
const PALETTE = [
|
||||
[13, 8, 35], [50, 12, 88], [82, 18, 122], [114, 32, 130],
|
||||
[148, 53, 130], [180, 82, 122], [210, 113, 105], [233, 152, 84],
|
||||
[247, 197, 70], [252, 245, 92],
|
||||
];
|
||||
function colormap(t) {
|
||||
t = Math.max(0, Math.min(0.999, t));
|
||||
const i = t * (PALETTE.length - 1);
|
||||
const a = PALETTE[Math.floor(i)], b = PALETTE[Math.ceil(i)];
|
||||
const f = i - Math.floor(i);
|
||||
return [
|
||||
a[0] + (b[0] - a[0]) * f,
|
||||
a[1] + (b[1] - a[1]) * f,
|
||||
a[2] + (b[2] - a[2]) * f,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Tomography render ──────────────────────────────────────────────────────
|
||||
function renderTomography(snap) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = tomo.clientWidth | 0, H = tomo.clientHeight | 0;
|
||||
if (tomo.width !== W * dpr) { tomo.width = W * dpr; tomo.height = H * dpr; }
|
||||
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
tctx.clearRect(0, 0, W, H);
|
||||
|
||||
// background grid
|
||||
tctx.strokeStyle = "#1d2233";
|
||||
tctx.lineWidth = 1;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const r = i * 24;
|
||||
tctx.beginPath();
|
||||
tctx.arc(W * laptop.fx, H * laptop.fy, r, 0, Math.PI * 2);
|
||||
tctx.stroke();
|
||||
}
|
||||
|
||||
// place APs
|
||||
const cx = W * laptop.fx, cy = H * laptop.fy;
|
||||
const maxR = Math.min(W, H) * 0.45;
|
||||
const aps = (snap?.aps ?? []).map(ap => ({
|
||||
...ap,
|
||||
angle: hashAngle(ap.id),
|
||||
radius: rssiToRadius(ap.rssi_dbm, maxR),
|
||||
})).map(ap => ({
|
||||
...ap,
|
||||
x: cx + Math.cos(ap.angle) * ap.radius,
|
||||
y: cy + Math.sin(ap.angle) * ap.radius,
|
||||
}));
|
||||
|
||||
// Heatmap: low-res grid, sum gaussian strips along each laptop↔AP link.
|
||||
const gw = Math.max(20, Math.floor(W / 6));
|
||||
const gh = Math.max(15, Math.floor(H / 6));
|
||||
const grid = new Float32Array(gw * gh);
|
||||
const sigma = Math.max(W, H) * 0.022;
|
||||
const inv2s2 = 1 / (2 * sigma * sigma);
|
||||
let maxVar = 0.001;
|
||||
for (const ap of aps) maxVar = Math.max(maxVar, ap.variance);
|
||||
|
||||
for (const ap of aps) {
|
||||
const w = ap.variance / maxVar;
|
||||
if (w < 0.05) continue;
|
||||
const dx = ap.x - cx, dy = ap.y - cy;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1) continue;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const samples = Math.max(8, Math.floor(len / 4));
|
||||
for (let s = 0; s < samples; s++) {
|
||||
const t = s / (samples - 1);
|
||||
// Stronger weight near the midpoint (Fresnel zone is widest there).
|
||||
const fresnel = 1 - Math.abs(t - 0.5) * 2;
|
||||
const px = cx + ux * len * t;
|
||||
const py = cy + uy * len * t;
|
||||
const wt = w * (0.4 + 0.6 * fresnel);
|
||||
|
||||
// Splat a gaussian into the grid around (px, py).
|
||||
const gxC = px / W * gw, gyC = py / H * gh;
|
||||
const gxS = Math.max(0, Math.floor(gxC - 4));
|
||||
const gxE = Math.min(gw, Math.ceil(gxC + 4));
|
||||
const gyS = Math.max(0, Math.floor(gyC - 4));
|
||||
const gyE = Math.min(gh, Math.ceil(gyC + 4));
|
||||
for (let gy = gyS; gy < gyE; gy++) {
|
||||
for (let gx = gxS; gx < gxE; gx++) {
|
||||
const cx2 = (gx + 0.5) * W / gw;
|
||||
const cy2 = (gy + 0.5) * H / gh;
|
||||
const r2 = (cx2 - px) ** 2 + (cy2 - py) ** 2;
|
||||
grid[gy * gw + gx] += wt * Math.exp(-r2 * inv2s2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize & paint
|
||||
let gMax = 0;
|
||||
for (const v of grid) if (v > gMax) gMax = v;
|
||||
if (gMax > 0) {
|
||||
const img = tctx.createImageData(gw, gh);
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const t = grid[i] / gMax;
|
||||
const [r, g, b] = colormap(t);
|
||||
const j = i * 4;
|
||||
img.data[j] = r;
|
||||
img.data[j+1] = g;
|
||||
img.data[j+2] = b;
|
||||
img.data[j+3] = Math.floor(t * 220);
|
||||
}
|
||||
// Upscale via offscreen canvas
|
||||
const off = document.createElement("canvas");
|
||||
off.width = gw; off.height = gh;
|
||||
off.getContext("2d").putImageData(img, 0, 0);
|
||||
tctx.imageSmoothingEnabled = true;
|
||||
tctx.imageSmoothingQuality = "high";
|
||||
tctx.globalCompositeOperation = "lighter";
|
||||
tctx.drawImage(off, 0, 0, W, H);
|
||||
tctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
// AP markers + link lines (faint)
|
||||
for (const ap of aps) {
|
||||
tctx.strokeStyle = "#2a3148";
|
||||
tctx.lineWidth = 1;
|
||||
tctx.beginPath();
|
||||
tctx.moveTo(cx, cy);
|
||||
tctx.lineTo(ap.x, ap.y);
|
||||
tctx.stroke();
|
||||
|
||||
tctx.fillStyle = bandColor(ap.band);
|
||||
const r = 3 + Math.min(7, ap.variance * 0.4);
|
||||
tctx.beginPath();
|
||||
tctx.arc(ap.x, ap.y, r, 0, Math.PI * 2);
|
||||
tctx.fill();
|
||||
}
|
||||
|
||||
// laptop marker
|
||||
tctx.fillStyle = "#45e3d6";
|
||||
tctx.beginPath();
|
||||
tctx.arc(cx, cy, 6, 0, Math.PI * 2);
|
||||
tctx.fill();
|
||||
tctx.strokeStyle = "#0a0c12";
|
||||
tctx.lineWidth = 2;
|
||||
tctx.stroke();
|
||||
tctx.fillStyle = "#8892a8";
|
||||
tctx.font = "11px -apple-system, system-ui, sans-serif";
|
||||
tctx.fillText("laptop", cx + 9, cy - 7);
|
||||
}
|
||||
|
||||
// ─── Polar radar ────────────────────────────────────────────────────────────
|
||||
function renderPolar(snap) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = polar.clientWidth | 0, H = polar.clientHeight | 0;
|
||||
if (polar.width !== W * dpr) { polar.width = W * dpr; polar.height = H * dpr; }
|
||||
pctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
pctx.clearRect(0, 0, W, H);
|
||||
const cx = W / 2, cy = H / 2;
|
||||
const R = Math.min(W, H) * 0.45;
|
||||
|
||||
pctx.strokeStyle = "#1d2233";
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pctx.beginPath();
|
||||
pctx.arc(cx, cy, R * i / 4, 0, Math.PI * 2);
|
||||
pctx.stroke();
|
||||
}
|
||||
pctx.beginPath();
|
||||
pctx.moveTo(cx, cy - R); pctx.lineTo(cx, cy + R);
|
||||
pctx.moveTo(cx - R, cy); pctx.lineTo(cx + R, cy);
|
||||
pctx.stroke();
|
||||
|
||||
const aps = snap?.aps ?? [];
|
||||
if (!aps.length) return;
|
||||
let maxVar = 0;
|
||||
for (const ap of aps) if (ap.variance > maxVar) maxVar = ap.variance;
|
||||
if (maxVar < 0.01) maxVar = 0.01;
|
||||
|
||||
for (const ap of aps) {
|
||||
const a = hashAngle(ap.id);
|
||||
const len = (ap.variance / maxVar) * R;
|
||||
const wedge = 0.12;
|
||||
pctx.fillStyle = bandColor(ap.band) + "cc";
|
||||
pctx.beginPath();
|
||||
pctx.moveTo(cx, cy);
|
||||
pctx.arc(cx, cy, len, a - wedge, a + wedge);
|
||||
pctx.closePath();
|
||||
pctx.fill();
|
||||
}
|
||||
|
||||
pctx.fillStyle = "#45e3d6";
|
||||
pctx.beginPath();
|
||||
pctx.arc(cx, cy, 4, 0, Math.PI * 2);
|
||||
pctx.fill();
|
||||
}
|
||||
|
||||
// ─── Motion timeline ────────────────────────────────────────────────────────
|
||||
function renderTimeline() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = tline.clientWidth | 0, H = tline.clientHeight | 0;
|
||||
if (tline.width !== W * dpr) { tline.width = W * dpr; tline.height = H * dpr; }
|
||||
lctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
lctx.clearRect(0, 0, W, H);
|
||||
if (timeline.length < 2) return;
|
||||
|
||||
const now = timeline[timeline.length - 1].t;
|
||||
const t0 = now - TIMELINE_SECS;
|
||||
const visible = timeline.filter(p => p.t >= t0);
|
||||
const max = Math.max(0.01, ...visible.map(p => p.total_var));
|
||||
|
||||
// baseline grid
|
||||
lctx.strokeStyle = "#1d2233";
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = H * (1 - i / 4);
|
||||
lctx.beginPath();
|
||||
lctx.moveTo(0, y); lctx.lineTo(W, y);
|
||||
lctx.stroke();
|
||||
}
|
||||
|
||||
// area
|
||||
lctx.beginPath();
|
||||
lctx.moveTo(0, H);
|
||||
for (const p of visible) {
|
||||
const x = ((p.t - t0) / TIMELINE_SECS) * W;
|
||||
const y = H - (p.total_var / max) * H * 0.92 - 4;
|
||||
lctx.lineTo(x, y);
|
||||
}
|
||||
lctx.lineTo(W, H);
|
||||
lctx.closePath();
|
||||
const grad = lctx.createLinearGradient(0, 0, 0, H);
|
||||
grad.addColorStop(0, "#45e3d680");
|
||||
grad.addColorStop(1, "#45e3d600");
|
||||
lctx.fillStyle = grad;
|
||||
lctx.fill();
|
||||
|
||||
lctx.strokeStyle = "#45e3d6";
|
||||
lctx.lineWidth = 1.5;
|
||||
lctx.beginPath();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const p = visible[i];
|
||||
const x = ((p.t - t0) / TIMELINE_SECS) * W;
|
||||
const y = H - (p.total_var / max) * H * 0.92 - 4;
|
||||
if (i === 0) lctx.moveTo(x, y); else lctx.lineTo(x, y);
|
||||
}
|
||||
lctx.stroke();
|
||||
|
||||
lctx.fillStyle = "#8892a8";
|
||||
lctx.font = "10px -apple-system, system-ui, sans-serif";
|
||||
lctx.fillText(`max ≈ ${max.toFixed(1)}`, 4, 12);
|
||||
}
|
||||
|
||||
// ─── AP table ───────────────────────────────────────────────────────────────
|
||||
function renderTable(snap) {
|
||||
const tbody = $("ap-rows");
|
||||
const aps = snap?.aps ?? [];
|
||||
const maxVar = Math.max(0.001, ...aps.map(a => a.variance));
|
||||
tbody.innerHTML = aps.map(ap => `
|
||||
<tr>
|
||||
<td title="${ap.id}">${ap.id.length > 22 ? ap.id.slice(0, 22) + "…" : ap.id}</td>
|
||||
<td>${ap.channel}</td>
|
||||
<td class="${bandClass(ap.band)}">${ap.band}</td>
|
||||
<td style="text-align:right">${ap.rssi_dbm.toFixed(0)}</td>
|
||||
<td style="text-align:right">${ap.noise_dbm ? ap.noise_dbm.toFixed(0) : "—"}</td>
|
||||
<td><span class="var-bar" style="width:${(ap.variance / maxVar * 80).toFixed(1)}px"></span>${ap.variance.toFixed(1)}</td>
|
||||
<td style="text-align:right;color:${ap.age_ms < 3000 ? "var(--accent)" : "var(--warn)"}">${(ap.age_ms / 1000).toFixed(1)}s</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// ─── Drag laptop in tomography canvas ───────────────────────────────────────
|
||||
tomo.addEventListener("mousedown", e => {
|
||||
const r = tomo.getBoundingClientRect();
|
||||
laptop.fx = (e.clientX - r.left) / r.width;
|
||||
laptop.fy = (e.clientY - r.top) / r.height;
|
||||
dragging = true;
|
||||
});
|
||||
window.addEventListener("mouseup", () => dragging = false);
|
||||
window.addEventListener("mousemove", e => {
|
||||
if (!dragging) return;
|
||||
const r = tomo.getBoundingClientRect();
|
||||
laptop.fx = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
|
||||
laptop.fy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
|
||||
});
|
||||
|
||||
// ─── Poll loop ──────────────────────────────────────────────────────────────
|
||||
let lastSeq = -1;
|
||||
let lastSeqAt = 0;
|
||||
async function poll() {
|
||||
try {
|
||||
const r = await fetch(API, { cache: "no-store" });
|
||||
const snap = await r.json();
|
||||
const now = performance.now();
|
||||
if (snap.seq !== lastSeq) { lastSeq = snap.seq; lastSeqAt = now; }
|
||||
const stale = now - lastSeqAt;
|
||||
const status = $("status");
|
||||
if (stale > 8000) { status.textContent = "offline"; status.className = "pill dead"; }
|
||||
else if (stale > 3000) { status.textContent = "stale"; status.className = "pill stale"; }
|
||||
else { status.textContent = "live"; status.className = "pill live"; }
|
||||
|
||||
$("ap-count").textContent = snap.aps.length;
|
||||
$("strongest").textContent = snap.strongest_rssi_dbm?.toFixed(0) ?? "-";
|
||||
$("seq").textContent = snap.seq ?? 0;
|
||||
const totalVar = (snap.aps ?? []).reduce((s, a) => s + a.variance, 0);
|
||||
$("total-var").textContent = totalVar.toFixed(1);
|
||||
|
||||
timeline.push({ t: snap.ts, total_var: totalVar });
|
||||
if (timeline.length > 600) timeline.shift();
|
||||
|
||||
renderTomography(snap);
|
||||
renderPolar(snap);
|
||||
renderTimeline();
|
||||
renderTable(snap);
|
||||
} catch (e) {
|
||||
const status = $("status");
|
||||
status.textContent = "no bridge"; status.className = "pill dead";
|
||||
}
|
||||
}
|
||||
poll();
|
||||
setInterval(poll, 1000 / POLL_HZ);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Debug listener: prints frame headers for each ESP32-format CSI frame
|
||||
the bridge emits. Reproduces only the fields needed for sanity checks.
|
||||
|
||||
Usage: python3 listen.py [port] (default port: 5005)
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5005
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", port))
|
||||
print(f"[listen] udp://127.0.0.1:{port} — Ctrl-C to stop", flush=True)
|
||||
n, t0 = 0, time.time()
|
||||
try:
|
||||
while True:
|
||||
data, _ = sock.recvfrom(4096)
|
||||
if len(data) < 20:
|
||||
continue
|
||||
magic, node_id, n_ant, n_sub = struct.unpack_from("<IBBB", data, 0)
|
||||
seq = struct.unpack_from("<I", data, 10)[0]
|
||||
rssi, noise = struct.unpack_from("bb", data, 14)
|
||||
n += 1
|
||||
print(
|
||||
f"#{n:>4} t+{time.time() - t0:5.1f}s "
|
||||
f"magic=0x{magic:08x} seq={seq:>4} node={node_id} "
|
||||
f"ant={n_ant} sub={n_sub} rssi={rssi}dBm noise={noise}dBm",
|
||||
flush=True,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print(f"[listen] {n} frames in {time.time() - t0:.1f}s")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
// mac_wifi — multi-BSSID WiFi scanner using CoreWLAN.
|
||||
//
|
||||
// Replaces the v1 single-AP helper at archive/v1/src/sensing/mac_wifi.swift.
|
||||
// Emits one JSON object per visible BSSID, in the format expected by
|
||||
// v2/crates/wifi-densepose-wifiscan/src/adapter/macos_scanner.rs:
|
||||
//
|
||||
// {"ssid":"<name>","bssid":"<aa:bb:cc:dd:ee:ff>","rssi":<int>,
|
||||
// "noise":<int>,"channel":<int>,"band":"<2.4GHz|5GHz|6GHz>"}
|
||||
//
|
||||
// Modes:
|
||||
// --scan-once : run a single active scan, print each BSSID as JSON, exit
|
||||
// --watch : repeat --scan-once forever at --interval seconds (default 1.0)
|
||||
// --interval N : seconds between scans in --watch mode
|
||||
//
|
||||
// Notes:
|
||||
// - macOS Sonoma 14.4+ redacts BSSIDs to "00:00:00:00:00:00" unless the calling
|
||||
// process holds the com.apple.wifi.scan entitlement OR Location Services is
|
||||
// authorized for it. The Rust adapter handles the redacted case via a
|
||||
// deterministic synthetic MAC; we just pass redacted MACs through.
|
||||
// - Active scans take ~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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1,489 @@
|
|||
//! macos-rssi-bridge
|
||||
//!
|
||||
//! Bridges a Mac's built-in WiFi card to the RuView sensing-server's UDP CSI
|
||||
//! input. Spawns the `mac_wifi` Swift helper in `--watch` mode, reads its
|
||||
//! JSON-lines output, maintains a per-BSSID RSSI ring buffer, and packs each
|
||||
//! scan into an ESP32-format CSI frame (magic `0xC511_0001`) emitted over UDP.
|
||||
//!
|
||||
//! No CSI hardware is involved — RSSI from each visible AP is treated as a
|
||||
//! pseudo-subcarrier amplitude. The sensing-server pipeline runs unmodified
|
||||
//! against this synthetic CSI, giving you laptop-grade motion sensing while
|
||||
//! you wait for ESP32 boards to arrive. See the v2/crates/wifi-densepose-wifiscan
|
||||
//! crate (ADR-022) for the formal multi-AP sensing model.
|
||||
//!
|
||||
//! Wire format reproduced from
|
||||
//! `v2/crates/wifi-densepose-sensing-server/src/csi.rs::parse_esp32_frame`:
|
||||
//!
|
||||
//! bytes 0..4 u32 magic = 0xC511_0001 (LE)
|
||||
//! byte 4 u8 node_id
|
||||
//! byte 5 u8 n_antennas
|
||||
//! byte 6 u8 n_subcarriers
|
||||
//! byte 7 _ (skipped)
|
||||
//! bytes 8..10 u16 freq_mhz (LE)
|
||||
//! bytes 10..14 u32 sequence (LE)
|
||||
//! byte 14 i8 rssi
|
||||
//! byte 15 i8 noise_floor
|
||||
//! bytes 16..20 _ (header padding; iq_start = 20)
|
||||
//! bytes 20.. i8 I/Q pairs, n_antennas * n_subcarriers entries
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::UdpSocket;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const FRAME_MAGIC: u32 = 0xC511_0001;
|
||||
const N_ANTENNAS: u8 = 1;
|
||||
const N_SUBCARRIERS: u8 = 56;
|
||||
const FRAME_HEADER_LEN: usize = 20;
|
||||
const HISTORY_LEN: usize = 16;
|
||||
/// Cap the AP set to keep things bounded and roughly aligned with the
|
||||
/// `WindowsWifiPipeline` default of `max_bssids: 32`. Without real BSSIDs
|
||||
/// the ordinal in the synthetic SSID can shuffle scan-to-scan, so the live
|
||||
/// set drifts; capping prevents that drift from polluting downstream stats.
|
||||
const MAX_APS: usize = 32;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "macos-rssi-bridge", about, long_about = None)]
|
||||
struct Args {
|
||||
/// Path to the compiled mac_wifi Swift helper.
|
||||
#[arg(long, default_value = "./mac_wifi")]
|
||||
helper: PathBuf,
|
||||
/// UDP target host for the sensing-server.
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
target_host: String,
|
||||
/// UDP target port (sensing-server default is 5005).
|
||||
#[arg(long, default_value_t = 5005)]
|
||||
target_port: u16,
|
||||
/// Seconds between active scans (the helper enforces a 0.5s floor).
|
||||
#[arg(long, default_value_t = 1.5)]
|
||||
interval: f64,
|
||||
/// node_id stamped into emitted frames.
|
||||
#[arg(long, default_value_t = 1)]
|
||||
node_id: u8,
|
||||
/// Print each emitted frame to stdout.
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
/// HTTP port for the per-AP state JSON + tomography dashboard.
|
||||
/// Set to 0 to disable.
|
||||
#[arg(long, default_value_t = 9090)]
|
||||
http_port: u16,
|
||||
/// Path to the static dashboard HTML to serve at GET /dashboard.
|
||||
/// Defaults to dashboard.html next to the binary.
|
||||
#[arg(long, default_value = "dashboard.html")]
|
||||
dashboard: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScanLine {
|
||||
ssid: String,
|
||||
#[allow(dead_code)]
|
||||
bssid: String,
|
||||
rssi: i32,
|
||||
noise: i32,
|
||||
channel: u16,
|
||||
band: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct ApState {
|
||||
rssi_history: Vec<f32>,
|
||||
last_rssi: f32,
|
||||
last_noise: f32,
|
||||
channel: u16,
|
||||
band: String,
|
||||
last_seen: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ApState {
|
||||
fn record(&mut self, rssi: f32, noise: f32, channel: u16, band: &str) {
|
||||
self.rssi_history.push(rssi);
|
||||
if self.rssi_history.len() > HISTORY_LEN {
|
||||
self.rssi_history.remove(0);
|
||||
}
|
||||
self.last_rssi = rssi;
|
||||
self.last_noise = noise;
|
||||
self.channel = channel;
|
||||
if self.band.is_empty() {
|
||||
self.band = band.to_owned();
|
||||
}
|
||||
self.last_seen = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Welford-ish variance over the rolling RSSI window.
|
||||
fn variance(&self) -> f32 {
|
||||
let n = self.rssi_history.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = self.rssi_history.iter().sum::<f32>() / n as f32;
|
||||
self.rssi_history
|
||||
.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f32>()
|
||||
/ n as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON shape returned from `GET /aps`. Consumed by the tomography dashboard.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct ApSnapshot {
|
||||
/// Stable id (synthetic SSID or real one).
|
||||
id: String,
|
||||
channel: u16,
|
||||
band: String,
|
||||
rssi_dbm: f32,
|
||||
noise_dbm: f32,
|
||||
/// Rolling-window variance — high values mean a moving body is
|
||||
/// modulating the AP↔laptop link.
|
||||
variance: f32,
|
||||
/// Most recent N RSSI samples (N = HISTORY_LEN). Newest last.
|
||||
history: Vec<f32>,
|
||||
/// Milliseconds since this AP was last seen in a scan.
|
||||
age_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Default)]
|
||||
struct StateSnapshot {
|
||||
/// Wall-clock timestamp in seconds since UNIX epoch.
|
||||
ts: f64,
|
||||
/// Sequence number of the most recent emitted CSI frame.
|
||||
seq: u32,
|
||||
/// Strongest AP's last RSSI in dBm (a coarse proxy for proximity).
|
||||
strongest_rssi_dbm: f32,
|
||||
/// Sorted-by-RSSI snapshot of every tracked AP.
|
||||
aps: Vec<ApSnapshot>,
|
||||
}
|
||||
|
||||
/// Map a -100..-30 dBm RSSI value into a roughly full-range i8 magnitude.
|
||||
/// We map dB linearly (1.27 LSB per dB) instead of using the linear
|
||||
/// amplitude `10^((rssi+100)/20)` — that exponential saturates at the i8
|
||||
/// ceiling for any AP stronger than ~-65 dBm, which is most modern indoor
|
||||
/// environments. Linear-in-dB keeps the strongest APs informative.
|
||||
fn rssi_to_i_byte(rssi: f32) -> i8 {
|
||||
((rssi + 100.0) * 1.27).clamp(0.0, 127.0) as i8
|
||||
}
|
||||
|
||||
/// Map per-BSSID RSSI variance into the Q channel — high variance = a
|
||||
/// person modulating that AP's reflections. ~10 dB^2 lands near full-range.
|
||||
fn variance_to_q_byte(var: f32) -> i8 {
|
||||
(var * 12.0).clamp(0.0, 127.0) as i8
|
||||
}
|
||||
|
||||
fn build_frame(seq: u32, node_id: u8, aps: &[(String, &ApState)]) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; FRAME_HEADER_LEN + 2 * N_ANTENNAS as usize * N_SUBCARRIERS as usize];
|
||||
|
||||
buf[0..4].copy_from_slice(&FRAME_MAGIC.to_le_bytes());
|
||||
buf[4] = node_id;
|
||||
buf[5] = N_ANTENNAS;
|
||||
buf[6] = N_SUBCARRIERS;
|
||||
buf[8..10].copy_from_slice(&2437u16.to_le_bytes()); // 2.4 GHz channel 6 reference
|
||||
buf[10..14].copy_from_slice(&seq.to_le_bytes());
|
||||
|
||||
let strongest = aps.iter().map(|(_, s)| s.last_rssi).fold(-127.0f32, f32::max);
|
||||
let avg_noise = if aps.is_empty() {
|
||||
-90.0
|
||||
} else {
|
||||
aps.iter().map(|(_, s)| s.last_noise).sum::<f32>() / aps.len() as f32
|
||||
};
|
||||
buf[14] = (strongest as i32).clamp(-127, 0) as i8 as u8;
|
||||
buf[15] = (avg_noise as i32).clamp(-127, 0) as i8 as u8;
|
||||
|
||||
// Pack the AP set into 56 pseudo-subcarriers as a *block* layout (not
|
||||
// interleaved). Each AP gets a contiguous slab of subcarriers, so
|
||||
// adjacent subcarriers are perfectly correlated — this produces a
|
||||
// single coherent "observed body" pattern that the sensing-server's
|
||||
// mincut person counter (estimate_persons_from_correlation) reads as
|
||||
// one person instead of fragmenting our N visible APs into N synthetic
|
||||
// people. Cap to 8 APs so each slab is wide enough (≥7 subcarriers)
|
||||
// for the correlation window to register as one group.
|
||||
let n_aps = aps.len().max(1).min(8);
|
||||
let slab = N_SUBCARRIERS as usize / n_aps;
|
||||
let leftover = N_SUBCARRIERS as usize - slab * n_aps;
|
||||
for k in 0..N_SUBCARRIERS as usize {
|
||||
// Map subcarrier index to AP index via slab boundaries; the last
|
||||
// few subcarriers (leftover) cycle the strongest AP.
|
||||
let ap_idx = if k < slab * n_aps {
|
||||
(k / slab).min(n_aps - 1)
|
||||
} else {
|
||||
// tail subcarriers go to the strongest AP (index 0 after sort)
|
||||
0
|
||||
};
|
||||
let _ = leftover; // explicit: we handle it via the else-branch above
|
||||
let (_, st) = &aps[ap_idx];
|
||||
let i_off = FRAME_HEADER_LEN + k * 2;
|
||||
buf[i_off] = rssi_to_i_byte(st.last_rssi) as u8;
|
||||
buf[i_off + 1] = variance_to_q_byte(st.variance()) as u8;
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
fn spawn_helper(helper: &PathBuf, interval: f64) -> std::io::Result<Child> {
|
||||
Command::new(helper)
|
||||
.arg("--watch")
|
||||
.arg("--interval")
|
||||
.arg(interval.to_string())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
}
|
||||
|
||||
/// Build a `StateSnapshot` from the current AP map (already RSSI-sorted).
|
||||
fn build_snapshot(seq: u32, aps: &[(String, &ApState)]) -> StateSnapshot {
|
||||
let now = Instant::now();
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(0.0);
|
||||
let strongest_rssi_dbm = aps.first().map(|(_, s)| s.last_rssi).unwrap_or(-100.0);
|
||||
let snaps: Vec<ApSnapshot> = aps
|
||||
.iter()
|
||||
.map(|(id, s)| ApSnapshot {
|
||||
id: id.clone(),
|
||||
channel: s.channel,
|
||||
band: s.band.clone(),
|
||||
rssi_dbm: s.last_rssi,
|
||||
noise_dbm: s.last_noise,
|
||||
variance: s.variance(),
|
||||
history: s.rssi_history.clone(),
|
||||
age_ms: s
|
||||
.last_seen
|
||||
.map(|t| now.duration_since(t).as_millis() as u64)
|
||||
.unwrap_or(u64::MAX),
|
||||
})
|
||||
.collect();
|
||||
StateSnapshot {
|
||||
ts,
|
||||
seq,
|
||||
strongest_rssi_dbm,
|
||||
aps: snaps,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight HTTP server: GET /aps → JSON state, GET /dashboard → static
|
||||
/// HTML, GET / → small index. Runs in its own thread, never blocks the
|
||||
/// scanner. Permissive CORS so a dashboard.html opened via file:// can still
|
||||
/// fetch /aps if the user prefers that path over the served version.
|
||||
fn spawn_http_server(
|
||||
port: u16,
|
||||
dashboard_path: PathBuf,
|
||||
snapshot: Arc<Mutex<StateSnapshot>>,
|
||||
) -> std::io::Result<()> {
|
||||
let server = tiny_http::Server::http(("0.0.0.0", port))
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()))?;
|
||||
eprintln!("[bridge] http://127.0.0.1:{port}/dashboard ← tomography UI");
|
||||
eprintln!("[bridge] http://127.0.0.1:{port}/aps ← per-AP state JSON");
|
||||
|
||||
thread::spawn(move || {
|
||||
for req in server.incoming_requests() {
|
||||
let url = req.url().to_string();
|
||||
let path = url.split('?').next().unwrap_or("/");
|
||||
let (status, content_type, body): (u16, &str, Vec<u8>) = match path {
|
||||
"/aps" => {
|
||||
let snap = snapshot.lock().expect("snapshot mutex poisoned").clone();
|
||||
let body = serde_json::to_vec(&snap).unwrap_or_else(|_| b"{}".to_vec());
|
||||
(200, "application/json", body)
|
||||
}
|
||||
"/dashboard" | "/dashboard/" => match std::fs::read(&dashboard_path) {
|
||||
Ok(html) => (200, "text/html; charset=utf-8", html),
|
||||
Err(e) => (
|
||||
404,
|
||||
"text/plain",
|
||||
format!("dashboard.html not found at {dashboard_path:?}: {e}").into_bytes(),
|
||||
),
|
||||
},
|
||||
"/" => (
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
b"<!doctype html><meta charset=utf-8><title>macos-rssi-bridge</title>\
|
||||
<body style='font:14px system-ui;margin:2em'>\
|
||||
<h1>macos-rssi-bridge</h1>\
|
||||
<p><a href='/dashboard'>/dashboard</a> — live tomography UI</p>\
|
||||
<p><a href='/aps'>/aps</a> — per-AP state JSON</p>\
|
||||
</body>"
|
||||
.to_vec(),
|
||||
),
|
||||
_ => (404, "text/plain", b"not found".to_vec()),
|
||||
};
|
||||
let response = tiny_http::Response::from_data(body)
|
||||
.with_status_code(status)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
|
||||
.expect("static header"),
|
||||
)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..])
|
||||
.expect("static header"),
|
||||
);
|
||||
let _ = req.respond(response);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
let target = format!("{}:{}", args.target_host, args.target_port);
|
||||
socket.connect(&target)?;
|
||||
eprintln!("[bridge] sending CSI frames to udp://{}", target);
|
||||
|
||||
// Shared snapshot consumed by the HTTP /aps endpoint. Starts empty;
|
||||
// populated on each emit tick. Mutex over a small struct — contention
|
||||
// is negligible at single-digit Hz.
|
||||
let snapshot = Arc::new(Mutex::new(StateSnapshot::default()));
|
||||
if args.http_port != 0 {
|
||||
// Resolve dashboard path relative to the binary if the user passed
|
||||
// the default — keeps `make run` working regardless of cwd.
|
||||
let mut dashboard_path = args.dashboard.clone();
|
||||
if dashboard_path.is_relative() && !dashboard_path.exists() {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let candidate = dir.join(&args.dashboard);
|
||||
if candidate.exists() {
|
||||
dashboard_path = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spawn_http_server(args.http_port, dashboard_path, Arc::clone(&snapshot))?;
|
||||
}
|
||||
|
||||
let mut child = spawn_helper(&args.helper, args.interval)?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.expect("helper stdout was piped at spawn time");
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
{
|
||||
let running = running.clone();
|
||||
let _ = ctrlc::set_handler(move || {
|
||||
running.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
let mut aps: HashMap<String, ApState> = HashMap::new();
|
||||
let mut seq: u32 = 0;
|
||||
let mut last_emit = Instant::now() - Duration::from_secs(10);
|
||||
let emit_interval = Duration::from_millis(100);
|
||||
|
||||
for line in reader.lines() {
|
||||
if !running.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let Ok(line) = line else { continue };
|
||||
let line = line.trim();
|
||||
if line.is_empty() || !line.starts_with('{') {
|
||||
continue;
|
||||
}
|
||||
let Ok(scan) = serde_json::from_str::<ScanLine>(line) else {
|
||||
continue;
|
||||
};
|
||||
let key = if scan.ssid.is_empty() {
|
||||
format!("ch{}_n{}", scan.channel, scan.rssi)
|
||||
} else {
|
||||
scan.ssid.clone()
|
||||
};
|
||||
aps.entry(key)
|
||||
.or_default()
|
||||
.record(scan.rssi as f32, scan.noise as f32, scan.channel, &scan.band);
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_emit) < emit_interval {
|
||||
continue;
|
||||
}
|
||||
last_emit = now;
|
||||
|
||||
// Drop APs we haven't seen in 30s so the picture stays current.
|
||||
aps.retain(|_, s| s.last_seen.map_or(false, |t| now.duration_since(t) < Duration::from_secs(30)));
|
||||
if aps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by recent RSSI so the strongest APs lead the subcarrier
|
||||
// layout — they're the most informative — then cap to MAX_APS.
|
||||
let mut sorted: Vec<(String, &ApState)> =
|
||||
aps.iter().map(|(k, v)| (k.clone(), v)).collect();
|
||||
sorted.sort_by(|a, b| b.1.last_rssi.partial_cmp(&a.1.last_rssi).unwrap_or(std::cmp::Ordering::Equal));
|
||||
sorted.truncate(MAX_APS);
|
||||
|
||||
seq = seq.wrapping_add(1);
|
||||
let frame = build_frame(seq, args.node_id, &sorted);
|
||||
// UDP sends can return ECONNREFUSED on macOS when the receiver
|
||||
// emits an ICMP unreachable. Log and continue — the receiver may
|
||||
// come back and we don't want a blip to kill the bridge.
|
||||
if let Err(e) = socket.send(&frame) {
|
||||
eprintln!("[bridge] udp send failed (continuing): {e}");
|
||||
}
|
||||
|
||||
// Refresh the shared snapshot the HTTP /aps endpoint reads from.
|
||||
if let Ok(mut s) = snapshot.lock() {
|
||||
*s = build_snapshot(seq, &sorted);
|
||||
}
|
||||
if args.verbose {
|
||||
let strongest = sorted[0].1.last_rssi;
|
||||
let max_var = sorted.iter().map(|(_, s)| s.variance()).fold(0f32, f32::max);
|
||||
eprintln!(
|
||||
"[bridge] seq={} aps={:>2} strongest={:>4.0} dBm max_var={:>5.1}",
|
||||
seq, sorted.len(), strongest, max_var
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rssi_mapping_baseline() {
|
||||
assert_eq!(rssi_to_i_byte(-100.0), 0);
|
||||
assert_eq!(rssi_to_i_byte(-30.0), 88);
|
||||
assert_eq!(rssi_to_i_byte(-50.0), 63);
|
||||
// Saturates cleanly past the calibrated range.
|
||||
assert_eq!(rssi_to_i_byte(0.0), 127);
|
||||
assert_eq!(rssi_to_i_byte(-200.0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variance_starts_at_zero() {
|
||||
let st = ApState::default();
|
||||
assert_eq!(st.variance(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variance_grows_with_jitter() {
|
||||
let mut st = ApState::default();
|
||||
for r in [-60.0, -64.0, -58.0, -65.0, -61.0, -67.0] {
|
||||
st.record(r, -90.0, 6);
|
||||
}
|
||||
assert!(st.variance() > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_starts_with_magic() {
|
||||
let mut st = ApState::default();
|
||||
st.record(-50.0, -90.0, 6);
|
||||
let frame = build_frame(42, 1, &[("test".into(), &st)]);
|
||||
assert_eq!(&frame[0..4], &FRAME_MAGIC.to_le_bytes());
|
||||
assert_eq!(frame[5], N_ANTENNAS);
|
||||
assert_eq!(frame[6], N_SUBCARRIERS);
|
||||
assert_eq!(frame.len(), 20 + 2 * 1 * 56);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue