580b1ea7a7
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Steam Deck pass-through (design/steam-deck-passthrough-plan.md), code-complete + all CI checks green on Linux + adversarially reviewed; on-glass validation pending: - usbip/`vhci_hcd` virtual Deck transport (inject/linux/steam_usbip.rs) for non-SteamOS hosts (Bazzite/generic) — presents a real interface-2 USB Deck so Steam Input promotes it. In-process vhci attach (loopback OP_REQ_IMPORT handshake → sysfs attach) with a bounded `usbip`-CLI fallback; detach on drop. - Backed by a vendored, libusb-free trim of the `usbip` crate (crates/punktfunk-host/vendor/usbip-sim, MIT + NOTICE; host/cdc/hid + rusb/nusb removed; interrupt-IN paced by bInterval). - Selection ladder raw_gadget (SteamOS fast-path) → usbip (universal) → UHID, with PUNKTFUNK_STEAM_USBIP / PUNKTFUNK_USBIP_ATTACH knobs. - Shared Deck descriptors + the 0x83/0xAE feature contract + a Steam-accepted serial consolidated into steam_proto.rs; the raw_gadget backend reuses them. - Linux client leave-shortcuts: Ctrl+Alt+Shift+D + holding the escape chord (L1+R1+Start+Select) >=1.5s end the session (short press still exits fullscreen); the chord state resets across sessions. Also bundles in-progress work already staged in the tree: - host(kwin): xdg-output logical-geometry mapping so the KWin fake_input backend places absolute coordinates correctly under display scaling. - docs: design/README index entries + design/controller-only-mode.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
734 lines
28 KiB
Rust
734 lines
28 KiB
Rust
//! Virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, universal
|
|
//! alternative to [`super::steam_gadget`] (`raw_gadget` + `dummy_hcd`, SteamOS-only).
|
|
//!
|
|
//! Like the gadget, this presents a *real* 3-interface USB Steam Deck (mouse = interface 0, keyboard
|
|
//! = 1, **controller = 2**) — the interface-2 layout Steam's own driver filters on, so Steam Input
|
|
//! promotes it (a UHID Deck, `Interface: -1`, never is). Unlike the gadget it needs no out-of-tree
|
|
//! module: `vhci_hcd` is in-tree + signed on SteamOS, Bazzite, and ~every distro, loads under Secure
|
|
//! Boot, and needs no MOK. A userspace [`usbip_sim`] server emulates the Deck; the local `vhci_hcd`
|
|
//! attaches it. **Validated on Bazzite**: `vhci_hcd` enumerates the 3-interface Deck, `hid-steam`
|
|
//! binds it, and Steam reserves an XInput slot — identical recognition to the gadget.
|
|
//!
|
|
//! The device model + the USB/IP protocol come from the vendored [`usbip_sim`] crate (the upstream
|
|
//! `usbip` crate trimmed of its libusb host mode); the captured descriptors + the `0x83`/`0xAE`
|
|
//! feature contract come from the shared [`super::steam_proto`] (one source of truth with the gadget).
|
|
//!
|
|
//! **Attach** is in-process by default (no external `usbip` CLI dependency — the production goal): we
|
|
//! run the emulation server on a loopback TCP port, connect to it ourselves, perform the
|
|
//! `OP_REQ_IMPORT` handshake, then hand the connected socket fd to `vhci_hcd` via its sysfs `attach`
|
|
//! file. If anything in that path fails we fall back to the widely-packaged `usbip` CLI; if *that*
|
|
//! also fails, [`open`](SteamDeckUsbip::open) returns `Err` and the caller degrades to UHID.
|
|
|
|
use super::steam_proto::{
|
|
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, parse_steam_output,
|
|
SteamFeedback, SteamState, RDESC_DECK_CTRL, RDESC_DECK_KBD, RDESC_DECK_MOUSE,
|
|
};
|
|
use anyhow::{bail, Context, Result};
|
|
use std::any::Any;
|
|
use std::collections::HashSet;
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::os::fd::AsRawFd;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread::JoinHandle;
|
|
use std::time::{Duration, Instant};
|
|
use usbip_sim::{
|
|
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbIpServer,
|
|
Version,
|
|
};
|
|
|
|
const STEAM_VENDOR: u16 = 0x28DE;
|
|
const STEAMDECK_PRODUCT: u16 = 0x1205;
|
|
/// The single device's USB/IP bus id (one device per server, so the fixed default is fine).
|
|
const BUS_ID: &str = "0-0-0";
|
|
/// The usbip default TCP port — the server must listen here for the `usbip` CLI fallback to attach.
|
|
const USBIP_TCP_PORT: u16 = 3240;
|
|
|
|
/// Build the 9-byte HID class descriptor inserted between the interface and endpoint descriptors.
|
|
fn hid_desc(report_len: usize, country: u8) -> Vec<u8> {
|
|
let l = report_len as u16;
|
|
#[rustfmt::skip]
|
|
let d = vec![0x09, 0x21, 0x10, 0x01, country, 1, 0x22, (l & 0xff) as u8, (l >> 8) as u8];
|
|
d
|
|
}
|
|
|
|
/// The Deck **controller** interface (vendor HID, interface 2): answers the HID feature reports
|
|
/// (descriptor / `0x83` attributes / `0xAE` serial), streams the current 64-byte state on the
|
|
/// interrupt-IN endpoint, and surfaces rumble written via SET_REPORT.
|
|
#[derive(Debug)]
|
|
struct ControllerHandler {
|
|
/// The current 64-byte Deck input report, shared with [`SteamDeckUsbip::write_state`].
|
|
report: Arc<Mutex<[u8; 64]>>,
|
|
/// Rumble extracted from the kernel's SET_REPORTs, drained by [`SteamDeckUsbip::service`].
|
|
feedback: Arc<Mutex<SteamFeedback>>,
|
|
/// The host's last SET_REPORT command (drives [`feature_reply`]).
|
|
last_set: Vec<u8>,
|
|
serial: String,
|
|
unit_id: u32,
|
|
}
|
|
|
|
impl UsbInterfaceHandler for ControllerHandler {
|
|
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
|
hid_desc(RDESC_DECK_CTRL.len(), 33)
|
|
}
|
|
fn handle_urb(
|
|
&mut self,
|
|
_interface: &UsbInterface,
|
|
ep: UsbEndpoint,
|
|
_len: u32,
|
|
setup: SetupPacket,
|
|
req: &[u8],
|
|
) -> std::io::Result<Vec<u8>> {
|
|
if ep.is_ep0() {
|
|
Ok(match (setup.request_type, setup.request) {
|
|
// GET report descriptor (standard, interface recipient).
|
|
(0x81, 0x06) if (setup.value >> 8) == 0x22 => RDESC_DECK_CTRL.to_vec(),
|
|
// HID GET_REPORT (feature) — the Deck `0x83`/`0xAE` contract.
|
|
(0xA1, 0x01) => feature_reply(&self.last_set, &self.serial, self.unit_id).to_vec(),
|
|
// HID SET_REPORT — remember the command (for the next feature reply) + surface rumble.
|
|
(0x21, 0x09) => {
|
|
self.last_set = req.to_vec();
|
|
// `parse_steam_output` expects `[report-id(0), cmd, …]`; EP0 OUT data is `[cmd, …]`.
|
|
let mut framed = Vec::with_capacity(req.len() + 1);
|
|
framed.push(0);
|
|
framed.extend_from_slice(req);
|
|
let fb = parse_steam_output(&framed);
|
|
if fb.rumble.is_some() {
|
|
if let Ok(mut g) = self.feedback.lock() {
|
|
*g = fb;
|
|
}
|
|
}
|
|
vec![]
|
|
}
|
|
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
|
|
_ => vec![],
|
|
})
|
|
} else if let Direction::In = ep.direction() {
|
|
// Interrupt-IN poll: return the current report. The vendored sim paces interrupt-IN by
|
|
// bInterval (vhci_hcd does NOT throttle the server side), so this isn't a busy spin.
|
|
let r = self
|
|
.report
|
|
.lock()
|
|
.map(|g| *g)
|
|
.unwrap_or_else(|_| neutral_deck_report());
|
|
Ok(r.to_vec())
|
|
} else {
|
|
Ok(vec![])
|
|
}
|
|
}
|
|
fn as_any(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
/// A minimal idle HID interface (mouse / keyboard) — serves only its report descriptor.
|
|
#[derive(Debug)]
|
|
struct IdleHidHandler {
|
|
report_desc: Vec<u8>,
|
|
}
|
|
impl UsbInterfaceHandler for IdleHidHandler {
|
|
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
|
hid_desc(self.report_desc.len(), 0)
|
|
}
|
|
fn handle_urb(
|
|
&mut self,
|
|
_i: &UsbInterface,
|
|
ep: UsbEndpoint,
|
|
_l: u32,
|
|
setup: SetupPacket,
|
|
_req: &[u8],
|
|
) -> std::io::Result<Vec<u8>> {
|
|
if ep.is_ep0() && setup.request == 0x06 && (setup.value >> 8) == 0x22 {
|
|
Ok(self.report_desc.clone())
|
|
} else {
|
|
Ok(vec![])
|
|
}
|
|
}
|
|
fn as_any(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
fn boxed(
|
|
h: impl UsbInterfaceHandler + Send + 'static,
|
|
) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
|
|
Arc::new(Mutex::new(Box::new(h)))
|
|
}
|
|
fn ep(addr: u8, mps: u16) -> UsbEndpoint {
|
|
UsbEndpoint {
|
|
address: addr,
|
|
attributes: 0x03, // interrupt
|
|
max_packet_size: mps,
|
|
interval: 4,
|
|
}
|
|
}
|
|
|
|
/// Assemble the simulated 3-interface USB Deck. The controller handler shares `report` + `feedback`
|
|
/// with the owning [`SteamDeckUsbip`].
|
|
fn build_device(
|
|
index: u8,
|
|
report: &Arc<Mutex<[u8; 64]>>,
|
|
feedback: &Arc<Mutex<SteamFeedback>>,
|
|
) -> UsbDevice {
|
|
let mut dev = UsbDevice::new(0); // one device per server; bus_id stays the default "0-0-0".
|
|
dev.vendor_id = STEAM_VENDOR;
|
|
dev.product_id = STEAMDECK_PRODUCT;
|
|
dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00
|
|
dev.device_bcd = Version::from(0x0300u16); // bcdDevice 3.00 (matches the gadget)
|
|
dev.set_manufacturer_name("Valve Software");
|
|
dev.set_product_name("Steam Deck Controller");
|
|
dev.set_serial_number(&deck_serial(index));
|
|
dev.with_interface(
|
|
0x03,
|
|
0x00,
|
|
0x02,
|
|
Some("mouse"),
|
|
vec![ep(0x81, 8)],
|
|
boxed(IdleHidHandler {
|
|
report_desc: RDESC_DECK_MOUSE.to_vec(),
|
|
}),
|
|
)
|
|
.with_interface(
|
|
0x03,
|
|
0x01,
|
|
0x01,
|
|
Some("keyboard"),
|
|
vec![ep(0x82, 8)],
|
|
boxed(IdleHidHandler {
|
|
report_desc: RDESC_DECK_KBD.to_vec(),
|
|
}),
|
|
)
|
|
.with_interface(
|
|
0x03,
|
|
0x00,
|
|
0x00,
|
|
Some("controller"),
|
|
vec![ep(0x83, 64)],
|
|
boxed(ControllerHandler {
|
|
report: report.clone(),
|
|
feedback: feedback.clone(),
|
|
last_set: vec![],
|
|
serial: deck_serial(index),
|
|
unit_id: deck_unit_id(index),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Owns the emulation-server thread (a dedicated current-thread tokio runtime) and stops it on drop.
|
|
/// Run on its own thread so `SteamDeckUsbip::open` works whether or not the caller is inside a tokio
|
|
/// runtime (creating a runtime inside one would panic).
|
|
struct ServerThread {
|
|
stop: Arc<tokio::sync::Notify>,
|
|
join: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl ServerThread {
|
|
/// Spawn the server on `listener`, serving exactly the one simulated `dev`.
|
|
fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result<ServerThread> {
|
|
let stop = Arc::new(tokio::sync::Notify::new());
|
|
let stop_t = stop.clone();
|
|
let join = std::thread::Builder::new()
|
|
.name("pf-deck-usbip".into())
|
|
.spawn(move || {
|
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(rt) => rt,
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "usbip server runtime build failed");
|
|
return;
|
|
}
|
|
};
|
|
rt.block_on(run_server(
|
|
listener,
|
|
Arc::new(UsbIpServer::new_simulated(vec![dev])),
|
|
stop_t,
|
|
));
|
|
})
|
|
.context("spawn usbip server thread")?;
|
|
Ok(ServerThread {
|
|
stop,
|
|
join: Some(join),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Drop for ServerThread {
|
|
fn drop(&mut self) {
|
|
self.stop.notify_one();
|
|
if let Some(j) = self.join.take() {
|
|
let _ = j.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Accept loop: serve each USB/IP connection with the vendored `usbip_sim::handler` until stopped.
|
|
async fn run_server(
|
|
listener: std::net::TcpListener,
|
|
server: Arc<UsbIpServer>,
|
|
stop: Arc<tokio::sync::Notify>,
|
|
) {
|
|
let listener = match tokio::net::TcpListener::from_std(listener) {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "usbip TcpListener::from_std failed");
|
|
return;
|
|
}
|
|
};
|
|
loop {
|
|
tokio::select! {
|
|
_ = stop.notified() => break,
|
|
r = listener.accept() => match r {
|
|
Ok((mut sock, _)) => {
|
|
let server = server.clone();
|
|
tokio::spawn(async move {
|
|
let _ = usbip_sim::handler(&mut sock, server).await;
|
|
});
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "usbip accept error");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A virtual Steam Deck presented over USB/IP. Dropping it detaches the `vhci_hcd` port (the device
|
|
/// disappears, Steam releases its slot) and stops the emulation server.
|
|
pub struct SteamDeckUsbip {
|
|
report: Arc<Mutex<[u8; 64]>>,
|
|
feedback: Arc<Mutex<SteamFeedback>>,
|
|
/// The `vhci_hcd` port we attached to — written to the sysfs `detach` file on drop.
|
|
vhci_port: u16,
|
|
/// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach
|
|
/// only; the CLI hands its own fd to the kernel and exits, so this is `None` there).
|
|
_client_sock: Option<TcpStream>,
|
|
/// Emulation-server thread; dropped (stopped) after the detach.
|
|
_server: ServerThread,
|
|
seq: u32,
|
|
}
|
|
|
|
impl SteamDeckUsbip {
|
|
/// Bind a virtual Deck and attach it locally via `vhci_hcd`. `index` varies only the serial.
|
|
/// Requires `vhci_hcd` loaded and root (the sysfs attach / the CLI both need it). Tries the
|
|
/// in-process sysfs attach first, then the `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli`
|
|
/// pins one path (for debugging).
|
|
pub fn open(index: u8) -> Result<SteamDeckUsbip> {
|
|
ensure_modules();
|
|
if vhci_base().is_none() {
|
|
bail!(
|
|
"vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?"
|
|
);
|
|
}
|
|
let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok();
|
|
if mode.as_deref() != Some("cli") {
|
|
match Self::open_in_process(index) {
|
|
Ok(d) => return Ok(d),
|
|
Err(e) if mode.as_deref() == Some("inproc") => return Err(e),
|
|
Err(e) => {
|
|
tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI")
|
|
}
|
|
}
|
|
}
|
|
Self::open_via_cli(index)
|
|
}
|
|
|
|
/// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the
|
|
/// connected socket to `vhci_hcd` via sysfs. No external dependency.
|
|
fn open_in_process(index: u8) -> Result<SteamDeckUsbip> {
|
|
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
|
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
|
|
let dev = build_device(index, &report, &feedback);
|
|
|
|
// An ephemeral loopback port (avoids contending the usbip default with another pad).
|
|
let listener =
|
|
std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?;
|
|
let port = listener
|
|
.local_addr()
|
|
.context("usbip server local_addr")?
|
|
.port();
|
|
listener
|
|
.set_nonblocking(true)
|
|
.context("usbip listener set_nonblocking")?;
|
|
let server = ServerThread::spawn(listener, dev)?;
|
|
|
|
// Connect to our own server and run the OP_REQ_IMPORT handshake.
|
|
let mut sock = connect_loopback(port).context("connect to usbip server")?;
|
|
let (devid, speed) = import_handshake(&mut sock).context("usbip import handshake")?;
|
|
|
|
// Hand the connected socket to vhci_hcd. Clear BOTH timeouts first: the kernel's vhci rx/tx
|
|
// threads honour SO_RCVTIMEO/SO_SNDTIMEO on this socket, so the 3s handshake timeouts would
|
|
// otherwise tear the device down after 3s idle (rx) or a 3s-blocked send (tx).
|
|
let vhci_port = vhci_find_free_port(speed).context("find a free vhci port")?;
|
|
sock.set_read_timeout(None).ok();
|
|
sock.set_write_timeout(None).ok();
|
|
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
|
|
|
|
tracing::info!(
|
|
index,
|
|
vhci_port,
|
|
"virtual Steam Deck attached via usbip (in-process — Steam Input recognizes it)"
|
|
);
|
|
Ok(SteamDeckUsbip {
|
|
report,
|
|
feedback,
|
|
vhci_port,
|
|
_client_sock: Some(sock),
|
|
_server: server,
|
|
seq: 0,
|
|
})
|
|
}
|
|
|
|
/// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci
|
|
/// port itself; we recover it by diffing the sysfs status).
|
|
fn open_via_cli(index: u8) -> Result<SteamDeckUsbip> {
|
|
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
|
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
|
|
let dev = build_device(index, &report, &feedback);
|
|
|
|
let listener = std::net::TcpListener::bind(("127.0.0.1", USBIP_TCP_PORT))
|
|
.with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?;
|
|
listener
|
|
.set_nonblocking(true)
|
|
.context("usbip listener set_nonblocking")?;
|
|
let server = ServerThread::spawn(listener, dev)?;
|
|
|
|
let before = vhci_used_ports();
|
|
usbip_attach_cli().context("usbip CLI attach")?;
|
|
let vhci_port = wait_for_new_port(&before)
|
|
.context("could not determine the vhci port the usbip CLI attached to")?;
|
|
|
|
tracing::info!(
|
|
index,
|
|
vhci_port,
|
|
"virtual Steam Deck attached via usbip (CLI — Steam Input recognizes it)"
|
|
);
|
|
Ok(SteamDeckUsbip {
|
|
report,
|
|
feedback,
|
|
vhci_port,
|
|
_client_sock: None,
|
|
_server: server,
|
|
seq: 0,
|
|
})
|
|
}
|
|
|
|
/// Serialize `st` into the 64-byte Deck report streamed on the controller interrupt-IN endpoint.
|
|
pub fn write_state(&mut self, st: &SteamState) {
|
|
self.seq = self.seq.wrapping_add(1);
|
|
let mut r = [0u8; 64];
|
|
super::steam_proto::serialize_deck_state(&mut r, st, self.seq);
|
|
if let Ok(mut g) = self.report.lock() {
|
|
*g = r;
|
|
}
|
|
}
|
|
|
|
/// Drain any rumble feedback the kernel/Steam wrote to the device.
|
|
pub fn service(&mut self) -> SteamFeedback {
|
|
self.feedback
|
|
.lock()
|
|
.map(|mut f| std::mem::take(&mut *f))
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl Drop for SteamDeckUsbip {
|
|
fn drop(&mut self) {
|
|
// Detach the vhci port first (the kernel closes its end of the socket + tears down the
|
|
// device); `_client_sock` + `_server` then drop, closing our side + stopping the server.
|
|
if let Err(e) = vhci_detach(self.vhci_port) {
|
|
tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- USB/IP import handshake (we act as the usbip *client* before handing the fd to the kernel) ----
|
|
|
|
const USBIP_VERSION: u16 = 0x0111;
|
|
const OP_REQ_IMPORT: u16 = 0x8003;
|
|
|
|
/// Connect to our own loopback server, retrying briefly while the server thread comes up.
|
|
fn connect_loopback(port: u16) -> Result<TcpStream> {
|
|
let addr = ("127.0.0.1", port);
|
|
let mut last = None;
|
|
for _ in 0..50 {
|
|
match TcpStream::connect(addr) {
|
|
Ok(s) => {
|
|
s.set_nodelay(true).ok();
|
|
return Ok(s);
|
|
}
|
|
Err(e) => {
|
|
last = Some(e);
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
}
|
|
Err(anyhow::anyhow!(
|
|
"connect 127.0.0.1:{port}: {}",
|
|
last.map(|e| e.to_string()).unwrap_or_default()
|
|
))
|
|
}
|
|
|
|
/// Send `OP_REQ_IMPORT` for [`BUS_ID`] and read `OP_REP_IMPORT`, returning `(devid, speed)` parsed
|
|
/// from the device record (the same `devid = bus_num<<16 | dev_num` + speed `vhci_hcd` wants). The
|
|
/// whole 320-byte reply MUST be consumed here so the socket starts clean at the kernel's first
|
|
/// `USBIP_CMD_SUBMIT`.
|
|
fn import_handshake(sock: &mut TcpStream) -> Result<(u32, u32)> {
|
|
// Bounded so a non-responsive server can't head-block the per-session input thread (this talks
|
|
// to our own in-process loopback server, so a working handshake completes in well under a ms).
|
|
sock.set_read_timeout(Some(Duration::from_secs(1))).ok();
|
|
sock.set_write_timeout(Some(Duration::from_secs(1))).ok();
|
|
|
|
let mut req = Vec::with_capacity(40);
|
|
req.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
|
req.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes());
|
|
req.extend_from_slice(&0u32.to_be_bytes()); // status
|
|
let mut busid = [0u8; 32];
|
|
let b = BUS_ID.as_bytes();
|
|
busid[..b.len()].copy_from_slice(b);
|
|
req.extend_from_slice(&busid);
|
|
sock.write_all(&req).context("send OP_REQ_IMPORT")?;
|
|
|
|
// Reply: version(2) code(2) status(4), then the 312-byte device record on success.
|
|
let mut header = [0u8; 8];
|
|
sock.read_exact(&mut header)
|
|
.context("read OP_REP_IMPORT header")?;
|
|
let status = u32::from_be_bytes([header[4], header[5], header[6], header[7]]);
|
|
if status != 0 {
|
|
bail!("OP_REP_IMPORT refused (status={status}) — device {BUS_ID} not exported?");
|
|
}
|
|
let mut dev = [0u8; 312];
|
|
sock.read_exact(&mut dev)
|
|
.context("read OP_REP_IMPORT device record")?;
|
|
// Device record layout: path[256], bus_id[32], bus_num(4 BE)@288, dev_num(4 BE)@292, speed(4)@296.
|
|
let be = |o: usize| u32::from_be_bytes([dev[o], dev[o + 1], dev[o + 2], dev[o + 3]]);
|
|
let bus_num = be(288);
|
|
let dev_num = be(292);
|
|
let speed = be(296);
|
|
Ok(((bus_num << 16) | dev_num, speed))
|
|
}
|
|
|
|
// ---- vhci_hcd sysfs plumbing ----
|
|
|
|
/// Best-effort load of `vhci_hcd` (in-tree + signed on SteamOS/Bazzite/most distros).
|
|
pub fn ensure_modules() {
|
|
let _ = Command::new("modprobe").arg("vhci_hcd").status();
|
|
}
|
|
|
|
/// Run `usbip attach -r 127.0.0.1 -b 0-0-0`, bounded by a deadline so a hung CLI can't head-block
|
|
/// the per-session input thread indefinitely (the caller runs this inline on that thread).
|
|
fn usbip_attach_cli() -> Result<()> {
|
|
let mut child = Command::new("usbip")
|
|
.args(["attach", "-r", "127.0.0.1", "-b", BUS_ID])
|
|
.spawn()
|
|
.context("spawn `usbip attach` (is usbip-utils installed?)")?;
|
|
let deadline = Instant::now() + Duration::from_secs(6);
|
|
loop {
|
|
match child.try_wait().context("wait on `usbip attach`")? {
|
|
Some(st) if st.success() => return Ok(()),
|
|
Some(st) => bail!("`usbip attach` exited with {st}"),
|
|
None if Instant::now() >= deadline => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
bail!("`usbip attach` timed out (>6s) — killed");
|
|
}
|
|
None => std::thread::sleep(Duration::from_millis(20)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a usbip attach should be attempted at all. Default on (the universal Steam-promotable
|
|
/// transport on non-SteamOS hosts); `PUNKTFUNK_STEAM_USBIP=0` forces it off, `=1` forces it on.
|
|
/// [`open`](SteamDeckUsbip::open) still degrades gracefully if `vhci_hcd` turns out to be absent.
|
|
pub fn usbip_preferred() -> bool {
|
|
!matches!(
|
|
std::env::var("PUNKTFUNK_STEAM_USBIP").ok().as_deref(),
|
|
Some("0") | Some("false")
|
|
)
|
|
}
|
|
|
|
/// The `vhci_hcd.0` (or legacy `vhci_hcd`) platform sysfs directory, if present.
|
|
fn vhci_base() -> Option<PathBuf> {
|
|
for p in [
|
|
"/sys/devices/platform/vhci_hcd.0",
|
|
"/sys/devices/platform/vhci_hcd",
|
|
] {
|
|
let base = Path::new(p);
|
|
if base.join("status").exists() {
|
|
return Some(base.to_path_buf());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn read_status() -> Result<String> {
|
|
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
|
std::fs::read_to_string(base.join("status")).context("read vhci_hcd status")
|
|
}
|
|
|
|
/// One parsed `status` row: `(port, hub_is_superspeed, sta)`. Handles both the modern
|
|
/// `hub port sta …` and the legacy `port sta …` column layouts; returns `None` for header/blank rows.
|
|
fn parse_status_row(line: &str) -> Option<(u16, bool, u32)> {
|
|
let t: Vec<&str> = line.split_whitespace().collect();
|
|
if t.is_empty() {
|
|
return None;
|
|
}
|
|
let (hub_ss, port_str, sta_str) = if t[0] == "hs" || t[0] == "ss" {
|
|
(Some(t[0] == "ss"), *t.get(1)?, *t.get(2)?)
|
|
} else if t[0].chars().all(|c| c.is_ascii_digit()) {
|
|
(None, t[0], *t.get(1)?) // legacy: port sta …
|
|
} else {
|
|
return None; // header ("hub"/"prt"/"port" …)
|
|
};
|
|
let port = port_str.parse::<u16>().ok()?;
|
|
let sta = sta_str.parse::<u32>().ok()?;
|
|
Some((port, hub_ss.unwrap_or(false), sta))
|
|
}
|
|
|
|
/// `sta == 4` is `VDEV_ST_NULL` (a free port).
|
|
const VDEV_ST_NULL: u32 = 4;
|
|
|
|
/// Pick a free `vhci_hcd` port matching the device speed (`usbip_speed >= 5` ⇒ SuperSpeed hub).
|
|
fn vhci_find_free_port(usbip_speed: u32) -> Result<u16> {
|
|
let want_ss = usbip_speed >= 5;
|
|
let status = read_status()?;
|
|
for line in status.lines() {
|
|
if let Some((port, is_ss, sta)) = parse_status_row(line) {
|
|
if sta == VDEV_ST_NULL && is_ss == want_ss {
|
|
return Ok(port);
|
|
}
|
|
}
|
|
}
|
|
// Speed-class match failed (legacy single-hub status): take any free port.
|
|
for line in status.lines() {
|
|
if let Some((port, _, sta)) = parse_status_row(line) {
|
|
if sta == VDEV_ST_NULL {
|
|
return Ok(port);
|
|
}
|
|
}
|
|
}
|
|
bail!("no free vhci_hcd port (all ports in use?)")
|
|
}
|
|
|
|
/// Ports currently in use (`sta != VDEV_ST_NULL`) — snapshotted around a CLI attach to recover its port.
|
|
fn vhci_used_ports() -> HashSet<u16> {
|
|
read_status()
|
|
.unwrap_or_default()
|
|
.lines()
|
|
.filter_map(parse_status_row)
|
|
.filter(|&(_, _, sta)| sta != VDEV_ST_NULL)
|
|
.map(|(port, _, _)| port)
|
|
.collect()
|
|
}
|
|
|
|
/// Poll the status file (briefly) for a port that became used since `before` — the one the CLI attached.
|
|
fn wait_for_new_port(before: &HashSet<u16>) -> Result<u16> {
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
if let Some(p) = vhci_used_ports().difference(before).copied().min() {
|
|
return Ok(p);
|
|
}
|
|
if Instant::now() >= deadline {
|
|
bail!("no newly-attached vhci port appeared after `usbip attach`");
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
fn vhci_attach(port: u16, sockfd: i32, devid: u32, speed: u32) -> Result<()> {
|
|
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
|
let line = format!("{port} {sockfd} {devid} {speed}");
|
|
std::fs::write(base.join("attach"), line)
|
|
.with_context(|| format!("write vhci_hcd attach (port {port}) — root?"))
|
|
}
|
|
|
|
fn vhci_detach(port: u16) -> Result<()> {
|
|
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
|
std::fs::write(base.join("detach"), format!("{port}")).context("write vhci_hcd detach")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The `status` parser handles the modern `hub port sta …` layout, the legacy `port sta …`
|
|
/// layout, and skips header/blank lines — a slip here would mean attaching to a busy port.
|
|
#[test]
|
|
fn status_parser_handles_both_layouts() {
|
|
// modern
|
|
assert_eq!(
|
|
parse_status_row("hs 0000 004 000 00000000 000000 0-0"),
|
|
Some((0, false, 4))
|
|
);
|
|
assert_eq!(
|
|
parse_status_row("ss 0008 006 000 00000000 000000 0-0"),
|
|
Some((8, true, 6))
|
|
);
|
|
// legacy (no hub column)
|
|
assert_eq!(
|
|
parse_status_row("0001 004 000 00000000 000000 0-0"),
|
|
Some((1, false, 4))
|
|
);
|
|
// header / blank
|
|
assert_eq!(
|
|
parse_status_row("hub port sta spd dev sockfd local_busid"),
|
|
None
|
|
);
|
|
assert_eq!(parse_status_row(""), None);
|
|
}
|
|
|
|
/// A free HS port is preferred for an HS device; a free SS port for an SS device.
|
|
#[test]
|
|
fn free_port_selection_matches_speed() {
|
|
let status = "hub port sta spd dev sockfd local_busid\n\
|
|
hs 0000 006 000 00000000 000000 0-0\n\
|
|
hs 0001 004 000 00000000 000000 0-0\n\
|
|
ss 0008 004 000 00000000 000000 0-0\n";
|
|
// Reuse the parser directly (vhci_find_free_port reads sysfs; test the selection logic).
|
|
let hs = status
|
|
.lines()
|
|
.filter_map(parse_status_row)
|
|
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && !is_ss)
|
|
.map(|(p, _, _)| p);
|
|
let ss = status
|
|
.lines()
|
|
.filter_map(parse_status_row)
|
|
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && is_ss)
|
|
.map(|(p, _, _)| p);
|
|
assert_eq!(hs, Some(1));
|
|
assert_eq!(ss, Some(8));
|
|
}
|
|
|
|
/// On-box smoke test (needs root + `vhci_hcd`): attach a virtual Deck, confirm `hid-steam` binds
|
|
/// it (the `Steam Deck` evdev appears) and that it tears down on drop. `#[ignore]`d in CI.
|
|
#[test]
|
|
#[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"]
|
|
fn usbip_deck_binds_and_tears_down() {
|
|
ensure_modules();
|
|
let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)");
|
|
let st = SteamState::from_gamepad(punktfunk_core::input::gamepad::BTN_A, 0, 0, 0, 0, 0, 0);
|
|
let start = Instant::now();
|
|
while start.elapsed() < Duration::from_millis(800) {
|
|
pad.write_state(&st);
|
|
let _ = pad.service();
|
|
std::thread::sleep(Duration::from_millis(8));
|
|
}
|
|
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
|
assert!(
|
|
devs.contains("Steam Deck"),
|
|
"hid-steam did not bind the usbip Deck"
|
|
);
|
|
drop(pad);
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
|
assert!(
|
|
!devs.contains("Steam Deck Motion Sensors"),
|
|
"device not torn down on drop"
|
|
);
|
|
}
|
|
}
|