IEEE 802.15.4 on esp-sdr-zephyr¶
A software 2.4 GHz O-QPSK radio for the ESP32-S3, which has no 802.15.4 hardware: the Wi-Fi receiver’s I/Q capture is the input, the Wi-Fi DAC is the output.
Status¶
Runs on hardware against the native radio of an ESP32-C6, both ways.
Channels 11 to 25: 200 of 200 test frames with 100-byte payloads in each direction on channels 11, 15 and 20, over a coax cable, and 50 of 50 ACKs received by the S3 for frames that asked for one.
apps/wpanselects the C6’s U.FL port (CONFIG_XIAO_ESP32C6_EXT_ANTENNA=y).Channel 26 from the C6 does not decode (parked). The C6’s channel 26 waveform carries the right chips at the right timing and power, but its one-chip phase steps are scrambled: 25 to 30 % of the hard chip decisions are wrong, a carrier line sits 6 to 8 dB below the modulation, and the spectrum is wider. No linear, I/Q imbalance, modulation index, limiter or time-varying model explains it. Soft differential despreading decodes the captures, with the margin of a weak signal. S3 to C6 works on channel 26. Next steps: a soft despreader in the PHY, a capture with the S3’s LO moved to rule out the receiver, and a look at what the C6 changes at 2480 MHz (its RF test library’s
phy_2480m_optis an empty stub). Captures and analysis scripts are kept outside the repository.ACKs: frames to the S3’s address that ask for one are acknowledged 192 us after their end (ieee802154-ack.md).
Not done: CCA and energy detection (CCA always reports clear), address filtering, RSSI, a calibrated TX power (dBm is mapped onto the vendor ladder with +20 dBm assumed at the top), and the ESP32-C6 as a software radio (the library has no C6 transmit path yet, and C6 captures are 80 MS/s bursts of 205 us only).
The pieces¶
File |
Role |
|---|---|
|
Portable PHY: RX discriminator, despreader, frame state machine; reference TX modulator; CRC-16. No Zephyr dependencies. |
|
The driver’s signal processing as portable inline C, shared with the unit test: DAC words from slot templates, the receive mix and decimation, and the measurement helpers. |
|
The |
|
The |
|
Unit tests (twister, native_sim) of the PHY and of the driver’s chain. With |
Public API of the PHY (all sample buffers are interleaved int16 I/Q at 4 MS/s):
Function |
Purpose |
|---|---|
|
Builds the 16 chip sequences and their discriminator-domain references. Call once. |
|
Registers the callback that receives each CRC-valid PSDU (including FCS), its length and an LQI. |
|
Feeds the next n complex samples. Blocks must be consecutive; state carries across calls. |
|
Drops a frame in progress, before a block that does not follow the last one. |
|
Registers a callback for frames coming in: length and predicted end at the PHR, then each further byte while it returns true (the driver’s ACK decision). |
|
Samples fed so far; in a callback, up to the sample being decided. |
|
Writes SHR + PHR + PSDU as I/Q. Needs (6 + len) x 128 + 4 samples; returns 0 if |
|
Appends the little-endian FCS; returns the new length. |
|
The 32 chips of a symbol, chip c0 in the MSB. |
The driver¶
Tuning. The LO sits 4 MHz below the channel, so its leakage and the DC offset stay out of the band.
Receive, gapless (
CONFIG_ESP_SDR_IEEE802154_RING, default withCONFIG_ESP_SDR_RINGand SMP): the 16 MS/s ring on CPU 1 takes every fourth pair at the phase where the mix by -fs/4 is 1 (summing four, the boxcar, costs about 14 cycles per pair, over the budget at 16 MS/s, and is 6 dB better in noise;CONFIG_ESP_SDR_RING_BOX4, selected by the driver, builds both) into a sample ring in internal RAM, and the PHY thread drains it on CPU 0. The PHY runs at about 52 cycles per sample, against a budget of 60. The ring runs in units of 1024 pairs (64 us): the PHY decides a frame about 120 us after its end. With ACKs on it rotates through two banks and keeps the third for the ACK waveform.Receive, burst (without the ring): 1 ms bank captures at 16 MS/s, a 27-tap FIR and decimation by 4. Only frames inside one capture decode: with the test app, payloads of 8 bytes or less.
Transmit:
esp_sdr_tx_dac_play_gen(), a generator mode of the DAC backend, plays the frame gapless at 40 MS/s from 18 half-sine slot templates, one chip period each, with the +4 MHz offset included. A transmission stops the ring and restarts it afterwards. A block that is filled late ends the session with silence and the call returns-EIO.ACK reception: a frame with the AR bit makes
tx()wait for its ACK (IEEE802154_HW_TX_RX_ACK). The generator session ends at the frame’s last sample, the front end is back in RX 50 us later, and one 1 ms bank capture then holds an ACK sent after the 192 us turnaround. No ACK returns-ENOMSG. ACKs are consumed by the driver; the L2 never sees them.Without
CONFIG_NET_L2_IEEE802154the driver is a plain device with a frame callback (ieee154_esp_sdr_set_rx_cb()): raw 802.15.4 mode would compile out the interface layer the Wi-Fi driver needs. The callback runs on the PHY thread with the PHY lock held and must not call back into the driver.Diagnostics:
ieee154_esp_sdr_set_invert()conjugates RX (after the mix) or TX, to check a peer’s I/Q convention;ieee154_esp_sdr_measure()and_dump()capture 1 ms at the current channel (carrier offset, power, chip phase step histogram, or the samples themselves for the host); a breadcrumb trail and the PCs of a stalled CPU 0 are kept in RTC memory across resets (wpan crash).Memory. The ring build keeps the driver’s stacks and tables in
ESP_SDR_HIGH_RAM(hardware-quirks.md, “Memory”).
Test app¶
apps/wpan (see its README) runs the same wpan shell on the S3 software radio and on the C6 native radio; apps/wpan/tools/interop.py runs frames both ways between them, the regression test is in testing.md.
How the PHY works¶
The receiver treats half-sine O-QPSK as MSK: each chip period the carrier phase turns by plus or minus 90 degrees. A one-chip phase-difference detector therefore recovers the chips without carrier recovery, and a carrier offset only biases the phase step slightly.
RX chain, per sample at 4 MS/s (2 samples per chip):
DC removal. A running mean (time constant 256 samples) is subtracted. It updates only while both trackers are hunting, because MSK has energy at DC.
Discriminator.
d = Im(x[n] * conj(x[n-2])), two multiplies and a subtract. Its sign is one hard decision per chip.Two phase trackers. Samples alternate between tracker 0 and tracker 1. One sees chip-aligned phase steps; the other sees half-aligned steps and mostly fails. Whichever decodes a valid frame first wins and resets the other.
Preamble hunt. A 32-bit shift register is compared to the symbol-0 reference by XOR and popcount. Four hits exactly 32 chips apart (at most 6 chip errors each) give symbol alignment.
SFD, PHR, PSDU. Further symbols are decided by minimum distance over all 16 references (at most 10 errors). The state machine expects more zeros, then symbols 7 and 10 (SFD 0xA7), the length byte, then the PSDU, low nibble first.
Frame check. CRC-16 (reflected polynomial 0x8408, initial value 0). On success the callback gets the PSDU and an LQI from the mean chip-error count.
Why the references are differential. The discriminator measures the change between adjacent chips, not the chips themselves. The reference for each symbol is therefore c ^ (c << 1) ^ MSK_MASK: the XOR of neighbouring chips plus a fixed parity pattern. The last bit of each symbol depends on the next symbol’s first chip, so it is masked out of every comparison (DMASK = 0xFFFFFFFE).
Chip tables. Only symbol 0 is stored (0xD9C3522E, chip c0 in the MSB). Symbols 1 to 7 are rotations by 4 chips each; symbols 8 to 15 are symbols 0 to 7 with the odd chips inverted. This matches the 802.15.4 2450 MHz table and avoids a hand-typed table.
TX modulator. ieee154_phy_modulate() turns each chip into a 4-sample half-sine pulse (0, 0.707, 1, 0.707). Even chips go on I and odd chips on Q; because chip k starts at sample 2k, Q is automatically one chip (2 samples) behind I. The output is a full frame buffer, at most 17,028 complex samples for a 127-byte PSDU. The driver does not use it on the air (see “The driver”); it is the reference for the host tests and for calibrating MSK_MASK.
Unit test results¶
The PHY decodes every frame from 6 dB SNR up, with carrier offsets up to 100 kHz (scripts/unit-tables.sh phy, 300 frames per point, DC offset 100 LSB):
SNR dB |
PER at 0 Hz |
PER at 50 kHz |
PER at 100 kHz |
|---|---|---|---|
0 |
0.993 |
0.990 |
0.993 |
2 |
0.337 |
0.390 |
0.550 |
4 |
0.007 |
0.003 |
0.010 |
6 to 16 |
0.000 |
0.000 |
0.000 |
No frame was delivered twice or with wrong content. SNR is measured over the full 4 MHz sample bandwidth, so the in-band SNR is roughly 3 dB higher. scripts/unit-tables.sh dsp runs the driver’s chain (DAC words, resampling to 16 MS/s, delay, carrier offset, DC, noise, 10-bit receive words, mix and decimation, PHY): all frames decode down to -2 dB SNR over 16 MHz, and the boxcar costs about 2 dB at the threshold.
Sign convention (MSK_MASK). The noiseless calibration test (test_calibration_noiseless) decides it: of the four candidates only 0xAAAAAAAA decodes (PER 0%), the other three lose every frame. It is the default in the source and is confirmed on the air against the C6.
Open¶
Status above lists what is missing. Sending ACKs: ieee802154-ack.md. Plans: a reference radio in zigbit-reference.md. Also open:
Wireshark sniffer. Planned shape: a
wpanmode that streams each CRC-valid frame as a binary record (timestamp, channel, RSSI, LQI, PSDU with FCS) over its own CDC-ACM port, and a Wireshark extcap script on the PC that writes pcap withLINKTYPE_IEEE802_15_4_TAP(283), so channel, RSSI and LQI show as TAP fields. Zigbee decoding is then a Wireshark setting (network key). Promiscuous receive needs no ACKs.Zephyr L2 and OpenThread. The L2 build compiles; untested on the air.
Sampling clock drift was only simulated (40 ppm over 4.3 ms is about 0.3 samples).