scpair.py 15 KB

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