Parcourir la source

battery readout works now

Jariullah Safi il y a 4 mois
Parent
commit
88d1472454

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 .DS_Store
 .idea
 *.log
+*.json
 tmp/
 # pairtui helper binary (build from scmd.c: clang -framework IOKit -framework CoreFoundation -o scmd scmd.c)
 pairtui/scmd

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

+ 8 - 3
OpenPuck/haptics.cpp

@@ -258,10 +258,15 @@ void hapticTask(){
   // Auto-stops when the 30s window elapses; re-arm from the WebUSB panel. Gated on link-up (no churn while down).
   static unsigned long floodMs=0;
   if ((int32_t)(g_buzzFloodUntil - millis()) > 0 && up && millis()-floodMs >= HAPTIC_FLOOD_GAP_MS){ floodMs=millis(); hapticReinit(); }
-  // Controller power-off on host sleep: send the power-off command (0x9F "off!") the instant the USB bus
-  // suspends, like the real puck. wasSusp starts true so a boot-into-suspended state never false-fires.
+  // 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; the controller should only power off on a shutdown
+  // chord/command or 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();
-  if (susp && !wasSusp) hapticSendShutdown();
+  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

+ 16 - 14
OpenPuck/puck_hid.cpp

@@ -226,6 +226,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,
@@ -275,21 +287,11 @@ void SteamPuckController::task(){
       uint8_t st=conn?0x02:0x01; hid[g_connSlot].sendReport(0x79,&st,1); usbConn=conn; last79=millis();
     } else if (conn && millis()-last7B>=2000){
       // 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).
+      // (capture showed 0xDD = -35). Report the RAW measured RSSI magnitude (rf_link's g_linkRssi) as signed
+      // dBm -- no calibration offset, no clamp (removed per request; the offset was a cosmetic match to the real
+      // puck's tuned front-end). 0 = no sample yet -> keep the capture template value rather than report 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);
-      }
+      if (g_linkRssi) s7b[8]=(uint8_t)(0u-(uint8_t)g_linkRssi);   // raw |dBm| -> signed dBm
       hid[g_connSlot].sendReport(0x7B,s7b,12); last7B=millis();
     }
   } else if(!conn) usbConn=false;

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

+ 10 - 1
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
 volatile uint8_t g_ctlrStatus[8] = {0};   // last F1 type-2 control/status TLV from the controller (battery/flags live here)
 volatile uint8_t g_ctlrStatusLen = 0;
 
@@ -133,7 +134,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
@@ -199,6 +200,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));
+          }
           else if((ttype==2 || ttype==4) && (size_t)(idx+2)+tlen<=sizeof(rfrx)){
             // The controller's F1 reply also carries NON-input TLVs that OpenPuck used to discard: type 2 = the
             // 4-byte control/status field (battery / charge / flags), type 4 = bulk. Battery is in here -- the

+ 6 - 7
OpenPuck/rf_link.h

@@ -44,16 +44,15 @@ extern uint16_t g_pollsps;      // last second's poll TX count (GET+relay) -- vs
 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.
+// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. Reported RAW (no offset,
+// no clamp) to Steam in report 0x7B byte 8 (signed dBm) and surfaced in the WebUSB panel.
 extern volatile uint8_t g_linkRssi;
-// Last F1 type-2 control/status TLV the controller sent (battery/charge/flags). Logged (capture slot 0xFB) for
-// discovery; once the battery byte is identified it feeds report 0x7B (today hardcoded -> Steam shows full).
+// Battery percent from the controller's periodic report 0x43 (body[1]); 0 = none yet. Surfaced in the panel;
+// the raw 0x43 report is also forwarded to Steam verbatim (puck_hid onAuxReport) so Steam reads battery itself.
+extern volatile uint8_t g_battery;
+// Last F1 type-2 control/status TLV the controller sent. Logged (capture slot 0xFB) for diagnostics.
 extern volatile uint8_t g_ctlrStatus[8];
 extern volatile uint8_t g_ctlrStatusLen;
-// 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 10
 
 // 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

+ 5 - 3
OpenPuck/webusb_config.cpp

@@ -13,14 +13,14 @@ 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|]
+#define WB_PAYLEN 38
 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]=3;                          // protocol version (3 = +battery/rssi at [38][39])
   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 +36,8 @@ 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)
   usb_web.write(p,sizeof p); usb_web.flush();
 }
 #if OPK_LOG

+ 6 - 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>
@@ -223,7 +225,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" : "—";

+ 40 - 10
docs/sniffer.html

@@ -60,7 +60,15 @@
   <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 class="note">filter <input type="text" id="filter" placeholder="hex e.g. 9f or e3" style="width:90px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"></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>
@@ -123,7 +131,7 @@ function parse(){
     }
     if(acc[i]===0xC0 && acc[i+1]===0xDE){            // packet
       if(i+3>acc.length) break;
-      const n=acc[i+2]; if(n>66){ i++; continue; }   // bad len -> resync
+      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];
@@ -135,27 +143,50 @@ function parse(){
   }
   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={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)};
+  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);
-  addRow(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"); let rowi=0; const flt=()=>$("#filter").value.trim().toLowerCase();
+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 f=flt(); if(f && !e.payload.replace(/ /g,"").includes(f.replace(/ /g,""))) return;
   const tr=document.createElement("tr");
   tr.className=(e.dir==="P→C"?"pc":e.dir==="C→P"?"cp":"")+(e.crc?"":" bad");
-  tr.innerHTML=`<td>${++rowi}</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>`;
+  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);
-  while(rows.childElementCount>MAXROWS) rows.removeChild(rows.firstChild);
+  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{
@@ -180,7 +211,7 @@ $("#cap").onclick=async()=>{
   else{ await cmd(0x02); log(`CAP ENDED — ${rec.length} packets recorded`); }
   $("#nrec").textContent=rec.length;
 };
-$("#clear").onclick=()=>{ rec=[]; rows.innerHTML=""; rowi=0; nPC=nCP=nBad=0; $("#nrec").textContent=0; $("#npc").textContent=0; $("#ncp").textContent=0; $("#nbad").textContent=0; };
+$("#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"); };
@@ -199,7 +230,6 @@ $("#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");
 };
-$("#filter").oninput=()=>{};   // filter applies to new rows; Clear to re-filter the view
 if(!navigator.usb) log("This browser has no WebUSB. Use Chrome/Edge over https or localhost.");
 </script>
 </body>

+ 19 - 1
puck_sniffer/README.md

@@ -63,7 +63,25 @@ address changes), so we anchor the lock on that uuid and force a reconnect.
 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). The `filter` box matches payload hex (e.g. `9f`).
+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

+ 3 - 3
puck_sniffer/puck_sniffer.ino

@@ -33,7 +33,7 @@
 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[66]; };
+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;
@@ -115,7 +115,7 @@ static void rxPump(){
     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 > 66) n = 66;   // LENGTH + S1 + payload, clamped
+    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
@@ -147,7 +147,7 @@ static void rxPump(){
 
 // ---- WebUSB stream out ----
 static void emitPacket(const Cap &c){
-  uint8_t f[11 + 66];
+  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;

+ 2 - 2
puck_sniffer/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,7 @@ 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; 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;

+ 1 - 1
puck_sniffer/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
 extern uint32_t g_rfRxCount;
 
 // ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----