Troubleshooting & Calibration
Most CSI problems are not subtle. They are a channel mismatch, an acquisition setting, or a console that cannot carry what the radio captured. This section is ordered by how often each cause actually turns out to be the one, so working through it in order is usually faster than reasoning about it.
The second half covers calibration — the adjustments that make measurements comparable across time, across devices, and across rooms.
Start With the Counters
Before changing anything, find out which half of the system is failing. On a
device running esp-csi-cli-rs:
show-stats
It reports packets transmitted and received, rates in Hz, and drop counts. One distinction resolves most cases:
- The received count is low. The radio is not hearing the traffic. The problem is upstream: channel, band, acquisition configuration, distance, or an emitter that is not transmitting.
- The received count is fine but fewer rows reach you. The radio heard it and the delivery path lost it. The problem is downstream: console bandwidth, the async logging queue, or a filter.
From firmware, the same numbers are available as get_total_rx_packets,
get_pps_rx, get_dropped_packets_rx, and — specifically for the async logging
queue — get_log_packet_drops.
Note that drops include frames rejected by set-csi-filter. That is deliberate:
a filtered frame stays visible as a drop rather than vanishing, so the gap
between captured and delivered is never unexplained.
No CSI at All
Check the channel first. More first-run failures trace to this than to
everything else combined. Every node in a capture set must agree on the primary
channel, and the defaults across this ecosystem do not agree with each other:
esp-csi-cli-rs defaults to channel 1, the esp-csi-rs emitter examples to 7,
and esp-csi-litegui-rs to 1 while expecting an emitter that defaults to 7.
On a C5, check the band. The channel number selects it. The default is 149, which is 5 GHz; a 2.4 GHz AP is simply invisible from there and reports only “no access point found”.
Check that the emitter is actually radiating. An emitter reports no errors
when its frames are accepted by the driver, which is not the same as them
reaching the air. collector_sniffer reports CSI rate per source MAC, so
point a collector at the channel and look for the emitter’s MAC specifically —
that distinguishes “transmitting but unheard” from “not transmitting”. If the
emitter’s MAC never appears, try the associated-link arrangement (softAP
collector plus station) instead, which puts energy in the channel through the
ordinary association path rather than through raw injection.
Check that ambient traffic exists. A sniffer on a quiet channel correctly reports nothing. Move to a channel your own network uses, or add an emitter.
Too Few Packets
If the rate is well below the emitter’s configured frame rate, work down this list:
- Console bandwidth.
textmode emits twenty-odd lines per packet. Switch toarray-listorserializedand re-measure before concluding anything else. This is the most common cause by a wide margin. - Baud rate. Fixed at build time, which is why release artifacts publish it
in
manifest.json. A monitor at the wrong rate produces garbage, not a slow stream — but a low build-time baud produces exactly a slow stream. - Logging queue overflow. Under
async-print, the callback enqueues onto a 32-slot channel.get_log_packet_drops()counts what it could not enqueue. - Filters.
set-csi-filter --peer-macand--min-phy=htare doing their job; confirm they are filtering what you meant. - Emitter period.
--inject-period-ms=20is 50 frames per second, not 500.
If throughput is the binding constraint, the ESP-NOW fast simplex pair sustains a markedly higher rate than any other arrangement, because the collector stops transmitting once it hears a source and all airtime belongs to one transmitter.
The Subcarrier Count Is Wrong
csi_data_len is the most informative single number in a capture, because it
tells you which PHY actually produced the measurement:
| Samples | What it means |
|---|---|
| ~53 | Legacy 20 MHz, L-LTF only |
| ~56 | HT20, HT-LTF |
| ~117–128 | HT40 |
If you configured HT40 and see ~53, HT40 did not engage. In order of likelihood:
- Legacy and ACK acquisition are still on. The default
CsiConfigacquires everything, and those reports arrive at ambient rates and swamp the HT40 ones. The symptom is distinctive: the count sits at ~53 and the CSI rate tracks ambient traffic rather than the emitter’s period. Fix withset-csi --csi-legacy=off --csi-ht20=off --csi-ht40=on --dump-ack=off, oremitter::phy::ht_csi_acquisitionfrom firmware, which does the right thing on every chip. - No room in the band.
Ht40Aboveon channel 7 occupies up to channel 11;Ht40Belowoccupies down to channel 3. A primary too close to the edge silently falls back to 20 MHz. - The collector’s receive path is still 20 MHz. Setting a secondary channel is not the same as widening the interface bandwidth. The library does both together; hand-rolled configuration often does not.
If the count varies packet to packet, nothing is wrong — you are capturing a
mix of formats. Read data_format (the RxCSIFmt classification) per packet
and handle each layout, or filter with --min-phy=ht so only HT frames arrive.
Rows That Look Like Someone Else’s
A collector is promiscuous. It reports CSI for every frame it decodes, including beacons, ACKs, association exchanges, and any third-party device on the channel. Those rows are valid CSI and they look wrong next to yours in two specific ways: the leading field is the frame’s own 802.11 sequence number, which is per-transmitter and per-TID and so neither starts at zero nor shares a counter with your traffic; and legacy-rate frames carry the shorter L-LTF-only payload.
set-csi-filter --peer-mac=<emitter MAC> is the precise fix, --min-phy=ht the
looser one. Filtering on the device is also cheaper than filtering on the host,
because a rejected frame is dropped in the Wi-Fi callback before the packet copy
and before any formatting.
Host-Side Failures
412 Precondition Failed from csi-webserver means the firmware has not
been verified as esp-csi-cli-rs. Call GET /api/devices/{id}/info, then
POST /api/devices/{id}/control/reset if that does not clear it.
503 Service Unavailable means the device is not connected at all — check
the cable and the port.
A Parquet file that will not open was almost certainly not stopped cleanly. The footer is written when the session ends; a crash, a power loss, or an unplugged board leaves the file without one and any unflushed rows lost. Unplugging a board to end a capture is the most common way to lose a dataset.
Device ids that change between runs mean you are relying on port basenames.
Pin them with --device lab1=/dev/ttyUSB0, and key per-device state on the MAC
from info rather than on the path — that is what the MAC is published for.
Decoded frames that are nonsense usually mean a schema mismatch. The
serialized format carries no self-describing tag and there are two
CSIDataPacket schemas, one for the classic parts and one for C5/C6. A decoder
built for the wrong one produces plausible-looking garbage rather than an error.
Calibration
Diagnosis gets the capture working. Calibration makes it comparable.
Amplitude
Automatic gain control moves the entire CSI vector when the receiver changes gain, and that movement is indistinguishable from the environment changing unless you account for it. Three levers:
- Per-packet normalisation. Divide by the vector norm, or by RSSI. Removes the AGC step at the cost of any genuine absolute-power information.
- Manual scaling.
manu_scalewithshifton the classic parts, andval_scale_cfg(0–3, default 2) on the C5 and C6, fix the scaling instead of letting the radio choose. Fixed scaling makes packets comparable; it also means a strong signal can clip and a weak one can quantise badly. Worth it for controlled measurements, not for a survey. - A static baseline. Record the empty environment and normalise against it. This is the most effective calibration available for presence and motion, and the cheapest.
Phase
Everything in Signal Processing in Rust about CFO, SFO, and packet detection delay is a calibration problem. The practical sequence: unwrap along the subcarrier axis, fit and remove a linear trend per packet, and use the antenna ratio where multiple chains are available. Raw phase without this is not a measurement of the room.
Geometry
Node placement dominates results more than any software setting.
- Keep the geometry fixed across a measurement campaign. Moving a node by a metre changes the channel response as much as the activity you are trying to detect.
- Put the subject between the nodes. Line-of-sight obstruction produces far stronger signal than reflection alone.
- Separate the nodes. Boards close together see a dominant direct path that swamps the environmental component.
- Record the geometry with every dataset. A capture without positions is very difficult to reproduce or to compare against later.
Channel Selection
Prefer a quiet channel for controlled emitter/collector work: interference appears as variance you did not cause. In 2.4 GHz, channels 1, 6, and 11 are the non-overlapping set — pick the one your neighbours are not on. On a C5, the 5 GHz band is generally quieter and its wider channels give better delay resolution for impulse-response work.
Characterising, Not Just Fixing
When you need numbers rather than a working capture, the esp-csi-rs repository
carries formal measurement specifications under specs/, with the firmware in
experiments/. They are methodology documents — each defines what is measured,
how, and what counts as a pass — and they are worth reading before designing
your own comparison, because each one exists to avoid a specific unfair
comparison:
packet_drop_rate_test_spec.mdand its ESP-NOW variant. The key insight is that comparing a receive count against an assumed transmit rate is not a loss measurement — any pacing drift or transmitter stall misreports as loss. Carrying an explicit sequence number lets the receiver computedropped / (received + dropped)from what was actually sent.cpu_utilization_test_spec.md— CPU load as a function of offered traffic, separated from the cost of the CSI callback itself.heap_usage_test_spec.md— memory, with rules that make Rust’s static-BSS layout comparable against ESP-IDF’s single DRAM pool.power_consumption_test_spec.md— workload power asactive − idle, so the always-on radio and the USB bridge cancel out.binary_footprint_test_spec.md— flash footprint by role, decomposed by memory section and by crate.
The experiments/README.md in that repository is explicit that these are
characterisation harnesses and not usage examples. Do not start from them.
Summary
In this section we looked at:
- Reading the counters first, to separate a radio problem from a delivery problem.
- Channel and band mismatches as the dominant first-run failure, and the conflicting defaults that cause them.
- Using
csi_data_lento tell which PHY actually produced a measurement, and what to do when HT40 does not engage. - The host-side failure modes: the firmware gate, unfinalised Parquet files, and schema mismatches in the serialized stream.
- Calibrating amplitude, phase, and geometry so measurements stay comparable.
- The measurement specifications available when you need numbers rather than a fix.