input_source.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. """input_source.py -- read the Steam Deck's built-in controls and normalize to Triton fields.
  2. Pure stdlib: talks the Linux **evdev** ioctl ABI on /dev/input/event* directly (no `evdev` package,
  3. so `uv` needs no compiler -- only pyserial + pygame for the app). EVIOCGRAB *detaches* the controls
  4. from everything else while forwarding; release to hand them back. Reliable in Desktop Mode.
  5. Gyro + trackpads: richer than evdev and best read from the Steam Deck's `hid-steam` hidraw report --
  6. that decode is hardware-specific and OFF by default (use_hidraw=True / --hidraw). Offsets in HidrawImu
  7. are a documented starting point to verify on hardware.
  8. Triton field layout matches triton.h (TB_* masks, int16 sticks/pads, 0..255 triggers, int16 IMU).
  9. """
  10. from __future__ import annotations
  11. import fcntl
  12. import glob
  13. import os
  14. import struct
  15. # ---- Triton button masks (mirror triton.h) ----
  16. TB_A = 0x1
  17. TB_B = 0x2
  18. TB_X = 0x4
  19. TB_Y = 0x8
  20. TB_QAM = 0x10
  21. TB_R3 = 0x20
  22. TB_VIEW = 0x40
  23. TB_R4 = 0x80
  24. TB_R5 = 0x100
  25. TB_RB = 0x200
  26. TB_DDN = 0x400
  27. TB_DRT = 0x800
  28. TB_DLF = 0x1000
  29. TB_DUP = 0x2000
  30. TB_MENU = 0x4000
  31. TB_L3 = 0x8000
  32. TB_STEAM = 0x10000
  33. TB_L4 = 0x20000
  34. TB_L5 = 0x40000
  35. TB_LB = 0x80000
  36. NEUTRAL = {
  37. "buttons": 0, "lx": 0, "ly": 0, "rx": 0, "ry": 0, "lt": 0, "rt": 0,
  38. "lpx": 0, "lpy": 0, "rpx": 0, "rpy": 0,
  39. "ax": 0, "ay": 0, "az": 0, "gx": 0, "gy": 0, "gz": 0,
  40. }
  41. # ---- Linux input ABI constants (stable kernel ABI) ----
  42. EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
  43. BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
  44. BTN_TL, BTN_TR = 0x136, 0x137
  45. BTN_SELECT, BTN_START, BTN_MODE = 0x13A, 0x13B, 0x13C
  46. BTN_THUMBL, BTN_THUMBR = 0x13D, 0x13E
  47. ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ = 0, 1, 2, 3, 4, 5
  48. ABS_HAT0X, ABS_HAT0Y = 0x10, 0x11
  49. # evdev key code -> Triton button mask
  50. KEYMAP = {
  51. BTN_SOUTH: TB_A, BTN_EAST: TB_B, BTN_WEST: TB_X, BTN_NORTH: TB_Y,
  52. BTN_TL: TB_LB, BTN_TR: TB_RB,
  53. BTN_SELECT: TB_VIEW, BTN_START: TB_MENU, BTN_MODE: TB_STEAM,
  54. BTN_THUMBL: TB_L3, BTN_THUMBR: TB_R3,
  55. }
  56. # struct input_event { __kernel_long tv_sec, tv_usec; __u16 type, code; __s32 value; } = 24 bytes (64-bit)
  57. _EVENT = struct.Struct("qqHHi")
  58. EVENT_SZ = _EVENT.size # 24
  59. # ---- ioctl number assembly (asm-generic _IOC) ----
  60. _IOC_NONE, _IOC_WRITE, _IOC_READ = 0, 1, 2
  61. def _IOC(d, t, nr, size):
  62. return (d << 30) | (size << 16) | (ord(t) << 8) | nr
  63. EVIOCGID = _IOC(_IOC_READ, 'E', 0x02, 8) # struct input_id (4x u16)
  64. def EVIOCGNAME(length):
  65. return _IOC(_IOC_READ, 'E', 0x06, length)
  66. def EVIOCGBIT(ev, length):
  67. return _IOC(_IOC_READ, 'E', 0x20 + ev, length)
  68. def EVIOCGABS(abscode):
  69. return _IOC(_IOC_READ, 'E', 0x40 + abscode, 24) # struct input_absinfo (6x s32)
  70. EVIOCGRAB = _IOC(_IOC_WRITE, 'E', 0x90, 4)
  71. def _test_bit(bitmask, bit):
  72. return bool(bitmask[bit // 8] & (1 << (bit % 8)))
  73. class _RawDev:
  74. """Minimal pure-stdlib evdev device."""
  75. def __init__(self, path):
  76. self.path = path
  77. self.fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
  78. def close(self):
  79. try:
  80. os.close(self.fd)
  81. except Exception:
  82. pass
  83. def ids(self):
  84. buf = bytearray(8)
  85. try:
  86. fcntl.ioctl(self.fd, EVIOCGID, buf, True)
  87. _bus, vid, pid, _ver = struct.unpack("<HHHH", buf)
  88. return vid, pid
  89. except Exception:
  90. return (0, 0)
  91. def name(self):
  92. buf = bytearray(256)
  93. try:
  94. fcntl.ioctl(self.fd, EVIOCGNAME(256), buf, True)
  95. return buf.split(b"\x00")[0].decode("latin1", "replace")
  96. except Exception:
  97. return ""
  98. def has_key(self, code):
  99. buf = bytearray(96) # (KEY_MAX/8)+1
  100. try:
  101. fcntl.ioctl(self.fd, EVIOCGBIT(EV_KEY, len(buf)), buf, True)
  102. return _test_bit(buf, code)
  103. except Exception:
  104. return False
  105. def abs_range(self, code):
  106. buf = bytearray(24)
  107. try:
  108. fcntl.ioctl(self.fd, EVIOCGABS(code), buf, True)
  109. _val, mn, mx, _fz, _flat, _res = struct.unpack("<6i", buf)
  110. return (mn, mx)
  111. except Exception:
  112. return (-32768, 32767)
  113. def grab(self):
  114. fcntl.ioctl(self.fd, EVIOCGRAB, 1)
  115. def ungrab(self):
  116. try:
  117. fcntl.ioctl(self.fd, EVIOCGRAB, 0)
  118. except Exception:
  119. pass
  120. def read_events(self):
  121. """Yield (type, code, value); non-blocking."""
  122. try:
  123. data = os.read(self.fd, EVENT_SZ * 64)
  124. except (BlockingIOError, OSError):
  125. return
  126. for i in range(0, len(data) - EVENT_SZ + 1, EVENT_SZ):
  127. _s, _u, typ, code, val = _EVENT.unpack_from(data, i)
  128. yield typ, code, val
  129. # Deck controller USB PIDs: 0x1205 = the PHYSICAL hid-steam controller (grabbing this DETACHES the pad,
  130. # once Steam Input is off for the foreground app); 0x11FF = the VIRTUAL pad Steam Input synthesizes
  131. # (only present while Steam Input is ON; grabbing it does NOT detach -- the physical pad keeps feeding
  132. # Steam). So we prefer the physical node. Override with EvdevSource(path=...) / openctrl --input.
  133. PHYS_PID = 0x1205
  134. VIRT_PID = 0x11FF
  135. def find_deck_controller():
  136. """Best gamepad event node to grab. Prefer the physical Deck controller (28de:1205); fall back to
  137. the virtual Steam pad (28de:11ff), then any other gamepad. Returns a path or None."""
  138. cands = [] # (priority, path) -- lower is better
  139. for path in sorted(glob.glob("/dev/input/event*")):
  140. try:
  141. d = _RawDev(path)
  142. except Exception:
  143. continue
  144. try:
  145. if not d.has_key(BTN_SOUTH):
  146. continue
  147. vid, pid = d.ids()
  148. nm = d.name().lower()
  149. valve = vid == 0x28DE or "steam" in nm or "deck" in nm
  150. if valve and pid == PHYS_PID:
  151. prio = 0
  152. elif valve and pid == VIRT_PID:
  153. prio = 2
  154. elif valve:
  155. prio = 1
  156. else:
  157. prio = 3 # generic gamepad, last resort
  158. cands.append((prio, path))
  159. finally:
  160. d.close()
  161. cands.sort()
  162. return cands[0][1] if cands else None
  163. def _scale_stick(v, lo, hi):
  164. if hi <= lo:
  165. return 0
  166. mid = (lo + hi) / 2.0
  167. half = (hi - lo) / 2.0
  168. return int(max(-32768, min(32767, (v - mid) / half * 32767)))
  169. def _scale_trig(v, lo, hi):
  170. if hi <= lo:
  171. return 0
  172. return int(max(0, min(255, (v - lo) / (hi - lo) * 255)))
  173. class EvdevSource:
  174. """Buttons / sticks / triggers via raw evdev, with grab() / ungrab() for detach."""
  175. def __init__(self, path=None):
  176. self.path = path or find_deck_controller()
  177. if not self.path:
  178. raise RuntimeError("no Deck gamepad event device found under /dev/input")
  179. self.dev = _RawDev(self.path)
  180. self._name = self.dev.name() or self.path
  181. self._range = {c: self.dev.abs_range(c)
  182. for c in (ABS_X, ABS_Y, ABS_RX, ABS_RY, ABS_Z, ABS_RZ)}
  183. self.state = dict(NEUTRAL)
  184. self._grabbed = False
  185. @property
  186. def name(self):
  187. return self._name
  188. def grab(self):
  189. if not self._grabbed:
  190. self.dev.grab()
  191. self._grabbed = True
  192. def ungrab(self):
  193. if self._grabbed:
  194. self.dev.ungrab()
  195. self._grabbed = False
  196. def pump(self):
  197. for typ, code, val in self.dev.read_events():
  198. self._apply(typ, code, val)
  199. return self.state
  200. def _apply(self, typ, code, val):
  201. s = self.state
  202. if typ == EV_KEY:
  203. mask = KEYMAP.get(code)
  204. if mask is not None:
  205. if val:
  206. s["buttons"] |= mask
  207. else:
  208. s["buttons"] &= ~mask
  209. elif typ == EV_ABS:
  210. if code == ABS_X:
  211. s["lx"] = _scale_stick(val, *self._range[ABS_X])
  212. elif code == ABS_Y:
  213. s["ly"] = -_scale_stick(val, *self._range[ABS_Y]) # evdev Y is down-positive
  214. elif code == ABS_RX:
  215. s["rx"] = _scale_stick(val, *self._range[ABS_RX])
  216. elif code == ABS_RY:
  217. s["ry"] = -_scale_stick(val, *self._range[ABS_RY])
  218. elif code == ABS_Z:
  219. s["lt"] = _scale_trig(val, *self._range[ABS_Z])
  220. elif code == ABS_RZ:
  221. s["rt"] = _scale_trig(val, *self._range[ABS_RZ])
  222. elif code == ABS_HAT0X:
  223. s["buttons"] &= ~(TB_DLF | TB_DRT)
  224. if val < 0:
  225. s["buttons"] |= TB_DLF
  226. elif val > 0:
  227. s["buttons"] |= TB_DRT
  228. elif code == ABS_HAT0Y:
  229. s["buttons"] &= ~(TB_DUP | TB_DDN)
  230. if val < 0:
  231. s["buttons"] |= TB_DUP
  232. elif val > 0:
  233. s["buttons"] |= TB_DDN
  234. class HidrawImu:
  235. """Optional gyro + trackpad reader from the Deck's hid-steam hidraw node (PID 0x1205).
  236. DISABLED by default. The Steam Deck input report layout must be confirmed on hardware -- the offsets
  237. below are a documented starting point, not verified bytes. Until then forwarding works with evdev
  238. buttons/sticks/triggers and zero IMU.
  239. """
  240. PID = 0x1205
  241. def __init__(self):
  242. self.path = self._find()
  243. self.fd = open(self.path, "rb", buffering=0) if self.path else None
  244. def _find(self):
  245. for p in glob.glob("/dev/hidraw*"):
  246. try:
  247. ue = open("/sys/class/hidraw/%s/device/uevent" % p.split("/")[-1]).read().upper()
  248. except Exception:
  249. continue
  250. if "28DE" in ue and "1205" in ue:
  251. return p
  252. return None
  253. def read_into(self, state):
  254. if not self.fd:
  255. return False
  256. try:
  257. rpt = self.fd.read(64)
  258. except Exception:
  259. return False
  260. if not rpt or len(rpt) < 64:
  261. return False
  262. # Placeholder offsets per the SDL hid_steam Deck report; VERIFY on hardware.
  263. try:
  264. ax, ay, az, gx, gy, gz = struct.unpack_from("<6h", rpt, 28)
  265. state["ax"], state["ay"], state["az"] = ax, ay, az
  266. state["gx"], state["gy"], state["gz"] = gx, gy, gz
  267. lpx, lpy, rpx, rpy = struct.unpack_from("<4h", rpt, 16)
  268. state["lpx"], state["lpy"], state["rpx"], state["rpy"] = lpx, lpy, rpx, rpy
  269. return True
  270. except Exception:
  271. return False
  272. def _list_inputs():
  273. """Diagnostic: print every /dev/input gamepad candidate and which one we'd grab.
  274. python3 input_source.py
  275. """
  276. pick = find_deck_controller()
  277. for path in sorted(glob.glob("/dev/input/event*")):
  278. try:
  279. d = _RawDev(path)
  280. except Exception as ex:
  281. print("%-22s (open failed: %s)" % (path, ex))
  282. continue
  283. try:
  284. vid, pid = d.ids()
  285. print("%-22s vid=%04x pid=%04x gamepad=%s %s%s"
  286. % (path, vid, pid, d.has_key(BTN_SOUTH), d.name(),
  287. " <== WILL GRAB" if path == pick else ""))
  288. finally:
  289. d.close()
  290. print("\nGrab target: %s" % (pick or
  291. "NONE found — is Steam holding the controller? try Desktop Mode"))
  292. if __name__ == "__main__":
  293. _list_inputs()