osmosdr¶
Overview¶
A USB SDR after HackRF’s model, for osmosdr and GNU Radio hosts: vendor
control requests set the radio, bulk endpoints carry the samples, one
direction at a time. The protocol is in src/esdr_proto.h, shared with
the host tool tools/esdr_usb.c.
Receive: gapless I/Q at 250, 125, 62.5, 31.25 or 15.625 kS/s, 8 or 16 bit. The dump engine runs in circular mode through three SRAM banks at 16 MS/s; a two-stage decimating FIR on the vector unit filters every bank (
esp_sdr_ring_run(), ported from upstream esp-sdr). It owns CPU 1 with that CPU’s interrupts masked; USB, Wi-Fi and the shell run on CPU 0.Offset tuning (default): the LO sits 4 MHz below the centre and the ring mixes the stream down, so the LO leakage and the 1/f noise stay out of the band. The LO is whole MHz plus a kHz PLL offset; a rotation of the output removes the rest, so the centre is exact to the Hz, after the crystal correction.
Transmit: the host’s samples go to the DAC backend (PSRAM ring, linear interpolation to 40 MS/s, gapless loop playback). USB flow control paces the host about 50 ms ahead of the air.
USB: the SDR’s vendor interface, CDC-ACM shell, DFU (MCUboot).
Building and flashing¶
west build --sysbuild -b xiao_esp32s3/esp32s3/procpu esp-sdr-zephyr/apps/osmosdr
west flash
The app uses the same USB IDs as the other apps (2fe3:0005, host udev rules
as in apps/stream/README.rst); hosts find it by its vendor
interface and the GET_INFO magic.
The three ring banks leave little internal RAM: sample blocks, filter
buffers, the transmit backend’s buffers and the thread stacks live in
ESP_SDR_HIGH_RAM (doc/hardware-quirks.md, Memory).
The first flash goes over USB-Serial-JTAG. From then on the app owns the USB port, so RTS/DTR can no longer reset the chip into the ROM bootloader. Update over DFU instead:
west dfu -s <usb serial>
west dfu (scripts/west_commands/dfu.py, build directory build unless
-d names another, -s needed with several boards) detaches the app into
DFU, downloads the image, waits for MCUboot to test-boot it, checks that its
shell keeps answering and only then confirms it (mcuboot confirm). An image
that is never confirmed is reset by a watchdog after about 30 s and MCUboot
reverts to the previous one, so a broken update never needs a replug.
CONFIG_APP_USB_START_DELAY_MS keeps USB-Serial-JTAG alive for a few
seconds after reset for recovery builds.
Protocol¶
Vendor requests to the device (bmRequestType 0x40 / 0xc0), all values
little endian:
Request |
Value / data |
|---|---|
|
|
|
off 0, receive 1, transmit 2; throughput tests 0x10 (in), 0x11 (out) |
|
centre in Hz, 8 bytes |
|
Hz, 4 bytes; the nearest supported rate is taken |
|
PHY gain index 0 to 82 (not dB), or 0xffff for the hardware AGC |
|
power step 0 (weakest) to 17 |
|
analog low-pass in Hz (13 to 69 MHz two-sided), 0 for the PHY’s |
|
8 or 16 bits per I and Q item |
|
crystal correction in ppb, 4 bytes signed |
|
settings as applied ( |
|
bit 0: offset tuning (default on) |
|
dB in steps of 6 up to 42, or 0xffff: 24 dB at 8 bit, 0 at 16 |
Receive data comes in 4096-byte blocks on bulk IN: a 16-byte header
(magic EBLK, flags, sample count, index of the first sample on the
stream’s time axis) and interleaved I/Q. A jump in the index is a gap (the
host did not read in time, or a retune restarted the ring); the host fills
it with zeros to keep the time axis. Transmit data is plain interleaved I/Q
on bulk OUT.
Changing a setting while streaming restarts the ring (a gap of about 4 ms for a retune) or the transmit session.
Host tool¶
cc -O2 -Iesp-sdr-zephyr/apps/osmosdr/src esp-sdr-zephyr/apps/osmosdr/tools/esdr_usb.c \
-lusb-1.0 -lm -o esdr_usb
./esdr_usb -s <serial> info
./esdr_usb -s <serial> test-in # USB throughput in; test-out for out
./esdr_usb -s <serial> rx -f 2450e6 -r 250e3 -g 32 -b 16 -t 10 -o rx.cs16
./esdr_usb -s <serial> tx -f 2450e6 -r 250e3 -G 4 -T 50e3 -a 0.3 -t 10
SoapySDR, GNU Radio, gqrx¶
soapy/ holds a SoapySDR module (driver=esdr) over the same protocol:
receive and transmit (half duplex: activating one direction stops the
other), CS8, CS16 and CF32, gains RF (receive index) and TX (power
step), frequency correction in ppm. gr-osmosdr reaches it through its
soapy backend, which is how GNU Radio and gqrx use it.
cmake -S esp-sdr-zephyr/apps/osmosdr/soapy -B build-soapy && cmake --build build-soapy
export SOAPY_SDR_PLUGIN_PATH=$PWD/build-soapy
SoapySDRUtil --find="driver=esdr"
gqrx # device string: soapy=0,driver=esdr (add ,serial=<serial> for one board)
port=/dev/... in the device string selects the serial link instead, for the ESP32-C6’s
osmosdr_serial app.
In GNU Radio: osmosdr.source(args="numchan=1 soapy=0,driver=esdr") and
osmosdr.sink(...) with the same string, at one of the receive rates
(250 kS/s down to 15.625 kS/s; other requests get the nearest one, which
getSampleRate() reports). Changing a setting while streaming restarts
the device’s stream: expect a few ms of zeros.
Spectrum/waterfall viewer¶
tools/esdr_scope.py is a small standalone GNU Radio + PyQt5 app: one
window, a frequency spectrum above a waterfall, with on-screen frequency,
sample rate, gain and ppm correction controls. It talks to the board
through the SoapySDR module above (build that first).
esp-sdr-zephyr/apps/osmosdr/tools/esdr_scope.sh --serial <serial> \
--freq 2450e6 --rate 250e3 --gain 32 --corr -7.5
esdr_scope.sh runs the script with GNU Radio’s own Python (PyQt5 and
gnuradio.qtgui live there) and the Qt platform plugin path, both found
from the gnuradio-companion wrapper nixpkgs builds, so it needs no
changes to the project’s shell.nix.
Measured¶
Two XIAO ESP32S3 boards cabled through a 30 dB attenuator.
USB full speed, vendor bulk: 1.16 MB/s in, 1.18 MB/s out.
Receive 250 kS/s, 16 bit (1.0 MB/s) and 8 bit, with fine tuning: 60 s, 15.0 M samples, no gaps. The filter takes 12.7 to 13.3 of the 15 cycles per input pair CPU 1 has at 16 MS/s (
sdr benchbreaks it down).Transmit 250 kS/s from the host: 66 s, nothing dropped, no underruns. A tone from one board lands where expected on the other (after the 7.5 ppm crystal correction between the two boards, to within their drift of about 100 Hz). The other board’s carrier leak is about 40 dB down, the image about 43 dB.
Receive to transmit and back on one board: no errors.
GNU Radio 3.10 through gr-osmosdr’s soapy backend: 20 s at 250 and 125 kS/s with no gaps (the module logs its counters when a stream stops); transmit a
sig_sourcetone for 7 s, nothing dropped.SoapySDRUtil --ratemeasures 0.24 MS/s (start included).
Limitations¶
USB full speed caps receive at 250 kS/s (the filter allows no less than 64 times decimation); 500 kS/s at 8 bit would fit USB but needs a filter stage with an odd window step.
Transmit interpolates linearly: images at multiples of the sample rate are only attenuated by the interpolator’s sinc squared response.
Gains are uncalibrated PHY indices; the crystal error (several ppm) needs
SET_FREQ_CORR.No flash writes while streaming: the ring CPU runs with interrupts masked. DFU stops the stream first.