hackrf-nusb 0.3.0

Rust-native half-duplex HackRF driver built on nusb.
Documentation

hackrf-nusb

hackrf-nusb is an experimental Rust-native HackRF driver built directly on nusb. It does not link to libusb or libhackrf.

HackRF is half duplex. This driver makes the hardware boundary explicit:

RX: start → read … → stop → Off
TX: start → write … → stop → Off

Starting RX while TX is active, or TX while RX is active, returns Error::Busy. The driver never switches direction on a read or write. A higher-level adapter may implement automatic TDD policy, but the core driver deliberately does not.

Status

  • discovery, opening, device information, and normal HackRF configuration;
  • one owned RX stream and one owned TX stream;
  • explicit reusable start/stop operations;
  • synchronous and executor-agnostic asynchronous APIs;
  • WebUSB support on wasm32-unknown-unknown;
  • Complex32 IQ conversion and TX terminal-flush handling.

There is no resampling or alternate raw/floating-point stream format. The configured rate is the complex sample rate. Signed 8-bit IQ values are normalized by 128.0 on receive.

Synchronous API

use hackrf_nusb::{Complex32, Device, MaybeFuture};
use std::time::Duration;

fn main() -> hackrf_nusb::Result<()> {
    let mut device = Device::builder()
        .frequency_hz(100_000_000)
        .sample_rate_hz(10_000_000)
        .open()
        .wait()?;

    let mut rx = device.rx_stream()?;
    rx.start().wait()?;

    // This takes effect during RX, with no sample-accurate boundary.
    device.set_lna_gain_db(24).wait()?;

    let mut samples = [Complex32::default(); 4096];
    let count = rx.read(&mut samples, Some(Duration::from_secs(1))).wait()?;
    let stats = rx.stop().wait()?;
    println!("received {count} samples; {stats:?}");

    device.shutdown().wait()?;
    Ok(())
}

To transmit after RX, stop RX first:

# use hackrf_nusb::{Complex32, Device, MaybeFuture};
# use std::time::Duration;
# fn main() -> hackrf_nusb::Result<()> {
# let mut device = Device::open().wait()?;
# let mut rx = device.rx_stream()?;
# let mut tx = device.tx_stream()?;
# rx.start().wait()?;
rx.stop().wait()?;
tx.start().wait()?;
tx.write(&[Complex32::default(); 1024], Some(Duration::from_secs(1)), true)
    .wait()?;
tx.stop().wait()?;
# Ok(()) }

start and a successful stop are reusable. A read/write stream I/O failure turns the radio off automatically but permanently invalidates that stream; on native targets, drop it and create a fresh stream from the same device. On WebUSB, drop and reopen the device before replacing a failed RX stream because the browser cannot cancel its pending RX transfers. If that automatic stop fails, retry stop before replacing the stream. Shutdown is terminal for a device handle: if its hardware request fails or is cancelled, the handle remains closed. Drop it and reopen the device to try again. Dropping a device or stream attempts cleanup but cannot report its result and is not a lifecycle guarantee.

Execution and performance

Streaming and one-shot operations implement nusb::MaybeFuture. On native targets, call .wait() for blocking operation or .await the same value in async code. WebUSB supports only the async form. Do not mix execution styles for one device session.

The RX stream owns endpoint 0x81 and its persistent queue of 16 × 256 KiB transfers. A successful RX stop turns the radio off but retains that queue; the next start resumes it without submitting a second queue. Drop the RX stream for final best-effort queue cleanup. An active TX stream owns endpoint 0x02 and its outstanding transfers. TX writes use a 16-transfer backpressure window; stop may queue one additional terminal flush before draining, to preserve the end-of-burst boundary. Steady-state RxStream::read and TxStream::write do not take the direction controller mutex; only start, stop, configuration, and shutdown do.

Configuration is allowed while the radio is off or streaming. A live control request has no sample-accurate application boundary; callers that need a clean configuration boundary should stop, configure, then start a stream.

WebUSB

On wasm32-unknown-unknown, request permission from a browser user gesture:

Device::builder().serial(serial).request_permission().await?;
let device = Device::open_serial(serial).await?;

The final application crate must enable the unstable web-sys WebUSB bindings. This repository's .cargo/config.toml contains the required setting. RX stop/start retains and resumes the existing queue, which avoids trying to cancel WebUSB bulk-IN transfers or create a second queue during a TX handoff. Samples already buffered before stop may be returned after start. Dropping an RX stream after it has started permanently reserves its browser queue for the device session; reopen the device before creating a replacement RX stream.

Manually validate a browser handoff on connected hardware by repeating RX start/readstop, TX start/write/stop, then RX start/read in one device session.

Hardware tests

Examples and hardware tests are gated and do not touch USB unless explicitly requested:

cargo run --example rx_sync -- --run
cargo run --example rx_sync -- --run --rx
cargo run --features smol --example rx_async -- --run --rx
cargo test --test hardware --features smol -- --ignored --test-threads=1 --nocapture