feat(core,host,android): Steam Controller 2 as-is passthrough to Linux hosts
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m2s
ci / rust (push) Failing after 5m34s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
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 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
ci / bench (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 14m2s
arch / build-publish (push) Successful in 11m57s
deb / build-publish (push) Successful in 11m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m5s
flatpak / build-publish (push) Failing after 8m3s
windows-host / package (push) Failing after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m18s
apple / swift (push) Successful in 5m4s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m14s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m43s
release / apple (push) Successful in 26m11s
apple / screenshots (push) Has been cancelled
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m2s
ci / rust (push) Failing after 5m34s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
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 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
ci / bench (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 14m2s
arch / build-publish (push) Successful in 11m57s
deb / build-publish (push) Successful in 11m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m5s
flatpak / build-publish (push) Failing after 8m3s
windows-host / package (push) Failing after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m18s
apple / swift (push) Successful in 5m4s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m14s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m43s
release / apple (push) Successful in 26m11s
apple / screenshots (push) Has been cancelled
The 2026 Steam Controller (Valve "Ibex" / SDL "Triton") captured on an Android client is passed through AS-IS: the host presents a virtual pad with the real wired identity (28DE:1302) and mirrors the physical pad's raw HID reports, so Steam on the host drives it over hidraw exactly like the real thing — trackpads, gyro, paddles, and its rumble/settings writes flow back onto the physical controller. Protocol ground truth: SDL's Valve-maintained SDL_hidapi_steam_triton.c + steam/controller_structs.h. Core: - GamepadPref::SteamController2 (wire byte 9; names steamcontroller2/ sc2/ibex) + PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 in the C ABI. - Raw HID planes: RichInput::HidReport (0xCC/0x04, client→host input reports verbatim, Copy fixed-64 body) and HidOutput::HidRaw (0xCD/0x05, host→client feature/output writes for replay). Best-effort is sound by the device protocol's own design (rumble re-sent every ~40 ms, settings every ~3 s — losses self-heal); HidRaw bypasses hidout dedup for exactly that reason. Host (Linux): - triton_proto.rs + steam_controller2.rs: Triton2Manager UHID backend — no kernel driver binds the PID (hidraw only; Steam Input is the consumer), raw mirroring with a typed-fallback 0x42 synthesizer until the first raw report, SET_REPORT ack + raw forward, canned GET_REPORT serial reply, rumble also parsed onto the universal 0xCA plane (phone mirror). Rides the uhid + 28DE-conflict degrades; UHID promotion by Steam is flagged in the creation log (usbip transport is the known follow-up if Steam ignores Interface:-1 devices for Triton too). Android: - Sc2UsbLink (wired/Puck: vendor-interface claim detaches the OS driver, interrupt read loop, lizard-off on the watchdog cadence, raw replay via interrupt-OUT / SET_REPORT with hidapi report-id framing) and Sc2BleLink (Valve vendor GATT service, notify subscribe machine, 0x45 re-framing, HIGH connection priority). - Sc2Capture orchestrator: raw plane + typed mirror (exit chord + host degrade paths keep working) on a GamepadRouter external slot; raw return path via GamepadFeedback.onHidRaw. - nativeSendPadHidReport JNI (direct ByteBuffer, no per-report copy), hidout raw decode, usb-host/BLUETOOTH_CONNECT manifest bits, opt-out settings toggle, StreamScreen engagement incl. the USB permission flow. Verified: core 149 + host 312 tests green on Linux (.21), on-box uhid smoke creates/mirrors/tears down the virtual 28DE:1302, C ABI harness round-trips, Android compileDebugKotlin green. On-glass with the real controller owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
//! Transport-independent contract for the virtual **Steam Controller 2** (2026, Valve "Ibex" /
|
||||
//! SDL "Triton", wired `28DE:1302`) — the as-is passthrough sibling of [`super::steam_proto`].
|
||||
//!
|
||||
//! Unlike the Deck/classic-SC backends, this device is NOT re-synthesized from typed wire state:
|
||||
//! the client captures the physical controller (USB Puck / wired / BLE) and forwards its raw
|
||||
//! input reports verbatim ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)); the host
|
||||
//! mirrors them out unchanged, and everything the host's hidraw consumer writes back (Steam's
|
||||
//! lizard-off / IMU-enable feature reports, `0x80` rumble output reports) is forwarded raw to the
|
||||
//! client for replay on the real controller ([`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)).
|
||||
//! Mainline `hid-steam` does not bind this PID, so no kernel evdev exists — **Steam Input is the
|
||||
//! consumer**, driving the hidraw node exactly as it drives the physical pad.
|
||||
//!
|
||||
//! Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
||||
//! (Valve-maintained). Input report ids `0x42`/`0x45` (`TritonMTUNoQuat_t`, 46 bytes with id),
|
||||
//! `0x47` (adds a trackpad timestamp), `0x43` battery, `0x46`/`0x79` wireless status. Feature
|
||||
//! reports are 64 bytes on report id `1`; haptics are OUTPUT reports `0x80..=0x85`.
|
||||
//!
|
||||
//! A typed **fallback synthesizer** is kept for the degraded case (a client that declared the
|
||||
//! kind but sends no raw feed): buttons/sticks/triggers from the ordinary gamepad plane are
|
||||
//! serialized into a minimal `0x42` state report. The first raw report permanently switches the
|
||||
//! pad to as-is mode.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
/// Valve vendor id (same as [`super::steam_proto::STEAM_VENDOR`], repeated to keep this module
|
||||
/// self-contained).
|
||||
pub const TRITON_VENDOR: u32 = 0x28DE;
|
||||
/// The wired Steam Controller 2 identity the virtual pad presents. The BLE (`0x1303`) and Puck
|
||||
/// dongle (`0x1304`/`0x1305`) identities are client-side transports only — Steam treats the wired
|
||||
/// PID as the canonical controller.
|
||||
pub const TRITON_WIRED_PRODUCT: u32 = 0x1302;
|
||||
|
||||
/// Triton input-report ids (`ETritonReportIDTypes`, SDL `controller_structs.h`).
|
||||
pub const ID_TRITON_CONTROLLER_STATE: u8 = 0x42;
|
||||
pub const ID_TRITON_BATTERY_STATUS: u8 = 0x43;
|
||||
pub const ID_TRITON_CONTROLLER_STATE_BLE: u8 = 0x45;
|
||||
pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47;
|
||||
|
||||
/// Haptic OUTPUT report ids (`ID_OUT_REPORT_*`). Only rumble is parsed host-side (for the
|
||||
/// universal 0xCA plane); every output report is forwarded raw regardless.
|
||||
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
|
||||
|
||||
/// Fixed report size: 64-byte feature reports, input reports at most 64 (state is 46/54).
|
||||
pub const TRITON_REPORT_LEN: usize = 64;
|
||||
|
||||
/// The `TritonMTUNoQuat_t` state payload (46 bytes with the leading report id).
|
||||
pub const TRITON_STATE_LEN: usize = 46;
|
||||
|
||||
/// Minimal vendor-defined HID report descriptor, mirroring [`super::steam_proto::STEAMDECK_RDESC`]
|
||||
/// with an added OUTPUT item: the Triton receives haptics as output reports (`SDL_hid_write`),
|
||||
/// not feature-only like the Deck, so hidapi consumers expect a writable interrupt-OUT-style
|
||||
/// report to exist. All items unnumbered 64-byte — the raw bytes we mirror carry the Valve
|
||||
/// report-type byte first, exactly like the physical device's stream.
|
||||
#[rustfmt::skip]
|
||||
pub const TRITON_RDESC: &[u8] = &[
|
||||
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||
0x75, 0x08, // Report Size (8 bits)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x81, 0x02, // Input (Data,Var,Abs) — the state/battery/wireless report stream
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x91, 0x02, // Output (Data,Var,Abs) — haptic commands (0x80 rumble, 0x81 pulse, …)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0xB1, 0x02, // Feature (Data,Var,Abs) — settings/attributes (report id 1 on the wire)
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
/// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's
|
||||
/// `TritonButtons`. Only the bits the typed fallback synthesizes are named; the raw path carries
|
||||
/// whatever the physical pad set.
|
||||
pub mod tbtn {
|
||||
pub const A: u32 = 0x0000_0001;
|
||||
pub const B: u32 = 0x0000_0002;
|
||||
pub const X: u32 = 0x0000_0004;
|
||||
pub const Y: u32 = 0x0000_0008;
|
||||
pub const QAM: u32 = 0x0000_0010;
|
||||
pub const R3: u32 = 0x0000_0020;
|
||||
pub const VIEW: u32 = 0x0000_0040;
|
||||
pub const R4: u32 = 0x0000_0080;
|
||||
pub const R5: u32 = 0x0000_0100;
|
||||
pub const RB: u32 = 0x0000_0200;
|
||||
pub const DPAD_DOWN: u32 = 0x0000_0400;
|
||||
pub const DPAD_RIGHT: u32 = 0x0000_0800;
|
||||
pub const DPAD_LEFT: u32 = 0x0000_1000;
|
||||
pub const DPAD_UP: u32 = 0x0000_2000;
|
||||
pub const MENU: u32 = 0x0000_4000;
|
||||
pub const L3: u32 = 0x0000_8000;
|
||||
pub const STEAM: u32 = 0x0001_0000;
|
||||
pub const L4: u32 = 0x0002_0000;
|
||||
pub const L5: u32 = 0x0004_0000;
|
||||
pub const LB: u32 = 0x0008_0000;
|
||||
pub const RPAD_TOUCH: u32 = 0x0020_0000;
|
||||
pub const RPAD_CLICK: u32 = 0x0040_0000;
|
||||
pub const RT_CLICK: u32 = 0x0080_0000;
|
||||
pub const LPAD_TOUCH: u32 = 0x0200_0000;
|
||||
pub const LPAD_CLICK: u32 = 0x0400_0000;
|
||||
pub const LT_CLICK: u32 = 0x0800_0000;
|
||||
}
|
||||
|
||||
/// One virtual Triton pad's report state. In as-is mode (`raw_len > 0`) the raw report IS the
|
||||
/// state; the typed fields only feed the fallback synthesizer until the first raw report lands.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TritonState {
|
||||
/// The last raw report the client forwarded (report-id byte first); `raw_len == 0` until the
|
||||
/// first one arrives, after which the typed fields below stop mattering.
|
||||
pub raw: [u8; TRITON_REPORT_LEN],
|
||||
pub raw_len: u8,
|
||||
/// Typed fallback fields (Triton bit layout / raw axis units), from the ordinary wire plane.
|
||||
pub buttons: u32,
|
||||
pub lt: u16,
|
||||
pub rt: u16,
|
||||
pub lx: i16,
|
||||
pub ly: i16,
|
||||
pub rx: i16,
|
||||
pub ry: i16,
|
||||
}
|
||||
|
||||
impl TritonState {
|
||||
pub fn neutral() -> TritonState {
|
||||
TritonState {
|
||||
raw: [0u8; TRITON_REPORT_LEN],
|
||||
raw_len: 0,
|
||||
buttons: 0,
|
||||
lt: 0,
|
||||
rt: 0,
|
||||
lx: 0,
|
||||
ly: 0,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed fallback: fold one wire button/stick frame into Triton fields. Mapping follows the
|
||||
/// Deck backend's conventions (PADDLE1/2/3/4 = R4/L4/R5/L5, MISC1 = QAM, the DualSense
|
||||
/// touchpad-click wire bit = right-pad click); sticks are already the device convention
|
||||
/// (+y up), triggers scale 0..255 → 0..32767.
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> TritonState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let trig = |v: u8| ((v as u32 * 32767) / 255) as u16;
|
||||
let mut b = 0u32;
|
||||
let set = |b: &mut u32, on: bool, m: u32| {
|
||||
if on {
|
||||
*b |= m;
|
||||
}
|
||||
};
|
||||
set(&mut b, on(gs::BTN_A), tbtn::A);
|
||||
set(&mut b, on(gs::BTN_B), tbtn::B);
|
||||
set(&mut b, on(gs::BTN_X), tbtn::X);
|
||||
set(&mut b, on(gs::BTN_Y), tbtn::Y);
|
||||
set(&mut b, on(gs::BTN_LB), tbtn::LB);
|
||||
set(&mut b, on(gs::BTN_RB), tbtn::RB);
|
||||
set(&mut b, on(gs::BTN_BACK), tbtn::VIEW);
|
||||
set(&mut b, on(gs::BTN_START), tbtn::MENU);
|
||||
set(&mut b, on(gs::BTN_GUIDE), tbtn::STEAM);
|
||||
set(&mut b, on(gs::BTN_LS_CLICK), tbtn::L3);
|
||||
set(&mut b, on(gs::BTN_RS_CLICK), tbtn::R3);
|
||||
set(&mut b, on(gs::BTN_DPAD_UP), tbtn::DPAD_UP);
|
||||
set(&mut b, on(gs::BTN_DPAD_DOWN), tbtn::DPAD_DOWN);
|
||||
set(&mut b, on(gs::BTN_DPAD_LEFT), tbtn::DPAD_LEFT);
|
||||
set(&mut b, on(gs::BTN_DPAD_RIGHT), tbtn::DPAD_RIGHT);
|
||||
set(&mut b, on(gs::BTN_TOUCHPAD), tbtn::RPAD_CLICK);
|
||||
set(&mut b, on(gs::BTN_PADDLE1), tbtn::R4);
|
||||
set(&mut b, on(gs::BTN_PADDLE2), tbtn::L4);
|
||||
set(&mut b, on(gs::BTN_PADDLE3), tbtn::R5);
|
||||
set(&mut b, on(gs::BTN_PADDLE4), tbtn::L5);
|
||||
set(&mut b, on(gs::BTN_MISC1), tbtn::QAM);
|
||||
// "Fully pressed" digital shadow of the analog triggers (the physical pad's own
|
||||
// threshold is a hard pull, not first-contact).
|
||||
set(&mut b, lt >= 240, tbtn::LT_CLICK);
|
||||
set(&mut b, rt >= 240, tbtn::RT_CLICK);
|
||||
TritonState {
|
||||
raw: [0u8; TRITON_REPORT_LEN],
|
||||
raw_len: 0,
|
||||
buttons: b,
|
||||
lt: trig(lt),
|
||||
rt: trig(rt),
|
||||
lx,
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the typed fallback state into a minimal `0x42` `TritonMTUNoQuat_t` report:
|
||||
/// `[0x42][seq u8][buttons u32][trigL i16][trigR i16][sticks i16×4][lpad x/y + pressure]
|
||||
/// [rpad x/y + pressure][imu ts u32 + accel i16×3 + gyro i16×3]` — pads and IMU stay zero
|
||||
/// (no raw feed = no trackpad/motion source; Steam only sees IMU data after enabling
|
||||
/// `SETTING_IMU_MODE` on a real feed anyway).
|
||||
pub fn serialize_triton_state(buf: &mut [u8; TRITON_STATE_LEN], st: &TritonState, seq: u8) {
|
||||
buf.fill(0);
|
||||
buf[0] = ID_TRITON_CONTROLLER_STATE;
|
||||
buf[1] = seq;
|
||||
buf[2..6].copy_from_slice(&st.buttons.to_le_bytes());
|
||||
buf[6..8].copy_from_slice(&(st.lt as i16).to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&(st.rt as i16).to_le_bytes());
|
||||
buf[10..12].copy_from_slice(&st.lx.to_le_bytes());
|
||||
buf[12..14].copy_from_slice(&st.ly.to_le_bytes());
|
||||
buf[14..16].copy_from_slice(&st.rx.to_le_bytes());
|
||||
buf[16..18].copy_from_slice(&st.ry.to_le_bytes());
|
||||
// [18..30] left/right pad + pressures stay zero; [30..46] IMU stays zero.
|
||||
}
|
||||
|
||||
/// One service pass's extracted feedback: the raw reports to forward (kind-tagged for
|
||||
/// [`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)) plus the rumble level parsed out of a
|
||||
/// `0x80` report for the universal 0xCA plane (drives the phone-mirror path on clients whose
|
||||
/// physical pad already gets the raw report).
|
||||
#[derive(Default)]
|
||||
pub struct TritonFeedback {
|
||||
/// `(low, high)` — `left.speed`/`right.speed` of the last rumble output report seen.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Raw reports to forward: `(kind, bytes)` with kind = `HID_RAW_OUTPUT`/`HID_RAW_FEATURE`.
|
||||
pub raw: Vec<(u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Parse a Triton haptic-rumble OUTPUT report (`MsgHapticRumble`, 10 bytes with id):
|
||||
/// `[0x80][type u8][intensity u16][left.speed u16][left.gain i8][right.speed u16][right.gain i8]`.
|
||||
/// Returns `(left_speed, right_speed)` as `(low, high)`.
|
||||
pub fn parse_triton_rumble(data: &[u8]) -> Option<(u16, u16)> {
|
||||
if data.len() < 10 || data[0] != ID_OUT_REPORT_HAPTIC_RUMBLE {
|
||||
return None;
|
||||
}
|
||||
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
|
||||
Some((le(4), le(7)))
|
||||
}
|
||||
|
||||
/// Strip the hidraw unnumbered-report `0x00` prefix if present: Triton report/command ids are all
|
||||
/// non-zero (`0x42+` input, `0x80+` output, `1` feature), so a leading zero can only be the
|
||||
/// synthetic report-id byte hidraw prepends on this unnumbered virtual descriptor.
|
||||
pub fn strip_report_prefix(data: &[u8]) -> &[u8] {
|
||||
match data {
|
||||
[0, rest @ ..] if !rest.is_empty() => rest,
|
||||
d => d,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The typed fallback lands the canonical wire mapping on the SDL-documented bit positions
|
||||
/// and byte offsets.
|
||||
#[test]
|
||||
fn fallback_state_serializes_sdl_layout() {
|
||||
let st = TritonState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_START | gs::BTN_PADDLE1 | gs::BTN_MISC1,
|
||||
1000,
|
||||
-2000,
|
||||
3000,
|
||||
-32768,
|
||||
255,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
st.buttons,
|
||||
tbtn::A | tbtn::MENU | tbtn::R4 | tbtn::QAM | tbtn::LT_CLICK
|
||||
);
|
||||
assert_eq!(st.lt, 32767); // exact full-scale, not the *128 approximation
|
||||
let mut r = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut r, &st, 7);
|
||||
assert_eq!(r[0], ID_TRITON_CONTROLLER_STATE);
|
||||
assert_eq!(r[1], 7);
|
||||
assert_eq!(u32::from_le_bytes([r[2], r[3], r[4], r[5]]), st.buttons);
|
||||
assert_eq!(i16::from_le_bytes([r[6], r[7]]), 32767); // sTriggerLeft
|
||||
assert_eq!(i16::from_le_bytes([r[10], r[11]]), 1000); // sLeftStickX
|
||||
assert_eq!(i16::from_le_bytes([r[16], r[17]]), -32768); // sRightStickY
|
||||
assert!(r[18..].iter().all(|&b| b == 0)); // pads + IMU zero
|
||||
}
|
||||
|
||||
/// A rumble output report parses to `(left_speed, right_speed)`; other ids don't.
|
||||
#[test]
|
||||
fn rumble_output_report_parses() {
|
||||
// [0x80, type, intensity(2), left.speed(2), left.gain, right.speed(2), right.gain]
|
||||
let mut d = [0u8; 10];
|
||||
d[0] = ID_OUT_REPORT_HAPTIC_RUMBLE;
|
||||
d[4..6].copy_from_slice(&0x1234u16.to_le_bytes());
|
||||
d[7..9].copy_from_slice(&0x5678u16.to_le_bytes());
|
||||
assert_eq!(parse_triton_rumble(&d), Some((0x1234, 0x5678)));
|
||||
d[0] = 0x81; // haptic pulse — not rumble
|
||||
assert_eq!(parse_triton_rumble(&d), None);
|
||||
assert_eq!(parse_triton_rumble(&d[..8]), None); // short
|
||||
}
|
||||
|
||||
/// The hidraw `0x00` unnumbered prefix strips; genuine command bytes survive.
|
||||
#[test]
|
||||
fn report_prefix_strips_only_leading_zero() {
|
||||
assert_eq!(strip_report_prefix(&[0x00, 0x80, 1, 2]), &[0x80, 1, 2]);
|
||||
assert_eq!(strip_report_prefix(&[0x80, 1, 2]), &[0x80, 1, 2]);
|
||||
assert_eq!(strip_report_prefix(&[0x01, 0x87]), &[0x01, 0x87]); // feature id 1 kept
|
||||
assert_eq!(strip_report_prefix(&[0x00]), &[0x00]); // lone zero: nothing to strip to
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user