#!/usr/bin/env python3 """scpair.py -- Linux hidraw steamless pairing for the Steam Controller 2 puck + (emulated) controller. The Linux equivalent of pairtui/scmd.c (which is macOS/IOKit). Talks the Valve feature-report command channel on the usagePage-0x01 control interface over /dev/hidraw* (HIDIOCSFEATURE / HIDIOCGFEATURE). Used to pair on the Steam Deck itself when BOTH the puck (PID 1304) and the ReversePuck controller (PID 1302) are plugged into the Deck -- or run it on any Linux box. Pairing flow (protocol/USB_COMMANDS.md, == pairtui.pair_full): 1. fresh 8-byte key (r1,r2) 2. puck slot N (rid 2): 0xA2 [r1][r2][controller_serial 16] 3. ctrl (rid 1): 0xEE ["esb/bond"\\0][r1][r2][puck_serial 16] then 0xEF ["esb/bond"\\0] 4. ctrl (rid 1): 0x95 52 AF 27 A4 (reboot into wireless) sudo python3 scpair.py list sudo python3 scpair.py pair [--slot N] sudo python3 scpair.py unpair --slot N Needs read/write on /dev/hidraw* (run with sudo or install the udev rule in README.md). """ import argparse import array import fcntl import glob import os import struct import sys import time from pathlib import Path # ---- Linux ioctl numbers for hidraw (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 def _IOR(t, nr, size): return _IOC(_IOC_READ, t, nr, size) def _IOC_SF(length): return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x06, length) # HIDIOCSFEATURE def _IOC_GF(length): return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x07, length) # HIDIOCGFEATURE HIDIOCGRDESCSIZE = _IOR('H', 0x01, 4) # HIDIOCGRDESC: struct hidraw_report_descriptor { __u32 size; __u8 value[4096]; } HIDIOCGRDESC = _IOR('H', 0x02, 4 + 4096) VALVE_VID = 0x28DE PUCK_PID = 0x1304 CTRL_PIDS = (0x1302, 0x1301, 0x1303, 0x1205) FEAT_LEN = 64 # Valve feature reports are exactly 64 bytes def _sysfs_ids(node): """Return (vid, pid, serial) for a /dev/hidrawN node, or (None, None, None).""" name = os.path.basename(node) try: # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304 ue = open("/sys/class/hidraw/%s/device/uevent" % name).read() except Exception: return (None, None, None) vid = None pid = None serial = None for line in ue.splitlines(): if line.startswith("HID_ID="): parts = line.split(":") try: vid = int(parts[1], 16) & 0xFFFF pid = int(parts[2], 16) & 0xFFFF except: pass if line.startswith("HID_UNIQ="): serial = line.split('=')[1] return (vid, pid, serial) def _first_usage_page(fd): """Parse the report descriptor's first Usage Page item. 0x01=generic desktop (control), 0xFF00=vendor.""" try: size = array.array('i', [0]) fcntl.ioctl(fd, HIDIOCGRDESCSIZE, size, True) buf = bytearray(4 + 4096) struct.pack_into(" unit serial string.""" try: self.set_feature(rid, 0xAE, bytes([0x01])) r = self.get_feature(rid) # reply [rid][AE][len][idx][serial...] return r[4:4 + 16].split(b"\x00")[0].decode("latin1", "replace") except Exception: return "" def nodesort(nodeobj): """Sorts the node path properly (numerically).""" if isinstance(nodeobj, HidDev): return nodesort(nodeobj.node) if isinstance(nodeobj, str): return int(nodeobj.replace("/dev/hidraw", "")) def enumerate_devices(): """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only).""" out = {"puck": [], "ctrl": []} for node in sorted(glob.glob("/dev/hidraw*"), key=nodesort): vid, pid, serial = _sysfs_ids(node) if vid != VALVE_VID: continue # Check if this hidraw object is actually a puck slot, or if it's a OpenPuck Wake mouse or similar dxi = node.replace('/dev/', '') base = Path(f"/sys/class/hidraw/{dxi}/device") iface_dir = base.resolve().parent bInterfaceClass = int((iface_dir / "bInterfaceClass").read_text().strip(), 16) bInterfaceSubClass = int((iface_dir / "bInterfaceSubClass").read_text().strip(), 16) bInterfaceProtocol = int((iface_dir / "bInterfaceProtocol").read_text().strip(), 16) # A real puck slot has these values: if (bInterfaceClass != 3 or bInterfaceSubClass != 0 or bInterfaceProtocol != 0): # If these don't match, this is not a Puck slot, but some other random HID interface. continue try: dev = HidDev(node, vid, pid, serial) except PermissionError: print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr) continue except Exception: continue # control interface only (usagePage 0x01); skip the vendor 0xFF00 diagnostic interface if dev.usage_page not in (0x01, None): dev.close() continue if pid == PUCK_PID: out["puck"].append(dev) elif pid in CTRL_PIDS: out["ctrl"].append(dev) else: dev.close() return out def _ser16(serial): b = serial.encode("latin1", "replace")[:16] return b + b"\x00" * (16 - len(b)) def read_slots(puck_list): """Read all bond slots from all attached pucks (each puck control interface == one slot).""" pucks = [] slots = [] last_dev = None idx = 0 for dev in sorted(puck_list, key=nodesort): if last_dev is not None and dev.serial != last_dev.serial: # This is a new puck, append the old puck and all its slots to the list. pucks.append({"serial": last_dev.serial, "slots": slots}) slots = [] idx = 0 try: dev.set_feature(2, 0xA3) r = dev.get_feature(2) # [02 A3 18 <8 uuid><16 serial>] rec = r[3:3 + 24] used = any(rec[0:8]) serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else "" slots.append({"idx": idx, "dev": dev, "used": used, "serial": serial, "rec": rec}) except Exception as ex: slots.append({"idx": idx, "dev": dev, "used": False, "serial": "", "rec": b"", "err": str(ex)}) last_dev = dev idx = idx + 1 pucks.append({"serial": dev.serial, "slots": slots}) return pucks def get_puck_slot_for_writing(puck_serial=None, slot=None): devs = enumerate_devices() pucks = sorted(devs["puck"], key=nodesort) if not pucks: raise SystemExit("no puck (28DE:1304) control interface found") puck_objects = read_slots(pucks) if puck_serial is None and len(puck_objects) > 1: raise SystemExit("Multiple Pucks connected, you need to specify its serial number") puck_object = None if puck_serial is not None: for puck in puck_objects: if puck['serial'] == puck_serial: puck_object = puck break if puck_object is None: raise SystemExit("Can't find a puck with that serial number!") else: puck_object = puck_objects[0] slots = puck_object['slots'] if slot is None: slot_object = next((s for s in slots if not s["used"]), None) else: slot_object = next((s for s in slots if s["idx"] == slot), None) if slot_object is None: raise SystemExit("This Puck has all slots occupied!") return (puck_object['serial'], slot_object) def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None): devs = enumerate_devices() pucks = sorted(devs["puck"], key=nodesort) ctrls = sorted(devs["ctrl"], key=nodesort) if not pucks: raise SystemExit("no puck (28DE:1304) control interface found") if not ctrls: raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in") ctrl = ctrls[0] if controller_name is None: ctrl_serial_str = ctrl.read_serial(1) or "FXA000000000" else: ctrl_serial_str = controller_name[:15] puck_serial, slot_obj = get_puck_slot_for_writing(puck_serial, puck_slot) if puck_name is None: puck_serial_str = puck_serial else: puck_serial_str = puck_name[:15] if pkey is None: r = os.urandom(8) else: r = pkey key = b"esb/bond\x00" if controller_slot == 0 else b"esb/bond_2\x00" print("Pairing key: %s" % (r.hex())) # Puck name gets written to controller (defaults to puck serial). # Controller name gets written to Puck print("Puck: %s slot %d controller %s" % (puck_serial, slot_obj['idx'], ctrl_serial_str)) print("Controller: %s slot %d puck %s" % (ctrl.read_serial(1), controller_slot, puck_serial_str)) # puck slot: 0xA2 [r1 r2][ctrl serial] slot_obj['dev'].set_feature(2, 0xA2, r + _ser16(ctrl_serial_str)) # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0] ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial_str)) ctrl.set_feature(1, 0xEF, key) time.sleep(0.1) # reboot controller into wireless mode (magic 0xA427AF52) ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4])) print("paired. controller rebooting into wireless — move the puck to your host.") def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None): puck_serial, slot_obj = get_puck_slot_for_writing(puck_serial, puck_slot) if ctrl_name is None and pkey is None: print("wiping puck %s slot %d" % (puck_serial, slot_obj['idx'])) slot_obj['dev'].set_feature(2, 0xA2, b"\x00" * 24) else: if ctrl_name is None or len(ctrl_name) > 15: raise SystemExit("Invalid controller name.") if pkey is None: raise SystemExit("No key provided!") print("writing to puck %s slot %d: controller %s key=%s" % (puck_serial, slot_obj['idx'], ctrl_name, pkey.hex())) # puck slot: 0xA2 [r1 r2][ctrl serial] print(slot_obj['dev'].node) slot_obj['dev'].set_feature(2, 0xA2, pkey + _ser16(ctrl_name)) print("done") def write_controller_slot(slot=0, puck_serial=None, pkey=None): devs = enumerate_devices() ctrls = sorted(devs["ctrl"], key=nodesort) if not ctrls: raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in") if slot not in [0, 1]: raise SystemExit("invalid controller slot %d" % (slot)) ctrl = ctrls[0] if pkey is None: r = os.urandom(8) else: r = pkey key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00" if puck_serial is None and pkey is None: print("Wiping controller slot %d" % (slot)) try: ctrl.set_feature(1, 0xEE, key + b"\x00" * 24) ctrl.set_feature(1, 0xEF, key) print("Slot wiped.") except Exception: print("Slot wipe failed.") else: print("Writing controller slot %d: puck serial %s key=%s" % (slot, puck_serial, r.hex())) # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0] ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial)) ctrl.set_feature(1, 0xEF, key) time.sleep(0.1) # reboot controller into wireless mode (magic 0xA427AF52) ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4])) print("paired. controller rebooting into wireless — move the puck to your host.") def cmd_list(): devs = enumerate_devices() pucks = sorted(devs["puck"], key=nodesort) ctrls = sorted(devs["ctrl"], key=nodesort) print("controllers:", [(d.node, "%04X" % d.pid, d.read_serial(1)) for d in ctrls] or "(none)") if pucks: ps = read_slots(pucks) for puck in ps: slots = puck['slots'] print(f"Puck: {puck['serial']}") for s in slots: if s["used"]: print(" slot %d: %s (key %s)" % (s["idx"], s["serial"], s["rec"][:8].hex())) else: print(" slot %d: Empty" % (s["idx"])) else: print("puck: (none)") def steam_key_check(key): try: key = bytes.fromhex(key) if len(key) == 8: return key except: raise argparse.ArgumentTypeError("Invalid pairing key!") def main(): ap = argparse.ArgumentParser(description="Linux hidraw steamless pairing") sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("list") p = sub.add_parser("pair") #def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None): p.add_argument("--puck-serial", type=str, default=None) p.add_argument("--puck-slot", type=int, default=None) p.add_argument("--puck-name", type=str, default=None) p.add_argument("--controller-slot", type=int, default=0) p.add_argument("--controller-name", type=str, default=None) p.add_argument("--key", type=steam_key_check, default=None) s = sub.add_parser("write-puck") #def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None): s.add_argument("--puck-serial", type=str, default=None) s.add_argument("--puck-slot", type=int, default=None) s.add_argument("--controller-name", type=str, default=None) s.add_argument("--key", type=steam_key_check, default=None) c = sub.add_parser("write-controller") #def write_controller_slot(slot=0, puck_serial=None, pkey=None): c.add_argument("--controller-slot", type=int, default=None) c.add_argument("--puck-name", type=str, default=None) c.add_argument("--key", type=steam_key_check, default=None) args = ap.parse_args() if args.cmd == "list": cmd_list() elif args.cmd == "pair": pair(args.puck_serial, args.puck_slot, args.controller_slot, args.puck_name, args.controller_name, args.key) elif args.cmd == "write-puck": write_puck_slot(args.puck_serial, args.puck_slot, args.controller_name, args.key) elif args.cmd == "write-controller": write_controller_slot(args.controller_slot, args.puck_name, args.key) if __name__ == "__main__": main()