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/wifiroute 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’sCsiProfile::extra_wifi_modesor over the serial console directly. Andconfig/csi-outputis 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"}'
| Mode | WebSocket | Parquet dump |
|---|---|---|
stream (default) | yes | no |
dump | /ws returns 403 | yes |
both | yes | yes |
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 tostream, 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.parquetnext 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.