/* * 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 #include #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. The daemon task blocks up to 1000ms per iteration inside * usb_host_lib_handle_events() before it re-checks h->running, so this * delay must exceed that or usb_host_uninstall() below can race a still * -running daemon task still holding the USB host lib open. 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(1200)); 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; }