Skip to content

⭐ Tutorial: ESP32-S3 CSI Pipeline End-to-End Setup (ADR-018) #34

@ruvnet

Description

@ruvnet

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

Component Version Install
ESP32-S3 board 8 MB or 4 MB flash DevKitC-1, WROOM, XIAO, SuperMini — any ESP32-S3
USB-C cable data + power
WiFi network 2.4 GHz any standard AP
Docker Desktop 28.x+ https://www.docker.com/products/docker-desktop (Windows recommended)
esptool 5.x+ pip install esptool
Rust toolchain 1.89+ https://rustup.rs (pinned via v2/rust-toolchain.toml)
CP210x driver latest https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers

Fast path: pre-built binaries (recommended)

If you just want it to work, skip the firmware build. Download v0.6.6-esp32 release binaries:

  1. Go to https://github.com/ruvnet/RuView/releases/tag/v0.6.6-esp32
  2. 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)
  3. 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):

sudo ufw allow 5005/udp

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.

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentation

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions