Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ main thread
│ ├── URB receive thread (reads USB/IP commands from client)
│ ├── URB submit thread (forwards to physical USB device)
│ └── URB reply thread (sends responses back to client)
└── device monitor thread (libusb hotplug callbacks)
└── device monitor (deferred to v1.1 per ADR-0005: libusb hotplug callbacks;
poll-based HotplugMonitor was removed — issue #29)
```

### Client Threads
Expand Down Expand Up @@ -271,15 +272,21 @@ battery_optimization_bypass = true
## Dependency Map

```
usbip-core (Rust, no_std capable)
usbip-core (Rust)
├── byteorder
├── crc32fast
├── zerocopy (safe transmutes)
└── thiserror
├── crc32fast
├── thiserror
├── tracing (structured logging)
├── ring (AES-GCM, X25519, HKDF)
├── uuid (correlation IDs)
├── crossbeam
├── rand
└── tokio (async message framing)

usbip-server (Rust)
├── usbip-core
├── libusb (via rusb)
├── rusb (libusb)
├── mdns-sd
├── tokio (async runtime)
├── tracing (structured logging)
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ cargo fmt --all -- --check # rustfmt is configured — s
cargo clippy --workspace -- -D warnings # CI gate; treat warnings as errors
```

Per-crate:
Per-crate (all three crates have integration tests under `tests/` plus inline `#[cfg(test)]` modules; see `find . -name '*.rs' -path '*/tests/*'` and `grep -rl '#[cfg(test)]'`):
```bash
cargo test --release -p usbip-core # only crate with real coverage today
cargo test --release -p usbip-server # passes vacuously
cargo test --release -p usbip-client # passes vacuously
cargo test --release -p usbip-core # protocol types + descriptor fixtures
cargo test --release -p usbip-server # wire protocol, REST API, encryption
cargo test --release -p usbip-client # VHCI injection seam + inline URB forwarding
```

Android (committed wrapper exists — `./gradlew`, not bare `gradle`):
Expand Down
38 changes: 38 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,41 @@ _Avoid_: Admin port, management port, HTTP port
UDP port for `_usbip._tcp.local` service advertisements. Link-local only —
does not cross subnets or VLANs.
_Avoid_: Discovery port, broadcast port

## Module ownership

This section names ONE owner per architectural concern. These claims are
authoritative; every other doc (CLAUDE.md, ARCHITECTURE.md, PROTOCOL.md,
ROADMAP.md, README.md, docs/*.md) must agree with them. When a doc disagrees,
the doc is wrong — fix the doc, not this section.

- **ConfigOwner**: `Server::app_state` (server/usbip-server/src/server.rs) —
the one place that reads and writes the merged config (`api::load_config` /
`AppState.config`). CLI flags in `main.rs` seed field values;
`app_state` reconciles them with the persisted file.
- **MetricsOwner**: `server/usbip-server/src/metrics.rs` —
`build_metrics_router` and the `ENCRYPTION_ENABLED` gauge are live (wired
from `main.rs`) and are the ONLY metrics present. The former
`DEVICES_EXPORTED` / `CLIENTS_CONNECTED` / `URB_SUBMIT_TOTAL` /
`URB_BYTES_TOTAL` statics were DELETED (issue #31): they were never wired.
If live export/connected/URB counters are ever needed again, add them at
the single site that owns each event, not as dead statics.
- **VhciBackendOwner**: `Client` (client/usbip-client/src/client.rs) — the
one place that constructs a `VhciBackend` (`detect_backend`),
and the injection seam `Client::new_with_vhci`.
- **HotplugOwner**: DEFERRED (issue #29). The poll-based `HotplugMonitor`
was DELETED (Path B); no v1.0 hotplug detection exists. ADR-0005 remains
the v1.1 blueprint (libusb/callback-based). Any future monitor is owned by
`Server` and must follow ADR-0005.
- **DiscoveryTxtOwner**: `usbip-core::discovery_txt` —
`shared/usbip-core/src/discovery_txt.rs` owns the `device` TXT wire format
(encode + decode: vid/pid/bus/name tuples, e.g.
`vid=0x046d,pid=0xc261,bus=1-1,n=046d:c261`). The server
(`discovery.rs`) advertises and delegates encode to it; the client
(`usbip-client::discovery`) decodes via it (issue #30). One seam.
- **AndroidStateOwner**: single Kotlin class managing Android connection /
runtime state — pending issue #32 (nominating the class is part of that work).
- **AndroidComposerOwner**: single Kotlin class composing the Compose UI
tree (phone and TV) — pending issue #32.
- **AndroidControllerOwner**: single Kotlin class owning UI event dispatch
and state mutation — pending issue #32.
17 changes: 11 additions & 6 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,24 +245,29 @@ When encryption is enabled, after the initial TCP handshake:
```
Client Server
│ │
ECDH key exchange (X25519)
[4-byte len=32][X25519 pubkey]
│────────────────────────────────────────►│
│ │
ECDH response
[4-byte len=32][X25519 pubkey]
│◄────────────────────────────────────────│
│ │
│ HKDF-SHA256 → AES-256-GCM session key │
│ │
│ All subsequent USB/IP messages: │
│ [4-byte ciphertext length][ciphertext] │
│ [4-byte ciphertext length BE] │
│ [ciphertext || 12-byte nonce || │
│ 16-byte GCM tag] │
│ Each message has unique 96-bit nonce │
│ (initialized from session key + seq) │
│ │
```

## 7. Compression Extension (optional)
The 4-byte ciphertext length prefix is big-endian. The nonce (12 bytes) and
GCM tag (16 bytes) are appended to the ciphertext within that length.

For bulk endpoints on slow links:
## 7. Compression Extension (optional — NOT implemented)

Designed for bulk endpoints on slow links. Not yet implemented in code;
this section describes the intended wire format for a future release.

```
After encryption (if enabled):
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Open the app on both — auto-discovery via mDNS. Tap the device on TV to connec
- **USB/IP protocol** — same as Linux kernel, battle-tested since 2008.
- **mDNS discovery** — no IP config needed, devices find each other.
- **AES-256-GCM encryption** — optional, for untrusted networks.
- **Sub-1ms per-URB latency** on Ethernet, 2-5ms on Wi-Fi 6.
- **Sub-2ms per-URB latency target** on Ethernet, 2-5ms on Wi-Fi 6.
- **Service mode** — runs headless, survives reboots.
- **Android TV UI** — D-pad navigable, remote-friendly.
- **Auto-reconnect** — survives network flaps and device cycles.
Expand All @@ -75,9 +75,9 @@ See [ARCHITECTURE.md](ARCHITECTURE.md).

## Latency Budget

HID URB round-trip on gigabit Ethernet: **~700 µs total RTT**.
HID URB round-trip target on gigabit Ethernet: **~1.5-3.5 ms total RTT** (see [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for the breakdown).

For FFB at 250 Hz (<4ms needed): 5x headroom on Ethernet, 2x on good Wi-Fi.
For FFB at 250 Hz (<4ms needed): comfortable headroom on Ethernet, marginal on Wi-Fi.

---

Expand Down
7 changes: 3 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Project roadmap and milestone tracking for the USB/IP passthrough system.
- [x] ROADMAP.md — milestone tracking
- [x] docs/SETUP.md — platform setup guides
- [x] docs/TROUBLESHOOTING.md — diagnosis and fixes
- [x] docs/DEVICES.md — device profiles and known quirks
- [ ] docs/DEVICES.md — device profiles and known quirks (file not yet created)
- [x] docs/ANDROID-TV.md — TV-specific guide
- [x] docs/PERFORMANCE.md — latency, benchmarks, tuning
- [x] docs/BUILDING.md — compilation from source
Expand Down Expand Up @@ -163,11 +163,11 @@ not just the original reference hardware. Delivered per PRD #1.
## Milestone 13: Reliability ✅

- [x] Structured errors with correlation IDs (per ADR-0003: must come first)
- [x] Hot-plug detection (device attach/detach after server start)
- [ ] Hot-plug detection (device attach/detach after server start) — v1.1; poll-based `hotplug.rs` was removed (issue #29), design per ADR-0005
- [x] Auto-reconnect (survive network flaps and server restarts)
- [x] Multiple simultaneous client connections to different devices
- [x] Linux client daemon (systemd unit + local control socket)
- [x] End-to-end latency monitoring dashboard
- [x] End-to-end latency monitoring dashboard — `/api/events` WebSocket broadcast + per-URB high-water-mark latency sampling (issue #33)

## Milestone 14: Ecosystem ✅

Expand All @@ -185,7 +185,6 @@ Features explicitly deferred beyond v1.0:
- [ ] Session persistence (resume active import after server crash) — per ADR-0003
- [ ] USB 3.0 SuperSpeed support (up to 5 Gbps)
- [ ] IPv6 support
- [ ] Prometheus metrics endpoint
- [ ] Bandwidth throttling per client
- [ ] Custom embedded firmware image (Buildroot/Yocto)
- [ ] Home Assistant add-on
Expand Down
15 changes: 6 additions & 9 deletions client/usbip-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,8 @@ async fn urb_forwarding_loop(stream: &mut TcpStream, vhci: &dyn VhciBackend) ->
UsbIpError::from(ErrorKind::Protocol("invalid RET_SUBMIT".into()))
})?;

let in_data = if ret.has_data() {
&payload[UsbIpRetSubmit::HEADER_SIZE..]
} else {
&[][..]
};
let in_data =
if ret.has_data() { &payload[UsbIpRetSubmit::HEADER_SIZE..] } else { &[][..] };

// Complete the URB on the VHCI side
vhci.complete_urb(
Expand All @@ -501,10 +498,10 @@ async fn urb_forwarding_loop(stream: &mut TcpStream, vhci: &dyn VhciBackend) ->
if payload.len() < UsbIpRetUnlink::SIZE {
return Err(UsbIpError::from(ErrorKind::Protocol("invalid RET_UNLINK".into())));
}
let (unlink, _) = UsbIpRetUnlink::read_from_prefix(&payload[..UsbIpRetUnlink::SIZE])
.map_err(|_| {
UsbIpError::from(ErrorKind::Protocol("invalid RET_UNLINK".into()))
})?;
let (unlink, _) = UsbIpRetUnlink::read_from_prefix(
&payload[..UsbIpRetUnlink::SIZE],
)
.map_err(|_| UsbIpError::from(ErrorKind::Protocol("invalid RET_UNLINK".into())))?;

vhci.cancel_urb(unlink.seqnum(), unlink.devid())?;
},
Expand Down
76 changes: 76 additions & 0 deletions client/usbip-client/src/discovery.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
//! mDNS service discovery for USB/IP.
//!
//! Browses `_usbip._tcp.local` to find servers on the local network.
//! Also provides helpers to decode the `devices` TXT key carried in
//! advertisements, delegating to `usbip_core::discovery_txt`.

use mdns_sd::{ServiceDaemon, ServiceEvent};
use std::net::SocketAddr;
use std::time::Duration;
use tracing::info;

use usbip_core::discovery_txt::decode_devices_txt;
pub use usbip_core::discovery_txt::DiscoveredDevice;
use usbip_core::error::*;

pub struct MdnsBrowser {
Expand Down Expand Up @@ -62,3 +66,75 @@ impl MdnsBrowser {
Ok(servers)
}
}

/// Decode the `devices` TXT value from a discovered server into a list of
/// [`DiscoveredDevice`] entries.
///
/// This is a thin wrapper around [`usbip_core::discovery_txt::decode_devices_txt`].
pub fn decode_devices_from_txt(txt: &str) -> UsbIpResult<Vec<DiscoveredDevice>> {
decode_devices_txt(txt)
}

/// Decode the `devices` TXT value from a server's TXT properties map.
///
/// Looks up the `"devices"` key; returns an empty list if the key is
/// absent or empty.
pub fn decode_devices_from_properties(
properties: &std::collections::HashMap<String, String>,
) -> UsbIpResult<Vec<DiscoveredDevice>> {
match properties.get("devices") {
Some(txt) if !txt.is_empty() => decode_devices_txt(txt),
_ => Ok(Vec::new()),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn decode_devices_from_txt_single() {
let result = decode_devices_from_txt("vid=0x1234,pid=0x5678,bus=1-1,n=1234:5678").unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].vid, 0x1234);
assert_eq!(result[0].pid, 0x5678);
assert_eq!(result[0].bus, "1-1");
assert_eq!(result[0].name, "1234:5678");
}

#[test]
fn decode_devices_from_txt_empty() {
let result = decode_devices_from_txt("").unwrap();
assert!(result.is_empty());
}

#[test]
fn decode_devices_from_txt_malformed() {
assert!(decode_devices_from_txt("vid=0x1234").is_err());
}

#[test]
fn decode_devices_from_properties_with_devices() {
let mut props = std::collections::HashMap::new();
props
.insert("devices".to_string(), "vid=0x046d,pid=0xc261,bus=1-1,n=046d:c261".to_string());
let result = decode_devices_from_properties(&props).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].vid, 0x046d);
}

#[test]
fn decode_devices_from_properties_no_devices_key() {
let props = std::collections::HashMap::new();
let result = decode_devices_from_properties(&props).unwrap();
assert!(result.is_empty());
}

#[test]
fn decode_devices_from_properties_empty_devices() {
let mut props = std::collections::HashMap::new();
props.insert("devices".to_string(), String::new());
let result = decode_devices_from_properties(&props).unwrap();
assert!(result.is_empty());
}
}
1 change: 1 addition & 0 deletions client/usbip-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ pub mod reconnect;
pub mod vhci;

pub use client::{Client, ClientConfig};
pub use discovery::{decode_devices_from_properties, decode_devices_from_txt, DiscoveredDevice};
pub use reconnect::{decide_reconnect, ReconnectConfig, ReconnectDecision, ReconnectState};
pub use vhci::{VhciBackend, VhciDevice};
24 changes: 17 additions & 7 deletions docs/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,25 +171,35 @@ anyplug/
### Crate Dependency Tree

```
usbip-core (shared protocol types — no platform deps)
├── ring (AES-256-GCM, X25519)
├── zerocopy (safe transmutation for wire types)
usbip-core (shared protocol types — no OS-specific deps)
├── byteorder (big-endian types)
├── zerocopy (safe transmutation for wire types)
├── crc32fast (CRC-32 for descriptor verification)
└── serde/serde_json (config serialization)
├── thiserror (error types)
├── ring (AES-256-GCM, X25519, HKDF)
├── uuid (correlation IDs)
├── crossbeam
├── rand
├── tokio (async message framing)
└── tracing (structured logging)

usbip-server (server binary)
├── usbip-core (protocol types)
├── rusb (libusb bindings)
├── tokio (async TCP)
├── tokio (async runtime)
├── mdns-sd (mDNS advertisement)
└── clap (CLI parsing)
├── clap (CLI parsing)
├── ring (AES-256-GCM)
├── axum/tower (REST API + WebSocket)
└── prometheus (metrics endpoint)

usbip-client (client binary)
├── usbip-core (protocol types)
├── tokio (async TCP)
├── mdns-sd (mDNS browsing)
└── clap (CLI parsing)
├── clap (CLI parsing)
├── ring (AES-256-GCM)
└── winapi (Windows: SetupAPI, IOCTL)

windows (Windows GUI + Service)
├── usbip-core (protocol types)
Expand Down
Loading