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 tháng trước cách đây
mục cha
commit
c3a247ab2a
1 tập tin đã thay đổi với 419 bổ sung và 113 xóa
  1. 419 113
      ReversePuck/scpair.py

+ 419 - 113
ReversePuck/scpair.py

@@ -1,11 +1,17 @@
 #!/usr/bin/env python3
 """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
 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.
 
+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):
   1. fresh 8-byte key (r1,r2)
   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
 PUCK_PIDS = (0x1304, 0x1305)
 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)."""
+    """Return (vid, pid) for a /dev/hidrawN node, or (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)
+        return (None, None)
     
     vid = None
     pid = None 
-    serial = None
 
     for line in ue.splitlines():
         if line.startswith("HID_ID="):
@@ -76,12 +80,12 @@ def _sysfs_ids(node):
             try:
                 vid = int(parts[1], 16) & 0xFFFF
                 pid = int(parts[2], 16) & 0xFFFF
+                return (vid, pid)
             except:
                 pass
-        if line.startswith("HID_UNIQ="):
-            serial = line.split('=')[1]
+
+    return (None, None)
     
-    return (vid, pid, serial)
 
 
 def _first_usage_page(fd):
@@ -109,11 +113,10 @@ def _first_usage_page(fd):
 
 
 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.vid = vid
         self.pid = pid
-        self.serial = serial
         self.info_str = info_str
         self.fd = os.open(node, os.O_RDWR)
         self.usage_page = _first_usage_page(self.fd)
@@ -124,33 +127,116 @@ class HidDev:
         except Exception:
             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""):
-        buf = bytearray(FEAT_LEN)
+        buf = bytearray(64)
         buf[0] = rid
         buf[1] = cmd
         buf[2] = len(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
-        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)
 
-    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):
     """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):
         return nodesort(nodeobj.node)
 
@@ -159,10 +245,15 @@ def nodesort(nodeobj):
 
 
 def enumerate_devices():
-    """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
+    """Return dict role -> list[HidDev]: {'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):
-        vid, pid, serial = _sysfs_ids(node)
+        vid, pid = _sysfs_ids(node)
         if vid != VALVE_VID:
             continue
 
@@ -195,22 +286,54 @@ def enumerate_devices():
                     device_vendor = "OpenPuck"
 
         try:
-            dev = HidDev(node, vid, pid, serial, device_vendor)
+            dev = HidDev(node, vid, pid, device_vendor)
         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()
+
+        if pid in CTRL_PIDS:
+            out["ctrl"].append(dev)
             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:
             out["ctrl"].append(dev)
         else:
             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
 
 
@@ -222,36 +345,50 @@ def _ser16(serial):
 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:
+    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.
-            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
 
 
 def get_puck_slot_for_writing(puck_serial=None, slot=None):
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=nodesort)
+    pucks = devs["puck"]
+
     if not pucks:
         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()
-    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)
     if not pucks:
         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: 
-        ctrl_serial_str = ctrl.read_serial(1) or "FXA000000000"
+        ctrl_serial_str = ctrl.read_serial(True)
     else: 
         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
 
     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]
     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")
 
-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()
-    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: 
         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]))
         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():
     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:
         ps = read_slots(pucks)
         for puck in ps:
             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: 
+                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"]:
-                    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: 
                     print("  slot %d: Empty" % (s["idx"]))
+
+            print()
+
     else:
         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):
     try: 
         key = bytes.fromhex(key)
@@ -485,10 +766,12 @@ def main():
     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):
+    #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-slot", type=int, 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-name", type=str, 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)
 
     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("--puck-name", type=str, 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()
     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)
+        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":
         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)
+    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)