Skip to main content

scdsu_core/devices/
triton.rs

1//! Adapter for the 2026 Steam Controller (Triton)
2
3use hidapi::{HidApi, HidDevice};
4use std::fs::OpenOptions;
5use std::os::unix::io::AsRawFd;
6use std::time::Duration;
7
8use crate::devices::device::Device;
9use crate::devices::util::{
10    is_u32_masked_button_pressed, scale_stick_to_byte, scale_trigger_to_byte,
11};
12use crate::dsu::DSUFrame;
13use crate::errors::DeviceError;
14
15/// Steam Controller vendor/product IDs.
16const VID: u16 = 0x28de;
17const PID: u16 = 0x1304;
18
19/// HID usage page for the vendor-defined gamepad interface.
20const USAGE_PAGE_VENDOR: u16 = 0xFF00;
21
22/// Feature report command IDs (shared with Steam Deck / old controller).
23const CMD_CLEAR_DIGITAL_MAPPINGS: u8 = 0x81;
24const CMD_LOAD_DEFAULT_SETTINGS: u8 = 0x8E;
25const CMD_SET_SETTINGS_VALUES: u8 = 0x87;
26
27/// Setting register IDs.
28const SETTING_LEFT_TRACKPAD_MODE: u8 = 0x07;
29const SETTING_RIGHT_TRACKPAD_MODE: u8 = 0x08;
30const SETTING_IMU_MODE: u8 = 0x30;
31
32/// Trackpad mode values.
33const MODE_NONE: u16 = 0x07;
34
35/// IMU mode bitflags.
36const IMU_MODE_SEND_RAW_ACCEL: u16 = 0x08;
37const IMU_MODE_SEND_RAW_GYRO: u16 = 0x10;
38
39const FEATURE_REPORT_SLEEP_MILLIS: u64 = 50;
40const READ_TIMOUT_MILLIS: i32 = 100;
41
42/// Input report ID for the Triton full-state packet
43const REPORT_ID_TRITON_FULL: u8 = 0x42;
44
45/// Total HID report length (including Report ID)
46const REPORT_SIZE: usize = 54;
47
48/// Sensor scale factors
49const ACCEL_PER_G: f32 = 16384.0;
50const GYRO_PER_DPS: f32 = 16.384;
51
52const ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD: u8 = 228; // ~90%
53
54const MASK_A: u32 = 0x0000_0001;
55const MASK_B: u32 = 0x0000_0002;
56const MASK_X: u32 = 0x0000_0004;
57const MASK_Y: u32 = 0x0000_0008;
58const MASK_QAM: u32 = 0x0000_0010;
59const MASK_R3: u32 = 0x0000_0020;
60const MASK_VIEW: u32 = 0x0000_0040;
61// const MASK_R4: u32 = 0x0000_0080;
62// const MASK_R5: u32 = 0x0000_0100;
63const MASK_R: u32 = 0x0000_0200;
64const MASK_DPAD_DOWN: u32 = 0x0000_0400;
65const MASK_DPAD_RIGHT: u32 = 0x0000_0800;
66const MASK_DPAD_LEFT: u32 = 0x0000_1000;
67const MASK_DPAD_UP: u32 = 0x0000_2000;
68const MASK_MENU: u32 = 0x0000_4000;
69const MASK_L3: u32 = 0x0000_8000;
70const MASK_STEAM: u32 = 0x0001_0000;
71// const MASK_L4: u32 = 0x0002_0000;
72// const MASK_L5: u32 = 0x0004_0000;
73const MASK_L: u32 = 0x0008_0000;
74
75/// Parsed Triton frame.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct TritonFrame {
78    pub seq_num: u8,
79    pub buttons: u32,
80    pub trigger_left: i16,
81    pub trigger_right: i16,
82    pub left_stick_x: i16,
83    pub left_stick_y: i16,
84    pub right_stick_x: i16,
85    pub right_stick_y: i16,
86    pub left_pad_x: i16,
87    pub left_pad_y: i16,
88    pub pressure_left: u16,
89    pub right_pad_x: i16,
90    pub right_pad_y: i16,
91    pub pressure_right: u16,
92    pub imu_timestamp: u32,
93    pub accel_x: i16,
94    pub accel_y: i16,
95    pub accel_z: i16,
96    pub gyro_x: i16,
97    pub gyro_y: i16,
98    pub gyro_z: i16,
99    pub quat_w: i16,
100    pub quat_x: i16,
101    pub quat_y: i16,
102    pub quat_z: i16,
103}
104
105impl TritonFrame {
106    /// Parse a raw HID report. `data` must include the Report ID byte.
107    pub fn parse(data: &[u8]) -> Option<Self> {
108        if data.len() < REPORT_SIZE || data[0] != REPORT_ID_TRITON_FULL {
109            return None;
110        }
111        let p = &data[1..];
112        Some(Self {
113            seq_num: p[0],
114            buttons: u32::from_le_bytes([p[1], p[2], p[3], p[4]]),
115            trigger_left: i16::from_le_bytes([p[5], p[6]]),
116            trigger_right: i16::from_le_bytes([p[7], p[8]]),
117            left_stick_x: i16::from_le_bytes([p[9], p[10]]),
118            left_stick_y: i16::from_le_bytes([p[11], p[12]]),
119            right_stick_x: i16::from_le_bytes([p[13], p[14]]),
120            right_stick_y: i16::from_le_bytes([p[15], p[16]]),
121            left_pad_x: i16::from_le_bytes([p[17], p[18]]),
122            left_pad_y: i16::from_le_bytes([p[19], p[20]]),
123            pressure_left: u16::from_le_bytes([p[21], p[22]]),
124            right_pad_x: i16::from_le_bytes([p[23], p[24]]),
125            right_pad_y: i16::from_le_bytes([p[25], p[26]]),
126            pressure_right: u16::from_le_bytes([p[27], p[28]]),
127            imu_timestamp: u32::from_le_bytes([p[29], p[30], p[31], p[32]]),
128            accel_x: i16::from_le_bytes([p[33], p[34]]),
129            accel_y: i16::from_le_bytes([p[35], p[36]]),
130            accel_z: i16::from_le_bytes([p[37], p[38]]),
131            gyro_x: i16::from_le_bytes([p[39], p[40]]),
132            gyro_y: i16::from_le_bytes([p[41], p[42]]),
133            gyro_z: i16::from_le_bytes([p[43], p[44]]),
134            quat_w: i16::from_le_bytes([p[45], p[46]]),
135            quat_x: i16::from_le_bytes([p[47], p[48]]),
136            quat_y: i16::from_le_bytes([p[49], p[50]]),
137            quat_z: i16::from_le_bytes([p[51], p[52]]),
138        })
139    }
140}
141
142impl From<TritonFrame> for DSUFrame {
143    fn from(value: TritonFrame) -> Self {
144        let l2 = scale_trigger_to_byte(value.trigger_left);
145        let r2 = scale_trigger_to_byte(value.trigger_right);
146
147        DSUFrame {
148            dpad_left: is_u32_masked_button_pressed(value.buttons, MASK_DPAD_LEFT),
149            dpad_down: is_u32_masked_button_pressed(value.buttons, MASK_DPAD_DOWN),
150            dpad_right: is_u32_masked_button_pressed(value.buttons, MASK_DPAD_RIGHT),
151            dpad_up: is_u32_masked_button_pressed(value.buttons, MASK_DPAD_UP),
152            options: is_u32_masked_button_pressed(value.buttons, MASK_VIEW),
153            r3: is_u32_masked_button_pressed(value.buttons, MASK_R3),
154            l3: is_u32_masked_button_pressed(value.buttons, MASK_L3),
155            share: is_u32_masked_button_pressed(value.buttons, MASK_MENU),
156            y: is_u32_masked_button_pressed(value.buttons, MASK_Y),
157            b: is_u32_masked_button_pressed(value.buttons, MASK_B),
158            a: is_u32_masked_button_pressed(value.buttons, MASK_A),
159            x: is_u32_masked_button_pressed(value.buttons, MASK_X),
160            r1: is_u32_masked_button_pressed(value.buttons, MASK_R),
161            l1: is_u32_masked_button_pressed(value.buttons, MASK_L),
162            r2: r2 >= ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD,
163            l2: l2 >= ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD,
164            home: is_u32_masked_button_pressed(value.buttons, MASK_STEAM),
165            touch: is_u32_masked_button_pressed(value.buttons, MASK_QAM),
166            left_stick_x: scale_stick_to_byte(value.left_stick_x),
167            left_stick_y: scale_stick_to_byte(value.left_stick_y),
168            right_stick_x: scale_stick_to_byte(value.right_stick_x),
169            right_stick_y: scale_stick_to_byte(value.right_stick_y),
170            analog_r2: r2,
171            analog_l2: l2,
172            accel_x: -(value.accel_x as f32 / ACCEL_PER_G),
173            accel_y: -(value.accel_z as f32 / ACCEL_PER_G),
174            accel_z: (value.accel_y as f32 / ACCEL_PER_G),
175            gyro_x: (value.gyro_x as f32 / GYRO_PER_DPS),
176            gyro_y: -(value.gyro_z as f32 / GYRO_PER_DPS),
177            gyro_z: (value.gyro_y as f32 / GYRO_PER_DPS),
178        }
179    }
180}
181
182/// Triton (Steam Controller 2026) Linux device
183pub struct LinuxTriton {
184    hid: HidDevice,
185    path: String,
186}
187
188impl Device for LinuxTriton {
189    /// Initialize by enabling IMU on the raw file.
190    fn initialize(&self) -> Result<(), DeviceError> {
191        let raw = OpenOptions::new().read(true).write(true).open(&self.path)?;
192        enable_imu_on_file(&raw)?;
193        Ok(())
194    }
195
196    /// Read a single DSU frame from the controller.
197    fn read_frame(&self) -> Result<DSUFrame, DeviceError> {
198        let mut buf = [0u8; 64];
199        let n = self.hid.read_timeout(&mut buf, READ_TIMOUT_MILLIS)?;
200        if n < REPORT_SIZE {
201            return Err(DeviceError::ShortRead(n, REPORT_SIZE));
202        }
203
204        let frame = TritonFrame::parse(&buf[..n]).ok_or(DeviceError::InvalidReport(buf[0]))?;
205
206        Ok(frame.into())
207    }
208}
209
210impl Drop for LinuxTriton {
211    fn drop(&mut self) {
212        // Best-effort cleanup: attempt to return controller to factory defaults
213        let Ok(raw) = OpenOptions::new().read(true).write(true).open(&self.path) else {
214            return;
215        };
216        let mut cmd = [0u8; 64];
217        cmd[0] = 0x01;
218        cmd[1] = CMD_LOAD_DEFAULT_SETTINGS;
219        cmd[2] = 0;
220        if send_feature_report_via_ioctl(&raw, &cmd).is_ok() {
221            log::debug!("IMU disable sequence complete");
222        }
223    }
224}
225
226/// Compute the HIDIOCSFEATURE ioctl number for a buffer length `len`.
227///
228/// `_IOC(_IOC_WRITE | _IOC_READ, 'H', 0x06, len)`
229fn hidiocsfeature(len: usize) -> libc::c_ulong {
230    let dir = 3u32; // _IOC_WRITE | _IOC_READ
231    ((dir << 30) | ((len as u32) << 16) | ((b'H' as u32) << 8) | 6u32) as libc::c_ulong
232}
233
234fn send_feature_report_via_ioctl(file: &std::fs::File, data: &[u8]) -> Result<(), std::io::Error> {
235    // TODO: re-evaluate if we can do this with `hidapi` instead of raw
236    let ret = unsafe { libc::ioctl(file.as_raw_fd(), hidiocsfeature(data.len()), data.as_ptr()) };
237    if ret < 0 {
238        Err(std::io::Error::last_os_error())
239    } else {
240        Ok(())
241    }
242}
243
244/// Enumerate all vendor interfaces and return the first Triton that responds to a
245/// `CMD_CLEAR_DIGITAL_MAPPINGS` probe without error. Requires an [`HidApi`](hidapi::HidApi)
246pub fn linux_find_and_open(api: &HidApi) -> Result<LinuxTriton, DeviceError> {
247    let candidates: Vec<_> = api
248        .device_list()
249        .filter(|d| {
250            d.vendor_id() == VID && d.product_id() == PID && d.usage_page() == USAGE_PAGE_VENDOR
251        })
252        .collect();
253
254    log::debug!("Found {} candidate vendor interfaces", candidates.len());
255
256    for info in candidates {
257        let Ok(path) = info.path().to_str() else {
258            log::debug!("Skipping device, could not get a path: {info:?}");
259            continue;
260        };
261
262        log::debug!("Trying interface at {}", path);
263
264        let hid = info.open_device(api)?;
265
266        // Probe with a feature report to verify the controller is actually
267        // connected and responsive. The dongle keeps the USB endpoint alive
268        // even when the controller is off.
269        let Ok(raw) = OpenOptions::new().read(true).write(true).open(path) else {
270            log::debug!("Could not open raw hidraw at {}", path);
271            continue;
272        };
273        let mut probe = [0u8; 64];
274        probe[0] = 0x01;
275        probe[1] = CMD_CLEAR_DIGITAL_MAPPINGS;
276        if send_feature_report_via_ioctl(&raw, &probe).is_ok() {
277            log::info!("Opened controller on {}", path);
278            return Ok(LinuxTriton {
279                hid,
280                path: path.to_string(),
281            });
282        }
283        log::debug!("Interface at {} rejected feature report probe", path);
284    }
285
286    Err(DeviceError::NoDeviceFound)
287}
288
289fn enable_imu_on_file(file: &std::fs::File) -> Result<(), DeviceError> {
290    log::debug!("Sending IMU enable sequence...");
291
292    // Disable lizard mode
293    let mut cmd = [0u8; 64];
294    cmd[0] = 0x01;
295    cmd[1] = CMD_CLEAR_DIGITAL_MAPPINGS;
296    send_feature_report_via_ioctl(file, &cmd)?;
297    log::trace!("Sent CLEAR_DIGITAL_MAPPINGS");
298
299    std::thread::sleep(Duration::from_millis(FEATURE_REPORT_SLEEP_MILLIS));
300
301    // Reset to factory defaults
302    let mut cmd = [0u8; 64];
303    cmd[0] = 0x01;
304    cmd[1] = CMD_LOAD_DEFAULT_SETTINGS;
305    cmd[2] = 0;
306    send_feature_report_via_ioctl(file, &cmd)?;
307    log::trace!("Sent LOAD_DEFAULT_SETTINGS");
308
309    std::thread::sleep(Duration::from_millis(FEATURE_REPORT_SLEEP_MILLIS));
310
311    // Disable trackpad mouse emulation and enable IMU raw data
312    // This does not seem to interfere with steam input configurations somehow..
313    let mut cmd = [0u8; 64];
314    cmd[0] = 0x01;
315    cmd[1] = CMD_SET_SETTINGS_VALUES;
316    cmd[2] = 9; // 3 settings x 3 bytes each
317
318    cmd[3] = SETTING_LEFT_TRACKPAD_MODE;
319    cmd[4] = (MODE_NONE & 0xFF) as u8;
320    cmd[5] = (MODE_NONE >> 8) as u8;
321
322    cmd[6] = SETTING_RIGHT_TRACKPAD_MODE;
323    cmd[7] = (MODE_NONE & 0xFF) as u8;
324    cmd[8] = (MODE_NONE >> 8) as u8;
325
326    let imu_mode = IMU_MODE_SEND_RAW_ACCEL | IMU_MODE_SEND_RAW_GYRO;
327    cmd[9] = SETTING_IMU_MODE;
328    cmd[10] = (imu_mode & 0xFF) as u8;
329    cmd[11] = (imu_mode >> 8) as u8;
330
331    send_feature_report_via_ioctl(file, &cmd)?;
332    log::debug!("IMU enable sequence complete");
333
334    Ok(())
335}