scpair.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. #!/usr/bin/env python3
  2. """scpair.py -- Linux hidraw steamless pairing for the Steam Controller 2 puck + (emulated) controller.
  3. The Linux equivalent of pairtui/scmd.c (which is macOS/IOKit). Talks the Valve feature-report command
  4. channel on the usagePage-0x01 control interface over /dev/hidraw* (HIDIOCSFEATURE / HIDIOCGFEATURE).
  5. Used to pair on the Steam Deck itself when BOTH the puck (PID 1304) and the ReversePuck controller
  6. (PID 1302) are plugged into the Deck -- or run it on any Linux box.
  7. Pairing flow (protocol/USB_COMMANDS.md, == pairtui.pair_full):
  8. 1. fresh 8-byte key (r1,r2)
  9. 2. puck slot N (rid 2): 0xA2 [r1][r2][controller_serial 16]
  10. 3. ctrl (rid 1): 0xEE ["esb/bond"\\0][r1][r2][puck_serial 16] then 0xEF ["esb/bond"\\0]
  11. 4. ctrl (rid 1): 0x95 52 AF 27 A4 (reboot into wireless)
  12. sudo python3 scpair.py list
  13. sudo python3 scpair.py pair [--slot N]
  14. sudo python3 scpair.py unpair --slot N
  15. Needs read/write on /dev/hidraw* (run with sudo or install the udev rule in README.md).
  16. """
  17. import argparse
  18. import array
  19. import fcntl
  20. import glob
  21. import os
  22. import struct
  23. import sys
  24. import time
  25. from pathlib import Path
  26. # ---- Linux ioctl numbers for hidraw (asm-generic _IOC) ----
  27. _IOC_NONE, _IOC_WRITE, _IOC_READ = 0, 1, 2
  28. def _IOC(d, t, nr, size):
  29. return (d << 30) | (size << 16) | (ord(t) << 8) | nr
  30. def _IOR(t, nr, size):
  31. return _IOC(_IOC_READ, t, nr, size)
  32. def _IOC_SF(length):
  33. return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x06, length) # HIDIOCSFEATURE
  34. def _IOC_GF(length):
  35. return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x07, length) # HIDIOCGFEATURE
  36. HIDIOCGRDESCSIZE = _IOR('H', 0x01, 4)
  37. # HIDIOCGRDESC: struct hidraw_report_descriptor { __u32 size; __u8 value[4096]; }
  38. HIDIOCGRDESC = _IOR('H', 0x02, 4 + 4096)
  39. VALVE_VID = 0x28DE
  40. PUCK_PID = 0x1304
  41. CTRL_PIDS = (0x1302, 0x1301, 0x1303, 0x1205)
  42. FEAT_LEN = 64 # Valve feature reports are exactly 64 bytes
  43. def _sysfs_ids(node):
  44. """Return (vid, pid, serial) for a /dev/hidrawN node, or (None, None, None)."""
  45. name = os.path.basename(node)
  46. try:
  47. # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304
  48. ue = open("/sys/class/hidraw/%s/device/uevent" % name).read()
  49. except Exception:
  50. return (None, None, None)
  51. vid = None
  52. pid = None
  53. serial = None
  54. for line in ue.splitlines():
  55. if line.startswith("HID_ID="):
  56. parts = line.split(":")
  57. try:
  58. vid = int(parts[1], 16) & 0xFFFF
  59. pid = int(parts[2], 16) & 0xFFFF
  60. except:
  61. pass
  62. if line.startswith("HID_UNIQ="):
  63. serial = line.split('=')[1]
  64. return (vid, pid, serial)
  65. def _first_usage_page(fd):
  66. """Parse the report descriptor's first Usage Page item. 0x01=generic desktop (control), 0xFF00=vendor."""
  67. try:
  68. size = array.array('i', [0])
  69. fcntl.ioctl(fd, HIDIOCGRDESCSIZE, size, True)
  70. buf = bytearray(4 + 4096)
  71. struct.pack_into("<I", buf, 0, size[0])
  72. fcntl.ioctl(fd, HIDIOCGRDESC, buf, True)
  73. n = struct.unpack_from("<I", buf, 0)[0]
  74. d = buf[4:4 + n]
  75. i = 0
  76. while i < len(d):
  77. b = d[i]
  78. if b == 0x05: # Usage Page, 1-byte
  79. return d[i + 1]
  80. if b == 0x06: # Usage Page, 2-byte
  81. return d[i + 1] | (d[i + 2] << 8)
  82. # skip item by its size (low 2 bits)
  83. i += 1 + (b & 0x03)
  84. except Exception:
  85. pass
  86. return None
  87. class HidDev:
  88. def __init__(self, node, vid, pid, serial):
  89. self.node = node
  90. self.vid = vid
  91. self.pid = pid
  92. self.serial = serial
  93. self.fd = os.open(node, os.O_RDWR)
  94. self.usage_page = _first_usage_page(self.fd)
  95. def close(self):
  96. try:
  97. os.close(self.fd)
  98. except Exception:
  99. pass
  100. def set_feature(self, rid, cmd, payload=b""):
  101. buf = bytearray(FEAT_LEN)
  102. buf[0] = rid
  103. buf[1] = cmd
  104. buf[2] = len(payload)
  105. buf[3:3 + len(payload)] = bytes(payload)
  106. fcntl.ioctl(self.fd, _IOC_SF(FEAT_LEN), buf, True)
  107. def get_feature(self, rid):
  108. buf = bytearray(FEAT_LEN)
  109. buf[0] = rid
  110. fcntl.ioctl(self.fd, _IOC_GF(FEAT_LEN), buf, True)
  111. return bytes(buf)
  112. def read_serial(self, rid):
  113. """0xAE idx 1 -> unit serial string."""
  114. try:
  115. self.set_feature(rid, 0xAE, bytes([0x01]))
  116. r = self.get_feature(rid)
  117. # reply [rid][AE][len][idx][serial...]
  118. return r[4:4 + 16].split(b"\x00")[0].decode("latin1", "replace")
  119. except Exception:
  120. return ""
  121. def nodesort(nodeobj):
  122. """Sorts the node path properly (numerically)."""
  123. if isinstance(nodeobj, HidDev):
  124. return nodesort(nodeobj.node)
  125. if isinstance(nodeobj, str):
  126. return int(nodeobj.replace("/dev/hidraw", ""))
  127. def enumerate_devices():
  128. """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
  129. out = {"puck": [], "ctrl": []}
  130. for node in sorted(glob.glob("/dev/hidraw*"), key=nodesort):
  131. vid, pid, serial = _sysfs_ids(node)
  132. if vid != VALVE_VID:
  133. continue
  134. # Check if this hidraw object is actually a puck slot, or if it's a OpenPuck Wake mouse or similar
  135. dxi = node.replace('/dev/', '')
  136. base = Path(f"/sys/class/hidraw/{dxi}/device")
  137. iface_dir = base.resolve().parent
  138. bInterfaceClass = int((iface_dir / "bInterfaceClass").read_text().strip(), 16)
  139. bInterfaceSubClass = int((iface_dir / "bInterfaceSubClass").read_text().strip(), 16)
  140. bInterfaceProtocol = int((iface_dir / "bInterfaceProtocol").read_text().strip(), 16)
  141. # A real puck slot has these values:
  142. if (bInterfaceClass != 3 or bInterfaceSubClass != 0 or bInterfaceProtocol != 0):
  143. # If these don't match, this is not a Puck slot, but some other random HID interface.
  144. continue
  145. try:
  146. dev = HidDev(node, vid, pid, serial)
  147. except PermissionError:
  148. print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
  149. continue
  150. except Exception:
  151. continue
  152. # control interface only (usagePage 0x01); skip the vendor 0xFF00 diagnostic interface
  153. if dev.usage_page not in (0x01, None):
  154. dev.close()
  155. continue
  156. if pid == PUCK_PID:
  157. out["puck"].append(dev)
  158. elif pid in CTRL_PIDS:
  159. out["ctrl"].append(dev)
  160. else:
  161. dev.close()
  162. return out
  163. def _ser16(serial):
  164. b = serial.encode("latin1", "replace")[:16]
  165. return b + b"\x00" * (16 - len(b))
  166. def read_slots(puck_list):
  167. """Read all bond slots from all attached pucks (each puck control interface == one slot)."""
  168. pucks = []
  169. slots = []
  170. last_dev = None
  171. idx = 0
  172. for dev in sorted(puck_list, key=nodesort):
  173. if last_dev is not None and dev.serial != last_dev.serial:
  174. # This is a new puck, append the old puck and all its slots to the list.
  175. pucks.append({"serial": last_dev.serial, "slots": slots})
  176. slots = []
  177. idx = 0
  178. try:
  179. dev.set_feature(2, 0xA3)
  180. r = dev.get_feature(2) # [02 A3 18 <8 uuid><16 serial>]
  181. rec = r[3:3 + 24]
  182. used = any(rec[0:8])
  183. serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else ""
  184. slots.append({"idx": idx, "dev": dev, "used": used, "serial": serial, "rec": rec})
  185. except Exception as ex:
  186. slots.append({"idx": idx, "dev": dev, "used": False, "serial": "", "rec": b"", "err": str(ex)})
  187. last_dev = dev
  188. idx = idx + 1
  189. pucks.append({"serial": dev.serial, "slots": slots})
  190. return pucks
  191. def get_puck_slot_for_writing(puck_serial=None, slot=None):
  192. devs = enumerate_devices()
  193. pucks = sorted(devs["puck"], key=nodesort)
  194. if not pucks:
  195. raise SystemExit("no puck (28DE:1304) control interface found")
  196. puck_objects = read_slots(pucks)
  197. if puck_serial is None and len(puck_objects) > 1:
  198. raise SystemExit("Multiple Pucks connected, you need to specify its serial number")
  199. puck_object = None
  200. if puck_serial is not None:
  201. for puck in puck_objects:
  202. if puck['serial'] == puck_serial:
  203. puck_object = puck
  204. break
  205. if puck_object is None:
  206. raise SystemExit("Can't find a puck with that serial number!")
  207. else:
  208. puck_object = puck_objects[0]
  209. slots = puck_object['slots']
  210. if slot is None:
  211. slot_object = next((s for s in slots if not s["used"]), None)
  212. else:
  213. slot_object = next((s for s in slots if s["idx"] == slot), None)
  214. if slot_object is None:
  215. raise SystemExit("This Puck has all slots occupied!")
  216. return (puck_object['serial'], slot_object)
  217. def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
  218. devs = enumerate_devices()
  219. pucks = sorted(devs["puck"], key=nodesort)
  220. ctrls = sorted(devs["ctrl"], key=nodesort)
  221. if not pucks:
  222. raise SystemExit("no puck (28DE:1304) control interface found")
  223. if not ctrls:
  224. raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
  225. ctrl = ctrls[0]
  226. if controller_name is None:
  227. ctrl_serial_str = ctrl.read_serial(1) or "FXA000000000"
  228. else:
  229. ctrl_serial_str = controller_name[:15]
  230. puck_serial, slot_obj = get_puck_slot_for_writing(puck_serial, puck_slot)
  231. if puck_name is None:
  232. puck_serial_str = puck_serial
  233. else:
  234. puck_serial_str = puck_name[:15]
  235. if pkey is None:
  236. r = os.urandom(8)
  237. else:
  238. r = pkey
  239. key = b"esb/bond\x00" if controller_slot == 0 else b"esb/bond_2\x00"
  240. print("Pairing key: %s" % (r.hex()))
  241. # Puck name gets written to controller (defaults to puck serial).
  242. # Controller name gets written to Puck
  243. print("Puck: %s slot %d controller %s" % (puck_serial, slot_obj['idx'], ctrl_serial_str))
  244. print("Controller: %s slot %d puck %s" % (ctrl.read_serial(1), controller_slot, puck_serial_str))
  245. # puck slot: 0xA2 [r1 r2][ctrl serial]
  246. slot_obj['dev'].set_feature(2, 0xA2, r + _ser16(ctrl_serial_str))
  247. # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0]
  248. ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial_str))
  249. ctrl.set_feature(1, 0xEF, key)
  250. time.sleep(0.1)
  251. # reboot controller into wireless mode (magic 0xA427AF52)
  252. ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
  253. print("paired. controller rebooting into wireless — move the puck to your host.")
  254. def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None):
  255. puck_serial, slot_obj = get_puck_slot_for_writing(puck_serial, puck_slot)
  256. if ctrl_name is None and pkey is None:
  257. print("wiping puck %s slot %d" % (puck_serial, slot_obj['idx']))
  258. slot_obj['dev'].set_feature(2, 0xA2, b"\x00" * 24)
  259. else:
  260. if ctrl_name is None or len(ctrl_name) > 15:
  261. raise SystemExit("Invalid controller name.")
  262. if pkey is None:
  263. raise SystemExit("No key provided!")
  264. print("writing to puck %s slot %d: controller %s key=%s" %
  265. (puck_serial, slot_obj['idx'], ctrl_name, pkey.hex()))
  266. # puck slot: 0xA2 [r1 r2][ctrl serial]
  267. print(slot_obj['dev'].node)
  268. slot_obj['dev'].set_feature(2, 0xA2, pkey + _ser16(ctrl_name))
  269. print("done")
  270. def write_controller_slot(slot=0, puck_serial=None, pkey=None):
  271. devs = enumerate_devices()
  272. ctrls = sorted(devs["ctrl"], key=nodesort)
  273. if not ctrls:
  274. raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
  275. if slot not in [0, 1]:
  276. raise SystemExit("invalid controller slot %d" % (slot))
  277. ctrl = ctrls[0]
  278. if pkey is None:
  279. r = os.urandom(8)
  280. else:
  281. r = pkey
  282. key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
  283. if puck_serial is None and pkey is None:
  284. print("Wiping controller slot %d" % (slot))
  285. try:
  286. ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
  287. ctrl.set_feature(1, 0xEF, key)
  288. print("Slot wiped.")
  289. except Exception:
  290. print("Slot wipe failed.")
  291. else:
  292. print("Writing controller slot %d: puck serial %s key=%s" %
  293. (slot, puck_serial, r.hex()))
  294. # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0]
  295. ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial))
  296. ctrl.set_feature(1, 0xEF, key)
  297. time.sleep(0.1)
  298. # reboot controller into wireless mode (magic 0xA427AF52)
  299. ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
  300. print("paired. controller rebooting into wireless — move the puck to your host.")
  301. def cmd_list():
  302. devs = enumerate_devices()
  303. pucks = sorted(devs["puck"], key=nodesort)
  304. ctrls = sorted(devs["ctrl"], key=nodesort)
  305. print("controllers:", [(d.node, "%04X" % d.pid, d.read_serial(1)) for d in ctrls] or "(none)")
  306. if pucks:
  307. ps = read_slots(pucks)
  308. for puck in ps:
  309. slots = puck['slots']
  310. print(f"Puck: {puck['serial']}")
  311. for s in slots:
  312. if s["used"]:
  313. print(" slot %d: %s (key %s)" % (s["idx"], s["serial"], s["rec"][:8].hex()))
  314. else:
  315. print(" slot %d: Empty" % (s["idx"]))
  316. else:
  317. print("puck: (none)")
  318. def steam_key_check(key):
  319. try:
  320. key = bytes.fromhex(key)
  321. if len(key) == 8:
  322. return key
  323. except:
  324. raise argparse.ArgumentTypeError("Invalid pairing key!")
  325. def main():
  326. ap = argparse.ArgumentParser(description="Linux hidraw steamless pairing")
  327. sub = ap.add_subparsers(dest="cmd", required=True)
  328. sub.add_parser("list")
  329. p = sub.add_parser("pair")
  330. #def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
  331. p.add_argument("--puck-serial", type=str, default=None)
  332. p.add_argument("--puck-slot", type=int, default=None)
  333. p.add_argument("--puck-name", type=str, default=None)
  334. p.add_argument("--controller-slot", type=int, default=0)
  335. p.add_argument("--controller-name", type=str, default=None)
  336. p.add_argument("--key", type=steam_key_check, default=None)
  337. s = sub.add_parser("write-puck")
  338. #def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None):
  339. s.add_argument("--puck-serial", type=str, default=None)
  340. s.add_argument("--puck-slot", type=int, default=None)
  341. s.add_argument("--controller-name", type=str, default=None)
  342. s.add_argument("--key", type=steam_key_check, default=None)
  343. c = sub.add_parser("write-controller")
  344. #def write_controller_slot(slot=0, puck_serial=None, pkey=None):
  345. c.add_argument("--controller-slot", type=int, default=None)
  346. c.add_argument("--puck-name", type=str, default=None)
  347. c.add_argument("--key", type=steam_key_check, default=None)
  348. args = ap.parse_args()
  349. if args.cmd == "list":
  350. cmd_list()
  351. elif args.cmd == "pair":
  352. pair(args.puck_serial, args.puck_slot, args.controller_slot, args.puck_name, args.controller_name, args.key)
  353. elif args.cmd == "write-puck":
  354. write_puck_slot(args.puck_serial, args.puck_slot, args.controller_name, args.key)
  355. elif args.cmd == "write-controller":
  356. write_controller_slot(args.controller_slot, args.puck_name, args.key)
  357. if __name__ == "__main__":
  358. main()