瀏覽代碼

stability: relay-ring hang fix, phantom-slot poll fix, hang diagnostics

Investigation + fixes for watchdog hangs and poll-rate anomalies on
nice!nano clone boards (AITRIP, Teyleten Robot) vs the known-good board.

Fixes:
- Relay ring drains (rfConnFlushRelay, hapticCancelPendingOn) run with IRQs
  disabled; an out-of-range/desynced g_rqHead made them spin forever ->
  invisible "watchdog (hang)" (millis/SOF frozen, only the HW WDT recovers).
  Cap both loops at RELAY_QLEN+1, recover (head=tail=0), count g_ringFault.
- Phantom bonded slots (carried in from cloned backups) were polled every
  cycle forever -> 2x poll rate + noRx flood + reply-window theft. Now a
  never-replied slot throttles to the cold cadence once another controller
  is connected; full-rate only when nothing is connected (fast first link).
- Polls/s counted every radio TX (poll + relay); split into true poll cycles
  (Polls/s, ~250 for one controller) and a separate Relay/s.
- Post-connect haptic block forced off and removed (was field 27/28 + UI).

Diagnostics (protocol v12):
- Clock fingerprint: LFCLK/HFCLK source + measured us-per-ms (clone triage).
- Hang localization: GPREGRET2 loop-stage breadcrumb (survives reset where
  the bootloader preserves it) + a live SOF-driven stall monitor that reports
  the stuck stage even while loop() is wedged (works when GPREGRET2 doesn't).
- Panel: new Relay/s, Clock, us/ms, Loop, ring-fault tiles + stall logging.

Panel UX:
- Auto-reconnect via navigator.usb.getDevices() (no device picker after the
  first authorization); reconnects on load, on the connect event, and on
  disconnect retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jari 3 月之前
父節點
當前提交
4908d3fcf6
共有 10 個文件被更改,包括 361 次插入 和 73 次删除
  1. 26 0
      OpenPuck/OpenPuck.ino
  2. 2 7
      OpenPuck/config.cpp
  3. 124 0
      OpenPuck/fault_diag.cpp
  4. 34 0
      OpenPuck/fault_diag.h
  5. 24 6
      OpenPuck/haptics.cpp
  6. 6 1
      OpenPuck/haptics.h
  7. 24 8
      OpenPuck/rf_link.cpp
  8. 5 1
      OpenPuck/rf_link.h
  9. 30 15
      OpenPuck/webusb_config.cpp
  10. 86 35
      docs/index.html

+ 26 - 0
OpenPuck/OpenPuck.ino

@@ -232,6 +232,9 @@ void setup()
 	// Classify why we (re)booted: distinguishes a watchdog hang from a HardFault from an intentional reboot
 	// (issue #72 -- those are conflated in the field). Surfaced on the WebUSB panel too.
 	faultDiagBoot();
+	// Clock fingerprint: which crystals/oscillators this (possibly clone) board actually came up on. Surfaced
+	// on the panel so flaky clones can be told apart from the known-good board.
+	clockDiagBoot();
 	if (puckMode)
 		Serial.printf(
 			"# puck USB: %s\n",
@@ -278,6 +281,9 @@ void loop()
 {
 	// feed the watchdog; if we ever stop, the ~8s WDT auto-resets us
 	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	faultDiagBeat(); // loop heartbeat -- the SOF blob reports ms-since-beat so a wedge is visible live
+	// cross-check HFCLK(micros) vs LFCLK(millis) once a second -- cheap, both builds (clone clock diagnostic)
+	clockDiagTick();
 	if (g_dirty) {
 		g_dirty = false;
 		saveBonds();
@@ -289,28 +295,37 @@ void loop()
 	static unsigned long secMs = 0;
 	uint32_t t;
 	t = micros();
+	faultDiagSetStage(0);
 	webusbPoll();
 	acc[0] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(1);
 	if (g_active)
 		g_active->task();
 	acc[1] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(2);
 	serialConsolePoll();
 	acc[2] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(3);
 	rfDiagTask();
 	acc[3] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(4);
 	rfLinkTask();
 	acc[4] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(5);
 	hapticTask();
 	acc[5] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(6);
 	ledTask();
 	acc[6] += (uint32_t)(micros() - t);
+	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 	loops++;
 	if (millis() - secMs >= 1000) {
@@ -330,15 +345,26 @@ void loop()
 		secMs = millis();
 	}
 #else
+	// faultDiagSetStage breadcrumb (GPREGRET2) before each stage: if loop() wedges, the watchdog reset's next
+	// boot reports which stage was stuck. Cheap (one register write each).
+	faultDiagSetStage(0);
 	webusbPoll();
+	faultDiagSetStage(1);
 	if (g_active)
 		g_active->task();
+	faultDiagSetStage(2);
 	serialConsolePoll();
+	faultDiagSetStage(3);
 	rfDiagTask();
+	faultDiagSetStage(4);
 	rfLinkTask();
+	faultDiagSetStage(5);
 	hapticTask();
+	faultDiagSetStage(6);
 	ledTask();
+	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 #endif
 }

+ 2 - 7
OpenPuck/config.cpp

@@ -151,13 +151,8 @@ void loadCfg()
 			// 0 is a valid setting (rumble off)
 			g_rumbleScale = c.rumbleScale;
 			// Connection tunables: the poll RX window is now FIXED (g_rxWin is const) -- any persisted
-			// rxWin10 is ignored. Restore the post-connect haptic block (enable + duration in seconds).
-			g_hapticBlockOn = c.hapticBlockOn ? 1 : 0;
-			// clamp seconds so s*1000 fits the uint16 ms field (max 60s)
-			g_hapticBlockMs = (uint16_t)((c.hapticBlockS > 60 ?
-							      60 :
-							      c.hapticBlockS) *
-						     1000u);
+			// rxWin10 is ignored. The post-connect haptic block is permanently disabled and no longer
+			// configurable, so any persisted hapticBlockOn/hapticBlockS is ignored too.
 		}
 		f.close();
 	}

+ 124 - 0
OpenPuck/fault_diag.cpp

@@ -9,6 +9,16 @@
 
 static uint8_t g_reason = RR_UNKNOWN;
 static uint32_t g_resetReas = 0;
+static uint8_t g_hangStage = 0xFF;
+
+// Hang breadcrumb: loop() stage written to GPREGRET2 as 0x80|stage. 0x80..0x9F can never collide with the
+// intentional-reboot (0xB1) / HardFault (0xFA) markers or the power-on default (0), so the same register
+// disambiguates all of them at boot.
+#define G2_STAGE_FLAG 0x80u
+static const char *const STAGE_STR[] = { "webusb", "ctrl.task", "serial",
+					 "rfdiag", "rflink",	"haptic",
+					 "led",	   "usbmount",	"usbtx" };
+#define STAGE_COUNT ((uint8_t)(sizeof STAGE_STR / sizeof STAGE_STR[0]))
 
 static const char *const REASON_STR[RR_COUNT] = {
 	"unknown",   "power-on", "pin/replug", "WATCHDOG (hang)", "CPU lockup",
@@ -33,6 +43,47 @@ void faultDiagArmIntentionalReset()
 	NRF_POWER->GPREGRET2 = G2_INTENT;
 }
 
+// Live stage + loop heartbeat. The post-reset GPREGRET2 breadcrumb only works if the bootloader preserves
+// GPREGRET2 across a watchdog reset -- some clone boards clear it, so it reads blank. These LIVE values don't
+// depend on surviving a reset: the WebUSB blob send runs on the USB SOF interrupt (independent of loop()), so
+// when loop() wedges, the SOF drain can still report the current stage + how long loop() has been stuck,
+// straight to the panel during the ~8s before the watchdog fires.
+static volatile uint8_t g_curStage = 0xFF;
+static volatile uint32_t g_loopBeatMs = 0;
+
+void faultDiagSetStage(uint8_t stage)
+{
+	g_curStage = stage;
+	NRF_POWER->GPREGRET2 = (uint8_t)(G2_STAGE_FLAG | (stage & 0x1Fu));
+}
+
+void faultDiagBeat()
+{
+	g_loopBeatMs = millis();
+}
+uint8_t faultDiagCurStage()
+{
+	return g_curStage;
+}
+uint32_t faultDiagStallMs()
+{
+	// ms since loop() last beat. ~0 when healthy (loop beats ~250x/s); grows while loop() is stuck.
+	return (uint32_t)(millis() - g_loopBeatMs);
+}
+const char *faultDiagStageStr(uint8_t s)
+{
+	return s < STAGE_COUNT ? STAGE_STR[s] : "?";
+}
+
+uint8_t faultDiagHangStage()
+{
+	return g_hangStage;
+}
+const char *faultDiagHangStageStr()
+{
+	return g_hangStage < STAGE_COUNT ? STAGE_STR[g_hangStage] : "n/a";
+}
+
 void faultDiagBoot()
 {
 	uint32_t rr =
@@ -41,6 +92,16 @@ void faultDiagBoot()
 	NRF_POWER->GPREGRET2 = 0; // consume the marker for this boot cycle
 	g_resetReas = rr;
 
+	// If this boot follows a watchdog/lockup reset and GPREGRET2 holds a stage breadcrumb (0x80|stage), recover
+	// which loop stage was stuck. Only meaningful for hangs -- an intentional reboot/HardFault stamps its own
+	// marker over the breadcrumb, and a clean power-on zeroes it.
+	bool isHang = (rr & POWER_RESETREAS_DOG_Msk) ||
+		      (rr & POWER_RESETREAS_LOCKUP_Msk);
+	if (isHang && (g2 & G2_STAGE_FLAG) && g2 < G2_INTENT)
+		g_hangStage = (uint8_t)(g2 & 0x1Fu);
+	else
+		g_hangStage = 0xFF;
+
 	uint8_t reason;
 	// Precedence: a physical pin reset / watchdog / lockup is unambiguous from RESETREAS; only a SREQ needs the
 	// GPREGRET2 marker to split intentional reboot vs HardFault.
@@ -65,6 +126,69 @@ void faultDiagBoot()
 	Serial.printf(
 		"# reset cause: %s (RESETREAS=0x%08lX gpregret2=0x%02X)\n",
 		REASON_STR[reason], (unsigned long)rr, g2);
+	if (g_hangStage != 0xFF)
+		Serial.printf("# hang stage: %s (%u)\n", faultDiagHangStageStr(),
+			      g_hangStage);
+}
+
+// ---- clock fingerprint -------------------------------------------------------------------------------------
+static uint8_t g_clkLf = 0, g_clkHf = 0;
+static uint16_t g_clkUsPerMs = 0;
+
+void clockDiagBoot()
+{
+	// LFCLKSTAT: bit16 STATE(running), bits1:0 SRC (0=RC,1=Xtal,2=Synth). HFCLKSTAT: bit16 STATE, bit0 SRC
+	// (0=RC,1=Xtal). The bare-metal radio needs HFXO; if HF shows RC here, RF runs on the 64 MHz internal RC.
+	uint32_t lf = NRF_CLOCK->LFCLKSTAT;
+	uint32_t hf = NRF_CLOCK->HFCLKSTAT;
+	bool lfrun = lf & CLOCK_LFCLKSTAT_STATE_Msk;
+	uint8_t lfsrc = (uint8_t)(lf & CLOCK_LFCLKSTAT_SRC_Msk);
+	g_clkLf = lfrun ? (uint8_t)(lfsrc + 1) : 0; // 1=RC,2=Xtal,3=Synth, 0=stopped
+	bool hfxtal = hf & CLOCK_HFCLKSTAT_SRC_Msk;
+	g_clkHf = hfxtal ? 2 : 0; // 2=crystal, 0=RC
+	Serial.printf("# clock: LFCLK=%s HFCLK=%s\n",
+		      g_clkLf == 2 ? "xtal" :
+		      g_clkLf == 1 ? "RC" :
+		      g_clkLf == 3 ? "synth" :
+				     "stopped",
+		      g_clkHf == 2 ? "xtal" : "RC");
+}
+
+void clockDiagTick()
+{
+	// Cross-check the two time bases: count micros() (HFCLK-derived) elapsed over a ~1s millis() (LFCLK/RTC)
+	// window. usPerMs = micros-delta / millis-delta; 1000 = the clocks agree. A clone whose HFCLK runs fast vs
+	// its LFCLK reads >1000 here -- the exact signature behind "poll rate too high, delivered too low".
+	static uint32_t u0 = 0;
+	static unsigned long m0 = 0;
+	static bool init = false;
+	unsigned long m = millis();
+	if (!init) {
+		u0 = micros();
+		m0 = m;
+		init = true;
+		return;
+	}
+	unsigned long dm = m - m0;
+	if (dm >= 1000) {
+		uint32_t du = (uint32_t)(micros() - u0);
+		g_clkUsPerMs = (uint16_t)(du / dm);
+		u0 = micros();
+		m0 = m;
+	}
+}
+
+uint8_t clockLfSrc()
+{
+	return g_clkLf;
+}
+uint8_t clockHfSrc()
+{
+	return g_clkHf;
+}
+uint16_t clockUsPerMs()
+{
+	return g_clkUsPerMs;
 }
 
 uint8_t faultDiagReason()

+ 34 - 0
OpenPuck/fault_diag.h

@@ -36,7 +36,41 @@ void faultDiagBoot();
 // Stamp "the reset about to happen is intentional" -- call immediately before a deliberate NVIC_SystemReset so
 // the next boot classifies it as RR_REBOOT, not RR_HARDFAULT.
 void faultDiagArmIntentionalReset();
+
+// ---- hang-stage breadcrumb -------------------------------------------------------------------------------
+// loop() writes the stage it is ENTERING to GPREGRET2 (encoded 0x80|stage, distinct from the intentional/fault
+// markers and from 0 during normal SREQ). If loop() wedges, the ~8s watchdog fires; GPREGRET2 survives that
+// reset, so the next boot can report WHICH loop stage was stuck -- the missing piece for the clone hangs.
+// Stage indices match the OPK_LOG timing order: 0 webusb,1 ctrl.task,2 serial,3 rfdiag,4 rflink,5 haptic,
+// 6 led,7 usbmount,8 usbtx.
+void faultDiagSetStage(uint8_t stage);
+// The stage loop() was in when the last WATCHDOG/LOCKUP reset fired (0xFF = last boot wasn't a hang, or the
+// breadcrumb didn't survive -- some clone bootloaders clear GPREGRET2). String form for the panel/console.
+uint8_t faultDiagHangStage();
+const char *faultDiagHangStageStr();
+
+// Live hang localization (does NOT need a reset to survive): faultDiagBeat() is called once per loop()
+// iteration; faultDiagStallMs() is ms since that last beat (~0 healthy, grows while loop() is wedged), and
+// faultDiagCurStage() is the stage loop() is currently in. The SOF-driven blob reports these so the panel can
+// show "STALLED in <stage>" live, before the watchdog even fires.
+void faultDiagBeat();
+uint8_t faultDiagCurStage();
+uint32_t faultDiagStallMs();
+const char *faultDiagStageStr(uint8_t s);
 // Last-boot classification (RR_*) + the raw RESETREAS, for the WebUSB panel / console.
 uint8_t faultDiagReason();
 uint32_t faultDiagResetReas();
 const char *faultDiagReasonStr();
+
+// ---- clock fingerprint (clone-board stability diagnostic) -------------------------------------------------
+// nice!nano clones vary in their crystals. The bare-metal radio needs HFXO (32 MHz xtal); millis()/RTC and the
+// watchdog run on LFCLK (32.768 kHz xtal or RC). If a clone runs HFCLK on the internal RC, or its LFCLK is
+// off-rate, the two time bases diverge -- which inflates the micros()-gated poll rate, shortens the RX window
+// (delivered drops), and skews the LFCLK-based watchdog. These let the panel show, per board: which source
+// each clock is actually on, and the measured micros()/millis() ratio (ideal 1000 us per ms).
+//   LF code: 0=stopped, 1=RC, 2=crystal, 3=synth.  HF code: 0=RC(running or not), 2=crystal.
+void clockDiagBoot(); // read+log the clock sources once at boot
+void clockDiagTick(); // call from loop(); recomputes usPerMs about once a second
+uint8_t clockLfSrc();
+uint8_t clockHfSrc();
+uint16_t clockUsPerMs(); // measured micros() advanced per millis() tick (ideal 1000; 0 until first sample)

+ 24 - 6
OpenPuck/haptics.cpp

@@ -75,6 +75,12 @@ static inline uint8_t rqNext(uint8_t i)
 {
 	return (uint8_t)((i + 1) % RELAY_QLEN);
 }
+// Counts times a ring-drain loop hit its iteration cap = head/tail were desynced or corrupted (e.g. an
+// out-of-range g_rqHead from a stray write/stack overflow). These loops run with IRQs DISABLED, so an
+// unterminated one spins forever with interrupts off -> millis()/USB/SOF all freeze and only the hardware
+// watchdog recovers (an invisible "watchdog (hang)" -- the live stall monitor can't see it because the SOF
+// IRQ is dead). The cap turns that into a logged, recovered event instead of a hang. Surfaced on the panel.
+volatile uint16_t g_ringFault = 0;
 
 bool relayPending()
 {
@@ -237,7 +243,13 @@ static void hapticCancelPendingOn()
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
 	for (int s = 0; s < NSLOT; s++) {
+		uint8_t guard = RELAY_QLEN + 1;
 		for (uint8_t i = g_rqTail[s]; i != g_rqHead[s]; i = rqNext(i)) {
+			if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off
+				g_rqHead[s] = g_rqTail[s] = 0;
+				g_ringFault++;
+				break;
+			}
 			RelayMsg &m = g_rq[s][i];
 			if (m.rid == 0x82) {
 				bool on = false;
@@ -338,7 +350,7 @@ void rfConnQueueHapticRelay()
 // rfConnFlushRelay(ch, s1): drain one entry from the current slot's relay queue and TX it. Each slot's queue
 // is independent, so each controller only sees its own commands. With N connected slots the per-slot relay
 // rate is 1/N of the per-cycle rate; sustained buzz streams are still 1 packet/cycle/slot.
-void rfConnFlushRelay(uint8_t ch, uint8_t s1)
+bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 {
 	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	// Snapshot one entry under a short critical section. relayEnqueue() (the producer) runs on the
@@ -350,7 +362,13 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 	bool have = false;
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
+	uint8_t guard = RELAY_QLEN + 1;
 	while (g_rqTail[cur] != g_rqHead[cur]) {
+		if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off (watchdog-hang class)
+			g_rqHead[cur] = g_rqTail[cur] = 0;
+			g_ringFault++;
+			break;
+		}
 		RelayMsg &m = g_rq[cur][g_rqTail[cur]];
 		g_rqTail[cur] = rqNext(g_rqTail[cur]); // consume the slot
 		// rid 0 = entry voided by hapticCancelPendingOn -> skip
@@ -402,6 +420,7 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				 80); // one relay per poll cycle
 		}
 	}
+	return have; // true = a relay frame went out this cycle (steals a reply window from the E3 poll)
 }
 
 // Haptic-subsystem re-init: the captured sequence Steam sends when it (re)takes control (0x81 reset + 0x87
@@ -470,9 +489,8 @@ void hapticInit()
 		g_rqHead[s] = g_rqTail[s] = 0;
 		g_rumble80On[s] = false;
 		g_rumble80Ms[s] = 0;
-		// block stale Steam 0x82 until the link is stable after boot (when the block is enabled)
-		g_hapticBlockUntil[s] =
-			g_hapticBlockOn ? (millis() + g_hapticBlockMs) : 0;
+		// post-connect haptic block is permanently disabled (not armed, not configurable)
+		g_hapticBlockUntil[s] = 0;
 	}
 }
 // Arm the post-(re)connect haptic block + schedule the clearing re-init. Called on the reliable first-reply
@@ -482,8 +500,8 @@ void hapticOnReconnect(int slot)
 {
 	if (slot < 0 || slot >= NSLOT)
 		return;
-	g_hapticBlockUntil[slot] =
-		g_hapticBlockOn ? (millis() + g_hapticBlockMs) : 0;
+	// post-connect haptic block is permanently disabled -- relay haptics immediately on (re)connect
+	g_hapticBlockUntil[slot] = 0;
 	g_haptic82On = false;
 	g_rumble80On[slot] = false;
 	g_rumble80Ms[slot] = 0;

+ 6 - 1
OpenPuck/haptics.h

@@ -107,7 +107,12 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
 // PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
 void rfConnQueueHapticRelay();
-void rfConnFlushRelay(uint8_t ch, uint8_t s1);
+// returns true if a relay frame was actually transmitted this call (queue had an entry), so the poll loop can
+// count relay TXs separately from poll cycles.
+bool rfConnFlushRelay(uint8_t ch, uint8_t s1);
+// times a relay-ring drain hit its iteration cap (head/tail desync or corruption) -- non-zero means we caught
+// and recovered from what would otherwise be an IRQ-off watchdog hang. Surfaced on the WebUSB panel.
+extern volatile uint16_t g_ringFault;
 
 // boot reset: clear relay/active flags, arm the reconnect block
 void hapticInit();

+ 24 - 8
OpenPuck/rf_link.cpp

@@ -101,6 +101,11 @@ int g_curSlot = -1;
 static uint8_t g_pollPid[NSLOT] = {};
 static uint8_t g_relayPid[NSLOT] = {};
 static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
+// g_stPoll counts true poll CYCLES (one E3 GET per warm slot per cycle). g_stRelay counts relay frames TX'd
+// (host/haptic output reports). They were conflated before (every rfConnTx bumped one counter), which made
+// "Polls/s" read ~540 (250 polls + ~290 relays) and hid that each relay steals a reply window from its poll.
+static uint32_t g_stRelay = 0;
+uint16_t g_relayps = 0;
 static unsigned long g_stMs = 0;
 // Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
 // slots into a single sequence makes one controller "swallow" the other's frame).
@@ -258,7 +263,6 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			    RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
 	NRF_RADIO->EVENTS_END = 0;
 	NRF_RADIO->TASKS_RXEN = 1;
-	g_stPoll++;
 	uint32_t t0 = micros();
 	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < win) {
 	} // RX window (tunable 'r'; or relay override)
@@ -748,7 +752,8 @@ static void rfConnStep()
 		{
 			uint8_t rs1 =
 				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
-			rfConnFlushRelay(ch, rs1);
+			if (rfConnFlushRelay(ch, rs1))
+				g_stRelay++;
 		}
 		// per-slot PID cycle so each bonded controller's polls stay distinct
 		uint8_t pidv = g_pollPid[k]++;
@@ -768,18 +773,27 @@ static void rfConnStep()
 		}
 		if (rx)
 			g_chF1[0]++;
+		g_stPoll++; // one true poll cycle for this slot
 		g_connPoll++;
 	};
 
-	// Poll every warm slot this cycle. "Cold" = was connected, now silent > SLOT_COLD_MS;
-	// those are retried only every SLOT_COLD_RETRY_MS. Never-replied slots count as warm so
-	// a controller that connects at any time gets the full poll rate to establish its link.
+	// Poll a slot every cycle (full rate) only when its controller is actually here. Throttle the rest to
+	// every SLOT_COLD_RETRY_MS:
+	//   - "cold": WAS connected but silent > SLOT_COLD_MS (controller powered off / out of range);
+	//   - "phantom": NEVER replied AND another controller is already connected. This is the cloned-puck case
+	//     (issue: bonds copied from a backup include controllers that aren't present). Polling a phantom slot
+	//     every cycle was doubling/tripling Polls/s, flooding noRx, and stealing reply windows from the live
+	//     controller. A never-replied slot with nothing else connected still polls full-rate, so the FIRST
+	//     controller connects instantly; a phantom only backs off once a real link exists to protect.
+	bool linkUp = anySlotLinkUp();
 	for (int k = 0; k < NSLOT; k++) {
 		if (!g_slot[k].used)
 			continue;
-		bool cold = g_connReplyMs[k] != 0 &&
-			    nowMs - g_connReplyMs[k] > SLOT_COLD_MS;
-		if (cold && nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+		bool everReplied = g_connReplyMs[k] != 0;
+		bool cold = everReplied && (nowMs - g_connReplyMs[k] > SLOT_COLD_MS);
+		bool phantom = !everReplied && linkUp;
+		if ((cold || phantom) &&
+		    nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
 			continue;
 		doPoll(k);
 	}
@@ -898,6 +912,7 @@ void rfLinkTask()
 		g_f1ps = g_stF1;
 		g_newps = g_stNew;
 		g_pollsps = (uint16_t)g_stPoll;
+		g_relayps = (uint16_t)g_stRelay;
 		g_crcps = (uint16_t)g_stCrc;
 		g_norxps = (uint16_t)g_stNoRx;
 		g_pollPeriodUs =
@@ -913,6 +928,7 @@ void rfLinkTask()
 				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
 				g_curSlot);
 		g_stPoll = 0;
+		g_stRelay = 0;
 		g_stF1 = 0;
 		g_stNew = 0;
 		g_stF3 = 0;

+ 5 - 1
OpenPuck/rf_link.h

@@ -78,8 +78,12 @@ extern uint16_t g_f1ps; // last completed second's F1 rate
 // genuine new-report rate (report 0x45 seq byte changes)
 extern uint16_t g_newps;
 
-// last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
+// last second's TRUE poll-cycle count (one E3 GET per warm slot per cycle, ~250 for one controller) -- vs F1
+// tells starvation from reply-loss.
 extern uint16_t g_pollsps;
+// last second's relay-frame TX count (host/haptic output reports forwarded to the controller). Each relay TX
+// precedes a poll and consumes a reply window, so a high relay rate directly depresses the F1/delivered rate.
+extern uint16_t g_relayps;
 // last second's CRC-fail and no-reply counts: diagnose a wedge -- polls>0 + noRx high = RX dead / wrong
 // channel / controller silent; crc high = corrupt replies (interference); polls==0 = poll loop stopped.
 extern uint16_t g_crcps, g_norxps;

+ 30 - 15
OpenPuck/webusb_config.cpp

@@ -35,7 +35,8 @@ static volatile bool g_bondExportRequest = false;
 // p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v11 extends to 113 bytes (115 total incl header);
 // browser reads with transferIn(128) to span the two USB-FS packets.
 //                [diag: crc/s][noRx/s][rfStallRecover count]
-#define WB_PAYLEN 116
+//                [v12: relayps(lo,hi)][clkLfSrc][clkHfSrc][usPerMs(lo,hi)][hangStage][curStage][stallMs/40][ringFault(lo,hi)]
+#define WB_PAYLEN 127
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -56,8 +57,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 11;
+	// protocol version (12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 12;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -166,6 +167,26 @@ static void webusbSendBlob()
 	p[115] = (uint8_t)(g_crcps > 255 ? 255 : g_crcps);
 	p[116] = (uint8_t)(g_norxps > 255 ? 255 : g_norxps);
 	p[117] = (uint8_t)(g_rfStallRecover > 255 ? 255 : g_rfStallRecover);
+	// v12: relay-frame TX rate (separated from true poll cycles -- Polls/s now reads ~250, Relay/s exposes the
+	// host output-report flood that steals reply windows), and the clock fingerprint for clone-board triage.
+	p[118] = (uint8_t)g_relayps;
+	p[119] = (uint8_t)(g_relayps >> 8);
+	p[120] = clockLfSrc();
+	p[121] = clockHfSrc();
+	uint16_t upm = clockUsPerMs();
+	p[122] = (uint8_t)upm;
+	p[123] = (uint8_t)(upm >> 8);
+	// last hang's loop stage (0xFF = last boot wasn't a watchdog/lockup hang, or GPREGRET2 didn't survive)
+	p[124] = faultDiagHangStage();
+	// LIVE hang localization: current loop stage + how long loop() has been stuck (40ms units, capped). When
+	// loop() wedges these keep updating because the blob is sent from the SOF interrupt, not loop().
+	p[125] = faultDiagCurStage();
+	uint32_t sms = faultDiagStallMs();
+	p[126] = (uint8_t)(sms / 40 > 255 ? 255 : sms / 40);
+	// relay-ring fault count: non-zero = we caught+recovered a desynced/corrupt ring that would otherwise be an
+	// invisible IRQ-off watchdog hang. A climbing count points at the haptic relay path / memory corruption.
+	p[127] = (uint8_t)g_ringFault;
+	p[128] = (uint8_t)(g_ringFault >> 8);
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -214,6 +235,11 @@ static void webusbSendBondExport()
 // one. Keeps every usb_web write/flush off the loop task so it can't block on the device event queue.
 static void webusbSofDrain(void)
 {
+	// If loop() has stopped beating, it's wedged -- keep pushing the blob (which carries the live stuck stage)
+	// so the panel shows WHERE it hung during the ~8s before the watchdog fires. This runs on the SOF IRQ, so
+	// it works even though loop() is dead. drop-on-full in webusbSendBlob keeps it from ever blocking.
+	if (faultDiagStallMs() > 300)
+		g_blobRequest = true;
 	if (g_blobRequest) {
 		g_blobRequest = false;
 		webusbSendBlob();
@@ -537,18 +563,7 @@ void webusbPoll()
 					break; // Switch Pro gyro scale x10
 
 				// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
-
-				// post-connect haptic block enable: drop Steam haptics for g_hapticBlockMs after a connect
-				case 27:
-					g_hapticBlockOn = v ? 1 : 0;
-					break;
-
-				// post-connect haptic block duration, in seconds (clamp 0..60)
-				case 28:
-					g_hapticBlockMs =
-						(uint16_t)((v > 60 ? 60 : v) *
-							   1000u);
-					break;
+				// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
 				}
 				if (persist)
 					saveCfg();

+ 86 - 35
docs/index.html

@@ -107,7 +107,11 @@
         <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">Relay/s</div><div class="v" id="stRelay">—</div></div>
         <div class="stat"><div class="k">RF fails/s (crc·noRx·heal)</div><div class="v" id="stRfFail">—</div></div>
+        <div class="stat"><div class="k">Clock LF / HF</div><div class="v" id="stClock">—</div></div>
+        <div class="stat"><div class="k">µs per ms (ideal 1000)</div><div class="v" id="stUsPerMs">—</div></div>
+        <div class="stat"><div class="k">Loop</div><div class="v" id="stLoopState">—</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>
@@ -119,17 +123,6 @@
       <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
         Raw SC2 accel (before scaling). This controller is a ±2 g sensor (~16384 = 1 g); the Switch report divides it by 4 to present the genuine ±8 g (4096 = 1 g) scale so the console's gravity-correction engages and the gyro stops drifting.</p>
       <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:14px 0 0;border-top:1px solid #272d3d;padding-top:14px">
-        <strong style="flex-basis:100%">Connection tuning <span class="note">(persists across reboot)</span></strong>
-      </div>
-      <div class="row" style="margin:8px 0 0"><label style="min-width:150px">Block haptics on connect</label>
-        <button id="hapBlockOn">off</button>
-        <span class="note" style="flex:1">Drops all Steam haptics for a short window right after a controller (re)connects, so its haptic engine settles before the first real haptic.</span>
-      </div>
-      <div class="row" style="margin:8px 0 0"><label style="min-width:150px">Block duration</label>
-        <input type="range" id="hapBlockS" min="0" max="30" step="1"><span class="val" id="hapBlockSV"></span>
-        <span class="note" style="flex:1">How long to drop haptics after connect (seconds). Default 10 s.</span>
-      </div>
       <div class="row" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><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>
@@ -337,9 +330,11 @@ function renderSlotTabs(bondedCount) {
   updateSlotDisplay(g_activeSlot);
 }
 
-async function connect(){
+const USB_FILTERS=[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}];
+// Acquire+set up an already-chosen USBDevice (shared by the manual picker and the no-picker auto-reconnect).
+async function openDevice(d){
   try{
-    dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
+    dev=d;
     await dev.open();
     if(dev.configuration===null) await dev.selectConfiguration(1);
     let found=null;
@@ -351,7 +346,7 @@ async function connect(){
       for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
       if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
     }
-    if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); return; }
+    if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); dev=null; return false; }
     ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
     await dev.claimInterface(ifNum);
     await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
@@ -360,16 +355,38 @@ async function connect(){
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
     log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
-    navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
     startPolling();
-  }catch(e){ log("connect failed: "+e.message); }
+    return true;
+  }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
+}
+// Manual connect: shows the Chrome device picker (needed once, to authorize the device).
+async function connect(){
+  try{ const d = await navigator.usb.requestDevice({filters:USB_FILTERS}); await openDevice(d); }
+  catch(e){ log("connect failed: "+e.message); }
+}
+// No-picker reconnect: reopen a device already authorized this browser. Returns true if it connected.
+async function autoConnect(){
+  if(dev) return true;
+  try{
+    const ds = await navigator.usb.getDevices();
+    const d = ds.find(x=>USB_FILTERS.some(f=>f.vendorId===x.vendorId));
+    if(d) return await openDevice(d);
+  }catch(e){}
+  return false;
 }
 function onGone(){
   polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
-  $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
+  $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
-  log("device disconnected (likely a mode-switch reboot — reconnect)");
+  log("device disconnected (mode-switch / watchdog reboot) — auto-reconnecting when it returns");
+  // The puck re-enumerates after a reboot; retry without the picker. The "connect" event also triggers this,
+  // but poll a few times in case the event is missed.
+  let tries=0;
+  const iv=setInterval(async()=>{ if(dev||tries++>40){ clearInterval(iv); return; } if(await autoConnect()) clearInterval(iv); }, 500);
 }
+// Auto-reconnect when an authorized puck (re)appears — no button, no picker.
+navigator.usb && navigator.usb.addEventListener("connect", e=>{ if(!dev) autoConnect(); });
+navigator.usb && navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev) onGone(); });
 async function send(bytes){
   if(!dev) return;
   try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
@@ -379,8 +396,8 @@ async function send(bytes){
 // the bond-export dump share the same framing, distinguished by the marker byte (0xA5 status, 0xA7 bonds).
 async function readFrame(marker, minLen){
   try{
-    // largest frame (status blob) spans two USB-FS packets; read 128 to capture both in one call.
-    const r = await dev.transferIn(epIn, 128);
+    // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
+    const r = await dev.transferIn(epIn, 192);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
     let i=0; while(i<d.length && d[i]!==marker) i++;
@@ -470,7 +487,41 @@ function applyBlob(p){
   // polls>0 here while Delivered is "—" means the puck is still polling but getting no usable replies.
   $("#stPolls").textContent = polls+" /s";
   { const crc=(p.length>113?p[113]:0), norx=(p.length>114?p[114]:0), heal=(p.length>115?p[115]:0);
-    $("#stRfFail").textContent = crc+" · "+norx+" · "+heal; }
+    const rf=(p.length>126?(p[125]|(p[126]<<8)):0);
+    $("#stRfFail").innerHTML = crc+" · "+norx+" · "+heal + (rf?' · <span class="pill dn">ring '+rf+'</span>':"");
+    // ring fault = we caught+recovered a relay-ring corruption that would otherwise be an invisible IRQ-off
+    // watchdog hang. Log each new one (persists across the device reset + panel reconnect).
+    if(rf>(window._ringFault||0)) log("⚠ RING FAULT #"+rf+" recovered — caught a relay-ring desync/corruption (would have hung loop with IRQs off)");
+    window._ringFault=rf; }
+  // v12: relay-frame rate + clock fingerprint (browser index = firmware index − 2)
+  { const relay=(p.length>117?(p[116]|(p[117]<<8)):null);
+    $("#stRelay").textContent = relay==null ? "—" : (relay+" /s"); }
+  if(p.length>121){
+    const LF=["stopped","RC","xtal","synth"], HF=["RC","?","xtal","?"];
+    const lf=p[118], hf=p[119], upm=(p[120]|(p[121]<<8));
+    const lfBad=(lf!==2), hfBad=(hf!==2), upmBad=(upm && (upm<985||upm>1015));
+    const tag=(t,bad)=> bad ? '<span class="pill dn">'+t+'</span>' : t;
+    $("#stClock").innerHTML = tag(LF[lf]||lf, lfBad)+" / "+tag(HF[hf]||hf, hfBad);
+    $("#stUsPerMs").innerHTML = upm ? tag(""+upm, upmBad) : "—";
+  }
+  // v12: LIVE loop heartbeat — curStage (firmware p[125]) + ms-since-beat (p[126], 40ms units). Updates even
+  // while loop() is wedged (blob is pushed from the SOF interrupt), so a hang shows "STALLED @ <stage>" live.
+  if(p.length>124){
+    const STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
+    const cur=p[123], stallMs=p[124]*40, name=(STAGE[cur]||("stage "+cur));
+    if(stallMs>=200){
+      $("#stLoopState").innerHTML='<span class="pill dn">STALLED @ '+name+' '+stallMs+'ms</span>';
+      // Latch+log the stall so it survives the imminent watchdog reset + panel reconnect (host-side log
+      // persists). Log once per episode + keep escalating if it climbs toward the ~8s watchdog.
+      if(!window._stallEpisode || stallMs>window._stallPeak+800){
+        window._stallEpisode=true; window._stallPeak=stallMs;
+        log("⚠ LOOP STALL @ "+name+" ("+stallMs+"ms) — watchdog reset imminent");
+      }
+    } else {
+      window._stallEpisode=false; window._stallPeak=0;
+      $("#stLoopState").innerHTML='<span class="pill up">running</span>';
+    }
+  }
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
@@ -484,16 +535,17 @@ function applyBlob(p){
     const code = p[105];
     const raw = ((p[106]|(p[107]<<8)|(p[108]<<16)|(p[109]<<24))>>>0);
     const name = RR_NAMES[code] || ("code "+code);
-    $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+name+'</span>') : name;
+    // v12: which loop stage was stuck when the last watchdog/lockup hang fired (0xFF = last boot wasn't a hang)
+    const STAGE_NAMES = ["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
+    const hangStage = (p.length>122 ? p[122] : 0xFF);
+    let label = name;
+    if (hangStage!==0xFF) label += " @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage));
+    $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+label+'</span>') : label;
     const el = $("#resetHist");
-    if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0");
+    if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0")
+      + (hangStage!==0xFF ? "  hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "");
   }
 
-  // Connection tuning: haptic-block enable (p[111]), block seconds (p[112]). (poll RX window p[110] is now
-  // fixed in firmware and no longer shown.)
-  const hapBlockOn = (p.length>111?p[111]:0);
-  $("#hapBlockOn").textContent = hapBlockOn?"on":"off"; $("#hapBlockOn").classList.toggle("active",!!hapBlockOn);
-  if (p.length>112) setSlider("hapBlockS", p[112]);
   const gitDirty=(p.length>38?p[38]:0); let gitHash="";
   for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = gitHash
@@ -528,7 +580,7 @@ function applyBlob(p){
   { const sel=$("#swGyro"); if(document.activeElement!==sel) sel.value=swGyro10; }
   for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
 }
-function fmtSlider(id,v){ return id==="rumble" ? v+"%" : id==="hapBlockS" ? v+" s" : ""+v; }
+function fmtSlider(id,v){ return id==="rumble" ? v+"%" : ""+v; }
 function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
 
 let inflight=false;
@@ -553,7 +605,7 @@ function buildBackup(p, bp){
     types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7]}); }
   return { magic:"openpuck-backup", version:1, bonds,
     config:{ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
-             rumble:p[51], swProRate:p[52], swGyro10:p[53], hapBlockOn:p[111], hapBlockS:p[112], types } };
+             rumble:p[51], swProRate:p[52], swGyro10:p[53], types } };
 }
 function downloadBackup(obj){
   const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,"-");
@@ -608,13 +660,12 @@ async function importBackup(file){
     // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
     await sf(1,c.mDiv); await sf(2,c.mFric); await sf(22,c.rumble); await sf(16,c.persistMode?1:0);
     await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
-    await sf(27,c.hapBlockOn?1:0); await sf(28,c.hapBlockS);
     await sf(23,c.swProRate); await sf(24,c.swGyro10);
     for(let et=0; et<c.types.length && et<4; et++){ const t=c.types[et];
       for(let k=0;k<4;k++) await sf(40+et*8+k, t.back[k]);
       await sf(40+et*8+4, t.qam); await sf(40+et*8+5, t.abSwap?1:0);
       await sf(40+et*8+6, t.pad?1:0); await sf(40+et*8+7, t.led); }
-    log("settings replayed ("+(11+c.types.length*8)+" fields)");
+    log("settings replayed ("+(9+c.types.length*8)+" fields)");
     // write each bond slot with its own small command [0x0D][slot][used][24-byte rec]
     let n=0;
     for(let s=0;s<4;s++){
@@ -736,9 +787,8 @@ $("#factoryErase").onclick=async()=>{
 };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
-$("#hapBlockOn").onclick=()=>{ const on=$("#hapBlockOn").classList.contains("active"); setField(27, on?0:1); };
-for(const id of ["mDiv","mFric","rumble","hapBlockS"]){
-  const field={mDiv:1,mFric:2,rumble:22,hapBlockS:28}[id];
+for(const id of ["mDiv","mFric","rumble"]){
+  const field={mDiv:1,mFric:2,rumble:22}[id];
   $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
@@ -755,6 +805,7 @@ for(const b of document.querySelectorAll(".modebtn")){
     if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
 }
 if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
+else autoConnect(); // reopen an already-authorized puck on load, no picker (first-ever connect still needs the button)
 </script>
 </body>
 </html>