Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Streaming to a Host

Two boards driven from two serial terminals works, and stops working the moment you want a third board, a recording, or a colleague watching from another machine. This section puts csi-webserver in front of the hardware and csi-webclient in front of that.

The boards must already be running esp-csi-cli-rs — the server drives them by issuing CLI commands over serial, and refuses anything it cannot identify as that firmware.

Start the Server

cargo install csi-webserver
csi-webserver

It binds 0.0.0.0:3000 by default and starts scanning for attached boards immediately, whether or not any are plugged in. Useful variations:

# Local only, faster serial, faster hotplug scanning.
csi-webserver --interface 127.0.0.1 --port 3000 \
              --baud-rate 921600 --scan-interval-ms 1000

# Pin friendly, stable ids instead of ttyUSB0 / ttyACM1.
csi-webserver --device emitter=/dev/ttyACM0 --device collector=/dev/ttyACM1

Those aliases are worth setting up early. Without them a device’s id is the sanitised port basename, which changes when boards are unplugged in a different order — and every endpoint is addressed by id.

Confirm the Devices

curl -sS "http://127.0.0.1:3000/api/devices"

Each entry reports its connection state and, separately, whether its firmware has been verified. Verification is not optional: on every serial connect the server runs an internal info exchange and looks for the ESP-CSI-CLI/<version> magic prefix. Until that succeeds, every command-dispatching endpoint returns 412 Precondition Failed.

If a board shows up connected but unverified, the recovery path is:

curl -sS "http://127.0.0.1:3000/api/devices/collector/info"
curl -sS -X POST "http://127.0.0.1:3000/api/devices/collector/control/reset"

On UART adapters the reset pulses RTS and re-verifies synchronously, so the response tells you whether it worked. On native USB-Serial-JTAG boards it sends the firmware’s own restart and returns immediately — poll /api/devices to watch it come back.

Configure and Run a Pairing

The same emitter/collector arrangement from the previous section, over HTTP instead of two terminals:

BASE=http://127.0.0.1:3000/api/devices

# Collector: sniffer on channel 6, no traffic generation.
curl -sS -X POST "$BASE/collector/config/wifi" \
  -H 'content-type: application/json' \
  -d '{"mode":"sniffer","channel":6}'
curl -sS -X POST "$BASE/collector/config/traffic" \
  -H 'content-type: application/json' \
  -d '{"frequency_hz":0}'

# Emitter: 20 MHz injection on the same channel.
curl -sS -X POST "$BASE/emitter/config/wifi" \
  -H 'content-type: application/json' \
  -d '{"mode":"ht20-emitter","channel":6,"inject_period_ms":20}'

# Start the collector first, then the emitter.
curl -sS -X POST "$BASE/collector/control/start"
curl -sS -X POST "$BASE/emitter/control/start"

Command endpoints answer { "success": true, "message": "..." }. A 400 means the body failed validation, a 503 means the device is not connected, and a 412 means it is not verified.

Two scope notes on the server. Its config/wifi route names five modes — station, sniffer, wifi-ap, ht20-emitter, ht40-emitter — and does not itself name the ESP-NOW ones the firmware still supports; those reach the device either through an embedder’s CsiProfile::extra_wifi_modes or over the serial console directly. And config/csi-output is documented ahead of its handler: the firmware-side contract is settled, but treat the HTTP path as provisional until it lands.

Watch the Stream

Each device has its own WebSocket, carrying only its own frames:

ws://127.0.0.1:3000/api/devices/collector/ws

The frames are the serialized format — COBS-framed postcard records described in CSI Data Formats. The server always runs devices in that format, which is why there is no log-mode setting over HTTP; it is the only format the server decodes.

Record to Parquet

Streaming is for watching. For anything you intend to analyse later, switch the device’s output mode:

curl -sS -X POST "$BASE/collector/config/output-mode" \
  -H 'content-type: application/json' -d '{"mode":"both"}'
ModeWebSocketParquet dump
stream (default)yesno
dump/ws returns 403yes
bothyesyes

The new mode applies on the next received frame. Files are named csi_dump_<id>_YYYYMMDD_HHmmss.parquet, one per session, so concurrent devices never collide. The server decodes into typed columns, so the result opens directly:

import polars as pl
df = pl.read_parquet("csi_dump_collector_20260621_120000.parquet")
print(df.schema)

One superset schema covers every chip, with per-family columns left null elsewhere — the 802.11n metadata is null on C5/C6 rows, the C5/C6 driver fields are null on ESP32-family rows, and the chip column tells you which apply.

Two timestamps per row mean different things. host_rx_time is the server’s wall clock; timestamp is the device’s microseconds-since-boot counter. Correlating measurements across two boards means using host_rx_time, because the device counters share no origin.

Stop the session cleanly. The Parquet footer is written when a session ends — on stop, a switch back to stream, a disconnect, or server shutdown. A crash or a yanked cable leaves the file without a footer, and such a file will not open at all. Unplugging a board to end a capture is the most common way to lose one.

curl -sS -X POST "$BASE/emitter/control/stop"
curl -sS -X POST "$BASE/collector/control/stop"

Use the Desktop Client

Everything above can be done by hand, and after the third board it stops being worth doing by hand. csi-webclient is a native desktop application that talks to the same server:

cargo run --release   # from a csi-webclient-rs checkout

Set the host and port in the top bar — 127.0.0.1:3000 by default — and click Connect. It polls for devices every couple of seconds, so boards appear and disappear as they are plugged in.

Four things it does that curl does not do comfortably:

  • Fleet control. Start All and Stop All, and multi-select for synchronized collection. Starting nodes one request at a time introduces exactly the skew a multi-receiver measurement is trying to avoid.
  • Pairing presets. The Devices tab applies a softAP lab pair, or an HT20/HT40 emitter plus sniffer, in one action — with the channel and mode on both ends already agreeing.
  • Local Parquet export. The Stream tab records to csi_export_{id}_YYYYMMDD_HHmmss.parquet next to you, rather than next to the server.
  • Configuration snapshots. Save and load device configuration as JSON, and copy a configuration from one device to another. Note that those files contain Wi-Fi passwords in plain text.

The five tabs are Devices (fleet overview and event log), Dashboard (status, firmware info, counters), Config (every configuration endpoint as a form), Control (start, stop, WebSocket connect), and Stream (counters, hex previews, recording). Selecting several devices drives the detail tabs side by side.

Embedding the Server Instead

If you are building a host application rather than operating one, the server is a library: csi-webserver-core gives you AppState, run_supervisor, and serve, plus build_router to mount the API inside your own Axum application and DeviceRegistry::attach to register a known port without hotplug discovery. See csi-webserver-core.

Where to Go Next

For captures with no host machine at all, continue to Viewing On-Device. For what to do with the Parquet files you are now producing, see Signal Processing in Rust.