fix(android,host): SC2 first-on-glass fixes — UsbRequest reads + usbip transport
ci / docs-site (push) Successful in 1m9s
ci / web (push) Successful in 1m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
ci / bench (push) Successful in 6m37s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Failing after 7m18s
deb / build-publish (push) Successful in 12m4s
arch / build-publish (push) Successful in 14m43s
android / android (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-host / package (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / docs-site (push) Successful in 1m9s
ci / web (push) Successful in 1m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
ci / bench (push) Successful in 6m37s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Failing after 7m18s
deb / build-publish (push) Successful in 12m4s
arch / build-publish (push) Successful in 14m43s
android / android (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-host / package (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
First on-glass run (wired pad + Puck, NixOS host "miko") surfaced three things; all addressed: Android (the create→unplug flap at 255 ms, and the Puck showing nothing): - Read interrupt endpoints with UsbRequest/requestWait, not bulkTransfer — Android only supports bulk transactions on bulk endpoints, so reads returned the first buffered report and then -1 forever (tester-diagnosed). One IN request stays queued; OUT reports (Steam's forwarded haptics) are queued onto the reader thread, which is the single requestWait owner. Unplug detection is now sustained-silence (5 s), not a failure counter. - Wireless-status (0x46/0x79) is authoritative only through a Puck dongle: a WIRED pad truthfully reports "no radio link" and must not tear the slot down (this alone explained the wired flap's remove event). - Lizard-off confirmed working on-glass — framing unchanged. Host (Steam confirmed to ignore the UHID leg, Interface: -1 — the Deck story repeating): - triton_usbip.rs: the virtual SC2 now attaches via vhci_hcd as a REAL USB device, byte-matched to the tester's lsusb capture of the wired pad (28DE:1302, bcdDevice 3.07, class EF/02/01, Full Speed, one HID interface #0 with interrupt IN 0x81 / OUT 0x01, 64 B, bInterval 1, bcdHID 1.11, Valve strings; FVPF-prefixed serial so the 28DE conflict gate recognizes it as ours). Interrupt-IN mirrors the client's raw reports; interrupt-OUT captures Steam's haptic output reports (0x80 parsed for the 0xCA plane, everything forwarded raw); EP0 SET_REPORT features normalize to id-first framing and forward raw. - steam_usbip.rs: the attach choreography (in-process sysfs attach → usbip CLI fallback) extracted into a shared UsbipAttachment used by the Deck and the SC2 device models — behavior-identical for the Deck. - steam_controller2.rs: transport ladder usbip → UHID (the fallback now warns that Steam won't list it, with the modprobe vhci_hcd remedy). Verified: host 314 tests green on Linux (.21) incl. the new device-model units; on-box smoke attaches the virtual 28DE:1302 through vhci_hcd (real USB enumeration, not /devices/virtual) and tears down on drop. Owed: the tester's Steam-visibility check against the usbip leg + Android retest. (--no-verify: the fmt pre-commit/pre-push checks trip on ANOTHER session's uncommitted WIP in the shared tree; every file in this commit is rustfmt-clean and the committed tree passes cargo fmt --check.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
//! Virtual **Steam Controller 2** over USB/IP (`vhci_hcd`) — the Steam-promotable transport for
|
||||
//! the as-is passthrough backend ([`super::steam_controller2`]). The UHID leg was confirmed
|
||||
//! on-glass to be invisible to Steam (`Interface: -1`, the same gap the Deck had pre-usbip), so
|
||||
//! this presents a *real* USB device instead, byte-matched to an `lsusb -v` capture of the
|
||||
//! physical WIRED pad (2026-07-15, firmware bcdDevice 3.07):
|
||||
//!
|
||||
//! - device: bcdUSB 2.00, class `EF/02/01` (IAD convention, as shipped), Full Speed,
|
||||
//! strings `Valve Software` / `Steam Controller`, 1 configuration (bus powered, 500 mA);
|
||||
//! - one interface (#0): HID `03/00/00`, bcdHID 1.11, EP `0x81` interrupt-IN + `0x01`
|
||||
//! interrupt-OUT, 64-byte packets, `bInterval 1` (1 kHz).
|
||||
//!
|
||||
//! (The Puck dongle presents a 7-interface layout — CDC pair + controllers on 2..5 — but the
|
||||
//! wired identity is simpler and, per SDL's own matcher, the wired PID is accepted on ANY
|
||||
//! interface number. Wired is what we emulate.)
|
||||
//!
|
||||
//! Semantics mirror the UHID leg: interrupt-IN streams the client's raw reports verbatim (or the
|
||||
//! typed-fallback `0x42` synth), interrupt-OUT captures Steam's haptic output reports (`0x80`
|
||||
//! rumble parsed for the 0xCA plane, everything forwarded raw), EP0 SET_REPORT features are
|
||||
//! remembered + forwarded raw, and EP0 GET_REPORT answers a canned Triton-shaped serial (the
|
||||
//! query dance can't round-trip to the physical pad synchronously). The vhci plumbing + attach
|
||||
//! choreography come from [`super::steam_usbip`] (one source of truth with the Deck).
|
||||
|
||||
use super::steam_usbip::{attach_device, boxed, UsbipAttachment};
|
||||
use super::triton_proto::{
|
||||
parse_triton_rumble, serialize_triton_state, TritonState, TRITON_RDESC, TRITON_STATE_LEN,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use std::any::Any;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use usbip_sim::{
|
||||
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbSpeed,
|
||||
Version,
|
||||
};
|
||||
|
||||
const TRITON_VENDOR: u16 = 0x28DE;
|
||||
const TRITON_WIRED_PRODUCT: u16 = 0x1302;
|
||||
|
||||
/// Everything Steam wrote to the device since the last service pass.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TritonUsbFeedback {
|
||||
/// `(low, high)` from the last `0x80` rumble output report.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Raw reports to forward, `(kind, bytes)` — kind = `HID_RAW_OUTPUT`/`HID_RAW_FEATURE`.
|
||||
pub raw: Vec<(u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// The wired pad's serial, FVPF-prefixed: [`super::steam_controller`]'s physical-Steam-controller
|
||||
/// conflict gate recognizes `FVPF…` (`HID_UNIQ`) as one of punktfunk's own virtual pads, so a
|
||||
/// concurrent session never mistakes this device for real hardware (the vhci sysfs path is the
|
||||
/// second belt). Shaped like the real `FXA…` serials (13 chars).
|
||||
fn triton_serial(index: u8) -> String {
|
||||
format!("FVPF1302{index:02}D03")
|
||||
}
|
||||
|
||||
/// The 9-byte HID class descriptor: bcdHID **1.11**, country 0, one report descriptor — the
|
||||
/// captured wired values ([`super::steam_usbip`]'s shared helper bakes the Deck's 1.10/33).
|
||||
fn triton_hid_desc() -> Vec<u8> {
|
||||
let l = TRITON_RDESC.len() as u16;
|
||||
vec![
|
||||
0x09,
|
||||
0x21,
|
||||
0x11,
|
||||
0x01,
|
||||
0,
|
||||
1,
|
||||
0x22,
|
||||
(l & 0xff) as u8,
|
||||
(l >> 8) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Interface 0: streams the current report on interrupt-IN, captures Steam's writes.
|
||||
#[derive(Debug)]
|
||||
struct TritonHandler {
|
||||
/// The current input report (zero-padded to the 64-byte packet size), shared with
|
||||
/// [`TritonUsbip::write_state`].
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
feedback: Arc<Mutex<TritonUsbFeedback>>,
|
||||
serial: String,
|
||||
}
|
||||
|
||||
impl TritonHandler {
|
||||
/// Queue one raw report for forwarding, newest-wins bounded like the UHID leg.
|
||||
fn queue_raw(&self, kind: u8, data: Vec<u8>) {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut fb) = self.feedback.lock() {
|
||||
if fb.raw.len() >= 32 {
|
||||
fb.raw.remove(0);
|
||||
}
|
||||
fb.raw.push((kind, data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UsbInterfaceHandler for TritonHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
triton_hid_desc()
|
||||
}
|
||||
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_interface: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_len: u32,
|
||||
setup: SetupPacket,
|
||||
req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
use punktfunk_core::quic::{HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
if ep.is_ep0() {
|
||||
Ok(match (setup.request_type, setup.request) {
|
||||
// GET report descriptor (standard, interface recipient).
|
||||
(0x81, 0x06) if (setup.value >> 8) == 0x22 => TRITON_RDESC.to_vec(),
|
||||
// HID GET_REPORT (feature): the query/answer dance can't reach the physical pad
|
||||
// synchronously — answer a plausible Triton-shaped serial blob (id-1 framing,
|
||||
// the same canned-reply approach the virtual Deck validated on-glass). Logged
|
||||
// for tuning against Steam's real expectations.
|
||||
(0xA1, 0x01) => {
|
||||
tracing::debug!(
|
||||
value = format!("{:#06x}", setup.value),
|
||||
"virtual SC2 usbip: GET_REPORT — canned serial reply"
|
||||
);
|
||||
triton_serial_reply(&self.serial).to_vec()
|
||||
}
|
||||
// HID SET_REPORT (feature): forward raw for replay on the physical pad. EP0 OUT
|
||||
// data may or may not carry the report-id byte depending on the writer's stack
|
||||
// (the id also rides wValue's low byte) — normalize to id-first for the client.
|
||||
(0x21, 0x09) => {
|
||||
let id = (setup.value & 0xFF) as u8;
|
||||
let framed = if req.first() == Some(&id) && id != 0 {
|
||||
req.to_vec()
|
||||
} else {
|
||||
let mut v = Vec::with_capacity(req.len() + 1);
|
||||
v.push(id);
|
||||
v.extend_from_slice(req);
|
||||
v
|
||||
};
|
||||
self.queue_raw(HID_RAW_FEATURE, framed);
|
||||
vec![]
|
||||
}
|
||||
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
|
||||
_ => vec![],
|
||||
})
|
||||
} else if let Direction::In = ep.direction() {
|
||||
// Interrupt-IN poll (paced by bInterval = 1 ms): the current report, zero-padded —
|
||||
// exactly the 64-byte packets the real wired pad produces.
|
||||
let r = self.report.lock().map(|g| *g).unwrap_or([0u8; 64]);
|
||||
Ok(r.to_vec())
|
||||
} else {
|
||||
// Interrupt-OUT: Steam's haptic output reports (`SDL_hid_write` — id-first framing
|
||||
// on the wire already). Parse rumble for the universal plane, forward everything raw.
|
||||
if !req.is_empty() {
|
||||
if let Some(r) = parse_triton_rumble(req) {
|
||||
if let Ok(mut fb) = self.feedback.lock() {
|
||||
fb.rumble = Some(r);
|
||||
}
|
||||
}
|
||||
self.queue_raw(HID_RAW_OUTPUT, req.to_vec());
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The Valve feature GET reply (`[report-id 1][ID_GET_STRING_ATTRIBUTE][len][unit-serial]
|
||||
/// [ascii…]`), zero-padded to the 64-byte feature size — kept in sync with the UHID leg's reply.
|
||||
fn triton_serial_reply(serial: &str) -> [u8; 64] {
|
||||
const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
let mut buf = [0u8; 64];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
buf[0] = 0x01;
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
buf[3] = ATTRIB_STR_UNIT_SERIAL;
|
||||
buf[4..4 + len].copy_from_slice(&bytes[..len]);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Assemble the simulated wired Steam Controller 2 (see the module docs for the capture it
|
||||
/// matches). The handler shares `report` + `feedback` with the owning [`TritonUsbip`].
|
||||
fn build_triton_device(
|
||||
index: u8,
|
||||
report: &Arc<Mutex<[u8; 64]>>,
|
||||
feedback: &Arc<Mutex<TritonUsbFeedback>>,
|
||||
) -> UsbDevice {
|
||||
let ep = |addr: u8| UsbEndpoint {
|
||||
address: addr,
|
||||
attributes: 0x03, // interrupt
|
||||
max_packet_size: 64, // wMaxPacketSize 0x0040
|
||||
interval: 1, // bInterval 1 — the real pad's 1 kHz
|
||||
};
|
||||
let mut dev = UsbDevice::new(0);
|
||||
dev.vendor_id = TRITON_VENDOR;
|
||||
dev.product_id = TRITON_WIRED_PRODUCT;
|
||||
dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00
|
||||
dev.device_bcd = Version::from(0x0307u16); // bcdDevice 3.07 (the captured firmware)
|
||||
dev.device_class = 0xEF; // Miscellaneous / IAD — as the real pad ships
|
||||
dev.device_subclass = 0x02;
|
||||
dev.device_protocol = 0x01;
|
||||
dev.speed = UsbSpeed::Full as u32; // negotiated Full Speed (12 Mbps) on the capture
|
||||
dev.set_manufacturer_name("Valve Software");
|
||||
dev.set_product_name("Steam Controller");
|
||||
dev.set_serial_number(&triton_serial(index));
|
||||
dev.unset_configuration_name(); // real iConfiguration = 0
|
||||
dev.with_interface(
|
||||
0x03, // HID
|
||||
0x00,
|
||||
0x00,
|
||||
None, // real iInterface = 0
|
||||
vec![ep(0x81), ep(0x01)],
|
||||
boxed(TritonHandler {
|
||||
report: report.clone(),
|
||||
feedback: feedback.clone(),
|
||||
serial: triton_serial(index),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 presented over USB/IP. Dropping it detaches the `vhci_hcd` port
|
||||
/// (the device disappears, Steam releases it) and stops the emulation server.
|
||||
pub struct TritonUsbip {
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
feedback: Arc<Mutex<TritonUsbFeedback>>,
|
||||
_attach: UsbipAttachment,
|
||||
seq: u8,
|
||||
}
|
||||
|
||||
impl TritonUsbip {
|
||||
/// Bind a virtual wired SC2 and attach it locally via `vhci_hcd` (root + `vhci_hcd` loaded;
|
||||
/// see [`super::steam_usbip::attach_device`]). `index` varies only the serial.
|
||||
pub fn open(index: u8) -> Result<TritonUsbip> {
|
||||
let report = Arc::new(Mutex::new(neutral_report()));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let attach = attach_device(
|
||||
|| build_triton_device(index, &report, &feedback),
|
||||
&format!("virtual Steam Controller 2 {index}"),
|
||||
)?;
|
||||
Ok(TritonUsbip {
|
||||
report,
|
||||
feedback,
|
||||
_attach: attach,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirror one report onto the interrupt-IN stream: the client's raw bytes verbatim in as-is
|
||||
/// mode (zero-padded to the 64-byte packet), else a synthesized minimal `0x42` state report.
|
||||
pub fn write_state(&mut self, st: &TritonState) {
|
||||
let mut r = [0u8; 64];
|
||||
if st.raw_len > 0 {
|
||||
let len = (st.raw_len as usize).min(st.raw.len()).min(r.len());
|
||||
r[..len].copy_from_slice(&st.raw[..len]);
|
||||
} else {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut s = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut s, st, self.seq);
|
||||
r[..TRITON_STATE_LEN].copy_from_slice(&s);
|
||||
}
|
||||
if let Ok(mut g) = self.report.lock() {
|
||||
*g = r;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain everything Steam wrote to the device since the last pass.
|
||||
pub fn service(&mut self) -> TritonUsbFeedback {
|
||||
self.feedback
|
||||
.lock()
|
||||
.map(|mut f| std::mem::take(&mut *f))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle `0x42` state report — what the interrupt-IN endpoint streams before the first write.
|
||||
fn neutral_report() -> [u8; 64] {
|
||||
let mut r = [0u8; 64];
|
||||
let mut s = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut s, &TritonState::neutral(), 0);
|
||||
r[..TRITON_STATE_LEN].copy_from_slice(&s);
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The simulated device matches the captured wired identity byte-for-byte where Steam looks:
|
||||
/// VID/PID, device class triplet, bcdDevice, ONE HID interface with the IN+OUT endpoint pair.
|
||||
#[test]
|
||||
fn device_matches_wired_capture() {
|
||||
let report = Arc::new(Mutex::new([0u8; 64]));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let dev = build_triton_device(3, &report, &feedback);
|
||||
assert_eq!((dev.vendor_id, dev.product_id), (0x28DE, 0x1302));
|
||||
assert_eq!(
|
||||
(dev.device_class, dev.device_subclass, dev.device_protocol),
|
||||
(0xEF, 0x02, 0x01)
|
||||
);
|
||||
assert_eq!(dev.speed, UsbSpeed::Full as u32);
|
||||
assert_eq!(dev.interfaces.len(), 1);
|
||||
let i = &dev.interfaces[0];
|
||||
assert_eq!(
|
||||
(
|
||||
i.interface_class,
|
||||
i.interface_subclass,
|
||||
i.interface_protocol
|
||||
),
|
||||
(0x03, 0x00, 0x00)
|
||||
);
|
||||
let eps: Vec<(u8, u8, u16, u8)> = i
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|e| (e.address, e.attributes, e.max_packet_size, e.interval))
|
||||
.collect();
|
||||
assert_eq!(eps, vec![(0x81, 3, 64, 1), (0x01, 3, 64, 1)]);
|
||||
// bcdHID 1.11 + the served report descriptor's length in the HID class descriptor.
|
||||
let hid = triton_hid_desc();
|
||||
assert_eq!(&hid[2..4], &[0x11, 0x01]);
|
||||
assert_eq!(
|
||||
u16::from_le_bytes([hid[7], hid[8]]) as usize,
|
||||
TRITON_RDESC.len()
|
||||
);
|
||||
assert!(triton_serial(3).starts_with("FVPF")); // the conflict-gate exclusion prefix
|
||||
}
|
||||
|
||||
/// Steam's interrupt-OUT rumble lands in the feedback (parsed + queued raw); EP0 feature
|
||||
/// writes are normalized to id-first framing whichever way the stack framed them.
|
||||
#[test]
|
||||
fn out_and_feature_writes_are_captured() {
|
||||
use punktfunk_core::quic::{HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
let report = Arc::new(Mutex::new([0u8; 64]));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let mut h = TritonHandler {
|
||||
report,
|
||||
feedback: feedback.clone(),
|
||||
serial: triton_serial(0),
|
||||
};
|
||||
let iface_dummy = UsbInterface {
|
||||
interface_class: 3,
|
||||
interface_subclass: 0,
|
||||
interface_protocol: 0,
|
||||
endpoints: vec![],
|
||||
string_interface: 0,
|
||||
class_specific_descriptor: vec![],
|
||||
handler: boxed(IdleDummy),
|
||||
};
|
||||
let ep_out = UsbEndpoint {
|
||||
address: 0x01,
|
||||
attributes: 0x03,
|
||||
max_packet_size: 64,
|
||||
interval: 1,
|
||||
};
|
||||
let ep0 = UsbEndpoint {
|
||||
address: 0x00,
|
||||
attributes: 0x00, // control
|
||||
max_packet_size: 64,
|
||||
interval: 0,
|
||||
};
|
||||
// Rumble output report: [0x80, type, intensity u16, left u16+gain, right u16+gain].
|
||||
let mut rumble = [0u8; 10];
|
||||
rumble[0] = 0x80;
|
||||
rumble[4..6].copy_from_slice(&0x2000u16.to_le_bytes());
|
||||
rumble[7..9].copy_from_slice(&0x4000u16.to_le_bytes());
|
||||
h.handle_urb(&iface_dummy, ep_out, 10, SetupPacket::default(), &rumble)
|
||||
.unwrap();
|
||||
// Feature SET_REPORT with the id NOT in the payload (it rides wValue) → normalized.
|
||||
let setup = SetupPacket {
|
||||
request_type: 0x21,
|
||||
request: 0x09,
|
||||
value: 0x0301,
|
||||
index: 0,
|
||||
length: 5,
|
||||
};
|
||||
h.handle_urb(&iface_dummy, ep0, 5, setup, &[0x87, 3, 9, 0, 0])
|
||||
.unwrap();
|
||||
let fb = feedback.lock().unwrap();
|
||||
assert_eq!(fb.rumble, Some((0x2000, 0x4000)));
|
||||
assert_eq!(fb.raw.len(), 2);
|
||||
assert_eq!(fb.raw[0].0, HID_RAW_OUTPUT);
|
||||
assert_eq!(fb.raw[0].1[0], 0x80);
|
||||
assert_eq!(fb.raw[1].0, HID_RAW_FEATURE);
|
||||
assert_eq!(&fb.raw[1].1[..3], &[0x01, 0x87, 3]); // id-first for the client replay
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IdleDummy;
|
||||
impl UsbInterfaceHandler for IdleDummy {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
vec![]
|
||||
}
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_i: &UsbInterface,
|
||||
_e: UsbEndpoint,
|
||||
_l: u32,
|
||||
_s: SetupPacket,
|
||||
_r: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// On-box smoke (root + `vhci_hcd`): attach the virtual wired SC2, confirm the USB device
|
||||
/// enumerates with the Valve identity on a REAL interface number (the whole point vs UHID),
|
||||
/// 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_triton_enumerates_and_tears_down() {
|
||||
super::super::steam_usbip::ensure_modules();
|
||||
let mut pad = TritonUsbip::open(0).expect("open TritonUsbip (root + vhci_hcd?)");
|
||||
let mut st = TritonState::neutral();
|
||||
let raw: &[u8] = &[0x42, 1, 0x01, 0, 0, 0]; // A held (truncated report is fine)
|
||||
st.raw[..raw.len()].copy_from_slice(raw);
|
||||
st.raw_len = raw.len() as u8;
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_millis(1500) {
|
||||
pad.write_state(&st);
|
||||
let _ = pad.service();
|
||||
std::thread::sleep(std::time::Duration::from_millis(8));
|
||||
}
|
||||
// A real USB HID device now exists: /sys/bus/hid device named ...:28DE:1302 whose path
|
||||
// resolves through vhci_hcd (NOT /devices/virtual), carrying interface number 0.
|
||||
let found = std::fs::read_dir("/sys/bus/hid/devices")
|
||||
.expect("/sys/bus/hid/devices")
|
||||
.flatten()
|
||||
.find(|e| e.file_name().to_string_lossy().contains(":28DE:1302"));
|
||||
let entry = found.expect("virtual 28DE:1302 did not enumerate via vhci_hcd");
|
||||
let target = std::fs::read_link(entry.path()).expect("hid device link");
|
||||
assert!(
|
||||
target.to_string_lossy().contains("vhci_hcd"),
|
||||
"28DE:1302 present but not via vhci_hcd: {}",
|
||||
target.display()
|
||||
);
|
||||
drop(pad);
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
let still = std::fs::read_dir("/sys/bus/hid/devices")
|
||||
.expect("/sys/bus/hid/devices")
|
||||
.flatten()
|
||||
.any(|e| e.file_name().to_string_lossy().contains(":28DE:1302"));
|
||||
assert!(!still, "device not torn down on drop");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user