Parcourir la source

true haptics fix with fallback?

Jariullah Safi il y a 4 mois
Parent
commit
3d47fecee3
6 fichiers modifiés avec 108 ajouts et 42 suppressions
  1. 6 3
      OpenPuck/config.cpp
  2. 1 0
      OpenPuck/config.h
  3. 65 25
      OpenPuck/haptics.cpp
  4. 22 8
      OpenPuck/haptics.h
  5. 6 4
      OpenPuck/webusb_config.cpp
  6. 8 2
      docs/index.html

+ 6 - 3
OpenPuck/config.cpp

@@ -9,6 +9,8 @@ bool    g_xbox = false;
 uint8_t g_chordBtn[3] = { MODE_LIZARD, MODE_XBOX, MODE_SW_HORI };   // back4+B/X/Y -> these modes (A always STEAM)
 bool    g_persistMode = false;
 uint8_t g_bootMode = 0xFF;
+bool    g_buzzFlood = false;   // OFF by default: connect-time haptic config (hapticConnectInit) should prevent the
+                               // buzz on its own. ON = also flood the buzz-clear re-init at 10Hz/30s on connect.
 
 bool          g_debugCdcThisBoot = false;
 static uint8_t g_debugCdc = 0;   // persisted one-shot arm, stored in Cfg.rsvd0 (1 = keep CDC for the next boot)
@@ -22,13 +24,13 @@ uint8_t g_back[4] = {5,6,7,8};   // L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons,
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-#define CFG_MAGIC 0xC7   // bumped (chordBtn[3]): old cfg ignored -> clean defaults on first boot
-struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3]; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
+#define CFG_MAGIC 0xC8   // bumped (buzzFlood): old cfg ignored -> clean defaults on first boot
+struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3], buzzFlood; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 void saveCfg(){
   Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,g_debugCdc,g_abSwap,
          {g_back[0],g_back[1],g_back[2],g_back[3]},(uint8_t)(g_pollUs/100),(uint8_t)(g_persistMode?1:0),g_bootMode,
-         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]}};
+         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]},(uint8_t)(g_buzzFlood?1:0)};
   InternalFS.remove(CFG_FILE); File f(InternalFS);
   if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
 }
@@ -52,6 +54,7 @@ void loadCfg(){
       else                 g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
       static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_HORI};
       for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
+      g_buzzFlood = c.buzzFlood?true:false;   // connect-time buzz-clear flood (default off)
     }
     f.close();
   }

+ 1 - 0
OpenPuck/config.h

@@ -51,6 +51,7 @@ extern uint8_t g_chordBtn[3];   // back4+B/X/Y -> these modes (A always STEAM)
 // last selected mode across reboots.
 extern bool    g_persistMode;   // false (default) = always boot Steam; true = boot into last mode
 extern uint8_t g_bootMode;      // one-shot: boot into this mode once then clear (!persistMode + explicit switch)
+extern bool    g_buzzFlood;     // false (default) = connect-time haptic config only; true = also 10Hz/30s buzz-clear flood
 
 // One-shot debug CDC. Puck mode normally DROPS the CDC serial console to free the USB endpoint its wake-mouse
 // interface needs (so puck can wake a sleeping Windows host). Arming this keeps CDC for the NEXT boot only --

+ 65 - 25
OpenPuck/haptics.cpp

@@ -13,7 +13,9 @@ uint8_t          g_relayOp  = 0xE3;   // E3 poll
 uint8_t          g_relaySub = 0x01;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
+unsigned long    g_buzzFloodUntil = 0;   // 10Hz re-init flood active while millis() < this; armed boot/connect/panel
 
+void hapticArmBuzzFlood(){ g_buzzFloodUntil = millis() + HAPTIC_FLOOD_MS; }
 // Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck (shutoff.pcapng): Steam's
 // "turn off controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The
 // PROTEUS dongle forwards host feature reports to the controller verbatim as SET_FR (esb.json host_usb_bridge),
@@ -27,7 +29,9 @@ 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 ONE post-reconnect haptic init (0 = none scheduled)
+static unsigned long g_reinitAt = 0;       // when to fire the ONE post-(re)connect haptic CONNECT-INIT (0 = none)
+static bool          g_hapClearArmed=false;// haptic activity happened -> arm a clear once it goes idle (catches a
+                                           // latch that engaged during/after use, even seconds after connect)
 
 // ---- 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
@@ -52,7 +56,9 @@ bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen){
   g_rq[h].rid = rid; g_rq[h].len = plen;
   if (plen) memcpy(g_rq[h].data, payload, plen);
   g_rqHead = nx;
-  if (rid==0x82) g_haptic82Ms = millis();   // track last haptic activity (for the Steam-mode quiet timeout)
+  // Any haptic relay (Steam OR Xbox rumble OR test) arms the idle-clear and refreshes its timer -- so the
+  // during-use buzz gets cleared in EVERY USB mode, not just Steam. The re-init's own 0x81/0x87 don't match.
+  if (rid==0x82){ g_haptic82Ms = millis(); g_hapClearArmed = true; }
   __set_PRIMASK(pm); return true;
 }
 
@@ -131,6 +137,7 @@ static void hapticCancelPendingOn(){   // void queued 0x82-ON + 0x9F-shutdown en
 void haptic82HostReport(const uint8_t* p, uint16_t n){
   if(n<3) return;
   g_haptic82Ms = millis();
+  g_hapClearArmed = true;   // any haptic activity arms a clear when it next goes idle (kills a latch from this use)
   // Track on/off only. Do NOT synthesize a stop burst when Steam's own stop arrives: that stop is already
   // forwarded verbatim, so adding 0x82-zero frames on top just makes the controller see the stop several times
   // over. Each 0x82 is a discrete pad click, so the extra frames are exactly the spurious end-of-movement
@@ -187,30 +194,52 @@ 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(){
-  // The haptic-engine init the real puck sends ONCE in the ~300ms window right after the controller connects
-  // (verified against a puck<->controller sniff): an 0x81 reset bracketing 0x87 writes to the haptic registers
-  // 07/08/31/52 and 18/2e/34/35, then a final 0x81 [01 00 00 00 00 00 00]. We must call this EXACTLY ONCE per
-  // connect, like the real puck -- NOT on a timer/flood. Repeatedly hammering these (the old 10Hz/30s buzz-flood
-  // + 8 reconnect shots + per-idle re-init) is what latched the controller into the self-sustaining buzz.
+  // The haptic-subsystem re-init Steam/the real puck sends to (re)take control of the haptic engine: a 0x81 reset
+  // action plus 0x87 writes to the haptic registers (07/08/31/52, 18/2e/34/35), then two trailing 0x81 frames.
+  // Replaying it clears a latched/stuck haptic on the controller. Driven from the flood + reconnect re-init in
+  // hapticTask -- NOT one-shot: the connect buzz engages at a random point in the first ~minute, so we keep
+  // replaying this (10Hz for 30s on connect) until the latch clears.
   //
-  // Two deliberate deviations from the raw capture: (1) the leading [0x30 ...] triplet (register 0x30 =
-  // SETTING_GYRO_MODE; LED brightness is 0x2D, gyro mode 0x30) is OMITTED -- replaying Steam's 0x30=0 there
-  // disabled the gyro, and the controller streams gyro on by default, so we leave 0x30 alone. (2) brightness
-  // (0x87 reg 2d) is OMITTED so we don't stomp the user's LED. The real puck's 0x81 payloads are [01 00..] and
-  // never the all-zero form, so we send only the [01 00..] one.
+  // Deliberate deviation: the real puck's sequence leads with [0x30 ...] (register 0x30 = SETTING_GYRO_MODE; LED
+  // brightness is 0x2D, gyro mode 0x30). Replaying 0x30=0 there DISABLED the gyro, and since the controller
+  // streams gyro on by default we OMIT the 0x30 triplet entirely. Brightness (0x87 reg 2d) is likewise omitted
+  // so the flood doesn't flicker the user's LED.
   static const uint8_t H30[]={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 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) -- the puck brackets the config with this
+  relayEnqueue(0x81, nullptr, 0);            // reset action (FUN_0001f554) -- Steam sends this first
   relayEnqueue(0x87, H30, sizeof H30);
-  relayEnqueue(0x87, H18, sizeof H18);       // haptic config (enabled/amplifier/gain)
+  relayEnqueue(0x87, H18, sizeof H18);       // haptic config (enabled/amplifier/gain): the part that clears a latch
   relayEnqueue(0x87, H35, sizeof H35);
-  relayEnqueue(0x81, nullptr, 0);            // closing 0x81 bracket
-  relayEnqueue(0x81, T81B, sizeof T81B);     // final [01 00 00 00 00 00 00] (matches the puck's last connect frame)
+  relayEnqueue(0x81, T81A, sizeof T81A);
+  relayEnqueue(0x81, T81B, sizeof T81B);
+}
+// ONE-TIME connect config, sent ~200ms after every (re)connect -- this is the part the previous build never sent.
+// The real puck, right after the controller connects, sends a config burst BEYOND the haptic-register block:
+// 0xdc/0xe2 setup commands and 0x87 register writes 0x22=0x64 / 0x23=0x50 (verified in steam-controller-sniff-
+// data.json). OpenPuck only ever sent the haptic-register subset, so the controller came up in a state prone to
+// the connect-buzz latch. We replay that extra config here, then the haptic-register block, ONCE per connect --
+// the intended root-cause fix so the buzz never engages (the 10Hz flood becomes an opt-in fallback, g_buzzFlood).
+// Deliberately omitted vs the raw capture: the LED writes (0x87 reg 0x2d brightness, 0xc1 color -- OpenPuck owns
+// the LED), the 0xed "user/wireless_transport" string (alters transport, Steam-session-specific), and the 0x30
+// gyro-mode writes (0x30=0 disables the gyro -- see hapticReinit). The on-air framing of these matches the puck
+// exactly: rid>=0x87 -> E3 [2+len][01][rid][len][data] (relayEnqueue), e.g. 0xdc -> e3 04 01 dc 02 01 02.
+void hapticConnectInit(){
+  static const uint8_t DC[]  = {0x01,0x02};        // 0xdc setup -> e3 04 01 dc 02 01 02
+  static const uint8_t E2[]  = {0x01,0x20};        // 0xe2 setup -> e3 04 01 e2 02 01 20
+  static const uint8_t R22[] = {0x22,0x64,0x00};   // 0x87 reg 0x22 = 0x0064
+  static const uint8_t R23[] = {0x23,0x50,0x00};   // 0x87 reg 0x23 = 0x0050
+  relayEnqueue(0xdc, DC,  sizeof DC);
+  relayEnqueue(0xe2, E2,  sizeof E2);
+  relayEnqueue(0x87, R22, sizeof R22);
+  relayEnqueue(0x87, R23, sizeof R23);
+  hapticReinit();   // then the haptic-engine register block + 0x81 resets (same as the puck's connect haptic init)
 }
 void hapticInit(){
   g_rqHead = g_rqTail = 0; g_haptic82On=false;
+  if(g_buzzFlood) hapticArmBuzzFlood();   // opt-in only: flood the buzz-clear for the first HAPTIC_FLOOD_MS
   g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // boot: block stale Steam 0x82 until link stable
   // 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
@@ -219,16 +248,19 @@ void hapticInit(){
   // pure pass-through of Steam's haptics, like the real puck.
   g_hapticStop = 0;
 }
-// Arm the post-(re)connect haptic block + schedule the ONE haptic init. Called from rf_link the moment a
+// 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 (it just re-arms the single pending init).
+// 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
-  // Schedule the haptic-engine init ONCE, shortly after the link is up -- exactly what the real puck does. (The
-  // old build flooded this at 10Hz for 30s + 8 shots, which is what latched the controller into a stuck buzz.)
-  g_reinitAt = millis() + HAPTIC_REINIT_DELAY_MS;
+  // DEFAULT: send the full connect config (hapticConnectInit) ONCE, ~200ms after the link is up -- like the real
+  // puck, so the haptic engine never latches in the first place.
+  g_reinitAt = millis() + 200u;
+  // OPT-IN fallback (g_buzzFlood): also hammer the buzz-clear re-init at 10Hz for 30s, in case the config alone
+  // doesn't fully prevent the buzz on a given board. Off by default.
+  if(g_buzzFlood) hapticArmBuzzFlood();
   uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+init armed)
 }
 void hapticTask(){
@@ -239,10 +271,16 @@ 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){   // the ONE post-(re)connect haptic init, like the real puck
-    hapticReinit();
+  if(g_reinitAt && up && (int32_t)(millis()-g_reinitAt) >= 0){   // the ONE connect config, ~200ms after (re)connect
+    hapticConnectInit();
     g_reinitAt = 0;
   }
+  // OPT-IN buzz-clear FLOOD (g_buzzFlood): while the armed window is open (-> g_buzzFloodUntil) and the link is up,
+  // replay the haptic re-init every HAPTIC_FLOOD_GAP_MS (10Hz) to hammer a stubborn latch. Auto-stops after 30s;
+  // re-armed on connect (only when enabled) and from the WebUSB panel. Off by default -- the connect config above
+  // is meant to prevent the buzz; this is the fallback for boards where it doesn't.
+  static unsigned long floodMs=0;
+  if (g_buzzFlood && (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. 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
@@ -257,6 +295,8 @@ void hapticTask(){
   // 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.
   if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) g_haptic82On=false;
-  // NO periodic / idle-triggered re-init. The real puck inits the haptic engine ONCE at connect and never again;
-  // the old per-idle + 10Hz-flood re-init is exactly what drove the controller into the stuck buzz this removes.
+  // OPT-IN (g_buzzFlood): after haptic activity goes idle, fire one re-init to clear any latch it left behind.
+  // Off by default along with the flood; the connect config is the primary defense. Always disarm so it doesn't
+  // accumulate while disabled.
+  if (g_hapClearArmed && (millis()-g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS){ g_hapClearArmed=false; if(g_buzzFlood) hapticReinit(); }
 }

+ 22 - 8
OpenPuck/haptics.h

@@ -31,14 +31,24 @@
 #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
-// Post-(re)connect haptic init: fire the engine init ONCE, this long after the link comes up. This matches the
-// REAL puck, which sends its haptic/config init exactly once in a ~300ms window right after connect and then
-// NEVER re-sends it (verified against a puck<->controller sniff). The previous build hammered the init at 10Hz
-// for 30s ("buzz flood") plus 8 shots on connect plus one after every haptic idle -- ~60 reset/config writes per
-// second vs the real puck's ~15 total. That flooding of the haptic engine's reset (0x81) + drive registers
-// (0x34/0x35=0xffff) is what latched the controller into a self-sustaining buzz that only a Steam-button press
-// or power-cycle cleared. One shot, like the real puck, is the fix.
-#define HAPTIC_REINIT_DELAY_MS 200u
+// 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
+// After haptic activity, if it's been idle this long, fire one clear-re-init -- kills a latch that engaged
+// during use (a buzz that starts seconds after connect and won't self-clear). Long enough not to fire between
+// rapid in-game haptics; short enough to clear a stuck buzz soon after the user pauses.
+#define HAPTIC_CLEAR_IDLE_MS 1200u
+// "Clear stuck buzz" FLOOD: replay the haptic re-init at this cadence (100ms = 10Hz) for a bounded window. The
+// connect-time buzz engages at a RANDOM point in the first ~minute of a connection (~1/10 connects); a single
+// re-init at +200ms fires too early to catch it. Re-initing at 10Hz for HAPTIC_FLOOD_MS (30s) keeps resetting
+// the engine until the latch is cleared -- proven on hardware to turn a stuck buzz into a brief "start then
+// immediately stop." Armed at boot and on every (re)connect; the WebUSB panel (op 0x07) re-arms on demand.
+// NOTE: the REAL puck does NOT flood -- it sends a fuller connect-time config (regs 0x22/0x23, the 0x30 enable
+// bracket, etc.) that keeps the engine from latching in the first place. Until that's replicated, the flood is
+// the working mitigation; do NOT remove it (removing it regressed the buzz to "stuck until Steam press").
+#define HAPTIC_FLOOD_GAP_MS 100u
+#define HAPTIC_FLOOD_MS     30000u
 // Controller power-off: hapticSendShutdown() relays Steam's confirmed "turn off controller" command (feature-0x01
 // cmd 0x9F, payload "off!" -- captured from the real puck). Sent as a small burst because the RF relay is NO-ACK.
 #define HAPTIC_SHUTDOWN_SHOTS 3u
@@ -52,7 +62,9 @@ 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_buzzFloodUntil; // re-init flood active while millis() < this (armed: boot/connect/panel)
 extern unsigned long    g_hapticBlockUntil;
+void hapticArmBuzzFlood();           // (re)arm the 10Hz buzz-clear flood for HAPTIC_FLOOD_MS
 void hapticSendShutdown();           // relay the controller power-off (0x9F "off!"), burst x3 (host-suspend / Steam 0x9F / test button)
 
 // ---- diagnostic capture (compiled in only when OPK_LOG): a ring of recent host->controller commands +
@@ -85,6 +97,8 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1);
 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
+void hapticConnectInit(); // ONE-TIME connect config (0xdc/0xe2 + 0x87 22/23 + the haptic block) -- the real puck's
+                          // full connect init; fired once ~200ms after each (re)connect to keep the buzz from latching
 // 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.

+ 6 - 4
OpenPuck/webusb_config.cpp

@@ -15,14 +15,14 @@ Adafruit_USBD_WebUSB usb_web;
 //                [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][battery%][rssi|dBm|]
-//                [gitDirty][gitHash 12B ASCII, NUL-padded]
-#define WB_PAYLEN 51
+//                [gitDirty][gitHash 12B ASCII, NUL-padded][buzzFlood]
+#define WB_PAYLEN 52
 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]=4;                          // protocol version (4 = +git build provenance at [40..52]; 3 = battery/rssi)
+  p[2]=5;                          // protocol version (5 = +buzzFlood at [53]; 4 = git provenance; 3 = battery/rssi)
   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;
@@ -46,6 +46,7 @@ static void webusbSendBlob(){
   p[40]=OPK_GIT_DIRTY ? 1 : 0;
   memset(&p[41],0,12);                                                 // 12B ASCII git hash, NUL-padded
   { const char* h=OPK_GIT_HASH; for(uint8_t i=0;i<12 && h[i];i++) p[41+i]=(uint8_t)h[i]; }
+  p[53]=g_buzzFlood?1:0;                                               // connect-time buzz-clear flood enabled?
   usb_web.write(p,sizeof p); usb_web.flush();
 }
 #if OPK_LOG
@@ -90,7 +91,7 @@ void webusbPoll(){
       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(); }   // manual one-shot haptic-engine init (same single sequence the puck sends at connect)
+      else if(op==0x07){ hapticArmBuzzFlood(); hapticReinit(); }   // (re)arm the 30s 10Hz buzz-clear flood; fire one now for instant feedback
       else if(op==0x08){ hapticSendShutdown(); }                   // TEST: trigger the controller power-off attempt (same path host-suspend uses)
       else if(op==0x0A){                                           // FULL factory wipe: erase cfg.bin + bonds.bin, reboot to clean defaults.
         // Guarded by a 3-byte magic ("ERS") so a stray/corrupt byte can never trigger it; the panel double-
@@ -120,6 +121,7 @@ void webusbPoll(){
           case 16: g_persistMode = v?true:false; break;   // persist last mode across reboots (else always boot Steam)
           case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break;   // back4+B/X/Y mode assignments
           case 20: armDebugCdcNextBoot(); usb_web.flush(); delay(40); NVIC_SystemReset(); break;  // reboot once WITH the CDC serial console (puck mode), then auto-revert
+          case 21: g_buzzFlood = v?true:false; break;          // connect-time haptic buzz-clear flood (persisted; default off)
         }
         if(persist) saveCfg();
         webusbSendBlob();

+ 8 - 2
docs/index.html

@@ -92,7 +92,10 @@
       </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">Sends the haptic-engine init once (the same sequence the real puck sends at connect). The copycat already runs this automatically once when the controller connects; click it if a buzz ever shows up later.</span>
+        <span class="note" style="flex:1">Manually (re)arm the buzz-clear flood now: replays the haptic re-init at 10&nbsp;Hz for 30&nbsp;s (same as pressing Steam). Use it if a buzz shows up right now.</span>
+      </div>
+      <div class="row" style="margin:10px 0 0"><label>Connect-time flood</label><button id="buzzFlood">off</button>
+        <span class="note" style="flex:1">The copycat now sends the real puck's full haptic config once on connect, which should keep the buzz from ever latching. If a board <em>still</em> buzzes in the first minute, turn this <strong>on</strong> to also flood the buzz-clear re-init at 10&nbsp;Hz for 30&nbsp;s after every connect. Off by default; persists across reboots.</span>
       </div>
       <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller (test)</button>
         <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path host-suspend uses. Watch the capture / link status to see if the controller actually disconnects.</span>
@@ -253,6 +256,8 @@ function applyBlob(p){
   setSlider("mDiv",mDiv); setSlider("mFric",mFric);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
+  const buzzFlood=(p.length>51?p[51]:0);   // connect-time buzz-clear flood toggle (protocol v5+)
+  $("#buzzFlood").textContent = buzzFlood? "on":"off"; $("#buzzFlood").classList.toggle("active",!!buzzFlood);
   for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
   for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
 }
@@ -345,7 +350,7 @@ $("#capDl").onclick=downloadCap;
 $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;
 let buzzFloodTimer=null;
-$("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); $("#hapClear").classList.add("active"); clearTimeout(buzzFloodTimer); buzzFloodTimer=setTimeout(()=>$("#hapClear").classList.remove("active"),800); log("haptic init sent (one-shot, same as the puck's connect init)"); } };
+$("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); $("#hapClear").classList.add("active"); clearTimeout(buzzFloodTimer); buzzFloodTimer=setTimeout(()=>$("#hapClear").classList.remove("active"),30000); log("buzz-clear flood armed — 10Hz re-init for 30s (click again to restart)"); } };
 $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status / capture"); } };
 $("#ledSet").onclick=async()=>{ if(dev){ const h=$("#ledColor").value; const r=parseInt(h.substr(1,2),16),g=parseInt(h.substr(3,2),16),b=parseInt(h.substr(5,2),16); await send([0x09,r,g,b]); log(`LED color set ${h} (r${r} g${g} b${b})`); } };
 $("#factoryErase").onclick=async()=>{
@@ -361,6 +366,7 @@ $("#factoryErase").onclick=async()=>{
 };
 $("#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); };
+$("#buzzFlood").onclick=()=>{ const on=$("#buzzFlood").classList.contains("active"); setField(21, on?0:1); };
 for(const id of ["mDiv","mFric"]){
   const field={mDiv:1,mFric:2}[id];
   $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });