scpair.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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) for a /dev/hidrawN node, or (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)
  50. for line in ue.splitlines():
  51. if line.startswith("HID_ID="):
  52. parts = line.split(":")
  53. try:
  54. return (int(parts[1], 16) & 0xFFFF, int(parts[2], 16) & 0xFFFF)
  55. except Exception:
  56. return (None, None)
  57. return (None, None)
  58. def _first_usage_page(fd):
  59. """Parse the report descriptor's first Usage Page item. 0x01=generic desktop (control), 0xFF00=vendor."""
  60. try:
  61. size = array.array('i', [0])
  62. fcntl.ioctl(fd, HIDIOCGRDESCSIZE, size, True)
  63. buf = bytearray(4 + 4096)
  64. struct.pack_into("<I", buf, 0, size[0])
  65. fcntl.ioctl(fd, HIDIOCGRDESC, buf, True)
  66. n = struct.unpack_from("<I", buf, 0)[0]
  67. d = buf[4:4 + n]
  68. i = 0
  69. while i < len(d):
  70. b = d[i]
  71. if b == 0x05: # Usage Page, 1-byte
  72. return d[i + 1]
  73. if b == 0x06: # Usage Page, 2-byte
  74. return d[i + 1] | (d[i + 2] << 8)
  75. # skip item by its size (low 2 bits)
  76. i += 1 + (b & 0x03)
  77. except Exception:
  78. pass
  79. return None
  80. class HidDev:
  81. def __init__(self, node, vid, pid):
  82. self.node = node
  83. self.vid = vid
  84. self.pid = pid
  85. self.fd = os.open(node, os.O_RDWR)
  86. self.usage_page = _first_usage_page(self.fd)
  87. def close(self):
  88. try:
  89. os.close(self.fd)
  90. except Exception:
  91. pass
  92. def set_feature(self, rid, cmd, payload=b""):
  93. buf = bytearray(FEAT_LEN)
  94. buf[0] = rid
  95. buf[1] = cmd
  96. buf[2] = len(payload)
  97. buf[3:3 + len(payload)] = bytes(payload)
  98. fcntl.ioctl(self.fd, _IOC_SF(FEAT_LEN), buf, True)
  99. def get_feature(self, rid):
  100. buf = bytearray(FEAT_LEN)
  101. buf[0] = rid
  102. fcntl.ioctl(self.fd, _IOC_GF(FEAT_LEN), buf, True)
  103. return bytes(buf)
  104. def read_serial(self, rid):
  105. """0xAE idx 1 -> unit serial string."""
  106. try:
  107. self.set_feature(rid, 0xAE, bytes([0x01]))
  108. r = self.get_feature(rid)
  109. # reply [rid][AE][len][idx][serial...]
  110. return r[4:4 + 16].split(b"\x00")[0].decode("latin1", "replace")
  111. except Exception:
  112. return ""
  113. def enumerate_devices():
  114. """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
  115. out = {"puck": [], "ctrl": []}
  116. for node in sorted(glob.glob("/dev/hidraw*")):
  117. vid, pid = _sysfs_ids(node)
  118. if vid != VALVE_VID:
  119. continue
  120. try:
  121. dev = HidDev(node, vid, pid)
  122. except PermissionError:
  123. print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
  124. continue
  125. except Exception:
  126. continue
  127. # control interface only (usagePage 0x01); skip the vendor 0xFF00 diagnostic interface
  128. if dev.usage_page not in (0x01, None):
  129. dev.close()
  130. continue
  131. if pid == PUCK_PID:
  132. out["puck"].append(dev)
  133. elif pid in CTRL_PIDS:
  134. out["ctrl"].append(dev)
  135. else:
  136. dev.close()
  137. return out
  138. def _ser16(serial):
  139. b = serial.encode("latin1", "replace")[:16]
  140. return b + b"\x00" * (16 - len(b))
  141. def read_slots(puck):
  142. """Read the puck's 4 bond slots (each puck control interface == one slot). Returns list of dicts."""
  143. slots = []
  144. for i, dev in enumerate(sorted(puck, key=lambda d: d.node)):
  145. try:
  146. dev.set_feature(2, 0xA3)
  147. r = dev.get_feature(2) # [02 A3 18 <8 uuid><16 serial>]
  148. rec = r[3:3 + 24]
  149. used = any(rec[0:8])
  150. serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else ""
  151. slots.append({"idx": i, "used": used, "serial": serial, "rec": rec})
  152. except Exception as ex:
  153. slots.append({"idx": i, "used": False, "serial": "", "rec": b"", "err": str(ex)})
  154. return slots
  155. def pair(slot=None, pkey=None):
  156. devs = enumerate_devices()
  157. pucks = sorted(devs["puck"], key=lambda d: d.node)
  158. ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
  159. if not pucks:
  160. raise SystemExit("no puck (28DE:1304) control interface found")
  161. if not ctrls:
  162. raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
  163. ctrl = ctrls[0]
  164. puck_serial = pucks[0].read_serial(2) or "FXB000000000"
  165. ctrl_serial = ctrl.read_serial(1) or "FXA000000000"
  166. if slot is None:
  167. slots = read_slots(pucks)
  168. slot = next((s["idx"] for s in slots if not s["used"]), 0)
  169. if slot >= len(pucks):
  170. raise SystemExit("slot %d has no puck control interface" % slot)
  171. if pkey is None:
  172. r = os.urandom(8)
  173. else:
  174. r = pkey
  175. key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
  176. print("pairing puck %s slot %d <-> controller %s key=%s" %
  177. (puck_serial, slot, ctrl_serial, r.hex()))
  178. # puck slot: 0xA2 [r1 r2][ctrl serial]
  179. pucks[slot].set_feature(2, 0xA2, r + _ser16(ctrl_serial))
  180. # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0]
  181. ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial))
  182. ctrl.set_feature(1, 0xEF, key)
  183. time.sleep(0.1)
  184. # reboot controller into wireless mode (magic 0xA427AF52)
  185. ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
  186. print("paired. controller rebooting into wireless — move the puck to your host.")
  187. def write_puck_slot(slot=None, ctrl_serial=None, pkey=None):
  188. devs = enumerate_devices()
  189. pucks = sorted(devs["puck"], key=lambda d: d.node)
  190. if not pucks:
  191. raise SystemExit("no puck (28DE:1304) control interface found")
  192. if slot is None:
  193. slots = read_slots(pucks)
  194. slot = next((s["idx"] for s in slots if not s["used"]), 0)
  195. if slot >= len(pucks):
  196. raise SystemExit("slot %d has no puck control interface" % slot)
  197. if ctrl_serial is None and pkey is None:
  198. print("wiping puck slot %d" % (slot))
  199. pucks[slot].set_feature(2, 0xA2, b"\x00" * 24)
  200. else:
  201. if pkey is None:
  202. raise SystemExit("No key provided!")
  203. print("writing to puck slot %d: controller %s key=%s" %
  204. (slot, ctrl_serial, pkey.hex()))
  205. # puck slot: 0xA2 [r1 r2][ctrl serial]
  206. pucks[slot].set_feature(2, 0xA2, pkey + _ser16(ctrl_serial))
  207. print("done")
  208. def write_controller_slot(slot=0, puck_serial=None, pkey=None):
  209. devs = enumerate_devices()
  210. ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
  211. if not ctrls:
  212. raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
  213. if slot not in [0, 1]:
  214. raise SystemExit("invalid controller slot %d" % (slot))
  215. ctrl = ctrls[0]
  216. if pkey is None:
  217. r = os.urandom(8)
  218. else:
  219. r = pkey
  220. key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
  221. if puck_serial is None and pkey is None:
  222. print("Wiping controller slot %d" % (slot))
  223. try:
  224. ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
  225. ctrl.set_feature(1, 0xEF, key)
  226. print("Slot wiped.")
  227. except Exception:
  228. print("Slot wipe failed.")
  229. else:
  230. print("Writing controller slot %d: puck serial %s key=%s" %
  231. (slot, puck_serial, r.hex()))
  232. # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0]
  233. ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial))
  234. ctrl.set_feature(1, 0xEF, key)
  235. time.sleep(0.1)
  236. # reboot controller into wireless mode (magic 0xA427AF52)
  237. ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
  238. print("paired. controller rebooting into wireless — move the puck to your host.")
  239. def unpair(slot):
  240. devs = enumerate_devices()
  241. pucks = sorted(devs["puck"], key=lambda d: d.node)
  242. if slot >= len(pucks):
  243. raise SystemExit("slot %d has no puck control interface" % slot)
  244. pucks[slot].set_feature(2, 0xA2, b"\x00" * 24) # clear puck slot
  245. # also clear the controller bond if present
  246. for ctrl in devs["ctrl"]:
  247. key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
  248. try:
  249. ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
  250. ctrl.set_feature(1, 0xEF, key)
  251. except Exception:
  252. print("Slot wipe on controller failed.")
  253. print("cleared slot %d" % slot)
  254. def cmd_list():
  255. devs = enumerate_devices()
  256. pucks = sorted(devs["puck"], key=lambda d: d.node)
  257. ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
  258. print("controllers:", [(d.node, "%04X" % d.pid, d.read_serial(1)) for d in ctrls] or "(none)")
  259. if pucks:
  260. print("puck:", pucks[0].read_serial(2))
  261. for s in read_slots(pucks):
  262. if s["used"]:
  263. print(" slot %d: %s (key %s)" % (s["idx"], s["serial"], s["rec"][:8].hex()))
  264. else:
  265. print(" slot %d: Empty" % (s["idx"]))
  266. else:
  267. print("puck: (none)")
  268. def steam_key_check(key):
  269. try:
  270. key = bytes.fromhex(key)
  271. if len(key) == 8:
  272. return key
  273. except:
  274. raise argparse.ArgumentTypeError("Invalid pairing key!")
  275. def main():
  276. ap = argparse.ArgumentParser(description="Linux hidraw steamless pairing")
  277. sub = ap.add_subparsers(dest="cmd", required=True)
  278. sub.add_parser("list")
  279. p = sub.add_parser("pair")
  280. p.add_argument("--slot", type=int, default=None)
  281. p.add_argument("--key", type=steam_key_check, default=None)
  282. u = sub.add_parser("unpair")
  283. u.add_argument("--slot", type=int, required=True)
  284. s = sub.add_parser("write-puck")
  285. s.add_argument("--slot", type=int, default=None)
  286. s.add_argument("--serial", type=str, default=None)
  287. s.add_argument("--key", type=steam_key_check, default=None)
  288. c = sub.add_parser("write-controller")
  289. c.add_argument("--slot", type=int, default=None)
  290. c.add_argument("--serial", type=str, default=None)
  291. c.add_argument("--key", type=steam_key_check, default=None)
  292. args = ap.parse_args()
  293. if args.cmd == "list":
  294. cmd_list()
  295. elif args.cmd == "pair":
  296. pair(args.slot, args.key)
  297. elif args.cmd == "unpair":
  298. unpair(args.slot)
  299. elif args.cmd == "write-puck":
  300. write_puck_slot(args.slot, args.serial, args.key)
  301. elif args.cmd == "write-controller":
  302. write_controller_slot(args.slot, args.serial, args.key)
  303. if __name__ == "__main__":
  304. main()