Prechádzať zdrojové kódy

Merge pull request #28 from safijari/various-bugfixes

Signal strength, attempted haptics fix
Jari 4 mesiacov pred
rodič
commit
adfea66957

+ 32 - 5
OpenPuck/OpenPuck.ino

@@ -100,14 +100,41 @@ void setup() {
   NRF_WDT->TASKS_START = 1;
 }
 
+// loop-timing diagnostics: the poll rate is capped by the loop ITERATION time (the pacing wants 4000us but a
+// slow loop fires the poll only as often as it comes around). Per-section us is accumulated and, each second,
+// the average loop period + the slowest section are published for the WebUSB panel to pinpoint the culprit.
+uint16_t g_loopPeriodUs = 0;   // avg loop iteration time last second (1e6/iterations)
+uint8_t  g_loopWorst    = 0;   // index of the slowest section: 0=webusb 1=ctrl.task 2=serial 3=rfdiag 4=rflink 5=haptic 6=led
+uint16_t g_loopWorstUs  = 0;   // that section's avg us per iteration
 void loop() {
   NRF_WDT->RR[0] = WDT_RR_RR_Reload;   // feed the watchdog each loop; if we ever stop, the ~8s WDT auto-resets us
   if (g_dirty) { g_dirty = false; saveBonds(); }
+#if OPK_LOG
+  // Diagnostic build: time each loop section so the panel can show the slowest one + the loop period.
+  static uint32_t acc[7] = {0}; static uint32_t loops = 0; static unsigned long secMs = 0;
+  uint32_t t;
+  t=micros(); webusbPoll();                   acc[0]+=(uint32_t)(micros()-t);
+  t=micros(); if (g_active) g_active->task(); acc[1]+=(uint32_t)(micros()-t);
+  t=micros(); serialConsolePoll();            acc[2]+=(uint32_t)(micros()-t);
+  t=micros(); rfDiagTask();                   acc[3]+=(uint32_t)(micros()-t);
+  t=micros(); rfLinkTask();                   acc[4]+=(uint32_t)(micros()-t);
+  t=micros(); hapticTask();                   acc[5]+=(uint32_t)(micros()-t);
+  t=micros(); ledTask();                      acc[6]+=(uint32_t)(micros()-t);
+  loops++;
+  if (millis()-secMs >= 1000) {
+    g_loopPeriodUs = loops ? (uint16_t)(1000000UL/loops) : 0;
+    uint8_t wi=0; uint32_t wv=0;
+    for (int i=0;i<7;i++){ if(acc[i]>wv){ wv=acc[i]; wi=i; } acc[i]=0; }
+    g_loopWorst = wi; g_loopWorstUs = loops ? (uint16_t)(wv/loops) : 0;
+    loops=0; secMs=millis();
+  }
+#else
   webusbPoll();
-  if (g_active) g_active->task();   // streamed-mode emit / handshake drains / mode upkeep (puck: USB conn presentation; xbox: rumble relay)
+  if (g_active) g_active->task();
   serialConsolePoll();
-  rfDiagTask();                     // service whichever RF RE/calibration mode is active (no-op in normal use)
-  rfLinkTask();                     // host-frame beacons + connected-mode poll (decodes input -> g_in -> active controller) + QoS + stats
-  hapticTask();                     // reconnect-block edge handling + steam 0x82 quiet timeout
-  ledTask();                        // times out the 500ms wake-sent LED flash
+  rfDiagTask();
+  rfLinkTask();
+  hapticTask();
+  ledTask();
+#endif
 }

+ 15 - 0
OpenPuck/config.h

@@ -7,6 +7,15 @@
 #pragma once
 #include <stdint.h>
 
+// ---- Compile-time logging / diagnostics ----
+// 0 (default) = PRODUCTION build: no host->controller capture ring (reclaims ~80KB RAM), no per-event
+// logging, no main-loop section timing, no WebUSB capture channel. The functional bits (haptic reconnect
+// block, the buzz-clear re-init, rate stats) stay. Build the diagnostic firmware with -DOPK_LOG=1.
+// The WebUSB panel reads this back in the status blob and hides its logging UI when it's 0.
+#ifndef OPK_LOG
+#define OPK_LOG 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
@@ -48,6 +57,12 @@ extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (5=L
 #define USB_STREAM_MS   4u      // host-side HID stream cadence for translated modes (~250 Hz)
 extern const uint32_t g_pollUs; // RF poll cadence (us). FIXED -- not configurable (see loadCfg).
 
+// loop-timing diagnostics (defined in OpenPuck.ino) -- surfaced in the WebUSB status blob to find what caps
+// the poll rate: avg loop period, slowest section index, and that section's avg us/iteration.
+extern uint16_t g_loopPeriodUs;
+extern uint8_t  g_loopWorst;
+extern uint16_t g_loopWorstUs;
+
 void loadCfg();
 void saveCfg();
 // Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in

+ 153 - 34
OpenPuck/haptics.cpp

@@ -5,9 +5,6 @@
 #include <Arduino.h>
 #include <string.h>
 
-volatile bool    g_relayPend = false;
-uint8_t          g_relayBuf[24];
-volatile uint8_t g_relayN = 0;
 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.
@@ -18,26 +15,84 @@ unsigned long    g_hapticBlockUntil = 0;   // drop Steam haptics briefly during
 
 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)
+static uint8_t       g_reinitLeft = 0;     // how many re-init shots remain in this connect window
 
+// ---- relay ring: multi-producer (USB ISR + loop-context console/xinput), single consumer (poll flush) ----
+// Producers serialize through a brief PRIMASK critical section (copy is <=62 bytes); the consumer only ever
+// touches the tail entry, which no producer writes while the ring isn't full -- so a flush can't be torn.
+struct RelayMsg { uint8_t rid, len; uint8_t data[RELAY_MAXP]; };
+#define RELAY_QLEN 32          // deep enough to hold a full Steam settings/LED transaction burst without loss
+static RelayMsg         g_rq[RELAY_QLEN];
+static volatile uint8_t g_rqHead = 0, g_rqTail = 0;   // head=next write, tail=next read; empty when equal
+static inline uint8_t rqNext(uint8_t i){ return (uint8_t)((i+1)%RELAY_QLEN); }
+
+bool relayPending(){ return g_rqHead != g_rqTail; }
+bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen){
+  if (plen > RELAY_MAXP) plen = RELAY_MAXP;            // 60B is the RF frame ceiling; longer can't be relayed
+  uint32_t pm = __get_PRIMASK(); __disable_irq();
+  uint8_t h = g_rqHead, nx = rqNext(h);
+  // Full -> evict the OLDEST entry, never the newest. Steam sends commands as bursts where the meaningful
+  // frame comes LAST (a settings transaction ends with its commit; a haptic stream ends with its stop). The
+  // old drop-newest policy threw away exactly those, so LED commits and haptic stops were the casualties --
+  // brightness never applied and a glide's final stop never reached the controller (stuck buzz). Dropping the
+  // oldest instead guarantees the most recent command always lands.
+  if (nx == g_rqTail) g_rqTail = rqNext(g_rqTail);
+  g_rq[h].rid = rid; g_rq[h].len = plen;
+  if (plen) memcpy(g_rq[h].data, payload, plen);
+  g_rqHead = nx;
+  __set_PRIMASK(pm); return true;
+}
+
+#if OPK_LOG
 // diagnostic capture: a ring of the last OUTPUT reports Steam sends (rid/slot/bytes/ms), dumped with 'H'.
 // Reproduce the whine, press Steam to stop it, then 'H' to see the ON stream + the exact OFF frame Steam sends.
-struct HapLog { uint32_t ms; uint8_t slot, rid, n, b[12]; };
-static HapLog  g_hapLog[28];
-static uint8_t g_hapHead = 0;
+struct HapLog { uint32_t ms; uint8_t slot, rid, n, b[16]; };   // 16 payload bytes: capture full 0x87 settings frames
+// Big always-on ring: log EVERYTHING from boot (Steam writes, our TX-to-controller, link edges) so a rare
+// reconnect-buzz can be caught after the fact -- the trigger happens moments after the puck boots, while this
+// RAM is fresh, and we dump it once the panel reconnects. 4096 * 20B ~= 80KB (we have plenty of free RAM).
+#define HAPLOG_N 4096
+static HapLog   g_hapLog[HAPLOG_N];
+static uint16_t g_hapHead = 0;
+static uint16_t g_hapTail = 0;  // live/dump drain cursor (loop-context reader; chases g_hapHead)
 
 void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n){
+  // Written from the USB SET ISR (handleSet) AND loop context (relay flush / link edges) -> guard g_hapHead.
+  // Special slot markers for the diagnostic capture: 0xFE = a frame WE transmitted to the controller (TX
+  // relay); 0xFD = a link state edge (b[0]=1 up, 0 down). Real Steam writes use the interface index (0..3).
+  uint32_t pm = __get_PRIMASK(); __disable_irq();
   HapLog &e = g_hapLog[g_hapHead]; e.ms = millis(); e.slot = slot; e.rid = rid; e.n = (uint8_t)(n>255?255:n);
-  for(int i=0;i<12;i++) e.b[i] = (i<(int)n) ? b[i] : 0;
+  for(int i=0;i<16;i++) e.b[i] = (i<(int)n) ? b[i] : 0;
   g_hapHead = (uint8_t)((g_hapHead+1) % (sizeof g_hapLog / sizeof g_hapLog[0]));
+  __set_PRIMASK(pm);
 }
-void hapticDumpLog(){   // dump the captured OUTPUT-report history (oldest->newest) for the haptic-whine hunt
-  const uint8_t N=sizeof g_hapLog/sizeof g_hapLog[0]; uint32_t now=millis();
-  Serial.printf("# --- haptic/OUTPUT history (now=%lu, connSlot=%d) ---\n",(unsigned long)now,g_connSlot);
-  for(uint8_t i=0;i<N;i++){ HapLog &e=g_hapLog[(g_hapHead+i)%N]; if(!e.ms && !e.rid) continue;
+void hapticDumpLog(){   // dump the captured history (oldest->newest) over CDC for the haptic-whine hunt
+  const uint16_t N=HAPLOG_N; uint32_t now=millis();
+  Serial.printf("# --- capture history (now=%lu, connSlot=%d) ---\n",(unsigned long)now,g_connSlot);
+  for(uint16_t i=0;i<N;i++){ HapLog &e=g_hapLog[(uint16_t)((g_hapHead+i)%N)]; if(!e.ms && !e.rid) continue;
     Serial.printf("# -%lums if%u rid=%02X n=%u:",(unsigned long)(now-e.ms),e.slot,e.rid,e.n);
-    for(uint8_t j=0;j<12 && j<e.n;j++) Serial.printf(" %02X",e.b[j]); Serial.println(); }
+    for(uint8_t j=0;j<16 && j<e.n;j++) Serial.printf(" %02X",e.b[j]); Serial.println(); }
   Serial.println("# --- end ---");
 }
+// ---- drain cursor: stream entries to the WebUSB panel. resetDrain(false)=from "now" (live only);
+//      resetDrain(true)=from the OLDEST entry (dump the whole ring from boot). pull skips empty slots. ----
+void hapLogResetDrain(bool fromBoot){
+  g_hapTail = fromBoot ? (uint16_t)((g_hapHead+1)%HAPLOG_N)   // oldest slot (the next-to-overwrite holds it)
+                       : g_hapHead;                            // "now"
+}
+bool hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_t bytes16[16]){
+  while(g_hapTail != g_hapHead){
+    HapLog &e=g_hapLog[g_hapTail];
+    g_hapTail=(uint16_t)((g_hapTail+1)%HAPLOG_N);
+    if(!e.ms && !e.rid) continue;                            // skip empty slot (ring not yet full)
+    // Return the ABSOLUTE log time (millis since boot), not "age now". The panel drains in batches 100ms
+    // apart, so an age computed here would jump between batches; the panel computes age vs the newest entry.
+    *logMs=e.ms; *slot=e.slot; *rid=e.rid; *n=(e.n>16)?16:e.n; memcpy(bytes16,e.b,16);
+    return true;
+  }
+  return false;
+}
+#endif // OPK_LOG
 
 bool hapticLinkUp(){
   return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
@@ -53,9 +108,13 @@ static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
   for(uint16_t i=2;i<n;i++) if(p[i]) return true;   // observed form is [01 01 gain], but treat any trailing non-zero as active
   return false;
 }
-static void hapticCancelPendingOn(){
-  if(g_relayPend && g_relayBuf[0]==0x82 && haptic82PayloadOn(g_relayBuf+2, g_relayBuf[1]))
-    g_relayPend = false;
+static void hapticCancelPendingOn(){   // void queued 0x82-ON entries (stale Steam haptics across a reconnect)
+  uint32_t pm = __get_PRIMASK(); __disable_irq();
+  for(uint8_t i=g_rqTail; i!=g_rqHead; i=rqNext(i)){
+    RelayMsg &m = g_rq[i];
+    if(m.rid==0x82){ bool on=false; for(uint8_t j=2;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
+  }
+  __set_PRIMASK(pm);
 }
 void haptic82HostReport(const uint8_t* p, uint16_t n){
   if(n<3) return;
@@ -66,39 +125,99 @@ void haptic82HostReport(const uint8_t* p, uint16_t n){
   // "click"/buzz that the real puck never produces. (Connect-time clearing still runs in hapticTask().)
   g_haptic82On = haptic82PayloadOn(p,n);
 }
-// Queue a pending host haptic / test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
+// Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
 void rfConnQueueHapticRelay(){
-  if(g_relayPend) return;
+  if(relayPending()) return;            // host relays first; injectables wait for an idle cycle
+  static const uint8_t HAP_ON[3]  = {0x01,0x01,0xF7};
+  static const uint8_t HAP_OFF[3] = {0x01,0x01,0x00};
   if(g_testHaptic){
-    g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0xF7; g_relayPend=true; g_testHaptic--;
+    if(relayEnqueue(0x82, HAP_ON, 3)) g_testHaptic--;
   } else if(g_hapticStop && !g_xbox){
-    g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0x00; g_relayPend=true; g_hapticStop--;
+    if(relayEnqueue(0x82, HAP_OFF, 3)) g_hapticStop--;
   }
 }
-void rfConnFlushRelay(uint8_t ch){
-  if(!g_relayPend) return;
-  uint8_t rid=g_relayBuf[0], rl=g_relayBuf[1]; if(rl>18)rl=18;
-  uint8_t p[24]; p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=g_relaySub; p[3]=rid;
-  for(uint8_t i=0;i<rl;i++) p[4+i]=g_relayBuf[2+i];
-  rfConnTx(ch,0x07,p,(uint8_t)(4+rl));
-  g_relayPend=false;
+void rfConnFlushRelay(uint8_t ch, uint8_t s1){
+  while(g_rqTail != g_rqHead){
+    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);
+      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);
+      return;                           // ONE relay per poll cycle (matches the real puck's pacing)
+    }
+    g_rqTail = rqNext(g_rqTail);
+  }
 }
 
+// Haptic-subsystem RE-INIT: the exact sequence Steam sends (captured on hardware) when it (re)takes control,
+// and which clears a stuck haptic script on the controller -- a 0x81 reset action plus 0x87 writes to the
+// 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).
+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};
+  static const uint8_t H2D[]={0x2d,0x64,0x00};                       // brightness (Steam sets it here too)
+  static const uint8_t T81A[]={0x00,0x00,0x00,0x00,0x00,0x00,0x00};
+  static const uint8_t T81B[]={0x01,0x00,0x00,0x00,0x00,0x00,0x00};
+  relayEnqueue(0x81, nullptr, 0);            // reset action (FUN_0001f554) -- Steam sends this first
+  relayEnqueue(0x87, H30, sizeof H30);
+  relayEnqueue(0x87, H18, sizeof H18);
+  relayEnqueue(0x87, H35, sizeof H35);
+  relayEnqueue(0x87, H2D, sizeof H2D);
+  relayEnqueue(0x81, T81A, sizeof T81A);
+  relayEnqueue(0x81, T81B, sizeof T81B);
+}
 void hapticInit(){
-  g_relayPend=false; g_haptic82On=false;
+  g_rqHead = g_rqTail = 0; g_haptic82On=false;
   g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // boot: block stale Steam 0x82 until link stable
-  g_hapticStop = HAPTIC_STOP_BURST;
+  // NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
+  // zero-gain [01 01 00] "stop". Injecting our invented stop frame (which the real puck never sends) at
+  // boot/connect is unfaithful and, given the controller's "haptic script already active -> ignore" behavior,
+  // a likelier cause of the connect-time buzz than a cure (the buzz happened WITH this burst in place). Be a
+  // pure pass-through of Steam's haptics, like the real puck.
+  g_hapticStop = 0;
+}
+// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called from rf_link the moment a
+// controller reply arrives after a gap (the reliable reconnect signal), and as a backup on hapticTask's
+// link-up edge. Idempotent -- safe to call repeatedly.
+void hapticOnReconnect(){
+  g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // no haptics relayed for the next 3s
+  g_haptic82On = false;
+  hapticCancelPendingOn();                                     // drop any haptic ON queued before the link came up
+  // Be PROACTIVE: start re-initing the haptic engine right after the link is up and repeat across the whole
+  // settle window, instead of waiting for the block to end. The brief connect buzz engages early (during the
+  // block, controller-internal), so a single late shot misses it -- frequent early resets cut it short or
+  // preempt it. The re-init is settings only (no haptic play), and Steam haptics are blocked the whole time,
+  // so repeating it can't itself buzz.
+  g_reinitAt = millis() + 200u;                               // first reset ~200ms after (re)connect
+  g_reinitLeft = HAPTIC_REINIT_SHOTS;                          // then every HAPTIC_REINIT_GAP_MS across the window
+  uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+reinit armed)
 }
 void hapticTask(){
   static bool wasHapticLinkUp=false;
   bool up=hapticLinkUp();
-  if(up && !wasHapticLinkUp){
-    g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;
-    g_haptic82On = false;
-    hapticCancelPendingOn();
-    g_hapticStop = HAPTIC_STOP_BURST;
-  }
+  // Link-edge markers are diagnostic only now -- the block/re-init is armed reliably from rf_link
+  // (hapticOnReconnect) on the first reply after a gap, which fires even when this 300ms edge doesn't.
+  if(up && !wasHapticLinkUp){ uint8_t mk=1; hapLogAdd(0xFD,0xEE,&mk,1); hapticOnReconnect(); }
+  if(!up && wasHapticLinkUp){ uint8_t mk=0; hapLogAdd(0xFD,0xEE,&mk,1); }
   wasHapticLinkUp=up;
+  if(g_reinitAt && up && (int32_t)(millis()-g_reinitAt) >= 0){   // proactive haptic re-init across the connect window
+    hapticReinit();
+    g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+HAPTIC_REINIT_GAP_MS) : 0;
+  }
   // 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.

+ 55 - 15
OpenPuck/haptics.h

@@ -1,43 +1,83 @@
-// haptics.h -- host -> controller haptic relay + the watchdogs that stop a stuck buzz.
+// haptics.h -- host -> controller relay queue (haptics + settings) + the watchdogs that stop a stuck buzz.
 //
-// Steam (and legacy XInput rumble) send OUTPUT reports the real dongle forwards to the controller as a SET
-// sub-TLV inside the E3 poll. We do the same: handleSet()/the console queue a relay into g_relayBuf, and
-// rfConnFlushRelay() emits it inside the poll cadence (rf_link.cpp), never at raw loop rate.
+// Steam (and legacy XInput rumble) send OUTPUT/feature reports the real dongle forwards to the controller as
+// a SET sub-TLV inside the E3 poll. We do the same: handleSet()/the console enqueue with relayEnqueue(), and
+// rfConnFlushRelay() emits ONE entry per poll cycle (rf_link.cpp), never at raw loop rate.
+//
+// The queue is a small ring, NOT a single buffer: producers run in USB-ISR context while the consumer may be
+// mid-flush in loop context, and Steam writes settings/calibration as bursts of back-to-back feature reports.
+// The old single g_relayBuf both LOST every report that arrived before the previous one flushed and could be
+// TORN mid-copy by the ISR (a half-old-half-new command on the air) -- the settings-never-save and the
+// corrupted-haptic-latch bugs. Entries carry up to RELAY_MAXP payload bytes (the RF frame fits 60: [E3][len]
+// [05][rid] + payload <= MAXLEN 64); the old path truncated everything to 18, which chopped any multi-register
+// 0x87 settings block (LED brightness) and calibration write.
 //
 // The controller's haptic LATCHES until told to stop. If the host's stop is lost over RF (or the link drops
-// mid-buzz) the actuator whines forever -- so we actively send 0x82-zero stop bursts on reconnect/silence and
-// gate relays on link + slot + lizard state. The g_hapLog ring captures recent OUTPUT reports for the 'H' dump.
+// mid-buzz) the actuator whines forever -- so we send 0x82-zero stop bursts on reconnect, but ONLY when a
+// haptic was actually active when the link dropped: every extra 0x82 frame is an audible click on the
+// controller (captured), so the old unconditional burst on each link-up edge was itself a buzz source on a
+// flapping link. The g_hapLog ring captures recent OUTPUT reports for the 'H' dump.
 #pragma once
 #include <stdint.h>
+#include "config.h"   // OPK_LOG
 
 // after this much host silence, consider the current 0x82 haptic stream inactive
 #define HAPTIC_QUIET_MS    300u
-// drop stale host 0x82 after RF reconnect (Steam floods on attach)
-#define HAPTIC_RECONNECT_BLOCK_MS 1500u
+// Block ALL haptic relays for this long after a (re)connect: a just-powered-on controller's haptic engine
+// isn't ready, and feeding it haptics in that window leaves it in a degraded/latched state (slow/stuck/missing
+// haptics until a re-init). 3s gives it time to initialize. (Was 1500.)
+#define HAPTIC_RECONNECT_BLOCK_MS 3000u
 // 0x82-zero relays per stop event (sent at poll cadence -- not loop rate)
 #define HAPTIC_STOP_BURST  4u
+// max relayed payload bytes per entry: RF frame = [E3][len][05][rid][payload] and MAXLEN=64 -> 60
+#define RELAY_MAXP 60u
+// Proactive post-(re)connect haptic re-init: this many shots, this far apart, starting ~200ms after the link
+// comes up -- covers ~0.2s..3s so the brief controller-side connect buzz gets reset before it can sustain.
+#define HAPTIC_REINIT_SHOTS  8u
+#define HAPTIC_REINIT_GAP_MS 350u
 
 // ---- relay queue (written by puck_hid.cpp, mode_xinput.cpp, serial_console.cpp; drained by rf_link.cpp) ----
-extern volatile bool    g_relayPend;
-extern uint8_t          g_relayBuf[24];
-extern volatile uint8_t g_relayN;
+// Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
+// goes on the air). ISR-safe (brief PRIMASK critical section). Returns false (dropped) when the ring is full.
+bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen);
+bool relayPending();                 // anything still queued (xinput uses it to pace rumble re-queues)
 extern uint8_t          g_relayOp;   // relay frame opcode (E3 poll)
 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;
 
+// ---- 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. ----
+#if OPK_LOG
 void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n);
 void hapticDumpLog();   // 'H' console dump of the recent OUTPUT-report history
+// WebUSB capture drain: resetDrain(true) rewinds to the OLDEST entry (dump the whole ring from boot);
+// resetDrain(false) starts at "now" (live only). pull yields each entry once, oldest->newest, skipping empties.
+void    hapLogResetDrain(bool fromBoot);
+bool    hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_t bytes16[16]);
+#else
+static inline void hapLogAdd(uint8_t, uint8_t, const uint8_t*, uint16_t){}
+static inline void hapticDumpLog(){}
+static inline void hapLogResetDrain(bool){}
+static inline bool hapLogPull(uint32_t*, uint8_t*, uint8_t*, uint8_t*, uint8_t*){ return false; }
+#endif
 
 bool hapticLinkUp();
 bool haptic82Blocked();
 bool hapticRelaySlotOk(int slot);
 void haptic82HostReport(const uint8_t* p, uint16_t n);
 
-// queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link)
+// queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
+// rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
+// PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
 void rfConnQueueHapticRelay();
-void rfConnFlushRelay(uint8_t ch);
+void rfConnFlushRelay(uint8_t ch, uint8_t s1);
 
-void hapticInit();   // boot reset: clear relay/active flags, arm the reconnect block + initial stop burst
-void hapticTask();   // per-loop upkeep: reconnect-block edge handling + steam 0x82 quiet timeout
+void hapticInit();   // boot reset: clear relay/active flags, arm the reconnect block
+void hapticTask();   // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
+void hapticReinit(); // replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz
+// Called from rf_link the instant a controller (re)connect is detected (an F-reply after a gap): blocks haptic
+// relays for HAPTIC_RECONNECT_BLOCK_MS and schedules a re-init just after, to keep the freshly-booted
+// controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
+void hapticOnReconnect();

+ 2 - 2
OpenPuck/mode_xinput.cpp

@@ -161,11 +161,11 @@ void XboxController::onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen
 // re-queued ~40/s like Steam's glide haptic so it sustains. Not active in the current HID Xbox presentation.
 void XboxController::task(){
   if (g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;
-  if (g_rumble && !g_relayPend && g_connSlot>=0) {
+  if (g_rumble && !relayPending() && g_connSlot>=0) {
     static unsigned long lastRumble=0;
     if (millis()-lastRumble>=25) { lastRumble=millis();
       uint8_t gain = g_rumble<0x30?0x30:g_rumble;   // floor so low rumble is still feelable
-      g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=gain; g_relayPend=true;
+      uint8_t pl[3]={0x01,0x01,gain}; relayEnqueue(0x82,pl,3);
     }
   }
 }

+ 50 - 15
OpenPuck/puck_hid.cpp

@@ -3,6 +3,7 @@
 #include "config.h"
 #include "identity.h"
 #include "haptics.h"
+#include "rf_link.h"
 #include "triton.h"
 #include "mode_lizard.h"
 #include <Adafruit_TinyUSB.h>
@@ -82,13 +83,18 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
                                    // driving -> leave lizard for gamepad NOW, so a haptic that arrives before the
                                    // first 0x87 doesn't get relayed while we're still presenting lizard (-> buzz loop).
     }
-    if (rid >= 0x80 && rid <= 0x86 && n >= 1 && hapticRelaySlotOk(slot) && !lizardActive()) {  // wrap as a SET sub-TLV like the report-01 path
+    // Post-resume mute also gates haptics: while onReport45 is muted Steam reads NO 0x45 back, which is the
+    // exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop). Same logic
+    // as the lizard gate; the mute is short (1.5s), so a dropped wake chime is the cheap end of the trade.
+    bool muted = g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS;
+    if (rid >= 0x80 && rid <= 0x86 && n >= 1 && hapticRelaySlotOk(slot) && !lizardActive() && !muted) {  // wrap as a SET sub-TLV like the report-01 path
       if (!haptic82Blocked()) {
-        uint8_t m = n > (uint16_t)(sizeof g_relayBuf - 2) ? (sizeof g_relayBuf - 2) : n;
-        g_relayBuf[0] = rid; g_relayBuf[1] = m; memcpy(g_relayBuf + 2, b, m);
-        g_relayN = m + 2; g_relayPend = true;
+        relayEnqueue(rid, b, (uint8_t)(n > RELAY_MAXP ? RELAY_MAXP : n));
+        // Track on/off from what was actually RELAYED (= the controller's believed state), not from every host
+        // report: a BLOCKED stop must leave "on" set (the controller may be latched -> reconnect stop-burst),
+        // and a blocked ON must not set it (nothing reached the controller -> no spurious burst clicks).
+        if (rid == 0x82) haptic82HostReport(b, n);
       }
-      if (rid == 0x82) haptic82HostReport(b, n);  // on/off tracking is only meaningful for the 0x82 stream
     }
     if (Serial.availableForWrite() > 80) {                      // log so we can see what Steam actually sends (e.g. glide haptics)
       Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid, n);
@@ -103,14 +109,23 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
   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
   if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
-    if (cmd >= 0x80 && cmd <= 0x89) hapLogAdd((uint8_t)slot, cmd, b, n);   // capture feature-passthrough haptics/LED too (rid shown = cmd; bytes start [cmd][len]...)
+    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
+                                           // shows cmd as "rid"; bytes start [cmd][len]...  Was gated to 0x80-0x89,
+                                           // which hid exactly the LED/power-off frames we now need to see.
     bool haptic82 = (cmd == 0x82 && len <= pln);
-    bool relayOk = hapticRelaySlotOk(slot) && !(haptic82 && lizardActive());  // never push haptics to the controller while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
+    bool muted = g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS;   // see the OUTPUT path: no haptics while Steam can't read 0x45 back
+    bool relayOk = hapticRelaySlotOk(slot) && !(haptic82 && (lizardActive() || muted));  // never push haptics to the controller while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
     if (relayOk && (!haptic82 || !haptic82Blocked())) {
-      uint16_t m = n > sizeof g_relayBuf ? sizeof g_relayBuf : n;
-      memcpy(g_relayBuf, b, m); g_relayN = m; g_relayPend = true;
+      // Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's multi-register
+      // 0x87 settings blocks (LED brightness) and calibration writes are longer than the old 18B cap, and the
+      // chopped frames were why those settings never landed on the controller.
+      uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
+      if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
+        Serial.printf("# RELAY TRUNC cmd=%02X len=%u>%u\n", cmd, len, (unsigned)RELAY_MAXP);
+      relayEnqueue(cmd, pl, rl);
+      if (haptic82) haptic82HostReport(pl, len);  // track from RELAYED frames only (see the OUTPUT path)
     }
-    if (haptic82) haptic82HostReport(pl, len);  // Steam can send haptics through feature passthrough too
   }
   if (Serial.availableForWrite() > 80) {   // log host feature writes (non-blocking)
     Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid, cmd, len);
@@ -241,15 +256,35 @@ void SteamPuckController::task(){
     if(wasSusp && !susp) g_resumeMs=millis();
     wasSusp=susp; }
   if (USBDevice.suspended()) return;   // no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
-  static bool usbConn=false; static unsigned long last79=0, last7B=0;
+  static bool usbConn=false; static unsigned long last79=0, last7B=0, connEdgeMs=0;
   bool conn = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
   if (g_connSlot>=0 && g_connSlot<NSLOT && hid[g_connSlot].ready()){
-    // 0x79 connection state: on edge AND repeated every 750ms while connected (Steam can miss the edge, and
-    // the 0x45 stream can starve the endpoint at the transition instant -> resend until it sticks).
-    if (conn!=usbConn || (conn && millis()-last79>=750)){
+    // 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
+    // settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; the old
+    // unconditional forever-resend could re-trigger Steam's connect handling (connect chime) every 750ms
+    // before Steam started consuming 0x45 -> the connect-time loop of haptic buzzes that only stopped on the
+    // first real input (the Steam button). Resending until acked still covers "Steam missed the edge".
+    bool steamAcked = g_steamAliveMs && (int32_t)(g_steamAliveMs - connEdgeMs) >= 0;
+    if (conn!=usbConn || (conn && !steamAcked && millis()-last79>=750)){
+      if (conn && !usbConn) connEdgeMs = millis();
       uint8_t st=conn?0x02:0x01; hid[g_connSlot].sendReport(0x79,&st,1); usbConn=conn; last79=millis();
     } else if (conn && millis()-last7B>=2000){
-      static const uint8_t s7b[12]={0xF7,0x01,0x89,0x00,0x00,0x00,0x03,0x00,0xDD,0x00,0x3A,0x02};
+      // 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed dBm
+      // (capture showed 0xDD = -35; replaying it verbatim is why Steam pinned -35dBm forever) -- patch in the
+      // smoothed RSSI the radio now samples on each controller reply (rf_link). 0 = no sample yet -> keep the
+      // capture value rather than report garbage.
+      //
+      // CALIBRATION: our raw RSSISAMPLE reads ~RSSI_DBM_OFFSET dB lower than the real Valve puck at the same
+      // distance (the Pro Micro's PCB-trace antenna vs Valve's tuned front-end). The 2Mbit ESB link has ~55dB
+      // of margin, so a -75dBm reading still works across a house -- but Steam's bar maps raw dBm to "weak"
+      // long before that. Adding the offset lines our close-range value up with the puck's captured -35 so the
+      // bar tracks usable range instead of antenna gain. Clamp keeps it in a sane window (no wrap/garbage).
+      uint8_t s7b[12]={0xF7,0x01,0x89,0x00,0x00,0x00,0x03,0x00,0xDD,0x00,0x3A,0x02};
+      if (g_linkRssi){
+        int mag = (int)g_linkRssi - RSSI_DBM_OFFSET;
+        if (mag < 25) mag = 25; else if (mag > 95) mag = 95;
+        s7b[8]=(uint8_t)(0u-(uint8_t)mag);
+      }
       hid[g_connSlot].sendReport(0x7B,s7b,12); last7B=millis();
     }
   } else if(!conn) usbConn=false;

+ 42 - 8
OpenPuck/rf_link.cpp

@@ -36,6 +36,10 @@ unsigned long g_qosCheckMs = 0, g_qosLastHopMs = 0;
 
 uint16_t g_f1ps = 0;
 uint16_t g_newps = 0;
+uint16_t g_pollsps = 0;   // polls/s (GET+relay TXs) last second -- distinguishes loop-starvation from reply-loss
+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)
 
 // ---- internal counters / timers ----
 static uint8_t  g_e3pid = 0;
@@ -83,7 +87,11 @@ static void rfHostFrameOnce(int slot, bool discovery){
   NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
   NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
   NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<800){}
+  // Discovery/pairing beacons listen for the controller's response (matters for RE/pairing); the connected
+  // session keepalive expects NO response (the controller answers E3 polls, not the beacon), so don't burn
+  // 800us of dead air per frame -- that was the bulk of the idle poll-rate deficit (40 beacons/s x slots).
+  uint16_t bwin = discovery ? 800u : 150u;
+  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<bwin){}
   if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;   // ANY reception = controller answered our frame
     g_rfRxCount++; bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
     if (Serial.availableForWrite()>90){                  // non-blocking: don't stall the loop on CDC backpressure
@@ -101,7 +109,8 @@ void rfHopTo(uint8_t newCh){
   g_rfCh=savedRfCh;                          // poll + session beacon now run on g_sessCh=newCh
 }
 
-uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, uint16_t rxWinUs){
+  uint16_t win = rxWinUs ? rxWinUs : g_rxWin;   // relays pass a tiny window (no reply expected); polls use g_rxWin
   memset(rftx,0,sizeof rftx);
   rftx[0]=plen;                          // LENGTH = payload byte count
   rftx[1]=s1;                            // S1 (type-specific)
@@ -112,10 +121,12 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
   NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
   RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
   NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;       // RXEN->READY->START; catch the reply
+  // RXEN->READY->START; catch the reply. ADDRESS->RSSISTART samples the reply's signal strength (read from
+  // RSSISAMPLE below, surfaced to Steam via report 0x7B); DISABLED->RSSISTOP closes the measurement.
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_ADDRESS_RSSISTART_Msk|RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
   NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
   g_stPoll++;
-  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<g_rxWin){}  // RX window (tunable 'r')
+  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<win){}  // RX window (tunable 'r'; or relay override)
   uint8_t rxlen=0;
   if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
     bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
@@ -127,7 +138,15 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
       // beacons (0xE1) + polls (0xE2/E3/E7) -- all E-type. Without this gate, puck A receives a SECOND puck's
       // 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
       // connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
-      if (rtype >= 0xF0){ g_connRx++; g_connReplyMs=millis(); }  // link alive -> loop() suppresses the redundant E1 beacon
+      if (rtype >= 0xF0){                                       // F-type reply (F1/F2/F3) -> our controller is alive
+        // A reply after a long gap (or the first ever) = a (re)connect. Arm the haptic block + re-init HERE,
+        // directly off the reply stream -- reliable even when hapticTask's 300ms link-up edge doesn't fire
+        // (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
+        if (g_connReplyMs==0 || (uint32_t)(millis()-g_connReplyMs) > 1500u) hapticOnReconnect();
+        g_connRx++; g_connReplyMs=millis();                     // link alive -> loop() suppresses the redundant E1 beacon
+        uint8_t rs=(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);      // |dBm| of this reply (started by the ADDRESS short)
+        if(rs) g_linkRssi = g_linkRssi ? (uint8_t)((g_linkRssi*7u + rs + 4u)/8u) : rs;   // EWMA, ~8-sample horizon
+      }
       if(rtype==0xF1) g_stF1++;
       if(rtype==0xF2) g_connCooldown=millis();          // controller disconnecting/powering off -> back off 2.5s
       if(rtype==0xF3){                                  // F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
@@ -221,10 +240,24 @@ static void rfConnStep(){
     if(++g_connStep>=4){ g_connSt=1; g_connStep=0; Serial.println("# CONN: awake announced -> polling GET report 0x45"); }
   } else {                               // poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
     if((uint32_t)(micros()-g_lastPollUs) < g_pollUs) return;   // CONTROLLED CADENCE: poll ~every g_pollUs
-    g_lastPollUs=micros();                                     // (over-polling starves replies)
+    // FIXED-RATE schedule: advance the deadline by exactly one interval. The old `g_lastPollUs = micros()`
+    // reset the reference to AFTER the gate passed, so each cycle's ~1ms of poll work + loop jitter was added
+    // to the next interval and never reclaimed -- the period drifted to ~5000us (200/s) instead of 4000us
+    // (250/s). Advancing by g_pollUs keeps the long-run average at exactly the target; the resync guard
+    // prevents a catch-up burst if we ever fall more than a full interval behind (a real stall).
+    { uint32_t now=micros();
+      static uint32_t lastFire=0;
+      if(lastFire){ g_pollDtSum += (uint32_t)(now-lastFire); g_pollDtCnt++; }   // MEASURED actual period
+      lastFire=now;
+      g_lastPollUs += g_pollUs;
+      if((uint32_t)(now - g_lastPollUs) >= g_pollUs) g_lastPollUs = now;        // fell >1 interval behind -> resync
+    }
     if((g_connPoll & 0x1F)==0){ uint8_t pa[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,pa,3); }   // re-assert awake/version
     rfConnQueueHapticRelay();
-    rfConnFlushRelay(ch);
+    // Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
+    // and the controller never dedups the GET as a retransmit of the relay (that was dropping ~half the replies
+    // during haptics). Advancing g_e3pid even when no relay is pending just skips a PID value (harmless).
+    { uint8_t rs1=(uint8_t)((((g_e3pid++)&3)<<1)|1); rfConnFlushRelay(ch, rs1); }
     {
       uint8_t p[5]={0xE3,0x02,0x01,0x45,g_getParam}; // E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
       uint8_t s1 = (g_e3mode==1) ? (uint8_t)((((g_e3pid++)&3)<<1)|1)   // cycle PID (S1 1,3,5,7), NO_ACK=1
@@ -268,5 +301,6 @@ void rfLinkTask(){
       rfHopTo(g_hopCand[g_hopIdx]); g_qosLastHopMs=millis();
     }
   }
-  if (g_connOn && millis()-g_stMs>=1000){ g_f1ps=g_stF1; g_newps=g_stNew; if(Serial.availableForWrite()>70) Serial.printf("# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",(unsigned long)g_stPoll,(unsigned long)g_stF1,(unsigned long)g_stNew,(unsigned long)g_stF3,(int8_t)g_connF3v,g_e7b,(unsigned long)g_stCrc,(unsigned long)g_stNoRx,g_connSlot); g_stPoll=0; g_stF1=0; g_stNew=0; g_stF3=0; g_stCrc=0; g_stNoRx=0; g_chF1[0]=g_chF1[1]=g_chF1[2]=0; g_stMs=millis(); }
+  if (g_connOn && millis()-g_stMs>=1000){ g_f1ps=g_stF1; g_newps=g_stNew; g_pollsps=(uint16_t)g_stPoll;
+    g_pollPeriodUs = g_pollDtCnt ? (uint16_t)(g_pollDtSum/g_pollDtCnt) : 0; g_pollDtSum=0; g_pollDtCnt=0; if(Serial.availableForWrite()>70) Serial.printf("# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",(unsigned long)g_stPoll,(unsigned long)g_stF1,(unsigned long)g_stNew,(unsigned long)g_stF3,(int8_t)g_connF3v,g_e7b,(unsigned long)g_stCrc,(unsigned long)g_stNoRx,g_connSlot); g_stPoll=0; g_stF1=0; g_stNew=0; g_stF3=0; g_stCrc=0; g_stNoRx=0; g_chF1[0]=g_chF1[1]=g_chF1[2]=0; g_stMs=millis(); }
 }

+ 13 - 1
OpenPuck/rf_link.h

@@ -40,9 +40,21 @@ extern unsigned long g_qosCheckMs, g_qosLastHopMs;
 // per-second rate readouts for the WebUSB status blob
 extern uint16_t g_f1ps;         // last completed second's F1 rate
 extern uint16_t g_newps;        // genuine new-report rate (report 0x45 seq byte changes)
+extern uint16_t g_pollsps;      // last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
+extern uint16_t g_pollPeriodUs; // MEASURED avg us between GET-poll fires (compare to the intended g_pollUs=4000)
+
+// Smoothed controller->puck signal strength, sampled by the radio (RSSISAMPLE) on each CRC-good controller
+// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. puck_hid reports it
+// to Steam in status report 0x7B byte 8 (signed dBm), after subtracting RSSI_DBM_OFFSET.
+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
 
 // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
-uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen);
+// rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
+// no reply, so they don't burn a full ~1.2ms window of dead air per haptic.
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, uint16_t rxWinUs=0);
 // Mid-session channel hop: advertise newCh on the current channel a few times, then move the poll to newCh.
 void rfHopTo(uint8_t newCh);
 // Per-loop: host-frame beacons + connected-mode poll + remote-wakeup + QoS hop + per-second stats.

+ 1 - 1
OpenPuck/serial_console.cpp

@@ -56,7 +56,7 @@ void serialConsolePoll(){
       else if (line[0]=='W'){ g_abSwap=!g_abSwap; saveCfg(); Serial.printf("# A/B + X/Y swap %s (Nintendo layout)\n",g_abSwap?"ON":"off"); }
       else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)"); }
       else if (line[0]=='J'){ char* sp=0; uint8_t id=strtoul(line+1,&sp,0); uint16_t val=sp?strtoul(sp,0,0):0;  // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
-        g_relayBuf[0]=0x87; g_relayBuf[1]=3; g_relayBuf[2]=id; g_relayBuf[3]=val&0xFF; g_relayBuf[4]=val>>8; g_relayN=5; g_relayPend=true;
+        uint8_t pl[3]={id,(uint8_t)(val&0xFF),(uint8_t)(val>>8)}; relayEnqueue(0x87,pl,3);
         Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }
       else if (line[0]=='G'){ g_sniff=!g_sniff; g_sniffPh=0; g_rfHost=g_connOn=g_rfCap=g_rfAuto=g_rfReplay=false; if(g_sniff) rfSniffStart(); Serial.printf("# SESSION SNIFF %s (phase0: catch host frame on ibex/ch2; phase1 PARK ch=%s)\n",g_sniff?"ON":"off",g_sniffPark?"override":"primary"); }
       else if (line[0]=='g'){ g_sniffPark=strtoul(line+1,0,10); if(g_sniffPh==1) rfSniffStart(); Serial.printf("# sniff park channel=%u (0=learned primary)\n",g_sniffPark); }

+ 40 - 4
OpenPuck/webusb_config.cpp

@@ -11,8 +11,10 @@ Adafruit_USBD_WebUSB usb_web;
 
 // blob payload = [ver=2][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
-//                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y]
-#define WB_PAYLEN 26
+//                [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
 static void webusbSendBlob(){
   if(!usb_web.connected()) return;
   bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
@@ -29,8 +31,37 @@ static void webusbSendBlob(){
   p[19]=g_e7b;
   p[20]=g_relayOp; p[21]=g_relaySub; p[22]=g_fwdNewOnly; p[23]=g_qos; p[24]=g_persistMode?1:0;
   p[25]=g_chordBtn[0]; p[26]=g_chordBtn[1]; p[27]=g_chordBtn[2];
+  p[28]=(uint8_t)g_pollsps; p[29]=(uint8_t)(g_pollsps>>8);   // poll TX rate (vs delivered/new -> starvation vs reply-loss)
+  p[30]=(uint8_t)g_loopPeriodUs; p[31]=(uint8_t)(g_loopPeriodUs>>8);   // loop diag: avg iteration time + slowest section
+  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
   usb_web.write(p,sizeof p); usb_web.flush();
 }
+#if OPK_LOG
+// Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames, so the browser can
+// pull it with no CDC. Frame formats:
+//   entry: [0xA6][L][T=1][age u32 LE][slot][rid][n][bytes n]   (L = 8 + n)
+//   end:   [0xA6][1][T=0]
+static void webusbCapFrame(uint32_t ms,uint8_t slot,uint8_t rid,uint8_t nb,const uint8_t* b){
+  if(nb>16) nb=16;
+  uint8_t f[2+9+16]; uint8_t L=(uint8_t)(8+nb);
+  f[0]=0xA6; f[1]=L; f[2]=1;
+  f[3]=(uint8_t)ms; f[4]=(uint8_t)(ms>>8); f[5]=(uint8_t)(ms>>16); f[6]=(uint8_t)(ms>>24);  // absolute log time
+  f[7]=slot; f[8]=rid; f[9]=nb; memcpy(f+10,b,nb);
+  usb_web.write(f,(uint16_t)(2+L)); usb_web.flush();
+}
+static void webusbCapEnd(){ uint8_t e[3]={0xA6,1,0}; usb_web.write(e,3); usb_web.flush(); }
+// 0x06: drain -- entries since the last drain (or since the 0x05 start/rewind). The panel polls this on a
+// timer and accumulates, so a dump-from-boot of the whole ring streams over many polls without blocking the
+// loop. A per-call budget bounds how long one poll spends here.
+static void webusbDrainCapture(){
+  if(!usb_web.connected()) return;
+  uint32_t ms=0; uint8_t slot=0,rid=0,nb=0,b[16]; uint16_t budget=128;
+  while(budget-- && hapLogPull(&ms,&slot,&rid,&nb,b)) webusbCapFrame(ms,slot,rid,nb,b);
+  webusbCapEnd();
+}
+#endif // OPK_LOG
 void webusbPoll(){
   static uint8_t buf[16]; static uint8_t n=0;
   while(usb_web.available()){
@@ -39,12 +70,17 @@ 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)?2 : (op==0x01)?1 : 1;
-      if(op!=0x01 && op!=0x02 && op!=0x03){ // resync: drop one byte
+      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
         memmove(buf,buf+1,--n); continue;
       }
       if(n<need) break;                      // wait for more bytes
       if(op==0x01){ webusbSendBlob(); }
+#if OPK_LOG
+      else if(op==0x05){ hapLogResetDrain(buf[1]!=0); }   // rewind drain: buf[1]=1 from boot (whole ring), 0 from now
+      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==0x02){
         uint8_t f=buf[1], v=buf[2];
         bool persist=true;   // every settable field persists (poll rate is no longer settable)

+ 23 - 1
docs/PROTOCOL.md

@@ -88,6 +88,26 @@ When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the con
 
 This is how settings writes, haptics, and lizard-disable requests reach the controller.
 
+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.
+
+### 3.4 Connection presentation (input reports `0x79` / `0x7B`)
+
+- `0x79` (1 byte): connection state, `0x02` = connected, `0x01` = disconnected. **Edge-triggered**, like the
+  real puck. OpenPuck additionally re-sends it every 750 ms after a connect edge *until Steam reacts* (its
+  first OUTPUT/settings write after the edge), covering a missed edge. It must not be re-sent unconditionally:
+  each `0x79=02` can re-trigger Steam's connect handling (connect chime) and loop the haptic before Steam
+  starts consuming `0x45`.
+- `0x7B` (12 bytes): periodic status, sent every 2 s while connected. Byte 8 is the controller→puck signal
+  strength as **signed dBm** (live-captured example `0xDD` = −35 dBm). OpenPuck fills it with the smoothed
+  radio RSSI sampled on each CRC-good controller reply (`RSSISAMPLE`, started by an `ADDRESS→RSSISTART`
+  short during the poll RX window). The other bytes are replayed from the live capture
+  (`F7 01 89 00 00 00 03 00 __ 00 3A 02`); their semantics are unconfirmed.
+
 ## 4. Radio physical layer
 
 OpenPuck uses Nordic raw radio mode configured to match the puck firmware:
@@ -297,7 +317,9 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
   that arrives before the heartbeat.
 - **Haptics are gated on this decision**: haptic reports are **never** relayed to the controller while
   the puck is presenting lizard. Relaying haptics while Steam isn't reading `0x45` back made Steam loop
-  the same command, leaving the controller buzzing; suppressing them keeps the lizard state clean.
+  the same command, leaving the controller buzzing; suppressing them keeps the lizard state clean. The
+  same gate applies during the post-resume input mute (`POST_RESUME_MUTE_MS`): Steam can't read `0x45`
+  back in that window either, so a wake-time haptic would loop identically.
 - **Which OUTPUT reports are relayed**: the haptic/actuator reports `0x80`–`0x86` are forwarded to the
   **connected slot only** (the slot gate is what stops a haptic aimed at another of the four exposed
   slots from buzzing the single controller). The 63-byte settings/config reports `0x87`/`0x88`/`0x89`

+ 104 - 2
docs/index.html

@@ -82,6 +82,14 @@
         <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>
+        <div class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
+        <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>
+      <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>
 
@@ -106,6 +114,18 @@
       <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes.</p>
     </div>
 
+    <div class="card logonly">
+      <h2>Capture — host → controller commands</h2>
+      <p class="note" style="margin-top:0">The firmware logs everything from boot into a big RAM ring: Steam's writes (<code>ifN</code>), the frames we transmit to the controller (<code>TX→ctlr</code>), and RF <code>LINK UP/DOWN</code> edges. For the reconnect buzz: trigger it (it appears moments after the puck boots / the controller reconnects), then connect here and click <b>Dump from boot</b> — the trigger is still in the ring. (Only present in a logging build, <code>-DOPK_LOG=1</code>.)</p>
+      <div class="row" style="margin:0;flex-wrap:wrap">
+        <button id="capStart" class="primary">Dump from boot</button>
+        <button id="capStop" disabled>Stop</button>
+        <button id="capDl">Download .txt</button>
+      </div>
+      <p class="note">Dump streams the whole ring (oldest→newest) and then keeps live-updating until you press Stop. <code>cmd</code> is the command/report byte, then the raw bytes.</p>
+      <pre id="capOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin-top:10px;max-height:260px;overflow:auto;white-space:pre-wrap"></pre>
+    </div>
+
     <div id="log"></div>
   </div>
 </div>
@@ -114,7 +134,7 @@
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)"];
 const CHORD_FIELD = [17, 18, 19];
 const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Start"],[11,"Guide"]];
-let dev=null, epIn=0, epOut=0, ifNum=0, polling=false;
+let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
@@ -185,6 +205,14 @@ function applyBlob(p){
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
   const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,2];   // v2 blob: chordBtn after qos/persist
+  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);          // poll TX rate
+  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);         // loop diag
+  const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
+  const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
+  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);         // MEASURED poll period
+  const pollIntended=(p.length>14?p[14]*100:0);            // intended poll interval (g_pollUs)
+  const logEnabled=(p.length>35?p[35]:0);                  // is this a logging build (-DOPK_LOG=1)?
+  for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
   // mode buttons
   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;
@@ -193,6 +221,10 @@ function applyBlob(p){
   $("#stSlot").textContent = slot===0xFF? "—" : slot;
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
+  $("#stPolls").textContent = up? polls+" /s" : "—";
+  $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
+  $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
+  $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
   // sliders (only if not being dragged)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
@@ -205,13 +237,79 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight) return; inflight=true;
+  if(!dev||inflight||capturing) return; inflight=true;   // don't let the status poll steal capture frames
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
 }
 async function setField(field,value){
   await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
 }
+function fmtCapEntry(e,maxMs){
+  const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
+  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;   // age vs the newest entry (consistent across drain batches)
+  if(e.slot===0xFD){ const k=e.bytes[0]; return `${t}════════ ${k===2?"RECONNECT (block+reinit armed)":k===1?"RF LINK UP":"RF LINK DOWN"} ════════`; }
+  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;                        // 0xFE = frame WE sent the controller
+  return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")}  n=${e.nb}:  ${hex}`;
+}
+function downloadCap(){
+  const t=window._lastCap||$("#capOut").textContent||"";
+  const a=document.createElement("a");
+  a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
+  a.download="puck-capture.txt"; a.click();
+}
+// ---- live Start/Stop capture: poll the firmware's drain (op 0x06) on a timer and accumulate in the browser ----
+let liveTimer=null, draining=false, capLines=[];   // capLines = array of {ms,slot,rid,nb,bytes}
+function renderCap(){
+  if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
+  const maxMs=capLines[capLines.length-1].ms;       // entries are chronological -> newest is last
+  const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
+  $("#capOut").textContent=t; window._lastCap=t;
+}
+async function drainTick(){
+  if(!dev||draining) return; draining=true;
+  let acc=new Uint8Array(0);
+  try{
+    await send([0x06]);
+    let done=false, guard=0;
+    while(!done && guard++<400){
+      const r=await dev.transferIn(epIn,64);
+      if(r.status!=="ok") break;
+      const d=new Uint8Array(r.data.buffer);
+      const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
+      let i=0;
+      while(i<acc.length){
+        if(acc[i]!==0xA6){ i++; continue; }
+        if(i+2>acc.length) break;
+        const L=acc[i+1]; if(i+2+L>acc.length) break;
+        const f=acc.slice(i+2,i+2+L); i+=2+L;
+        if(f[0]===0){ done=true; break; }
+        if(f[0]===1 && L>=8){
+          const ms=((f[1])|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0;
+          capLines.push({ms, slot:f[5], rid:f[6], nb:f[7], bytes:[...f.slice(8,8+f[7])]});
+        }
+      }
+      acc=acc.slice(i);
+    }
+  }catch(e){ log("drain err: "+e.message); }
+  draining=false;
+  if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
+}
+async function startCapture(){
+  if(!dev||capturing) return;
+  capturing=true; capLines=[];                       // capturing=true pauses the status poll so it can't steal frames
+  $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
+  $("#capStart").disabled=true; $("#capStop").disabled=false;
+  await send([0x05,1]);                               // rewind drain to OLDEST -> dumps the whole ring from boot
+  liveTimer=setInterval(drainTick,100);
+}
+async function stopCapture(){
+  if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
+  await drainTick();                                  // final flush
+  try{ await send([0x05,0]); }catch(e){}
+  capturing=false;
+  $("#capStart").disabled=false; $("#capStop").disabled=true;
+  renderCap(); log(`capture stopped — ${capLines.length} entries`);
+}
 async function startPolling(){
   polling=true; await refresh();
   while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
@@ -219,6 +317,10 @@ async function startPolling(){
 
 // UI wiring
 $("#connectBtn").onclick=connect;
+$("#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)"); } };
 $("#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"]){