feat: add UAC1 loopback test device and fix endpoint attribute dispatch

Add a simulated USB Audio Class 1 loopback device for testing
isochronous transfers. Audio sent to the playback OUT endpoint
(48kHz/16-bit/stereo) is looped back to the capture IN endpoint.

- Add UsbEndpoint::transfer_type() masking bmAttributes to bits 0-1,
  fixing dispatch for isochronous endpoints with sync-type sub-bits
- Update all endpoint attribute dispatch sites across the library
- Add UacLoopbackBuffer, UacControlHandler, UacStreamOutHandler,
  UacStreamInHandler in lib/src/uac.rs
- Add build_uac_loopback_device() builder function
- Add `test_uac connect` CLI subcommand
- Add 10 unit tests covering buffer, descriptors, and handler behavior
- Add design spec and implementation plan docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Davíð Steinn Geirsson 2026-03-25 01:43:31 +00:00
parent e4cdc4beec
commit 18a413870a
14 changed files with 1970 additions and 50 deletions

View file

@ -1,17 +0,0 @@
# usbip
## linux modules
- vhci-hcd: client side, virtual usb host controller
- usbip-host: server side, bound to usb device to make it exportable
- usbip-vudc: server side, export a usb gadget
### usbip-host
Files:
- drivers/usb/usbip/stub.h
- drivers/usb/usbip/stub_main.c
- drivers/usb/usbip/stub_dev.c
- drivers/usb/usbip/stub_rx.c
- drivers/usb/usbip/stub_tx.c

View file

@ -1,15 +1,18 @@
# usbip-rs
A USB/IP tool and library for Linux. Share physical USB devices over vsock or TCP, or export simulated devices for testing.
Implements a userspace server-side server for the usbip protocol, and a tool for the client-side to set up the socket and hand it off to the kernel. In usbip terminology, the "server"/"host" is the side that has the physical device being shared, and the "client" is the side the device is being shared to.
## Overview
This project is based on [https://github.com/jiegec/usbip](jiegec/usbip), with significant changes and additions.
usbip-rs provides:
## Why?
- **CLI tool** (`usbip-rs`) — pass through real USB devices from a host to a client over vsock or TCP, using Linux's vhci_hcd kernel module
- **Rust library** (`usbip-rs` crate) — implement custom USB/IP device handlers, with built-in HID keyboard and CDC ACM serial examples
Implementing the server side in userspace instead of the kernel's `usbip_host` driver reduces the attack surface considerably. The kernel is not exposed directly to potentially untrusted network input. The userspace server talks to the USB device via /dev/bus/usb/..., and can validate the client's traffic before passing it on to the real USB device. It also makes it possible to implement strong sandboxing via seccomp and namespaces for the server side. The client side is of course still all in the kernel, since making the USB devices visible to the client kernel's drivers is the whole point. But for my use case, the server side is trusted and client side is untrusted, so securing the host side is critical.
Supported transfer types: control, bulk and interrupt. Isochronous support is being worked on.
There are a few other userspace usbip server implementations around, but as far as I could tell, none of them support isochronous mode. Isochronous is needed for e.g. USB audio devices and webcams. This project does, though my testing so far is limited to USB audio devices which work well.
This implementation also allows for reversing the connection flow. In standard usbip, the server side listens for connections, and the client side connects to it to access the device. usbip-rs supports having the client listen and the server initiating the connection, which maps much better to my needs.
usbip-rs also supports using vsock instead of TCP as the transport, making the "ip" in "usbip" not really true. This is useful when sharing a device to a virtual machine on the same hardware.
## Building
@ -31,7 +34,7 @@ The CLI binary is at `target/release/usbip-rs`.
## Usage
The CLI has three top-level commands: `client`, `host`, and `test_hid`. Transport addresses use the format `vsock:<port>`, `vsock:<cid>:<port>`, `tcp:<port>`, or `tcp:<host>:<port>`.
The CLI has four top-level commands: `client`, `host`, `test_hid`, and `test_uac`. Transport addresses use the format `vsock:<port>`, `vsock:<cid>:<port>`, `tcp:<port>`, `tcp:<host>:<port>`, or `fc-vsock:<path>:<port>`.
### Pass through a real USB device
@ -59,24 +62,17 @@ usbip-rs client listen vsock:5000
usbip-rs test_hid connect vsock:5000
```
### Library examples
The `lib/examples/` directory contains standalone USB/IP servers using the library directly:
- **hid_keyboard** — simulated HID keyboard
- **cdc_acm_serial** — simulated CDC ACM serial device
- **host** — physical device passthrough (legacy, single-device)
### Test with a simulated UAC1 loopback audio device
```bash
cargo run -p usbip-rs --example hid_keyboard
# Client side
usbip-rs client listen tcp:3240
# Host side (simulated USB audio device, no real device needed)
usbip-rs test_uac connect tcp:3240
```
These listen on the standard USB/IP port and work with the Linux `usbip` userspace tools:
```bash
usbip list -r <host-ip>
usbip attach -r <host-ip> -b <bus-id>
```
The UAC1 loopback device advertises 48kHz 16-bit stereo playback and capture. Audio sent to the playback endpoint is looped back to the capture endpoint.
## Project structure

View file

@ -3,6 +3,7 @@ use clap::{Parser, Subcommand};
mod client;
mod host;
mod test_hid;
mod test_uac;
mod transport;
mod vhci;
@ -31,6 +32,12 @@ enum Commands {
#[command(subcommand)]
action: TestHidAction,
},
/// Export a simulated UAC1 loopback device for testing
#[command(name = "test_uac")]
TestUac {
#[command(subcommand)]
action: TestUacAction,
},
}
#[derive(Subcommand)]
@ -62,6 +69,15 @@ enum TestHidAction {
},
}
#[derive(Subcommand)]
enum TestUacAction {
/// Connect to a listening client with a simulated UAC1 loopback device
Connect {
/// Transport address: vsock:[<cid>:]<port>, tcp:[<host>:]<port>, or fc-vsock:<path>:<port>
address: String,
},
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_default_env()
@ -89,6 +105,12 @@ async fn main() {
test_hid::run(addr).await
}
},
Commands::TestUac { action } => match action {
TestUacAction::Connect { address } => {
let addr = transport::parse_address(&address).expect("Invalid transport address");
test_uac::run(addr).await
}
},
};
if let Err(e) = result {

46
cli/src/test_uac.rs Normal file
View file

@ -0,0 +1,46 @@
use log::info;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use usbip_rs::{UsbDevice, usbip_protocol::UsbIpResponse};
use crate::transport;
/// Send handshake and run URB loop.
async fn do_test_uac_session<S: AsyncReadExt + AsyncWriteExt + Unpin + Send + 'static>(
mut stream: S,
device: UsbDevice,
) -> std::io::Result<()> {
let handshake = UsbIpResponse::op_rep_import_success(&device).to_bytes()?;
stream
.write_all(&handshake)
.await
.map_err(|e| std::io::Error::other(format!("Failed to send handshake: {e}")))?;
info!("Handshake sent, entering URB loop");
usbip_rs::handle_urb_loop(stream, Arc::new(device)).await
}
pub async fn run(addr: transport::TransportAddr) -> std::io::Result<()> {
let device = usbip_rs::uac::build_uac_loopback_device()?;
info!(
"Created simulated UAC1 loopback device {:04x}:{:04x}",
device.vendor_id, device.product_id
);
match addr {
transport::TransportAddr::Vsock(v) => {
let stream = transport::connect_vsock(v.cid, v.port).await?;
do_test_uac_session(stream, device).await
}
transport::TransportAddr::Tcp(ref t) => {
let stream = transport::connect_tcp(t).await?;
do_test_uac_session(stream, device).await
}
transport::TransportAddr::FcVsock(ref fc) => {
let stream = transport::connect_fc_vsock(fc).await?;
do_test_uac_session(stream, device).await
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,195 @@
# UAC1 Loopback Test Device Design
## Overview
Add a simulated USB Audio Class 1 (UAC1) loopback device to verify isochronous transfer functionality. The device has one playback output (ISO OUT) and one capture input (ISO IN). Audio sent to the playback endpoint is looped back and returned from the capture endpoint.
**Audio format:** 48kHz, 16-bit, stereo (192 bytes/frame at 1ms full-speed intervals).
## Module Structure
**File: `lib/src/uac.rs`**
Three handler structs with a shared loopback buffer:
### UacLoopbackBuffer
Shared ring buffer connecting playback OUT to capture IN.
- Inner storage: `Mutex<VecDeque<u8>>`
- Capacity: ~960 bytes (5 frames, absorbs jitter without unbounded growth)
- `write(data: &[u8])` — appends; drops oldest samples if over capacity
- `read(len: usize) -> Vec<u8>` — drains up to `len` bytes, pads with silence (zeros) on underrun
### UacControlHandler
Handles the AudioControl interface (interface 0).
- EP0 class-specific control requests: returns STALL (`status = -32` / `-EPIPE`) for all unknown/unsupported requests. No Feature Unit in the topology means no volume/mute requests to handle.
- `get_class_specific_descriptor()` — returns the AC Header + Input/Output Terminal chain.
### UacStreamOutHandler (playback)
Handles AudioStreaming interface 1, alt setting 1 (ISO OUT).
- ISO OUT: extracts audio data from each ISO packet, writes to `UacLoopbackBuffer`. Returns success descriptors (`actual_length = length` per packet).
- `get_class_specific_descriptor()` — returns AS General + Format Type I descriptor.
- `set_alt_setting()` — clears the loopback buffer on alt switch to prevent stale data.
### UacStreamInHandler (capture)
Handles AudioStreaming interface 2, alt setting 1 (ISO IN).
- ISO IN: reads from `UacLoopbackBuffer` to fill each ISO packet. On underrun, fills with silence (zeros). Returns descriptors with `actual_length` set to packet size.
- `get_class_specific_descriptor()` — returns AS General + Format Type I descriptor.
### Builder Function
```rust
pub fn build_uac_loopback_device() -> std::io::Result<UsbDevice>
```
Constructs the full `UsbDevice` with all three interfaces, alt settings, descriptors, and wired-up handlers. Keeps descriptor complexity out of the CLI.
## Library Fix: EndpointAttributes transfer type masking
The `EndpointAttributes` enum (values 0-3) represents the base transfer type, but per the USB spec, `bmAttributes` encodes sync type and usage type in bits 2-5 for isochronous endpoints (e.g., `0x0D` = isochronous adaptive, `0x05` = isochronous async). The library currently compares or parses `ep.attributes` directly against the enum, which fails when sub-bits are set.
**Fix:** Add a `transfer_type()` method to `UsbEndpoint` that masks to bits 0-1:
```rust
impl UsbEndpoint {
pub fn transfer_type(&self) -> Option<EndpointAttributes> {
FromPrimitive::from_u8(self.attributes & 0x03)
}
}
```
Update all dispatch sites to use `ep.transfer_type()` instead of `FromPrimitive::from_u8(ep.attributes)` or `ep.attributes == EndpointAttributes::X as u8`:
- `device.rs:377` — main URB dispatch (`FromPrimitive::from_u8(ep.attributes)`)
- `host.rs` — nusb and rusb handler comparisons (`ep.attributes == ...`)
- `cdc.rs:59` — CDC handler comparison
- `util.rs:123` — ISO loopback test handler comparison
This allows the UAC device to use spec-compliant attribute values while keeping existing devices (which use base values like `0x01`) working unchanged.
## USB Descriptor Topology
### Device Level
- `device_class: 0x00` (per-interface)
- `vendor_id: 0x1234`, `product_id: 0x5678` (arbitrary test values)
- `usb_version: 2.0`, `device_bcd: 1.0`
- Speed: Full-speed (`UsbSpeed::Full as u32`, UAC1 isochronous at 1ms intervals)
### Interface 0 — AudioControl
- Alt 0 only (no alt settings needed)
- `class=0x01` (Audio), `subclass=0x01` (AudioControl), `protocol=0x00`
- No endpoints (AC interrupt endpoint is optional in UAC1)
- Class-specific descriptor chain:
- **AC Header** — links to streaming interfaces 1 and 2, `wTotalLength=52` (10+12+9+12+9)
- **Input Terminal 1** (USB streaming, ID=1) — receives playback from host
- **Output Terminal 2** (speaker, ID=2) — source=IT1
- **Input Terminal 3** (microphone, ID=3) — capture source
- **Output Terminal 4** (USB streaming, ID=4) — source=IT3
### Interface 1 — AudioStreaming OUT (playback)
- **Alt 0:** zero-bandwidth (no endpoints, required by UAC1 spec)
- **Alt 1:** active
- `class=0x01` (Audio), `subclass=0x02` (AudioStreaming), `protocol=0x00`
- Class-specific: AS General (terminal link=IT1) + Format Type I (48kHz, 16-bit, stereo)
- Endpoint: `0x01` (OUT), isochronous adaptive, `bmAttributes=0x0D`, `max_packet_size=192`, interval=1
### Interface 2 — AudioStreaming IN (capture)
- **Alt 0:** zero-bandwidth (no endpoints)
- **Alt 1:** active
- Same class/subclass/protocol as interface 1
- Class-specific: AS General (terminal link=OT4) + Format Type I (48kHz, 16-bit, stereo)
- Endpoint: `0x82` (IN), isochronous async, `bmAttributes=0x05`, `max_packet_size=192`, interval=1
## Handler Behavior Details
### Control Request Handling
The AudioControl interface has no Feature Unit (no volume/mute controls). All class-specific control requests receive a STALL response (`status = -32`). The Linux audio driver probes for optional features and handles STALLs gracefully.
### Loopback Data Flow
```
Host playback OUT → UacStreamOutHandler → UacLoopbackBuffer → UacStreamInHandler → Host capture IN
```
- OUT handler writes samples into the ring buffer
- IN handler reads samples from the ring buffer
- Buffer overflow: oldest samples dropped, newest preserved
- Buffer underrun: silence (zeros) returned
### Alt Setting Transitions
UAC1 requires zero-bandwidth alt setting 0 for streaming interfaces. The host driver activates alt setting 1 to start streaming. On `set_alt_setting()`, the OUT handler clears the loopback buffer.
Both alt 0 and alt 1 of each streaming interface share the same handler `Arc`. This is required because `set_alt_setting()` is called on the current active handler before the switch — sharing the `Arc` ensures the notification reaches the right handler regardless of which alt setting is active.
## CLI Subcommand
**File: `cli/src/test_uac.rs`**
```
usbip-rs test_uac connect <address>
```
Follows the `test_hid` pattern:
1. Calls `usbip_rs::uac::build_uac_loopback_device()`
2. Connects to client via transport (vsock/tcp/fc-vsock)
3. Sends OP_REP_IMPORT handshake
4. Runs `handle_urb_loop`
No background task needed — the loopback is entirely driven by the host's URBs.
**File: `cli/src/main.rs`** — add `TestUac` variant to the clap subcommand enum.
## Public API Surface
Exported from `lib/src/lib.rs` via `pub mod uac`:
- `UacLoopbackBuffer` — for test injection/inspection
- `UacControlHandler`
- `UacStreamOutHandler`
- `UacStreamInHandler`
- `build_uac_loopback_device() -> std::io::Result<UsbDevice>`
All public for test suite reuse. Tests can construct individual handlers with a shared buffer and drive them directly without building the full device.
## Testing
Unit tests in `lib/src/uac.rs`:
- **`desc_verify`** — validates each handler's class-specific descriptor via `verify_descriptor()`
- **`loopback_buffer_write_read`** — write data, read back, verify match
- **`loopback_buffer_underrun`** — read from empty buffer, verify silence
- **`loopback_buffer_overflow`** — write beyond capacity, verify oldest dropped, newest preserved
- **`stream_out_handler_iso`** — ISO OUT URB request, verify success response
- **`stream_in_handler_iso`** — write to buffer then ISO IN request, verify data returned
- **`stream_in_handler_silence`** — ISO IN with empty buffer, verify zero-filled packets
- **`control_handler_stall`** — unknown class request, verify STALL status (-32)
No integration tests in this scope — those will come when the broader test suite is built.
## Files Changed
| File | Change |
|------|--------|
| `lib/src/endpoint.rs` | Add `transfer_type()` method to `UsbEndpoint` |
| `lib/src/device.rs` | Use `ep.transfer_type()` in URB dispatch |
| `lib/src/host.rs` | Use `ep.transfer_type()` in handler comparisons |
| `lib/src/cdc.rs` | Use `ep.transfer_type()` in handler comparison |
| `lib/src/util.rs` | Use `ep.transfer_type()` in ISO loopback handler |
| `lib/src/uac.rs` | New — handlers, buffer, builder, tests |
| `lib/src/lib.rs` | Add `pub mod uac` |
| `cli/src/test_uac.rs` | New — CLI subcommand |
| `cli/src/main.rs` | Add `TestUac` subcommand variant |

View file

@ -56,7 +56,7 @@ impl UsbInterfaceHandler for UsbCdcAcmHandler {
) -> Result<UrbResponse> {
let ep = request.ep;
let req = &request.data;
if ep.attributes == EndpointAttributes::Interrupt as u8 {
if ep.transfer_type() == Some(EndpointAttributes::Interrupt) {
// interrupt
if let Direction::In = ep.direction() {
// interrupt in

View file

@ -43,7 +43,7 @@ pub enum ClassCode {
}
/// A list of defined USB endpoint attributes
#[derive(Copy, Clone, Debug, FromPrimitive)]
#[derive(Copy, Clone, Debug, FromPrimitive, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum EndpointAttributes {
Control = 0,

View file

@ -374,7 +374,7 @@ impl UsbDevice {
let setup_packet = request.setup.clone();
let out_data = request.data.clone();
match (FromPrimitive::from_u8(ep.attributes), ep.direction()) {
match (ep.transfer_type(), ep.direction()) {
(Some(Control), In) => {
// control in
debug!("Control IN setup={setup_packet:x?}");

View file

@ -28,4 +28,10 @@ impl UsbEndpoint {
pub fn is_ep0(&self) -> bool {
self.address & 0x7F == 0
}
/// Get the base transfer type from bmAttributes (bits 0-1).
/// This masks off the isochronous sync/usage sub-bits (bits 2-5).
pub fn transfer_type(&self) -> Option<EndpointAttributes> {
FromPrimitive::from_u8(self.attributes & 0x03)
}
}

View file

@ -43,7 +43,7 @@ impl UsbInterfaceHandler for RusbUsbHostInterfaceHandler {
let mut buffer = vec![0u8; transfer_buffer_length as usize];
let timeout = std::time::Duration::new(1, 0);
let handle = self.handle.lock().unwrap_or_else(|e| e.into_inner());
if ep.attributes == EndpointAttributes::Control as u8 {
if ep.transfer_type() == Some(EndpointAttributes::Control) {
// control
if let Direction::In = ep.direction() {
// control in
@ -75,7 +75,7 @@ impl UsbInterfaceHandler for RusbUsbHostInterfaceHandler {
)
})?;
}
} else if ep.attributes == EndpointAttributes::Interrupt as u8 {
} else if ep.transfer_type() == Some(EndpointAttributes::Interrupt) {
// interrupt
if let Direction::In = ep.direction() {
// interrupt in
@ -94,7 +94,7 @@ impl UsbInterfaceHandler for RusbUsbHostInterfaceHandler {
)
})?;
}
} else if ep.attributes == EndpointAttributes::Bulk as u8 {
} else if ep.transfer_type() == Some(EndpointAttributes::Bulk) {
// bulk
if let Direction::In = ep.direction() {
// bulk in
@ -110,7 +110,7 @@ impl UsbInterfaceHandler for RusbUsbHostInterfaceHandler {
)
})?;
}
} else if ep.attributes == EndpointAttributes::Isochronous as u8 {
} else if ep.transfer_type() == Some(EndpointAttributes::Isochronous) {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"isochronous transfers not supported on rusb handler",
@ -236,7 +236,7 @@ impl UsbInterfaceHandler for NusbUsbHostInterfaceHandler {
_ => std::time::Duration::from_secs(1), // control, bulk
};
let handle = self.handle.lock().unwrap_or_else(|e| e.into_inner());
if ep.attributes == EndpointAttributes::Control as u8 {
if ep.transfer_type() == Some(EndpointAttributes::Control) {
// control
if let Direction::In = ep.direction() {
// control in
@ -307,7 +307,7 @@ impl UsbInterfaceHandler for NusbUsbHostInterfaceHandler {
.wait()
.map_err(std::io::Error::from)?;
}
} else if ep.attributes == EndpointAttributes::Interrupt as u8 {
} else if ep.transfer_type() == Some(EndpointAttributes::Interrupt) {
// interrupt
// Release Mutex before blocking so other URBs on this interface aren't starved.
if let Direction::In = ep.direction() {
@ -359,7 +359,7 @@ impl UsbInterfaceHandler for NusbUsbHostInterfaceHandler {
let completion = endpoint.transfer_blocking(buffer, timeout);
completion.status.map_err(std::io::Error::from)?;
}
} else if ep.attributes == EndpointAttributes::Bulk as u8 {
} else if ep.transfer_type() == Some(EndpointAttributes::Bulk) {
// bulk
if let Direction::In = ep.direction() {
// bulk in - round up to max_packet_size as required by USB spec
@ -387,7 +387,7 @@ impl UsbInterfaceHandler for NusbUsbHostInterfaceHandler {
let completion = endpoint.transfer_blocking(buffer, timeout);
completion.status.map_err(std::io::Error::from)?;
}
} else if ep.attributes == EndpointAttributes::Isochronous as u8 {
} else if ep.transfer_type() == Some(EndpointAttributes::Isochronous) {
// Isochronous transfer
if request.iso_packet_descriptors.is_empty() {
warn!("ISO transfer on ep {:02x} but no packet descriptors", ep.address);

View file

@ -25,6 +25,7 @@ mod consts;
mod device;
mod endpoint;
pub mod hid;
pub mod uac;
mod host;
mod interface;
mod setup;

608
lib/src/uac.rs Normal file
View file

@ -0,0 +1,608 @@
//! USB Audio Class 1 (UAC1) loopback device for testing isochronous transfers.
use super::*;
/// Capacity in bytes: 5 frames of 48kHz 16-bit stereo (5 * 192 = 960).
const LOOPBACK_BUFFER_CAPACITY: usize = 960;
/// Shared ring buffer connecting playback OUT to capture IN.
#[derive(Debug)]
pub struct UacLoopbackBuffer {
inner: Mutex<VecDeque<u8>>,
}
impl UacLoopbackBuffer {
pub fn new() -> Self {
Self {
inner: Mutex::new(VecDeque::with_capacity(LOOPBACK_BUFFER_CAPACITY)),
}
}
/// Write audio data into the buffer. Drops oldest samples if over capacity.
pub fn write(&self, data: &[u8]) {
let mut buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
buf.extend(data);
let excess = buf.len().saturating_sub(LOOPBACK_BUFFER_CAPACITY);
if excess > 0 {
buf.drain(..excess);
}
}
/// Read up to `len` bytes from the buffer. Pads with silence on underrun.
pub fn read(&self, len: usize) -> Vec<u8> {
let mut buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let available = buf.len().min(len);
let mut result: Vec<u8> = buf.drain(..available).collect();
result.resize(len, 0); // pad with silence
result
}
/// Clear all buffered data.
pub fn clear(&self) {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
}
/// Handler for the AudioControl interface (interface 0).
/// STALLs all class-specific requests — no Feature Unit in this topology.
#[derive(Debug)]
pub struct UacControlHandler;
impl UacControlHandler {
pub fn new() -> Self {
Self
}
}
impl UsbInterfaceHandler for UacControlHandler {
fn handle_urb(
&self,
_interface: &UsbInterface,
request: UrbRequest,
) -> Result<UrbResponse> {
// STALL all class-specific control requests (no Feature Unit)
if request.ep.is_ep0() {
return Ok(UrbResponse { status: -32, ..Default::default() }); // EPIPE = STALL
}
Ok(UrbResponse::default())
}
fn get_class_specific_descriptor(&self) -> Vec<u8> {
vec![
// --- AC Interface Header (UAC1 spec Table 4-2) ---
0x0A, // bLength: 10
0x24, // bDescriptorType: CS_INTERFACE
0x01, // bDescriptorSubtype: HEADER
0x00, 0x01, // bcdADC: 1.00
0x34, 0x00, // wTotalLength: 52 (10 + 12 + 9 + 12 + 9)
0x02, // bInCollection: 2 streaming interfaces
0x01, // baInterfaceNr(1): interface 1
0x02, // baInterfaceNr(2): interface 2
// --- Input Terminal 1: USB Streaming (playback source) ---
0x0C, // bLength: 12
0x24, // bDescriptorType: CS_INTERFACE
0x02, // bDescriptorSubtype: INPUT_TERMINAL
0x01, // bTerminalID: 1
0x01, 0x01, // wTerminalType: USB Streaming (0x0101)
0x00, // bAssocTerminal: 0
0x02, // bNrChannels: 2 (stereo)
0x03, 0x00, // wChannelConfig: left + right
0x00, // iChannelNames: 0
0x00, // iTerminal: 0
// --- Output Terminal 2: Speaker (playback sink) ---
0x09, // bLength: 9
0x24, // bDescriptorType: CS_INTERFACE
0x03, // bDescriptorSubtype: OUTPUT_TERMINAL
0x02, // bTerminalID: 2
0x01, 0x03, // wTerminalType: Speaker (0x0301)
0x00, // bAssocTerminal: 0
0x01, // bSourceID: 1 (Input Terminal 1)
0x00, // iTerminal: 0
// --- Input Terminal 3: Microphone (capture source) ---
0x0C, // bLength: 12
0x24, // bDescriptorType: CS_INTERFACE
0x02, // bDescriptorSubtype: INPUT_TERMINAL
0x03, // bTerminalID: 3
0x01, 0x02, // wTerminalType: Microphone (0x0201)
0x00, // bAssocTerminal: 0
0x02, // bNrChannels: 2 (stereo)
0x03, 0x00, // wChannelConfig: left + right
0x00, // iChannelNames: 0
0x00, // iTerminal: 0
// --- Output Terminal 4: USB Streaming (capture sink) ---
0x09, // bLength: 9
0x24, // bDescriptorType: CS_INTERFACE
0x03, // bDescriptorSubtype: OUTPUT_TERMINAL
0x04, // bTerminalID: 4
0x01, 0x01, // wTerminalType: USB Streaming (0x0101)
0x00, // bAssocTerminal: 0
0x03, // bSourceID: 3 (Input Terminal 3)
0x00, // iTerminal: 0
]
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Handler for AudioStreaming OUT interface (playback).
/// Writes received audio data into the shared loopback buffer.
#[derive(Debug)]
pub struct UacStreamOutHandler {
buffer: Arc<UacLoopbackBuffer>,
}
impl UacStreamOutHandler {
pub fn new(buffer: Arc<UacLoopbackBuffer>) -> Self {
Self { buffer }
}
}
impl UsbInterfaceHandler for UacStreamOutHandler {
fn handle_urb(
&self,
_interface: &UsbInterface,
request: UrbRequest,
) -> Result<UrbResponse> {
if request.ep.is_ep0() {
return Ok(UrbResponse { status: -32, ..Default::default() });
}
// ISO OUT: write audio data into loopback buffer
if request.ep.transfer_type() == Some(EndpointAttributes::Isochronous) {
let descriptors: Vec<IsoPacketDescriptor> = request
.iso_packet_descriptors
.iter()
.map(|d| {
let offset = d.offset as usize;
let length = d.length as usize;
if offset + length <= request.data.len() {
self.buffer.write(&request.data[offset..offset + length]);
}
IsoPacketDescriptor {
offset: d.offset,
length: d.length,
actual_length: d.length,
status: 0,
}
})
.collect();
return Ok(UrbResponse {
data: vec![],
iso_packet_descriptors: descriptors,
status: 0,
start_frame: request.start_frame,
error_count: 0,
});
}
Ok(UrbResponse::default())
}
fn get_class_specific_descriptor(&self) -> Vec<u8> {
vec![
// --- AS General Descriptor (UAC1 spec Table 4-19) ---
0x07, // bLength: 7
0x24, // bDescriptorType: CS_INTERFACE
0x01, // bDescriptorSubtype: AS_GENERAL
0x01, // bTerminalLink: 1 (Input Terminal 1 — playback)
0x01, // bDelay: 1 frame
0x01, 0x00, // wFormatTag: PCM (0x0001)
// --- Format Type I Descriptor (UAC1 spec Table 2-1, Format Type I) ---
0x0B, // bLength: 11
0x24, // bDescriptorType: CS_INTERFACE
0x02, // bDescriptorSubtype: FORMAT_TYPE
0x01, // bFormatType: FORMAT_TYPE_I
0x02, // bNrChannels: 2 (stereo)
0x02, // bSubframeSize: 2 bytes (16-bit)
0x10, // bBitResolution: 16
0x01, // bSamFreqType: 1 (one discrete frequency)
0x80, 0xBB, 0x00, // tSamFreq: 48000 Hz (0x00BB80, little-endian)
]
}
fn set_alt_setting(&self, _alt: u8) -> Result<()> {
self.buffer.clear();
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Handler for AudioStreaming IN interface (capture).
/// Reads audio data from the shared loopback buffer; returns silence on underrun.
#[derive(Debug)]
pub struct UacStreamInHandler {
buffer: Arc<UacLoopbackBuffer>,
}
impl UacStreamInHandler {
pub fn new(buffer: Arc<UacLoopbackBuffer>) -> Self {
Self { buffer }
}
}
impl UsbInterfaceHandler for UacStreamInHandler {
fn handle_urb(
&self,
_interface: &UsbInterface,
request: UrbRequest,
) -> Result<UrbResponse> {
if request.ep.is_ep0() {
return Ok(UrbResponse { status: -32, ..Default::default() });
}
// ISO IN: read audio data from loopback buffer
if request.ep.transfer_type() == Some(EndpointAttributes::Isochronous) {
let mut data = Vec::new();
let descriptors: Vec<IsoPacketDescriptor> = request
.iso_packet_descriptors
.iter()
.map(|d| {
let packet_data = self.buffer.read(d.length as usize);
data.extend_from_slice(&packet_data);
IsoPacketDescriptor {
offset: d.offset,
length: d.length,
actual_length: d.length,
status: 0,
}
})
.collect();
return Ok(UrbResponse {
data,
iso_packet_descriptors: descriptors,
status: 0,
start_frame: request.start_frame,
error_count: 0,
});
}
Ok(UrbResponse::default())
}
fn get_class_specific_descriptor(&self) -> Vec<u8> {
vec![
// --- AS General Descriptor ---
0x07, // bLength: 7
0x24, // bDescriptorType: CS_INTERFACE
0x01, // bDescriptorSubtype: AS_GENERAL
0x04, // bTerminalLink: 4 (Output Terminal 4 — capture)
0x01, // bDelay: 1 frame
0x01, 0x00, // wFormatTag: PCM (0x0001)
// --- Format Type I Descriptor ---
0x0B, // bLength: 11
0x24, // bDescriptorType: CS_INTERFACE
0x02, // bDescriptorSubtype: FORMAT_TYPE
0x01, // bFormatType: FORMAT_TYPE_I
0x02, // bNrChannels: 2 (stereo)
0x02, // bSubframeSize: 2 bytes (16-bit)
0x10, // bBitResolution: 16
0x01, // bSamFreqType: 1 (one discrete frequency)
0x80, 0xBB, 0x00, // tSamFreq: 48000 Hz
]
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Build a complete UAC1 loopback device with AudioControl + two AudioStreaming interfaces.
pub fn build_uac_loopback_device() -> std::io::Result<UsbDevice> {
let buffer = Arc::new(UacLoopbackBuffer::new());
let control_handler: Arc<dyn UsbInterfaceHandler> = Arc::new(UacControlHandler::new());
let out_handler: Arc<dyn UsbInterfaceHandler> = Arc::new(UacStreamOutHandler::new(buffer.clone()));
let in_handler: Arc<dyn UsbInterfaceHandler> = Arc::new(UacStreamInHandler::new(buffer));
let mut device = UsbDevice::new(0)?;
device.vendor_id = 0x1234;
device.product_id = 0x5678;
device.device_class = 0x00; // per-interface
device.speed = UsbSpeed::Full as u32;
device.usb_version = device::Version { major: 2, minor: 0, patch: 0 };
device.device_bcd = device::Version { major: 1, minor: 0, patch: 0 };
device.string_manufacturer = device.new_string("Test")?;
device.string_product = device.new_string("UAC1 Loopback")?;
// Interface 0: AudioControl (single alt setting, no endpoints)
let ac_class_desc = control_handler.get_class_specific_descriptor();
device.interface_states.push(InterfaceState::new(UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x01, // AudioControl
interface_protocol: 0x00,
endpoints: vec![],
string_interface: 0,
class_specific_descriptor: ac_class_desc,
handler: control_handler,
}));
// Interface 1: AudioStreaming OUT (playback) — alt 0 (zero-bw) + alt 1 (active)
let out_class_desc = out_handler.get_class_specific_descriptor();
let out_alt0 = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x02, // AudioStreaming
interface_protocol: 0x00,
endpoints: vec![], // zero-bandwidth
string_interface: 0,
class_specific_descriptor: vec![],
handler: out_handler.clone(),
};
let out_alt1 = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x02,
interface_protocol: 0x00,
endpoints: vec![UsbEndpoint {
address: 0x01, // OUT
attributes: 0x0D, // isochronous adaptive
max_packet_size: 192,
interval: 1,
}],
string_interface: 0,
class_specific_descriptor: out_class_desc,
handler: out_handler,
};
device.interface_states.push(InterfaceState::with_alt_settings(vec![out_alt0, out_alt1]));
// Interface 2: AudioStreaming IN (capture) — alt 0 (zero-bw) + alt 1 (active)
let in_class_desc = in_handler.get_class_specific_descriptor();
let in_alt0 = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x02,
interface_protocol: 0x00,
endpoints: vec![],
string_interface: 0,
class_specific_descriptor: vec![],
handler: in_handler.clone(),
};
let in_alt1 = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x02,
interface_protocol: 0x00,
endpoints: vec![UsbEndpoint {
address: 0x82, // IN
attributes: 0x05, // isochronous async
max_packet_size: 192,
interval: 1,
}],
string_interface: 0,
class_specific_descriptor: in_class_desc,
handler: in_handler,
};
device.interface_states.push(InterfaceState::with_alt_settings(vec![in_alt0, in_alt1]));
Ok(device)
}
#[cfg(test)]
mod tests {
use crate::util::tests::*;
use super::*;
#[test]
fn loopback_buffer_write_read() {
let buf = UacLoopbackBuffer::new();
buf.write(&[1, 2, 3, 4]);
assert_eq!(buf.read(4), vec![1, 2, 3, 4]);
// Buffer should be empty now
assert_eq!(buf.read(2), vec![0, 0]);
}
#[test]
fn loopback_buffer_underrun() {
let buf = UacLoopbackBuffer::new();
let result = buf.read(4);
assert_eq!(result, vec![0, 0, 0, 0]);
}
#[test]
fn loopback_buffer_overflow() {
let buf = UacLoopbackBuffer::new();
// Write more than capacity: 100 old bytes + CAPACITY new bytes
let mut big_data = vec![0xBB; 100];
big_data.extend(vec![0xAA; LOOPBACK_BUFFER_CAPACITY]);
buf.write(&big_data);
// Should have dropped the oldest 100 bytes (0xBB), kept newest CAPACITY (0xAA)
let result = buf.read(LOOPBACK_BUFFER_CAPACITY);
assert_eq!(result.len(), LOOPBACK_BUFFER_CAPACITY);
assert!(result.iter().all(|&b| b == 0xAA), "oldest bytes should have been dropped");
// Buffer is now empty
assert_eq!(buf.read(1), vec![0]);
}
#[test]
fn desc_verify_control() {
setup_test_logger();
let handler = UacControlHandler::new();
verify_descriptor(&handler.get_class_specific_descriptor());
}
#[test]
fn control_handler_stall() {
setup_test_logger();
let handler = UacControlHandler::new();
let interface = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x01,
interface_protocol: 0x00,
endpoints: vec![],
string_interface: 0,
class_specific_descriptor: handler.get_class_specific_descriptor(),
handler: Arc::new(UacControlHandler::new()),
};
// Send an unknown class request (SET_CUR to some unit)
let request = UrbRequest {
ep: UsbEndpoint {
address: 0x00,
attributes: EndpointAttributes::Control as u8,
max_packet_size: 64,
interval: 0,
},
setup: SetupPacket {
request_type: 0x21, // class, interface, host-to-device
request: 0x01, // SET_CUR
value: 0x0100,
index: 0x0000,
length: 0,
},
..Default::default()
};
let response = handler.handle_urb(&interface, request).unwrap();
assert_eq!(response.status, -32); // EPIPE = STALL
}
#[test]
fn desc_verify_stream_out() {
setup_test_logger();
let handler = UacStreamOutHandler::new(Arc::new(UacLoopbackBuffer::new()));
verify_descriptor(&handler.get_class_specific_descriptor());
}
#[test]
fn stream_out_handler_iso() {
setup_test_logger();
let buffer = Arc::new(UacLoopbackBuffer::new());
let handler = UacStreamOutHandler::new(buffer.clone());
let interface = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x02,
interface_protocol: 0x00,
endpoints: vec![UsbEndpoint {
address: 0x01,
attributes: 0x0D, // isochronous adaptive
max_packet_size: 192,
interval: 1,
}],
string_interface: 0,
class_specific_descriptor: handler.get_class_specific_descriptor(),
handler: Arc::new(UacStreamOutHandler::new(Arc::new(UacLoopbackBuffer::new()))),
};
let audio_data: Vec<u8> = (0..192).map(|i| (i & 0xFF) as u8).collect();
let request = UrbRequest {
ep: UsbEndpoint {
address: 0x01,
attributes: 0x0D,
max_packet_size: 192,
interval: 1,
},
data: audio_data.clone(),
number_of_packets: 1,
iso_packet_descriptors: vec![IsoPacketDescriptor {
offset: 0,
length: 192,
actual_length: 0,
status: 0,
}],
..Default::default()
};
let response = handler.handle_urb(&interface, request).unwrap();
assert_eq!(response.status, 0);
assert_eq!(response.iso_packet_descriptors.len(), 1);
assert_eq!(response.iso_packet_descriptors[0].actual_length, 192);
// Verify data was written to loopback buffer
assert_eq!(buffer.read(192), audio_data);
}
#[test]
fn desc_verify_stream_in() {
setup_test_logger();
let handler = UacStreamInHandler::new(Arc::new(UacLoopbackBuffer::new()));
verify_descriptor(&handler.get_class_specific_descriptor());
}
#[test]
fn stream_in_handler_iso() {
setup_test_logger();
let buffer = Arc::new(UacLoopbackBuffer::new());
let handler = UacStreamInHandler::new(buffer.clone());
let interface = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x02,
interface_protocol: 0x00,
endpoints: vec![UsbEndpoint {
address: 0x82,
attributes: 0x05, // isochronous async
max_packet_size: 192,
interval: 1,
}],
string_interface: 0,
class_specific_descriptor: handler.get_class_specific_descriptor(),
handler: Arc::new(UacStreamInHandler::new(Arc::new(UacLoopbackBuffer::new()))),
};
// Pre-fill buffer with known data
let audio_data: Vec<u8> = (0..192).map(|i| (i & 0xFF) as u8).collect();
buffer.write(&audio_data);
let request = UrbRequest {
ep: UsbEndpoint {
address: 0x82,
attributes: 0x05,
max_packet_size: 192,
interval: 1,
},
transfer_buffer_length: 192,
number_of_packets: 1,
iso_packet_descriptors: vec![IsoPacketDescriptor {
offset: 0,
length: 192,
actual_length: 0,
status: 0,
}],
..Default::default()
};
let response = handler.handle_urb(&interface, request).unwrap();
assert_eq!(response.status, 0);
assert_eq!(response.data, audio_data);
assert_eq!(response.iso_packet_descriptors.len(), 1);
assert_eq!(response.iso_packet_descriptors[0].actual_length, 192);
}
#[test]
fn stream_in_handler_silence() {
setup_test_logger();
let buffer = Arc::new(UacLoopbackBuffer::new());
let handler = UacStreamInHandler::new(buffer);
let interface = UsbInterface {
interface_class: ClassCode::Audio as u8,
interface_subclass: 0x02,
interface_protocol: 0x00,
endpoints: vec![],
string_interface: 0,
class_specific_descriptor: handler.get_class_specific_descriptor(),
handler: Arc::new(UacStreamInHandler::new(Arc::new(UacLoopbackBuffer::new()))),
};
let request = UrbRequest {
ep: UsbEndpoint {
address: 0x82,
attributes: 0x05,
max_packet_size: 192,
interval: 1,
},
transfer_buffer_length: 192,
number_of_packets: 1,
iso_packet_descriptors: vec![IsoPacketDescriptor {
offset: 0,
length: 192,
actual_length: 0,
status: 0,
}],
..Default::default()
};
let response = handler.handle_urb(&interface, request).unwrap();
assert_eq!(response.status, 0);
// All silence (zeros) because buffer is empty
assert_eq!(response.data, vec![0u8; 192]);
}
}

View file

@ -120,7 +120,7 @@ pub(crate) mod tests {
) -> std::io::Result<UrbResponse> {
let ep = request.ep;
if ep.attributes == EndpointAttributes::Isochronous as u8 {
if ep.transfer_type() == Some(EndpointAttributes::Isochronous) {
if let crate::Direction::In = ep.direction() {
// ISO IN: generate test data
let mut data = Vec::new();