Skip to main content

scdsu_core/devices/
triton.rs

1//! Adapter for the 2026 Steam Controller (Triton)
2
3use hidapi::{HidApi, HidDevice};
4use std::time::Duration;
5
6use crate::devices::device::{Device, FrameDevice};
7use crate::devices::util::{
8    is_u32_masked_button_pressed, scale_stick_to_byte, scale_trigger_to_byte,
9};
10use crate::devices::{DeviceButton, DeviceConfig, GyroActivationMode};
11use crate::dsu::DSUFrame;
12use crate::errors::DeviceError;
13
14/// Steam Controller vendor/product IDs.
15const VID: u16 = 0x28de;
16const PID_WIRED: u16 = 0x1302;
17const PID_BT: u16 = 0x1303;
18const PID_PUCK: u16 = 0x1304;
19
20/// HID usage page for the vendor-defined gamepad interface.
21const USAGE_PAGE_VENDOR_MIN: u16 = 0xFF00;
22
23/// Feature report constants
24const FEATURE_REPORT_ID: u8 = 0x01;
25const FEATURE_REPORT_SIZE: usize = 64;
26const SEND_FEATURE_REPORT_SLEEP_DURATION: Duration = Duration::from_millis(50);
27const CMD_SET_SETTINGS_VALUES: u8 = 0x87;
28
29/// Setting register IDs
30const SETTING_LIZARD_MODE: u8 = 9;
31const SETTING_IMU_MODE: u8 = 48;
32
33/// Setting values
34const LIZARD_MODE_OFF: u16 = 0;
35const LIZARD_MODE_ON: u16 = 1;
36const IMU_MODE_SEND_RAW_ACCEL: u16 = 0x08;
37const IMU_MODE_SEND_RAW_GYRO: u16 = 0x10;
38const IMU_MODE_GYRO_ACCEL: u16 = IMU_MODE_SEND_RAW_ACCEL | IMU_MODE_SEND_RAW_GYRO;
39
40/// Input report IDs
41const REPORT_ID_STATE_USB: u8 = 0x42;
42const REPORT_ID_STATE_BLE: u8 = 0x45;
43
44/// IMU data offset in input report (after report ID)
45const IMU_OFFSET_START: usize = 29;
46const IMU_OFFSET_ACCEL_X: usize = IMU_OFFSET_START + 4;
47const IMU_OFFSET_ACCEL_Y: usize = IMU_OFFSET_START + 6;
48const IMU_OFFSET_ACCEL_Z: usize = IMU_OFFSET_START + 8;
49const IMU_OFFSET_GYRO_X: usize = IMU_OFFSET_START + 10;
50const IMU_OFFSET_GYRO_Y: usize = IMU_OFFSET_START + 12;
51const IMU_OFFSET_GYRO_Z: usize = IMU_OFFSET_START + 14;
52
53/// Sensor scale factors
54const ACCEL_PER_G: f32 = 16384.0;
55const GYRO_PER_DPS: f32 = 16.384;
56
57const ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD: u8 = 228;
58
59// Button masks
60const MASK_A: u32 = 0x0000_0001;
61const MASK_B: u32 = 0x0000_0002;
62const MASK_X: u32 = 0x0000_0004;
63const MASK_Y: u32 = 0x0000_0008;
64const MASK_QAM: u32 = 0x0000_0010;
65const MASK_R3: u32 = 0x0000_0020;
66const MASK_VIEW: u32 = 0x0000_0040;
67const MASK_R: u32 = 0x0000_0200;
68const MASK_DPAD_DOWN: u32 = 0x0000_0400;
69const MASK_DPAD_RIGHT: u32 = 0x0000_0800;
70const MASK_DPAD_LEFT: u32 = 0x0000_1000;
71const MASK_DPAD_UP: u32 = 0x0000_2000;
72const MASK_MENU: u32 = 0x0000_4000;
73const MASK_L3: u32 = 0x0000_8000;
74const MASK_STEAM: u32 = 0x0001_0000;
75const MASK_L: u32 = 0x0008_0000;
76const MASK_RIGHT_STICK_TOUCH: u32 = 0x0010_0000;
77const MASK_RIGHT_PAD_TOUCH: u32 = 0x0020_0000;
78const MASK_LEFT_STICK_TOUCH: u32 = 0x0100_0000;
79const MASK_LEFT_PAD_TOUCH: u32 = 0x0200_0000;
80const MASK_RIGHT_GRIP: u32 = 0x1000_0000;
81const MASK_LEFT_GRIP: u32 = 0x2000_0000;
82const MASK_L4: u32 = 0x0002_0000;
83const MASK_L5: u32 = 0x0004_0000;
84const MASK_R4: u32 = 0x0000_0080;
85const MASK_R5: u32 = 0x0000_0100;
86
87const READ_TIMEOUT_MILLIS: i32 = 100;
88
89/// Parsed Triton frame.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct TritonFrame {
92    pub buttons: u32,
93    pub trigger_left: u16,
94    pub trigger_right: u16,
95    pub left_stick_x: i16,
96    pub left_stick_y: i16,
97    pub right_stick_x: i16,
98    pub right_stick_y: i16,
99    pub imu_timestamp: u32,
100    pub accel_x: i16,
101    pub accel_y: i16,
102    pub accel_z: i16,
103    pub gyro_x: i16,
104    pub gyro_y: i16,
105    pub gyro_z: i16,
106
107    // the remaining are not for DSU, only for gyro toggle support.
108    pub left_stick_touch: bool,
109    pub right_stick_touch: bool,
110    pub left_pad_touch: bool,
111    pub right_pad_touch: bool,
112    pub left_grip: bool,
113    pub right_grip: bool,
114}
115
116impl TritonFrame {
117    /// Parse a raw HID report. Works for both USB (0x42) and BLE (0x45) report IDs.
118    pub fn parse(data: &[u8]) -> Option<Self> {
119        if data.is_empty() {
120            return None;
121        }
122
123        let report_id = data[0];
124        if report_id != REPORT_ID_STATE_USB && report_id != REPORT_ID_STATE_BLE {
125            return None;
126        }
127
128        // Need at least: 1 report ID + 29 bytes to IMU + 16 bytes IMU data
129        if data.len() < 1 + IMU_OFFSET_START + 16 {
130            return None;
131        }
132
133        let p = &data[1..];
134        let buttons = u32::from_le_bytes([p[1], p[2], p[3], p[4]]);
135
136        Some(Self {
137            buttons,
138            trigger_left: u16::from_le_bytes([p[5], p[6]]),
139            trigger_right: u16::from_le_bytes([p[7], p[8]]),
140            left_stick_x: i16::from_le_bytes([p[9], p[10]]),
141            left_stick_y: i16::from_le_bytes([p[11], p[12]]),
142            right_stick_x: i16::from_le_bytes([p[13], p[14]]),
143            right_stick_y: i16::from_le_bytes([p[15], p[16]]),
144            imu_timestamp: u32::from_le_bytes([
145                p[IMU_OFFSET_START],
146                p[IMU_OFFSET_START + 1],
147                p[IMU_OFFSET_START + 2],
148                p[IMU_OFFSET_START + 3],
149            ]),
150            accel_x: i16::from_le_bytes([p[IMU_OFFSET_ACCEL_X], p[IMU_OFFSET_ACCEL_X + 1]]),
151            accel_y: i16::from_le_bytes([p[IMU_OFFSET_ACCEL_Y], p[IMU_OFFSET_ACCEL_Y + 1]]),
152            accel_z: i16::from_le_bytes([p[IMU_OFFSET_ACCEL_Z], p[IMU_OFFSET_ACCEL_Z + 1]]),
153            gyro_x: i16::from_le_bytes([p[IMU_OFFSET_GYRO_X], p[IMU_OFFSET_GYRO_X + 1]]),
154            gyro_y: i16::from_le_bytes([p[IMU_OFFSET_GYRO_Y], p[IMU_OFFSET_GYRO_Y + 1]]),
155            gyro_z: i16::from_le_bytes([p[IMU_OFFSET_GYRO_Z], p[IMU_OFFSET_GYRO_Z + 1]]),
156            left_stick_touch: (buttons & MASK_LEFT_STICK_TOUCH) != 0,
157            right_stick_touch: (buttons & MASK_RIGHT_STICK_TOUCH) != 0,
158            left_pad_touch: (buttons & MASK_LEFT_PAD_TOUCH) != 0,
159            right_pad_touch: (buttons & MASK_RIGHT_PAD_TOUCH) != 0,
160            left_grip: (buttons & MASK_LEFT_GRIP) != 0,
161            right_grip: (buttons & MASK_RIGHT_GRIP) != 0,
162        })
163    }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq)]
167pub enum ConnectionMode {
168    Usb,
169    UsbPuck,
170    Bluetooth,
171}
172
173/// Triton (Steam Controller 2026) device
174pub struct Triton {
175    config: DeviceConfig,
176    hid: HidDevice,
177}
178
179impl Triton {
180    /// Enumerate all vendor interfaces and return the first Triton found.
181    ///
182    /// The created Triton will use `config` as its ['DeviceConfiguration'](crate::devices::DeviceConfiguration).
183    /// Requires passing an `api` ([`HidApi`](hidapi::HidApi)) and optionally a specific `device_path`
184    pub fn find(
185        config: DeviceConfig,
186        api: &HidApi,
187        device_path: Option<&str>,
188    ) -> Result<Self, DeviceError> {
189        let candidates: Vec<_> = api
190            .device_list()
191            .filter(|d| {
192                log::debug!(
193                    "Considering VID {:04x}, PID {:04x}, Usage page {:04x}",
194                    d.vendor_id(),
195                    d.product_id(),
196                    d.usage_page()
197                );
198                d.vendor_id() == VID
199                    && d.usage_page() >= USAGE_PAGE_VENDOR_MIN
200                    && (d.product_id() == PID_PUCK
201                        || d.product_id() == PID_WIRED
202                        || d.product_id() == PID_BT)
203            })
204            .collect();
205
206        log::debug!("Found {} candidate vendor interfaces", candidates.len());
207
208        if let Some(target) = device_path {
209            let info = candidates
210                .into_iter()
211                .find(|d| d.path().to_str().ok() == Some(target));
212
213            let Some(info) = info else {
214                return Err(DeviceError::NoDeviceFoundAtPath(target.to_string()));
215            };
216
217            let pid = info.product_id();
218            let hid = info.open_device(api)?;
219            let mode = connection_mode_from_pid(pid);
220
221            probe_device(&hid)?;
222
223            log::info!("Opened controller on {} ({:?})", target, mode);
224            return Ok(Triton { config, hid });
225        }
226
227        for info in candidates {
228            let Ok(path) = info.path().to_str() else {
229                log::debug!("Skipping device, could not get a path: {info:?}");
230                continue;
231            };
232
233            log::debug!("Trying interface at {}", path);
234
235            let pid = info.product_id();
236            let hid = match info.open_device(api) {
237                Ok(hid) => hid,
238                Err(err) => {
239                    log::debug!("Failed to obtain handle to device at {path}: {err:?}");
240                    continue;
241                }
242            };
243
244            let mode = connection_mode_from_pid(pid);
245
246            if let Err(e) = probe_device(&hid) {
247                log::debug!("Probe failed for device at {path}: {e}");
248                continue;
249            }
250
251            log::info!("Opened controller on {} ({:?})", path, mode);
252            return Ok(Triton { config, hid });
253        }
254
255        Err(DeviceError::NoDeviceFound)
256    }
257}
258
259impl Device for Triton {
260    fn initialize(&self) -> Result<(), DeviceError> {
261        log::debug!("Sending IMU enable sequence... ");
262        send_setting(&self.hid, SETTING_LIZARD_MODE, LIZARD_MODE_OFF)?;
263
264        std::thread::sleep(SEND_FEATURE_REPORT_SLEEP_DURATION);
265
266        send_setting(&self.hid, SETTING_IMU_MODE, IMU_MODE_GYRO_ACCEL)?;
267
268        log::debug!("IMU enable sequence complete");
269
270        Ok(())
271    }
272
273    fn read_frame(&self) -> Result<DSUFrame, DeviceError> {
274        let mut buf = [0u8; 64];
275        let n = self.hid.read_timeout(&mut buf, READ_TIMEOUT_MILLIS)?;
276
277        if n == 0 {
278            return Err(DeviceError::ShortRead(0, 1));
279        }
280
281        let frame = TritonFrame::parse(&buf[..n]).ok_or(DeviceError::InvalidReport(buf[0]))?;
282
283        let inputs = &self.config.gyro_activation_inputs;
284        let mut enable_gyro = true;
285
286        // gyro toggling
287        if !inputs.is_empty() {
288            enable_gyro = match self.config.gyro_activation_mode {
289                GyroActivationMode::Any => inputs
290                    .iter()
291                    .any(|button| self.is_device_button_pressed(button, &frame)),
292
293                GyroActivationMode::All => inputs
294                    .iter()
295                    .all(|button| self.is_device_button_pressed(button, &frame)),
296            };
297        }
298
299        log::trace!("Parsed TritonFrame: {:?}", frame);
300
301        Ok(self.to_dsu_frame(&frame, !enable_gyro))
302    }
303}
304
305impl FrameDevice<TritonFrame> for Triton {
306    fn to_dsu_frame(&self, frame: &TritonFrame, gyro_disabled: bool) -> DSUFrame {
307        let l2 = scale_trigger_to_byte(frame.trigger_left as i16);
308        let r2 = scale_trigger_to_byte(frame.trigger_right as i16);
309
310        let gyro_x_dps = frame.gyro_x as f32 / GYRO_PER_DPS;
311        let gyro_y_dps = -(frame.gyro_z as f32 / GYRO_PER_DPS);
312        let gyro_z_dps = frame.gyro_y as f32 / GYRO_PER_DPS;
313
314        let apply_deadzone = |v: f32| {
315            if v.abs() < self.config.gyro_deadzone {
316                0.0
317            } else {
318                v
319            }
320        };
321
322        let zero_on_gyro_disabled = |v: f32| {
323            if gyro_disabled { 0.0 } else { v }
324        };
325
326        DSUFrame {
327            dpad_left: is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_LEFT),
328            dpad_down: is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_DOWN),
329            dpad_right: is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_RIGHT),
330            dpad_up: is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_UP),
331            options: is_u32_masked_button_pressed(frame.buttons, MASK_VIEW),
332            r3: is_u32_masked_button_pressed(frame.buttons, MASK_R3),
333            l3: is_u32_masked_button_pressed(frame.buttons, MASK_L3),
334            share: is_u32_masked_button_pressed(frame.buttons, MASK_MENU),
335            y: is_u32_masked_button_pressed(frame.buttons, MASK_Y),
336            b: is_u32_masked_button_pressed(frame.buttons, MASK_B),
337            a: is_u32_masked_button_pressed(frame.buttons, MASK_A),
338            x: is_u32_masked_button_pressed(frame.buttons, MASK_X),
339            r1: is_u32_masked_button_pressed(frame.buttons, MASK_R),
340            l1: is_u32_masked_button_pressed(frame.buttons, MASK_L),
341            r2: r2 >= ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD,
342            l2: l2 >= ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD,
343            home: is_u32_masked_button_pressed(frame.buttons, MASK_STEAM),
344            touch: is_u32_masked_button_pressed(frame.buttons, MASK_QAM),
345            left_stick_x: scale_stick_to_byte(frame.left_stick_x),
346            left_stick_y: scale_stick_to_byte(frame.left_stick_y),
347            right_stick_x: scale_stick_to_byte(frame.right_stick_x),
348            right_stick_y: scale_stick_to_byte(frame.right_stick_y),
349            analog_r2: r2,
350            analog_l2: l2,
351            raw_accel_x: frame.accel_x as f32,
352            raw_accel_y: frame.accel_y as f32,
353            raw_accel_z: frame.accel_z as f32,
354            raw_gyro_x: frame.gyro_x as f32,
355            raw_gyro_y: frame.gyro_y as f32,
356            raw_gyro_z: frame.gyro_z as f32,
357            accel_x: zero_on_gyro_disabled(-(frame.accel_x as f32 / ACCEL_PER_G)),
358            accel_y: zero_on_gyro_disabled(-(frame.accel_z as f32 / ACCEL_PER_G)),
359            accel_z: zero_on_gyro_disabled(frame.accel_y as f32 / ACCEL_PER_G),
360            gyro_x: zero_on_gyro_disabled(
361                apply_deadzone(gyro_x_dps) * self.config.gyro_pitch_scale,
362            ),
363            gyro_y: zero_on_gyro_disabled(apply_deadzone(gyro_y_dps) * self.config.gyro_yaw_scale),
364            gyro_z: zero_on_gyro_disabled(apply_deadzone(gyro_z_dps) * self.config.gyro_roll_scale),
365        }
366    }
367
368    fn is_device_button_pressed(&self, button: &DeviceButton, frame: &TritonFrame) -> bool {
369        match button {
370            DeviceButton::DpadLeft => is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_LEFT),
371            DeviceButton::DpadDown => is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_DOWN),
372            DeviceButton::DpadRight => is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_RIGHT),
373            DeviceButton::DpadUp => is_u32_masked_button_pressed(frame.buttons, MASK_DPAD_UP),
374            DeviceButton::Start => is_u32_masked_button_pressed(frame.buttons, MASK_VIEW),
375            DeviceButton::Select => is_u32_masked_button_pressed(frame.buttons, MASK_MENU),
376            DeviceButton::Guide => is_u32_masked_button_pressed(frame.buttons, MASK_STEAM),
377            DeviceButton::Quaternary => is_u32_masked_button_pressed(frame.buttons, MASK_QAM),
378            DeviceButton::A => is_u32_masked_button_pressed(frame.buttons, MASK_A),
379            DeviceButton::B => is_u32_masked_button_pressed(frame.buttons, MASK_B),
380            DeviceButton::X => is_u32_masked_button_pressed(frame.buttons, MASK_X),
381            DeviceButton::Y => is_u32_masked_button_pressed(frame.buttons, MASK_Y),
382            DeviceButton::L1 => is_u32_masked_button_pressed(frame.buttons, MASK_L),
383            DeviceButton::R1 => is_u32_masked_button_pressed(frame.buttons, MASK_R),
384            DeviceButton::L2 => {
385                scale_trigger_to_byte(frame.trigger_left as i16)
386                    >= ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD
387            }
388            DeviceButton::R2 => {
389                scale_trigger_to_byte(frame.trigger_right as i16)
390                    >= ANALOG_TRIGGER_TO_DIGITAL_THRESHOLD
391            }
392            DeviceButton::L3 => is_u32_masked_button_pressed(frame.buttons, MASK_L3),
393            DeviceButton::R3 => is_u32_masked_button_pressed(frame.buttons, MASK_R3),
394            DeviceButton::L4 => is_u32_masked_button_pressed(frame.buttons, MASK_L4),
395            DeviceButton::L5 => is_u32_masked_button_pressed(frame.buttons, MASK_L5),
396            DeviceButton::R4 => is_u32_masked_button_pressed(frame.buttons, MASK_R4),
397            DeviceButton::R5 => is_u32_masked_button_pressed(frame.buttons, MASK_R5),
398            DeviceButton::LeftStickTouch => frame.left_stick_touch,
399            DeviceButton::RightStickTouch => frame.right_stick_touch,
400            DeviceButton::LeftPadTouch => frame.left_pad_touch,
401            DeviceButton::RightPadTouch => frame.right_pad_touch,
402            DeviceButton::LeftGrip => frame.left_grip,
403            DeviceButton::RightGrip => frame.right_grip,
404            DeviceButton::Unknown => false,
405        }
406    }
407}
408
409impl Drop for Triton {
410    fn drop(&mut self) {
411        if !self.config.no_enable_lizard_mode_on_close
412            && send_setting(&self.hid, SETTING_LIZARD_MODE, LIZARD_MODE_ON).is_ok()
413        {
414            log::debug!("Re-enabled lizard mode");
415        }
416    }
417}
418
419/// Send a single setting value using hidapi.
420fn send_setting(hid: &HidDevice, setting: u8, value: u16) -> Result<(), DeviceError> {
421    let mut buf = [0u8; FEATURE_REPORT_SIZE];
422    buf[0] = FEATURE_REPORT_ID;
423    buf[1] = CMD_SET_SETTINGS_VALUES;
424    buf[2] = 3;
425    buf[3] = setting;
426    buf[4] = (value & 0xFF) as u8;
427    buf[5] = ((value >> 8) & 0xFF) as u8;
428
429    hid.send_feature_report(&buf)?;
430    Ok(())
431}
432
433fn connection_mode_from_pid(pid: u16) -> ConnectionMode {
434    match pid {
435        PID_BT => ConnectionMode::Bluetooth,
436        PID_WIRED => ConnectionMode::Usb,
437        PID_PUCK => ConnectionMode::UsbPuck,
438
439        // todo: this is stupid
440        _ => ConnectionMode::Usb,
441    }
442}
443
444/// Probe a device to verify it's responsive.
445fn probe_device(hid: &HidDevice) -> Result<(), DeviceError> {
446    let mut probe = [0u8; FEATURE_REPORT_SIZE];
447    probe[0] = FEATURE_REPORT_ID;
448    probe[1] = CMD_SET_SETTINGS_VALUES;
449    probe[2] = 3;
450    probe[3] = SETTING_LIZARD_MODE;
451    probe[4] = 0;
452    probe[5] = 0;
453    hid.send_feature_report(&probe)?;
454    Ok(())
455}