Bumblebee — Topological Navigator v0.1: room-scale positioning via passive RF fingerprint similarity
This is a lab-notebook entry for the first hardware-edge primitive in the Stax fleet. The claim is small and the substrate is real: an ESP32 plus two ~$3 I2C sensor breakouts, running Zig 0.16 firmware, can publish a room-scale topological fingerprint every ten seconds — without connecting to Wi-Fi, without transmitting on BLE, without uploading anything to any cloud service. The fingerprint is a passive snapshot of which APs and beacons are visible at what relative signal strength, plus a magnetic + gravity vector. Two fingerprints from the same room have small cosine distance over the RSSI feature vector. Two from different rooms don't.
The end-to-end path — ESP32 firmware (Zig) → USB-CDC serial frames → Python bridge daemon → NATS → Phoenix LiveView SVG map — works today with the host-stub firmware build piped into the bridge's self-test (5 ok, 0 errors). The piece that does not work yet is the ESP32 running real firmware; the C bridge that wires the bb_scan_wifi / bb_scan_ble / bb_read_imu extern functions to ESP-IDF driver calls is a documented skeleton, not a flashed binary. This entry is evidence-graded compiled / sketch and the status footer in §10 names every gap that v0.2 has to close.
1. The claim
A passive-RF + IMU substrate captured on a ~$25 board can produce stable enough room fingerprints that a deterministic 2D projection clusters them into recognizable groups, without GPS, without a positioning-cloud account, without the ESP32 ever transmitting anything on its radios. The first ship is the wire format, the bridge, the LiveView, and the algorithmic substrate — not yet the hardware validation.
Evidence breakdown:
- The fingerprint serialization format (52-byte header + variable
observation payload, capped at the frame-protocol 1024 B bound) is [unit-tested][readme-tests] — 39/39 tests pass under Zig 0.16.0 including round-trip, max-size, truncation rejection, oversized-count rejection, sort-by-RSSI, and the host stub's plausibility.
- The Python bridge's wire-format parser is differentially validated
against the Zig firmware's host-stub emission. Pipe 5 frames of firmware output into the bridge's self-test subcommand — 5 ok, 0 errors. This is the cross-language proof that the COBS framing, CRC32 trailer, version byte, and fingerprint payload layout all match.
- The LiveView surface compiles cleanly into AETHER (39/39 tests pass
except for one pre-existing Phoenix scaffold test unrelated to this ship), routes at /bumblebee, and renders an SVG with deterministic 2D positions even with synthetic input.
What's sketch: the ESP-IDF C bridge. What's not yet hardware-verified: anything that requires actually flashing the firmware to an ESP32 and sitting in a room to verify the fingerprints cluster.
[readme-tests]: # [reproducibility]: #7-reproducibility
2. Why "topological"
The cheap way to do indoor positioning is to estimate a coordinate — trilaterate from beacons, measure ToF, fuse with IMU dead-reckoning, publish (x, y, floor). That path requires either Apple/Google's positioning cloud or a dedicated infrastructure deployment (UWB beacons, BLE 5.1 AoA arrays, ultrasonic anchors).
The cheap sovereign way is to do topological positioning: instead of estimating where you are, estimate which room you're in, by fingerprinting the radio environment that room produces. Every room has a characteristic set of visible APs at characteristic RSSI levels. Every room has a characteristic magnetic vector — orientation, intensity, local distortions from steel framing and appliances. The fingerprint is the room's signature.
This is not new science — Wi-Fi fingerprinting goes back to the early 2000s, and the Apple-Google Indoor Maps + Hyperion Hyperledger systems are commercial implementations of variants. What's new in this substrate is the sovereignty story: passive scan only (the ESP32 never transmits), no cloud uplink (only the local NATS bus and a LiveView on the same tailnet), AGPL across all three substrates, ~$25 per node, full Zig source.
The substrate the Bumblebee primitive owns is the algorithm: cosine similarity over RSSI feature vectors, weighted by signal stability, projected onto a deterministic 2D plane for visualization. The hardware is interchangeable; any ESP32 + accelerometer + magnetometer combo satisfies the contract.
3. The 3-substrate architecture
The work splits cleanly into three substrates that compose:
┌─────────────────────┐ UART ┌──────────────────┐ NATS ┌─────────────────┐
│ ESP32 firmware │──────────────│ Bridge daemon │────────────│ LiveView map │
│ (Zig 0.16) │ USB-CDC │ (Python 3.10) │ lab. │ (Elixir/Phoenix)│
│ passive Wi-Fi scan │ 115200 8N1 │ COBS+CRC decode │ bumblebee.│ AETHER /bumblebee│
│ passive BLE observe│ COBS+CRC32 │ fingerprint │ > │ SVG topology │
│ LIS2DH12 + LIS3MDL │ framed │ parse │ │ cosine + 2D proj│
└─────────────────────┘ └──────────────────┘ └─────────────────┘
Substrate 1 — firmware at <internal-lab>/firmware/bumblebee-v0.1/. Zig 0.16 sources, ~944 LOC including comments and tests, two build profiles. The host profile (default) builds an executable for Linux that uses PRNG-driven sensor stubs and emits real wire frames to stdout — used for testing the rest of the pipe without hardware. The ESP32 profile (-Dtarget_esp32=true) builds a static library (libbumblebee_firmware.a) that an ESP-IDF C project links against; the C side provides driver calls (bb_scan_wifi, bb_scan_ble, bb_read_imu, bb_uart_write, bb_delay_ms) and calls bumblebee_run_forever from FreeRTOS app_main.
Wire format: every 10 s, the firmware builds a Fingerprint struct (52-byte header + up to 20×8B Wi-Fi observations + up to 16×20B BLE observations), serializes it via the file-internal little-endian packing, and hands the bytes to frame_protocol.encode — that adds the kind byte, sequence number, monotonic timestamp, payload-length field, IEEE 802.3 CRC32, and COBS framing with a 0x00 delimiter. Worst-case wire frame: ~600 B per fingerprint. The Zig firmware never allocates: all buffers are stack-resident, the I/O is a single write(fd, buf, len) syscall (on host) or uart_write_bytes (on ESP32).
Substrate 2 — bridge at <internal-lab>/bumblebee-cli/. Python 3.10 daemon, ~340 LOC. No third-party dependencies for the wire-format parser (it implements COBS decode and IEEE CRC32 directly); pyserial is optional and falls back to raw open() if absent. Subcommands:
bumblebee-cli daemon— long-lived bridge loop. Reads frames,
decodes, republishes to lab.bumblebee.node.<id>.fingerprint, lab.bumblebee.all.fingerprints (rollup), and lab.bumblebee.node.<id>.health (every 30 s). Audit-stream entry to lab.event.audit per fingerprint.
bumblebee-cli identify— print the next fingerprint as pretty JSON.bumblebee-cli scan -n 5— print summaries of the next 5.bumblebee-cli pair <node_id> <room>— register a node ↔ room
binding to ~/.config/bumblebee/allowlist.json.
bumblebee-cli self-test <path>— parse a recorded frame bundle (or
- for stdin) and report ok/error counts. This is the differential test that validates Python↔Zig wire-format agreement.
The bridge does not own NATS publish-mechanics directly — it shells out to the nats CLI binary, matching the pattern in sonos_bridge.py. This is intentional: no Python NATS client dependency, the CLI is already on the lab box, and the failure modes are a subprocess timeout rather than an opaque library-internal stall.
Substrate 3 — LiveView at <internal-lab>/aether/lib/aether_web/live/bumblebee_live.ex plus the supporting Aether.Bumblebee.Registry GenServer (subscribes to the NATS subjects via the existing Aether.Nats helper, broadcasts on the "bumblebee" PubSub topic) and Aether.Bumblebee.Similarity (pure-functional feature extraction, cosine similarity, 2D projection, viewport scaling). Total ~580 LOC of Elixir.
Mounted at /bumblebee. Renders a pure server-side SVG with each allowlisted node plotted by its deterministic 2D projection, color-coded by stable hash, with a fading trail of the last 8 fingerprint positions. A right-hand sidebar shows per-node metadata: node_id, room label from allowlist, Wi-Fi/BLE observation counts, battery mV, magnetic and gravity vectors, online/stale/unknown status pill. No D3, no client-side JS beyond the LiveView framework — every visual element is server-rendered EEx.
The compile-time allowlist (driven by config :aether, :bumblebee_nodes) is the defense-in-depth: even if a rogue ESP32 started publishing on lab.bumblebee.>, the Registry GenServer ignores it before it ever reaches the LiveView state. Mirrors Aether.Zones's compile-time allowlist pattern.
4. The privacy story
Three properties make this a sovereign substrate, not a positioning service:
- The ESP32 never transmits. Both radios are configured for
passive scan / observer mode only. The Wi-Fi scan_type is WIFI_SCAN_TYPE_PASSIVE; esp_wifi_connect is never called. The BLE side runs as observer, never as broadcaster or central. The substrate is a receiver, never a transmitter on the air.
- The only outbound channel is the USB cable. The ESP32 emits
frames over UART. The bridge daemon reads from /dev/ttyUSB0. The NATS bus is on the same machine. The LiveView is on the same tailnet. Nothing about a Bumblebee fingerprint ever crosses the public internet.
- AGPL across the substrate. All three substrates ship as
AGPL-3.0. Any third party who deploys this stack and modifies it must contribute their modifications back to their users — the strong-copyleft choice keeps the sovereignty property intact.
The fingerprint payload contains BSSIDs (Wi-Fi MAC addresses) of nearby APs, not the ESP32's own MAC. BLE-advertised names rather than 6-byte addresses are stored because BLE devices rotate their random address ~every 15 minutes for privacy; the name is more stable. Neither is fundamentally personally-identifying for other people's devices on a multi-tenant network — a v0.2 hardening pass will hash BSSIDs and names locally before publish, so the LiveView gets opaque identifiers that still cluster correctly.
5. Honest limitations
Seven gaps, named in order of how badly they bound the v0.1 claim:
- No real ESP32 has run this firmware as of 2026-05-18. The C
bridge skeleton at examples/esp_idf_bridge.c.example is a documented contract surface; it has not been compiled against ESP-IDF nor flashed to a board. Until that happens, every measurement in this entry is from the host stub, which is PRNG-driven. The host-stub fingerprints look plausible but are not representative of any real RF environment.
- Single-room accuracy only. The substrate is a *room
classifier*, not a coordinate solver. Two adjacent rooms with overlapping AP coverage may produce indistinguishable fingerprints (the bedroom and the master closet have the same APs through the same wall). The right v0.2 mitigation is multi-floor disambiguation via the magnetic vector — magnetic-field gradients across floors are larger than across rooms — but that's calibration work, not yet done.
- Magnetic-vector calibration drift. The LIS3MDL needs
per-installation hard-iron / soft-iron calibration to be useful for orientation. The firmware reads raw counts and reports raw converted-to-µT values; no calibration is applied. The gravity-vector from the LIS2DH12 is similarly raw. For v0.1 the magnetic-vector arrow in the LiveView is decorative; it becomes load-bearing only after calibration ships.
- The 2D projection is not PCA. It's a deterministic circular
embedding over the top-16 most-frequently-observed identifiers, chosen because it requires no eigendecomposition (no Nx, no LAPACK). It's good enough for separating clear room clusters; it's not good enough for fine-grained sub-room resolution. v0.2 should evaluate UMAP / t-SNE; if either becomes load-bearing, the cost is a real linear-algebra dependency.
- No fingerprint stability metric. Two fingerprints from the
same node 10 s apart should be similar; if they're not, that's either someone walked past with a phone (transient BLE) or the environment is genuinely changing (someone closed a metal door). v0.1 doesn't expose a stability score; v0.2 should compute and display it.
- No multi-node fingerprint aggregation. Each Bumblebee publishes
its own fingerprint independently. A real positioning substrate would fuse fingerprints across nodes (the same environment observed from different vantage points) and triangulate the room. v0.1 plots them all on the same map but does no fusion.
- The cross-validation test bundle is small. The bridge's
self-test ran 5 firmware-stub frames successfully; that's enough to prove the wire format agrees, but it's not a stress test. A v0.2 pass should generate 10,000+ frames at the host, fuzz the COBS layer with random byte corruption, and verify the bridge gracefully rejects every malformed input without crashing the daemon.
6. v0.2 roadmap
Gated for the next pass, in shipping order:
- Flash a real ESP32-S3 with the firmware + C bridge. The single
highest-leverage step. Brings every claim out of compiled / sketch into hardware-verified. Photograph the setup, capture a real fingerprint stream over coffee + the kitchen + the bedroom, post the cluster image.
- Magnetic-vector calibration UI. Per-node calibration mode that
walks the user through the "wave it in a figure-8" routine, writes hard-iron / soft-iron compensation coefficients to NVS, applies them on every subsequent IMU read.
- Multi-floor disambiguation via magnetic gradient. Add the magnitude
delta between consecutive fingerprints to the feature vector; rooms on different floors have characteristically different magnitudes.
- Fingerprint privacy via local hashing. BSSID and BLE name fields
hashed (HMAC-SHA256 with a per-deployment salt) before publish. The LiveView still clusters correctly because hashes preserve identity; the bridge daemon no longer carries plaintext identifiers of neighbors' networks.
- Stability score. Per-node, exponentially-weighted moving average
of self-similarity over the last 10 fingerprints. Surface as a third pill (alongside online/stale): stable / drifting / chaotic.
- Multi-node fusion. Replace per-node maps with a fused map: each
room gets a single dot, computed from all nodes that hear it. Requires that node positions be calibrated against each other — another v0.2 sub-task.
- Property-based fuzz of the parser. 10,000 randomized COBS bytes
through parse_frame; the bridge daemon must never panic, only reject. Match the discipline of zig-cobs's own fuzz suite.
7. Reproducibility
The end-to-end self-test is one shell line:
cd <internal-lab>/firmware/bumblebee-v0.1 \
&& zig build \
&& ./zig-out/bin/bumblebee-host 5 \
| python3 <internal-lab>/bumblebee-cli/bumblebee_bridge.py self-test -
Expected output:
ok node=host-stub-000001 seq=0 wifi=9 ble=4
ok node=host-stub-000001 seq=1 wifi=10 ble=4
ok node=host-stub-000001 seq=2 wifi=10 ble=3
ok node=host-stub-000001 seq=3 wifi=10 ble=4
ok node=host-stub-000001 seq=4 wifi=9 ble=3
self-test: 5 ok, 0 errors
The Zig firmware tests are one command:
cd <internal-lab>/firmware/bumblebee-v0.1 && zig build test --summary all
# Build Summary: 8/8 steps succeeded; 39/39 tests passed
The ESP32 static library is one command:
cd <internal-lab>/firmware/bumblebee-v0.1 && zig build -Dtarget_esp32=true
# produces zig-out/lib/libbumblebee_firmware.a
The LiveView lives in AETHER and compiles with the rest of the project:
cd ~/aether && mix compile
When a Bumblebee node is paired, configure it in config/config.exs under :aether, :bumblebee_nodes as [{"host-stub-000001", "Kitchen"}, ...] and visit http://localhost:4000/bumblebee.
8. Cross-references
- [
~/.claude/projects/-home-stax/memory/project_transformers_rc_doctrine.md][doc] — the
Transformers RC doctrine that names Bumblebee as the Topological Navigator and originally specced a 1-week ship target. This entry is the v0.1 of that target.
- [
zig-frame-protocol][zfp] (v0.2.0) — the COBS+CRC32 wire-framing
substrate that wraps every fingerprint. Used as a path-dependency so this build doesn't need network.
- [
zig-cobs][zcobs] (v1.2.0) — transitive dep via zig-frame-protocol;
provides the COBS encoding primitive.
- AETHER orchestration layer at
<internal-lab>/aether/— the/bumblebeeLiveView
is one of five surfaces (alongside /, /music, /audio, /director) on the same Phoenix.PubSub fan-out. The Aether.Bumblebee.Registry GenServer composes with the existing Aether.Nats wrapper.
[doc]: # "private — memory only" [zfp]: https://github.com/SMC17/zig-frame-protocol [zcobs]: https://github.com/SMC17/zig-cobs
9. The three-substrate split as a doctrine
Splitting the work as firmware ↔ bridge ↔ LiveView is load-bearing, not just a convenience.
The firmware cannot speak NATS — that would require connecting to Wi-Fi, which would violate the passive-only sovereignty property. The bridge cannot be co-located with the firmware — that would require either Bluetooth or Wi-Fi between the node and the host. The LiveView cannot talk to the ESP32 directly — that would require either a USB-over-IP shim or pulling Phoenix into ESP-IDF, neither of which are reasonable. The split is forced by the substrate: each component owns one boundary, and the boundaries are the actual physical-world primitives.
This composes with the broader Stax doctrine that the wire format is the contract, not the language or the library. Zig on one side of the cable, Python on the other, Elixir on the third hop, and a 12-byte COBS-framed packet is what they agree on. The languages are interchangeable; the contract is forever.
10. Status footer
- Evidence grade:
compiled(firmware + bridge + LiveView all
build clean and unit-test green) with sketch callouts on (a) the ESP-IDF C bridge skeleton and (b) the hardware-verified path that v0.2 has to ship.
- Reproducible:
true. The four commands in §7 produce every
claim. The end-to-end self-test is the binding evidence that the cross-language wire format is correct.
- Last verified: 2026-05-18, on the maintainer's workstation
(Intel Core i7-1065G7 @ 1.30 GHz, Linux 7.0.3-arch1-1 x86_64, Zig 0.16.0, Elixir 1.18 / OTP 27).
- Open gaps: ESP32 hardware bring-up; ESP-IDF bridge C
implementation; magnetic-vector calibration UI; multi-floor disambiguation; fingerprint privacy via local hashing; stability score; multi-node fusion; property-based fuzz of the parser.
- License: AGPL-3.0 across all three substrates.