فهرست منبع

picked changes from other branch seem to work

Jariullah Safi 4 ماه پیش
والد
کامیت
dafcb50fa1

+ 7 - 0
.gitignore

@@ -1,4 +1,11 @@
 .DS_Store
 .idea
 *.log
+*.json
 tmp/
+# pairtui helper binary (build from scmd.c: clang -framework IOKit -framework CoreFoundation -o scmd scmd.c)
+pairtui/scmd
+__pycache__/
+
+# generated build-provenance header (gen_version.sh); never commit
+OpenPuck/git_version.h

+ 6 - 0
OpenPuck/OpenPuck.ino

@@ -20,6 +20,7 @@
 using namespace Adafruit_LittleFS_Namespace;
 
 #include "config.h"
+#include "build_info.h"   // OPK_GIT_HASH (used to tag the one-time factory-reset build)
 #include "identity.h"
 #include "bonds.h"
 #include "radio.h"
@@ -43,6 +44,11 @@ void setup() {
   ledInit();
   rfGenSessionAddr();   // per-device unique RF session address (advertised in the host frame; isolates pucks)
   InternalFS.begin();
+#if OPK_FACTORY_RESET
+  // Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
+  // then persist normally (tracked by a git-hash tag so it does NOT wipe every boot). See config.h / config.cpp.
+  factoryResetOnce(OPK_GIT_HASH);
+#endif
   loadCfg(); g_xbox = !modeIsPuck(g_usbMode);   // load persisted config + decide USB presentation BEFORE registering interfaces
   g_active = controllerFor(g_usbMode);
 

+ 22 - 0
OpenPuck/build_info.h

@@ -0,0 +1,22 @@
+// build_info.h -- firmware build provenance, surfaced in the WebUSB panel so you can confirm EXACTLY which
+// build is flashed on a board (the git commit it was built from, and whether that working tree was dirty).
+//
+// The values come from one of two sources, in priority order:
+//   1) -D defines on the compile line, e.g. build.extra_flags += -DOPK_GIT_HASH=\"abc12345\" -DOPK_GIT_DIRTY=1
+//   2) a generated git_version.h (written by gen_version.sh before compiling), picked up via __has_include.
+// If neither is present (a bare checkout compiled by hand) the values fall back to "unknown" / not-dirty, so
+// the build always succeeds -- the panel just shows "unknown" until you generate the header.
+#pragma once
+
+#if !defined(OPK_GIT_HASH) && defined(__has_include)
+#  if __has_include("git_version.h")
+#    include "git_version.h"
+#  endif
+#endif
+
+#ifndef OPK_GIT_HASH
+#define OPK_GIT_HASH "unknown"
+#endif
+#ifndef OPK_GIT_DIRTY
+#define OPK_GIT_DIRTY 0
+#endif

+ 32 - 0
OpenPuck/config.cpp

@@ -1,6 +1,7 @@
 #include "config.h"
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
+#include <string.h>
 using namespace Adafruit_LittleFS_Namespace;
 
 uint8_t g_usbMode = 0;
@@ -64,3 +65,34 @@ void saveMode(uint8_t m){
 }
 
 void armDebugCdcNextBoot(){ g_debugCdc = 1; saveCfg(); }   // next boot keeps CDC; loadCfg() consumes it after
+
+// FULL factory wipe: erase ALL persistent storage in one shot -- cfg.bin (modes/tunables/chords), bonds.bin
+// (the paired-controller record), and anything else living in the internal LittleFS. format() reformats the
+// whole filesystem, so there's nothing left to selectively miss. The caller is expected to reboot immediately
+// (NVIC_SystemReset): on the next boot loadCfg()/loadBonds() find no files and fall back to clean defaults,
+// and the controller must be re-paired. Destructive and irreversible -- gated behind explicit confirmation at
+// every call site (serial "ERASE-ALL", WebUSB op 0x0A magic, both with on-screen warnings).
+void factoryErase(){
+  InternalFS.begin();      // ensure mounted before we reformat (no-op if already up)
+  InternalFS.format();     // wipes cfg.bin + bonds.bin + the entire FS
+}
+
+// One-time factory reset for the -DOPK_FACTORY_RESET recovery build. The point is to clear a bad config/bond
+// ONCE (at the first boot after flashing) and then behave like a normal build that PERSISTS settings -- NOT to
+// wipe on every boot (which would make the board un-configurable). We track "already reset" with a tiny tag file
+// holding the build's git hash, written AFTER the wipe (so it survives in the freshly-formatted FS):
+//   - tag missing or != this build's hash  -> a reset is pending: wipe, then stamp the tag. Next boot persists.
+//   - tag == this build's hash             -> already reset for this build: skip, boot normally.
+// Because the tag is keyed to the git hash, flashing a DIFFERENT build re-triggers the one-time wipe; re-flashing
+// the identical image does not (use the WebUSB/serial erase for an on-demand wipe). buildTag is OPK_GIT_HASH.
+#define RESET_TAG_FILE "/rsttag"
+void factoryResetOnce(const char* buildTag){
+  char tag[24] = {0};
+  { File f(InternalFS);
+    if (f.open(RESET_TAG_FILE, FILE_O_READ)) { int n=f.read((uint8_t*)tag, sizeof tag - 1); if(n>0) tag[n]=0; f.close(); } }
+  if (strncmp(tag, buildTag, sizeof tag - 1) == 0) return;   // this build already did its one-time reset -> persist
+  factoryErase();                                            // wipe cfg.bin + bonds.bin + the old tag
+  InternalFS.begin();                                        // remount the fresh FS
+  File g(InternalFS);                                        // stamp the tag so subsequent boots skip the wipe
+  if (g.open(RESET_TAG_FILE, FILE_O_WRITE)) { g.write((const uint8_t*)buildTag, strlen(buildTag)); g.close(); }
+}

+ 18 - 0
OpenPuck/config.h

@@ -16,6 +16,17 @@
 #define OPK_LOG 0
 #endif
 
+// Build-time FACTORY RESET (recovery build). 0 (default) = normal. Build with -DOPK_FACTORY_RESET=1 to produce a
+// firmware that wipes ALL persistent storage (cfg.bin + bonds.bin) ONCE -- on the first boot after flashing --
+// then behaves exactly like a normal build that persists settings. It is NOT a wipe-every-boot image: a git-hash
+// tag file (written after the wipe) records that this build already did its one-time reset, so subsequent boots
+// skip it. This makes it a safe recovery image for a bad config/bond: flash it, boot once (cleaned), keep using
+// it. Flashing a DIFFERENT build re-triggers the one-time wipe; for an on-demand wipe use the serial "ERASE-ALL"
+// or WebUSB "Factory erase". Re-pair the controller after the reset. See factoryResetOnce() in config.cpp.
+#ifndef OPK_FACTORY_RESET
+#define OPK_FACTORY_RESET 0
+#endif
+
 // ---- USB presentation modes (g_usbMode). RF poll/relay is identical across all; only USB enumeration +
 //      report mapping differ. ----
 #define MODE_STEAM   0   // Valve puck; auto-lizard when Steam closed
@@ -65,6 +76,13 @@ extern uint16_t g_loopWorstUs;
 
 void loadCfg();
 void saveCfg();
+// FULL factory wipe: reformat the internal LittleFS, erasing cfg.bin (modes/tunables/chords) AND bonds.bin
+// (paired controller). Irreversible; the caller reboots so the next boot comes up on clean defaults and the
+// controller must be re-paired. Gated behind explicit confirmation at every call site.
+void factoryErase();
+// One-time factory reset for the -DOPK_FACTORY_RESET recovery build: wipe ONCE on the first boot after flashing
+// (tracked by a git-hash tag file so it doesn't wipe on every boot), then persist normally. buildTag = OPK_GIT_HASH.
+void factoryResetOnce(const char* buildTag);
 // Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
 // the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
 void saveMode(uint8_t m);

+ 4 - 0
OpenPuck/controllers.h

@@ -22,6 +22,10 @@ public:
   virtual ~IController() {}
   virtual void begin() = 0;
   virtual void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) { (void)rep; (void)fresh; (void)bodyTlen; }
+  // Other controller->host input reports decoded from the F1 reply (NOT 0x45): the periodic power/battery status
+  // report 0x43, the status event 0x44. The real puck forwards these to Steam verbatim -- that's how Steam reads
+  // battery. rid = report id, data/n = body after the id. Default no-op (clean modes don't expose these reports).
+  virtual void onAuxReport(uint8_t rid, const uint8_t* data, uint8_t n) { (void)rid; (void)data; (void)n; }
   virtual void task() {}
   virtual void wakeEvent() {}   // a wake gesture fired while suspended; queue any post-resume input the host needs to actually wake
   virtual bool isPuck() const { return false; }

+ 51 - 11
OpenPuck/haptics.cpp

@@ -2,17 +2,30 @@
 #include "bonds.h"
 #include "config.h"
 #include "rf_link.h"
+#include <Adafruit_TinyUSB.h>   // USBDevice.suspended() -> autonomous controller power-off on host sleep
 #include <Arduino.h>
 #include <string.h>
 
 uint8_t          g_relayOp  = 0xE3;   // E3 poll
-// relay sub-TLV type byte = SET (confirmed on hardware: E3 + [len][05][rid][payload] makes the controller act
-// on it -- haptics buzz + lizard-off lands). GET is 01.
+// relay sub-TLV TYPE byte. Vestigial now: rfConnFlushRelay derives the on-air type from the report id (see the
+// CONFIRMED split there -- 0x05 for actuators/haptics <0x87, 0x01 for config/settings/LED >=0x87). Still exposed
+// over WebUSB (relaySub field) for the buzz-hunt diagnostics, but the flush path no longer reads it.
 uint8_t          g_relaySub = 0x05;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
 unsigned long    g_hapticBlockUntil = 0;   // drop Steam haptics briefly during reconnect settle
 
+// Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck (shutoff.pcapng): Steam's
+// "turn off controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The
+// dongle forwards host feature reports to the controller verbatim, so the controller acts on report 0x9F
+// directly -- we relay it the same way (E3 SET sub-TLV). The wire relay is NO-ACK, so send a small burst: a
+// single lost frame must not leave the controller on. Driven by the Steam 0x9F passthrough (puck_hid), the
+// host-suspend edge (hapticTask), and the WebUSB test button (op 0x08).
+void hapticSendShutdown(){
+  static const uint8_t OFF[4] = {0x6f,0x66,0x66,0x21};   // "off!"
+  for (uint8_t i=0; i<HAPTIC_SHUTDOWN_SHOTS; i++) relayEnqueue(0x9F, OFF, sizeof OFF);
+}
+
 static unsigned long g_haptic82Ms = 0;     // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
 static bool          g_haptic82On = false; // a non-zero 0x82 haptic is currently active (awaiting host stop)
 static unsigned long g_reinitAt = 0;       // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
@@ -147,15 +160,33 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1){
     RelayMsg &m = g_rq[g_rqTail];
     if(m.rid){                          // rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
       uint8_t rl = m.len; if(rl > RELAY_MAXP) rl = RELAY_MAXP;
-      uint8_t p[4+RELAY_MAXP]; p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=g_relaySub; p[3]=m.rid;
-      memcpy(p+4, m.data, rl);
+      // On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs (puck_sniffer): a command LANDS on
+      // the controller only with the type-01 + inner-len form  E3 [2+rl][01][rid][innerlen][data]; the legacy
+      // form  E3 [1+rl][05][rid][data]  (no inner-len) is what the long-working build shipped, and the controller
+      // DISCARDS any 0x87+ command sent that way (it reads data[0] as the length).
+      //
+      // We WHITELIST the landing form to EXACTLY the two commands that need it -- and nothing else:
+      //   * LED brightness  -- report 0x87 whose first register byte is 0x2D
+      //   * controller power-off -- report 0x9F ("off!")
+      // Everything else keeps the legacy form. This is the crux: Steam ALSO sends 0x87 passthrough writes during
+      // normal play -- the haptic-config block (reg 0x30 = IMU/subsystem enable, 0x34/0x35 = haptic amplitude). On
+      // the legacy framing the controller discards them, which is WHY the gyro keeps streaming (a landed 0x30
+      // FREEZES the IMU) and the haptics stay quiet (landed 0x34/0x35 = the connect buzz). Landing ALL 0x87 (the
+      // naive "split on >=0x87") regresses BOTH: stuck gyro + buzz. So keep this list tight.
+      bool land01 = (m.rid==0x9F) || (m.rid==0x87 && rl>=1 && m.data[0]==0x2D);
+      uint8_t p[5+RELAY_MAXP], plen;
+      if (land01){
+        p[0]=g_relayOp; p[1]=(uint8_t)(2+rl); p[2]=0x01; p[3]=m.rid; p[4]=rl; memcpy(p+5, m.data, rl); plen=(uint8_t)(5+rl);
+      } else {
+        p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=0x05; p[3]=m.rid;          memcpy(p+4, m.data, rl); plen=(uint8_t)(4+rl);
+      }
       hapLogAdd(0xFE, m.rid, m.data, rl);   // log what we actually TX to the controller (slot 0xFE) for the buzz hunt
       g_rqTail = rqNext(g_rqTail);      // copied out -> release the slot before the TX
       // s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
       // dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, the
       // controller sends nothing back, so don't burn the full ~1.2ms reply window (that was halving the poll
       // rate during haptics).
-      rfConnTx(ch,s1,p,(uint8_t)(4+rl),80);
+      rfConnTx(ch,s1,p,plen,80);
       return;                           // ONE relay per poll cycle (matches the real puck's pacing)
     }
     g_rqTail = rqNext(g_rqTail);
@@ -167,11 +198,12 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1){
 // haptic registers (30/07/08/31/52, 18/2e/34/35). We replay it to recover from the latched-buzz the
 // controller falls into across a reconnect. Brightness (0x87 reg 2d) is deliberately OMITTED so we don't
 // stomp the LED. Enqueued onto the normal relay (drains in the poll cadence).
+//
+// NOTE: the 0x87 frames below go out on the LEGACY (type-05) framing -- the relay whitelist only lands 0x87
+// when its first register is brightness 0x2D -- so the controller DISCARDS them, exactly as on the long-working
+// build. They are kept verbatim so this path matches that build byte-for-byte; the effective re-init is the
+// three 0x81 frames, which is what cleared the buzz there. (Landing the 0x30 here would freeze the gyro.)
 void hapticReinit(){
-  // The FULL sequence Steam sends when it (re)takes control / on a Steam-button press -- captured on hardware
-  // and proven to clear a stuck haptic. Replayed verbatim and in order (no trimming this time: the brightness
-  // write 0x87[2d] and the two 7-byte 0x81 frames are part of what Steam sends, and dropping them may be why
-  // the trimmed version didn't always clear the connect buzz).
   static const uint8_t H30[]={0x30,0x00,0x00,0x07,0x07,0x00,0x08,0x07,0x00,0x31,0x02,0x00,0x52,0x03,0x00};
   static const uint8_t H18[]={0x18,0x00,0x00,0x2e,0x00,0x00,0x34,0xff,0xff,0x35,0xff,0xff,0x34,0xff,0xff};
   static const uint8_t H35[]={0x35,0xff,0xff,0x2e,0x00,0x00};
@@ -183,8 +215,6 @@ void hapticReinit(){
   relayEnqueue(0x87, H35, sizeof H35);
   relayEnqueue(0x81, T81A, sizeof T81A);
   relayEnqueue(0x81, T81B, sizeof T81B);
-  // NOTE: the brightness write (0x87 2d) that Steam includes is deliberately OMITTED -- it's an LED setting,
-  // not haptic, and this re-init now fires often (after every haptic-idle), so we must not flicker the LED.
 }
 void hapticInit(){
   g_rqHead = g_rqTail = 0; g_haptic82On=false;
@@ -224,6 +254,16 @@ void hapticTask(){
     hapticReinit();
     g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+HAPTIC_REINIT_GAP_MS) : 0;
   }
+  // Controller power-off on host SLEEP: send the power-off command (0x9F "off!") the instant the USB bus
+  // suspends, like the real puck. BUT only when USB power (VBUS) is still present -- i.e. a genuine host sleep,
+  // NOT a cable unplug. Pulling the dongle ALSO trips the suspend edge (in the brief window it runs on residual
+  // power), and we must NOT kill the controller then; it should only power off on a shutdown command or a real
+  // host sleep. VBUSDETECT is 1 while the cable still delivers 5V, 0 once unplugged. wasSusp starts true so a
+  // boot-into-suspended state never false-fires.
+  static bool wasSusp=true; bool susp=USBDevice.suspended();
+  bool vbus = (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
+  if (susp && !wasSusp && vbus) hapticSendShutdown();
+  wasSusp=susp;
   // Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
   // are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
   // click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.

+ 4 - 0
OpenPuck/haptics.h

@@ -39,6 +39,9 @@
 // during use (a buzz that starts seconds after connect and won't self-clear). Long enough not to fire between
 // rapid in-game haptics; short enough to clear a stuck buzz soon after the user pauses.
 #define HAPTIC_CLEAR_IDLE_MS 1200u
+// Controller power-off: hapticSendShutdown() relays Steam's confirmed "turn off controller" command (feature-0x01
+// cmd 0x9F, payload "off!" -- captured from the real puck). Sent as a small burst because the RF relay is NO-ACK.
+#define HAPTIC_SHUTDOWN_SHOTS 3u
 
 // ---- relay queue (written by puck_hid.cpp, mode_xinput.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
@@ -50,6 +53,7 @@ extern uint8_t          g_relaySub;  // relay sub-TLV type byte = SET
 extern volatile uint8_t g_testHaptic;// 't<n>' injects n test haptics for the buzz hunt
 extern volatile uint8_t g_hapticStop;// pending haptic-STOP frames to relay (kill a latched whine)
 extern unsigned long    g_hapticBlockUntil;
+void hapticSendShutdown();           // relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
 
 // ---- diagnostic capture (compiled in only when OPK_LOG): a ring of recent host->controller commands +
 //      link/TX markers, dumped over WebUSB. No-ops in a production build so call sites vanish. ----

+ 16 - 0
OpenPuck/puck_hid.cpp

@@ -108,6 +108,10 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
   uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
   const uint8_t *pl = b + 2; uint16_t pln = (n >= 2) ? n - 2 : 0;
   if (cmd >= 0x80 && cmd <= 0x89) g_steamAliveMs = millis();   // any Steam settings/haptic/LED report (incl. the 0x87 lizard-off heartbeat) -> Steam present, forward gamepad, suppress auto-lizard
+  // Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
+  // confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown()
+  // bursts it for NO-ACK reliability (the single hook the test button + host-suspend also drive).
+  if (rid == 1 && cmd == 0x9F) hapticSendShutdown();
   if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
     hapLogAdd((uint8_t)slot, cmd, b, n);   // capture EVERY relayed feature-1 command for the WebUSB capture view
                                            // (haptics, LED SET_LED_COLOR, 0x87 settings, 0x9F power-off). The log
@@ -221,6 +225,18 @@ void SteamPuckController::onReport45(const uint8_t* rep, bool fresh, uint8_t bod
   }
 }
 
+// Forward the controller's NON-input status reports (0x43 power/battery, 0x44) to Steam verbatim -- the real
+// puck does this and it's how Steam reads battery. OpenPuck used to drop everything but 0x45, so Steam never got
+// the power report (-> default/unknown battery). Same host-asleep / post-resume gating as 0x45; no lizard path
+// (status reports aren't input, so they forward regardless of the lizard decision).
+void SteamPuckController::onAuxReport(uint8_t rid, const uint8_t* data, uint8_t n){
+  if (USBDevice.suspended()) return;
+  if (g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS) return;
+  if (g_connSlot < 0 || g_connSlot >= NSLOT) return;
+  if (g_slot[g_connSlot].used && hid[g_connSlot].ready())
+    hid[g_connSlot].sendReport(rid, data, n);
+}
+
 // ---- wake nudge: a bare USB resume signal is NOT enough to wake some hosts (Windows in particular) -- they
 // only wake when actual mouse/keyboard input follows. So on a deliberate wake gesture we play a HARMLESS mouse
 // JIGGLE (move a few px right, then back -- NET ZERO cursor, NO button): real mouse activity wakes the host,

+ 1 - 0
OpenPuck/puck_hid.h

@@ -22,6 +22,7 @@ class SteamPuckController : public IController {
 public:
   void begin() override;
   void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) override;
+  void onAuxReport(uint8_t rid, const uint8_t* data, uint8_t n) override;   // forward controller 0x43/0x44 status to Steam
   void task() override;
   void wakeEvent() override;   // queue the harmless post-resume wake nudge (mouse jiggle + Ctrl tap)
   bool isPuck() const override { return true; }

+ 3 - 2
OpenPuck/radio.cpp

@@ -27,7 +27,7 @@ void rfGenSessionAddr(){
      g_sessBase[2]==g_rfBase[2] && g_sessBase[3]==g_rfBase[3]) g_sessBase[0]^=0x80;   // collided with "ibex"
 }
 
-uint8_t  rfrx[80], rftx[80];
+uint8_t  rfrx[100], rftx[100];
 uint32_t g_rfRxCount = 0;
 
 // HYBRID puck-link config (firmware-derived, definitive). DECODED from real-puck capture: puck TX = Ble_2Mbit,
@@ -37,7 +37,8 @@ uint8_t  g_whiteiv = 37;
 uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
 uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
 uint8_t  g_statlen = 0x20;
-uint32_t g_pcnf1   = 0x01040040;                  // ENDIAN=Big, BALEN4, MAXLEN64 - CRC-VALIDATED
+uint32_t g_pcnf1   = 0x01040060;                  // ENDIAN=Big, BALEN4, MAXLEN=96 (was 64 -> truncated the controller's
+                                                  // 66-byte 0x43-battery-augmented F1 -> CRC-fail -> battery never arrived)
 uint32_t g_crcpoly = 0x11021UL;
 uint16_t g_crccnf  = 0x2;                          // CRC16, address included
 uint8_t  g_pid     = 0;

+ 1 - 1
OpenPuck/radio.h

@@ -32,7 +32,7 @@ extern uint8_t  g_sessBase[4];
 extern uint8_t  g_sessPrefix;
 void rfGenSessionAddr();
 
-extern uint8_t  rfrx[80], rftx[80];  // RADIO DMA buffers
+extern uint8_t  rfrx[100], rftx[100];  // RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
 extern uint32_t g_rfRxCount;
 
 // ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----

+ 17 - 2
OpenPuck/rf_link.cpp

@@ -40,6 +40,7 @@ uint16_t g_pollsps = 0;   // polls/s (GET+relay TXs) last second -- distinguishe
 uint16_t g_pollPeriodUs = 0;   // MEASURED avg us between GET-poll fires (vs intended g_pollUs) -- ground truth
 static uint32_t g_pollDtSum = 0; static uint16_t g_pollDtCnt = 0;
 volatile uint8_t g_linkRssi = 0;   // smoothed |dBm| of the controller's replies (0 = none yet)
+volatile uint8_t g_battery  = 0;   // battery % from the controller's report 0x43 (body[1]); 0 = none yet
 
 // ---- internal counters / timers ----
 static uint8_t  g_e3pid = 0;
@@ -131,7 +132,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, u
   if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
     bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
     if(!crcok){ g_stCrc++; g_qosBad++; }                // reply arrived but CRC failed -> RF quality (channel/interference)
-    if (crcok && rxlen && rxlen<=64){                   // F1 report ~46B, so allow up to MAXLEN
+    if (crcok && rxlen && rxlen<=96){                   // F1 input ~46B; 0x43-augmented ~66B -> allow up to MAXLEN(96)
       uint8_t rtype=rfrx[2];                            // reply type byte (proven offset from captures)
       // Only OUR controller's replies (F-type: 0xF1 input / 0xF2 disconnect / 0xF3 status) mark the link
       // alive. Every OpenPuck shares the same RF address "ibex" + CRC config, and a puck transmits host-frame
@@ -187,7 +188,13 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, u
             g_in.lt=trigU8(u16off(rep,4));   g_in.rt=trigU8(u16off(rep,6));   // for the Switch digital-trigger threshold
             g_in.lpx=(int16_t)s16off(rep,16); g_in.lpy=(int16_t)s16off(rep,18);
             g_in.rpx=(int16_t)s16off(rep,22); g_in.rpy=(int16_t)s16off(rep,24);
-            imuFrom45(rep, &g_in.ax,&g_in.ay,&g_in.az,&g_in.gx,&g_in.gy,&g_in.gz);
+            // IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report was
+            // actually received -- bounded by `end` (the received length), NOT sizeof(rfrx). The outer gate is
+            // tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
+            // one whose IMU tail was lost) still passes it; without this guard imuFrom45 would read STALE bytes
+            // past the received data and clobber g_in's gyro/accel. On a short frame, hold the last good IMU.
+            if(tlen>=46 && (size_t)(idx+2)+46<=(size_t)end)
+              imuFrom45(rep, &g_in.ax,&g_in.ay,&g_in.az,&g_in.gx,&g_in.gy,&g_in.gz);
             // Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in.buttons stays
             // intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
             if((bb&CHORD_BACK4)==CHORD_BACK4)
@@ -197,6 +204,14 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, u
             if(g_active) g_active->onReport45(rep, fresh, tlen);
             lastRep=rep; lastTlen=tlen;
           }
+          else if(ttype==6 && (size_t)(idx+2)+tlen<=sizeof(rfrx) && tlen>=2 && (rfrx[idx+2]==0x43 || rfrx[idx+2]==0x44)){
+            // Controller STATUS reports (0x43 = periodic power/battery, ~every 2s; 0x44 = status event). The real
+            // puck forwards these to the host verbatim -- that's how Steam reads battery -- but OpenPuck used to
+            // drop everything but 0x45. Forward them (onAuxReport), and snapshot the battery % for the WebUSB panel.
+            const uint8_t* rep=&rfrx[idx+2];                 // [rid][body...]
+            if(rep[0]==0x43 && tlen>=3) g_battery=rep[2];    // 0x43 body[1] (~0x5e=94) reads as battery % (sniff-derived)
+            if(g_active) g_active->onAuxReport(rep[0], rep+1, (uint8_t)(tlen-1));
+          }
           idx+=tlen+2;
         }
         // mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.

+ 3 - 0
OpenPuck/rf_link.h

@@ -50,6 +50,9 @@ extern volatile uint8_t g_linkRssi;
 // dB to subtract from our raw RSSI magnitude before reporting, to match the real puck's close-range -35dBm
 // (compensates the Pro Micro antenna vs Valve's front-end). Tune against one known-distance reading.
 #define RSSI_DBM_OFFSET 20
+// Battery percent from the controller's periodic report 0x43 (body[1]); 0 = none yet. Surfaced in the WebUSB
+// panel; the raw 0x43 report is also forwarded to Steam verbatim (puck_hid onAuxReport) so Steam reads it itself.
+extern volatile uint8_t g_battery;
 
 // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
 // rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect

+ 6 - 1
OpenPuck/serial_console.cpp

@@ -8,6 +8,7 @@
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <stdlib.h>
+#include <string.h>
 
 // CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds, etc.
 // NOTE: command order matters -- this is an else-if chain, so the FIRST matching letter wins. A couple of
@@ -17,7 +18,11 @@ void serialConsolePoll(){
   while (Serial.available()){
     char c=Serial.read();
     if (c=='\n'||c=='\r'){ line[li]=0;
-      if (line[0]=='l') rfListenStart();
+      // FULL factory wipe -- requires the exact word "ERASE-ALL" (not a single letter) so it can't be fat-
+      // fingered. Reformats the internal FS (cfg.bin + bonds.bin gone), then reboots into clean defaults; the
+      // controller must be re-paired afterwards.
+      if (!strcmp(line,"ERASE-ALL")){ Serial.println("# ERASING ALL persistent storage (config + bonds)..."); factoryErase(); Serial.println("# done -- rebooting into clean defaults; re-pair the controller"); delay(40); NVIC_SystemReset(); }
+      else if (line[0]=='l') rfListenStart();
       else if (line[0]=='B'){ g_rfBeacon=!g_rfBeacon; g_rfListen=false; Serial.printf("# beacon %s ch%u\n",g_rfBeacon?"ON":"off",g_rfCh); }
       else if (line[0]=='L'){ g_plen=strtol(line+1,0,16); Serial.printf("# plen=%02X\n",g_plen); }
       else if (line[0]=='1'){ g_s1incl=!g_s1incl; Serial.printf("# s1incl=%u\n",g_s1incl); }

+ 21 - 5
OpenPuck/webusb_config.cpp

@@ -4,6 +4,7 @@
 #include "rf_link.h"
 #include "haptics.h"
 #include "puck_hid.h"
+#include "build_info.h"
 #include <Arduino.h>
 #include <string.h>
 
@@ -13,14 +14,15 @@ Adafruit_USBD_WebUSB usb_web;
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
-//                [pollPeriod_lo][pollPeriod_hi][logEnabled]
-#define WB_PAYLEN 36
+//                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
+//                [gitDirty][gitHash 12B ASCII, NUL-padded]
+#define WB_PAYLEN 51
 static void webusbSendBlob(){
   if(!usb_web.connected()) return;
   bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
   uint8_t p[2+WB_PAYLEN];
   p[0]=0xA5; p[1]=WB_PAYLEN;
-  p[2]=2;                          // protocol version (2 = chordBtn[3] in blob)
+  p[2]=4;                          // protocol version (4 = +battery/rssi/git; 2 = chordBtn[3])
   p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=0 /*rsvd: ex-padSmooth*/; p[7]=g_abSwap;
   p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
   p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
@@ -36,6 +38,14 @@ static void webusbSendBlob(){
   p[32]=g_loopWorst; p[33]=(uint8_t)g_loopWorstUs; p[34]=(uint8_t)(g_loopWorstUs>>8);
   p[35]=(uint8_t)g_pollPeriodUs; p[36]=(uint8_t)(g_pollPeriodUs>>8);   // measured actual poll period (vs intended 4000)
   p[37]=OPK_LOG;                                                       // logging build? panel shows/hides its log UI
+  p[38]=g_battery;                                                     // controller battery % (report 0x43); 0=unknown
+  p[39]=g_linkRssi;                                                    // RAW signal strength |dBm| (0=no sample)
+  // Build provenance: which git commit this firmware was built from, and whether the tree was dirty. Lets the
+  // panel confirm exactly what's flashed. Injected at build time (build_info.h / gen_version.sh); "unknown" if
+  // the version header wasn't generated.
+  p[40]=OPK_GIT_DIRTY ? 1 : 0;
+  memset(&p[41],0,12);                                                 // 12B ASCII git hash, NUL-padded
+  { const char* h=OPK_GIT_HASH; for(uint8_t i=0;i<12 && h[i];i++) p[41+i]=(uint8_t)h[i]; }
   usb_web.write(p,sizeof p); usb_web.flush();
 }
 #if OPK_LOG
@@ -70,8 +80,8 @@ void webusbPoll(){
     // process complete commands from the front of buf
     for(;;){
       if(n==0) break;
-      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : 1;   // 0x05 carries a value byte
-      if(op<0x01 || op>0x07){ // resync: drop one byte
+      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : (op==0x0A)?4 : 1;   // 0x05 carries a value byte; 0x0A carries a 3-byte magic
+      if(op<0x01 || op>0x0A){ // resync: drop one byte
         memmove(buf,buf+1,--n); continue;
       }
       if(n<need) break;                      // wait for more bytes
@@ -81,6 +91,12 @@ void webusbPoll(){
       else if(op==0x06){ webusbDrainCapture(); }          // drain entries since the rewind (panel polls this)
 #endif
       else if(op==0x07){ hapticReinit(); }                // clear a stuck/latched haptic buzz on the controller (functional)
+      else if(op==0x08){ hapticSendShutdown(); }          // TEST: trigger the controller power-off (same path Steam 0x9F / host-suspend use)
+      else if(op==0x0A){                                  // FULL factory wipe: erase cfg.bin + bonds.bin, reboot to clean defaults.
+        // Guarded by a 3-byte magic ("ERS") so a stray/corrupt byte can never trigger it; the panel double-
+        // confirms before sending. Irreversible -- the controller must be re-paired afterwards.
+        if(buf[1]==0x45 && buf[2]==0x52 && buf[3]==0x53){ usb_web.flush(); factoryErase(); delay(40); NVIC_SystemReset(); }
+      }
       else if(op==0x02){
         uint8_t f=buf[1], v=buf[2];
         bool persist=true;   // every settable field persists (poll rate is no longer settable)

+ 1 - 1
README.md

@@ -21,7 +21,7 @@ There are two fundamental problems with the controller:
 
 [![OpenPuck Intro](https://img.youtube.com/vi/gSaqO9oqq9s/0.jpg)](https://www.youtube.com/watch?v=gSaqO9oqq9s)
 
-OpenPuck uses a [Pro Micro NRF52840](https://www.amazon.com/dp/B0GSZ7FD6T) ($8 on Amazon, possibly cheaper elsewhere) which uses a radio similar to the one being used by the controller and the puck. Once the arduino sketch is uploaded it emulates the puck over USB to Steam by default and allows pairing the controller by having both the copycat and the controller being plugged into USB together on the same computer. Note that the lizard mode for when Steam is off might not be 1:1. Obviously it can't charge the controller and cannot pair using the pogo pins method used by the official puck.
+OpenPuck uses a [Pro Micro NRF52840](https://www.amazon.com/dp/B0GSZ7FD6T) ($8 on Amazon, possibly cheaper elsewhere) which uses a radio similar to the one being used by the controller and the puck. Once the arduino sketch is uploaded it emulates the puck over USB to Steam by default and allows pairing the controller normally (almost, the lizard mode for when Steam is off might not be 1:1).
 
 At any point you can hold all 4 back buttons and press X to switch over to ***Xbox mode** which maps all canonical inputs to their expected counterparts (plus L4 -> LB, L5 -> L3, etc which are configurable). In this mode the right trackpad acts as a mouse but at present this only works in Android and SteamOS.
 

+ 25 - 3
docs/BUILD_AND_DEPLOY.md

@@ -47,11 +47,14 @@ arduino-cli core install adafruit:nrf52 --additional-urls https://adafruit.githu
 From the repository root:
 
 ```bash
+./gen_version.sh   # optional: stamp the build with the current git hash + dirty flag (shown in the panel)
 arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" OpenPuck
 ```
 
 This sketch requires `CFG_TUD_HID=4` because Steam mode exposes four HID interfaces.
 
+**Build provenance.** `gen_version.sh` writes `OpenPuck/git_version.h` with the current commit's short hash and a dirty flag (1 if the working tree has any tracked change or untracked file). The firmware bakes these in and reports them over WebUSB, so the panel's **Build (git)** field shows exactly which commit is flashed and whether it was a clean checkout — handy for confirming what's on a board. The header is git-ignored; if you skip the script the build still succeeds and the panel shows `unknown`. You can also inject the values directly instead of using the script, e.g. append ``-DOPK_GIT_HASH=\"$(git rev-parse --short=8 HEAD)\" -DOPK_GIT_DIRTY=0`` to `build.extra_flags`.
+
 ## 5. Upload the firmware
 
 ### macOS / Linux
@@ -92,7 +95,26 @@ arduino-cli upload `
 
 Replace `COM5` with the actual board port.
 
-## 6. Pair and verify
+## 6. Factory reset (erase persistent storage)
+
+Re-flashing firmware does **not** erase the board's internal LittleFS. The paired-controller bond (`bonds.bin`) and every saved setting (`cfg.bin`: USB mode, chord assignments, back-paddle map, mouse sensitivity) survive a fresh build and upload. To bring a board up in a truly clean state — a new unit, a hand-me-down with a stale bond, or a corrupted config — wipe the filesystem with one of:
+
+- **Recovery build (`-DOPK_FACTORY_RESET=1`):** a firmware that wipes all persistent storage **once, on the first boot after flashing**, then behaves like a normal build that persists settings. Use it to recover a board from a bad config/bond without a console or panel:
+
+  ```bash
+  ./gen_version.sh   # recommended: gives the build a distinct git hash (see below)
+  arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DOPK_FACTORY_RESET=1" OpenPuck
+  ```
+
+  It is **not** a wipe-every-boot image: after the one-time reset it stamps a tag file with the build's git hash, so subsequent boots skip the wipe and persist normally. Flashing this same image again won't re-wipe (the tag matches); flashing a **different** build (different git hash) re-triggers the one-time reset. For an on-demand wipe at any time, use the WebUSB button or serial `ERASE-ALL` below. Re-pair the controller after a reset.
+- **WebUSB panel (any mode):** open the panel (§8), and in the maintenance card click **⚠ Factory erase**. Confirm the two warning dialogs and type `ERASE` when prompted. The board reformats its filesystem and reboots to factory defaults. This works in every USB mode.
+- **Serial console (CDC):** connect to the board's serial port at 115200 baud and send the line `ERASE-ALL` (exact, all caps). Same effect: reformat + reboot.
+
+Both reformat the entire internal filesystem (`cfg.bin` + `bonds.bin` and anything else), so the action is irreversible and the controller must be **re-paired afterwards** (see §7).
+
+Note on the serial method: puck (Steam/Lizard) mode drops the CDC console by default to free a USB endpoint for the wake-mouse interface, so the serial port may not be present in that mode. Either arm the one-shot debug CDC first (panel debug-CDC toggle / `D` console command, which keeps the console for the next boot), or just use the WebUSB **Factory erase** button, which is available in all modes.
+
+## 7. Pair and verify
 
 1. Flash the board.
 2. Plug it into the host.
@@ -100,7 +122,7 @@ Replace `COM5` with the actual board port.
 4. Pair the controller to one of the bond slots.
 5. Verify that the slot returns `0xB4 = 0x02` when connected.
 
-## 7. Run the WebUSB app locally
+## 8. Run the WebUSB app locally
 
 WebUSB requires a secure context. `http://localhost` qualifies.
 
@@ -130,7 +152,7 @@ Open:
 http://localhost:8008
 ```
 
-## 8. Known operational details
+## 9. Known operational details
 
 - Chrome or Edge is required for WebUSB.
 - The board re-enumerates on USB mode switches.

+ 27 - 10
docs/PROTOCOL.md

@@ -78,22 +78,39 @@ Returns:
 
 ### 3.3 Host-to-controller relay
 
-When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the controller as an `E3` frame containing a SET sub-TLV:
+When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the controller as an `E3` frame
+containing a sub-TLV. There are two on-air forms:
 
 ```text
-[E3][sub_len][0x05][report_id][payload...]
+legacy   [E3][1+len][0x05][report_id][data...]              (no inner-len)
+landing  [E3][2+len][0x01][report_id][innerlen][data...]    (type 01, KEEPS inner-len; controller ACTS on it)
 ```
 
-`0x05` is the confirmed SET subtype. `0x01` is the GET subtype.
+A command only **lands** on the controller in the `landing` form (type `0x01` — the same type byte the GET poll
+uses, `E3 02 01 45 <param>`; the `report_id` selects the action, `innerlen` is the report's own `[len]`). In the
+`legacy` form the controller discards any `0x87+` command (it reads the first data byte as the length).
 
-This is how settings writes, haptics, and lizard-disable requests reach the controller.
+**OpenPuck sends the `legacy` form for everything EXCEPT a tight whitelist** — only LED brightness (`0x87` whose
+first register byte is `0x2D`) and controller power-off (`0x9F`) use the `landing` form:
 
-The relay carries the command's **declared length** (the `[len]` byte of the feature payload), up to 60 bytes —
-the most one RF frame fits (`[E3][len][05][rid]` + payload ≤ MAXLEN 64). Multi-register `0x87` settings blocks
-(e.g. LED brightness) and calibration writes exceed 18 bytes, so any shorter cap silently corrupts them and the
-settings never land on the controller. Relays are staged in a small ring (not a single buffer): the USB SET
-callbacks run in ISR context and Steam sends settings/calibration as back-to-back bursts, so a single pending
-slot both drops reports and can be torn mid-flush. One queued relay is emitted per poll cycle.
+```text
+haptic on    E3 04 05 82 01 01 F7              (report 0x82, legacy)
+brightness   E3 05 01 87 03 2D <val> 00        (report 0x87 reg 0x2D, LANDING -- whitelisted)
+power-off    E3 06 01 9F 04 6F 66 66 21        (report 0x9F "off!", LANDING -- whitelisted)
+Steam 0x87 cfg  E3 .. 05 87 ..                 (haptic/IMU config: legacy -> DISCARDED, by design)
+```
+
+**Why the whitelist is tight, not "land everything `0x87+`":** Steam continuously writes its own `0x87`
+passthrough during normal play — the haptic-config block including reg `0x30` (IMU/subsystem enable) and
+`0x34/0x35` (haptic amplitude). If those *land*, reg `0x30` **freezes the controller's gyro stream** and
+`0x34/0x35` drive the **connect-time buzz**. The long-working build never landed any `0x87`, so neither happened.
+Landing the whole class regresses both (stuck gyro + buzz); landing only the brightness register and power-off
+adds those two features while leaving Steam's config writes discarded exactly as before.
+
+The relay carries the command's declared length, up to 60 bytes — the most one RF frame fits. Relays are staged
+in a small ring (not a single buffer): the USB SET callbacks run in ISR context and Steam sends
+settings/calibration as back-to-back bursts, so a single pending slot both drops reports and can be torn
+mid-flush. One queued relay is emitted per poll cycle.
 
 ### 3.4 Connection presentation (input reports `0x79` / `0x7B`)
 

+ 33 - 0
docs/index.html

@@ -79,6 +79,8 @@
       <h2>Link status</h2>
       <div class="statgrid">
         <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
+        <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
+        <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
         <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
         <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
         <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
@@ -86,11 +88,19 @@
         <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
         <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
         <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
+        <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
       </div>
       <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
       <div class="row" style="margin:10px 0 0"><button id="hapClear">Clear stuck buzz</button>
         <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
       </div>
+      <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
+        <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path Steam's "turn off controller" and host-suspend use. Watch the link status to see it disconnect.</span>
+      </div>
+      <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
+        <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
+        <span class="note" style="flex:1"><strong style="color:#ff9bb0">Danger:</strong> wipes <em>all</em> persistent storage on the copycat — the paired controller bond <em>and</em> every saved setting (mode, chords, back-paddle map, sensitivity). The copycat reboots to factory defaults and you must <strong>re-pair the controller</strong>. This cannot be undone.</span>
+      </div>
     </div>
 
     <div class="card" id="mouseCard">
@@ -217,7 +227,11 @@ function applyBlob(p){
   for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
   // status
+  const battery=(p.length>36?p[36]:0);                    // controller battery % (report 0x43); 0 = unknown
+  const rssi=(p.length>37?p[37]:0);                       // RAW signal strength |dBm| (0 = no sample)
   $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+  $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
+  $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
   $("#stSlot").textContent = slot===0xFF? "—" : slot;
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
@@ -225,6 +239,13 @@ function applyBlob(p){
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
+  // build provenance (protocol v4+): dirty flag at p[38], 12-byte NUL-padded git hash at p[39..50]. Independent
+  // of link state. Older firmware sends a shorter blob -> stays "—".
+  const gitDirty=(p.length>38?p[38]:0); let gitHash="";
+  for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
+  $("#stBuild").innerHTML = gitHash
+    ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
+    : "—";
   // sliders (only if not being dragged)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
@@ -321,6 +342,18 @@ $("#capDl").onclick=downloadCap;
 $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
+$("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
+$("#factoryErase").onclick=async()=>{
+  if(!dev){ log("not connected"); return; }
+  // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.
+  if(!confirm("Factory erase?\n\nThis wipes ALL persistent storage on the copycat:\n  • the paired-controller bond (you'll have to re-pair)\n  • every saved setting (mode, chords, back paddles, sensitivity)\n\nThe copycat reboots to factory defaults. This CANNOT be undone.")) return;
+  if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
+  const typed=prompt('Final confirmation — type  ERASE  (all caps) to wipe everything:');
+  if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
+  // op 0x0A + magic "ERS" (0x45,0x52,0x53) -- firmware reformats the FS and reboots; the device drops off USB.
+  await send([0x0A,0x45,0x52,0x53]);
+  log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
+};
 $("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 for(const id of ["mDiv","mFric"]){

+ 236 - 0
docs/sniffer.html

@@ -0,0 +1,236 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>OpenPuck RF Sniffer</title>
+<style>
+  :root{ --bg:#0d1117; --fg:#c9d1d9; --mut:#8b949e; --acc:#2f81f7; --up:#238636; --dn:#8b949e; --warn:#d29922; --card:#161b22; --line:#30363d; }
+  *{ box-sizing:border-box; }
+  body{ margin:0; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg); color:var(--fg); }
+  header{ padding:14px 18px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
+  h1{ font-size:15px; margin:0; font-weight:600; }
+  .pill{ padding:2px 9px; border-radius:11px; font-size:11px; }
+  .pill.up{ background:#1f3d2a; color:#3fb950; } .pill.dn{ background:#21262d; color:var(--mut); }
+  button{ background:#21262d; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
+  button:hover{ border-color:var(--acc); } button:disabled{ opacity:.4; cursor:default; }
+  button.cap{ background:#3d1f1f; border-color:#6e2c2c; } button.cap.on{ background:#8b1d1d; color:#fff; }
+  input[type=number]{ width:54px; background:#0d1117; color:var(--fg); border:1px solid var(--line); border-radius:5px; padding:4px; font:inherit; }
+  .row{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
+  .note{ color:var(--mut); font-size:11px; }
+  .stats{ padding:8px 18px; border-bottom:1px solid var(--line); display:flex; gap:22px; flex-wrap:wrap; }
+  .stats b{ color:#fff; font-weight:600; }
+  #wrap{ padding:0; }
+  table{ width:100%; border-collapse:collapse; }
+  th,td{ text-align:left; padding:3px 8px; border-bottom:1px solid #1c2128; white-space:nowrap; }
+  th{ position:sticky; top:0; background:var(--card); color:var(--mut); font-weight:600; font-size:11px; }
+  td.hex{ white-space:pre; color:#9ad; font-family:inherit; }
+  tr.pc td.dir{ color:#d29922; } tr.cp td.dir{ color:#3fb950; }
+  tr.bad{ opacity:.5; }
+  #tbl{ max-height:calc(100vh - 200px); overflow:auto; }
+  #log{ padding:6px 18px; color:var(--mut); font-size:11px; white-space:pre-wrap; max-height:90px; overflow:auto; border-top:1px solid var(--line); }
+  code{ color:#9ad; }
+</style>
+</head>
+<body>
+<header>
+  <h1>OpenPuck RF Sniffer</h1>
+  <button id="connect">Connect sniffer</button>
+  <span id="state" class="pill dn">disconnected</span>
+  <button id="cap" class="cap" disabled>● Start Cap</button>
+  <button id="clear" disabled>Clear</button>
+  <button id="dl" disabled>Download JSON</button>
+  <button id="dltxt" disabled>Download text</button>
+  <span style="flex:1"></span>
+  <button id="reacq" disabled>Re-acquire</button>
+  <button id="survey" disabled>Survey ch2</button>
+  <label class="note">lock ibex <input type="text" id="tgtIbex" placeholder="EF7171B4" style="width:84px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:4px;font:inherit"></label>
+  <button id="setTgt" disabled>Lock</button>
+  <label class="note">pin ch <input type="number" id="pinCh" min="1" max="80" placeholder="auto"></label>
+  <button id="setch" disabled>Set</button>
+  <label class="note">pin session <input type="text" id="pinSess" placeholder="b0 b1 b2 b3 pfx ch (hex)" style="width:170px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:4px;font:inherit"></label>
+  <button id="setsess" disabled>Pin</button>
+</header>
+<div class="stats">
+  <span>link: <b id="acq">—</b></span>
+  <span>rx: <b id="rx">0 B</b></span>
+  <span>session: <b id="sess">—</b></span>
+  <span>E1 advertises: <b id="adv">—</b></span>
+  <span>captured: <b id="nrec">0</b></span>
+  <span>P→C: <b id="npc">0</b></span>
+  <span>C→P: <b id="ncp">0</b></span>
+  <span>crc-bad: <b id="nbad">0</b></span>
+  <span>shown: <b id="nshown">0</b></span>
+</div>
+<div class="stats" style="border-top:none;padding-top:0">
+  <label class="note"><input type="checkbox" id="fHideRoutine"> hide input/polls (49-byte F1 + bare E3)</label>
+  <label class="note">dir <select id="fDir" style="background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"><option value="">all</option><option value="P→C">P→C</option><option value="C→P">C→P</option></select></label>
+  <label class="note">op <input type="text" id="fOp" placeholder="f1" style="width:48px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"></label>
+  <label class="note">len <select id="fLen" style="background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"><option value="">any</option><option value="ne49">≠ 49 (non-input)</option><option value="gt49">&gt; 49</option><option value="gt1">&gt; 1</option></select></label>
+  <label class="note">hex <input type="text" id="filter" placeholder="9f" style="width:90px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"></label>
+  <span class="note">↑ to find battery: <b>hide input/polls</b> + dir <b>C→P</b>, then read the controller's battery in Steam.</span>
+</div>
+<div id="tbl"><table><thead><tr>
+  <th>#</th><th>t (ms)</th><th>dir</th><th>ch</th><th>pipe</th><th>op</th><th>crc</th><th>rssi</th><th>len</th><th>payload (from opcode)</th>
+</tr><tbody id="rows"></tbody></table></div>
+<div id="log"></div>
+
+<script>
+const $=s=>document.querySelector(s);
+let dev=null, epIn=0, epOut=0, ifNum=0, reading=false;
+let capturing=false, t0=0, rec=[], nPC=0, nCP=0, nBad=0;
+let acqState=-1, sessTxt="—", rxBytes=0, gotData=false;
+const MAXROWS=600;            // live table cap (full record kept in `rec` for export)
+function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,1500); }
+const hex=b=>Array.from(b,x=>x.toString(16).padStart(2,"0")).join(" ");
+
+async function connect(){
+  try{
+    dev=await navigator.usb.requestDevice({filters:[{vendorId:0x28DE}]});
+    await dev.open();
+    if(dev.configuration===null) await dev.selectConfiguration(1);
+    let found=null;
+    for(const itf of dev.configuration.interfaces){
+      const a=itf.alternate; if(a.interfaceClass!==0xFF || a.interfaceSubClass===0x5D) continue;
+      let bin=0,bout=0;
+      for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
+      if(bin&&bout){ found={ifNum:itf.interfaceNumber,epIn:bin,epOut:bout}; break; }
+    }
+    if(!found){ log("no WebUSB bulk interface — is this the sniffer board (OpenPuck Sniffer)?"); return; }
+    ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
+    await dev.claimInterface(ifNum);
+    await dev.controlTransferOut({requestType:"class",recipient:"interface",request:0x22,value:0x01,index:ifNum});
+    $("#state").textContent="connected"; $("#state").className="pill up"; $("#connect").textContent="Reconnect";
+    for(const id of ["cap","clear","dl","dltxt","reacq","survey","setTgt","setch","setsess"]) $("#"+id).disabled=false;
+    navigator.usb.addEventListener("disconnect",e=>{ if(e.device===dev) onGone(); });
+    log(`connected — iface ${ifNum} epIn ${epIn} epOut ${epOut} — waiting for stream…`);
+    rxBytes=0; gotData=false;
+    setTimeout(()=>{ if(dev && !gotData) log("⚠ connected but NO bytes from sniffer in 2s — firmware not streaming (re-flash the updated sniffer build) or wrong interface."); }, 2000);
+    reading=true; readLoop();
+  }catch(e){ log("connect failed: "+e.message); }
+}
+function onGone(){ reading=false; dev=null; capturing=false; $("#state").textContent="disconnected"; $("#state").className="pill dn"; $("#cap").classList.remove("on"); $("#cap").textContent="● Start Cap"; log("sniffer disconnected"); }
+async function cmd(...b){ if(dev) try{ await dev.transferOut(epOut,new Uint8Array(b)); }catch(e){ log("write err: "+e.message); } }
+
+let acc=new Uint8Array(0);
+function append(buf){ const m=new Uint8Array(acc.length+buf.length); m.set(acc); m.set(buf,acc.length); acc=m; }
+function parse(){
+  let i=0;
+  while(i+2<=acc.length){
+    if(acc[i]===0xC1 && acc[i+1]===0xDE){            // status (19 bytes)
+      if(i+19>acc.length) break;
+      acqState=acc[i+2];
+      const ch=acc[i+3], b=acc.slice(i+4,i+8), pfx=acc[i+8];
+      const advCh=acc[i+12], ab=acc.slice(i+13,i+17), apfx=acc[i+17], lastMatch=acc[i+18];
+      sessTxt=`ch ${ch}  addr ${hex(b)}/${pfx.toString(16).padStart(2,"0")}  (last pipe ${lastMatch})`;
+      $("#acq").textContent=acqState===1?"CAPTURE (session locked)":"ACQUIRE (scanning ibex/ch2)";
+      $("#acq").style.color=acqState===1?"#3fb950":"#d29922";
+      $("#sess").textContent=sessTxt;
+      $("#adv").textContent = advCh ? `ch ${advCh}  addr ${hex(ab)}/${apfx.toString(16).padStart(2,"0")}` : "— (no E1 yet)";
+      i+=19; continue;
+    }
+    if(acc[i]===0xC0 && acc[i+1]===0xDE){            // packet
+      if(i+3>acc.length) break;
+      const n=acc[i+2]; if(n>98){ i++; continue; }   // bad len -> resync (frames up to MAXLEN+2)
+      if(i+11+n>acc.length) break;
+      const t=acc[i+3]|(acc[i+4]<<8)|(acc[i+5]<<16)|(acc[i+6]*16777216);
+      const ch=acc[i+7], crc=acc[i+8]&1, rssi=acc[i+9], match=acc[i+10];
+      const raw=acc.slice(i+11,i+11+n);
+      onPacket(t,ch,crc,rssi,match,raw);
+      i+=11+n; continue;
+    }
+    i++;                                             // resync
+  }
+  acc=acc.slice(i);
+}
+let allFrames=[], seqCtr=0, nShown=0;          // ring of recent frames so filters can re-render; seq = stable # column
+const FRAMEBUF=8000;
+function onPacket(t_us,ch,crc,rssi,match,raw){
+  // raw = [LENGTH, S1, payload...]; opcode = raw[2]; dir from opcode high nibble
+  const op=raw.length>2?raw[2]:0;
+  const dir=(op&0xF0)===0xE0?"P→C":(op&0xF0)===0xF0?"C→P":"?";
+  if(dir==="P→C")nPC++; else if(dir==="C→P")nCP++; if(!crc)nBad++;
+  const payload=raw.slice(2);                        // from opcode onward (what we care about)
+  const ent={seq:++seqCtr,t_us,ms:(t_us/1000),ch,pipe:match,dir,op:op.toString(16).padStart(2,"0"),crc,rssi:-rssi,len:raw.length>0?raw[0]:0,raw:hex(raw),payload:hex(payload)};
+  if(capturing) rec.push(ent);
+  allFrames.push(ent); if(allFrames.length>FRAMEBUF) allFrames.shift();
+  if(passesFilter(ent)) addRow(ent);
+  $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
+}
+const rows=$("#rows");
+function passesFilter(e){
+  if($("#fHideRoutine").checked){                    // drop the 99% spam: input replies + bare polls
+    if(e.dir==="C→P" && e.op==="f1" && e.len===49) return false;
+    if(e.dir==="P→C" && e.op==="e3" && e.len<=1) return false;
+  }
+  const d=$("#fDir").value; if(d && e.dir!==d) return false;
+  const op=$("#fOp").value.trim().toLowerCase(); if(op && e.op!==op) return false;
+  const lf=$("#fLen").value;
+  if(lf==="ne49" && e.len===49) return false;
+  if(lf==="gt49" && e.len<=49) return false;
+  if(lf==="gt1"  && e.len<=1)  return false;
+  const hx=$("#filter").value.trim().toLowerCase().replace(/ /g,""); if(hx && !e.payload.replace(/ /g,"").includes(hx)) return false;
+  return true;
+}
+function addRow(e){
+  const tr=document.createElement("tr");
+  tr.className=(e.dir==="P→C"?"pc":e.dir==="C→P"?"cp":"")+(e.crc?"":" bad");
+  tr.innerHTML=`<td>${e.seq}</td><td>${e.ms.toFixed(1)}</td><td class="dir">${e.dir}</td><td>${e.ch}</td><td>${e.pipe}</td><td>${e.op}</td><td>${e.crc?"ok":"BAD"}</td><td>${e.rssi}</td><td>${e.len}</td><td class="hex">${e.payload}</td>`;
+  rows.appendChild(tr);
+  nShown++;
+  while(rows.childElementCount>MAXROWS){ rows.removeChild(rows.firstChild); }
+  $("#nshown").textContent=nShown;
+  const w=$("#tbl"); if(w.scrollTop+w.clientHeight>=w.scrollHeight-40) w.scrollTop=w.scrollHeight;
+}
+function applyFilters(){                              // re-render visible table from the buffer with current filters
+  rows.innerHTML=""; nShown=0;
+  for(const e of allFrames.slice(-MAXROWS)) if(passesFilter(e)) addRow(e);
+}
+for(const id of ["fHideRoutine","fDir","fOp","fLen","filter"]) $("#"+id).addEventListener("input",applyFilters);
+async function readLoop(){
+  while(reading && dev){
+    try{
+      const r=await dev.transferIn(epIn,16384);
+      if(r.status==="ok" && r.data.byteLength){
+        const u=new Uint8Array(r.data.buffer);
+        if(!gotData){ gotData=true; log("✓ receiving — first bytes: "+hex(u.slice(0,24))); }
+        rxBytes+=u.length; $("#rx").textContent=(rxBytes<1024?rxBytes+" B":(rxBytes/1024).toFixed(1)+" KB");
+        append(u); parse();
+      }
+      else if(r.status==="stall"){ await dev.clearHalt("in",epIn); }
+    }catch(e){ if(dev) log("read err: "+e.message); break; }
+  }
+}
+
+$("#connect").onclick=connect;
+$("#cap").onclick=async()=>{
+  capturing=!capturing;
+  $("#cap").classList.toggle("on",capturing);
+  $("#cap").textContent=capturing?"■ End Cap":"● Start Cap";
+  if(capturing){ rec=[]; t0=Date.now(); await cmd(0x01); log("CAP STARTED — recording"); }
+  else{ await cmd(0x02); log(`CAP ENDED — ${rec.length} packets recorded`); }
+  $("#nrec").textContent=rec.length;
+};
+$("#clear").onclick=()=>{ rec=[]; allFrames=[]; seqCtr=0; rows.innerHTML=""; nPC=nCP=nBad=nShown=0; for(const id of ["nrec","npc","ncp","nbad","nshown"]) $("#"+id).textContent=0; };
+$("#reacq").onclick=()=>{ $("#pinCh").value=""; cmd(0x03); log("re-acquire requested"); };
+let surveying=false;
+$("#survey").onclick=()=>{ surveying=!surveying; $("#survey").classList.toggle("active",surveying); cmd(0x06); log(surveying?"SURVEY on — listing every E1 beacon on ch2 (no lock). Each distinct 'op e1' payload = one puck/slot session.":"survey off"); };
+$("#setTgt").onclick=()=>{ const v=$("#tgtIbex").value.trim().replace(/^0x/i,""); if(/^[0-9a-fA-F]{8}$/.test(v)){ const u=parseInt(v,16); cmd(0x07,u&0xff,(u>>8)&0xff,(u>>16)&0xff,(u>>24)&0xff); log("locking ibex_uuid 0x"+v.toUpperCase()+" — only that slot's E1 (then hunting its channels for F1)"); } else log("enter ibex_uuid as 8 hex digits from pairtui, e.g. EF7171B4"); };
+$("#setch").onclick=()=>{ const c=parseInt($("#pinCh").value); if(c>=1&&c<=80){ cmd(0x04,c); log("pinned channel "+c); } };
+$("#setsess").onclick=()=>{   // "b0 b1 b2 b3 pfx ch" -- base/prefix as hex, channel decimal or hex. Read these
+  const v=$("#pinSess").value.trim().split(/[\s,]+/).filter(x=>x.length);   // off a captured E1 (bytes 13..17 of its payload = base, byte 17 = prefix, byte 9 = ch)
+  if(v.length!==6){ log("pin session needs 6 values: b0 b1 b2 b3 pfx ch"); return; }
+  const b=v.slice(0,5).map(x=>parseInt(x,16)); const ch=parseInt(v[5], v[5].toLowerCase().startsWith("0x")?16:10);
+  if(b.some(isNaN)||isNaN(ch)||ch<1||ch>80){ log("bad pin-session values"); return; }
+  cmd(0x05,b[0],b[1],b[2],b[3],b[4],ch); log(`pinned session addr ${v.slice(0,4).join(" ")}/${v[4]} ch ${ch}`);
+};
+function save(name,text,type){ const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([text],{type})); a.download=name; a.click(); }
+$("#dl").onclick=()=>save("sniff-"+Date.now()+".json", JSON.stringify(rec,null,1), "application/json");
+$("#dltxt").onclick=()=>{
+  const lines=rec.map(e=>`${e.ms.toFixed(1)}ms ch${e.ch} ${e.dir} ${e.crc?"":"BAD "}rssi${e.rssi} op=${e.op} | ${e.payload}`);
+  save("sniff-"+Date.now()+".txt", lines.join("\n"), "text/plain");
+};
+if(!navigator.usb) log("This browser has no WebUSB. Use Chrome/Edge over https or localhost.");
+</script>
+</body>
+</html>

+ 20 - 0
gen_version.sh

@@ -0,0 +1,20 @@
+#!/bin/sh
+# gen_version.sh -- write OpenPuck/git_version.h with the current git short hash + dirty flag, for the build
+# provenance shown in the WebUSB panel. Run this BEFORE `arduino-cli compile` (it's a no-op to re-run). The
+# generated header is git-ignored, so generating it never itself marks the tree dirty.
+set -e
+cd "$(dirname "$0")"
+
+HASH=$(git rev-parse --short=8 HEAD 2>/dev/null || echo unknown)
+# Dirty = any tracked change OR untracked file (porcelain is empty only on a clean tree). git_version.h is
+# git-ignored so it doesn't count.
+if [ -z "$(git status --porcelain 2>/dev/null)" ]; then DIRTY=0; else DIRTY=1; fi
+
+cat > OpenPuck/git_version.h <<EOF
+// AUTO-GENERATED by gen_version.sh -- do not edit, do not commit (git-ignored).
+#pragma once
+#define OPK_GIT_HASH "$HASH"
+#define OPK_GIT_DIRTY $DIRTY
+EOF
+
+echo "OpenPuck/git_version.h: hash=$HASH dirty=$DIRTY"

+ 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;
+}

+ 116 - 0
puck_sniffer/README.md

@@ -0,0 +1,116 @@
+# OpenPuck RF Sniffer
+
+A **passive** 2.4 GHz sniffer for the link between a real Valve SC2 puck and its controller. It never
+transmits, so it cannot disturb the pairing — it only listens, and streams every frame to a browser app in
+real time with **Start Cap / End Cap**.
+
+Use it to capture the actual puck↔controller traffic for any function (shutdown, LED, battery, …), isolate
+the relevant frames, and export them.
+
+## What you need
+- A **second** nRF52840 board (e.g. the same Pro Micro / Feather you flash OpenPuck onto) — *not* the puck.
+- The real Valve puck + controller, paired and working.
+- Chrome or Edge (WebUSB).
+
+## 1. Flash the sniffer firmware
+```
+arduino-cli compile -b adafruit:nrf52:feather52840 \
+  --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" \
+  --upload -p <PORT> puck_sniffer
+```
+(Same manual-DFU/double-tap-reset dance as OpenPuck if the port won't auto-reset.) It enumerates as
+`28DE:534E "OpenPuck Sniffer"`.
+
+## 2. Open the app
+WebUSB needs a secure context, so **not** a `file://` path. Either:
+- **Hosted:** https://safijari.github.io/OpenPuck/sniffer.html (once `docs/` is pushed to gh-pages), or
+- **Local:** `cd docs && python3 -m http.server` → open http://localhost:8000/sniffer.html
+
+Click **Connect sniffer**, pick *OpenPuck Sniffer*.
+
+## 3. Capture — the exact sequence (do it in this order)
+
+Why order matters: the link is Nordic **Gazell** — the on-air address is random per session, and **a *connected*
+slot never beacons on ch2; only a *reconnecting* one does.** So you can't lock onto an already-connected
+controller — you must catch its **reconnect**. The bond's **ibex_uuid is stable** across reconnects (only the
+address changes), so we anchor the lock on that uuid and force a reconnect.
+
+### Setup (once)
+1. **Unplug the copycat** (the OpenPuck dev board) if it's plugged in — it beacons its own `E1`s and pollutes
+   ch2. Leave only: the **real Valve puck**, the **controller**, and the **sniffer board**.
+2. Controller connected to the **real puck** (Steam sees it). With `pairtui` (see `../pairtui/`), note the
+   connected slot's **ibex_uuid** (e.g. `EF7171B4`) — it's the bond id, stable across sessions.
+
+### Arm the sniffer
+3. Flash the sniffer, open the app (`docs/sniffer.html` over localhost/https — not `file://`), **Connect** →
+   *OpenPuck Sniffer*. Confirm **rx** is climbing.
+4. In **lock ibex**, type the uuid (`EF7171B4`), click **Lock**. Status → **ACQUIRE** (waiting on ch2 for *only*
+   that slot's `E1`; ignores other slots and the copycat).
+5. Click **● Start Cap**.
+
+### Trigger (the step that's easy to miss)
+6. **Power-cycle the controller** — hold its power button until it turns **off**, wait ~2 s, turn it back **on**.
+   On reconnect it re-runs ch2 discovery and broadcasts slot 3's `E1` (matching the uuid) with a **fresh** session
+   address → the sniffer matches the uuid, locks that session, and hunts its channels.
+7. Watch the app: link → **CAPTURE (session locked)** and **`C→P` climbs**. Wiggle sticks / trackpad to keep
+   traffic flowing.
+
+### Capture the action
+8. With `C→P` climbing, do the thing in Steam (change brightness / LED, or turn off).
+9. **■ End Cap → Download.**
+
+**Always: Lock the uuid → Start Cap → *then* power-cycle.** The most common failure is the controller silently
+auto-reconnecting *before* the lock is armed, so its `E1` is never caught.
+
+Direction: `P→C` (opcode `0xE_`, puck→controller — LED/shutoff/brightness commands ride here) vs `C→P` (`0xF_`,
+controller→puck — input + battery/telemetry).
+
+### Filters (find the needle in the input spam)
+The table has a filter bar:
+- **hide input/polls** — drops the 99% routine traffic (the 49-byte `0xF1` input replies and the bare `0xE3`
+  polls), leaving only commands, feature responses, beacons, and anything unusual.
+- **dir** — `P→C` / `C→P` / all.
+- **op** — exact opcode, e.g. `f1`, `e3`.
+- **len** — `≠ 49 (non-input)`, `> 49`, `> 1`. **`≠ 49` is the key for finding feature responses / battery.**
+- **hex** — substring match on the payload (e.g. `9f`, `2d`).
+
+Filters re-render the visible table from a buffer, so you can change them after the fact.
+
+### Capturing battery
+Battery is **not** in the input stream — it's `ID_GET_BATTERY_DATA`, a feature GET Steam issues **on demand**
+(when it shows the battery), answered by the controller in a **longer `C→P` frame (len > 49)** carrying a
+`type-04` TLV. To catch it: lock + Start Cap as above, set **hide input/polls** + dir **C→P** (or len **≠ 49**),
+then **open the controller's page in Steam** (Settings → Controller / the battery indicator) so Steam polls it.
+Grab the non-49 `C→P` frame that appears.
+
+### If `C→P` won't climb after the power-cycle
+- **Survey ch2** and look at the `op e1` rows during the reconnect: one payload should contain your uuid in
+  little-endian (`EF7171B4` → `… b4 71 71 ef …`). If you only ever see a *different* uuid, the copycat or another
+  bonded slot is the only thing reconnecting — make sure the real controller is re-pairing to the **real** puck.
+- Re-check you did **Lock → Start Cap → power-cycle** in that order.
+
+> Bond info (which controller/slot, its serial + uuids) is read over USB, not the air — see `../pairtui/`. Useful
+> for the uuid lock and labelling a capture; it does **not** give the on-air address (random per session).
+
+## How it works
+After RX the radio buffer is `[LENGTH][S1][payload…]`; `payload[0]` is the opcode. The puck advertises its
+per-session base/prefix/channel inside the `E1` host frame on the shared `ibex`/ch2 rendezvous, so the sniffer
+reads those, retunes to the session, and receives both directions (same ESB base — and it now listens on **all
+8 ESB pipes** of that base, so a controller reply on a different prefix is still caught; the **pipe** column /
+status `last pipe` shows which `RXMATCH` each frame hit). A QoS channel-hop is followed via the `E1` keepalive.
+When the session goes silent it **sweeps clean candidate channels keeping the learned address** (rather than
+abandoning to ch2) and only falls back to a full discovery scan after two dry sweeps. Radio parameters are
+OpenPuck's CRC-validated config (`radio.cpp`).
+
+### If you only see `E2`/`E1` on ch2 and never the controller's `0xF_` replies
+That means it never camps on the session. Read a real `E1` row in the table (a `P→C`, op `e1` frame): its
+payload bytes are `e1 [proteus:4] [ibex:4] [CH] 00 00 00 [BASE:4] [PFX]` — i.e. **byte 9 = session channel,
+bytes 13–16 = session base, byte 17 = prefix** (the "E1 advertises" stat shows the sniffer's own read of these).
+Cross-check that against where traffic actually is, then type the six values into **pin session**
+(`b0 b1 b2 b3 pfx ch`, address bytes hex) and click **Pin** to force the sniffer onto that exact session — this
+bypasses auto-acquire entirely, which is the reliable path if the real puck's `E1` offsets differ from ours.
+
+## Stream protocol (WebUSB bulk)
+- packet: `C0 DE [N] [t_us:4 LE] [ch] [flags] [rssi] [match] [N raw bytes]`  (flags bit0 = CRC ok; match = RXMATCH pipe; raw = `[LEN][S1][payload]`)
+- status: `C1 DE [state] [curCh] [base:4] [prefix] [cap] [hb:2] [advCh] [advBase:4] [advPfx] [lastMatch]`  (19 B; adv* = session parsed from the last `E1`)
+- commands (bulk OUT): `01` start · `02` stop · `03` re-acquire · `04 <ch>` pin channel · `05 <b0 b1 b2 b3 pfx ch>` pin full session

+ 235 - 0
puck_sniffer/puck_sniffer.ino

@@ -0,0 +1,235 @@
+// puck_sniffer.ino -- PASSIVE 2.4GHz sniffer for the Valve SC2 puck <-> controller link.
+//
+// Flash this onto a SECOND nRF52840 board (NOT the one emulating a puck). It never transmits, so it cannot
+// disturb the real puck<->controller pairing -- it only listens. It reuses OpenPuck's CRC-validated radio
+// config (radio.cpp): PHY Ble_2Mbit, ENDIAN=Big, no whitening, CRC16 0x11021/0xFFFF, 5-byte ESB address.
+//
+// HOW IT FINDS THE SESSION
+//   The puck and controller talk on a per-puck SESSION address/channel. The puck advertises those in its E1
+//   host-frame beacon, which it sends on the SHARED discovery rendezvous ("ibex" / prefix 0x10 / channel 2).
+//   So the sniffer: (1) ACQUIRE -- listen on ibex/ch2, catch an E1, read session base/prefix/channel from it;
+//   (2) CAPTURE -- retune to that session address/channel and receive every frame both ways. E1 keepalives on
+//   the session address re-advertise the channel, so a QoS channel-hop is followed; a stall re-acquires.
+//
+// Frame in RAM after RX (PCNF0 S0=0/LF=8/S1=3): rfrx[0]=LENGTH(payload bytes), rfrx[1]=S1, rfrx[2..]=payload.
+//   payload[0] (rfrx[2]) is the opcode: 0xE? = puck->controller, 0xF? = controller->puck.
+//
+// STREAM (over WebUSB bulk IN), little-endian:
+//   packet:  C0 DE [N] [t_us:4] [ch] [flags] [rssi] [match] [N raw bytes]   flags bit0=CRC ok; match=RXMATCH pipe
+//   status:  C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch]
+//            (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; 19 bytes)
+// COMMANDS (WebUSB bulk OUT):
+//   01 start  | 02 stop  | 03 re-acquire (back to ibex/ch2)  | 04 <ch> pin channel (keep current address)
+//   05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (use after reading a real
+//      E1 by eye -- the surest way to camp on the session when auto-acquire's fixed E1 offsets are wrong)
+//
+// Build (Adafruit nRF52 core, same flags as OpenPuck):
+//   arduino-cli compile -b adafruit:nrf52:feather52840 \
+//     --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" puck_sniffer
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include "radio.h"
+
+Adafruit_USBD_WebUSB usb_web;
+
+// ---- capture ring (single producer + consumer, both loop context -> no locking) ----
+struct Cap { uint32_t t; uint8_t ch, flags, rssi, match, n; uint8_t b[96]; };
+#define RINGN 512
+static Cap      g_ring[RINGN];
+static volatile uint16_t g_head = 0, g_tail = 0;
+static inline uint16_t rnext(uint16_t i){ return (uint16_t)((i + 1) % RINGN); }
+
+static void rxArmInit();
+static void tuneDiscovery();
+static void tuneSession();
+
+static bool     g_streaming = false;      // app sent START -> emit packet frames
+static uint8_t  g_state = 0;              // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
+static uint8_t  g_curCh = 2;
+static uint8_t  g_sBase[4] = {0x69,0x62,0x65,0x78};
+static uint8_t  g_sPrefix = 0x10;
+static uint8_t  g_forceCh = 0;            // !=0 -> user pinned a channel (skip auto-acquire retune)
+static bool     g_pinSession = false;     // user pinned a FULL session (cmd 05): never auto-retune addr/ch
+static bool     g_survey = false;         // cmd 06: stay on ibex/ch2 and stream EVERY E1 beacon, never auto-lock
+                                          // (so you can see how many pucks/slots are advertising + their bases)
+// cmd 07: lock ONLY the E1 whose ibex_uuid (E1 payload bytes, = rfrx[7..10], LE) matches -- the puck beacons one
+// E1 per bonded slot and we must pick the CONNECTED controller's. Read its uuid from pairtui (e.g. 0xEF7171B4).
+static bool     g_haveTarget = false;
+static uint8_t  g_targetIbex[4] = {0,0,0,0};
+static unsigned long g_lastRx = 0;        // for stall -> sweep/re-acquire
+static uint8_t  g_lastMatch = 0;          // RXMATCH pipe of the most recent frame (diagnostic)
+
+// What the last E1 host frame ADVERTISED (parsed by the assumed offsets). Reported in status EVEN IF we don't
+// lock, so the panel can show whether those offsets are sane (channel in range, address non-degenerate) -- the
+// fastest way to tell if auto-acquire is mis-reading the real puck's E1.
+static uint8_t  g_advCh = 0, g_advBase[4] = {0,0,0,0}, g_advPfx = 0;
+
+// CAPTURE-mode stall sweep: instead of jumping straight back to discovery (which abandons the session and
+// strands us on ch2 seeing only E1/E2), step through clean candidate channels KEEPING the learned address --
+// this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
+static const uint8_t SWEEP_CH[] = {18,46,76,22,68,2,80,52,55,56,57};
+static uint8_t  g_sweepIdx = 0, g_sweepDry = 0;
+
+// Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
+// controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
+// 0xF* controller reply) rides a ~3-channel set {primary,2,80} derived from the per-session address. So HUNT on
+// "no 0xF* reply" instead: dwell briefly on each of {primary,2,80,+clean candidates} until the controller's
+// replies appear, then PARK there. g_lastFrx = last controller (0xF*) frame; g_primaryCh = E1-advertised channel.
+static const uint8_t HUNT_CH[] = {2,80,18,46,76,22,68,52};
+static uint8_t  g_primaryCh = 78, g_huntIdx = 0;
+static unsigned long g_lastFrx = 0, g_huntMs = 0;
+#define HUNT_DWELL_MS 350u
+#define HUNT_NOFRX_MS 1000u
+
+static void tuneDiscovery(){ g_state=0; g_curCh=2; rfConfig(2); rfSetAddr(PAIR_BASE, 0x10); rxArmInit(); }
+// Session RX listens across all 8 pipes on the learned base (rfSetAddrMulti) so a same-base/other-prefix
+// controller reply is still caught; RXMATCH tells us which pipe each frame used.
+static void tuneSession(){ g_state=1; rfConfig(g_curCh); rfSetAddrMulti(g_sBase, g_sPrefix); rxArmInit(); }
+
+// Continuous (gapless) RX: READY->START and END->START keep the radio receiving; ADDRESS->RSSISTART samples
+// signal strength for each packet. We poll EVENTS_END and copy rfrx before the next packet can overwrite it.
+static void rxArmInit(){
+  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+  NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk
+                    | RADIO_SHORTS_ADDRESS_RSSISTART_Msk | RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
+  NRF_RADIO->EVENTS_END = 0; NRF_RADIO->EVENTS_ADDRESS = 0;
+  NRF_RADIO->TASKS_RXEN = 1;
+}
+
+static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi, uint8_t match, const uint8_t* b, uint8_t n){
+  uint16_t h = g_head, nx = rnext(h);
+  if (nx == g_tail) g_tail = rnext(g_tail);   // full -> drop oldest
+  Cap &c = g_ring[h];
+  c.t = t; c.ch = ch; c.flags = crcok ? 1 : 0; c.rssi = rssi; c.match = match; c.n = n;
+  memcpy(c.b, b, n);
+  g_head = nx;
+}
+
+// Drain the radio: copy every completed RX frame into the ring. Called very often from loop() so the tight
+// poll->reply exchange is caught. RSSISAMPLE/CRCSTATUS belong to the frame whose END we just saw.
+static void rxPump(){
+  for (int guard = 0; guard < 8 && NRF_RADIO->EVENTS_END; guard++){
+    NRF_RADIO->EVENTS_END = 0;
+    bool crcok = NRF_RADIO->CRCSTATUS & 1;
+    uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
+    uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);   // which logical address (pipe) the frame hit
+    g_lastMatch = match;
+    uint8_t len = rfrx[0];
+    uint16_t n = (uint16_t)(2 + len); if (n > 96) n = 96;   // LENGTH + S1 + payload, clamped
+    ringPush(micros(), g_curCh, crcok, rssi, match, rfrx, (uint8_t)n);
+    g_lastRx = millis();
+    if (g_state == 1) g_sweepDry = 0;                        // any frame on the session resets the dry-sweep count
+    if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0) g_lastFrx = millis();   // controller reply = ACTIVE session here -> stop hunting, park
+    // Parse the session params advertised in an E1 host-frame (payload[0]=0xE1) by the ASSUMED offsets, and
+    // always record them for the status report -- so the panel shows what auto-acquire would lock onto even
+    // when we choose not to (lets us eyeball whether the real puck's offsets match ours).
+    if (crcok && len >= 18 && rfrx[2] == 0xE1){
+      g_advCh = rfrx[11];
+      g_advBase[0]=rfrx[15]; g_advBase[1]=rfrx[16]; g_advBase[2]=rfrx[17]; g_advBase[3]=rfrx[18];
+      g_advPfx = rfrx[19];
+      if (g_pinSession || g_survey) continue;                // pinned / survey -> never auto-retune (just record adv)
+      // Target a specific bonded slot by its ibex_uuid (E1 bytes rfrx[7..10]): the puck beacons one E1 per slot,
+      // so skip any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
+      if (g_haveTarget && memcmp(rfrx+7, g_targetIbex, 4) != 0) continue;
+      // ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
+      if (g_advCh >= 1 && g_advCh <= 80){
+        if (g_state == 0){
+          memcpy(g_sBase, g_advBase, 4); g_sPrefix = g_advPfx; g_primaryCh = g_advCh;
+          g_curCh = g_forceCh ? g_forceCh : g_advCh; g_huntIdx = 0; g_lastFrx = millis();
+          tuneSession(); return;
+        } else if (!g_forceCh && g_advCh != g_curCh){
+          g_curCh = g_advCh; tuneSession(); return;
+        }
+      }
+    }
+  }
+}
+
+// ---- WebUSB stream out ----
+static void emitPacket(const Cap &c){
+  uint8_t f[11 + 96];
+  f[0]=0xC0; f[1]=0xDE; f[2]=c.n;
+  f[3]=(uint8_t)c.t; f[4]=(uint8_t)(c.t>>8); f[5]=(uint8_t)(c.t>>16); f[6]=(uint8_t)(c.t>>24);
+  f[7]=c.ch; f[8]=c.flags; f[9]=c.rssi; f[10]=c.match;
+  memcpy(f+11, c.b, c.n);
+  usb_web.write(f, (uint32_t)(11 + c.n));
+}
+static void emitStatus(){
+  static unsigned long last=0; if (millis()-last < 250) return; last=millis();
+  static uint16_t rx=0; rx++;
+  uint8_t f[19]={0xC1,0xDE,g_state,g_curCh,g_sBase[0],g_sBase[1],g_sBase[2],g_sBase[3],g_sPrefix,
+                 (uint8_t)(g_streaming?1:0),(uint8_t)rx,(uint8_t)(rx>>8),
+                 g_advCh,g_advBase[0],g_advBase[1],g_advBase[2],g_advBase[3],g_advPfx,g_lastMatch};
+  usb_web.write(f, sizeof f); usb_web.flush();
+}
+static void drainToHost(){
+  if (!usb_web.connected()) return;
+  uint16_t budget = 64;   // bound per-call so RX stays responsive; the ring covers bursts
+  while (budget-- && g_tail != g_head){
+    if (g_streaming) emitPacket(g_ring[g_tail]);
+    g_tail = rnext(g_tail);
+  }
+  usb_web.flush();
+  emitStatus();
+}
+static void handleCmd(){
+  while (usb_web.available()){
+    int c = usb_web.read(); if (c < 0) break;
+    if (c == 0x01){ g_streaming = true; g_head = g_tail = 0; }   // START (flush stale ring)
+    else if (c == 0x02){ g_streaming = false; }                 // STOP
+    else if (c == 0x03){ g_forceCh = 0; g_pinSession = false; g_survey = false; tuneDiscovery(); }   // re-acquire from ibex/ch2
+    else if (c == 0x06){ g_survey = !g_survey; g_forceCh = 0; g_pinSession = false; tuneDiscovery(); }   // survey: list all E1 beacons on ch2, no lock
+    else if (c == 0x07){                                       // target ibex_uuid: lock only the matching slot's E1
+      int u0=usb_web.read(),u1=usb_web.read(),u2=usb_web.read(),u3=usb_web.read();
+      if (u0>=0&&u1>=0&&u2>=0&&u3>=0){
+        g_targetIbex[0]=u0; g_targetIbex[1]=u1; g_targetIbex[2]=u2; g_targetIbex[3]=u3;
+        g_haveTarget=true; g_survey=false; g_forceCh=0; g_pinSession=false; tuneDiscovery();
+      }
+    }
+    else if (c == 0x04){ int ch = usb_web.read(); if (ch>=1 && ch<=80){ g_forceCh=(uint8_t)ch; g_curCh=(uint8_t)ch; tuneSession(); } }   // pin channel, keep addr
+    else if (c == 0x05){                                        // pin a FULL session: b0 b1 b2 b3 pfx ch
+      int b0=usb_web.read(),b1=usb_web.read(),b2=usb_web.read(),b3=usb_web.read(),pf=usb_web.read(),ch=usb_web.read();
+      if (b0>=0&&b1>=0&&b2>=0&&b3>=0&&pf>=0&&ch>=1&&ch<=80){
+        g_sBase[0]=b0; g_sBase[1]=b1; g_sBase[2]=b2; g_sBase[3]=b3; g_sPrefix=(uint8_t)pf;
+        g_curCh=(uint8_t)ch; g_forceCh=(uint8_t)ch; g_pinSession=true; tuneSession();
+      }
+    }
+  }
+}
+
+void setup(){
+  // Standard Adafruit WebUSB setup (keep the default CDC composite; just add the WebUSB vendor interface). No
+  // landing page -> no Chrome "open <url>" notification. No detach/clearConfiguration: that's OpenPuck-specific
+  // endpoint juggling and can leave the WebUSB stream un-enabled.
+  USBDevice.setID(0x28DE, 0x534E);                 // 'SN' -- distinct PID so the host doesn't confuse it with a puck
+  USBDevice.setManufacturerDescriptor("Valve Software");
+  USBDevice.setProductDescriptor("OpenPuck Sniffer");
+  usb_web.begin();
+  Serial.begin(115200);
+  for (int i=0;i<200 && !USBDevice.mounted(); i++) delay(10);
+
+  rfGenSessionAddr();        // harmless (we never TX); leaves the radio tunables at their validated defaults
+  tuneDiscovery();
+  g_lastRx = millis();
+}
+
+void loop(){
+  rxPump();
+  handleCmd();
+  rxPump();                  // pump again around the (fast, buffered) USB work to catch tight poll->reply pairs
+  drainToHost();
+  // Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise: while we're NOT seeing the
+  // controller's 0xF* replies (the ACTIVE session), the keepalive trickles in on the primary but the real E3/F1
+  // exchange is on another channel of the per-session set -> hunt {primary,2,80,+candidates}, dwelling briefly on
+  // each, KEEPING the learned address. The instant a 0xF* arrives (g_lastFrx) we stop and park there. After two
+  // full dry passes (no replies anywhere -> session address likely changed on a reconnect) fall back to discovery
+  // to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
+  if (g_state == 1 && !g_forceCh && !g_pinSession
+      && millis() - g_lastFrx > HUNT_NOFRX_MS && millis() - g_huntMs > HUNT_DWELL_MS){
+    g_huntMs = millis();
+    uint8_t ch = (g_huntIdx == 0) ? g_primaryCh : HUNT_CH[(g_huntIdx - 1) % (sizeof HUNT_CH)];
+    g_huntIdx++;
+    if (g_huntIdx > 1 + 2 * (uint8_t)sizeof HUNT_CH){ g_huntIdx = 0; tuneDiscovery(); g_lastFrx = millis(); g_huntMs = millis(); return; }
+    g_curCh = ch; rfConfig(g_curCh); rfSetAddrMulti(g_sBase, g_sPrefix); rxArmInit();
+  }
+}

+ 93 - 0
puck_sniffer/radio.cpp

@@ -0,0 +1,93 @@
+#include "radio.h"
+
+// Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key that precedes the "ibex" string is a
+// DIFFERENT key, NOT the discovery address (confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...").
+const uint8_t PAIR_BASE[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+uint8_t  g_rfPrefix = 0x10;
+uint8_t  g_rfCh = 2;
+uint8_t  g_rfBase[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+// ch18 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own channel scan and is in its active hop set
+// {18,2,80}; trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
+uint8_t  g_sessCh = 18;
+
+// pre-init to "ibex"/0x10 as a safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
+uint8_t  g_sessBase[4] = {0x69, 0x62, 0x65, 0x78};
+uint8_t  g_sessPrefix = 0x10;
+
+void rfGenSessionAddr(){
+  // Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
+  // debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
+  // session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
+  // the shared discovery base "ibex" (else the isolation is lost).
+  uint32_t h  = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
+  uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
+  for(int i=0;i<4;i++){ uint8_t b=(uint8_t)(h>>(i*8)); if(b==0x00||b==0xFF) b^=0x5A; g_sessBase[i]=b; }
+  uint8_t p=(uint8_t)(h2>>16); if(p==0x00||p==0xFF) p=0x5C; g_sessPrefix=p;
+  if(g_sessBase[0]==g_rfBase[0] && g_sessBase[1]==g_rfBase[1] &&
+     g_sessBase[2]==g_rfBase[2] && g_sessBase[3]==g_rfBase[3]) g_sessBase[0]^=0x80;   // collided with "ibex"
+}
+
+uint8_t  rfrx[100], rftx[100];
+uint32_t g_rfRxCount = 0;
+
+// HYBRID puck-link config (firmware-derived, definitive). DECODED from real-puck capture: puck TX = Ble_2Mbit,
+// ENDIAN=Big (MSB-first), NO whitening, addr "ibex", CRC16 0x11021/0xFFFF; frame = FUN_00027e9a (static length).
+uint32_t g_crcinit = 0xFFFF;
+uint8_t  g_whiteiv = 37;
+uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
+uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint8_t  g_statlen = 0x20;
+uint32_t g_pcnf1   = 0x01040060;                  // ENDIAN=Big, BALEN4, MAXLEN=96 (was 64; longer F1 frames e.g. 0x43-augmented)
+uint32_t g_crcpoly = 0x11021UL;
+uint16_t g_crccnf  = 0x2;                          // CRC16, address included
+uint8_t  g_pid     = 0;
+uint8_t  g_balen   = 4;                            // ESB 5-byte addr
+bool     g_prefixRaw = false;
+
+uint8_t rfBitrev8(uint8_t x){ uint8_t y=0; for(int i=0;i<8;i++){y=(y<<1)|(x&1);x>>=1;} return y; }
+
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix){
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
+  NRF_RADIO->BASE0   = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 1u<<0;
+}
+
+// Listen on ONE base across ALL 8 ESB logical addresses (pipes): the real prefix on pipe 0, plus a spread of
+// neighbour prefixes on pipes 1..7 (BASE1 mirrors BASE0). Catches a controller whose reply stream rides the
+// same base on a DIFFERENT prefix -- a real possibility per the RE notes' "separate stream" model -- without
+// us having to know that prefix in advance. RXMATCH (reported per packet) then tells us which pipe it used.
+// Extra pipes only ADD matches; they never drop the pipe-0 (puck) frames. base/prefix are stored (pre-transform)
+// bytes; the on-air values get the same bitrev8 transform as rfSetAddr (g_prefixRaw=false).
+void rfSetAddrMulti(const uint8_t b4[4], uint8_t prefix){
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
+  uint32_t base = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->BASE0 = base; NRF_RADIO->BASE1 = base;          // pipe 0 uses BASE0; pipes 1..7 use BASE1
+  // 8 candidate prefixes: the learned one first, then a spread. De-dup isn't needed (a repeat just matches twice).
+  uint8_t cand[8] = { prefix, (uint8_t)(prefix^0x01), (uint8_t)(prefix+1), (uint8_t)(prefix-1),
+                      0x10, (uint8_t)(prefix^0x80), (uint8_t)(prefix+0x10), (uint8_t)(prefix-0x10) };
+  uint8_t ap[8]; for(int i=0;i<8;i++) ap[i] = g_prefixRaw ? cand[i] : rfBitrev8(cand[i]);
+  NRF_RADIO->PREFIX0 = (uint32_t)ap[0] | ((uint32_t)ap[1]<<8) | ((uint32_t)ap[2]<<16) | ((uint32_t)ap[3]<<24);
+  NRF_RADIO->PREFIX1 = (uint32_t)ap[4] | ((uint32_t)ap[5]<<8) | ((uint32_t)ap[6]<<16) | ((uint32_t)ap[7]<<24);
+  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 0xFF;   // enable all 8 pipes
+}
+
+void rfConfig(uint8_t ch){
+  NRF_RADIO->TASKS_DISABLE = 1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED = 0;
+  NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);   // Ble_2Mbit (tunable 'M')
+  NRF_RADIO->FREQUENCY = ch;
+  NRF_RADIO->TXPOWER   = (RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+  NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+#endif
+  NRF_RADIO->PCNF0 = g_pcnf0;       // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
+  NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
+        ((1u<<RADIO_PCNF1_ENDIAN_Pos)              // ENDIAN=Big (puck transmits MSB-first)
+        |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
+        |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
+        |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos));   // WHITEEN=0
+  NRF_RADIO->CRCCNF = g_crccnf;     // CRC16, address included
+  NRF_RADIO->CRCPOLY = g_crcpoly; NRF_RADIO->CRCINIT = g_crcinit;
+  NRF_RADIO->DATAWHITEIV = g_whiteiv;
+  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}

+ 59 - 0
puck_sniffer/radio.h

@@ -0,0 +1,59 @@
+// radio.h -- bare-metal nRF52 RADIO (ESB-style) hardware layer.
+//
+// We drive the RADIO peripheral directly, with NO SoftDevice (BLE is never started), coexisting with TinyUSB.
+// This file owns the register-level config: PHY/CRC/whitening/address parameters (all live-tunable from the
+// CDC console for hardware iteration) plus rfConfig()/rfSetAddr() which program the radio, and the rfrx/rftx
+// DMA buffers. The puck *protocol* built on top lives in rf_link.cpp; the RE/calibration tooling in rf_diag.cpp.
+//
+// Register-confirmed config (decoded from real-puck capture): PHY=Ble_2Mbit, ENDIAN=Big (MSB-first), whitening
+// off, addr "ibex", CRC16 poly 0x11021 init 0xFFFF (address included). On-air address = bitrev8 of stored bytes.
+#pragma once
+#include <stdint.h>
+#include <Arduino.h>
+
+// Bounded radio-disable wait: NEVER spin forever. A wedged RADIO peripheral would otherwise hang the whole
+// main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
+// the next rfConfig re-inits the radio.
+#define RWAIT_DISABLED() do{ uint32_t _w=micros(); while(!NRF_RADIO->EVENTS_DISABLED && (uint32_t)(micros()-_w)<3000){} }while(0)
+
+// Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
+//   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
+extern const uint8_t PAIR_BASE[4];   // "ibex"
+extern uint8_t  g_rfPrefix;          // discovery prefix (rodata byte @0x56f98)
+extern uint8_t  g_rfCh;              // current TX/RX channel (hopped during beacon/poll)
+extern uint8_t  g_rfBase[4];         // "ibex"
+extern uint8_t  g_sessCh;            // connected-session channel: a CLEAN data channel off the congested adv ch2
+
+// Per-device SESSION address (base+prefix). Discovery/rendezvous stays on the SHARED "ibex" address (g_rfBase)
+// so any controller can find us; the host frame then advertises THIS unique address and the controller adopts
+// it for the connected session. Two OpenPucks therefore never share an on-air session address -> no crosstalk,
+// no spurious cross-wake. Derived from the FICR DEVICEID (stable per chip, unique across chips).
+extern uint8_t  g_sessBase[4];
+extern uint8_t  g_sessPrefix;
+void rfGenSessionAddr();
+
+extern uint8_t  rfrx[100], rftx[100];  // RADIO DMA buffers
+extern uint32_t g_rfRxCount;
+
+// ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----
+extern uint32_t g_crcinit;
+extern uint8_t  g_whiteiv;
+extern uint8_t  g_mode;     // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
+extern uint32_t g_pcnf0;    // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
+extern uint8_t  g_statlen;  // static-mode payload length
+extern uint32_t g_pcnf1;    // ENDIAN=Big, BALEN4, MAXLEN64 -- CRC-VALIDATED
+extern uint32_t g_crcpoly;
+extern uint16_t g_crccnf;   // CRC16, address included
+extern uint8_t  g_pid;
+extern uint8_t  g_balen;    // ESB 5-byte addr
+extern bool     g_prefixRaw;// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+
+uint8_t rfBitrev8(uint8_t x);
+// Access address (IBEX FUN_00037530): BASE0 = bitrev8 each base byte packed big-endian; PREFIX0 = raw or
+// bitrev8 prefix per g_prefixRaw. "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix);
+// Listen on one base across all 8 ESB pipes (learned prefix + a neighbour spread) so a same-base/other-prefix
+// controller stream is still caught; RXMATCH then reveals which pipe. See radio.cpp for the candidate set.
+void rfSetAddrMulti(const uint8_t b4[4], uint8_t prefix);
+// Program MODE/FREQUENCY/PCNF/CRC/whitening from the tunables above onto channel ch (radio left disabled).
+void rfConfig(uint8_t ch);