ESP32-S3 CSI Pipeline: Step-by-Step Tutorial (current as of v0.6.6-esp32)
⚠️ Updated 2026-05-21 — original tutorial referenced paths and commands that no longer match the repo. Updated for the current state of ruvnet/RuView (note: the repo was renamed from wifi-densepose to RuView).
What changed vs the original: repo name, rust-port/ → v2/, v1/ → archive/v1/, flash offsets (0x10000 → 0x20000 + new ota_data_initial.bin at 0xf000), WiFi credentials now via provision.py not sdkconfig.defaults, sensing-server is the primary path not the bare aggregator, OTA PSK now fail-closed by default.
Complete end-to-end guide for building, flashing, and running the WiFi CSI human presence + vitals pipeline using an ESP32-S3 and the Rust sensing server.
Verified hardware: ESP32-S3-DevKitC-1, ESP32-S3 WROOM (8 MB or 4 MB flash). ESP32 (original) and ESP32-C3 not supported — single-core, insufficient for CSI DSP.
Verified performance (v0.6.6-esp32, real WiFi CSI, no mocks): 20 Hz CSI streaming, 64–192 subcarrier frames, RSSI -47 to -88 dBm, presence + breathing + heart rate, multi-node mesh up to 6 nodes per room.
Prerequisites
Fast path: pre-built binaries (recommended)
If you just want it to work, skip the firmware build. Download v0.6.6-esp32 release binaries:
- Go to https://github.com/ruvnet/RuView/releases/tag/v0.6.6-esp32
- Download all 6 binaries:
bootloader.bin, partition-table.bin, ota_data_initial.bin, esp32-csi-node.bin (for 8 MB boards), plus esp32-csi-node-4mb.bin and partition-table-4mb.bin (for 4 MB boards)
- Skip to Step 5 (Flash) below.
Step 1: Clone the repository
git clone https://github.com/ruvnet/RuView.git
cd RuView
Step 2: Build the sensing server (Rust)
The sensing server is the new primary aggregator + REST/WS surface. The standalone aggregator binary from the original tutorial still exists as a packet-inspection tool but doesn't expose the dashboard.
cd v2
cargo build -p wifi-densepose-sensing-server --release
Binary: v2/target/release/sensing-server (or sensing-server.exe on Windows). First build takes ~5–10 minutes.
Step 3: Build the firmware (skip if using release binaries)
The repo provides a Docker build that works on Linux, macOS, and Windows (Git Bash) without any local ESP-IDF setup:
cd firmware/esp32-csi-node
# Linux/macOS:
docker run --rm -v "$(pwd):/project" -w /project \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
# Windows (Git Bash):
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd):/project" -w /project \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
Build output appears in build/ (~1426 compilation steps, takes 3–5 minutes first time, ~30 s incremental).
Output files needed for flashing:
build/bootloader/bootloader.bin
build/partition_table/partition-table.bin
build/ota_data_initial.bin
build/esp32-csi-node.bin
Step 4: Find your serial port
| OS |
Command |
Typical port |
| Windows |
Device Manager → Ports (COM & LPT) |
COM7 |
| Linux |
ls /dev/ttyUSB* or ls /dev/ttyACM* |
/dev/ttyUSB0 |
| macOS |
ls /dev/cu.usbserial* or ls /dev/cu.SLAB_USBtoUART |
/dev/cu.SLAB_USBtoUART |
Step 5: Flash the firmware
8 MB boards (DevKitC-1, most WROOM variants):
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 8MB \
0x0 bootloader.bin \
0x8000 partition-table.bin \
0xf000 ota_data_initial.bin \
0x20000 esp32-csi-node.bin
4 MB boards (SuperMini 4 MB):
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 4MB \
0x0 bootloader.bin \
0x8000 partition-table-4mb.bin \
0xf000 ota_data_initial.bin \
0x20000 esp32-csi-node-4mb.bin
⚠️ The app flash offset is 0x20000, not 0x10000 — that was a long-standing README error fixed in #561. The repo's partition tables put ota_0 at 0x20000, and ota_data_initial.bin at 0xf000 is required for the OTA system.
Replace COM7 with your serial port. Expected output ends with:
Hash of data verified.
Leaving...
Hard resetting via RTS pin...
Step 6: Provision WiFi credentials via provision.py
⚠️ WiFi credentials are NOT in sdkconfig.defaults anymore. The original tutorial baked them into the firmware at build time; the current system writes them to the device's NVS partition via provision.py, so the same binary works for any deployment.
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourWiFi" \
--password "YourPassword" \
--target-ip 192.168.1.20 \
--node-id 1 \
--edge-tier 2 \
--force-partial
Flags:
Step 7: (Optional) Provision an OTA PSK
⚠️ Security default changed in v0.6.5: OTA upload is fail-closed until you provision a PSK. Without this step, POST /ota returns 403. The OTA HTTP server still runs so you can provision later without re-flashing.
python firmware/esp32-csi-node/provision.py --port COM7 \
--ota-psk "$(python -c 'import secrets; print(secrets.token_hex(32))')" \
--force-partial
Save the printed PSK somewhere safe. Future OTA pushes need it in the Authorization: Bearer <psk> header.
Step 8: Open firewall (Windows only)
Elevated PowerShell:
netsh advfirewall firewall add rule name="ESP32 CSI" dir=in action=allow protocol=UDP localport=5005
Linux (ufw):
Step 9: Start the sensing server + dashboard
cd v2
cargo run -p wifi-densepose-sensing-server --release -- \
--source esp32 \
--udp-port 5005 \
--http-port 3000 \
--ws-port 3001 \
--ui-path ../ui
Open the dashboard: http://localhost:3000/ui/index.html
If the dashboard's "Sensing" tab shows LIVE — ESP32 HARDWARE Connected, frames are flowing. If it shows DISCONNECTED:
# Check whether UDP packets reach the host
sudo tcpdump -i any -n 'udp port 5005' -c 20
# Should show 192.168.1.x → 192.168.1.20:5005 frames, ~20/s
# Check how many nodes the server sees
curl http://localhost:3000/api/v1/nodes
# Should show "total": 1 for your single node
Step 10: Verify what you can see
Walk near the ESP32 and watch:
- Motion energy increases when you move
- Presence indicator flips from 0 → 1
- Breathing rate (~12–20 BPM) stabilises after ~60 s once you sit still
- Heart rate (~50–90 BPM) — needs reasonable signal-to-noise; harder than breathing
For pose-related data, see the honest caveats section below.
Step 11: Multi-node mesh (optional)
Repeat Steps 5–6 for additional ESP32-S3 boards with different --node-id values. Spread nodes around the room diagonally (not in a row) for geometric diversity — multistatic fusion needs that. Recommended: 3–6 nodes per room, 2–3 m apart, ~1 m off the floor, not pressed against walls.
All nodes UDP-stream to the same sensing-server instance. The server auto-detects new nodes and adds them to the active mesh.
Honest caveats (read before reporting bugs)
- No pretrained 17-keypoint pose model ships in the repo today (tracked in #509). What works today: presence, motion, breathing rate, heart rate, fall detection. What's pending: full skeleton estimation (the training pipeline exists, the weights don't).
n_persons is a slot-capacity heuristic, not a learned classifier. Documented in firmware/esp32-csi-node/README.md under "What this firmware does NOT do (Tier 2 caveats)". Auto-calibrating fix in flight: #491.
- First 60 seconds are an adaptive-calibration window. Power-cycle in an empty room if it boots with someone present.
- Strong RF interferers (microwaves, fans near the antenna, neighbouring AP power swings) cause false-positive presence until calibration re-converges.
Architecture
ESP32-S3 Node(s) Host Machine
+-------------------+ +--------------------------+
| WiFi CSI callback | UDP/5005 | wifi-densepose- |
| (promiscuous, MGMT| ADR-018 | sensing-server (Rust, |
| only — RuView#396)| binary | Axum) |
| Edge DSP tier 2: | ~20 Hz | - REST API :3000 |
| presence, vitals,| | - WS /ws/sensing :3000 |
| fall detection | | - WS pose :3001 |
| ADR-018 serialize | | - Dashboard /ui/ |
+-------------------+ | - Optional --model file |
+--------------------------+
ADR-018 binary frame format
Offset Size Field
0 4 Magic: 0xC5110001 (LE)
4 1 Node ID
5 1 Number of antennas
6 2 Number of subcarriers (LE u16)
8 4 Frequency MHz (LE u32)
12 4 Sequence number (LE u32)
16 1 RSSI (i8)
17 1 Noise floor (i8)
18 2 Reserved
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
Troubleshooting
| Symptom |
Cause |
Fix |
| No serial output |
Wrong baud rate |
Use 115200 |
Boot loop with ***ERROR*** A stack overflow in task Tmr Svc |
Old firmware (pre-v0.6.5) or custom build missing the Tmr Svc stack fix |
Flash v0.6.6-esp32 from releases |
| WiFi connection fails |
Wrong SSID/password |
Re-run provision.py with --ssid / --password (do NOT edit sdkconfig.defaults) |
| No UDP frames at server |
Firewall blocking |
Allow UDP 5005 inbound (Step 8) |
UDP frames arrive but server shows total: 1 despite 3 nodes |
Old firmware with node_id clobber bug |
Flash v0.6.6-esp32 (closed by #232/#390) |
| OTA upload returns 403 |
OTA PSK not provisioned (fail-closed since v0.6.5) |
Step 7 above |
Device at edge-tier 2 panics / reboots in a loop (N16R8/PSRAM boards) |
WDT storm — DSP task starved IDLE1 before v0.6.6 |
Flash v0.6.6-esp32 (fixed in #683) |
| Dashboard shows "LIVE" but never updates |
UI bug fixed in #618 / #621 |
Pull latest, rebuild sensing-server |
| Person count is too high (multi-node) |
Subcarrier slot heuristic over-clustering |
Drop --subk-count to 4, or wait for #491 |
| FPS shows ∞ |
UI div-by-zero (fixed in #610) |
Pull latest dashboard |
Related links
If something in this tutorial is wrong against current main, please open a new issue — these tutorials get updates as the project evolves.
ESP32-S3 CSI Pipeline: Step-by-Step Tutorial (current as of v0.6.6-esp32)
Complete end-to-end guide for building, flashing, and running the WiFi CSI human presence + vitals pipeline using an ESP32-S3 and the Rust sensing server.
Verified hardware: ESP32-S3-DevKitC-1, ESP32-S3 WROOM (8 MB or 4 MB flash). ESP32 (original) and ESP32-C3 not supported — single-core, insufficient for CSI DSP.
Verified performance (v0.6.6-esp32, real WiFi CSI, no mocks): 20 Hz CSI streaming, 64–192 subcarrier frames, RSSI -47 to -88 dBm, presence + breathing + heart rate, multi-node mesh up to 6 nodes per room.
Prerequisites
pip install esptoolv2/rust-toolchain.toml)Fast path: pre-built binaries (recommended)
If you just want it to work, skip the firmware build. Download v0.6.6-esp32 release binaries:
bootloader.bin,partition-table.bin,ota_data_initial.bin,esp32-csi-node.bin(for 8 MB boards), plusesp32-csi-node-4mb.binandpartition-table-4mb.bin(for 4 MB boards)Step 1: Clone the repository
git clone https://github.com/ruvnet/RuView.git cd RuViewStep 2: Build the sensing server (Rust)
The sensing server is the new primary aggregator + REST/WS surface. The standalone
aggregatorbinary from the original tutorial still exists as a packet-inspection tool but doesn't expose the dashboard.cd v2 cargo build -p wifi-densepose-sensing-server --releaseBinary:
v2/target/release/sensing-server(orsensing-server.exeon Windows). First build takes ~5–10 minutes.Step 3: Build the firmware (skip if using release binaries)
The repo provides a Docker build that works on Linux, macOS, and Windows (Git Bash) without any local ESP-IDF setup:
Build output appears in
build/(~1426 compilation steps, takes 3–5 minutes first time, ~30 s incremental).Output files needed for flashing:
build/bootloader/bootloader.binbuild/partition_table/partition-table.binbuild/ota_data_initial.binbuild/esp32-csi-node.binStep 4: Find your serial port
COM7ls /dev/ttyUSB*orls /dev/ttyACM*/dev/ttyUSB0ls /dev/cu.usbserial*orls /dev/cu.SLAB_USBtoUART/dev/cu.SLAB_USBtoUARTStep 5: Flash the firmware
8 MB boards (DevKitC-1, most WROOM variants):
4 MB boards (SuperMini 4 MB):
Replace
COM7with your serial port. Expected output ends with:Step 6: Provision WiFi credentials via
provision.pyFlags:
--target-ip— IP of the machine runningsensing-server--node-id 1— unique ID per board (1, 2, 3 for multi-node)--edge-tier 2— full pipeline (vitals + presence + fall detection). Use0for raw passthrough,1for basic DSP only--force-partial— keeps any NVS keys you didn't pass on the CLI (the issue provision.py: esptool v5 incompat + NVS partition wipes existing keys when partial update #391 footgun fix; safe default unless you want a hard reset)Step 7: (Optional) Provision an OTA PSK
python firmware/esp32-csi-node/provision.py --port COM7 \ --ota-psk "$(python -c 'import secrets; print(secrets.token_hex(32))')" \ --force-partialSave the printed PSK somewhere safe. Future OTA pushes need it in the
Authorization: Bearer <psk>header.Step 8: Open firewall (Windows only)
Elevated PowerShell:
Linux (
ufw):Step 9: Start the sensing server + dashboard
cd v2 cargo run -p wifi-densepose-sensing-server --release -- \ --source esp32 \ --udp-port 5005 \ --http-port 3000 \ --ws-port 3001 \ --ui-path ../uiOpen the dashboard: http://localhost:3000/ui/index.html
If the dashboard's "Sensing" tab shows
LIVE — ESP32 HARDWARE Connected, frames are flowing. If it showsDISCONNECTED:Step 10: Verify what you can see
Walk near the ESP32 and watch:
For pose-related data, see the honest caveats section below.
Step 11: Multi-node mesh (optional)
Repeat Steps 5–6 for additional ESP32-S3 boards with different
--node-idvalues. Spread nodes around the room diagonally (not in a row) for geometric diversity — multistatic fusion needs that. Recommended: 3–6 nodes per room, 2–3 m apart, ~1 m off the floor, not pressed against walls.All nodes UDP-stream to the same
sensing-serverinstance. The server auto-detects new nodes and adds them to the active mesh.Honest caveats (read before reporting bugs)
n_personsis a slot-capacity heuristic, not a learned classifier. Documented infirmware/esp32-csi-node/README.mdunder "What this firmware does NOT do (Tier 2 caveats)". Auto-calibrating fix in flight: #491.Architecture
ADR-018 binary frame format
Troubleshooting
115200***ERROR*** A stack overflow in task Tmr Svcprovision.pywith--ssid/--password(do NOT editsdkconfig.defaults)total: 1despite 3 nodesnode_idclobber bugedge-tier 2panics / reboots in a loop (N16R8/PSRAM boards)--subk-countto 4, or wait for #491Related links
If something in this tutorial is wrong against current main, please open a new issue — these tutorials get updates as the project evolves.