//! 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 { 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>, /// Rumble extracted from the kernel's SET_REPORTs, drained by [`SteamDeckUsbip::service`]. feedback: Arc>, /// The host's last SET_REPORT command (drives [`feature_reply`]). last_set: Vec, serial: String, unit_id: u32, } impl UsbInterfaceHandler for ControllerHandler { fn get_class_specific_descriptor(&self) -> Vec { 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> { 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, } impl UsbInterfaceHandler for IdleHidHandler { fn get_class_specific_descriptor(&self) -> Vec { 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> { 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>> { 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>, feedback: &Arc>, ) -> 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, join: Option>, } impl ServerThread { /// Spawn the server on `listener`, serving exactly the one simulated `dev`. fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result { 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, stop: Arc, ) { 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>, feedback: Arc>, /// 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, /// 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 { 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 { 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 { 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 { 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 { 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 { 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::().ok()?; let sta = sta_str.parse::().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 { 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 { 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) -> Result { 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" ); } }