Files
qtalker---/firmware/esp32p4-sensor-node/components/rtlsdr_experimental/rtlsdr_experimental.c
Indiana abd7174c9c feat(firmware): add experimental RTL-SDR USB-host module (Workstream J)
Self-contained ESP-IDF component (firmware/esp32p4-sensor-node/components/
rtlsdr_experimental/) exploring RTL2832U-over-USB-host on the ESP32-P4,
ported from frontend/src/lib/sdr.ts's researched WebUSB protocol sequence
(vendor commands, I2C-repeater tuner init) to the ESP-IDF USB Host Library.

Implements: USB Host Library install/client lifecycle, RTL2832U/Terratec
vendor-ID device matching, the demod+R820T init vendor-command sequence
over control transfers, a pipelined bulk-IN read loop for raw IQ, and an
inert-by-default upstream IQ-forwarding stub targeting a proposed separate
binary endpoint (not the JSON telemetry shape — reasoning documented in
the README) since no such backend endpoint exists yet.

Off by default (RTLSDR_EXP_ENABLE Kconfig, default n). Unverified against
real hardware and never compiled (no ESP-IDF toolchain in this
environment) — marked as such in every source file and in a dedicated
"Workstream J" section of firmware/esp32p4-sensor-node/README.md, which
this commit also creates since Workstream I's core skeleton (owned by a
separate, unmerged worktree) hadn't created one yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:10:21 +00:00

914 lines
39 KiB
C

/*
* rtlsdr_experimental.c — EXPERIMENTAL RTL2832U-over-USB-Host module.
*
* ============================================================================
* HARDWARE PASS REQUIRED — UNVERIFIED AGAINST REAL HARDWARE
* ============================================================================
* See rtlsdr_experimental.h for the full disclaimer and ../../README.md's
* "Workstream J" section for the complete honesty write-up. Short version:
* nobody working on this had physical ESP32-P4 or RTL2832U hardware, and
* this environment has no ESP-IDF toolchain to even compile against. Every
* function below is written to the *documented shape* of:
* (a) the ESP-IDF USB Host Library API (usb_host.h) — install/client/
* transfer lifecycle, callback dispatch model, control vs. bulk
* transfer submission, config/interface descriptor parsing helpers;
* (b) the RTL2832U + R820T vendor-command sequence already researched
* and documented in frontend/src/lib/sdr.ts (WebUSB driver for this
* same chipset, against public librtlsdr register docs).
* It has not been built or run. Register pokes, timing, and buffer sizing
* are carried over from sdr.ts's own "HARDWARE PASS REQUIRED" markings
* with no changes beyond translating WebUSB JS calls to USB Host C calls.
* ============================================================================
*
* Architecture (see README for the fuller picture):
*
* usb_host_lib_daemon_task — pumps usb_host_lib_handle_events() forever.
* Required by the USB Host Library regardless
* of how many clients exist.
* rtlsdr_exp_client_task — registers as the one USB Host *client* this
* module needs, pumps usb_host_client_handle_
* events() (which is also how *this task's*
* control- and bulk-transfer completion
* callbacks get dispatched — the ESP-IDF USB
* Host Library calls transfer callbacks
* synchronously from inside whichever task
* called *_handle_events(), not from an ISR
* or a hidden thread). On NEW_DEV it attempts
* bring-up; on DEV_GONE it tears down.
* rtlsdr_exp_forward_task — drains the IQ block queue that the bulk
* transfer callback feeds and hands each
* block to rtlsdr_exp_forward_iq_block()
* (the upstream-forwarding STUB).
*
* Bulk IQ reads are pipelined: `bulk_read_queue_depth` transfers are kept
* perpetually in flight (each callback re-submits itself), so the USB pipe
* doesn't idle waiting for the forward task to catch up. Completed buffers
* are handed off via a FreeRTOS queue; if the forward task falls behind,
* blocks are DROPPED (counted in stats.iq_blocks_dropped) rather than
* blocking the USB callback — losing samples is preferable to stalling the
* USB Host Library's event dispatch, which would also stall the client's
* disconnect detection. Whether this is an acceptable loss policy for the
* real product is exactly the kind of thing that needs real-hardware/real-
* throughput testing — see README.
*/
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"
#include "esp_check.h"
#include "usb/usb_host.h"
#include "usb/usb_helpers.h"
#include "usb/usb_types_ch9.h"
#include "esp_http_client.h"
#include "rtlsdr_experimental.h"
static const char *TAG = "rtlsdr_exp";
const uint16_t RTLSDR_EXP_KNOWN_PRODUCT_IDS[4] = { 0x2832, 0x2834, 0x2838, 0x2837 };
/* ---------------------------------------------------------------------------
* Vendor-command constants — ported verbatim from frontend/src/lib/sdr.ts
* (CTRL_IN / CTRL_OUT / DEMOD / USB_EPA / SYS / PAGE_USB, lines ~37-42).
* ------------------------------------------------------------------------ */
#define RTLSDR_CTRL_IN 0xC0u /* bmRequestType: IN | vendor | device */
#define RTLSDR_CTRL_OUT 0x40u /* bmRequestType: OUT | vendor | device */
#define RTLSDR_BLOCK_DEMOD 0x03u
#define RTLSDR_BLOCK_USB_EPA 0x02u
#define RTLSDR_BLOCK_SYS 0x09u
#define RTLSDR_PAGE_USB 0x01u
/* Default tuning + read-loop parameters (rtlsdr_exp_config_default). These
* mirror sdr.ts's open(sampleRateHz = 2_048_000) and its 98 MHz test tune;
* bulk_read_chunk_bytes/queue_depth are new (sdr.ts's readSamples() takes
* an explicit byte count per call from its sweep() caller, there is no
* fixed default there to inherit) and are UNVERIFIED guesses pending real
* throughput measurement. */
#define RTLSDR_EXP_DEFAULT_CENTER_HZ 98000000u
#define RTLSDR_EXP_DEFAULT_SAMPLE_RATE 2048000u
#define RTLSDR_EXP_DEFAULT_CHUNK_BYTES 16384u /* 32 * 512, USB HS multiple */
#define RTLSDR_EXP_DEFAULT_QUEUE_DEPTH 4u
#define RTLSDR_EXP_FORWARD_QUEUE_LEN 8u /* IQ blocks buffered for forward task */
#define RTLSDR_EXP_CTRL_XFER_TIMEOUT_MS 1000u
#define RTLSDR_EXP_USB_INTERFACE_NUM 0u
struct rtlsdr_exp_handle_s {
rtlsdr_exp_config_t cfg;
usb_host_client_handle_t client_hdl;
usb_device_handle_t dev_hdl;
bool device_open;
bool interface_claimed;
uint8_t ep_in_addr; /* bulk IN endpoint address, once found */
/* pending work handed from the client-event callback (kept short, per
* ESP-IDF USB Host guidance) to the client task's main loop */
volatile uint8_t pending_new_dev_addr; /* 0 == none pending */
volatile bool pending_dev_gone;
volatile bool running; /* set false by rtlsdr_exp_stop() */
volatile bool streaming; /* bulk read loop active */
TaskHandle_t lib_task;
TaskHandle_t client_task;
TaskHandle_t forward_task;
QueueHandle_t iq_block_queue; /* holds rtlsdr_iq_block_t* */
/* control-transfer synchronous-wait bookkeeping. Safe as plain fields
* (not a semaphore) because control transfers issued by this module are
* only ever awaited from the same task that also pumps
* usb_host_client_handle_events() — see file header. */
volatile bool ctrl_xfer_pending;
volatile bool ctrl_xfer_ok;
usb_transfer_t *bulk_transfers[8]; /* sized to the max we allow bulk_read_queue_depth to be */
rtlsdr_exp_stats_t stats;
};
typedef struct {
uint8_t *data;
size_t len;
} rtlsdr_iq_block_t;
/* ---------------------------------------------------------------------------
* Public: config defaults
* ------------------------------------------------------------------------ */
void rtlsdr_exp_config_default(rtlsdr_exp_config_t *config)
{
if (!config) return;
memset(config, 0, sizeof(*config));
config->center_freq_hz = RTLSDR_EXP_DEFAULT_CENTER_HZ;
config->sample_rate_hz = RTLSDR_EXP_DEFAULT_SAMPLE_RATE;
config->bulk_read_chunk_bytes = RTLSDR_EXP_DEFAULT_CHUNK_BYTES;
config->bulk_read_queue_depth = RTLSDR_EXP_DEFAULT_QUEUE_DEPTH;
config->iq_upload_url = NULL; /* caller must set */
config->device_bearer_token = NULL; /* caller must set */
}
/* ---------------------------------------------------------------------------
* Low-level control-transfer helpers.
* Direct port of sdr.ts's private writeReg()/demodWrite()/i2cWrite()
* (lines ~252-303). Same wValue/wIndex encoding, same register addresses,
* same repeater on/off dance for tuner I2C access. The WebUSB calls
*
* dev.controlTransferOut({ requestType: 'vendor', recipient: 'device',
* request: 0, value: ..., index: ... }, data)
*
* become a manually-built usb_setup_packet_t with bmRequestType =
* RTLSDR_CTRL_OUT (0x40 — OUT | vendor | device, matching WebUSB's
* 'vendor'/'device' fields), bRequest = 0, wValue/wIndex as below, wLength
* = payload length, submitted via usb_host_transfer_submit_control().
* ------------------------------------------------------------------------ */
/* Synchronously wait for a previously-submitted control transfer to
* complete by pumping usb_host_client_handle_events() from THIS task (see
* file header for why that's safe/required here). Returns ESP_OK if the
* transfer completed successfully within the timeout. */
static esp_err_t rtlsdr_wait_ctrl_xfer(rtlsdr_exp_handle_t h)
{
TickType_t start = xTaskGetTickCount();
TickType_t timeout_ticks = pdMS_TO_TICKS(RTLSDR_EXP_CTRL_XFER_TIMEOUT_MS);
while (h->ctrl_xfer_pending) {
usb_host_client_handle_events(h->client_hdl, pdMS_TO_TICKS(10));
if ((xTaskGetTickCount() - start) > timeout_ticks) {
ESP_LOGW(TAG, "control transfer timed out");
h->ctrl_xfer_pending = false;
return ESP_ERR_TIMEOUT;
}
}
return h->ctrl_xfer_ok ? ESP_OK : ESP_FAIL;
}
static void rtlsdr_ctrl_xfer_cb(usb_transfer_t *transfer)
{
rtlsdr_exp_handle_t h = (rtlsdr_exp_handle_t)transfer->context;
h->ctrl_xfer_ok = (transfer->status == USB_TRANSFER_STATUS_COMPLETED);
h->ctrl_xfer_pending = false;
usb_host_transfer_free(transfer);
}
/* value/index encoding matches sdr.ts's writeReg()/demodWrite() exactly:
* writeReg: wValue = (block<<8)|0x10, wIndex = address
* demodWrite: wValue = (DEMOD<<8)|0x10, wIndex = (page<<8)|address
* `length` is 1 or 2 bytes of little-endian `value`, same as sdr.ts. */
static esp_err_t rtlsdr_ctrl_write_raw(rtlsdr_exp_handle_t h, uint16_t wValue,
uint16_t wIndex, uint16_t value, uint8_t length)
{
if (!h->dev_hdl) return ESP_ERR_INVALID_STATE;
usb_transfer_t *transfer = NULL;
esp_err_t err = usb_host_transfer_alloc(sizeof(usb_setup_packet_t) + length, 0, &transfer);
if (err != ESP_OK) return err;
usb_setup_packet_t *setup = (usb_setup_packet_t *)transfer->data_buffer;
setup->bmRequestType = RTLSDR_CTRL_OUT;
setup->bRequest = 0;
setup->wValue = wValue;
setup->wIndex = wIndex;
setup->wLength = length;
uint8_t *payload = transfer->data_buffer + sizeof(usb_setup_packet_t);
payload[0] = (uint8_t)(value & 0xFF);
if (length > 1) payload[1] = (uint8_t)((value >> 8) & 0xFF);
transfer->device_handle = h->dev_hdl;
transfer->bEndpointAddress = 0; /* control endpoint */
transfer->num_bytes = sizeof(usb_setup_packet_t) + length;
transfer->callback = rtlsdr_ctrl_xfer_cb;
transfer->context = h;
transfer->timeout_ms = RTLSDR_EXP_CTRL_XFER_TIMEOUT_MS;
h->ctrl_xfer_pending = true;
h->ctrl_xfer_ok = false;
err = usb_host_transfer_submit_control(h->client_hdl, transfer);
if (err != ESP_OK) {
h->ctrl_xfer_pending = false;
usb_host_transfer_free(transfer);
return err;
}
return rtlsdr_wait_ctrl_xfer(h);
}
/* writeReg() equivalent — sdr.ts private writeReg(block, address, value, length) */
static esp_err_t rtlsdr_reg_write(rtlsdr_exp_handle_t h, uint8_t block, uint16_t address,
uint16_t value, uint8_t length)
{
uint16_t wValue = (uint16_t)((block << 8) | 0x10);
return rtlsdr_ctrl_write_raw(h, wValue, address, value, length);
}
/* demodWrite() equivalent — sdr.ts private demodWrite(page, address, value, length) */
static esp_err_t rtlsdr_demod_write(rtlsdr_exp_handle_t h, uint8_t page, uint16_t address,
uint16_t value, uint8_t length)
{
uint16_t wValue = (uint16_t)((RTLSDR_BLOCK_DEMOD << 8) | 0x10);
uint16_t wIndex = (uint16_t)((page << 8) | address);
return rtlsdr_ctrl_write_raw(h, wValue, wIndex, value, length);
}
/* i2cWrite() equivalent — sdr.ts private i2cWrite(i2cAddr, reg, value).
* Tunnels a single-byte tuner register write through the demod's I2C
* repeater: repeater on (demod 1/0x02 = 0x41) -> vendor write addressed to
* (i2cAddr<<8)|reg -> repeater off (demod 1/0x02 = 0x01). */
static esp_err_t rtlsdr_i2c_write(rtlsdr_exp_handle_t h, uint8_t i2c_addr, uint8_t reg, uint8_t value)
{
esp_err_t err = rtlsdr_demod_write(h, 1, 0x02, 0x41, 1); /* repeater on */
if (err != ESP_OK) return err;
uint16_t wValue = (uint16_t)((0x02 << 8) | 0x10);
uint16_t wIndex = (uint16_t)((i2c_addr << 8) | reg);
err = rtlsdr_ctrl_write_raw(h, wValue, wIndex, value, 1);
esp_err_t err2 = rtlsdr_demod_write(h, 1, 0x02, 0x01, 1); /* repeater off, always attempted */
return (err != ESP_OK) ? err : err2;
}
/* ---------------------------------------------------------------------------
* Tuning — port of sdr.ts's setFrequency()/setSampleRate() (lines
* ~156-180). Same simplifications, same "HARDWARE PASS REQUIRED" caveat:
* real librtlsdr computes exact sdm/vco from the crystal reference; this
* is the same reduced integer-N approximation sdr.ts uses, carried over
* unchanged rather than re-derived (see this project's Honesty-policy note
* — the browser driver's math is the primary reference, not a place to
* invent new, unverified math on top of already-unverified math).
* ------------------------------------------------------------------------ */
esp_err_t rtlsdr_exp_set_frequency(rtlsdr_exp_handle_t h, uint32_t hz)
{
if (!h || !h->dev_hdl) return ESP_ERR_INVALID_STATE;
uint32_t lo_hz = hz + 3570000u; /* R820T IF offset */
uint32_t ref = 28800000u;
uint32_t mix_div = 2u;
uint32_t nint = lo_hz / (ref * mix_div);
uint32_t vco = lo_hz % (ref * mix_div);
uint32_t sdm = (uint32_t)(((uint64_t)vco * 65536u) / (ref * mix_div));
if (sdm > 0xFFFFu) sdm = 0xFFFFu;
uint8_t reg = (uint8_t)(nint & 0x3F);
esp_err_t err;
ESP_RETURN_ON_ERROR((err = rtlsdr_i2c_write(h, 0x1A, 0x10, reg)), TAG, "pll nint");
ESP_RETURN_ON_ERROR((err = rtlsdr_i2c_write(h, 0x1A, 0x11, (sdm >> 8) & 0xFF)), TAG, "pll sdm hi");
ESP_RETURN_ON_ERROR((err = rtlsdr_i2c_write(h, 0x1A, 0x12, sdm & 0xFF)), TAG, "pll sdm lo");
h->cfg.center_freq_hz = hz;
return ESP_OK;
}
esp_err_t rtlsdr_exp_set_sample_rate(rtlsdr_exp_handle_t h, uint32_t hz)
{
if (!h || !h->dev_hdl) return ESP_ERR_INVALID_STATE;
uint32_t crystal = 28800000u;
uint32_t rsamp_ratio = (uint32_t)((((uint64_t)crystal << 22) / hz)) & 0x0FFFFFFCu;
esp_err_t err;
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x9f, (rsamp_ratio >> 16) & 0xFFFF, 2)), TAG, "rsamp hi");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0xa1, rsamp_ratio & 0xFFFF, 2)), TAG, "rsamp lo");
h->cfg.sample_rate_hz = hz;
return ESP_OK;
}
/* ---------------------------------------------------------------------------
* Init sequence — port of sdr.ts's open() body (lines ~110-140): demod
* soft reset -> demod_ctl/suspend-off register block -> standby off -> AGC
* mode -> R820T tuner power-up over the I2C repeater -> sample rate ->
* initial tune -> streaming endpoint reset -> test-mode off. Same order,
* same register addresses/values, unchanged.
* ------------------------------------------------------------------------ */
static esp_err_t rtlsdr_run_init_sequence(rtlsdr_exp_handle_t h)
{
esp_err_t err;
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x01, 0x14, 1)), TAG, "soft reset"); /* soft reset */
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x01, 0x10, 1)), TAG, "reset clear");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 0, 0x01, 0x08, 2)), TAG, "demod_ctl");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 0, 0x06, 0x80, 1)), TAG, "demod_ctl2");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x15, 0x00, 1)), TAG, "suspend off 0x15");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x16, 0x00, 1)), TAG, "suspend off 0x16");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x17, 0x00, 1)), TAG, "suspend off 0x17");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x18, 0x00, 1)), TAG, "suspend off 0x18");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x19, 0x00, 1)), TAG, "suspend off 0x19");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x1a, 0x00, 1)), TAG, "suspend off 0x1a");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x1b, 0x00, 1)), TAG, "suspend off 0x1b");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x1c, 0x00, 1)), TAG, "suspend off 0x1c");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x0d, 0x83, 1)), TAG, "standby off");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x0b, 0x1b, 1)), TAG, "AGC mode");
/* R820T power-up through I2C repeater (addr 0x1a) */
ESP_RETURN_ON_ERROR((err = rtlsdr_i2c_write(h, 0x1a, 0x05, 0x8f)), TAG, "LNA power");
ESP_RETURN_ON_ERROR((err = rtlsdr_i2c_write(h, 0x1a, 0x08, 0x80)), TAG, "mixer");
ESP_RETURN_ON_ERROR((err = rtlsdr_i2c_write(h, 0x1a, 0x0a, 0x10)), TAG, "IF filter");
ESP_RETURN_ON_ERROR((err = rtlsdr_exp_set_sample_rate(h, h->cfg.sample_rate_hz)), TAG, "sample rate");
ESP_RETURN_ON_ERROR((err = rtlsdr_exp_set_frequency(h, h->cfg.center_freq_hz)), TAG, "frequency");
/* Reset streaming endpoint before bulk reads begin. */
ESP_RETURN_ON_ERROR((err = rtlsdr_reg_write(h, RTLSDR_BLOCK_USB_EPA, 0x0001, 0xFFFF, 2)), TAG, "ep reset");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x02, 0x00, 1)), TAG, "streaming pre");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 0, 0x02, 0x40, 2)), TAG, "streaming enable");
ESP_RETURN_ON_ERROR((err = rtlsdr_demod_write(h, 1, 0x02, 0x00, 1)), TAG, "test mode off");
return ESP_OK;
}
/* ---------------------------------------------------------------------------
* Bulk-IN read loop — NOT present in sdr.ts in this pipelined form (the
* WebUSB driver does one-shot `await dev.transferIn(...)` calls from
* inside its own async sweep loop; ESP-IDF's USB Host Library is callback-
* driven and async by design, so continuous streaming needs an explicit
* resubmit-on-completion pipeline instead). This is the least-verified
* part of this whole module — see README "What's unverified" — because
* correct chunk size, queue depth, and drop policy all depend on real
* measured USB throughput on real ESP32-P4 silicon, which nobody involved
* in this workstream has access to.
* ------------------------------------------------------------------------ */
static void rtlsdr_bulk_xfer_cb(usb_transfer_t *transfer)
{
rtlsdr_exp_handle_t h = (rtlsdr_exp_handle_t)transfer->context;
if (transfer->status == USB_TRANSFER_STATUS_COMPLETED && transfer->actual_num_bytes > 0) {
h->stats.bulk_reads_ok++;
rtlsdr_iq_block_t *block = malloc(sizeof(rtlsdr_iq_block_t));
uint8_t *copy = block ? malloc(transfer->actual_num_bytes) : NULL;
if (block && copy) {
memcpy(copy, transfer->data_buffer, transfer->actual_num_bytes);
block->data = copy;
block->len = (size_t)transfer->actual_num_bytes;
/* Non-blocking send: dropping is preferred to stalling the USB
* callback path (see file header). Drop is counted, not silent. */
if (h->iq_block_queue && xQueueSend(h->iq_block_queue, &block, 0) != pdTRUE) {
h->stats.iq_blocks_dropped++;
free(copy);
free(block);
}
} else {
h->stats.iq_blocks_dropped++;
free(copy);
free(block);
}
} else if (transfer->status != USB_TRANSFER_STATUS_COMPLETED) {
h->stats.bulk_reads_failed++;
ESP_LOGW(TAG, "bulk IN transfer failed, status=%d", transfer->status);
}
/* Keep the pipe full: resubmit immediately unless we're stopping. */
if (h->streaming && h->running) {
esp_err_t err = usb_host_transfer_submit(transfer);
if (err != ESP_OK) {
ESP_LOGW(TAG, "failed to resubmit bulk transfer: %d", err);
h->stats.bulk_reads_failed++;
}
}
}
static esp_err_t rtlsdr_start_bulk_streaming(rtlsdr_exp_handle_t h)
{
size_t depth = h->cfg.bulk_read_queue_depth;
if (depth == 0 || depth > (sizeof(h->bulk_transfers) / sizeof(h->bulk_transfers[0]))) {
depth = RTLSDR_EXP_DEFAULT_QUEUE_DEPTH;
}
if (h->cfg.bulk_read_chunk_bytes == 0 || (h->cfg.bulk_read_chunk_bytes % 512) != 0) {
ESP_LOGW(TAG, "bulk_read_chunk_bytes not a multiple of 512, forcing default");
h->cfg.bulk_read_chunk_bytes = RTLSDR_EXP_DEFAULT_CHUNK_BYTES;
}
for (size_t i = 0; i < depth; i++) {
usb_transfer_t *t = NULL;
esp_err_t err = usb_host_transfer_alloc(h->cfg.bulk_read_chunk_bytes, 0, &t);
if (err != ESP_OK) {
ESP_LOGE(TAG, "failed to allocate bulk transfer %zu: %d", i, err);
return err;
}
t->device_handle = h->dev_hdl;
t->bEndpointAddress = h->ep_in_addr;
t->num_bytes = h->cfg.bulk_read_chunk_bytes;
t->callback = rtlsdr_bulk_xfer_cb;
t->context = h;
t->timeout_ms = 0; /* no timeout: this endpoint is expected to be
* continuously producing once streaming is on;
* UNVERIFIED whether 0 (no timeout) is the
* right choice vs. a bounded timeout + retry —
* needs real-hardware behavior to decide. */
h->bulk_transfers[i] = t;
}
h->streaming = true;
for (size_t i = 0; i < depth; i++) {
esp_err_t err = usb_host_transfer_submit(h->bulk_transfers[i]);
if (err != ESP_OK) {
ESP_LOGE(TAG, "failed to submit initial bulk transfer %zu: %d", i, err);
h->streaming = false;
return err;
}
}
ESP_LOGI(TAG, "bulk IQ streaming started: chunk=%zuB depth=%zu",
h->cfg.bulk_read_chunk_bytes, depth);
return ESP_OK;
}
static void rtlsdr_stop_bulk_streaming(rtlsdr_exp_handle_t h)
{
h->streaming = false;
for (size_t i = 0; i < sizeof(h->bulk_transfers) / sizeof(h->bulk_transfers[0]); i++) {
if (h->bulk_transfers[i]) {
usb_host_transfer_free(h->bulk_transfers[i]);
h->bulk_transfers[i] = NULL;
}
}
}
/* ---------------------------------------------------------------------------
* Upstream IQ forwarding — STUB. See README "IQ forwarding architecture
* decision" for the full reasoning. Summary: raw IQ at even a modest
* 2.048 Msps / 8-bit-per-sample-per-channel is ~4.1 MB/s, which is wildly
* incompatible with the regular `POST /api/device/telemetry` shape (per
* the ESP32-P4 Sensor Node spec's contract: readings array capped at 64
* entries, request body capped at 16KB, rate-limited to ~1 req/sec
* sustained per device) — that endpoint is sized for a handful of scalar
* sensor readings, not a continuous binary stream. Rather than distort
* that endpoint's shape (e.g. base64-stuffing IQ bytes into a JSON
* "reading" — which would also add ~33% overhead on top of an already
* too-large payload), this stub targets a SEPARATE, not-yet-implemented
* endpoint carrying raw binary chunks, authenticated the same way (device
* bearer token) but outside the JSON telemetry contract entirely.
*
* This function is intentionally inert by default (returns early unless
* iq_upload_url is set) because:
* 1. No backend endpoint for this exists anywhere in this repo yet —
* building one is explicitly out of scope for this workstream.
* 2. Even the *shape* proposed here (raw octet-stream POST per block,
* small fixed binary header) is a guess pending: (a) real measured
* USB throughput off actual hardware, (b) a product decision on
* whether continuous IQ upload is even desired given typical
* home-WiFi-uplink bandwidth, and (c) whether a WebSocket stream
* would suit the backend's existing async pub/sub model (see the
* spec's /ws/device-feed design) better than repeated POSTs.
* ------------------------------------------------------------------------ */
/* Minimal fixed binary header prepended to each forwarded chunk, so the
* (not-yet-existing) backend endpoint can frame chunks without needing
* JSON/base64 parsing on a hot path. All fields little-endian.
* UNVERIFIED / PROPOSED — not agreed with any backend implementation. */
typedef struct __attribute__((packed)) {
uint32_t magic; /* 'QMIQ' = 0x51 0x4D 0x49 0x51 */
uint32_t seq;
uint32_t center_hz;
uint32_t sample_rate_hz;
uint32_t payload_len;
} rtlsdr_iq_chunk_header_t;
#define RTLSDR_IQ_CHUNK_MAGIC 0x51494D51u /* "QMIQ" */
static esp_err_t rtlsdr_exp_forward_iq_block(rtlsdr_exp_handle_t h, const uint8_t *data, size_t len)
{
if (!h->cfg.iq_upload_url || !h->cfg.device_bearer_token) {
/* No upload target configured — this is the expected state until a
* real backend endpoint exists and a caller opts in. Not an error. */
return ESP_OK;
}
static uint32_t s_seq = 0;
rtlsdr_iq_chunk_header_t hdr = {
.magic = RTLSDR_IQ_CHUNK_MAGIC,
.seq = s_seq++,
.center_hz = h->cfg.center_freq_hz,
.sample_rate_hz = h->cfg.sample_rate_hz,
.payload_len = (uint32_t)len,
};
esp_http_client_config_t http_cfg = {
.url = h->cfg.iq_upload_url,
.method = HTTP_METHOD_POST,
.timeout_ms = 5000,
};
esp_http_client_handle_t client = esp_http_client_init(&http_cfg);
if (!client) return ESP_FAIL;
char auth_header[512];
snprintf(auth_header, sizeof(auth_header), "Bearer %s", h->cfg.device_bearer_token);
esp_http_client_set_header(client, "Authorization", auth_header);
esp_http_client_set_header(client, "Content-Type", "application/octet-stream");
esp_err_t err = esp_http_client_open(client, (int)(sizeof(hdr) + len));
if (err == ESP_OK) {
int written = esp_http_client_write(client, (const char *)&hdr, sizeof(hdr));
if (written == (int)sizeof(hdr)) {
written = esp_http_client_write(client, (const char *)data, (int)len);
}
if (written < 0) err = ESP_FAIL;
esp_http_client_fetch_headers(client);
int status = esp_http_client_get_status_code(client);
if (status < 200 || status >= 300) {
ESP_LOGW(TAG, "IQ upload got HTTP %d (endpoint likely doesn't exist yet)", status);
err = ESP_FAIL;
}
}
esp_http_client_close(client);
esp_http_client_cleanup(client);
if (err == ESP_OK) {
h->stats.bytes_forwarded_upstream += (uint32_t)len;
} else {
h->stats.forward_failures++;
}
return err;
}
static void rtlsdr_forward_task(void *arg)
{
rtlsdr_exp_handle_t h = (rtlsdr_exp_handle_t)arg;
rtlsdr_iq_block_t *block = NULL;
while (h->running) {
if (xQueueReceive(h->iq_block_queue, &block, pdMS_TO_TICKS(500)) == pdTRUE) {
rtlsdr_exp_forward_iq_block(h, block->data, block->len);
free(block->data);
free(block);
block = NULL;
}
}
/* drain remaining queued blocks on shutdown without forwarding */
while (xQueueReceive(h->iq_block_queue, &block, 0) == pdTRUE) {
free(block->data);
free(block);
}
vTaskDelete(NULL);
}
/* ---------------------------------------------------------------------------
* Device bring-up
* ------------------------------------------------------------------------ */
static bool rtlsdr_vendor_id_matches(uint16_t vid)
{
return vid == RTLSDR_EXP_VENDOR_RTL2832U || vid == RTLSDR_EXP_VENDOR_TERRATEC;
}
static void rtlsdr_log_known_product_hint(uint16_t vid, uint16_t pid)
{
if (vid != RTLSDR_EXP_VENDOR_RTL2832U) return;
for (size_t i = 0; i < RTLSDR_EXP_NUM_KNOWN_PRODUCT_IDS; i++) {
if (RTLSDR_EXP_KNOWN_PRODUCT_IDS[i] == pid) return;
}
ESP_LOGI(TAG, "product id 0x%04x not in the known RTL2832U list — "
"attempting bring-up anyway (vendor-only match, same policy "
"as frontend/src/lib/sdr.ts's WebUSB device filter)", pid);
}
/* Finds the first bulk-IN endpoint on the device's active configuration's
* first interface. Mirrors sdr.ts's:
* iface = dev.configuration.interfaces[0]; alt = iface.alternates[0];
* ep = alt.endpoints.find(e => e.direction==='in' && e.type==='bulk') */
static esp_err_t rtlsdr_find_bulk_in_endpoint(usb_device_handle_t dev_hdl, uint8_t *out_ep)
{
const usb_config_desc_t *config_desc = NULL;
esp_err_t err = usb_host_get_active_config_descriptor(dev_hdl, &config_desc);
if (err != ESP_OK || !config_desc) return ESP_FAIL;
int offset = 0;
const usb_intf_desc_t *intf = usb_parse_interface_descriptor(config_desc, 0, 0, &offset);
if (!intf) return ESP_FAIL;
for (int i = 0; i < intf->bNumEndpoints; i++) {
int ep_offset = offset;
const usb_ep_desc_t *ep = usb_parse_endpoint_descriptor_by_index(
intf, i, config_desc->wTotalLength, &ep_offset);
if (!ep) continue;
bool is_in = (ep->bEndpointAddress & USB_B_ENDPOINT_ADDRESS_EP_DIR_MASK) != 0;
bool is_bulk = (ep->bmAttributes & USB_BM_ATTRIBUTES_XFERTYPE_MASK) == USB_BM_ATTRIBUTES_XFER_BULK;
if (is_in && is_bulk) {
*out_ep = ep->bEndpointAddress;
return ESP_OK;
}
}
return ESP_ERR_NOT_FOUND;
}
static void rtlsdr_teardown_device(rtlsdr_exp_handle_t h)
{
rtlsdr_stop_bulk_streaming(h);
if (h->interface_claimed) {
usb_host_interface_release(h->client_hdl, h->dev_hdl, RTLSDR_EXP_USB_INTERFACE_NUM);
h->interface_claimed = false;
}
if (h->device_open) {
usb_host_device_close(h->client_hdl, h->dev_hdl);
h->device_open = false;
}
h->dev_hdl = NULL;
h->stats.device_present = false;
h->stats.bring_up_ok = false;
h->stats.streaming = false;
}
static void rtlsdr_try_bring_up(rtlsdr_exp_handle_t h, uint8_t dev_addr)
{
esp_err_t err = usb_host_device_open(h->client_hdl, dev_addr, &h->dev_hdl);
if (err != ESP_OK) {
ESP_LOGW(TAG, "usb_host_device_open failed: %d", err);
return;
}
h->device_open = true;
const usb_device_desc_t *dev_desc = NULL;
err = usb_host_get_device_descriptor(h->dev_hdl, &dev_desc);
if (err != ESP_OK || !dev_desc) {
ESP_LOGW(TAG, "could not read device descriptor: %d", err);
rtlsdr_teardown_device(h);
return;
}
if (!rtlsdr_vendor_id_matches(dev_desc->idVendor)) {
ESP_LOGD(TAG, "vendor 0x%04x is not RTL2832U/Terratec, ignoring", dev_desc->idVendor);
rtlsdr_teardown_device(h);
return;
}
rtlsdr_log_known_product_hint(dev_desc->idVendor, dev_desc->idProduct);
ESP_LOGI(TAG, "candidate RTL2832U-class device: vid=0x%04x pid=0x%04x",
dev_desc->idVendor, dev_desc->idProduct);
err = usb_host_interface_claim(h->client_hdl, h->dev_hdl, RTLSDR_EXP_USB_INTERFACE_NUM, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "could not claim interface %d: %d — device may be claimed by "
"another driver, or this isn't the RTL2832U bulk interface",
RTLSDR_EXP_USB_INTERFACE_NUM, err);
rtlsdr_teardown_device(h);
return;
}
h->interface_claimed = true;
err = rtlsdr_find_bulk_in_endpoint(h->dev_hdl, &h->ep_in_addr);
if (err != ESP_OK) {
ESP_LOGW(TAG, "no bulk IN endpoint found on interface %d", RTLSDR_EXP_USB_INTERFACE_NUM);
rtlsdr_teardown_device(h);
return;
}
h->stats.device_present = true;
err = rtlsdr_run_init_sequence(h);
if (err != ESP_OK) {
ESP_LOGE(TAG, "RTL2832U init vendor-command sequence failed at some step: %d "
"(UNVERIFIED sequence — see README, this is exactly the kind of "
"failure real hardware bring-up is expected to need to debug)", err);
rtlsdr_teardown_device(h);
return;
}
h->stats.bring_up_ok = true;
ESP_LOGI(TAG, "RTL2832U init sequence completed without a control-transfer error "
"(this does NOT confirm valid IQ is being produced — only a real "
"spectrum/logic-analyzer check against hardware can confirm that)");
err = rtlsdr_start_bulk_streaming(h);
if (err != ESP_OK) {
ESP_LOGE(TAG, "failed to start bulk IQ streaming: %d", err);
rtlsdr_teardown_device(h);
return;
}
h->stats.streaming = true;
}
/* ---------------------------------------------------------------------------
* USB Host Library plumbing: daemon task + client task + client event cb
* ------------------------------------------------------------------------ */
static void rtlsdr_usb_lib_daemon_task(void *arg)
{
rtlsdr_exp_handle_t h = (rtlsdr_exp_handle_t)arg;
while (h->running) {
uint32_t event_flags = 0;
usb_host_lib_handle_events(pdMS_TO_TICKS(1000), &event_flags);
if (event_flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) {
ESP_LOGD(TAG, "USB host lib: no clients");
}
if (event_flags & USB_HOST_LIB_EVENT_FLAGS_ALL_FREE) {
ESP_LOGD(TAG, "USB host lib: all devices free");
}
}
vTaskDelete(NULL);
}
static void rtlsdr_client_event_cb(const usb_host_client_event_msg_t *event_msg, void *arg)
{
rtlsdr_exp_handle_t h = (rtlsdr_exp_handle_t)arg;
/* Deliberately kept short — per ESP-IDF USB Host guidance, heavy work
* (device open, control transfers) is done back in the client task's
* main loop, not inside this callback. */
switch (event_msg->event) {
case USB_HOST_CLIENT_EVENT_NEW_DEV:
h->pending_new_dev_addr = event_msg->new_dev.address;
break;
case USB_HOST_CLIENT_EVENT_DEV_GONE:
h->pending_dev_gone = true;
break;
default:
break;
}
}
static void rtlsdr_exp_client_task(void *arg)
{
rtlsdr_exp_handle_t h = (rtlsdr_exp_handle_t)arg;
usb_host_client_config_t client_config = {
.is_synchronous = false,
.max_num_event_msg = 5,
.async = {
.client_event_callback = rtlsdr_client_event_cb,
.callback_arg = h,
},
};
esp_err_t err = usb_host_client_register(&client_config, &h->client_hdl);
if (err != ESP_OK) {
ESP_LOGE(TAG, "usb_host_client_register failed: %d", err);
h->running = false;
vTaskDelete(NULL);
return;
}
while (h->running) {
/* This call is also what dispatches completion callbacks for any
* control/bulk transfers this client has outstanding — see file
* header. Timeout keeps the loop responsive to h->running. */
usb_host_client_handle_events(h->client_hdl, pdMS_TO_TICKS(200));
if (h->pending_new_dev_addr != 0) {
uint8_t addr = h->pending_new_dev_addr;
h->pending_new_dev_addr = 0;
if (!h->dev_hdl) { /* only attempt bring-up if we don't already hold a device */
rtlsdr_try_bring_up(h, addr);
}
}
if (h->pending_dev_gone) {
h->pending_dev_gone = false;
ESP_LOGW(TAG, "RTL2832U device disconnected");
rtlsdr_teardown_device(h);
}
}
rtlsdr_teardown_device(h);
usb_host_client_deregister(h->client_hdl);
h->client_hdl = NULL;
vTaskDelete(NULL);
}
/* ---------------------------------------------------------------------------
* Public lifecycle API
* ------------------------------------------------------------------------ */
esp_err_t rtlsdr_exp_init(const rtlsdr_exp_config_t *config, rtlsdr_exp_handle_t *out_handle)
{
if (!config || !out_handle) return ESP_ERR_INVALID_ARG;
rtlsdr_exp_handle_t h = calloc(1, sizeof(struct rtlsdr_exp_handle_s));
if (!h) return ESP_ERR_NO_MEM;
h->cfg = *config;
if (h->cfg.center_freq_hz == 0) h->cfg.center_freq_hz = RTLSDR_EXP_DEFAULT_CENTER_HZ;
if (h->cfg.sample_rate_hz == 0) h->cfg.sample_rate_hz = RTLSDR_EXP_DEFAULT_SAMPLE_RATE;
if (h->cfg.bulk_read_chunk_bytes == 0) h->cfg.bulk_read_chunk_bytes = RTLSDR_EXP_DEFAULT_CHUNK_BYTES;
if (h->cfg.bulk_read_queue_depth == 0) h->cfg.bulk_read_queue_depth = RTLSDR_EXP_DEFAULT_QUEUE_DEPTH;
h->iq_block_queue = xQueueCreate(RTLSDR_EXP_FORWARD_QUEUE_LEN, sizeof(rtlsdr_iq_block_t *));
if (!h->iq_block_queue) {
free(h);
return ESP_ERR_NO_MEM;
}
*out_handle = h;
return ESP_OK;
}
esp_err_t rtlsdr_exp_deinit(rtlsdr_exp_handle_t h)
{
if (!h) return ESP_ERR_INVALID_ARG;
if (h->running) {
rtlsdr_exp_stop(h);
}
if (h->iq_block_queue) vQueueDelete(h->iq_block_queue);
free(h);
return ESP_OK;
}
esp_err_t rtlsdr_exp_start(rtlsdr_exp_handle_t h)
{
if (!h) return ESP_ERR_INVALID_ARG;
if (h->running) return ESP_ERR_INVALID_STATE;
/* NOTE: usb_host_install() installs a library instance shared by the
* whole firmware process. If Workstream I's core skeleton (or any
* future module) also needs USB host for something else, installing
* it twice is an error — this experimental module assumes it is the
* ONLY USB Host client in the project. Document/resolve this at merge
* time; see README integration notes. */
usb_host_config_t host_config = {
.skip_phy_setup = false,
.intr_flags = ESP_INTR_FLAG_LEVEL1,
};
esp_err_t err = usb_host_install(&host_config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "usb_host_install failed: %d", err);
return err;
}
h->running = true;
BaseType_t ok = xTaskCreate(rtlsdr_usb_lib_daemon_task, "rtlsdr_usb_lib", 4096, h, 5, &h->lib_task);
if (ok != pdPASS) {
h->running = false;
usb_host_uninstall();
return ESP_ERR_NO_MEM;
}
ok = xTaskCreate(rtlsdr_exp_client_task, "rtlsdr_client", 6144, h, 5, &h->client_task);
if (ok != pdPASS) {
h->running = false;
vTaskDelete(h->lib_task);
usb_host_uninstall();
return ESP_ERR_NO_MEM;
}
ok = xTaskCreate(rtlsdr_forward_task, "rtlsdr_forward", 4096, h, 4, &h->forward_task);
if (ok != pdPASS) {
h->running = false;
vTaskDelete(h->client_task);
vTaskDelete(h->lib_task);
usb_host_uninstall();
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "rtlsdr_experimental started (UNVERIFIED against real hardware — "
"see firmware/esp32p4-sensor-node/README.md)");
return ESP_OK;
}
esp_err_t rtlsdr_exp_stop(rtlsdr_exp_handle_t h)
{
if (!h) return ESP_ERR_INVALID_ARG;
if (!h->running) return ESP_OK;
h->running = false;
/* Tasks self-delete once they observe h->running == false; give them a
* moment. A production version should use task-completion notification
* instead of a fixed delay — left as a TODO, not hardware-dependent but
* still unverified/untuned. */
vTaskDelay(pdMS_TO_TICKS(500));
usb_host_uninstall(); /* see rtlsdr_exp_start() note re: sole USB client assumption */
return ESP_OK;
}
void rtlsdr_exp_get_stats(rtlsdr_exp_handle_t h, rtlsdr_exp_stats_t *out_stats)
{
if (!h || !out_stats) return;
*out_stats = h->stats;
}