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) device
183pub struct Triton {
184    hid: HidDevice,
185    path: String,
186}
187
188impl Device for Triton {
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 Triton {
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)
246/// to be passed to the first parameter.
247///
248/// If `device_path` is not `None`, only the device at that path is considered.
249pub fn find(api: &HidApi, device_path: Option<&str>) -> Result<Triton, DeviceError> {
250    let candidates: Vec<_> = api
251        .device_list()
252        .filter(|d| {
253            d.vendor_id() == VID && d.product_id() == PID && d.usage_page() == USAGE_PAGE_VENDOR
254        })
255        .collect();
256
257    log::debug!("Found {} candidate vendor interfaces", candidates.len());
258
259    if let Some(target) = device_path {
260        // Try the specific device
261        let info = candidates
262            .into_iter()
263            .find(|d| d.path().to_str().ok() == Some(target));
264
265        let Some(info) = info else {
266            return Err(DeviceError::NoDeviceFoundAtPath(target.to_string()));
267        };
268
269        let hid = info.open_device(api)?;
270        let device = open_and_probe(target, hid)?;
271
272        return Ok(device);
273    }
274
275    // Consider all candidates
276    for info in candidates {
277        let Ok(path) = info.path().to_str() else {
278            log::debug!("Skipping device, could not get a path: {info:?}");
279            continue;
280        };
281
282        log::debug!("Trying interface at {}", path);
283
284        let hid = match info.open_device(api) {
285            Ok(hid) => hid,
286            Err(err) => {
287                log::debug!("Failed to obtain handle to device at {path}: {err:?}");
288                continue;
289            }
290        };
291
292        let device = match open_and_probe(path, hid) {
293            Ok(device) => device,
294            Err(err) => {
295                log::debug!("Device at {path} failed probe: {err:?}");
296                continue;
297            }
298        };
299
300        return Ok(device);
301    }
302
303    Err(DeviceError::NoDeviceFound)
304}
305
306fn open_and_probe(path: &str, hid: hidapi::HidDevice) -> Result<Triton, DeviceError> {
307    log::debug!("Trying interface at {}", path);
308
309    // Probe with a feature report to verify the controller is actually
310    // connected and responsive. The dongle keeps the USB endpoint alive
311    // even when the controller is off.
312    let raw = OpenOptions::new().read(true).write(true).open(path)?;
313
314    let mut probe = [0u8; 64];
315    probe[0] = 0x01;
316    probe[1] = CMD_CLEAR_DIGITAL_MAPPINGS;
317
318    send_feature_report_via_ioctl(&raw, &probe)?;
319    log::info!("Opened controller on {}", path);
320    Ok(Triton {
321        hid,
322        path: path.to_string(),
323    })
324}
325
326fn enable_imu_on_file(file: &std::fs::File) -> Result<(), DeviceError> {
327    log::debug!("Sending IMU enable sequence...");
328
329    // Disable lizard mode
330    let mut cmd = [0u8; 64];
331    cmd[0] = 0x01;
332    cmd[1] = CMD_CLEAR_DIGITAL_MAPPINGS;
333    send_feature_report_via_ioctl(file, &cmd)?;
334    log::trace!("Sent CLEAR_DIGITAL_MAPPINGS");
335
336    std::thread::sleep(Duration::from_millis(FEATURE_REPORT_SLEEP_MILLIS));
337
338    // Reset to factory defaults
339    let mut cmd = [0u8; 64];
340    cmd[0] = 0x01;
341    cmd[1] = CMD_LOAD_DEFAULT_SETTINGS;
342    cmd[2] = 0;
343    send_feature_report_via_ioctl(file, &cmd)?;
344    log::trace!("Sent LOAD_DEFAULT_SETTINGS");
345
346    std::thread::sleep(Duration::from_millis(FEATURE_REPORT_SLEEP_MILLIS));
347
348    // Disable trackpad mouse emulation and enable IMU raw data
349    // This does not seem to interfere with steam input configurations somehow..
350    let mut cmd = [0u8; 64];
351    cmd[0] = 0x01;
352    cmd[1] = CMD_SET_SETTINGS_VALUES;
353    cmd[2] = 9; // 3 settings x 3 bytes each
354
355    cmd[3] = SETTING_LEFT_TRACKPAD_MODE;
356    cmd[4] = (MODE_NONE & 0xFF) as u8;
357    cmd[5] = (MODE_NONE >> 8) as u8;
358
359    cmd[6] = SETTING_RIGHT_TRACKPAD_MODE;
360    cmd[7] = (MODE_NONE & 0xFF) as u8;
361    cmd[8] = (MODE_NONE >> 8) as u8;
362
363    let imu_mode = IMU_MODE_SEND_RAW_ACCEL | IMU_MODE_SEND_RAW_GYRO;
364    cmd[9] = SETTING_IMU_MODE;
365    cmd[10] = (imu_mode & 0xFF) as u8;
366    cmd[11] = (imu_mode >> 8) as u8;
367
368    send_feature_report_via_ioctl(file, &cmd)?;
369    log::debug!("IMU enable sequence complete");
370
371    Ok(())
372}