SDR Driver Development Guide
February 8, 2026 · View on GitHub
This document explains how to add support for new SDR hardware in WebSDR.
WebSDR is frontend-only: all SDR control and streaming happen inside the browser. New devices are supported by implementing a driver that inherits from the shared base class and provides device-specific logic through a small set of abstract methods.
Driver Model
To support a new SDR, implement a subclass of the base driver:
- Inherit from
WebUsbclass - Implement all abstract methods
- Map WebSDR high-level commands to device-specific control operations
- Provide IQ streaming decode/encode compatible with the framework
The web application and higher-level control logic interact with the base API and do not need to know the concrete SDR type.
Base Class: WebUsb
The WebUsb base class provides:
- WebUSB discovery and lifecycle helpers (
open(),close()) - Convenience transport wrappers (
read,write,readControl,writeControl) - A built-in command queue (
_commands) and dispatcher (runCommandPool) - TX queue limiting (
MAX_SEND_DATA_REQUEST,sendDataReqCnt,waitForChangeSendDataReq) - Generic streaming helpers (
submitRxPacket,sendTxPacket,sendTxRawPacket) - EventTarget-based integration hooks (
onConnect,onDisconnect, custom events)
Your subclass focuses on:
- device framing (packet sizes),
- IQ parsing/packing,
- device command transport/mapping.
What you must implement
A driver must implement the following abstract methods:
1) Device configuration
getConfiguration(): DeviceConfiguration
Return device-specific static configuration, such as:
- supported frequency ranges
- supported sample rates / bandwidth ranges
- default sample counts / preferred packetization
- supported stream directions/modes (RX, TX, TX and RX)
- supported stream types and data formats for them
This is the main capability descriptor used by higher-level logic.
2) Streaming geometry (packet sizes and sample alignment)
These methods define how the framework allocates USB buffers and requests transfer sizes.
getRXSamplesCount(samples: number): number
The framework may request samples, but the device may require alignment (e.g. multiples of N).
Return the device-corrected number of samples that will actually be transferred/decoded.
getRXPacketSize(samples: number): number
Return RX packet byte size for samples complex samples (including device headers, if any).
getTXPacketSize(samples: number): number
Return TX packet byte size for samples complex samples (including device headers, if any).
These three methods are critical to prevent underruns/overruns and to keep USB transfers stable.
3) IQ decoding and encoding
These methods convert between device-native binary framing and WebSDR buffer objects.
decodeRxData(data: DataView, samples: number, opts?: RXDecoderOptions): Promise<RXBuffer>
Parse an incoming RX packet and produce an RXBuffer:
- parse device-specific headers
- detect misalignment or dropped/partial packets
- extract/normalize metadata (datatype, sample count, timestamps if present)
- return the decoded IQ payload
encodeTxData(data: TXBuffer, opts?: TXEncoderOptions): Promise<ArrayBufferLike>
Convert a TXBuffer into device-native format ready for transferOut:
- apply required headers/framing
- enforce alignment
- handle datatype conversion if necessary (e.g., cf32↔ci16)
- honor
TXEncoderOptions(e.g. allowDrop behavior is handled by base TX queue logic)
4) Command transport / request mapping
sendCommandToDevice(req: Record<string, any>): Promise<Record<string, any>>
This is the main control hook used by runCommandPool().
Full command descriptions are available in docs/webusb/commands.md.
Your implementation must perform the full request/response cycle using the chosen mechanism:
- WASM control module call (e.g. Emscripten-exported function)
- direct USB control transfers
- pure-JS mapping (no WASM)
Return value contract:
- On success:
{ ...parsed reply... } - On device-level error:
{ error: <string>, ...optional fields... }
Drivers should avoid throwing for expected device errors; either return { error: ... }
or throw only for transport-level failures (timeouts, disconnected device, etc.).
runCommandPool() normalizes both.
Optional debug command hook
sendDebugCommandToDevice(req: string): Promise<string>
The base implementation returns Error: not implemented.
Implement this if your device supports:
- vendor debug endpoints
- raw command console
- development-only diagnostic channel
This method is called via sendDebugCommand().
How command dispatch works in the base class
WebUsb provides a structured command queue:
sendCommand(req)enqueues aCommandRequestrunCommandPool()processes the queue sequentially- for each command it calls
sendCommandToDevice(req) - it resolves/rejects the original promise depending on
{ error: ... }or thrown exception
This ensures:
- no concurrent control transactions collide
- command ordering is deterministic
- driver implementations stay simple
Streaming helpers you get
Your driver does not need to reimplement USB transfer orchestration.
RX
submitRxPacket(samples, opts):
- allocates the correct RX transfer size using
getRXPacketSize() - reads from the streaming endpoint
- calls your
decodeRxData()and returns anRXBuffer
TX
sendTxPacket(data, opts):
- calls your
encodeTxData() - enforces TX queue limits (
MAX_SEND_DATA_REQUEST) - sends prepared packet via
_sendTxPacket()andtransferOut
This design keeps transport orchestration in the base class and pushes only device-specific encoding/decoding into the driver.
Overriding low-level transport (send/receive)
The base WebUsb class exposes two public wrapper methods that handle
the raw USB streaming I/O: sendDataToDevice(pkt) and
receiveDataFromDevice(len). These methods delegate to the default
USB endpoints (STREAM_EP) and are intended to be overridden by drivers
or tests that need custom transport behavior.
Why override:
- Route IQ packets through a different endpoint or transport (e.g. a worker, tunnel, or emulated device).
- Inject test hooks, logging, or instrumentation around raw transfers.
- Implement device-specific framing that requires special reads/writes.
Default behavior:
sendDataToDevice(pkt)— callsUSBDevice.transferOut(STREAM_EP, pkt)receiveDataFromDevice(len)— callsUSBDevice.transferIn(STREAM_EP, len)
Example: override to route TX packets to a custom endpoint or worker while leaving RX on the default path:
export class MyDevice extends WebUsb {
// implement abstract methods...
// route TX to a different endpoint
async sendDataToDevice(pkt: ArrayBufferLike): Promise<USBOutTransferResult | undefined> {
// custom logic here (e.g. send to endpoint 4 or to a worker)
return this.device?.transferOut(4, pkt as BufferSource);
}
// you can also override receive path if needed
async receiveDataFromDevice(len: number): Promise<USBInTransferResult | undefined> {
// custom receive implementation (e.g. synthetic data for tests)
return this.device?.transferIn(WebUsbEndpoints.STREAM_EP, len);
}
}
Notes:
- If you override these methods, be sure to preserve the Promise
semantics (resolve to
USBOutTransferResult/USBInTransferResultorundefined) so the base class logic continues to work. - Overriding is optional — most drivers only need to implement
encodeTxData/decodeRxDataandsendCommandToDevice.
Implementing mapping from WebSDR control model to device commands
WebSDR uses a high-level command catalog (e.g. set frequency, gain, streaming control).
Full command descriptions are available in docs/webusb/commands.md.
A driver is responsible for translating those abstract requests into device-native actions.
Typical strategies:
- Direct JSON passthrough to firmware (device understands WebSDR-like JSON)
- JSON-to-binary mapping (driver converts request into control transfers)
- JS-native handling (driver performs actions without firmware JSON)
The recommended approach is:
- keep the high-level command names stable at the WebSDR level
- implement device-specific mapping inside
sendCommandToDevice()
Add static metadata for mapping, e.g. VENDOR_ID and PRODUCT_ID
Drivers must expose their USB identifiers as static fields so the framework can map a connected USB device to the correct driver class.
Use the exact static names VENDOR_ID and PRODUCT_ID (case-sensitive).
Example device class:
export class MyDevice extends WebUsb {
static VENDOR_ID = 0x1234;
static PRODUCT_ID = 0xabcd;
constructor(params: WebUsbParams) {
super(params);
}
// implement required abstract methods...
}
Register the class so the framework can create instances when a matching
USB device is plugged in. Call registerWebUsbInstance() with the class:
import { registerWebUsbInstance } from '../../packages/frontend-core/src/webusb/webUsb';
registerWebUsbInstance(MyDevice);
At runtime the framework computes a key from the connected device's VENDOR_ID
and PRODUCT_ID (via getDeviceHash) and then calls getWebUsbInstance(key, params):
const key = getDeviceHash({ vendorId: usbDevice.vendorId, productId: usbDevice.productId });
const instance = getWebUsbInstance(key, params);
if (instance) await instance.open();
The class static fields must be present and correct for the registration and lookup to work.
Checklist: bringing up a new SDR
- Create a subclass
class MyDevice extends WebUsb - Implement all abstract methods
- Define endpoints and transfer sizes (RX/TX framing)
- Implement RX decode and TX encode (including headers if needed)
- Implement
sendCommandToDevice():- map high-level commands to device operations
- return
{ error: ... }on device errors
- Validate:
open()finds the device (vid/pid)- connect/config commands work
- RX streaming decode produces correct IQ samples
- TX streaming is stable under queue pressure
- Add static metadata for mapping, e.g.
VENDOR_IDandPRODUCT_ID.
Driver Template
A driver template is available at packages/frontend-core/src/webusb/templates/WebUsbTemplate.ts.