Browse Source

scpair: Bunch of fixes (#222)

* scpair: Steam Machine bugfixes

* Add support for wireless controllers to scpair

* Cleanup, allow pairing a wireless controller
Florian Bach 2 months ago
parent
commit
c3a247ab2a
1 changed files with 419 additions and 113 deletions
  1. 419 113
      ReversePuck/scpair.py

+ 419 - 113
ReversePuck/scpair.py

@@ -1,11 +1,17 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 """scpair.py -- Linux hidraw steamless pairing for the Steam Controller 2 puck + (emulated) controller.
 """scpair.py -- Linux hidraw steamless pairing for the Steam Controller 2 puck + (emulated) controller.
 
 
+Original code by safijari
+Steam Machine, slot cloning and wireless controller support, firmware version query by Leseratte10.
+
 The Linux equivalent of pairtui/scmd.c (which is macOS/IOKit). Talks the Valve feature-report command
 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).
 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
 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.
 (PID 1302) are plugged into the Deck -- or run it on any Linux box.
 
 
+This code is not limited to the ReversePuck, it can also be used to manage, reset, pair and clone
+normal pairings between a Puck (or OpenPuck) and a Steam Controller.
+
 Pairing flow (protocol/USB_COMMANDS.md, == pairtui.pair_full):
 Pairing flow (protocol/USB_COMMANDS.md, == pairtui.pair_full):
   1. fresh 8-byte key (r1,r2)
   1. fresh 8-byte key (r1,r2)
   2. puck   slot N  (rid 2):  0xA2 [r1][r2][controller_serial 16]
   2. puck   slot N  (rid 2):  0xA2 [r1][r2][controller_serial 16]
@@ -54,21 +60,19 @@ HIDIOCGRDESC = _IOR('H', 0x02, 4 + 4096)
 VALVE_VID = 0x28DE
 VALVE_VID = 0x28DE
 PUCK_PIDS = (0x1304, 0x1305)
 PUCK_PIDS = (0x1304, 0x1305)
 CTRL_PIDS = (0x1302, 0x1301, 0x1303, 0x1205)
 CTRL_PIDS = (0x1302, 0x1301, 0x1303, 0x1205)
-FEAT_LEN = 64  # Valve feature reports are exactly 64 bytes
 
 
 
 
 def _sysfs_ids(node):
 def _sysfs_ids(node):
-    """Return (vid, pid, serial) for a /dev/hidrawN node, or (None, None, None)."""
+    """Return (vid, pid) for a /dev/hidrawN node, or (None, None)."""
     name = os.path.basename(node)
     name = os.path.basename(node)
     try:
     try:
         # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304
         # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304
         ue = open("/sys/class/hidraw/%s/device/uevent" % name).read()
         ue = open("/sys/class/hidraw/%s/device/uevent" % name).read()
     except Exception:
     except Exception:
-        return (None, None, None)
+        return (None, None)
     
     
     vid = None
     vid = None
     pid = None 
     pid = None 
-    serial = None
 
 
     for line in ue.splitlines():
     for line in ue.splitlines():
         if line.startswith("HID_ID="):
         if line.startswith("HID_ID="):
@@ -76,12 +80,12 @@ def _sysfs_ids(node):
             try:
             try:
                 vid = int(parts[1], 16) & 0xFFFF
                 vid = int(parts[1], 16) & 0xFFFF
                 pid = int(parts[2], 16) & 0xFFFF
                 pid = int(parts[2], 16) & 0xFFFF
+                return (vid, pid)
             except:
             except:
                 pass
                 pass
-        if line.startswith("HID_UNIQ="):
-            serial = line.split('=')[1]
+
+    return (None, None)
     
     
-    return (vid, pid, serial)
 
 
 
 
 def _first_usage_page(fd):
 def _first_usage_page(fd):
@@ -109,11 +113,10 @@ def _first_usage_page(fd):
 
 
 
 
 class HidDev:
 class HidDev:
-    def __init__(self, node, vid, pid, serial, info_str = "Valve"):
+    def __init__(self, node, vid, pid, info_str = "Valve"):
         self.node = node
         self.node = node
         self.vid = vid
         self.vid = vid
         self.pid = pid
         self.pid = pid
-        self.serial = serial
         self.info_str = info_str
         self.info_str = info_str
         self.fd = os.open(node, os.O_RDWR)
         self.fd = os.open(node, os.O_RDWR)
         self.usage_page = _first_usage_page(self.fd)
         self.usage_page = _first_usage_page(self.fd)
@@ -124,33 +127,116 @@ class HidDev:
         except Exception:
         except Exception:
             pass
             pass
 
 
+    def get_bcd_version(self):
+        """
+        Relevant for Pucks. Both the standalone Puck and the receiver in the Steam Machine
+        use bcdVersion 2, but Steam also supports Pucks with other versions that sometimes 
+        behave differently.
+        """
+        dxi = self.node.replace('/dev/', '')
+        base = Path(f"/sys/class/hidraw/{dxi}/device")
+        dev_dir = base.resolve().parent.parent
+        bcdDevice = int((dev_dir / "bcdDevice").read_text().strip(), 16)
+        return bcdDevice
+
+
     def set_feature(self, rid, cmd, payload=b""):
     def set_feature(self, rid, cmd, payload=b""):
-        buf = bytearray(FEAT_LEN)
+        buf = bytearray(64)
         buf[0] = rid
         buf[0] = rid
         buf[1] = cmd
         buf[1] = cmd
         buf[2] = len(payload)
         buf[2] = len(payload)
         buf[3:3 + len(payload)] = bytes(payload)
         buf[3:3 + len(payload)] = bytes(payload)
-        fcntl.ioctl(self.fd, _IOC_SF(FEAT_LEN), buf, True)
+        fcntl.ioctl(self.fd, _IOC_SF(64), buf, True)
 
 
-    def get_feature(self, rid):
-        buf = bytearray(FEAT_LEN)
+    def get_feature(self, rid, max_len=64):
+        buf = bytearray(max_len)
         buf[0] = rid
         buf[0] = rid
-        fcntl.ioctl(self.fd, _IOC_GF(FEAT_LEN), buf, True)
+        try: 
+            fcntl.ioctl(self.fd, _IOC_GF(max_len), buf, True)
+        except: 
+            # wireless connections sometimes take two attempts ...
+            time.sleep(0.05)
+            fcntl.ioctl(self.fd, _IOC_GF(max_len), buf, True)
         return bytes(buf)
         return bytes(buf)
 
 
-    def read_serial(self, rid):
-        """0xAE idx 1 -> 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 read_attribute_values(self, rid, cmd):
+        self.set_feature(rid, cmd)
+        data = self.get_feature(rid, 256)   # Feature report with attributes can be longer than 64.
+
+        data = data[3:]
+        attribute_count = len(data) // 5
+        padding_len = len(data) - (attribute_count * 5)
+
+        fstr = '<' + 'BL' * attribute_count + 'B' * padding_len
+        unpacked = struct.unpack(fstr, data)
+
+        attribute_list = {}
+        for i in range(attribute_count):
+            tag = unpacked[2*i]
+            val = unpacked[2*i + 1]
+            attribute_list[tag] = val
+
+        return attribute_list
+
+    def read_firmware_version(self, force_esb = False):
+        # When sent to a Puck, force_esb=False will return the puck's firmware, 
+        # while force_esb=True will return the connected controller's firmware.
+
+        if self.pid in CTRL_PIDS or force_esb:
+            attrs = self.read_attribute_values(1, 0x83)
+
+        elif self.pid in PUCK_PIDS:
+            if self.get_bcd_version() in [2, 0x212, 0x213, 0x211]:       
+                # 2 = Valve Puck or Machine, 211-213 OpenPuck? Unclear why.
+                attrs = self.read_attribute_values(2, 0x83)
+            else: 
+                # I wonder which official 
+                # Valve device uses a BCD other than 2. Maybe a prototype?
+                attrs = self.read_attribute_values(1, 0xa6)
+
+        return attrs[4]
+
+    def read_serial(self, force_esb = False, index = 1):
+        # When sent to a Puck, force_esb=False will return the puck's serial, 
+        # while force_esb=True will return the connected controller's serial.
+
+        # Index:
+        # 0: MXA123123123A ??
+        # 1: FXB9912345678 (serial number on sticker)
+        # 2: NA
+        # 3: fec01234c8af git firmware commit hash
+        # 4: MXA123123123A
+
+        r = None
+
+        if self.pid in CTRL_PIDS or force_esb:
+            self.set_feature(1, 0xae, bytes([index]))
+            r = self.get_feature(1)
+              
+        elif self.pid in PUCK_PIDS:
+            if self.get_bcd_version() in [2, 0x212, 0x213, 0x211]:       
+                # 2 = Valve Puck or Machine, 211-213 OpenPuck? Unclear why.
+                self.set_feature(2, 0xae, bytes([index]))
+                r = self.get_feature(2)
+            else: 
+                # I wonder which official Valve device uses 
+                # a BCD other than 2. Maybe a prototype?
+                self.set_feature(1, 0xa4, bytes([index]))
+                r = self.get_feature(1)
+
+        if r is None: 
+            return None
+        
+        return r[4:].split(b"\x00")[0].decode("latin1", "replace")
+            
+       
 
 
 def nodesort(nodeobj):
 def nodesort(nodeobj):
     """Sorts the node path properly (numerically)."""
     """Sorts the node path properly (numerically)."""
 
 
+    # TODO: This doesn't seem to be 100% reliable, if you plug in two Pucks at the exact same time
+    # the interface order might get messed up.
+
     if isinstance(nodeobj, HidDev):
     if isinstance(nodeobj, HidDev):
         return nodesort(nodeobj.node)
         return nodesort(nodeobj.node)
 
 
@@ -159,10 +245,15 @@ def nodesort(nodeobj):
 
 
 
 
 def enumerate_devices():
 def enumerate_devices():
-    """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
+    """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]}."""
     out = {"puck": [], "ctrl": []}
     out = {"puck": [], "ctrl": []}
+
+    last_serial = None
+    current_ctrl_iface = None
+    current_esb_ifaces = []
+
     for node in sorted(glob.glob("/dev/hidraw*"), key=nodesort):
     for node in sorted(glob.glob("/dev/hidraw*"), key=nodesort):
-        vid, pid, serial = _sysfs_ids(node)
+        vid, pid = _sysfs_ids(node)
         if vid != VALVE_VID:
         if vid != VALVE_VID:
             continue
             continue
 
 
@@ -195,22 +286,54 @@ def enumerate_devices():
                     device_vendor = "OpenPuck"
                     device_vendor = "OpenPuck"
 
 
         try:
         try:
-            dev = HidDev(node, vid, pid, serial, device_vendor)
+            dev = HidDev(node, vid, pid, device_vendor)
         except PermissionError:
         except PermissionError:
             print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
             print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
             continue
             continue
         except Exception:
         except Exception:
             continue
             continue
-        # control interface only (usagePage 0x01); skip the vendor 0xFF00 diagnostic interface
-        if dev.usage_page not in (0x01, None):
-            dev.close()
+
+        if pid in CTRL_PIDS:
+            out["ctrl"].append(dev)
             continue
             continue
-        if pid in PUCK_PIDS:
-            out["puck"].append(dev)
+
+        serial = dev.read_serial()
+
+        if pid in PUCK_PIDS and last_serial is not None and last_serial != serial:
+            # OpenCode has no control interface.          
+            current_puck = {
+                "control": current_ctrl_iface,
+                "esb": current_esb_ifaces
+            }
+
+            out["puck"].append(current_puck)
+            current_esb_ifaces = []
+            current_ctrl_iface = None
+
+        last_serial = serial
+
+
+        if pid in PUCK_PIDS and dev.usage_page in (0x01, None):
+            current_esb_ifaces.append(dev)
+        elif pid in PUCK_PIDS:
+            current_ctrl_iface = dev
         elif pid in CTRL_PIDS:
         elif pid in CTRL_PIDS:
             out["ctrl"].append(dev)
             out["ctrl"].append(dev)
         else:
         else:
             dev.close()
             dev.close()
+
+
+    if len(current_esb_ifaces) > 0:
+        # Add puck interfaces from last loop
+        # OpenCode has no control interface.          
+
+        current_puck = {
+            "control": current_ctrl_iface,
+            "esb": current_esb_ifaces
+        }
+
+        out["puck"].append(current_puck)
+
     return out
     return out
 
 
 
 
@@ -222,36 +345,50 @@ def _ser16(serial):
 def read_slots(puck_list):
 def read_slots(puck_list):
     """Read all bond slots from all attached pucks (each puck control interface == one slot)."""
     """Read all bond slots from all attached pucks (each puck control interface == one slot)."""
     pucks = []
     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:
+    for puck in puck_list:
+
+        esb_devices = puck['esb']
+
+        slots = []
+        idx = 0
+
+        for esb in esb_devices:
+            try:
+
+                esb.set_feature(2, 0xA3)
+                r = esb.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 ""
+                key = rec[:8].hex()
+    
+                # Check if a controller is currently connected to this slot:
+                esb.set_feature(2, 0xB4)
+                r = esb.get_feature(2)
+                active = (r[0] == 0x02 and r[1] == 0xb4 and r[3] == 2)
+    
+                slots.append({"idx": idx, "dev": esb, "used": used, "active": active, "serial": serial, "rec": rec})
+            except Exception as ex:
+                slots.append({"idx": idx, "dev": esb, "used": False, "active": False, "serial": "", "rec": b"", "err": str(ex)})
+
+            idx = idx + 1
+            
+        if esb is not None:
             # This is a new puck, append the old puck and all its slots to the list.
             # This is a new puck, append the old puck and all its slots to the list.
-            pucks.append({"serial": last_dev.serial, "slots": slots, "info_str": last_dev.info_str})
-            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)})
+            pucks.append({"serial": esb.read_serial(), 
+                        "firmware_ts": esb.read_firmware_version(),
+                        "firmware_commit": esb.read_serial(False, 3),
+                        "slots": slots, 
+                        "info_str": esb.info_str})
         
         
-        last_dev = dev
-        idx = idx + 1
-
-    pucks.append({"serial": dev.serial, "slots": slots, "info_str": dev.info_str})
     return pucks
     return pucks
 
 
 
 
 def get_puck_slot_for_writing(puck_serial=None, slot=None):
 def get_puck_slot_for_writing(puck_serial=None, slot=None):
     devs = enumerate_devices()
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=nodesort)
+    pucks = devs["puck"]
+
     if not pucks:
     if not pucks:
         raise SystemExit("no puck (28DE:1304/1305) control interface found")
         raise SystemExit("no puck (28DE:1304/1305) control interface found")
 
 
@@ -289,18 +426,49 @@ def get_puck_slot_for_writing(puck_serial=None, slot=None):
     
     
 
 
 
 
-def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
+def pair(puck_serial=None, puck_slot=None, controller_serial=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
     devs = enumerate_devices()
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=nodesort)
+    puck_devs = devs["puck"]
+    pucks = []
+
+    for puck in puck_devs:
+        pucks.append(puck["esb"])
+
     ctrls = sorted(devs["ctrl"], key=nodesort)
     ctrls = sorted(devs["ctrl"], key=nodesort)
     if not pucks:
     if not pucks:
         raise SystemExit("no puck (28DE:1304/1305) control interface found")
         raise SystemExit("no puck (28DE:1304/1305) control interface found")
-    if not ctrls:
-        raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
-    ctrl = ctrls[0]
+    
+    ctrl = None
+    if controller_serial is not None:
+        # Check controllers connected through USB
+        for c in ctrls:
+            if c.read_serial() == controller_serial:
+                print(f"Found controller {controller_serial} connected over USB")
+                ctrl = c
+
+        if ctrl is None: 
+            # Check controllers connected wirelessly
+            for puck in pucks:
+                for slot in puck:
+                    try: 
+                        if slot.read_serial(True) == controller_serial:
+                            print(f"Found controller {controller_serial} connected wirelessly")
+                            ctrl = puck
+                    except BrokenPipeError:
+                        pass
+    else: 
+        # Controller serial is none. Assume the user wants to use the 1st wired controller. 
+        if not ctrls:
+            raise SystemExit("no controller (28DE:1302) control interface found - please plug in either a Steam Controller or the ReversePuck controller")
+           
+        ctrl = ctrls[0]
+
+    if ctrl is None:
+        raise SystemExit(f"Controller with serial {controller_serial} not found.")
+
 
 
     if controller_name is None: 
     if controller_name is None: 
-        ctrl_serial_str = ctrl.read_serial(1) or "FXA000000000"
+        ctrl_serial_str = ctrl.read_serial(True)
     else: 
     else: 
         ctrl_serial_str = controller_name[:15]
         ctrl_serial_str = controller_name[:15]
 
 
@@ -324,7 +492,7 @@ def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, co
     # Controller name gets written to Puck
     # Controller name gets written to Puck
 
 
     print("Puck: %s slot %d controller %s" % (puck_serial, slot_obj['idx'], ctrl_serial_str))
     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))
+    print("Controller: %s slot %d puck %s" % (ctrl.read_serial(True), controller_slot, puck_serial_str))
 
 
     # puck slot: 0xA2 [r1 r2][ctrl serial]
     # puck slot: 0xA2 [r1 r2][ctrl serial]
     slot_obj['dev'].set_feature(2, 0xA2, r + _ser16(ctrl_serial_str))
     slot_obj['dev'].set_feature(2, 0xA2, r + _ser16(ctrl_serial_str))
@@ -361,17 +529,45 @@ def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None)
 
 
     print("done")
     print("done")
 
 
-def write_controller_slot(slot=0, puck_serial=None, pkey=None):
+def write_controller_slot(controller_serial=None, slot=0, puck_serial=None, pkey=None):
+
     devs = enumerate_devices()
     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))
+    puck_devs = devs["puck"]
+    pucks = []
+
+    for puck in puck_devs:
+        pucks.append(puck["esb"])
+
+    ctrls = sorted(devs["ctrl"], key=nodesort) 
+    ctrl = None
+    if controller_serial is not None:
+        # Check controllers connected through USB
+        for c in ctrls:
+            if c.read_serial() == controller_serial:
+                print(f"Found controller {controller_serial} connected over USB")
+                ctrl = c
+
+        if ctrl is None: 
+            # Check controllers connected wirelessly
+            for puck in pucks:
+                for slot in puck:
+                    print(slot)
+                    try: 
+                        if slot.read_serial(True) == controller_serial:
+                            print(f"Found controller {controller_serial} connected wirelessly")
+                            ctrl = puck
+                    except BrokenPipeError:
+                        pass
+    else: 
+        # Controller serial is none. Assume the user wants to use the 1st wired controller. 
+        if not ctrls:
+            raise SystemExit("no controller (28DE:1302) control interface found - please plug in a Steam Controller")
+           
+        ctrl = ctrls[0]
 
 
+    if ctrl is None:
+        raise SystemExit(f"Controller with serial {controller_serial} not found.")
 
 
-    ctrl = ctrls[0]
 
 
     if pkey is None: 
     if pkey is None: 
         r = os.urandom(8)
         r = os.urandom(8)
@@ -402,75 +598,160 @@ def write_controller_slot(slot=0, puck_serial=None, pkey=None):
         ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
         ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
         print("paired. controller rebooting into wireless — move the puck to your host.")
         print("paired. controller rebooting into wireless — move the puck to your host.")
 
 
-def get_controller_pairings():
-    devs = enumerate_devices()
-    ctrls = sorted(devs["ctrl"], key=nodesort)
+def get_single_controller_pairings(controller_dev):
 
 
-    if not ctrls:
-        return []
-
-    controllers = []
+    slots = []
+    i = 0
+    for bond_str in [b"esb/bond\x00", b"esb/bond_2\x00"]:
+        controller_dev.set_feature(1, 0xED, bond_str)
+        bond = controller_dev.get_feature(1)
+
+        if bond[0] == 0x01 and bond[1] == 0xed and bond[2] > 0:
+            bond_len = bond[2]
+            bond_key = bond[3:3+8]
+            bond_name = bond[11:11+(bond_len-8)]
+            slots.append({"idx": i, "used": True, "key": bond_key.hex(), "serial": bond_name.decode("latin1", "replace").split('\x00')[0]})
+        else: 
+            slots.append({"idx": i, "used": False})
+
+        i = i + 1
+
+    return {"serial": controller_dev.read_serial(True), 
+            "firmware_ts": controller_dev.read_firmware_version(True), 
+            "firmware_commit": controller_dev.read_serial(True, 3), 
+            "slots": slots}
+
+
+def cmd_reboot(device_serial: str, reboot_type: int):
+    """
+        type:
+        These types don't correspond to anything in the protocol, these are just flags used for this function.
+        
+        1: reboot (controller only)
+        2: reboot to bootloader (puck or controller)
+        3: reboot to wireless connection (controller only)
+        4: power off (controller only)
+    
+    """
 
 
-    for c in ctrls:
-        slots = []
-        i = 0
+    devs = enumerate_devices()
 
 
-        for bond_str in [b"esb/bond\x00", b"esb/bond_2\x00"]:
-            c.set_feature(1, 0xED, bond_str)
-            bond = c.get_feature(1)
+    puck_devs = sorted(devs["puck"], key=nodesort)
 
 
-            if bond[0] == 0x01 and bond[1] == 0xed and bond[2] > 0:
-                bond_len = bond[2]
-                bond_key = bond[3:3+8]
-                bond_name = bond[11:11+(bond_len-8)]
-                slots.append({"idx": i, "used": True, "key": bond_key.hex(), "serial": bond_name.decode("latin1", "replace")})
-            else: 
-                slots.append({"idx": i, "used": False})
+    ctrls = sorted(devs["ctrl"], key=nodesort)
 
 
-            i = i + 1
 
 
-        controllers.append({"serial": c.read_serial(1), "slots": slots})
+    # Check if we want to reboot a Puck
+    for p in puck_devs: 
+        pc = p['control']
+        if pc.read_serial() == device_serial:
+            if reboot_type == 2:
+                print(f"Found puck with serial {device_serial}, rebooting to bootloader...")
+                if pc.get_bcd_version() == 2:
+                    pc.set_feature(2, 0x90)
+                else: 
+                    pc.set_feature(1, 0x90)
+                return True
+            else:
+                print(f"Found puck with serial {device_serial}, but pucks can only reboot to bootloader. Skipping.")
+                print(f"If that's what you want, run again with --bootloader.")
+
+    # Check if we want to reboot a controller that's wireless.
+    found_puck_dev = None
+    ps = read_slots(puck_devs)
+    for puck in ps:
+        slots = puck['slots']
+
+        for s in slots: 
+            if s["active"]:
+                if s['dev'].read_serial(True) == device_serial:
+                    found_puck_dev = s['dev']
+                    break
+
+    # Check if we want to reboot a controller that's wired.
+    if found_puck_dev is None:
+        for c in ctrls: 
+            if c.read_serial() == device_serial:
+                found_puck_dev = c
+                break
 
 
-    return controllers
+    if found_puck_dev is not None:
+        if reboot_type == 1: 
+            print(f"Found controller with serial {device_serial}, rebooting...")
+            found_puck_dev.set_feature(1, 0x95)
+        elif reboot_type == 2:
+            print(f"Found controller with serial {device_serial}, rebooting to bootloader...")
+            found_puck_dev.set_feature(1, 0x90)
+        elif reboot_type == 3:
+            print(f"Found controller with serial {device_serial}, rebooting to wireless...")
+            found_puck_dev.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
+        elif reboot_type == 4:
+            print(f"Found controller with serial {device_serial}, powering off...")
+            found_puck_dev.set_feature(1, 0x9F, b'off!')
+        return True
 
 
 
 
 def cmd_list():
 def cmd_list():
     devs = enumerate_devices()
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=nodesort)
-    ctrls = sorted(devs["ctrl"], key=nodesort)
-
-    if ctrls:
-        cs = get_controller_pairings()
-
-        for c in cs:
-            print(f"Controller: {c['serial']}")
-
-            for s in c['slots']:
-                if s['used']:
-                    print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["key"]))
-                else:
-                    print("  slot %d: Unused" % (s["idx"]))
-    else:
-        print("No controller connected.")
-
+    pucks = devs["puck"]
+    ctrls = devs["ctrl"]
 
 
+    ctrls_esb = []
 
 
     if pucks:
     if pucks:
         ps = read_slots(pucks)
         ps = read_slots(pucks)
         for puck in ps:
         for puck in ps:
             slots = puck['slots']
             slots = puck['slots']
 
 
-            print(f"Puck: {puck['serial']} ({puck['info_str']})")
+            firmware_str = hex(puck['firmware_ts']).replace('0x', '').upper() + " (" + puck['firmware_commit']  + ")"
+
+            print(f"Puck: {puck['serial']} ({puck['info_str']}), firmware {firmware_str}")
 
 
             for s in slots: 
             for s in slots: 
+                suffix = ""
+                if s["active"]:
+                    conn_serial = s['dev'].read_serial(True)
+                    suffix = f", connected to controller " + conn_serial
+                    ctrls_esb.append(s['dev'])
+
                 if s["used"]:
                 if s["used"]:
-                    print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["rec"][:8].hex()))
+                    print("  slot %d: %s (key %s)%s" % (s["idx"], s["serial"], s["rec"][:8].hex(), suffix))
                 else: 
                 else: 
                     print("  slot %d: Empty" % (s["idx"]))
                     print("  slot %d: Empty" % (s["idx"]))
+
+            print()
+
     else:
     else:
         print("No Puck connected.")
         print("No Puck connected.")
 
 
 
 
+    if ctrls or ctrls_esb:
+        ctype = "wired"
+        for c in [*ctrls, None, *ctrls_esb]:
+            if c is None: 
+                ctype = "wireless"
+                continue
+
+            cdata = get_single_controller_pairings(c)
+
+            firmware_str = hex(cdata['firmware_ts']).replace('0x', '').upper() + " (" + cdata['firmware_commit']  + ")"
+        
+            print(f"Controller ({ctype}): {cdata['serial']}, firmware {firmware_str}")
+
+            for s in cdata['slots']:
+                if s['used']:
+                    print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["key"]))
+                else:
+                    print("  slot %d: Empty" % (s["idx"]))
+
+            print()
+
+    else:
+        print("No controller connected.")
+
+
+
+
 def steam_key_check(key):
 def steam_key_check(key):
     try: 
     try: 
         key = bytes.fromhex(key)
         key = bytes.fromhex(key)
@@ -485,10 +766,12 @@ def main():
     sub = ap.add_subparsers(dest="cmd", required=True)
     sub = ap.add_subparsers(dest="cmd", required=True)
     sub.add_parser("list")
     sub.add_parser("list")
     p = sub.add_parser("pair")
     p = sub.add_parser("pair")
-    #def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
+    #def pair(puck_serial=None, puck_slot=None, controller_serial=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-serial", type=str, default=None)
     p.add_argument("--puck-slot", type=int, default=None)
     p.add_argument("--puck-slot", type=int, default=None)
     p.add_argument("--puck-name", type=str, default=None)
     p.add_argument("--puck-name", type=str, default=None)
+    p.add_argument("--controller-serial", type=str, default=None)
     p.add_argument("--controller-slot", type=int, default=0)
     p.add_argument("--controller-slot", type=int, default=0)
     p.add_argument("--controller-name", type=str, default=None)
     p.add_argument("--controller-name", type=str, default=None)
     p.add_argument("--key", type=steam_key_check, default=None)
     p.add_argument("--key", type=steam_key_check, default=None)
@@ -502,22 +785,45 @@ def main():
     s.add_argument("--key", type=steam_key_check, default=None)
     s.add_argument("--key", type=steam_key_check, default=None)
 
 
     c = sub.add_parser("write-controller")
     c = sub.add_parser("write-controller")
-    #def write_controller_slot(slot=0, puck_serial=None, pkey=None):
+    #def write_controller_slot(controller_serial=None, slot=0, puck_serial=None, pkey=None):
+    c.add_argument("--controller-serial", type=str, default=None)
     c.add_argument("--controller-slot", type=int, default=None)
     c.add_argument("--controller-slot", type=int, default=None)
     c.add_argument("--puck-name", type=str, default=None)
     c.add_argument("--puck-name", type=str, default=None)
     c.add_argument("--key", type=steam_key_check, default=None)
     c.add_argument("--key", type=steam_key_check, default=None)
 
 
 
 
+    rb = sub.add_parser("reboot")
+    #def cmd_reboot(device_serial: str, reboot_type: int):
+    rb.add_argument("--serial", type=str, required=True)
+
+    reboot_group = rb.add_mutually_exclusive_group()        # no parameters: normal reboot, type 1
+    reboot_group.add_argument("--bootloader", action='store_true') # type 2
+    reboot_group.add_argument("--wireless", action='store_true') # type 3
+    reboot_group.add_argument("--poweroff", action='store_true') # type 4
+
+
+
     
     
     args = ap.parse_args()
     args = ap.parse_args()
     if args.cmd == "list":
     if args.cmd == "list":
         cmd_list()
         cmd_list()
     elif args.cmd == "pair":
     elif args.cmd == "pair":
-        pair(args.puck_serial, args.puck_slot, args.controller_slot, args.puck_name, args.controller_name, args.key)
+        pair(args.puck_serial, args.puck_slot, args.controller_serial, 
+                args.controller_slot, args.puck_name, args.controller_name, args.key)
     elif args.cmd == "write-puck":
     elif args.cmd == "write-puck":
         write_puck_slot(args.puck_serial, args.puck_slot, args.controller_name, args.key)
         write_puck_slot(args.puck_serial, args.puck_slot, args.controller_name, args.key)
     elif args.cmd == "write-controller":
     elif args.cmd == "write-controller":
         write_controller_slot(args.controller_slot, args.puck_name, args.key)
         write_controller_slot(args.controller_slot, args.puck_name, args.key)
+    elif args.cmd == "reboot":
+        reboot_type = 1
+        if args.bootloader: 
+            reboot_type = 2
+        if args.wireless: 
+            reboot_type = 3
+        if args.poweroff: 
+            reboot_type = 4
+
+        cmd_reboot(args.serial, reboot_type)