Browse Source

add pairtui

Jariullah Safi 4 tháng trước cách đây
mục cha
commit
4f25f1bea9
3 tập tin đã thay đổi với 1074 bổ sung và 0 xóa
  1. 58 0
      pairtui/README.md
  2. 350 0
      pairtui/pairtui.py
  3. 666 0
      pairtui/scmd.c

+ 58 - 0
pairtui/README.md

@@ -0,0 +1,58 @@
+# pairtui — steamless pairing + bond inspector (macOS)
+
+A small terminal UI for the Valve SC2 ("Triton") puck / copycat. It reads the puck's 4 bond slots over USB-HID
+and can pair / unpair controllers **without Steam**. It also surfaces the **`ibex_uuid`** of each bond — which is
+exactly what the RF sniffer (`../puck_sniffer/`, `../docs/sniffer.html`) needs for its **lock ibex** field.
+
+It talks to the **vendor HID interface** (usage page `0xFF00`), which opens **without** macOS Input-Monitoring
+permission, via the helper binary `scmd`.
+
+## Build (macOS)
+`pairtui.py` is pure Python stdlib (`curses`, no pip installs). Its only dependency is `scmd`, built from
+`scmd.c` with the IOKit + CoreFoundation frameworks:
+
+```sh
+clang -framework IOKit -framework CoreFoundation -o scmd scmd.c
+```
+
+(macOS only — `scmd` uses IOKit HID. There is no Linux/Windows port here.)
+
+## Run
+```sh
+python3 pairtui.py
+```
+
+Keys: `↑↓` select slot · `p` pair · `u` unpair · `F` wipe all · `P` next puck · `r` rescan · `i` refresh · `q` quit.
+
+The table shows each slot's **state** (CONNECTED / CONNECTING / BONDED / empty — live `ibexN_state` is read from
+the real puck's `dongle~$` CDC shell when present), the bonded **controller serial**, and the **IBEX UUID**.
+
+## What you need it for here
+- **Sniffer lock:** a *connected* slot doesn't beacon on ch2, so the sniffer locks onto a bonded slot by its
+  `ibex_uuid`. Read that uuid here (e.g. `0xEF7171B4`) and type it into the sniffer app's **lock ibex** field.
+  The uuid is the bond id — **stable across reconnects** (the on-air address is random per session, the uuid is not).
+- **Labelling captures:** confirms which controller/slot a capture belongs to.
+
+## Bond record (read via `0xA3`, `--up 1 --idx 0..3`)
+24 bytes per slot:
+
+```
+[0..3]  proteus_uuid   (u32, little-endian)
+[4..7]  ibex_uuid      (u32, little-endian)   <- the value the sniffer locks on
+[8..23] controller serial (ASCII, NUL-padded)
+```
+
+## scmd (reference)
+`scmd` is a thin HID feature-report tool. Safe (read-only) commands are used by default; destructive opcodes
+(`0x86` reset, `0x9F` off, `0xAD` pair, …) are only sent when passed explicitly.
+
+```sh
+./scmd list                                   # list Valve HID nodes + serials
+./scmd 1304 --serial <S> --up 1 --idx 0 A3    # read bond slot 0 of puck <S>  (1304 = puck PID)
+```
+
+pairtui drives `scmd` for all reads/writes; you normally won't call it directly.
+
+## Safety note
+`pairtui` only sends `connection` to a shell it has confirmed is the **real puck** (`dongle~$` prompt); it hands
+off harmlessly if it sees the copycat's different single-letter CDC shell.

+ 350 - 0
pairtui/pairtui.py

@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+"""pairtui — steamless pairing manager for the Steam Controller 2 ("Triton") puck / copycat.
+
+Reads the 4 bond slots over USB-HID (0xA3 on control interface --up 1 --idx 0..3, the same way Steam
+reads dongle slots — one bond per interface). Pair / unpair go via ./scmd over USB-HID; live connection
+state (CONNECTED/CONNECTING/IDLE) is read from the real puck's `dongle~$` CDC shell when present.
+
+  scmd: 0xA2 puck slot write/clear, 0xEE/0xEF controller esb/bond, 0x95 reboot-to-wireless, 0xAD pairing.
+  Build scmd:  clang -framework IOKit -framework CoreFoundation -o scmd scmd.c
+  Run: python3 pairtui.py        (pure stdlib: curses + termios)
+"""
+import curses, subprocess, re, os, glob, termios, time, select
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SCMD = os.path.join(HERE, "scmd")
+PUCK_PID = "1304"          # Valve puck / copycat
+CTRL_PIDS = ("1301", "1302", "1303", "1205")  # Steam Controller variants (NOT a puck)
+
+
+def scmd(args, timeout=30):
+    try:
+        return subprocess.run([SCMD] + args, capture_output=True, text=True, timeout=timeout).stdout
+    except Exception as e:
+        return "ERR %s" % e
+
+
+def _list_devices():
+    """Parse `scmd list` -> dedup dict {serial: {pid, product}}. One entry per physical device."""
+    out = scmd(["list"], timeout=8)
+    devs = {}
+    for m in re.finditer(r"Valve 28DE:([0-9A-Fa-f]+)\b.*?serial=(\S+)\s+product=(.+?)\s+manufacturer", out):
+        pid, ser, prod = m.group(1).lower(), m.group(2), m.group(3).strip()
+        if ser not in devs:
+            devs[ser] = {"pid": pid, "product": prod}
+    return devs
+
+
+def find_pucks():
+    """[(serial, cdc_port)] for PID-1304 PUCKS ONLY (a controller is PID 1302 and is excluded)."""
+    res = []
+    for ser, d in _list_devices().items():
+        if d["pid"] != PUCK_PID:
+            continue
+        port = (glob.glob("/dev/cu.usbmodem%s*" % ser) or [None])[0]
+        res.append((ser, port))
+    return res
+
+
+def find_controllers():
+    """[(pid, serial)] of docked Valve controllers (anything that is NOT a puck)."""
+    res = []
+    for ser, d in _list_devices().items():
+        if d["pid"] == PUCK_PID or "Puck" in d["product"]:
+            continue
+        if ser.startswith("FX"):
+            res.append((d["pid"], ser))
+    return res
+
+
+class Shell:
+    """Talk to the real puck's CDC debug shell (dongle~$). Used only for live `connection` state."""
+    def __init__(self, port): self.port = port; self.fd = None
+    def open(self):
+        self.fd = os.open(self.port, os.O_RDWR | os.O_NONBLOCK | os.O_NOCTTY)
+        a = termios.tcgetattr(self.fd); a[4] = a[5] = termios.B115200
+        a[3] &= ~(termios.ICANON | termios.ECHO | termios.ISIG | termios.IEXTEN)
+        a[0] &= ~(termios.IXON | termios.ICRNL); a[1] &= ~termios.OPOST
+        termios.tcsetattr(self.fd, termios.TCSANOW, a)
+        self.cmd("", 0.3)
+    def close(self):
+        if self.fd is not None:
+            try: os.close(self.fd)
+            except Exception: pass
+            self.fd = None
+    def cmd(self, c, wait=0.8):
+        os.write(self.fd, (c + "\r\n").encode()); out = b""; t0 = time.time()
+        while time.time() - t0 < wait:
+            if select.select([self.fd], [], [], 0.1)[0]:
+                try: out += os.read(self.fd, 8192)
+                except Exception: pass
+        return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", out.decode("ascii", "replace"))
+
+
+def shell_connection(port):
+    """Return {slot_idx: STATE} from the real puck's `connection` command, or {} otherwise.
+
+    SAFETY: the copycat's CDC is a *different* command shell (single-letter cmds like 'c'=channel), so
+    blindly sending "connection" there would be misread as a command and corrupt its state. We first
+    probe with a harmless newline and ONLY proceed if the reply shows the real puck's `dongle~$` prompt."""
+    if not port:
+        return {}
+    sh = Shell(port)
+    try:
+        sh.open()
+        probe = sh.cmd("", 0.3)                      # bare newline — harmless on any shell
+        if "dongle" not in probe and "~$" not in probe:
+            return {}                                # not the dongle shell (e.g. the copycat): hands off
+        txt = sh.cmd("connection", 0.7)
+    except Exception:
+        return {}
+    finally:
+        sh.close()
+    states = {}
+    for m in re.finditer(r"ibex(\d)_state\s*:\s*(\w+)", txt):
+        states[int(m.group(1))] = m.group(2).upper()
+    return states
+
+
+def ctrl_read(puck_serial, idx):
+    """Read 0xA3 (bond/status) from control interface --up 1 --idx; return (serial, raw_record_hex)."""
+    out = scmd([PUCK_PID, "--serial", puck_serial, "--up", "1", "--idx", str(idx), "A3"], timeout=8)
+    m = re.search(r"ascii:.*?(FX[A-Z0-9]{6,})", out)
+    serial = m.group(1) if m else ""
+    rm = re.search(r"02 A3 [0-9A-Fa-f]{2} ([0-9A-Fa-f ]+)", out)
+    return serial, (rm.group(1).strip() if rm else "")
+
+
+def _ser16(serial):
+    b = serial.encode()[:16]; b += b"\x00" * (16 - len(b))
+    return ["%02x" % x for x in b]
+
+
+def pair_full(puck_serial, puck_slot, ctrl_pid, ctrl_serial):
+    """Steamless pair (controller must be USB-docked). Fresh 8-byte uuid; writes the puck slot
+    (0xA2 [uuids][ctrl_serial]) and the controller (0xEE esb/bond [uuids][puck_serial] + 0xEF commit),
+    then 0x95 reboots the controller to wireless. Undock to connect. Returns the uuids hex string."""
+    uuids = ["%02x" % x for x in os.urandom(8)]
+    key = ["%02x" % x for x in b"esb/bond\x00"]
+    scmd([PUCK_PID, "--serial", puck_serial, "--up", "1", "--idx", str(puck_slot), "A2"]
+         + uuids + _ser16(ctrl_serial))
+    scmd([ctrl_pid, "--serial", ctrl_serial, "--up", "1", "EE"] + key + uuids + _ser16(puck_serial))
+    scmd([ctrl_pid, "--serial", ctrl_serial, "--up", "1", "EF"] + key)
+    scmd([ctrl_pid, "--serial", ctrl_serial, "--up", "1", "95", "52", "af", "27", "a4"])
+    return "".join(uuids)
+
+
+def usb_slots(puck_serial):
+    """Read all 4 bond slots over USB (0xA3, --up 1 --idx 0..3). Works for real puck and copycat.
+    Record = [4B proteus_uuid LE][4B ibex_uuid LE][16B controller serial]."""
+    slots = []
+    for i in range(4):
+        _, rec = ctrl_read(puck_serial, i)
+        b = bytes(int(x, 16) for x in rec.split()[:24]) if rec else b""
+        used = len(b) >= 24 and any(b[0:8])
+        slots.append({
+            "idx": i,
+            "puuid": "0x%08X" % int.from_bytes(b[0:4], "little") if used else "",
+            "iuuid": "0x%08X" % int.from_bytes(b[4:8], "little") if used else "",
+            "serial": b[8:24].split(b"\x00")[0].decode("latin1") if used else "",
+            "state": "BONDED" if used else "empty",
+        })
+    return slots
+
+
+# ───────────────────────────── UI ─────────────────────────────
+CP_TITLE, CP_OK, CP_CONN, CP_WARN, CP_DIM, CP_SEL, CP_KEY, CP_HDR = range(1, 9)
+
+STATE_STYLE = {
+    "CONNECTED": (CP_CONN, "●"), "CONNECTING": (CP_WARN, "◐"),
+    "BONDED": (CP_OK, "●"), "IDLE": (CP_DIM, "○"), "empty": (CP_DIM, "·"),
+}
+
+
+class TUI:
+    def __init__(self, scr):
+        self.scr = scr; self.pucks = []; self.pi = 0; self.slot_i = 0
+        self.slots = []; self.conn = {}; self.log = ["ready"]
+        self.rescan()
+
+    def msg(self, m):
+        self.log.append("%s  %s" % (time.strftime("%H:%M:%S"), m)); self.log = self.log[-300:]
+
+    def cur(self):
+        return self.pucks[self.pi] if self.pucks else None
+
+    def rescan(self):
+        self.pucks = find_pucks(); self.pi = min(self.pi, max(0, len(self.pucks) - 1))
+        self.msg("found %d puck(s), %d controller(s)" % (len(self.pucks), len(find_controllers())))
+        self.refresh()
+
+    def refresh(self):
+        p = self.cur()
+        if not p:
+            self.slots = []; self.conn = {}; return
+        try:
+            self.slots = usb_slots(p[0])
+            self.conn = shell_connection(p[1])  # {} unless it's a real puck dongle shell
+            n = sum(1 for s in self.slots if s["serial"])
+            self.msg("%s: %d bonded%s" % (p[0], n, "  (live state)" if self.conn else ""))
+        except Exception as e:
+            self.msg("read err: %s" % e); self.slots = []
+
+    def slot_state(self, i):
+        """Prefer live connection state from the shell; fall back to the bonded/empty USB read."""
+        sl = self.slots[i] if i < len(self.slots) else {"state": "empty", "serial": "", "iuuid": ""}
+        st = self.conn.get(i, sl["state"])
+        if st == "IDLE" and not sl["serial"]:
+            st = "empty"
+        return st, sl
+
+    # ---- drawing helpers ----
+    def _cs(self, cp):
+        return curses.color_pair(cp) if curses.has_colors() else 0
+
+    def draw(self):
+        s = self.scr; s.erase(); h, w = s.getmaxyx(); W = max(40, w)
+        def ln(y, x, t, a=0):
+            if 0 <= y < h: s.addnstr(y, x, t, max(0, w - x - 1), a)
+        def rule(y, label=""):
+            if 0 <= y < h:
+                s.hline(y, 0, curses.ACS_HLINE, w - 1)
+                if label: ln(y, 3, " %s " % label, self._cs(CP_HDR) | curses.A_BOLD)
+
+        # title bar
+        title = " ⬡  TRITON  ·  steamless pairing manager "
+        ln(0, 0, title.ljust(w - 1), self._cs(CP_TITLE) | curses.A_BOLD)
+
+        # device line
+        p = self.cur()
+        if p:
+            anyconn = any(v in ("CONNECTED", "CONNECTING") for v in self.conn.values())
+            badge = "● live" if self.conn else "○ usb"
+            ln(2, 2, "PUCK", self._cs(CP_KEY) | curses.A_BOLD)
+            ln(2, 8, "%d/%d" % (self.pi + 1, len(self.pucks)), curses.A_DIM)
+            ln(2, 14, p[0], curses.A_BOLD)
+            ln(2, 14 + len(p[0]) + 3, badge, self._cs(CP_CONN if anyconn else CP_DIM))
+            if p[1]:
+                ln(2, w - len(p[1]) - 3, p[1].split("/")[-1], curses.A_DIM)
+        else:
+            ln(2, 2, "no puck found — plug in the copycat / dongle, press [r]", self._cs(CP_WARN))
+
+        # slot table
+        rule(4, "bond slots")
+        ln(5, 2, "  %-4s %-12s %-18s %-12s" % ("SLOT", "STATE", "CONTROLLER", "IBEX UUID"),
+           self._cs(CP_DIM) | curses.A_BOLD)
+        for i in range(4):
+            st, sl = self.slot_state(i)
+            cp, glyph = STATE_STYLE.get(st, (CP_DIM, "·"))
+            sel = (i == self.slot_i)
+            who = sl["serial"] or "—"
+            cursor = "▶" if sel else " "
+            base_a = self._cs(CP_SEL) | curses.A_BOLD if sel else 0
+            ln(6 + i, 1, "%s ibex%d %s %-11s %-18s %-12s"
+               % (cursor, i, glyph, st.lower(), who, sl.get("iuuid", "")), base_a)
+
+        # controllers available
+        rule(11, "docked controllers")
+        ctrls = find_controllers()
+        ln(12, 2, "  ".join("%s" % c[1] for c in ctrls) or "(none docked)",
+           self._cs(CP_OK) if ctrls else self._cs(CP_DIM))
+
+        # log
+        rule(14, "log")
+        for i, m in enumerate(self.log[-(h - 18):]):
+            ln(15 + i, 2, m, curses.A_DIM)
+
+        # footer key bar
+        keys = [("↑↓", "slot"), ("p", "pair"), ("u", "unpair"), ("F", "wipe"),
+                ("P", "puck"), ("r", "rescan"), ("i", "refresh"), ("q", "quit")]
+        x = 1
+        for k, lbl in keys:
+            seg = " %s " % k
+            if x + len(seg) + len(lbl) + 2 < w:
+                s.addnstr(h - 1, x, seg, len(seg), self._cs(CP_KEY) | curses.A_REVERSE | curses.A_BOLD)
+                x += len(seg)
+                s.addnstr(h - 1, x, " %s " % lbl, len(lbl) + 2, self._cs(CP_DIM))
+                x += len(lbl) + 2
+        s.refresh()
+
+    def prompt(self, msg, color=CP_WARN):
+        h, w = self.scr.getmaxyx(); curses.echo()
+        self.scr.addnstr(h - 1, 0, (msg + " ").ljust(w - 1), w - 1,
+                         self._cs(color) | curses.A_REVERSE | curses.A_BOLD)
+        self.scr.refresh()
+        try: v = self.scr.getstr(h - 1, len(msg) + 1, 60).decode().strip()
+        except Exception: v = ""
+        curses.noecho(); return v
+
+    def do_pair(self):
+        p = self.cur(); i = self.slot_i
+        if not p: return
+        if i < len(self.slots) and self.slots[i]["serial"]:
+            self.msg("slot %d occupied — pick a vacant slot or unpair first" % i); return
+        ctrls = find_controllers()
+        if not ctrls:
+            self.msg("no USB-docked controller found — plug one in to pair"); return
+        pid, cser = ctrls[0]
+        if len(ctrls) > 1:
+            pick = self.prompt("controllers %s — serial to pair:" % ",".join(c[1] for c in ctrls)).strip()
+            for cp, cs in ctrls:
+                if cs == pick: pid, cser = cp, cs; break
+        if self.prompt("pair %s -> ibex%d ?  type YES:" % (cser, i)) != "YES":
+            self.msg("pair cancelled"); return
+        uu = pair_full(p[0], i, pid, cser)
+        self.msg("paired %s -> ibex%d (uuid %s) — UNDOCK controller to connect" % (cser, i, uu))
+        time.sleep(1); self.refresh()
+
+    def do_unpair(self):
+        p = self.cur(); i = self.slot_i
+        if not p or i >= len(self.slots) or not self.slots[i]["serial"]:
+            self.msg("slot %d empty" % i); return
+        target = self.slots[i]["serial"]
+        if self.prompt("clear ibex%d (%s) ?  type YES:" % (i, target)) != "YES":
+            self.msg("unpair cancelled"); return
+        scmd([PUCK_PID, "--serial", p[0], "--up", "1", "--idx", str(i), "A2"] + ["00"] * 24, timeout=12)
+        self.msg("forget sent (slot %d)" % i); time.sleep(0.3); self.refresh()
+
+    def do_factory(self):
+        p = self.cur()
+        if not p: return
+        if self.prompt("wipe ALL slots ?  type WIPE:") != "WIPE":
+            self.msg("wipe cancelled"); return
+        for i in range(4):
+            scmd([PUCK_PID, "--serial", p[0], "--up", "1", "--idx", str(i), "A2"] + ["00"] * 24, timeout=12)
+        self.msg("wiped all slots"); time.sleep(0.5); self.refresh()
+
+    def run(self):
+        while True:
+            self.draw(); k = self.scr.getch()
+            if k in (ord("q"), 27): break
+            elif k in (curses.KEY_UP, ord("k")): self.slot_i = (self.slot_i - 1) % 4
+            elif k in (curses.KEY_DOWN, ord("j")): self.slot_i = (self.slot_i + 1) % 4
+            elif k == ord("P") and self.pucks:
+                self.pi = (self.pi + 1) % len(self.pucks); self.refresh()
+            elif k == ord("p"): self.do_pair()
+            elif k == ord("u"): self.do_unpair()
+            elif k == ord("F"): self.do_factory()
+            elif k == ord("i"): self.refresh()
+            elif k == ord("r"): self.rescan()
+
+
+def main(scr):
+    curses.curs_set(0); scr.keypad(True)
+    if curses.has_colors():
+        curses.start_color(); curses.use_default_colors()
+        curses.init_pair(CP_TITLE, curses.COLOR_CYAN, -1)
+        curses.init_pair(CP_OK,    curses.COLOR_GREEN, -1)
+        curses.init_pair(CP_CONN,  curses.COLOR_GREEN, -1)
+        curses.init_pair(CP_WARN,  curses.COLOR_YELLOW, -1)
+        curses.init_pair(CP_DIM,   curses.COLOR_WHITE, -1)
+        curses.init_pair(CP_SEL,   curses.COLOR_CYAN, -1)
+        curses.init_pair(CP_KEY,   curses.COLOR_BLACK, curses.COLOR_CYAN)
+        curses.init_pair(CP_HDR,   curses.COLOR_MAGENTA, -1)
+    TUI(scr).run()
+
+
+if __name__ == "__main__":
+    if not os.path.exists(SCMD):
+        raise SystemExit("build scmd: clang -framework IOKit -framework CoreFoundation -o scmd scmd.c")
+    curses.wrapper(main)

+ 666 - 0
pairtui/scmd.c

@@ -0,0 +1,666 @@
+// scmd.c — Steam Controller / Puck feature-report command tool (macOS IOKit).
+// Talks to the VENDOR HID interface (usage page 0xFF00) which opens WITHOUT
+// Input Monitoring permission. Implements the 64-byte SET_REPORT(command) plus
+// GET_REPORT(reply) command channel observed on the Triton copycat/real puck.
+//
+//   clang -framework IOKit -framework CoreFoundation -o scmd scmd.c
+//   ./scmd list [pidHex]            # list Valve HID nodes and serials
+//   ./scmd <pidHex> [--serial S] [--loc HEX]    # run the SAFE read battery (no writes)
+//   ./scmd <pidHex> [--serial S] [--loc HEX] <cmdHex> [payloadHexBytes...]   # one custom command
+//   ./scmd <pidHex> [--serial S] [--loc HEX] rid2 <cmdHex> [payloadHexBytes...] # custom command on one report id
+//   ./scmd <pidHex> [--serial S] [--loc HEX] seq-noget rid2 <cmd> [bytes...] -- rid2 <cmd> [bytes...]
+//   ./scmd <pidHex> [--serial S] [--loc HEX] a3watch [count] [delayMs] # watch/decode Triton A3 bond events
+//   ./scmd <pidHex> [--serial S] [--loc HEX] a3variants # read-only A3 framing tests
+//   ./scmd <pidHex> [--serial S] [--loc HEX] triton-bond <0|1> <keyHex16> <peerSerial>
+//   ./scmd <pidHex> [--serial S] [--loc HEX] triton-transport <slot0|slot1|slot2|rawByte>
+//
+// SAFE reads used by default: 0xAE GET_STRING_ATTRIBUTE (serials), 0x83
+// GET_ATTRIBUTES_VALUES, 0xA1 GET_DEVICE_INFO, 0xB4 DONGLE_GET_WIRELESS_STATE.
+//
+// Command labels below are only labels. 0xA2/0xA3 are Triton-confirmed from our
+// copycat logs; the 0xAF..0xC4 names come from SDL's shared Steam constants and
+// are not proof of Triton pairing semantics. Destructive opcodes (0x86 reset,
+// 0x9F off, 0xAD pair, 0xB2/B3 dongle candidates, 0xAF/B0 radio-record
+// candidates) are NEVER sent unless you pass them explicitly.
+#include <IOKit/hid/IOHIDManager.h>
+#include <IOKit/IOKitLib.h>
+#include <CoreFoundation/CoreFoundation.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define VALVE_VID 0x28DE
+
+static void dump(const char *label, const uint8_t *p, int n) {
+    printf("%s [%d]:\n  ", label, n);
+    for (int i = 0; i < n; i++) { if (i && i % 32 == 0) printf("\n  "); printf("%02X ", p[i]); }
+    printf("\n  ascii: ");
+    for (int i = 0; i < n; i++) putchar((p[i] >= 32 && p[i] < 127) ? p[i] : '.');
+    printf("\n");
+}
+
+static void print_payload_inline(const uint8_t *p, int n) {
+    if (n <= 0) {
+        printf("-");
+        return;
+    }
+    for (int i = 0; i < n; i++) {
+        if (i) printf(" ");
+        printf("%02X", p[i]);
+    }
+}
+
+static const char *cmd_name(uint8_t cmd) {
+    switch (cmd) {
+        case 0x83: return "GET_ATTRIBUTES_VALUES";
+        case 0xA1: return "GET_DEVICE_INFO";
+        case 0xA2: return "TRITON_A2_OBSERVED_PAIRING_RECORD";
+        case 0xA3: return "TRITON_A3_BOND_EVENT_OR_STATUS";
+        case 0xAD: return "ENABLE_PAIRING";
+        case 0xAE: return "GET_STRING_ATTRIBUTE";
+        case 0xAF: return "SDL_SHARED_RADIO_ERASE_RECORDS";
+        case 0xB0: return "SDL_SHARED_RADIO_WRITE_RECORD";
+        case 0xB2: return "SDL_SHARED_DONGLE_DISCONNECT_DEVICE";
+        case 0xB3: return "SDL_SHARED_DONGLE_COMMIT_DEVICE_CANDIDATE";
+        case 0xB4: return "SDL_SHARED_DONGLE_GET_WIRELESS_STATE";
+        case 0xC4: return "SDL_SHARED_DONGLE_GET_CONNECTED_SLOTS";
+        case 0xEE: return "TRITON_KEYED_VALUE_WRITE";
+        case 0xEF: return "TRITON_KEYED_VALUE_COMMIT";
+        default: return NULL;
+    }
+}
+
+static int hid_long(IOHIDDeviceRef d, CFStringRef key, long *out) {
+    CFTypeRef v = IOHIDDeviceGetProperty(d, key);
+    if (!v || CFGetTypeID(v) != CFNumberGetTypeID()) return 0;
+    return CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, out);
+}
+
+static void hid_string(IOHIDDeviceRef d, CFStringRef key, char *out, size_t outn) {
+    if (!outn) return;
+    out[0] = 0;
+    CFTypeRef v = IOHIDDeviceGetProperty(d, key);
+    if (!v) return;
+    if (CFGetTypeID(v) == CFStringGetTypeID()) {
+        CFStringGetCString((CFStringRef)v, out, outn, kCFStringEncodingUTF8);
+    } else if (CFGetTypeID(v) == CFNumberGetTypeID()) {
+        long x = 0;
+        if (CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, &x)) snprintf(out, outn, "%ld", x);
+    }
+}
+
+static int reg_long(io_object_t svc, const char *key, long *out) {
+    CFStringRef k = CFStringCreateWithCString(kCFAllocatorDefault, key, kCFStringEncodingUTF8);
+    if (!k) return 0;
+    CFTypeRef v = IORegistryEntryCreateCFProperty(svc, k, kCFAllocatorDefault, 0);
+    CFRelease(k);
+    if (!v) return 0;
+    int ok = 0;
+    if (CFGetTypeID(v) == CFNumberGetTypeID()) ok = CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, out);
+    CFRelease(v);
+    return ok;
+}
+
+static void reg_string(io_object_t svc, const char *key, char *out, size_t outn) {
+    if (!outn) return;
+    out[0] = 0;
+    CFStringRef k = CFStringCreateWithCString(kCFAllocatorDefault, key, kCFStringEncodingUTF8);
+    if (!k) return;
+    CFTypeRef v = IORegistryEntryCreateCFProperty(svc, k, kCFAllocatorDefault, 0);
+    CFRelease(k);
+    if (!v) return;
+    if (CFGetTypeID(v) == CFStringGetTypeID()) {
+        CFStringGetCString((CFStringRef)v, out, outn, kCFStringEncodingUTF8);
+    } else if (CFGetTypeID(v) == CFNumberGetTypeID()) {
+        long x = 0;
+        if (CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, &x)) snprintf(out, outn, "%ld", x);
+    }
+    CFRelease(v);
+}
+
+static void print_device_line(io_object_t svc, IOHIDDeviceRef d) {
+    long vid = -1, pid = -1, up = -1, usage = -1, loc = -1, maxF = -1;
+    char serial[128], product[128], manufacturer[128];
+    serial[0] = product[0] = manufacturer[0] = 0;
+    reg_long(svc, "VendorID", &vid); if (vid < 0) hid_long(d, CFSTR(kIOHIDVendorIDKey), &vid);
+    reg_long(svc, "ProductID", &pid); if (pid < 0) hid_long(d, CFSTR(kIOHIDProductIDKey), &pid);
+    reg_long(svc, "PrimaryUsagePage", &up); if (up < 0) hid_long(d, CFSTR(kIOHIDPrimaryUsagePageKey), &up);
+    reg_long(svc, "PrimaryUsage", &usage); if (usage < 0) hid_long(d, CFSTR(kIOHIDPrimaryUsageKey), &usage);
+    reg_long(svc, "LocationID", &loc); if (loc < 0) hid_long(d, CFSTR(kIOHIDLocationIDKey), &loc);
+    reg_long(svc, "MaxFeatureReportSize", &maxF);
+    reg_string(svc, "SerialNumber", serial, sizeof serial); if (!serial[0]) hid_string(d, CFSTR(kIOHIDSerialNumberKey), serial, sizeof serial);
+    reg_string(svc, "Product", product, sizeof product); if (!product[0]) hid_string(d, CFSTR(kIOHIDProductKey), product, sizeof product);
+    reg_string(svc, "Manufacturer", manufacturer, sizeof manufacturer); if (!manufacturer[0]) hid_string(d, CFSTR(kIOHIDManufacturerKey), manufacturer, sizeof manufacturer);
+    printf("Valve %04lX:%04lX usagePage=0x%lX usage=0x%lX maxFeature=%ld loc=0x%lX serial=%s product=%s manufacturer=%s\n",
+           vid, pid, up, usage, maxF, loc, serial[0] ? serial : "-", product[0] ? product : "-",
+           manufacturer[0] ? manufacturer : "-");
+}
+
+static int list_devices(long wantPid) {
+    CFMutableDictionaryRef m = IOServiceMatching("IOHIDDevice");
+    io_iterator_t it;
+    if (IOServiceGetMatchingServices(kIOMainPortDefault, m, &it) != KERN_SUCCESS) {
+        fprintf(stderr, "match fail\n");
+        return 1;
+    }
+    int n = 0;
+    io_object_t svc;
+    while ((svc = IOIteratorNext(it))) {
+        IOHIDDeviceRef d = IOHIDDeviceCreate(kCFAllocatorDefault, svc);
+        if (d) {
+            long vid = -1, pid = -1;
+            reg_long(svc, "VendorID", &vid); if (vid < 0) hid_long(d, CFSTR(kIOHIDVendorIDKey), &vid);
+            reg_long(svc, "ProductID", &pid); if (pid < 0) hid_long(d, CFSTR(kIOHIDProductIDKey), &pid);
+            if (vid == VALVE_VID && (wantPid < 0 || pid == wantPid)) {
+                print_device_line(svc, d);
+                n++;
+            }
+            CFRelease(d);
+        }
+        IOObjectRelease(svc);
+    }
+    IOObjectRelease(it);
+    if (!n) printf("no Valve HID devices found%s\n", wantPid < 0 ? "" : " for requested PID");
+    return n ? 0 : 1;
+}
+
+// Issue one command on report id `rid`: [rid][cmd][len][payload...].
+static void sc_cmd_ex(IOHIDDeviceRef d, int rid, uint8_t cmd, const uint8_t *pl, int pln, int do_get) {
+    uint8_t tx[64]; memset(tx, 0, sizeof tx);
+    tx[0] = (uint8_t)rid; tx[1] = cmd; tx[2] = (uint8_t)pln;
+    if (pln > 0) memcpy(tx + 3, pl, pln);
+    IOReturn rs = IOHIDDeviceSetReport(d, kIOHIDReportTypeFeature, rid, tx, 64);
+    const char *name = cmd_name(cmd);
+    if (rs != kIOReturnSuccess) {
+        printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd, name ? " " : "", name ? name : "", pln);
+        print_payload_inline(pl, pln);
+        printf(") on report id %d  [set=0x%08X get=skipped-set-failed] ===\n", rid, rs);
+        return;
+    }
+    if (!do_get) {
+        printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd, name ? " " : "", name ? name : "", pln);
+        print_payload_inline(pl, pln);
+        printf(") on report id %d  [set=0x%08X get=skipped] ===\n", rid, rs);
+        return;
+    }
+    usleep(20000);
+    uint8_t rx[64]; memset(rx, 0, sizeof rx); rx[0] = (uint8_t)rid;
+    CFIndex rl = 64;
+    IOReturn rg = IOHIDDeviceGetReport(d, kIOHIDReportTypeFeature, rid, rx, &rl);
+    printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd, name ? " " : "", name ? name : "", pln);
+    print_payload_inline(pl, pln);
+    printf(") on report id %d  [set=0x%08X get=0x%08X] ===\n", rid, rs, rg);
+    if (rg == kIOReturnSuccess) dump("reply", rx, (int)rl);
+    else printf("  (GET failed)\n");
+}
+
+static void sc_cmd(IOHIDDeviceRef d, int rid, uint8_t cmd, const uint8_t *pl, int pln) {
+    sc_cmd_ex(d, rid, cmd, pl, pln, 1);
+}
+
+static int hex_value(char c) {
+    if (c >= '0' && c <= '9') return c - '0';
+    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+    return -1;
+}
+
+static int parse_hex_bytes(const char *hex, uint8_t *out, int outn) {
+    int n = 0;
+    int hi = -1;
+    for (const char *p = hex; *p; p++) {
+        if (*p == ':' || *p == '-' || *p == ' ' || *p == '_') continue;
+        int v = hex_value(*p);
+        if (v < 0) return -1;
+        if (hi < 0) {
+            hi = v;
+        } else {
+            if (n >= outn) return -1;
+            out[n++] = (uint8_t)((hi << 4) | v);
+            hi = -1;
+        }
+    }
+    if (hi >= 0) return -1;
+    return n;
+}
+
+static int make_serial16(const char *serial, uint8_t out[16]) {
+    memset(out, 0, 16);
+    size_t n = strlen(serial);
+    if (n > 16) return 0;
+    memcpy(out, serial, n);
+    return 1;
+}
+
+static int triton_keyed_write(IOHIDDeviceRef dev, const char *key, const uint8_t *payload, int payload_len) {
+    size_t key_len = strlen(key) + 1;
+    if (key_len + (size_t)payload_len > 61 || key_len > 61) {
+        fprintf(stderr, "keyed payload too long for 64-byte feature report\n");
+        return 2;
+    }
+
+    uint8_t ee[61];
+    memcpy(ee, key, key_len);
+    if (payload_len > 0) memcpy(ee + key_len, payload, payload_len);
+
+    uint8_t ef[61];
+    memcpy(ef, key, key_len);
+
+    printf("Triton keyed write key=\"%s\" payload=", key);
+    print_payload_inline(payload, payload_len);
+    printf("\n");
+    sc_cmd_ex(dev, 2, 0xEE, ee, (int)key_len + payload_len, 0);
+    usleep(20000);
+    sc_cmd_ex(dev, 2, 0xEF, ef, (int)key_len, 0);
+    return 0;
+}
+
+static int triton_bond_write(IOHIDDeviceRef dev, int slot, const char *key_hex, const char *serial) {
+    if (slot < 0 || slot > 1) {
+        fprintf(stderr, "triton-bond slot must be 0 or 1\n");
+        return 2;
+    }
+    uint8_t payload[24];
+    int keyn = parse_hex_bytes(key_hex, payload, 8);
+    if (keyn != 8) {
+        fprintf(stderr, "triton-bond key must be exactly 8 bytes / 16 hex digits\n");
+        return 2;
+    }
+    if (!make_serial16(serial, payload + 8)) {
+        fprintf(stderr, "controller serial must be at most 16 ASCII bytes\n");
+        return 2;
+    }
+    const char *key = slot == 0 ? "esb/bond" : "esb/bond_2";
+    printf("note: Steam uses this keyed path for controller-side bond storage; real pucks reject it.\n");
+    return triton_keyed_write(dev, key, payload, sizeof payload);
+}
+
+static int parse_triton_transport(const char *arg, uint8_t *out) {
+    if (strcmp(arg, "slot0") == 0 || strcmp(arg, "0") == 0) { *out = 2; return 1; }
+    if (strcmp(arg, "slot1") == 0 || strcmp(arg, "1") == 0) { *out = 3; return 1; }
+    if (strcmp(arg, "slot2") == 0 || strcmp(arg, "2") == 0) { *out = 0; return 1; }
+    char *end = NULL;
+    long raw = strtol(arg, &end, 0);
+    if (end && *end == 0 && raw >= 0 && raw <= 255) { *out = (uint8_t)raw; return 1; }
+    return 0;
+}
+
+static int triton_transport_write(IOHIDDeviceRef dev, const char *arg) {
+    uint8_t value = 0;
+    if (!parse_triton_transport(arg, &value)) {
+        fprintf(stderr, "triton-transport expects slot0, slot1, slot2, or raw byte 0..255\n");
+        return 2;
+    }
+    return triton_keyed_write(dev, "user/wireless_transport", &value, 1);
+}
+
+static void print_a3_decode(const uint8_t *rx, int rl) {
+    if (rl < 3 || rx[1] != 0xA3) {
+        printf("  not an A3 reply\n");
+        return;
+    }
+    int len = rx[2];
+    if (len != 0x18 || rl < 27) {
+        printf("  A3 len=0x%02X (no bond payload)\n", len);
+        return;
+    }
+    const uint8_t *key = rx + 3;
+    const uint8_t *serial = rx + 11;
+    uint32_t w0 = (uint32_t)key[0] | ((uint32_t)key[1] << 8) | ((uint32_t)key[2] << 16) | ((uint32_t)key[3] << 24);
+    uint32_t w1 = (uint32_t)key[4] | ((uint32_t)key[5] << 8) | ((uint32_t)key[6] << 16) | ((uint32_t)key[7] << 24);
+    printf("  A3 bond key=");
+    for (int i = 0; i < 8; i++) printf("%s%02X", i ? " " : "", key[i]);
+    printf(" steam_words=%X_%X serial=", w0, w1);
+    for (int i = 0; i < 16 && serial[i]; i++) putchar((serial[i] >= 32 && serial[i] < 127) ? serial[i] : '.');
+    printf("\n");
+}
+
+static void print_a3_decode_any(const uint8_t *rx, int rl) {
+    if (rl >= 2 && rx[0] == 0xA3) {
+        uint8_t shifted[64];
+        int n = rl + 1;
+        if (n > (int)sizeof shifted) n = (int)sizeof shifted;
+        memset(shifted, 0, sizeof shifted);
+        shifted[0] = 2;
+        memcpy(shifted + 1, rx, n - 1);
+        print_a3_decode(shifted, n);
+        return;
+    }
+    print_a3_decode(rx, rl);
+}
+
+static int a3_watch(IOHIDDeviceRef dev, int count, int delay_ms) {
+    if (count <= 0) count = 20;
+    if (delay_ms < 0) delay_ms = 100;
+    for (int i = 0; i < count; i++) {
+        uint8_t tx[64]; memset(tx, 0, sizeof tx);
+        tx[0] = 2; tx[1] = 0xA3; tx[2] = 0;
+        IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature, 2, tx, 64);
+        usleep(20000);
+        uint8_t rx[64]; memset(rx, 0, sizeof rx); rx[0] = 2;
+        CFIndex rl = 64;
+        IOReturn rg = kIOReturnError;
+        if (rs == kIOReturnSuccess) rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature, 2, rx, &rl);
+        printf("[%02d] A3 set=0x%08X get=0x%08X", i + 1, rs, rg);
+        if (rg == kIOReturnSuccess && rl >= 3) printf(" reply=%02X %02X %02X", rx[0], rx[1], rx[2]);
+        printf("\n");
+        if (rg == kIOReturnSuccess) print_a3_decode(rx, (int)rl);
+        if (delay_ms > 0 && i + 1 < count) usleep((useconds_t)delay_ms * 1000);
+    }
+    return 0;
+}
+
+static void a3_variant(IOHIDDeviceRef dev, const char *label, int include_rid, int set_len, int get_len, int seed_rid) {
+    uint8_t tx[64]; memset(tx, 0, sizeof tx);
+    if (include_rid) {
+        tx[0] = 2;
+        tx[1] = 0xA3;
+        tx[2] = 0;
+    } else {
+        tx[0] = 0xA3;
+        tx[1] = 0;
+    }
+    IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature, 2, tx, set_len);
+    usleep(20000);
+    uint8_t rx[64]; memset(rx, 0, sizeof rx);
+    if (seed_rid) rx[0] = 2;
+    CFIndex rl = get_len;
+    IOReturn rg = kIOReturnError;
+    if (rs == kIOReturnSuccess) rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature, 2, rx, &rl);
+    printf("\n=== A3 variant %s setLen=%d getLen=%d includeRid=%d seedRid=%d set=0x%08X get=0x%08X rl=%ld ===\n",
+           label, set_len, get_len, include_rid, seed_rid, rs, rg, (long)rl);
+    if (rg == kIOReturnSuccess) {
+        dump("reply", rx, (int)rl);
+        print_a3_decode_any(rx, (int)rl);
+    }
+}
+
+static int a3_variants(IOHIDDeviceRef dev) {
+    a3_variant(dev, "steam-short", 0, 2, 0x1a, 0);
+    a3_variant(dev, "steam-short-seeded", 0, 2, 0x1a, 1);
+    a3_variant(dev, "rid-short", 1, 3, 0x1b, 1);
+    a3_variant(dev, "rid-64", 1, 64, 64, 1);
+    return 0;
+}
+
+static int run_seq(IOHIDDeviceRef dev, int argc, char **argv, int start, int do_get) {
+    int i = start;
+    while (i < argc) {
+        if (strcmp(argv[i], "--") == 0) { i++; continue; }
+        if (strncmp(argv[i], "rid", 3) != 0) {
+            fprintf(stderr, "seq expects ridN before each command, got: %s\n", argv[i]);
+            return 2;
+        }
+        int rid = atoi(argv[i] + 3);
+        if (rid < 1 || rid > 255) { fprintf(stderr, "bad report id: %s\n", argv[i]); return 2; }
+        i++;
+        if (i >= argc) { fprintf(stderr, "seq missing cmd after rid%d\n", rid); return 2; }
+        uint8_t cmd = (uint8_t)strtol(argv[i++], NULL, 16);
+        uint8_t pl[62]; int pln = 0;
+        while (i < argc && strcmp(argv[i], "--") != 0) {
+            if (strncmp(argv[i], "rid", 3) == 0) break;
+            if (pln >= 62) { fprintf(stderr, "payload too long for cmd 0x%02X\n", cmd); return 2; }
+            pl[pln++] = (uint8_t)strtol(argv[i++], NULL, 16);
+        }
+        sc_cmd_ex(dev, rid, cmd, pl, pln, do_get);
+        usleep(20000);
+    }
+    return 0;
+}
+
+int main(int argc, char **argv) {
+    if (argc < 2) { fprintf(stderr, "usage: %s list [pidHex] | <pidHex> [--serial SERIAL] [cmdHex payloadBytes...]\n", argv[0]); return 2; }
+    if (strcmp(argv[1], "list") == 0) {
+        long wantPid = argc >= 3 ? strtol(argv[2], NULL, 16) : -1;
+        return list_devices(wantPid);
+    }
+    long wantPid = strtol(argv[1], NULL, 16);
+    const char *wantSerial = NULL;
+    long wantLoc = -1;
+    long wantUp = -1;
+    int wantIdx = -1;
+    int matchIdx = 0;
+    int arg = 2;
+    while (arg < argc) {
+        if (strcmp(argv[arg], "--serial") == 0) {
+            if (arg + 1 >= argc) { fprintf(stderr, "--serial needs a value\n"); return 2; }
+            wantSerial = argv[arg + 1];
+            arg += 2;
+        } else if (strncmp(argv[arg], "--serial=", 9) == 0) {
+            wantSerial = argv[arg] + 9;
+            arg++;
+        } else if (strcmp(argv[arg], "--loc") == 0) {
+            if (arg + 1 >= argc) { fprintf(stderr, "--loc needs a value\n"); return 2; }
+            wantLoc = strtol(argv[arg + 1], NULL, 0);
+            arg += 2;
+        } else if (strncmp(argv[arg], "--loc=", 6) == 0) {
+            wantLoc = strtol(argv[arg] + 6, NULL, 0);
+            arg++;
+        } else if (strcmp(argv[arg], "--up") == 0) {
+            if (arg + 1 >= argc) { fprintf(stderr, "--up needs a value\n"); return 2; }
+            wantUp = strtol(argv[arg + 1], NULL, 0);
+            arg += 2;
+        } else if (strcmp(argv[arg], "--idx") == 0) {
+            if (arg + 1 >= argc) { fprintf(stderr, "--idx needs a value\n"); return 2; }
+            wantIdx = strtol(argv[arg + 1], NULL, 0);
+            arg += 2;
+        } else {
+            break;
+        }
+    }
+
+    CFMutableDictionaryRef m = IOServiceMatching("IOHIDDevice");
+    io_iterator_t it;
+    if (IOServiceGetMatchingServices(kIOMainPortDefault, m, &it) != KERN_SUCCESS) { fprintf(stderr, "match fail\n"); return 1; }
+
+    IOHIDDeviceRef dev = NULL; io_object_t svc;
+    while ((svc = IOIteratorNext(it))) {
+        CFTypeRef vid = IORegistryEntryCreateCFProperty(svc, CFSTR("VendorID"), kCFAllocatorDefault, 0);
+        CFTypeRef pid = IORegistryEntryCreateCFProperty(svc, CFSTR("ProductID"), kCFAllocatorDefault, 0);
+        long v = -1, p = -1;
+        if (vid) { CFNumberGetValue((CFNumberRef)vid, kCFNumberLongType, &v); CFRelease(vid); }
+        if (pid) { CFNumberGetValue((CFNumberRef)pid, kCFNumberLongType, &p); CFRelease(pid); }
+        if (v == VALVE_VID && p == wantPid) {
+            IOHIDDeviceRef d = IOHIDDeviceCreate(kCFAllocatorDefault, svc);
+            long up = -1, maxF = -1, loc = -1;
+            if (d) {
+                reg_long(svc, "PrimaryUsagePage", &up);
+                if (up < 0) {
+                    CFTypeRef u = IOHIDDeviceGetProperty(d, CFSTR(kIOHIDPrimaryUsagePageKey));
+                    if (u) CFNumberGetValue((CFNumberRef)u, kCFNumberLongType, &up);
+                }
+                reg_long(svc, "MaxFeatureReportSize", &maxF);
+                reg_long(svc, "LocationID", &loc);
+            }
+            char serial[128] = "";
+            char product[128] = "";
+            if (d) {
+                reg_string(svc, "SerialNumber", serial, sizeof serial);
+                if (!serial[0]) hid_string(d, CFSTR(kIOHIDSerialNumberKey), serial, sizeof serial);
+                reg_string(svc, "Product", product, sizeof product);
+                if (!product[0]) hid_string(d, CFSTR(kIOHIDProductKey), product, sizeof product);
+            }
+            if (wantSerial && strcmp(wantSerial, serial) != 0) {
+                if (d) CFRelease(d);
+                IOObjectRelease(svc);
+                continue;
+            }
+            if (wantLoc >= 0 && loc != wantLoc) {
+                if (d) CFRelease(d);
+                IOObjectRelease(svc);
+                continue;
+            }
+            if (wantUp >= 0 && up != wantUp) {
+                if (d) CFRelease(d);
+                IOObjectRelease(svc);
+                continue;
+            }
+            if (wantIdx >= 0 && matchIdx++ != wantIdx) {
+                if (d) CFRelease(d);
+                IOObjectRelease(svc);
+                continue;
+            }
+            int command_capable = (up == 0xFF00) || (maxF >= 64);
+            IOReturn ro = d && command_capable ? IOHIDDeviceOpen(d, kIOHIDOptionsTypeNone) : kIOReturnNotFound;
+            if (d && command_capable && ro == kIOReturnSuccess) {
+                printf("opened Valve %04lX:%04lX node (usagePage=0x%lX maxFeature=%ld serial=%s product=%s)\n",
+                       (long)VALVE_VID, wantPid, up, maxF, serial[0] ? serial : "-", product[0] ? product : "-");
+                dev = d; IOObjectRelease(svc); break;
+            }
+            if (d && command_capable && (wantSerial || wantLoc >= 0)) {
+                fprintf(stderr, "matched Valve %04lX:%04lX usagePage=0x%lX maxFeature=%ld loc=0x%lX serial=%s but open failed: 0x%08X\n",
+                        (long)VALVE_VID, wantPid, up, maxF, loc, serial[0] ? serial : "-", ro);
+            }
+            if (d) CFRelease(d);
+        }
+        IOObjectRelease(svc);
+    }
+    IOObjectRelease(it);
+    if (!dev) {
+        if (wantSerial || wantLoc >= 0) fprintf(stderr, "no openable Valve %04lX command-capable interface matching serial=%s loc=0x%lX (quit Steam? wrong device?)\n", wantPid, wantSerial ? wantSerial : "-", wantLoc);
+        else fprintf(stderr, "no openable Valve %04lX command-capable interface (quit Steam? add --serial/--loc?)\n", wantPid);
+        return 1;
+    }
+
+    if (arg < argc && strcmp(argv[arg], "slotwrite") == 0) {
+        // Single-session dongle slot write: 0xAD 01 (enter pairing mode) -> 0xA2 <24-byte record>
+        // -> 0xAD 00, all on the SAME open handle (Steam keeps one session; pairing mode resets on
+        // close). record = [8-byte uuids][16-byte serial]; zeros-serial clears the matching slot.
+        // first arg = report id (the ALT channel is report id 3); rest = 24-byte record
+        int rid = (arg + 1 < argc) ? atoi(argv[arg + 1]) : 3;
+        uint8_t rec[24]; memset(rec, 0, sizeof rec);
+        int n = 0;
+        for (int i = arg + 2; i < argc && n < 24; i++) rec[n++] = (uint8_t)strtol(argv[i], NULL, 16);
+        uint8_t on[2] = {0x01, 0x00}, off[1] = {0x00};
+        printf("# slotwrite rid%d: AD 01 -> A2 <24> -> AD 00 (one session)\n", rid);
+        sc_cmd(dev, rid, 0xAD, on, 2);
+        sc_cmd(dev, rid, 0xA2, rec, 24);
+        sc_cmd(dev, rid, 0xAD, off, 1);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return 0;
+    } else if (arg < argc && strcmp(argv[arg], "pair") == 0) {
+        // Steamless pairing (firmware-derived): 0xAD [mode=1][channel] enters RF pairing mode;
+        // the puck then runs the pairing handshake autonomously. Channel 0 => firmware default
+        // (0x3c). Put the controller in pairing via its key-chord, then we poll 0xA3 for the bond.
+        uint8_t ch = (arg + 1 < argc) ? (uint8_t)strtol(argv[arg + 1], NULL, 16) : 0x00;
+        uint8_t start[2] = { 0x01, ch };
+        printf("# pair: entering pairing mode (0xAD 01 %02X).\n", ch);
+        printf("# >>> Now put the controller into pairing via its key-chord. Polling 0xA3...\n");
+        sc_cmd(dev, 2, 0xAD, start, 2);
+        int rc = a3_watch(dev, 120, 200);          // ~24s: watch status for the controller to bond
+        uint8_t stop[1] = { 0x00 };
+        printf("# pair: exiting pairing mode (0xAD 00).\n");
+        sc_cmd(dev, 2, 0xAD, stop, 1);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return rc;
+    } else if (arg < argc && strcmp(argv[arg], "stop") == 0) {
+        uint8_t stop[1] = { 0x00 };                // exit pairing mode
+        sc_cmd(dev, 2, 0xAD, stop, 1);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return 0;
+    } else if (arg < argc && strcmp(argv[arg], "info") == 0) {
+        // Read identity + bond status without changing anything.
+        printf("\n##### 0x83 attributes #####\n");        sc_cmd(dev, 2, 0x83, NULL, 0);
+        printf("\n##### 0xAE strings (idx 0..7) #####\n"); for (int a = 0; a < 8; a++) { uint8_t aa = (uint8_t)a; sc_cmd(dev, 2, 0xAE, &aa, 1); }
+        printf("\n##### 0xB4 wireless state #####\n");     sc_cmd(dev, 2, 0xB4, NULL, 0);
+        printf("\n##### 0xA3 status / bond #####\n");      sc_cmd(dev, 2, 0xA3, NULL, 0);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return 0;
+    } else if (arg < argc && strcmp(argv[arg], "a3watch") == 0) {
+        int count = (arg + 1 < argc) ? atoi(argv[arg + 1]) : 20;
+        int delay_ms = (arg + 2 < argc) ? atoi(argv[arg + 2]) : 100;
+        int rc = a3_watch(dev, count, delay_ms);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+        CFRelease(dev);
+        return rc;
+    } else if (arg < argc && strcmp(argv[arg], "a3variants") == 0) {
+        int rc = a3_variants(dev);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+        CFRelease(dev);
+        return rc;
+    } else if (arg < argc && strcmp(argv[arg], "triton-bond") == 0) {
+        if (arg + 3 >= argc) {
+            fprintf(stderr, "usage: %s <pidHex> [--serial S] triton-bond <0|1> <keyHex16> <controllerSerial>\n", argv[0]);
+            IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+            CFRelease(dev);
+            return 2;
+        }
+        int slot = atoi(argv[arg + 1]);
+        int rc = triton_bond_write(dev, slot, argv[arg + 2], argv[arg + 3]);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+        CFRelease(dev);
+        return rc;
+    } else if (arg < argc && strcmp(argv[arg], "triton-transport") == 0) {
+        if (arg + 1 >= argc) {
+            fprintf(stderr, "usage: %s <pidHex> [--serial S] triton-transport <slot0|slot1|slot2|rawByte>\n", argv[0]);
+            IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+            CFRelease(dev);
+            return 2;
+        }
+        int rc = triton_transport_write(dev, argv[arg + 1]);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+        CFRelease(dev);
+        return rc;
+    } else if (arg < argc && strcmp(argv[arg], "find") == 0) {     // probe all non-destructive opcodes for bond/serial data
+        // BLOCKLIST = known state-changing/destructive opcodes; never sent here.
+        static const int block[] = {0x80,0x81,0x85,0x86,0x87,0x88,0x8D,0x8E,0x8F,0x9F,
+                                     0xAD,0xAF,0xB0,0xB2,0xB3,0xEB,-1};
+        int rid = 2;
+        for (int cmd = 0x82; cmd <= 0xFE; cmd++) {
+            int blocked = 0; for (const int *b = block; *b >= 0; b++) if (*b == cmd) blocked = 1;
+            if (blocked) continue;
+            // send with empty payload; report only if SET succeeded (supported opcode)
+            uint8_t tx[64]; memset(tx, 0, sizeof tx); tx[0] = rid; tx[1] = (uint8_t)cmd; tx[2] = 0;
+            IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature, rid, tx, 64);
+            if (rs != kIOReturnSuccess) continue;             // unsupported -> stall, skip silently
+            usleep(15000);
+            uint8_t rx[64]; memset(rx, 0, sizeof rx); rx[0] = rid; CFIndex rl = 64;
+            IOReturn rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature, rid, rx, &rl);
+            if (rg == kIOReturnSuccess && rx[1] == cmd) {      // echoed -> real reply
+                char lbl[24]; snprintf(lbl, sizeof lbl, "SUPPORTED cmd 0x%02X", cmd);
+                dump(lbl, rx, (int)rl);
+            }
+        }
+    } else if (arg < argc && strcmp(argv[arg], "labels") == 0) {   // SAFE read-only label/settings sweep
+        int rid = 2;
+        printf("\n##### 0x84 GET_ATTRIBUTE_LABEL idx 0..15 #####\n");
+        for (int i = 0; i < 16; i++) { uint8_t p[2] = {(uint8_t)i, 0}; sc_cmd(dev, rid, 0x84, p, 2); }
+        printf("\n##### 0x8A GET_SETTING_LABEL idx 0..47 #####\n");
+        for (int i = 0; i < 48; i++) { uint8_t p[2] = {(uint8_t)i, 0}; sc_cmd(dev, rid, 0x8A, p, 2); }
+        printf("\n##### 0x89 GET_SETTINGS_VALUES idx 0..47 #####\n");
+        for (int i = 0; i < 48; i++) { uint8_t p[2] = {(uint8_t)i, 0}; sc_cmd(dev, rid, 0x89, p, 2); }
+    } else if (arg < argc && strcmp(argv[arg], "seq") == 0) {
+        int rc = run_seq(dev, argc, argv, arg + 1, 1);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+        CFRelease(dev);
+        return rc;
+    } else if (arg < argc && strcmp(argv[arg], "seq-noget") == 0) {
+        int rc = run_seq(dev, argc, argv, arg + 1, 0);
+        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+        CFRelease(dev);
+        return rc;
+    } else if (arg + 1 < argc && strncmp(argv[arg], "rid", 3) == 0) { // custom command on one report id
+        int rid = atoi(argv[arg] + 3);
+        if (rid < 1 || rid > 255) { fprintf(stderr, "bad report id: %s\n", argv[arg]); return 2; }
+        uint8_t cmd = (uint8_t)strtol(argv[arg + 1], NULL, 16);
+        uint8_t pl[62]; int pln = 0;
+        for (int i = arg + 2; i < argc && pln < 62; i++) pl[pln++] = (uint8_t)strtol(argv[i], NULL, 16);
+        sc_cmd(dev, rid, cmd, pl, pln);
+    } else if (arg < argc) {                   // custom single command on both command report ids
+        uint8_t cmd = (uint8_t)strtol(argv[arg], NULL, 16);
+        uint8_t pl[62]; int pln = 0;
+        for (int i = arg + 1; i < argc && pln < 62; i++) pl[pln++] = (uint8_t)strtol(argv[i], NULL, 16);
+        for (int rid = 1; rid <= 2; rid++) sc_cmd(dev, rid, cmd, pl, pln);
+    } else {                                  // safe read battery on the working channel (report id 2)
+        int rid = 2;
+        printf("\n########## string attributes 0xAE (idx 0..15) ##########\n");
+        for (int a = 0; a < 16; a++) { uint8_t aa = (uint8_t)a; sc_cmd(dev, rid, 0xAE, &aa, 1); }
+        printf("\n########## 0x83 GET_ATTRIBUTES_VALUES ##########\n");
+        sc_cmd(dev, rid, 0x83, NULL, 0);
+        printf("\n########## 0xB4 DONGLE_GET_WIRELESS_STATE (no idx, then idx 0..3) ##########\n");
+        sc_cmd(dev, rid, 0xB4, NULL, 0);
+        for (int i = 0; i < 4; i++) { uint8_t ii = (uint8_t)i; sc_cmd(dev, rid, 0xB4, &ii, 1); }
+        printf("\n########## 0x82 GET_DIGITAL_MAPPINGS (read) ##########\n");
+        sc_cmd(dev, rid, 0x82, NULL, 0);
+    }
+    IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+    CFRelease(dev);
+    return 0;
+}