|
| 1 | +# ADR-018: ESP32 Development Implementation Path |
| 2 | + |
| 3 | +## Status |
| 4 | +Proposed |
| 5 | + |
| 6 | +## Date |
| 7 | +2026-02-28 |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +ADR-012 established the ESP32 CSI Sensor Mesh architecture: hardware rationale, firmware file structure, `csi_feature_frame_t` C struct, aggregator design, clock-drift handling via feature-level fusion, and a $54 starter BOM. That ADR answers *what* to build and *why*. |
| 12 | + |
| 13 | +This ADR answers *how* to build it — the concrete development sequence, the specific integration points in existing code, and how to test each layer before hardware is in hand. |
| 14 | + |
| 15 | +### Current State |
| 16 | + |
| 17 | +**Already implemented:** |
| 18 | + |
| 19 | +| Component | Location | Status | |
| 20 | +|-----------|----------|--------| |
| 21 | +| Binary frame parser | `wifi-densepose-hardware/src/esp32_parser.rs` | Complete — `Esp32CsiParser::parse_frame()`, `parse_stream()`, 7 passing tests | |
| 22 | +| Frame types | `wifi-densepose-hardware/src/csi_frame.rs` | Complete — `CsiFrame`, `CsiMetadata`, `SubcarrierData`, `to_amplitude_phase()` | |
| 23 | +| Parse error types | `wifi-densepose-hardware/src/error.rs` | Complete — `ParseError` enum with 6 variants | |
| 24 | +| Signal processing pipeline | `wifi-densepose-signal` crate | Complete — Hampel, Fresnel, BVP, Doppler, spectrogram | |
| 25 | +| CSI extractor (Python) | `v1/src/hardware/csi_extractor.py` | Stub — `_read_raw_data()` raises `NotImplementedError` | |
| 26 | +| Router interface (Python) | `v1/src/hardware/router_interface.py` | Stub — `_parse_csi_response()` raises `RouterConnectionError` | |
| 27 | + |
| 28 | +**Not yet implemented:** |
| 29 | + |
| 30 | +- ESP-IDF C firmware (`firmware/esp32-csi-node/`) |
| 31 | +- UDP aggregator binary (`crates/wifi-densepose-hardware/src/aggregator/`) |
| 32 | +- `CsiFrame` → `wifi_densepose_signal::CsiData` bridge |
| 33 | +- Python `_read_raw_data()` real UDP socket implementation |
| 34 | +- Proof capture tooling for real hardware |
| 35 | + |
| 36 | +### Binary Frame Format (implemented in `esp32_parser.rs`) |
| 37 | + |
| 38 | +``` |
| 39 | +Offset Size Field |
| 40 | +0 4 Magic: 0xC5110001 (LE) |
| 41 | +4 1 Node ID (0-255) |
| 42 | +5 1 Number of antennas |
| 43 | +6 2 Number of subcarriers (LE u16) |
| 44 | +8 4 Frequency Hz (LE u32, e.g. 2412 for 2.4 GHz ch1) |
| 45 | +12 4 Sequence number (LE u32) |
| 46 | +16 1 RSSI (i8, dBm) |
| 47 | +17 1 Noise floor (i8, dBm) |
| 48 | +18 2 Reserved (zero) |
| 49 | +20 N*2 I/Q pairs: (i8, i8) per subcarrier, repeated per antenna |
| 50 | +``` |
| 51 | + |
| 52 | +Total frame size: 20 + (n_antennas × n_subcarriers × 2) bytes. |
| 53 | + |
| 54 | +For 3 antennas, 56 subcarriers: 20 + 336 = 356 bytes per frame. |
| 55 | + |
| 56 | +The firmware must write frames in this exact format. The parser already validates magic, bounds-checks `n_subcarriers` (≤512), and resyncs the stream on magic search for `parse_stream()`. |
| 57 | + |
| 58 | +## Decision |
| 59 | + |
| 60 | +We will implement the ESP32 development stack in four sequential layers, each independently testable before hardware is available. |
| 61 | + |
| 62 | +### Layer 1 — ESP-IDF Firmware (`firmware/esp32-csi-node/`) |
| 63 | + |
| 64 | +Implement the C firmware project per the file structure in ADR-012. Key design decisions deferred from ADR-012: |
| 65 | + |
| 66 | +**CSI callback → frame serializer:** |
| 67 | + |
| 68 | +```c |
| 69 | +// main/csi_collector.c |
| 70 | +static void csi_data_callback(void *ctx, wifi_csi_info_t *info) { |
| 71 | + if (!info || !info->buf) return; |
| 72 | + |
| 73 | + // Write binary frame header (20 bytes, little-endian) |
| 74 | + uint8_t frame[FRAME_MAX_BYTES]; |
| 75 | + uint32_t magic = 0xC5110001; |
| 76 | + memcpy(frame + 0, &magic, 4); |
| 77 | + frame[4] = g_node_id; |
| 78 | + frame[5] = info->rx_ctrl.ant; // antenna index (1 for ESP32 single-antenna) |
| 79 | + uint16_t n_sub = info->len / 2; // len = n_subcarriers * 2 (I + Q bytes) |
| 80 | + memcpy(frame + 6, &n_sub, 2); |
| 81 | + uint32_t freq_mhz = g_channel_freq_mhz; |
| 82 | + memcpy(frame + 8, &freq_mhz, 4); |
| 83 | + memcpy(frame + 12, &g_seq_num, 4); |
| 84 | + frame[16] = (int8_t)info->rx_ctrl.rssi; |
| 85 | + frame[17] = (int8_t)info->rx_ctrl.noise_floor; |
| 86 | + frame[18] = 0; frame[19] = 0; |
| 87 | + |
| 88 | + // Write I/Q payload directly from info->buf |
| 89 | + memcpy(frame + 20, info->buf, info->len); |
| 90 | + |
| 91 | + // Send over UDP to aggregator |
| 92 | + stream_sender_write(frame, 20 + info->len); |
| 93 | + g_seq_num++; |
| 94 | +} |
| 95 | +``` |
| 96 | +
|
| 97 | +**No on-device FFT** (contradicting ADR-012's optional feature extraction path): The Rust aggregator will do feature extraction using the SOTA `wifi-densepose-signal` pipeline. Raw I/Q is cheaper to stream at ESP32 sampling rates (~100 Hz at 56 subcarriers = ~35 KB/s per node). |
| 98 | +
|
| 99 | +**`sdkconfig.defaults`** must enable: |
| 100 | +
|
| 101 | +``` |
| 102 | +CONFIG_ESP_WIFI_CSI_ENABLED=y |
| 103 | +CONFIG_LWIP_SO_RCVBUF=y |
| 104 | +CONFIG_FREERTOS_HZ=1000 |
| 105 | +``` |
| 106 | +
|
| 107 | +**Build toolchain**: ESP-IDF v5.2+ (pinned). Docker image: `espressif/idf:v5.2` for reproducible CI. |
| 108 | +
|
| 109 | +### Layer 2 — UDP Aggregator (`crates/wifi-densepose-hardware/src/aggregator/`) |
| 110 | +
|
| 111 | +New module within the hardware crate. Entry point: `aggregator_main()` callable as a binary target. |
| 112 | +
|
| 113 | +```rust |
| 114 | +// crates/wifi-densepose-hardware/src/aggregator/mod.rs |
| 115 | +
|
| 116 | +pub struct Esp32Aggregator { |
| 117 | + socket: UdpSocket, |
| 118 | + nodes: HashMap<u8, NodeState>, // keyed by node_id from frame header |
| 119 | + tx: mpsc::SyncSender<CsiFrame>, // outbound to bridge |
| 120 | +} |
| 121 | +
|
| 122 | +struct NodeState { |
| 123 | + last_seq: u32, |
| 124 | + drop_count: u64, |
| 125 | + last_recv: Instant, |
| 126 | +} |
| 127 | +
|
| 128 | +impl Esp32Aggregator { |
| 129 | + /// Bind UDP socket and start blocking receive loop. |
| 130 | + /// Each valid frame is forwarded on `tx`. |
| 131 | + pub fn run(&mut self) -> Result<(), AggregatorError> { |
| 132 | + let mut buf = vec![0u8; 4096]; |
| 133 | + loop { |
| 134 | + let (n, _addr) = self.socket.recv_from(&mut buf)?; |
| 135 | + match Esp32CsiParser::parse_frame(&buf[..n]) { |
| 136 | + Ok((frame, _consumed)) => { |
| 137 | + let state = self.nodes.entry(frame.metadata.node_id) |
| 138 | + .or_insert_with(NodeState::default); |
| 139 | + // Track drops via sequence number gaps |
| 140 | + if frame.metadata.seq_num != state.last_seq + 1 { |
| 141 | + state.drop_count += (frame.metadata.seq_num |
| 142 | + .wrapping_sub(state.last_seq + 1)) as u64; |
| 143 | + } |
| 144 | + state.last_seq = frame.metadata.seq_num; |
| 145 | + state.last_recv = Instant::now(); |
| 146 | + let _ = self.tx.try_send(frame); // drop if pipeline is full |
| 147 | + } |
| 148 | + Err(e) => { |
| 149 | + // Log and continue — never crash on bad UDP packet |
| 150 | + eprintln!("aggregator: parse error: {e}"); |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | + } |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +**Testable without hardware**: The test suite generates frames using `build_test_frame()` (same helper pattern as `esp32_parser.rs` tests) and sends them over a loopback UDP socket. The aggregator receives and forwards them identically to real hardware frames. |
| 159 | + |
| 160 | +### Layer 3 — CsiFrame → CsiData Bridge |
| 161 | + |
| 162 | +Bridge from `wifi-densepose-hardware::CsiFrame` to the signal processing type `wifi_densepose_signal::CsiData` (or a compatible intermediate type consumed by the Rust pipeline). |
| 163 | + |
| 164 | +```rust |
| 165 | +// crates/wifi-densepose-hardware/src/bridge.rs |
| 166 | + |
| 167 | +use crate::{CsiFrame}; |
| 168 | + |
| 169 | +/// Intermediate type compatible with the signal processing pipeline. |
| 170 | +/// Maps directly from CsiFrame without cloning the I/Q storage. |
| 171 | +pub struct CsiData { |
| 172 | + pub timestamp_unix_ms: u64, |
| 173 | + pub node_id: u8, |
| 174 | + pub n_antennas: usize, |
| 175 | + pub n_subcarriers: usize, |
| 176 | + pub amplitude: Vec<f64>, // length: n_antennas * n_subcarriers |
| 177 | + pub phase: Vec<f64>, // length: n_antennas * n_subcarriers |
| 178 | + pub rssi_dbm: i8, |
| 179 | + pub noise_floor_dbm: i8, |
| 180 | + pub channel_freq_mhz: u32, |
| 181 | +} |
| 182 | + |
| 183 | +impl From<CsiFrame> for CsiData { |
| 184 | + fn from(frame: CsiFrame) -> Self { |
| 185 | + let n_ant = frame.metadata.n_antennas as usize; |
| 186 | + let n_sub = frame.metadata.n_subcarriers as usize; |
| 187 | + let (amplitude, phase) = frame.to_amplitude_phase(); |
| 188 | + CsiData { |
| 189 | + timestamp_unix_ms: frame.metadata.timestamp_unix_ms, |
| 190 | + node_id: frame.metadata.node_id, |
| 191 | + n_antennas: n_ant, |
| 192 | + n_subcarriers: n_sub, |
| 193 | + amplitude, |
| 194 | + phase, |
| 195 | + rssi_dbm: frame.metadata.rssi_dbm, |
| 196 | + noise_floor_dbm: frame.metadata.noise_floor_dbm, |
| 197 | + channel_freq_mhz: frame.metadata.channel_freq_mhz, |
| 198 | + } |
| 199 | + } |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +The bridge test: parse a known binary frame, convert to `CsiData`, assert `amplitude[0]` = √(I₀² + Q₀²) to within f64 precision. |
| 204 | + |
| 205 | +### Layer 4 — Python `_read_raw_data()` Real Implementation |
| 206 | + |
| 207 | +Replace the `NotImplementedError` stub in `v1/src/hardware/csi_extractor.py` with a UDP socket reader. This allows the Python pipeline to receive real CSI from the aggregator while the Rust pipeline is being integrated. |
| 208 | + |
| 209 | +```python |
| 210 | +# v1/src/hardware/csi_extractor.py |
| 211 | +# Replace _read_raw_data() stub: |
| 212 | + |
| 213 | +import socket as _socket |
| 214 | + |
| 215 | +class CSIExtractor: |
| 216 | + ... |
| 217 | + def _read_raw_data(self) -> bytes: |
| 218 | + """Read one raw CSI frame from the UDP aggregator. |
| 219 | +
|
| 220 | + Expects binary frames in the ESP32 format (magic 0xC5110001 header). |
| 221 | + Aggregator address configured via AGGREGATOR_HOST / AGGREGATOR_PORT |
| 222 | + environment variables (defaults: 127.0.0.1:5005). |
| 223 | + """ |
| 224 | + if not hasattr(self, '_udp_socket'): |
| 225 | + host = self.config.get('aggregator_host', '127.0.0.1') |
| 226 | + port = int(self.config.get('aggregator_port', 5005)) |
| 227 | + sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) |
| 228 | + sock.bind((host, port)) |
| 229 | + sock.settimeout(1.0) |
| 230 | + self._udp_socket = sock |
| 231 | + try: |
| 232 | + data, _ = self._udp_socket.recvfrom(4096) |
| 233 | + return data |
| 234 | + except _socket.timeout: |
| 235 | + raise CSIExtractionError( |
| 236 | + "No CSI data received within timeout — " |
| 237 | + "is the ESP32 aggregator running?" |
| 238 | + ) |
| 239 | +``` |
| 240 | + |
| 241 | +This is tested with a mock UDP server in the unit tests (existing `test_csi_extractor_tdd.py` pattern) and with the real aggregator in integration. |
| 242 | + |
| 243 | +## Development Sequence |
| 244 | + |
| 245 | +``` |
| 246 | +Phase 1 (Firmware + Aggregator — no pipeline integration needed): |
| 247 | + 1. Write firmware/esp32-csi-node/ C project (ESP-IDF v5.2) |
| 248 | + 2. Flash to one ESP32-S3-DevKitC board |
| 249 | + 3. Verify binary frames arrive on laptop UDP socket using Wireshark |
| 250 | + 4. Write aggregator crate + loopback test |
| 251 | +
|
| 252 | +Phase 2 (Bridge + Python stub): |
| 253 | + 5. Implement CsiFrame → CsiData bridge |
| 254 | + 6. Replace Python _read_raw_data() with UDP socket |
| 255 | + 7. Run Python pipeline end-to-end against loopback aggregator (synthetic frames) |
| 256 | +
|
| 257 | +Phase 3 (Real hardware integration): |
| 258 | + 8. Run Python pipeline against live ESP32 frames |
| 259 | + 9. Capture 10-second real CSI bundle (firmware/esp32-csi-node/proof/) |
| 260 | + 10. Verify proof bundle hash (ADR-011 pattern) |
| 261 | + 11. Mark ADR-012 Accepted, mark this ADR Accepted |
| 262 | +``` |
| 263 | + |
| 264 | +## Testing Without Hardware |
| 265 | + |
| 266 | +All four layers are testable before a single ESP32 is purchased: |
| 267 | + |
| 268 | +| Layer | Test Method | |
| 269 | +|-------|-------------| |
| 270 | +| Firmware binary format | Build a `build_test_frame()` helper in Rust, compare its output byte-for-byte against a hand-computed reference frame | |
| 271 | +| Aggregator | Loopback UDP: test sends synthetic frames to 127.0.0.1:5005, aggregator receives and forwards on channel | |
| 272 | +| Bridge | `assert_eq!(csi_data.amplitude[0], f64::sqrt((iq[0].i as f64).powi(2) + (iq[0].q as f64).powi(2)))` | |
| 273 | +| Python UDP reader | Mock UDP server in pytest using `socket.socket` in a background thread | |
| 274 | + |
| 275 | +The existing `esp32_parser.rs` test suite already validates parsing of correctly-formatted binary frames. The aggregator and bridge tests build on top of the same test frame construction. |
| 276 | + |
| 277 | +## Consequences |
| 278 | + |
| 279 | +### Positive |
| 280 | +- **Layered testability**: Each layer can be validated independently before hardware acquisition. |
| 281 | +- **No new external dependencies**: UDP sockets are in stdlib (both Rust and Python). Firmware uses only ESP-IDF and esp-dsp component. |
| 282 | +- **Stub elimination**: Replaces the last two `NotImplementedError` stubs in the Python hardware layer with real code backed by real data. |
| 283 | +- **Proof of reality**: Phase 3 produces a captured CSI bundle hashed to a known value, satisfying ADR-011 for hardware-sourced data. |
| 284 | +- **Signal-crate reuse**: The SOTA Hampel/Fresnel/BVP/Doppler processing from ADR-014 applies unchanged to real ESP32 frames after the bridge converts them. |
| 285 | + |
| 286 | +### Negative |
| 287 | +- **Firmware requires ESP-IDF toolchain**: Not buildable without a 2+ GB ESP-IDF installation. CI must use the official Docker image or skip firmware compilation. |
| 288 | +- **Raw I/Q bandwidth**: Streaming raw I/Q (not features) at 100 Hz × 3 antennas × 56 subcarriers = ~35 KB/s/node. At 6 nodes = ~210 KB/s. Fine for LAN; not suitable for WAN. |
| 289 | +- **Single-antenna real-world**: Most ESP32-S3-DevKitC boards have one on-board antenna. Multi-antenna data requires external antenna + board with U.FL connector or purpose-built multi-radio setup. |
| 290 | + |
| 291 | +### Deferred |
| 292 | +- **Multi-node clock drift compensation**: ADR-012 specifies feature-level fusion. The aggregator in this ADR passes raw `CsiFrame` per-node. Drift compensation lives in a future `FeatureFuser` layer (not scoped here). |
| 293 | +- **ESP-IDF firmware CI**: Firmware compilation in GitHub Actions requires the ESP-IDF Docker image. CI integration is deferred until Phase 3 hardware validation. |
| 294 | + |
| 295 | +## Interaction with Other ADRs |
| 296 | + |
| 297 | +| ADR | Interaction | |
| 298 | +|-----|-------------| |
| 299 | +| ADR-011 | Phase 3 produces a real CSI proof bundle satisfying mock elimination | |
| 300 | +| ADR-012 | This ADR implements the development path for ADR-012's architecture | |
| 301 | +| ADR-014 | SOTA signal processing applies unchanged after bridge layer | |
| 302 | +| ADR-008 | Aggregator handles multi-node; distributed consensus is a later concern | |
| 303 | + |
| 304 | +## References |
| 305 | + |
| 306 | +- [Espressif ESP-CSI Repository](https://github.com/espressif/esp-csi) |
| 307 | +- [ESP-IDF WiFi CSI API Reference](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/wifi.html#wi-fi-channel-state-information) |
| 308 | +- `wifi-densepose-hardware/src/esp32_parser.rs` — binary frame parser implementation |
| 309 | +- `wifi-densepose-hardware/src/csi_frame.rs` — `CsiFrame`, `to_amplitude_phase()` |
| 310 | +- ADR-012: ESP32 CSI Sensor Mesh (architecture) |
| 311 | +- ADR-011: Python Proof-of-Reality and Mock Elimination |
| 312 | +- ADR-014: SOTA Signal Processing |
0 commit comments