Ver Fonte

good spot with haptic buzzes?

Jariullah Safi há 4 meses atrás
pai
commit
9a8b20406b

+ 15 - 4
OpenPuck/OpenPuck.ino

@@ -107,15 +107,17 @@ uint16_t g_loopPeriodUs = 0;   // avg loop iteration time last second (1e6/itera
 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() {
-  static uint32_t acc[7] = {0}; static uint32_t loops = 0; static unsigned long secMs = 0;
   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);   // puck: USB conn presentation; xbox: rumble relay
+  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);   // RF RE/calibration (no-op in normal use)
-  t=micros(); rfLinkTask();                   acc[4]+=(uint32_t)(micros()-t);   // beacons + connected poll + QoS + stats
+  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++;
@@ -126,4 +128,13 @@ void loop() {
     g_loopWorst = wi; g_loopWorstUs = loops ? (uint16_t)(wv/loops) : 0;
     loops=0; secMs=millis();
   }
+#else
+  webusbPoll();
+  if (g_active) g_active->task();
+  serialConsolePoll();
+  rfDiagTask();
+  rfLinkTask();
+  hapticTask();
+  ledTask();
+#endif
 }

+ 9 - 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

+ 28 - 10
OpenPuck/haptics.cpp

@@ -15,7 +15,8 @@ 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 post-reconnect haptic re-init (0 = none scheduled)
+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
@@ -43,6 +44,7 @@ bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen){
   __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[16]; };   // 16 payload bytes: capture full 0x87 settings frames
@@ -90,6 +92,7 @@ bool hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_
   }
   return false;
 }
+#endif // OPK_LOG
 
 bool hapticLinkUp(){
   return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
@@ -159,13 +162,23 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1){
 // 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(){
-  static const uint8_t H1[]={0x30,0x00,0x00,0x07,0x07,0x00,0x08,0x07,0x00,0x31,0x02,0x00,0x52,0x03,0x00};
-  static const uint8_t H2[]={0x18,0x00,0x00,0x2e,0x00,0x00,0x34,0xff,0xff,0x35,0xff,0xff,0x34,0xff,0xff};
-  static const uint8_t H3[]={0x35,0xff,0xff,0x2e,0x00,0x00};
-  relayEnqueue(0x81, nullptr, 0);          // reset action (FUN_0001f554) -- Steam sends this first
-  relayEnqueue(0x87, H1, sizeof H1);
-  relayEnqueue(0x87, H2, sizeof H2);
-  relayEnqueue(0x87, H3, sizeof H3);
+  // 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_rqHead = g_rqTail = 0; g_haptic82On=false;
@@ -184,7 +197,9 @@ 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
-  g_reinitAt = millis() + HAPTIC_RECONNECT_BLOCK_MS + 200u;    // re-init just after the block, on the settled link
+  g_reinitAt = millis() + HAPTIC_RECONNECT_BLOCK_MS + 200u;    // first re-init just after the block, on the settled link
+  g_reinitLeft = 2;                                           // ...then a 2nd ~2s later: the connect buzz can latch
+                                                              // anytime in the first few seconds, after a single shot
   uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+reinit armed)
 }
 void hapticTask(){
@@ -195,7 +210,10 @@ void hapticTask(){
   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){ g_reinitAt=0; hapticReinit(); }   // clear reconnect-stuck haptics
+  if(g_reinitAt && up && (int32_t)(millis()-g_reinitAt) >= 0){   // clear reconnect-stuck haptics (a couple of shots)
+    hapticReinit();
+    g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+2000u) : 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.

+ 10 - 0
OpenPuck/haptics.h

@@ -19,6 +19,7 @@
 // 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
@@ -42,12 +43,21 @@ extern volatile uint8_t g_testHaptic;// 't<n>' injects n test haptics for the bu
 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();

+ 16 - 2
OpenPuck/rf_link.cpp

@@ -37,6 +37,8 @@ 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 ----
@@ -238,7 +240,18 @@ 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();
     // Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
@@ -288,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; g_pollsps=(uint16_t)g_stPoll; 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(); }
 }

+ 1 - 0
OpenPuck/rf_link.h

@@ -41,6 +41,7 @@ extern unsigned long g_qosCheckMs, g_qosLastHopMs;
 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

+ 9 - 2
OpenPuck/webusb_config.cpp

@@ -13,7 +13,8 @@ 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]
-#define WB_PAYLEN 33
+//                [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);
@@ -33,8 +34,11 @@ static void webusbSendBlob(){
   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)
@@ -57,6 +61,7 @@ static void webusbDrainCapture(){
   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()){
@@ -71,9 +76,11 @@ void webusbPoll(){
       }
       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)
-      else if(op==0x07){ hapticReinit(); }                // clear a stuck/latched haptic buzz on the controller
+#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)

+ 15 - 7
docs/index.html

@@ -82,11 +82,15 @@
         <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"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
-        <div class="stat"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
-        <div class="stat"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</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>
-      <p class="note">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 — that's what's holding the rate down.</p>
     </div>
 
     <div class="card" id="mouseCard">
@@ -110,14 +114,13 @@
       <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes.</p>
     </div>
 
-    <div class="card">
+    <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.</p>
+      <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>
-        <button id="hapClear">Clear stuck buzz</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>
@@ -206,6 +209,10 @@ function applyBlob(p){
   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;
@@ -217,6 +224,7 @@ function applyBlob(p){
   $("#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);