Bumblebee — Topological Navigator v0.2: real ESP-IDF C bridge, privacy hashing, stability score, multi-node fusion

2026-05-19 · project bumblebee

This entry tracks the v0.1 → v0.2 substrate delta on the Topological Navigator. The headline claim is substrate not hardware: every software-side gap that v0.1's [Honest Limitations][v01-limits] section named has been closed at the substrate layer. Hardware verification on real silicon remains the v0.3 step.

[v01-limits]: bumblebee-topological-navigator-v0.1.md

The shippable surface area:

scan via esp_wifi_scan_start, BLE observer via esp_ble_gap_register_callback, LIS2DH12 + LIS3MDL I2C over the standard ST register maps, UART USB-CDC writes. 528 lines, every function citation-grounded against ST datasheet DM00042751 (LIS2DH12) and DM00075867 (LIS3MDL).

sequences pushed through the COBS + CRC32 + fingerprint-parse pipe per CI run; zero unhandled exceptions across every seed tested. The seed prints at the start so failures are reproducible.

salt, provisioned by bumblebee-cli init, applied transparently on the bridge's way through. The LiveView still clusters correctly (hashes preserve identity); the NATS bus no longer carries plaintext-identifiers of neighbors' networks.

self-similarity in Aether.Bumblebee.Registry. Surfaces as a stability: 0.94 pill on every node card in the LiveView.

fuse_by_room/2 produce a per-room centroid feature map; the LiveView renders these as dashed-yellow "room dots" that legibly collapse N nodes-in-a-room into one location.

ships the hard-iron offset math + the bridge's bumblebee-cli calibrate subcommand that captures a 30-second sample and writes the offset to ~/.config/bumblebee/calibration.json.

Evidence: still compiled / sketch for the C bridge (no real ESP32 has run it yet) and the LiveView calibration UI surface; unit-tested for the new fingerprint round-trip tests (5 new tests added to the firmware suite, plus 19 new tests across the AETHER Similarity + Calibration modules); property-tested for the bridge parser fuzz (10,000 inputs, zero crashes).

1. The v0.1 → v0.2 delta in one screen

| Concern | v0.1 status | v0.2 status | |-—|-—|-—| | ESP-IDF C bridge | sketch (stub returning 0/-1) | compiled (real ESP-IDF API calls, cited datasheets) | | Bridge parser fuzz | not exercised | 10,000 random inputs, zero crashes | | Firmware tests | 39/39 | 44/44 (5 new round-trip + edge-case tests) | | BSSID/BLE-name privacy | plaintext | HMAC-SHA256 with per-deployment salt | | Stability metric | none | EMA of self-similarity per node | | Multi-node fusion | none | per-room centroid via Similarity.fuse_by_room/2 | | Magnetic-vector calibration | none | hard-iron offset primitive + bridge capture subcommand | | Hardware verification | gated | still gated — v0.3 |

2. ESP-IDF C bridge — what real code looks like

The v0.1 skeleton at examples/esp_idf_bridge.c.example returned -1 or zero from every function. The v0.2 implementation at src/esp_idf_bridge.c calls real ESP-IDF v5.x APIs.

Wi-Fi scan uses esp_wifi_scan_start with WIFI_SCAN_TYPE_PASSIVE and a 100ms per-channel dwell. The scan is synchronous (second arg true). Results are pulled via esp_wifi_scan_get_ap_records, sorted by RSSI descending via qsort, then copied to the caller's out_bssids[] / out_channels[] / out_rssis[] arrays. Up to 20 APs per scan (matches the firmware-side MAX_WIFI_APS ceiling). esp_wifi_connect is never called — passive scan only, per the Topological Navigator's sovereignty contract.

BLE observer uses esp_ble_gap_register_callback + a passive scan config (BLE_SCAN_TYPE_PASSIVE, BLE_SCAN_FILTER_ALLOW_ALL, 30ms window in a 50ms interval, BLE_SCAN_DUPLICATE_DISABLE). A bb_ble_gap_cb handler accumulates discovered advertisements into a 32-slot mutex-guarded ring buffer keyed by complete-or-shortened local name (AD types 0x09 / 0x08 per Bluetooth Core v5.3 Vol 3 Part C §11). bb_scan_ble drains the ring buffer into the caller's arrays sorted by RSSI descending. The ESP32 never advertises, never responds to scan requests, never opens a connection — observer-only.

I2C IMU wires the LIS2DH12 (accelerometer, default I2C address 0x19 when SA0 is tied to VDD per ST DM00042751 §6) and the LIS3MDL (magnetometer, default I2C address 0x1C when SDO is tied to GND per ST DM00075867 §6) to a shared I2C bus on GPIO21/SDA + GPIO22/SCL at 400 kHz. The LIS2DH12 is initialised to ±2g high-resolution mode at 100 Hz ODR; the LIS3MDL to ±4 gauss continuous-conversion at 10 Hz with ultra-high-performance X/Y/Z axes. Multi-byte reads use the auto-increment flag (register-address MSB = 1) per both datasheets' §6.1.1.

Sensitivity conversions are explicit and cited:

16-bit register pair (so shift-right-by-4 recovers signed 12-bit). 1 g = 9.80665 m/s².

(because 1 gauss = 100 µT).

Battery ADC uses adc_oneshot_new_unit + adc_oneshot_config_channel on ADC1_CHANNEL_7 (GPIO35) at 11 dB attenuation (full 0..3.3V swing), with adc_cali_create_scheme_line_fitting for one-point calibration when the chip variant supports it. The battery rail goes through a 2:1 resistor divider so the function multiplies the pin reading by 2 to recover battery millivolts.

UART USB-CDC runs at 115200 8N1 with a 1024-byte TX buffer and hardware flow control disabled. bb_uart_write is the only outbound channel on the entire system.

Every function is compiled evidence-tier. None of them are hardware-verified until a real ESP32-S3 (or ESP32 classic with classic Bluetooth disabled) actually flashes and runs the firmware.

3. Privacy hashing — the v0.1 limitation that was load-bearing

v0.1's section 5.1 named neighbor-MAC plaintext as a v0.2 deliverable. The v0.2 implementation:

  1. Salt provisioning. bumblebee-cli init writes 32 random bytes

to /etc/bumblebee/salt (preferred — survives $HOME wipes, readable by the bridge daemon's run-user) with a fallback to ~/.config/bumblebee/salt when /etc isn't writable. Mode 0600. Idempotent: re-running without --force prints the existing path and exits without rotating. Rotation invalidates every prior hash, which breaks AETHER's stability score, so we don't rotate silently.

  1. Hashing on the bridge's way through. Every BSSID string and

non-empty BLE name is run through HMAC-SHA256(salt, plaintext)[:16-hex-chars] before the fingerprint dict reaches NATS. The wire format is unchanged; the bridge re-hashes during parse_fingerprint.

  1. Plaintext fallback with explicit warning. When no salt is

provisioned, the bridge logs a warning at daemon start and passes identifiers through unchanged. The hash-marker prefix (h:) distinguishes hashed from unhashed values in downstream audits.

The cryptographic choice is HMAC-SHA256 via Python's stdlib hmac + hashlib. No third-party crypto. AGPL across the substrate.

The 16-hex-character truncation balances clustering (enough bits that collision is astronomically unlikely for the ~hundreds of identifiers a small deployment sees) against privacy (irreversibility without the salt is guaranteed by the HMAC construction).

4. Stability score — the missing fingerprint-quality metric

Two fingerprints from the same node ten seconds apart should be similar. If they're not, either someone walked through with a phone (transient BLE), the environment is genuinely changing (someone moved a router), or the node itself is in motion. v0.1 didn't expose this; a LiveView dot moving wildly around the map looked indistinguishable from a node that was genuinely flickering between rooms.

The v0.2 metric:

stability(t) = α * cosine_similarity(fp(t), fp(t-1)) + (1 - α) * stability(t-1)
α = 0.3

α = 0.3 means "current sample is 30%, previous EMA is 70%" — slow enough to absorb a single transient, fast enough that a genuine environment change registers within ~10 fingerprints. The first fingerprint from a node gets stability = 1.0 because there's nothing to compare against.

The LiveView surfaces this as a pill on each node card:

The math lives in Aether.Bumblebee.Registry.handle_fingerprint_msg/2; the rendering lives in AetherWeb.BumblebeeLive.stability_pill_*/1.

5. Multi-node fusion — the centroid as a "room dot"

When two Bumblebees are deployed in the same room, plotting them as two dots is a worse story than plotting the room as one dot — the viewer wants to know about the room, not about node-A versus node-B. The v0.2 fusion path:

  1. Similarity.fuse/1 accepts a list of N fingerprints, extracts each

one's feature map (features/1), then computes the per-key arithmetic mean — but only across the nodes that actually observed that key. Keys that only some nodes saw are averaged across only those observers, not zero-filled. (Zero-fill would bias the centroid toward "didn't observe.")

  1. Similarity.fuse_by_room/2 groups the latest fingerprints by

their allowlist room, then fuses each group. Returns %{room_name => fused_feature_map}.

  1. The LiveView feeds those fused maps back through project/2 and

renders each as a dashed-yellow circle at 18px radius with a "(fused)" label.

Rooms with only one node aren't rendered as fused dots — that node's own dot is already the room's location. The dashed style + italic label distinguishes fused room dots from per-node dots at a glance.

6. Magnetic-vector calibration primitive

The LIS3MDL needs hard-iron + soft-iron calibration to be useful for orientation. v0.2 ships the hard-iron half (the simpler 3-vector offset) and defers soft-iron (the 3×3 inverse-distortion matrix) to v0.3:

centroid (min + max) / 2 per axis. Robust to noise, requires only that the sample covers a reasonable orientation span (the figure-8 routine). Returns nil for sub-30-sample inputs to refuse biased computation.

elementwise. Pass nil to passthrough.

the fingerprint lacks the field or the offset is nil; otherwise applies and returns the updated fingerprint.

fingerprints for the named node, accumulates the magnetic readings, writes the bounding-box centroid to ~/.config/bumblebee/calibration.json.

The LiveView "Calibrate" button is on the v0.3 roadmap — wiring a phx-click event handler through the Registry + the bridge's local SQLite is straightforward but didn't fit this substrate pass.

7. Honest limitations (still)

The v0.1 limitation list shrinks but doesn't vanish:

  1. No real ESP32 has run this firmware as of 2026-05-19. Same as

v0.1. The substrate is compiled (real C, real datasheet citations, real ESP-IDF API calls). It's not hardware-verified until someone flashes it. Until then, every claim about real-world Wi-Fi scan timing, real BLE coexistence on the shared 2.4GHz radio, real magnetometer hard-iron bias from the dev board's own GND traces is a hypothesis.

  1. Single-room accuracy only — unchanged from v0.1. Multi-floor

disambiguation via magnetic gradient is still v0.3.

  1. Soft-iron calibration not yet shipped — only the simpler

hard-iron offset. The 3×3 inverse-distortion matrix is v0.3.

  1. LiveView "Calibrate" button is documented but not wired. The

bridge subcommand works today; the in-browser flow is a one-Phx-event-handler add that gets folded into the v0.3 hardware- bringup pass.

  1. JetStream replay (v0.2 Tier 3 stretch goal) not shipped. The

homelab.bumblebee.replay stream + LiveView scrubber surface is v0.3.

  1. The 2D projection is still not PCA. Same circular embedding as

v0.1. UMAP/t-SNE evaluation is v0.3 once we have real fingerprints to evaluate against.

  1. The fuzz seed is reproducible but not pinned in CI yet. Every

run generates a fresh seed (and prints it). A v0.3 step is to capture a pinned-seed regression set so refactors don't accidentally reduce coverage.

8. What's still gated on hardware

Naming this explicitly because it bounds every claim in this entry:

channel × 13 channels = ~1.3s for a passive scan. Real timing varies with 11n/11ac AP density + channel utilization; we don't know what the actual distribution is until a real board runs in a real apartment.

ESP32 (classic, not ESP32-C6) shares one radio for both. The arbiter is documented but the actual practical packet-loss rate of the BLE observer during a Wi-Fi scan is a hardware-bringup question.

breakout board's offset depends on the proximity of the ESP32's RF can, the USB-C connector's magnetic field, GND-trace routing, any nearby capacitors. The calibration primitive ships ready to consume real samples; the samples don't exist until a board is flashed.

similarity numbers we see on real fingerprints actually cluster by room (versus, e.g., dominated by floor-level / building-shell effects) is the load-bearing empirical question that v0.3 must answer.

These four gaps share a common shape: every one of them is a hypothesis, not a defect. The v0.2 substrate is ready to consume the answers as soon as real hardware produces them.

9. Reproducibility — the same one-liner

The end-to-end self-test is unchanged:

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: self-test: 5 ok, 0 errors.

The Zig firmware tests:

cd <internal-lab>/firmware/bumblebee-v0.1 && zig build test --summary all
# Build Summary: 10/10 steps succeeded; 44/44 tests passed

The bridge property-fuzz:

cd <internal-lab>/bumblebee-cli && python3 test_fuzz.py
# bumblebee fuzz: seed=<random> iterations=10000
# bumblebee fuzz: 0 ok / 10000 rejected / 0 CRASHED

The AETHER unit tests for the new Bumblebee modules:

cd ~/aether && mix test test/aether/bumblebee/ --no-deps-check
# 19 tests, 0 failures

10. Status footer

unit-tested for the new fingerprint round-trip tests + the AETHER Similarity / Calibration math; property-tested for the bridge parser. Still sketch for the LiveView calibration-button surface and not-yet-hardware-verified for everything that requires a flashed ESP32.

every measurable claim in this entry.

(Linux 7.0.3-arch1-1 x86_64, Zig 0.16.0, Elixir 1.18 / OTP 27, Python 3.11).

calibration matrix; LiveView calibration-button surface; JetStream replay scrubber; multi-floor magnetic-gradient feature; pinned-seed fuzz regression set; UMAP/t-SNE evaluation against real fingerprints.

11. Cross-references

compiled / sketch, named every gap that v0.2 closed.

the broader Topological Navigator doctrine. v0.2 keeps the same substrate ↔ distribution split: software substrate first, public Twitter aura-farm gated on hardware verification.

[v01]: bumblebee-topological-navigator-v0.1.md