Make RTL2832U SDR dongles first-class peripherals on ESP32-P4 — continuous I/Q over USB Host, with a real embedded driver API.
Not a librtlsdr port. Clean-room Blog V4 USB profile · PC-captured Nooelec SMArt v5 (P4 acceptance pending) · provisional Blog V3 stream · stand-alone ESP-IDF component · fail-closed lifecycle
Status authority: PROJECT_TRUTH.md wins if anything here disagrees. This is 0.9.3, pre-1.0 — early, public, honest. See the 0.9.3 release notes for the capture-derived Nooelec profile and its remaining hardware acceptance checks.
Release numbers and alpha / beta / rc meanings are defined in
docs/VERSIONING.md.
Most microcontroller “RTL-SDR” work falls into one of two traps:
| Typical approach | What goes wrong on an MCU |
|---|---|
| Port / embed librtlsdr | Desktop library assumptions (threads, heap, USB stack). Hard to own as a small component; GPL gravity; opaque lifecycle. |
| One-off sketch | Works once in a demo; no clear start/stop; no metrics when USB starves; no way to know what the board can sustain. |
esp_rtl_sdr is built for a different goal:
An ESP32 should own, characterize, monitor, and adapt an RTL dongle as a native embedded peripheral — not pretend it’s a PC running GQRX.
That means:
-
A stable C API you can put next to FreeRTOS tasks and UI code
-
Fail-closed behavior (no “half-open USB” after a failed start)
-
Capability flags so apps don’t assume gain/bias/HF without CAP bits
-
Health + metrics (is USB starving? is the app too slow? RF clipping?)
-
Rate passport — probe which sample rates this host + stick actually sustain
-
Intent presets (
NEED_FM,NEED_ADSB, …) so apps speak missions, not only registers -
Profiles (Blog V4 first) so more dongles can be added without rewriting the core
Board stuff (display, Ethernet, VBUS, audio) stays in your app. This component is the radio USB path only.
Longer vision: docs/VISION.md.
Desktop drivers optimize for “open stick, set knobs, dump I/Q to the PC.” On a P4, the host USB and RAM path is the hard part. We lean into that:
| Desktop / sketch style | esp_rtl_sdr |
|---|---|
| Assume the USB bus always keeps up | Measure effective SPS, overruns, drops |
| Fixed rate list or “set and hope” | Windows + quantize + optional on-device passport |
| Call set_freq from anywhere | Hot retune drains bulk before EP0; async from callbacks |
| Opaque internals | Explicit state machine, error codes, reentrancy rules |
| “Supports every RTL” marketing | Fail closed on unknown sticks; one measured profile first |
| App figures out “is RF dead?” | Health narrative (USB / app / RF clip / weak) |
We are not chasing full librtlsdr feature parity (tuner IF filter still open). We are chasing a driver that is safe to live inside a real FreeRTOS product.
| Item | Notes |
|---|---|
| MCU | ESP32-P4 with High-Speed USB Host (e.g. M5Stack Tab5, Waveshare P4 kit) |
| Dongle | RTL-SDR Blog V4 (primary) — RTLSDRBlog / Blog V4. 0.9.x also supports the Blog V4L (R828S; 28.8 MHz HF upconverter, optional direct route for 24-28.8 MHz) and Blog V3/V3c (R820T2 0x34 remap; direct-Q HF). This branch implements Nooelec NESDR SMArt v5 from first-party 2026-09-30 captures, with P4 acceptance pending. Bare 0bda:2838 is never assumed V4. |
| Tooling | ESP-IDF ≥ 5.5 with esp32p4 support (OrcSDR Tab5 uses 5.5.4) |
| Antenna | For RF; compile/smoke works without RF |
Not claimed yet: ESP32-S2/S3 Full-Speed hosts, random eBay RTL sticks, production warranty.
Why that is intentional: docs/SCOPE.md.
Fastest path to “does it build and talk USB?”
git clone https://github.com/hardcoreerik/esp-rtl-sdr.git
cd esp-rtl-sdr/examples/p4_serial_smoke
idf.py set-target esp32p4
idf.py build
idf.py -p PORT flash monitorPlug the Blog V4 into the P4 USB Host port (not the flash/UART port).
- No dongle: helpers + install should run;
start→NO_DEVICEis OK. - With dongle: stream, optional
read(), health logs, passport if the example runs it.
More: examples/p4_serial_smoke/README.md.
Option A — path next to your project
# your project CMakeLists.txt (before project())
set(EXTRA_COMPONENT_DIRS "/path/to/esp-rtl-sdr")Component name is the folder name of that path. Prefer the smoke example’s pattern (stable name esp_rtl_sdr via a thin wrapper under examples/p4_serial_smoke/components/).
Option B — git submodule
git submodule add https://github.com/hardcoreerik/esp-rtl-sdr.git components/esp_rtl_sdrThen REQUIRES esp_rtl_sdr from your app component (folder name must match).
#include "esp_rtl_sdr.h"
static void on_sdr(esp_rtl_sdr_event_t ev, const void *payload, void *ctx)
{
if (ev == ESP_RTL_SDR_EVT_IQ_BLOCK) {
const esp_rtl_sdr_iq_block_t *iq = payload;
/* iq->data: CU8 interleaved I,Q — valid only until callback returns */
(void)iq;
}
if (ev == ESP_RTL_SDR_EVT_RETUNED) {
/* LO applied (including after async retune from a callback) */
}
/* Do not call start/stop/uninstall from here on the same handle */
}
void app_start_sdr(void)
{
esp_rtl_sdr_config_t cfg;
esp_rtl_sdr_config_default(&cfg);
cfg.event_cb = on_sdr;
esp_rtl_sdr_handle_t sdr = NULL;
ESP_ERROR_CHECK(esp_rtl_sdr_install(&cfg, &sdr));
/* Mission preset: preferred rate/LO (does not start streaming) */
ESP_ERROR_CHECK(esp_rtl_sdr_apply_need(sdr, ESP_RTL_SDR_NEED_FM));
/* Or explicit: */
/* esp_rtl_sdr_set_center_freq(sdr, 100100000); */
/* esp_rtl_sdr_set_sample_rate(sdr, ESP_RTL_SDR_RATE_960K); */
ESP_ERROR_CHECK(esp_rtl_sdr_start_hz(sdr, 0, 0)); /* 0 = use preferred */
/* Later: */
/* esp_rtl_sdr_retune_hz(sdr, 162400000); */
/* esp_rtl_sdr_stop(sdr, 0); */
/* esp_rtl_sdr_uninstall(sdr); */
}Works with or without an event callback:
uint8_t buf[16384];
size_t n = 0;
esp_err_t err = esp_rtl_sdr_read(sdr, buf, sizeof(buf), 1000, &n);
if (err == ESP_OK && n > 0) {
/* process n bytes of CU8 IQ */
}uint32_t caps = esp_rtl_sdr_get_capabilities();
if (caps & ESP_RTL_SDR_CAP_STREAM) { /* start/stop OK to attempt */ }
if (caps & ESP_RTL_SDR_CAP_GAIN) {
/* 0.7.5+: manual gain after start */
}
if (caps & ESP_RTL_SDR_CAP_GAIN_AUTO) {
/* 0.7.8+: set_tuner_gain_mode(AUTO) — measured Tuner AGC */
}Full API reference (params, returns, examples): docs/API_REFERENCE.md
Design contract: docs/API.md · header: include/esp_rtl_sdr.h.
| API | Purpose |
|---|---|
esp_rtl_sdr_config_default / config_validate |
Safe defaults + checks |
esp_rtl_sdr_install / uninstall |
Create / destroy handle + USB client |
esp_rtl_sdr_start / start_hz / stop |
Stream on/off |
esp_rtl_sdr_reset |
Clear FAULT → IDLE when not streaming |
esp_rtl_sdr_get_state / state_to_name |
IDLE · STARTING · STREAMING · … |
| API | Purpose |
|---|---|
set/get_center_freq · retune_hz |
LO (async if called from event callback) |
set/get_sample_rate · quantize_sample_rate |
Allowlisted windows → exact programmed SPS |
is_rate_supported · get_supported_rates |
Policy / UI lists |
set/get_freq_correction |
Software ppm LO offset (±200) |
apply_need |
NEED_FM · NEED_ADSB · NEED_WX · NEED_HF · NEED_MAX_STABLE · NEED_LISTEN |
| HF LO map | RF < 28.8 MHz → tuner RF+28.8 MHz (CAP_HF_UPCONVERTER, 0.7.7+) |
| API / event | Purpose |
|---|---|
EVT_IQ_BLOCK |
Async CU8 I/Q (borrowed pointer) |
esp_rtl_sdr_read |
Blocking sync pull ring |
| Format | Unsigned interleaved I/Q (I0,Q0,I1,Q1,…) |
| API / event | Purpose |
|---|---|
get_metrics |
Bytes, blocks, overruns, consumer drops, effective SPS, sample min/max |
get_health · EVT_HEALTH |
USB starving / app slow / RF clip / weak + short advice |
probe_rates · get_rate_passport |
On-host rate stability matrix |
| API | Purpose |
|---|---|
refresh_device_list · get_device_count · get_device_at |
Candidates |
select_device · select_device_serial |
Choose Blog V4 by index/serial |
| API | Today |
|---|---|
set/get_tuner_gain · get_tuner_gains |
Manual ladder 0.0…49.6 dB (CAP_GAIN); need claimed stream |
set_tuner_gain_mode(MANUAL|AUTO) |
MANUAL ladder; AUTO measured R828D AGC (CAP_GAIN_AUTO, 0.7.8+). Streaming = async EP0. |
set/get_rtl_agc |
RTL2832 digital AGC (CAP_RTL_AGC); not tuner AUTO. get = requested shadow, not readback. |
set/get_bias_tee |
Measured SYS EP0 (CAP_BIAS_TEE); need claimed stream |
| HF/LF (unreleased) | Blog V4 accepts 24 kHz…1766 MHz with exact-Hz requests; RF<28.8 MHz uses +28.8 MHz LO and Cable-2 (CAP_HF_UPCONVERTER). Blog V3/V3c uses capture-derived Q-branch direct sampling below 24 MHz (CAP_DIRECT_SAMPLING); its tuner gain is bypassed there. Nooelec independently uses Q below the captured 24 MHz cutoff, accepts 100 kHz…1750 MHz, and has no bias tee or upconverter; the 24 MHz PC native-boundary tune warned of no PLL lock (rated native floor 25 MHz). Nooelec P4 RF acceptance remains open. |
Evidence: docs/PHASE3_CAPTURE_REPORT.md, docs/AGC_IF_CAPTURE.md, and the checked-in clean-room transfer table. The 0.7.15 routing composition is host/build verified only; P4 GPIO and RF acceptance remain open.
| Macro | Use |
|---|---|
ESP_RTL_SDR_RATE_960K |
FM-class continuous (provenance path) |
ESP_RTL_SDR_RATE_2048K |
ADS-B-class (provenance path) |
| Others in allowlist | See docs/RATES.md; low band starts at 225001 Hz |
install → IDLE
│ start()
▼
STARTING ──fail──► IDLE / FAULT
│ ok
▼
STREAMING ←── retune_hz / set_center_freq (queued if from callback)
│ stop()
▼
IDLE
│ uninstall()
▼
destroyed
Rules of thumb
- One owner task for
uninstall. - Event callbacks: OK to call
retune_hz/get_*/readcarefully; don’tstart/stop/uninstallon the same handle. - Check
get_capabilities()before assuming advanced features. - Prefer
config_default+ set fields;struct_sizesupports append-only growth.
| Works | Not yet |
|---|---|
| Blog V4 stream + retune + metrics on P4 | Tuner IF / SDR# Bandwidth EP0 (USB-silent) |
| Continuous rates + passport API | “Any RTL2832U” |
| Manual gain + bias CAP (0.7.5+) | Formal multi-hour soak artifact from this repo only |
| HF upconverter CAP (0.7.7) | IF / channel filter EP0 |
| CI: host tests + P4 compile of smoke app | librtlsdr drop-in ABI |
| Clean-room tables |
Details: PROJECT_TRUTH.md · story: docs/archive/DEVELOPMENT_NARRATIVE_0_7.md.
Created and maintained by Erik / hardcoreerik, with AI-assisted development (Claude Sonnet, OpenAI Codex, Grok Build) — same honesty model as TheOrc.
See docs/AI_DEVELOPMENT_DISCLOSURE.md.
| Doc | Purpose |
|---|---|
| PROJECT_TRUTH.md | What is true now |
| docs/API_REFERENCE.md | Full API reference (params / returns / examples) |
| docs/API.md | Design contract (invariants) |
| docs/EXAMPLES.md | Usage recipes |
| docs/TROUBLESHOOTING.md | Common failures |
| docs/KCONFIG.md | menuconfig |
| docs/SCOPE.md | Why P4 + Blog V4 only (for now) |
| docs/SOAK.md | Hardware soak procedure |
| docs/VISION.md | Product direction |
| architecture.md | Layering & threading |
| docs/RATES.md | Rate windows + passport |
| docs/TESTING_GUIDE.md | Automated tests + CI |
| docs/LAB_HOBBYIST.md | Desk lab + TinySA |
| docs/DRIVER_GAPS_VS_DESKTOP.md | Gaps vs SDR# / librtlsdr-class tools |
| docs/README.md | Full index |
| CHANGELOG.md | Releases |
| CONTRIBUTING.md | PRs / clean-room |
| LICENSING.md | AGPL + commercial process |
- OrcSDR — ESP32-P4 SDR application (optional consumer)
- TheOrc — local-first multi-agent tools (truth culture)
- This repo — driver only; builds without OrcSDR
AGPL-3.0-only by default. Commercial licensing available — LICENSING.md.
Try it. Break it. Open a truth: issue if we oversold something.
https://github.com/hardcoreerik/esp-rtl-sdr