ソースを参照

proper reset + buzz fix

Jariullah Safi 4 ヶ月 前
親
コミット
b3cfd47f7d

+ 4 - 3
OpenPuck/OpenPuck.ino

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

+ 21 - 0
OpenPuck/config.cpp

@@ -1,6 +1,7 @@
 #include "config.h"
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
+#include <string.h>
 using namespace Adafruit_LittleFS_Namespace;
 
 uint8_t g_usbMode = 0;
@@ -75,3 +76,23 @@ void factoryErase(){
   InternalFS.begin();      // ensure mounted before we reformat (no-op if already up)
   InternalFS.format();     // wipes cfg.bin + bonds.bin + the entire FS
 }
+
+// One-time factory reset for the -DOPK_FACTORY_RESET recovery build. The point is to clear a bad config/bond
+// ONCE (at the first boot after flashing) and then behave like a normal build that PERSISTS settings -- NOT to
+// wipe on every boot (which would make the board un-configurable). We track "already reset" with a tiny tag file
+// holding the build's git hash, written AFTER the wipe (so it survives in the freshly-formatted FS):
+//   - tag missing or != this build's hash  -> a reset is pending: wipe, then stamp the tag. Next boot persists.
+//   - tag == this build's hash             -> already reset for this build: skip, boot normally.
+// Because the tag is keyed to the git hash, flashing a DIFFERENT build re-triggers the one-time wipe; re-flashing
+// the identical image does not (use the WebUSB/serial erase for an on-demand wipe). buildTag is OPK_GIT_HASH.
+#define RESET_TAG_FILE "/rsttag"
+void factoryResetOnce(const char* buildTag){
+  char tag[24] = {0};
+  { File f(InternalFS);
+    if (f.open(RESET_TAG_FILE, FILE_O_READ)) { int n=f.read((uint8_t*)tag, sizeof tag - 1); if(n>0) tag[n]=0; f.close(); } }
+  if (strncmp(tag, buildTag, sizeof tag - 1) == 0) return;   // this build already did its one-time reset -> persist
+  factoryErase();                                            // wipe cfg.bin + bonds.bin + the old tag
+  InternalFS.begin();                                        // remount the fresh FS
+  File g(InternalFS);                                        // stamp the tag so subsequent boots skip the wipe
+  if (g.open(RESET_TAG_FILE, FILE_O_WRITE)) { g.write((const uint8_t*)buildTag, strlen(buildTag)); g.close(); }
+}

+ 10 - 6
OpenPuck/config.h

@@ -16,12 +16,13 @@
 #define OPK_LOG 0
 #endif
 
-// Build-time FACTORY RESET. 0 (default) = normal. Build with -DOPK_FACTORY_RESET=1 to produce a firmware that
-// reformats ALL persistent storage (cfg.bin + bonds.bin) on EVERY boot, before anything is loaded -- so flashing
-// this build wipes the board to clean defaults. It keeps wiping each boot while the flag is compiled in, so the
-// intended workflow is: flash the reset build once, let it boot (storage erased), then flash a normal build for
-// regular use. Equivalent to the serial "ERASE-ALL" / WebUSB "Factory erase" actions, but at flash time and
-// without needing a console or the panel. Re-pair the controller afterwards.
+// Build-time FACTORY RESET (recovery build). 0 (default) = normal. Build with -DOPK_FACTORY_RESET=1 to produce a
+// firmware that wipes ALL persistent storage (cfg.bin + bonds.bin) ONCE -- on the first boot after flashing --
+// then behaves exactly like a normal build that persists settings. It is NOT a wipe-every-boot image: a git-hash
+// tag file (written after the wipe) records that this build already did its one-time reset, so subsequent boots
+// skip it. This makes it a safe recovery image for a bad config/bond: flash it, boot once (cleaned), keep using
+// it. Flashing a DIFFERENT build re-triggers the one-time wipe; for an on-demand wipe use the serial "ERASE-ALL"
+// or WebUSB "Factory erase". Re-pair the controller after the reset. See factoryResetOnce() in config.cpp.
 #ifndef OPK_FACTORY_RESET
 #define OPK_FACTORY_RESET 0
 #endif
@@ -79,6 +80,9 @@ void saveCfg();
 // (paired controller). Irreversible; the caller reboots so the next boot comes up on clean defaults and the
 // controller must be re-paired. Gated behind explicit confirmation at every call site.
 void factoryErase();
+// One-time factory reset for the -DOPK_FACTORY_RESET recovery build: wipe ONCE on the first boot after flashing
+// (tracked by a git-hash tag file so it doesn't wipe on every boot), then persist normally. buildTag = OPK_GIT_HASH.
+void factoryResetOnce(const char* buildTag);
 // Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
 // the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
 void saveMode(uint8_t m);

+ 27 - 53
OpenPuck/haptics.cpp

@@ -13,9 +13,7 @@ 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),
@@ -29,10 +27,7 @@ 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
-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)
+static unsigned long g_reinitAt = 0;       // when to fire the ONE post-reconnect haptic init (0 = none scheduled)
 
 // ---- 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
@@ -57,9 +52,7 @@ 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;
-  // 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; }
+  if (rid==0x82) g_haptic82Ms = millis();   // track last haptic activity (for the Steam-mode quiet timeout)
   __set_PRIMASK(pm); return true;
 }
 
@@ -138,7 +131,6 @@ 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
@@ -195,35 +187,30 @@ 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 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).
-  // The leading triplet of Steam's captured sequence is [0x30 00 00] -- register 0x30 is SETTING_GYRO_MODE
-  // (Steam Controller register map: LED brightness 0x2D, gyro mode 0x30), and 0x0000 turns the IMU OFF. Steam
-  // writes it then re-enables gyro with its own follow-up; we only replay the haptic-clear subset, so replaying
-  // 0x30=0 just KILLS the gyro -- and this re-init fires on every reconnect (a mode-switch reboot), repeatedly
-  // across the buzz-flood, and after every haptic idle. That is the "gyro randomly stops, typically after
-  // switching to ps5/switch mode" bug. The controller streams gyro by default (it works on a fresh connect
-  // before any re-init), so the fix is to NOT touch reg 0x30 here. The 0x07/0x08/0x31/0x52 writes that remain
-  // are the haptic-engine registers that actually clear the latch.
+  // 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.
+  //
+  // 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.
   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) -- Steam sends this first
+  relayEnqueue(0x81, nullptr, 0);            // reset action (FUN_0001f554) -- the puck brackets the config with this
   relayEnqueue(0x87, H30, sizeof H30);
-  relayEnqueue(0x87, H18, sizeof H18);       // haptic config (enabled/amplifier/gain): the part that clears a latch
+  relayEnqueue(0x87, H18, sizeof H18);       // haptic config (enabled/amplifier/gain)
   relayEnqueue(0x87, H35, sizeof H35);
-  relayEnqueue(0x81, T81A, sizeof T81A);
-  relayEnqueue(0x81, T81B, sizeof T81B);
-  // NOTE: the brightness write (0x87 2d) that Steam includes is deliberately OMITTED -- it's an LED setting,
-  // not haptic, and this re-init now fires often (after every haptic-idle), so we must not flicker the LED.
+  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)
 }
 void hapticInit(){
   g_rqHead = g_rqTail = 0; g_haptic82On=false;
-  hapticArmBuzzFlood();   // flood the buzz-clear for the first HAPTIC_FLOOD_MS (re-armed on each connect below)
   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
@@ -232,22 +219,17 @@ 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 clearing re-init. Called from rf_link the moment a
+// Arm the post-(re)connect haptic block + schedule the ONE haptic 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.
+// link-up edge. Idempotent -- safe to call repeatedly (it just re-arms the single pending init).
 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
-  hapticArmBuzzFlood();                                        // and hammer the buzz-clear at 10Hz for the next 30s
-  uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+reinit armed)
+  // 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;
+  uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+init armed)
 }
 void hapticTask(){
   static bool wasHapticLinkUp=false;
@@ -257,15 +239,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){   // proactive haptic re-init across the connect window
+  if(g_reinitAt && up && (int32_t)(millis()-g_reinitAt) >= 0){   // the ONE post-(re)connect haptic init, like the real puck
     hapticReinit();
-    g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+HAPTIC_REINIT_GAP_MS) : 0;
+    g_reinitAt = 0;
   }
-  // Buzz-clear FLOOD: while the armed window is open (boot/connect/panel -> g_buzzFloodUntil) and the link is up,
-  // replay the re-init every HAPTIC_FLOOD_GAP_MS (10Hz) to hammer a stubborn latch a single re-init won't shift.
-  // 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. 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
@@ -280,9 +257,6 @@ 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;
-  // Haptic activity has gone idle for a while -> fire one re-init to clear any latch it left behind (the buzz
-  // that engages during/after use and won't self-clear, incl. after a mode switch). Fires only after a quiet
-  // gap, so it never interrupts active haptics; the brightness-less re-init is silent (settings, no play, no
-  // LED). Runs in ALL modes (the controller-side latch is mode-independent).
-  if (g_hapClearArmed && (millis()-g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS){ g_hapClearArmed=false; hapticReinit(); }
+  // 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.
 }

+ 8 - 18
OpenPuck/haptics.h

@@ -31,22 +31,14 @@
 #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
-// 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. A
-// single re-init doesn't always shift a stubborn latch; hammering it at 10Hz does. The re-init is settings-only
-// (no haptic play, no LED), so flooding it can't itself buzz or flicker. The flood is armed for HAPTIC_FLOOD_MS
-// (30s) at boot and again on every (re)connect -- so the first 30s after the controller connects (when the
-// connect-buzz engages) get hammered automatically -- then it stops. The WebUSB panel (op 0x07) re-arms the
-// same 30s window on demand, for a buzz that shows up later. Gated on link-up so it doesn't churn while down.
-#define HAPTIC_FLOOD_GAP_MS 100u
-#define HAPTIC_FLOOD_MS     30000u
+// 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
 // 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
@@ -60,9 +52,7 @@ 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 +

+ 1 - 1
OpenPuck/webusb_config.cpp

@@ -90,7 +90,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){ hapticArmBuzzFlood(); hapticReinit(); }   // (re)arm the 30s 10Hz buzz-clear flood; fire one now for instant feedback
+      else if(op==0x07){ hapticReinit(); }   // manual one-shot haptic-engine init (same single sequence the puck sends at connect)
       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-

+ 3 - 2
docs/BUILD_AND_DEPLOY.md

@@ -99,13 +99,14 @@ Replace `COM5` with the actual board port.
 
 Re-flashing firmware does **not** erase the board's internal LittleFS. The paired-controller bond (`bonds.bin`) and every saved setting (`cfg.bin`: USB mode, chord assignments, back-paddle map, mouse sensitivity) survive a fresh build and upload. To bring a board up in a truly clean state — a new unit, a hand-me-down with a stale bond, or a corrupted config — wipe the filesystem with one of:
 
-- **Build flag (wipe on flash):** compile with `-DOPK_FACTORY_RESET=1` and upload. This build reformats all persistent storage on boot, so flashing it brings the board up on clean defaults — no console or panel needed:
+- **Recovery build (`-DOPK_FACTORY_RESET=1`):** a firmware that wipes all persistent storage **once, on the first boot after flashing**, then behaves like a normal build that persists settings. Use it to recover a board from a bad config/bond without a console or panel:
 
   ```bash
+  ./gen_version.sh   # recommended: gives the build a distinct git hash (see below)
   arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DOPK_FACTORY_RESET=1" OpenPuck
   ```
 
-  It wipes on **every** boot while the flag is compiled in, so this is a one-shot reset tool: flash the reset build once, let it boot (storage erased), then build and flash a **normal** firmware (without the flag) for regular use.
+  It is **not** a wipe-every-boot image: after the one-time reset it stamps a tag file with the build's git hash, so subsequent boots skip the wipe and persist normally. Flashing this same image again won't re-wipe (the tag matches); flashing a **different** build (different git hash) re-triggers the one-time reset. For an on-demand wipe at any time, use the WebUSB button or serial `ERASE-ALL` below. Re-pair the controller after a reset.
 - **WebUSB panel (any mode):** open the panel (§8), and in the maintenance card click **⚠ Factory erase**. Confirm the two warning dialogs and type `ERASE` when prompted. The board reformats its filesystem and reboots to factory defaults. This works in every USB mode.
 - **Serial console (CDC):** connect to the board's serial port at 115200 baud and send the line `ERASE-ALL` (exact, all caps). Same effect: reformat + reboot.
 

+ 2 - 2
docs/index.html

@@ -92,7 +92,7 @@
       </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 floods the re-init (same as pressing Steam) at 10&nbsp;Hz for 30&nbsp;s. It also runs automatically for 30&nbsp;s on connect; click to re-arm if a buzz shows up later.</span>
+        <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>
       </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>
@@ -345,7 +345,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"),30000); log("buzz-clear flood armed — 10Hz re-init for 30s (click again to restart)"); } };
+$("#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)"); } };
 $("#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()=>{