"""input_source.py -- read the Steam Deck's built-in controls and normalize to Triton fields. Pure stdlib: talks the Linux **evdev** ioctl ABI on /dev/input/event* directly (no `evdev` package, so `uv` needs no compiler -- only pyserial + pygame for the app). EVIOCGRAB *detaches* the controls from everything else while forwarding; release to hand them back. Reliable in Desktop Mode. Gyro + trackpads: richer than evdev and best read from the Steam Deck's `hid-steam` hidraw report -- that decode is hardware-specific and OFF by default (use_hidraw=True / --hidraw). Offsets in HidrawImu are a documented starting point to verify on hardware. Triton field layout matches triton.h (TB_* masks, int16 sticks/pads, 0..255 triggers, int16 IMU). """ from __future__ import annotations import fcntl import glob import os import struct # ---- Triton button masks (mirror triton.h) ---- TB_A = 0x1 TB_B = 0x2 TB_X = 0x4 TB_Y = 0x8 TB_QAM = 0x10 TB_R3 = 0x20 TB_VIEW = 0x40 TB_R4 = 0x80 TB_R5 = 0x100 TB_RB = 0x200 TB_DDN = 0x400 TB_DRT = 0x800 TB_DLF = 0x1000 TB_DUP = 0x2000 TB_MENU = 0x4000 TB_L3 = 0x8000 TB_STEAM = 0x10000 TB_L4 = 0x20000 TB_L5 = 0x40000 TB_LB = 0x80000 NEUTRAL = { "buttons": 0, "lx": 0, "ly": 0, "rx": 0, "ry": 0, "lt": 0, "rt": 0, "lpx": 0, "lpy": 0, "rpx": 0, "rpy": 0, "ax": 0, "ay": 0, "az": 0, "gx": 0, "gy": 0, "gz": 0, } # ---- Linux input ABI constants (stable kernel ABI) ---- EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03 BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134 BTN_TL, BTN_TR = 0x136, 0x137 BTN_SELECT, BTN_START, BTN_MODE = 0x13A, 0x13B, 0x13C BTN_THUMBL, BTN_THUMBR = 0x13D, 0x13E ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ = 0, 1, 2, 3, 4, 5 ABS_HAT0X, ABS_HAT0Y = 0x10, 0x11 # evdev key code -> Triton button mask KEYMAP = { BTN_SOUTH: TB_A, BTN_EAST: TB_B, BTN_WEST: TB_X, BTN_NORTH: TB_Y, BTN_TL: TB_LB, BTN_TR: TB_RB, BTN_SELECT: TB_VIEW, BTN_START: TB_MENU, BTN_MODE: TB_STEAM, BTN_THUMBL: TB_L3, BTN_THUMBR: TB_R3, } # struct input_event { __kernel_long tv_sec, tv_usec; __u16 type, code; __s32 value; } = 24 bytes (64-bit) _EVENT = struct.Struct("qqHHi") EVENT_SZ = _EVENT.size # 24 # ---- ioctl number assembly (asm-generic _IOC) ---- _IOC_NONE, _IOC_WRITE, _IOC_READ = 0, 1, 2 def _IOC(d, t, nr, size): return (d << 30) | (size << 16) | (ord(t) << 8) | nr EVIOCGID = _IOC(_IOC_READ, 'E', 0x02, 8) # struct input_id (4x u16) def EVIOCGNAME(length): return _IOC(_IOC_READ, 'E', 0x06, length) def EVIOCGBIT(ev, length): return _IOC(_IOC_READ, 'E', 0x20 + ev, length) def EVIOCGABS(abscode): return _IOC(_IOC_READ, 'E', 0x40 + abscode, 24) # struct input_absinfo (6x s32) EVIOCGRAB = _IOC(_IOC_WRITE, 'E', 0x90, 4) def _test_bit(bitmask, bit): return bool(bitmask[bit // 8] & (1 << (bit % 8))) class _RawDev: """Minimal pure-stdlib evdev device.""" def __init__(self, path): self.path = path self.fd = os.open(path, os.O_RDWR | os.O_NONBLOCK) def close(self): try: os.close(self.fd) except Exception: pass def ids(self): buf = bytearray(8) try: fcntl.ioctl(self.fd, EVIOCGID, buf, True) _bus, vid, pid, _ver = struct.unpack(" 0: s["buttons"] |= TB_DRT elif code == ABS_HAT0Y: s["buttons"] &= ~(TB_DUP | TB_DDN) if val < 0: s["buttons"] |= TB_DUP elif val > 0: s["buttons"] |= TB_DDN class HidrawImu: """Optional gyro + trackpad reader from the Deck's hid-steam hidraw node (PID 0x1205). DISABLED by default. The Steam Deck input report layout must be confirmed on hardware -- the offsets below are a documented starting point, not verified bytes. Until then forwarding works with evdev buttons/sticks/triggers and zero IMU. """ PID = 0x1205 def __init__(self): self.path = self._find() self.fd = open(self.path, "rb", buffering=0) if self.path else None def _find(self): for p in glob.glob("/dev/hidraw*"): try: ue = open("/sys/class/hidraw/%s/device/uevent" % p.split("/")[-1]).read().upper() except Exception: continue if "28DE" in ue and "1205" in ue: return p return None def read_into(self, state): if not self.fd: return False try: rpt = self.fd.read(64) except Exception: return False if not rpt or len(rpt) < 64: return False # Placeholder offsets per the SDL hid_steam Deck report; VERIFY on hardware. try: ax, ay, az, gx, gy, gz = struct.unpack_from("<6h", rpt, 28) state["ax"], state["ay"], state["az"] = ax, ay, az state["gx"], state["gy"], state["gz"] = gx, gy, gz lpx, lpy, rpx, rpy = struct.unpack_from("<4h", rpt, 16) state["lpx"], state["lpy"], state["rpx"], state["rpy"] = lpx, lpy, rpx, rpy return True except Exception: return False def _list_inputs(): """Diagnostic: print every /dev/input gamepad candidate and which one we'd grab. python3 input_source.py """ pick = find_deck_controller() for path in sorted(glob.glob("/dev/input/event*")): try: d = _RawDev(path) except Exception as ex: print("%-22s (open failed: %s)" % (path, ex)) continue try: vid, pid = d.ids() print("%-22s vid=%04x pid=%04x gamepad=%s %s%s" % (path, vid, pid, d.has_key(BTN_SOUTH), d.name(), " <== WILL GRAB" if path == pick else "")) finally: d.close() print("\nGrab target: %s" % (pick or "NONE found — is Steam holding the controller? try Desktop Mode")) if __name__ == "__main__": _list_inputs()