# 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/wpan` selects 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_opt` is 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](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 | | --- | --- | | `lib/ieee802154/ieee154_phy.c`, `include/esp_sdr/ieee154_phy.h` | Portable PHY: RX discriminator, despreader, frame state machine; reference TX modulator; CRC-16. No Zephyr dependencies. | | `lib/ieee802154/ieee154_dsp.h` | 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. | | `lib/ieee802154/ieee154_esp_sdr.c`, `include/esp_sdr/ieee154_esp_sdr.h` | The `ieee802154_radio_api` driver (device `esp_sdr_154`, `CONFIG_ESP_SDR_IEEE802154`) and its diagnostics. | | `apps/wpan/` | The `wpan` shell: test frames and counters on the software radio (S3) or a native radio (C6). | | `tests/unit/ieee154_phy/`, `tests/unit/ieee154_dsp/` | Unit tests (twister, native_sim) of the PHY and of the driver's chain. With `IEEE154_TABLES` set they also print the PHY's error rate table and the driver chain's decimator table (`scripts/unit-tables.sh`). | Public API of the PHY (all sample buffers are interleaved int16 I/Q at 4 MS/s): | Function | Purpose | | --- | --- | | `ieee154_phy_init()` | Builds the 16 chip sequences and their discriminator-domain references. Call once. | | `ieee154_phy_set_rx_cb(cb, user)` | Registers the callback that receives each CRC-valid PSDU (including FCS), its length and an LQI. | | `ieee154_phy_rx_block(iq, n)` | Feeds the next n complex samples. Blocks must be consecutive; state carries across calls. | | `ieee154_phy_rx_reset()` | Drops a frame in progress, before a block that does not follow the last one. | | `ieee154_phy_set_hdr_cb(cb, user)` | 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). | | `ieee154_phy_rx_position()` | Samples fed so far; in a callback, up to the sample being decided. | | `ieee154_phy_modulate(psdu, len, amp, out, max)` | Writes SHR + PHR + PSDU as I/Q. Needs (6 + len) x 128 + 4 samples; returns 0 if `max` is too small. | | `ieee154_phy_append_fcs(buf, n)` | Appends the little-endian FCS; returns the new length. | | `ieee154_phy_chips(sym)` | 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 with `CONFIG_ESP_SDR_RING` and 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_IEEE802154`** the 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](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](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):** 1. **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. 2. **Discriminator.** `d = Im(x[n] * conj(x[n-2]))`, two multiplies and a subtract. Its sign is one hard decision per chip. 3. **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. 4. **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. 5. **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. 6. **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](ieee802154-ack.md). Plans: a reference radio in [zigbit-reference.md](zigbit-reference.md). Also open: - **Wireshark sniffer.** Planned shape: a `wpan` mode 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 with `LINKTYPE_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).