ソースを参照

can't seem to crash the Teyleten board

Jari 3 ヶ月 前
親
コミット
f715e16ea0

+ 3 - 0
OpenPuck/OpenPuck.ino

@@ -273,6 +273,9 @@ void setup()
 	NRF_WDT->TASKS_START = 1;
 	// Capture the stuck PC on the WDT's pre-reset interrupt (software stand-in for SWD on the clone hangs).
 	faultDiagArmHangCapture();
+	// Flash black box: at ~6s of loop stall (2s before the WDT reset) dump both tasks' stacked PCs + vitals
+	// to a reserved flash page -- the post-mortem channel that survives even the boards that wipe .noinit.
+	faultDiagBlackBoxArm();
 }
 
 // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only

+ 269 - 5
OpenPuck/fault_diag.cpp

@@ -185,6 +185,261 @@ static const char *frEvtStr(uint8_t e)
 	return e < FR_STR_COUNT ? FR_STR[e] : "?";
 }
 
+// ---- flash black box (pre-watchdog dump) -------------------------------------------------------------------
+// On boards whose bootloader wipes .noinit + GPREGRET2 across a watchdog reset (Teyleten-class), EVERY
+// post-mortem channel above is destroyed -- hangs on those boards were unforensicable. Flash survives any
+// reset, so: a 4 Hz TIMER4 interrupt (priority 1, above FreeRTOS BASEPRI masking, below the WDT capture's 0)
+// watches the loop heartbeat; when loop() has been frozen ~6 s (the WDT resets at 8 s), it walks the loop and
+// usbd tasks' TCBs for their stacked PCs (both tasks BLOCKED = the suspected mutual-deadlock class) plus the
+// PC it interrupted (covers the one-task-spinning class), and writes one record to a reserved raw flash page.
+// Next boot reports it on CDC and feeds the existing hangPC/hangStage panel channels. Caveats: fires only if
+// priority-1 interrupts still run (PRIMASK-off hangs leave no record -- same caveat as the WDT capture, and
+// itself a signal); a task that is RUNNING (not blocked) has a stale TCB pxTopOfStack, so cross-check its PC
+// against irqPC.
+#define BB_ADDR \
+	0xE8000UL // raw page: app image (~170 KB, ends < 0x60000) < here < InternalFS (0xED000)
+#define BB_MAGIC 0x62627831u // "bbx1"
+#define BB_WORDS 12
+static TaskHandle_t g_hLoop,
+	g_hUsbd; // captured by faultDiagStackTick's 1 Hz sweep
+// non-static (like g_hangFrame): the naked ISR's `ldr =symbol` needs external linkage to resolve
+extern "C" {
+volatile uint32_t g_bbIrqPC,
+	g_bbIrqLR; // stacked frame of whatever TIMER4 preempted
+}
+static void bbBootReport(
+	bool isHang); // defined below faultDiagBlackBoxArm; called from faultDiagBoot
+static void bbErase(void); // dump-time lazy erase (see bbTimerBody)
+
+static void bbFlashWord(uint32_t addr, uint32_t v)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+	*(volatile uint32_t *)addr = v;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+}
+// Stacked PC/LR of a BLOCKED FreeRTOS task. pxTopOfStack is the first TCB member (portable-layer invariant);
+// the CM4F PendSV push order is [r4-r11, EXC_RETURN] then (s16-s31 iff the FPU frame was live) then the
+// hardware frame [r0-r3, r12, lr, pc, xpsr]. EXC_RETURN bit4 = 0 means the FPU frame is present.
+static void bbTaskPc(TaskHandle_t h, uint32_t *pc, uint32_t *lr)
+{
+	*pc = *lr = 0;
+	if (!h)
+		return;
+	uint32_t *tos = *(uint32_t **)h;
+	uint32_t a = (uint32_t)tos;
+	if (a < 0x20000000u || a >= 0x20040000u - (26 + 8) * 4)
+		return; // corrupt TCB/stack -> don't fault inside the black box
+	uint32_t excret = tos[8];
+	uint32_t hw = 9 + ((excret & 0x10) ? 0 : 16);
+	*pc = tos[hw + 6];
+	*lr = tos[hw + 5];
+}
+extern "C" void bbTimerBody(void)
+{
+	NRF_TIMER4->EVENTS_COMPARE[0] = 0;
+	(void)NRF_TIMER4->EVENTS_COMPARE[0]; // readback: event write is posted
+	static uint32_t lastBeat;
+	static uint16_t stuck;
+	static bool dumped;
+	uint32_t b = g_loopBeatMs;
+	if (b != lastBeat) {
+		lastBeat = b;
+		stuck = 0;
+		return;
+	}
+	if (dumped)
+		return;
+	if (++stuck <
+	    24) { // 24 ticks @ 4 Hz = 6 s frozen; WDT reset lands at 8 s
+		// USBD self-heal kick before giving up. The black-boxed wedge (2026-07-03, loopPC=start_dma,
+		// usbdPC=idle xQueueReceive) is a USB EasyDMA whose END event/interrupt got swallowed (clone
+		// USBD silicon under concurrent RADIO EasyDMA): TinyUSB's dma_running never clears, every next
+		// transfer re-defers on the TASK_PRIO_HIGH usbd task (livelock), and TASK_PRIO_LOW loop starves.
+		// If the END EVENT is latched but its IRQ was lost, re-pending the USBD IRQ makes the ISR run
+		// edpt_dma_end -> the defer chain unwinds -> FULL recovery, no reset (the panel trail shows a
+		// recovered stall episode). Harmless when nothing is pending: a spurious USBD ISR just returns.
+		// >=500ms of frozen loop heartbeat is never legitimate (bond flash saves are ms-class).
+		if (stuck >= 2) {
+			NVIC_SetPendingIRQ(USBD_IRQn);
+			faultDiagTrace(
+				FR_HEAL,
+				0x05Bu); // arg 0x5B = USBD kick (radio heal passes its own count)
+		}
+		return;
+	}
+	dumped = true;
+	// Lazy erase: the page still holds the PREVIOUS hang's (already-reported) record -- boot no longer
+	// erases it, so its CDC banner stays re-printable on any later debug boot. An in-ISR page erase is
+	// ~85ms of CPU stall, irrelevant here: the system is already dead and the WDT reset is ~2s away.
+	bbErase();
+	// Record v2, written in TWO PHASES: phase 1 (PCs + vitals) commits BEFORE any USBD register is
+	// touched -- if the USBD peripheral has wedged the AHB matrix, reading its registers could bus-stall
+	// this ISR forever (the WDT still resets: its reset path is hardware, not CPU). A record with phase 1
+	// present and phase 2 erased (0xFFFFFFFF) is therefore itself the proof of an AHB/USBD bus wedge.
+	uint32_t lpc, llr, upc, ulr;
+	bbTaskPc(g_hLoop, &lpc, &llr);
+	bbTaskPc(g_hUsbd, &upc, &ulr);
+	uint32_t w1[8] = {
+		BB_MAGIC,
+		(2u << 24) | ((uint32_t)g_curStage << 16), // ver 2
+		lpc, // w2: loop task stacked PC (where it starved/blocked)
+		g_bbIrqPC, // w3: what TIMER4 preempted = the code RUNNING at dump time
+		upc, // w4: usbd task stacked PC
+		((uint32_t)g_flight.usbdStackFree << 16) |
+			g_flight.loopStackFree, // w5
+		((uint32_t)g_flight.relayps << 16) | g_flight.pollsps, // w6
+		g_flight.vMs, // w7: millis at last healthy vitals ~= wedge time
+	};
+	(void)llr;
+	(void)ulr;
+	for (int i = 0; i < 8; i++)
+		bbFlashWord(BB_ADDR + 4u * i, w1[i]);
+	// phase 2: USBD peripheral state -- did the DMA END event ever latch? are its interrupts enabled?
+	uint32_t endmask = 0;
+	for (int i = 0; i < 8; i++) {
+		if (NRF_USBD->EVENTS_ENDEPIN[i])
+			endmask |= (1u << i);
+		if (NRF_USBD->EVENTS_ENDEPOUT[i])
+			endmask |= (1u << (8 + i));
+	}
+	if (NRF_USBD->EVENTS_EPDATA)
+		endmask |= (1u << 16);
+	if (NRF_USBD->EVENTS_USBEVENT)
+		endmask |= (1u << 17);
+	if (NRF_USBD->EVENTS_EP0DATADONE)
+		endmask |= (1u << 18);
+	if (NRF_USBD->EVENTS_SOF)
+		endmask |= (1u << 19);
+	if (NVIC_GetPendingIRQ(USBD_IRQn))
+		endmask |= (1u << 20); // IRQ pending but not being serviced
+	if (NVIC_GetEnableIRQ(USBD_IRQn))
+		endmask |= (1u << 21);
+	uint32_t w2[4] = {
+		endmask, // w8: latched-event bitmap (ENDEPIN 0-7, ENDEPOUT 8-15, EPDATA/USBEVENT/EP0DD/SOF, NVIC)
+		NRF_USBD->INTEN, // w9: which USBD interrupts are enabled
+		NRF_USBD->EPDATASTATUS, // w10: per-endpoint data-ready flags
+		((uint32_t)NRF_USBD->EPINEN << 16) |
+			(NRF_USBD->EPOUTEN & 0xFFFF), // w11
+	};
+	for (int i = 0; i < 4; i++)
+		bbFlashWord(BB_ADDR + 4u * (8 + i), w2[i]);
+}
+// Naked shim: grab the interrupted context's stacked PC/LR (like the WDT capture), then tail-branch to the C
+// body -- LR still holds EXC_RETURN, so the body's return IS the exception return.
+extern "C" __attribute__((naked)) void TIMER4_IRQHandler(void)
+{
+	__asm volatile("tst lr, #4            \n"
+		       "ite eq                \n"
+		       "mrseq r0, msp         \n"
+		       "mrsne r0, psp         \n"
+		       "ldr r1, [r0, #24]     \n"
+		       "ldr r2, =g_bbIrqPC    \n"
+		       "str r1, [r2]          \n"
+		       "ldr r1, [r0, #20]     \n"
+		       "ldr r2, =g_bbIrqLR    \n"
+		       "str r1, [r2]          \n"
+		       "b bbTimerBody         \n"
+		       ".ltorg                \n");
+}
+void faultDiagBlackBoxArm()
+{
+	NRF_TIMER4->TASKS_STOP = 1;
+	NRF_TIMER4->MODE = TIMER_MODE_MODE_Timer << TIMER_MODE_MODE_Pos;
+	NRF_TIMER4->BITMODE = TIMER_BITMODE_BITMODE_32Bit
+			      << TIMER_BITMODE_BITMODE_Pos;
+	NRF_TIMER4->PRESCALER = 9; // 16 MHz / 512 = 31250 Hz
+	NRF_TIMER4->CC[0] = 31250u / 4u; // 4 Hz
+	NRF_TIMER4->SHORTS = TIMER_SHORTS_COMPARE0_CLEAR_Msk;
+	NRF_TIMER4->EVENTS_COMPARE[0] = 0;
+	NRF_TIMER4->INTENSET = TIMER_INTENSET_COMPARE0_Msk;
+	NVIC_SetPriority(TIMER4_IRQn,
+			 1); // above BASEPRI masking, below the WDT capture
+	NVIC_ClearPendingIRQ(TIMER4_IRQn);
+	NVIC_EnableIRQ(TIMER4_IRQn);
+	NRF_TIMER4->TASKS_START = 1;
+}
+static void bbErase()
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+	NRF_NVMC->ERASEPAGE = BB_ADDR;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+}
+// Boot-side: report the record. Runs from faultDiagBoot AFTER the .noinit hangPC/hangStage recovery, so on
+// boards where those survive the black box only fills the gaps; on RAM-wiping boards it IS the evidence.
+// Lifecycle: the record is NOT erased here -- a normal boot often has no CDC attached, so erasing on first
+// boot would destroy the register words unread. Instead it persists (the banner re-prints on ANY later boot,
+// e.g. a debug-CDC reboot) and the NEXT dump erases the page itself. A "seen" marker at word 12 keeps the
+// panel adoption one-shot so a stale record can't masquerade as a later, unrelated hang's evidence.
+static void bbBootReport(bool isHang)
+{
+	const volatile uint32_t *w = (const volatile uint32_t *)BB_ADDR;
+	bool fresh = (w[12] == 0xFFFFFFFFu);
+	if (w[0] != BB_MAGIC) {
+		// garbage/partial page (interrupted dump or pre-feature content): clean it so the next dump
+		// writes onto erased flash. All-blank needs no erase.
+		bool blank = true;
+		for (int i = 0; i < BB_WORDS; i++)
+			if (w[i] != 0xFFFFFFFFu)
+				blank = false;
+		// keep a half-written phase-1 record (magic present handled below); anything else non-blank
+		// without magic is garbage
+		if (!blank)
+			bbErase();
+		return;
+	}
+	{
+		uint8_t stage = (uint8_t)(w[1] >> 16);
+		Serial.printf(
+			"# BLACK BOX v%lu (flash dump written ~6s into the last hang):\n",
+			(unsigned long)(w[1] >> 24));
+		Serial.printf(
+			"#   stage=%s  loopPC=%08lX  irqPC=%08lX (running at dump)  usbdPC=%08lX\n",
+			faultDiagStageStr(stage), (unsigned long)w[2],
+			(unsigned long)w[3], (unsigned long)w[4]);
+		Serial.printf(
+			"#   stkFree usbd=%luw loop=%luw  polls/s=%lu relay/s=%lu  tWedge~%lums\n",
+			(unsigned long)(w[5] >> 16),
+			(unsigned long)(w[5] & 0xFFFF),
+			(unsigned long)(w[6] & 0xFFFF),
+			(unsigned long)(w[6] >> 16), (unsigned long)w[7]);
+		// phase 2 = USBD peripheral state; all-FF here with phase 1 present = reading USBD registers
+		// bus-stalled the dump ISR = the peripheral wedged the AHB matrix (decisive by itself).
+		if (w[8] == 0xFFFFFFFFu && w[9] == 0xFFFFFFFFu)
+			Serial.printf(
+				"#   USBD regs: UNREADABLE (dump died reading them -> AHB/USBD bus wedge)\n");
+		else
+			Serial.printf(
+				"#   USBD: events=%08lX (ENDEPIN0-7,ENDEPOUT8-15,EPDATA16,USBEVT17,EP0DD18,SOF19,NVICpend20,NVICen21)"
+				" INTEN=%08lX EPDATASTATUS=%08lX EPINEN=%04lX EPOUTEN=%04lX\n",
+				(unsigned long)w[8], (unsigned long)w[9],
+				(unsigned long)w[10],
+				(unsigned long)(w[11] >> 16),
+				(unsigned long)(w[11] & 0xFFFF));
+		// feed the existing panel channels (hangLog / Last-reset tile) when the .noinit path came back
+		// blank -- i.e. exactly the RAM-wiping boards this exists for. loopPC is the primary suspect
+		// location; irqPC (the code actually RUNNING while loop starved) rides the LR slot. One-shot:
+		// only a FRESH record (seen-marker still erased) may claim this boot's hang.
+		if (isHang && fresh) {
+			if (!g_reportHangPC && w[2]) {
+				g_reportHangPC = w[2];
+				g_reportHangLR = w[3];
+			}
+			if (g_hangStage == 0xFF && stage < STAGE_COUNT)
+				g_hangStage = stage;
+		}
+		if (fresh)
+			bbFlashWord(BB_ADDR + 4u * 12, 1u); // stamp "seen"
+	}
+}
+
 void faultDiagBoot()
 {
 	uint32_t rr =
@@ -246,6 +501,10 @@ void faultDiagBoot()
 			      (unsigned long)g_reportHangPC,
 			      (unsigned long)g_reportHangLR);
 
+	// Flash black box: report + consume last hang's pre-watchdog dump (fills hangPC/stage on boards whose
+	// bootloader wiped the .noinit/GPREGRET2 evidence above).
+	bbBootReport(isHang);
+
 	// Flight recorder: if the pre-reset trail survived (.noinit), snapshot it into ordinary RAM so the live
 	// session can start a fresh ring while the console can still re-dump the old trail ("FR"). On a hang, dump
 	// it now -- this is the post-mortem of what the firmware was doing in the seconds before it wedged.
@@ -546,11 +805,16 @@ void faultDiagStackTick()
 	UBaseType_t n = uxTaskGetSystemState(st, 12, NULL);
 	for (UBaseType_t i = 0; i < n; i++) {
 		uint16_t hw = (uint16_t)st[i].usStackHighWaterMark;
-		if (!strcmp(st[i].pcTaskName, "usbd") && hw < g_usbdStackMin)
-			g_usbdStackMin = hw;
-		else if (!strcmp(st[i].pcTaskName, "loop") &&
-			 hw < g_loopStackMin)
-			g_loopStackMin = hw;
+		if (!strcmp(st[i].pcTaskName, "usbd")) {
+			g_hUsbd =
+				st[i].xHandle; // black box: TCB to walk at dump time
+			if (hw < g_usbdStackMin)
+				g_usbdStackMin = hw;
+		} else if (!strcmp(st[i].pcTaskName, "loop")) {
+			g_hLoop = st[i].xHandle;
+			if (hw < g_loopStackMin)
+				g_loopStackMin = hw;
+		}
 	}
 }
 uint16_t faultDiagUsbdStackFree()

+ 4 - 0
OpenPuck/fault_diag.h

@@ -62,6 +62,10 @@ const char *faultDiagStageStr(uint8_t s);
 // watchdog hang, faultDiagHangPC()/LR() return the PC/LR of the stuck code (0 if the hang hard-masked
 // interrupts so the capture ISR couldn't run). Map the PC with addr2line on the build .elf.
 void faultDiagArmHangCapture();
+// Flash black box: 4 Hz TIMER4 watchdog-of-the-watchdog; at ~6s of loop stall it dumps both tasks' stacked
+// PCs + vitals to a reserved flash page (survives ANY reset, incl. boards that wipe .noinit/GPREGRET2).
+// Reported + consumed by faultDiagBoot on the next boot. Arm once from setup(), after the WDT starts.
+void faultDiagBlackBoxArm();
 uint32_t faultDiagHangPC();
 uint32_t faultDiagHangLR();
 

+ 16 - 3
OpenPuck/haptics.cpp

@@ -2,6 +2,7 @@
 #include "bonds.h"
 #include "config.h"
 #include "rf_link.h"
+#include "usb_tx.h" // usbTxBoost/Unboost -- flood-rate CDC prints share the dcd DMA claim window
 #include "puck_hid.h" // puckLizardActive() -- gate the lizard-suppression keepalive
 
 #include "fault_diag.h" // faultDiagTrace() -- flight recorder
@@ -357,14 +358,22 @@ void hapticStabTask()
 	static const uint8_t off[3] = { 0x01, 0x01, 0x00 };
 	static unsigned long lastOn = 0, offAt = 0;
 	unsigned long now = millis();
+	// Buzz only slots whose controller is actually answering polls (same 300ms liveness the panel/0x79 use).
+	// The old 0xFF broadcast kept stuffing a powered-off controller's relay ring -- pure eviction churn plus
+	// wasted TX at a radio that can't hear it, right inside the power-off hang window (issue #72 repro).
+	auto enqLive = [&](const uint8_t *p) {
+		for (uint8_t s = 0; s < NSLOT; s++)
+			if (g_slot[s].used && g_connReplyMs[s] &&
+			    (unsigned long)(now - g_connReplyMs[s]) < 300u)
+				relayEnqueue(0x82, p, 3, s);
+	};
 	if (lastOn == 0 || (uint32_t)(now - lastOn) >= 10000u) {
 		lastOn = now;
-		relayEnqueue(0x82, on, 3,
-			     0xFF); // broadcast buzz-on to all slots
+		enqLive(on);
 		offAt = now + 150;
 	}
 	if (offAt && (int32_t)(now - offAt) >= 0) {
-		relayEnqueue(0x82, off, 3, 0xFF);
+		enqLive(off);
 		offAt = 0;
 	}
 }
@@ -477,12 +486,16 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			// latches -- the piece the I45 stream (controller->us) can't show. Low rate outside a haptic
 			// burst; guarded so it never blocks the loop. cur = target slot.
 			if (Serial.availableForWrite() > 60) {
+				// boosted: this print runs at relay-flood rate and CDC flush enters the same
+				// TinyUSB DMA claim window as HID sends (the issue-72 livelock; see usb_tx.cpp)
+				usbTxBoost();
 				Serial.printf("# TX t=%lu slot%d %s rid=%02X:",
 					      (unsigned long)millis(), cur,
 					      land01 ? "L01" : "l05", m.rid);
 				for (uint8_t i = 0; i < rl && i < 8; i++)
 					Serial.printf(" %02X", m.data[i]);
 				Serial.println();
+				usbTxUnboost();
 			}
 			// slot was already consumed under the critical section above.
 			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB

+ 4 - 0
OpenPuck/puck_hid.cpp

@@ -142,6 +142,9 @@ static void fcPush(uint8_t dir, int iface, uint8_t rid, uint8_t cmd,
 }
 void puckCmdLogDrain(void)
 {
+	// boosted: runs at Steam's feature-storm rate and CDC flush enters the same TinyUSB DMA claim window
+	// as HID sends (the issue-72 livelock; see usb_tx.cpp)
+	usbTxBoost();
 	while (g_fcHead != g_fcTail) {
 		if (Serial.availableForWrite() < 70)
 			break; // don't stall loop on CDC backpressure; resume next iteration
@@ -154,6 +157,7 @@ void puckCmdLogDrain(void)
 			Serial.printf(" %02X", r.b[i]);
 		Serial.println();
 	}
+	usbTxUnboost();
 }
 
 // ===================== seamless LIZARD decision =====================

+ 16 - 4
OpenPuck/rf_link.cpp

@@ -767,6 +767,14 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 // instead of every cycle. This keeps the online controllers at full 250 Hz while barely touching offline ones.
 #define SLOT_COLD_MS 5000u
 #define SLOT_COLD_RETRY_MS 2000u
+// Quiet tier: a slot that WAS replying but has been silent past SLOT_QUIET_MS (controller powering off, out
+// of range) backs off to SLOT_QUIET_RETRY_MS retries instead of full-rate polling until SLOT_COLD_MS demotes
+// it. Without this, a power-off left the slot at 250 Hz for 5 s with EVERY poll burning the whole g_rxWin
+// (~2 ms) waiting for a reply that never comes -- with two controllers powered off together that is ~100%
+// radio duty, the churn window the power-off watchdog hang (issue #72 repro) lives in. Recovery stays snappy:
+// the first reply re-warms the slot to full rate, so a controller returning from a fade waits <= one retry.
+#define SLOT_QUIET_MS 300u
+#define SLOT_QUIET_RETRY_MS 50u
 static unsigned long g_slotLastAttemptMs[NSLOT] = {};
 
 // Drive the connected-mode sequence. The cycle gate fires once per g_pollUs (250 Hz); each fire
@@ -883,11 +891,15 @@ static void rfConnStep()
 		if (!g_slot[k].used)
 			continue;
 		bool everReplied = g_connReplyMs[k] != 0;
-		bool cold = everReplied &&
-			    (nowMs - g_connReplyMs[k] > SLOT_COLD_MS);
+		unsigned long silentMs =
+			everReplied ? (nowMs - g_connReplyMs[k]) : 0;
+		bool cold = everReplied && silentMs > SLOT_COLD_MS;
+		bool quiet = everReplied && !cold && silentMs > SLOT_QUIET_MS;
 		bool phantom = !everReplied && linkUp;
-		if ((cold || phantom) &&
-		    nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+		unsigned long retry = (cold || phantom) ? SLOT_COLD_RETRY_MS :
+				      quiet		? SLOT_QUIET_RETRY_MS :
+							  0;
+		if (retry && nowMs - g_slotLastAttemptMs[k] < retry)
 			continue;
 		doPoll(k);
 	}

+ 28 - 0
OpenPuck/usb_tx.cpp

@@ -114,6 +114,30 @@ void usbTxRegisterDrain(usbTxDrainFn fn)
 		g_drainFns[g_nDrain++] = fn;
 }
 
+// ---- priority-inversion guard (BLACK BOX capture 3, 2026-07-03) --------------------------------------------
+// TinyUSB's nrf5x dcd claims the chip's ONE EasyDMA slot (dma_running test_and_set) and only a few
+// instructions LATER writes TASKS_STARTEPIN -- and loop-context sends reach that window at TASK_PRIO_LOW.
+// Preempted right there during a Steam OUT flood (0x82 haptic storm), the flag is held by a task that never
+// runs again: the TASK_PRIO_HIGH usbd task livelocks re-deferring xact_out_dma around the never-started DMA
+// (caught live: loopPC=start_dma:139, irqPC=xact_out_dma re-defer) -> USB dead -> watchdog. The fix needs no
+// library patch: run loop's USB-TX windows at the usbd task's own priority. Equal priority = no preemption on
+// interrupt exit = the claim+start pair completes atomically w.r.t. the usbd task. Depth-counted so nested
+// boosted sections (pump -> drain callbacks) stay balanced; only ever used from the loop task.
+static UBaseType_t g_boostSaved;
+static uint8_t g_boostDepth;
+void usbTxBoost(void)
+{
+	if (!g_boostDepth++) {
+		g_boostSaved = uxTaskPriorityGet(NULL);
+		vTaskPrioritySet(NULL, TASK_PRIO_HIGH);
+	}
+}
+void usbTxUnboost(void)
+{
+	if (g_boostDepth && !--g_boostDepth)
+		vTaskPrioritySet(NULL, g_boostSaved);
+}
+
 // Drain everything from loop() -- NOT from a SOF callback, a dedicated task, or report_complete. All three of
 // those were tried and all fight the RF poll, which is a microsecond-precise busy-wait running in loop(): SOF
 // got silently disabled by bus resets; the high-priority usbd task (report_complete) preempted the RF RX
@@ -125,9 +149,13 @@ void usbTxRegisterDrain(usbTxDrainFn fn)
 // Call once per loop() iteration.
 void usbTxPump(void)
 {
+	// Whole pump (HID sends + vendor/WebUSB drains) runs boosted -- every dcd claim window in here is
+	// then atomic against the usbd task (see usbTxBoost above).
+	usbTxBoost();
 	usbTxDrain();
 	for (uint8_t i = 0; i < g_nDrain; i++)
 		g_drainFns[i]();
+	usbTxUnboost();
 }
 void usbTxBegin(void)
 {

+ 7 - 0
OpenPuck/usb_tx.h

@@ -36,6 +36,13 @@ void usbTxPump(void);
 // Drain pending HID reports (one ready report per destination per call). Internal -- called by usbTxPump().
 void usbTxDrain(void);
 
+// Priority-inversion guard: run a loop-context USB TX window (HID/vendor/CDC writes) at the usbd task's
+// priority so TinyUSB's dcd DMA claim+start can't be preempted mid-claim (the issue-72 livelock; see
+// usb_tx.cpp). Depth-counted; loop task only, always in a balanced pair. usbTxPump() boosts itself; wrap
+// direct Serial/usb_web writes that can run at flood rate (e.g. per-relay logging).
+void usbTxBoost(void);
+void usbTxUnboost(void);
+
 // Register a callback to run every USB frame ON THE usbd TASK (from tud_sof_cb, right after usbTxDrain()).
 // This is how senders that DON'T go through Adafruit_USBD_HID get their transmits off the loop task too:
 //   - mode_xinput flushes its raw custom-class IN endpoints;

+ 60 - 2
OpenPuck/webusb_config.cpp

@@ -56,7 +56,11 @@ static void webusbSendBlob()
 	// every ~250ms in normal use.
 	int cs = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	bool up = (g_curSlot >= 0 && (millis() - g_connReplyMs[cs]) < 300);
-	uint8_t p[2 + WB_PAYLEN];
+	// STATIC, not stack: this runs on the usbd task, whose 800B stack (hardcoded USBD_STACK_SZ in the
+	// Adafruit core, not growable from the sketch) is the prime overflow suspect behind the issue-72
+	// watchdog hangs (hang boots report usbd stack free = 0). A 178B frame on that stack was the single
+	// biggest consumer. Single-writer (SOF drain only), so a static buffer is race-free.
+	static uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
@@ -251,7 +255,8 @@ static void webusbSendBondExport()
 {
 	if (!usb_web.connected())
 		return;
-	uint8_t p[2 + WB_BOND_PAYLEN];
+	// static for the same reason as the status blob: keep the 100B frame off the 800B usbd-task stack.
+	static uint8_t p[2 + WB_BOND_PAYLEN];
 	p[0] = 0xA7;
 	p[1] = WB_BOND_PAYLEN;
 	p[2] = 1; // format version
@@ -280,10 +285,23 @@ static void webusbSendBondExport()
 // The panel sends 0x10 with buf[1]=1 to (re)start (emits the header + rewinds the cursor), then 0x10 buf[1]=0
 // repeatedly; each call streams a budgeted chunk and the final call appends the end frame.
 #define WB_FLIGHT_HDR 29
+// RAII boost: these drains run from loop() and push many usb_web.write()s per call -- each enters TinyUSB's
+// dcd DMA claim window (the issue-72 livelock; see usb_tx.cpp), so the whole burst runs boosted.
+struct TxBoostScope {
+	TxBoostScope()
+	{
+		usbTxBoost();
+	}
+	~TxBoostScope()
+	{
+		usbTxUnboost();
+	}
+};
 static void webusbDrainFlight(bool restart)
 {
 	if (!usb_web.connected())
 		return;
+	TxBoostScope boost;
 	if (restart) {
 		faultDiagFlightDrainReset();
 		if (tud_vendor_write_available() >= 2 + WB_FLIGHT_HDR) {
@@ -381,11 +399,49 @@ static void webusbSofDrain(void)
 		webusbSendBondExport();
 	}
 }
+// ---- live wedge reporter (0xA9) --------------------------------------------------------------------------
+// THE one channel that survives a loop() wedge on boards that wipe .noinit/GPREGRET across the watchdog reset
+// (Teyleten): tud_sof_cb runs on the usbd task, which is HIGHER priority than the loop() task, so the USB
+// stack keeps dispatching it even while loop() is wedged (spinning or blocked) -- as long as interrupts aren't
+// hard-masked (PRIMASK). When loop() has been stuck a while, emit a tiny frame carrying the stuck loop stage so
+// the panel can log WHERE it hung, live, during the ~8s before the watchdog fires. Zero traffic when healthy
+// (returns immediately unless stalled); rate-limited; drop-on-full so it never spins the usbd task.
+//   frame: [0xA9][3][stuck stage][stallMs lo][stallMs hi]
+extern "C" void tud_sof_cb(uint32_t frame_count)
+{
+	(void)frame_count;
+	uint32_t stall = faultDiagStallMs();
+	if (stall <
+	    1000) // loop() healthy -> nothing to report (the common case: near-zero overhead)
+		return;
+	static uint32_t lastMs = 0;
+	uint32_t now = millis();
+	if ((uint32_t)(now - lastMs) < 200) // ~5 Hz cap
+		return;
+	lastMs = now;
+	if (!usb_web.connected())
+		return;
+	uint8_t f[2 + 3];
+	f[0] = 0xA9;
+	f[1] = 3;
+	f[2] = faultDiagCurStage();
+	uint16_t s = (stall > 0xFFFFu) ? 0xFFFFu : (uint16_t)stall;
+	f[3] = (uint8_t)s;
+	f[4] = (uint8_t)(s >> 8);
+	if (tud_vendor_write_available() >= sizeof f) {
+		usb_web.write(f, sizeof f);
+		usb_web.flush();
+	}
+}
+
 // Register the SOF drain. Call once from setup() (harmless even if WebUSB isn't enumerated -- webusbSendBlob
 // no-ops while disconnected, and g_blobRequest is only ever set by panel traffic, which needs a connection).
 void webusbInit(void)
 {
 	usbTxRegisterDrain(webusbSofDrain);
+	// Enable SOF-callback dispatch so tud_sof_cb() above fires -- our only live view into a loop() wedge on
+	// boards whose bootloader wipes retained RAM. Cheap: the callback no-ops unless loop() is already stalled.
+	tud_sof_cb_enable(true);
 }
 #if OPK_LOG
 // Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames. Frame formats:
@@ -425,6 +481,8 @@ static void webusbDrainCapture()
 {
 	if (!usb_web.connected())
 		return;
+	TxBoostScope
+		boost; // capture streams at flood rate through the same dcd DMA window
 	uint32_t ms = 0;
 	uint8_t slot = 0, rid = 0, nb = 0, b[16];
 	uint16_t budget = 128;

+ 116 - 6
docs/index.html

@@ -204,6 +204,16 @@
       <pre id="hangOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin:0;max-height:260px;overflow:auto;white-space:pre"></pre>
     </div>
 
+    <div class="card wide">
+      <h2>Loop-state trail</h2>
+      <p class="note" style="margin-top:0">Every change of the <em>Loop</em> pill, timestamped — stall episodes, live wedge reports, heartbeat loss (hard wedge, USB silent), recoveries, disconnects and the reset cause on reconnect — so an unattended hang is recorded even if you weren't watching. Saved in this browser (localStorage): it <strong>survives page refreshes</strong> and device reboots, and clears only with the button.</p>
+      <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
+        <span class="v" id="trailSummary" style="flex:1">no events yet</span>
+        <button id="trailClear">Clear</button>
+      </div>
+      <pre id="trailOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin:0;max-height:260px;overflow:auto;white-space:pre-wrap">—</pre>
+    </div>
+
     <div class="card wide">
       <h2>Flight recorder</h2>
       <p class="note" style="margin-top:0">The trail of events the firmware recorded in the seconds before its most recent <b>watchdog hang</b> — the ring survives the reset in <code>.noinit</code> RAM (like the hang PC), so it's the post-mortem of what wedged the board. Loads automatically after a hang reconnects; click to reload. Same data the console <code>FR</code> command prints. <span id="flightWedge" style="font-family:ui-monospace,Menlo,monospace"></span></p>
@@ -268,6 +278,28 @@ setInterval(updateStabUI, 500);
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
+// ---- Loop-state trail (persistent) ----
+// Every transition of the Loop pill lands here with a wall-clock timestamp, persisted in localStorage --
+// unlike the pill (live-only) and the in-page logs (a refresh wipes them), this survives page refreshes and
+// device reboots, so a hang that happened while nobody was watching is still on record. Newest first, capped;
+// cleared only by the Clear button.
+const TRAIL_KEY="opk_loop_trail", TRAIL_MAX=400;
+let loopTrail=[]; try{ loopTrail=JSON.parse(localStorage.getItem(TRAIL_KEY))||[]; }catch(e){ loopTrail=[]; }
+function trailAdd(m){
+  loopTrail.unshift({t:Date.now(), m});
+  if(loopTrail.length>TRAIL_MAX) loopTrail.length=TRAIL_MAX;
+  try{ localStorage.setItem(TRAIL_KEY, JSON.stringify(loopTrail)); }catch(e){}
+  renderTrail();
+}
+function renderTrail(){
+  const out=$("#trailOut"), sum=$("#trailSummary"); if(!out) return;
+  sum.textContent = loopTrail.length ? (loopTrail.length+" event(s) — newest first") : "no events yet";
+  out.textContent = loopTrail.length
+    ? loopTrail.map(e=>new Date(e.t).toLocaleString()+"  "+e.m).join("\n") : "—";
+}
+$("#trailClear").onclick=()=>{ loopTrail=[]; try{ localStorage.removeItem(TRAIL_KEY); }catch(e){} renderTrail(); log("loop-state trail cleared"); };
+renderTrail();
+
 // Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
 // only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
 // (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics, 7 LED brightness). Blob v10 reads them back at p[73 + et*8 + ...].
@@ -454,9 +486,20 @@ function onGone(){
   // (kept in-page so it survives the reset + reconnect, shown next to Last reset).
   let up=null;
   if(stabArmed && stabStart){ up=(Date.now()-stabStart)/1000; stabLastRun={secs:up}; log("⏱ stability: stayed up "+fmtDur(up)+", then reset"); stabStart=0; }
+  // if the PREVIOUS reset was never classified (no full blob landed between two resets), flush it as
+  // "unclassified" instead of silently overwriting it -- every reset must leave a row.
+  if(pendingHang){
+    hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
+      reason:"unclassified", stage:"", pc:"", lr:"", usbd:null });
+    renderHangLog();
+    trailAdd("note: the previous reset was never classified — no full status blob arrived before this disconnect");
+  }
   // queue a hang-log entry; the reason/PC/stack are filled from the first blob after reconnect
   pendingHang = { t: Date.now(), uptimeSecs: up };
+  trailAdd("device disconnected"+(up!=null?(" after "+fmtDur(up)+" up"):"")+" — reset or replug; reason logged on reconnect");
   window._flightAuto=false; // re-arm the one-shot flight-trail auto-load for the next reconnect
+  window._wedgeEp=false; window._wedgePeak=0; // re-arm the live wedge logger for the next wedge episode
+  window._lastBlobTs=0; window._hbLostEp=false; // re-arm the heartbeat watchdog (don't fire on a stale pre-reset timestamp)
   polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
   $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
@@ -482,15 +525,26 @@ async function readFrame(marker, minLen){
     const r = await dev.transferIn(epIn, 192);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
+    // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
+    // the ONLY signal that survives a loop wedge on boards that wipe retained RAM across the reset. Scan for it
+    // in every read so it surfaces regardless of which frame we were after. Payload: [stage][stallMs u16].
+    for(let w=0; w+4<d.length; w++){
+      if(d[w]===0xA9 && d[w+1]===3){ onWedge(d[w+2], d[w+3]|(d[w+4]<<8)); break; }
+    }
     let i=0; while(i<d.length && d[i]!==marker) i++;
     if(i+2>d.length) return null;
-    const len=d[i+1]; const p=d.slice(i+2, i+2+len);
+    const len=d[i+1];
+    // truncated read (transfer ended mid-frame): applying a half blob silently skips every late field --
+    // reset cause, pendingHang classification, stack stats -- so drop the whole frame and retry next poll.
+    if(i+2+len>d.length) return null;
+    const p=d.slice(i+2, i+2+len);
     return p.length>=(minLen||0) ? p : null;
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 async function readBlob(){ return readFrame(0xA5, 12); }
 function applyBlob(p){
   lastP=p; // remember for Export (carries every config field the panel shows)
+  window._lastBlobTs=Date.now(); // feeds the host-side heartbeat watchdog (hard-wedge detection)
   const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
   const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
@@ -603,6 +657,7 @@ function applyBlob(p){
     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>';
+      if(!window._stallEpisode) trailAdd("STALLED @ "+name+" ("+stallMs+"ms, soft wedge — usbd still alive)");
       // 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){
@@ -610,6 +665,7 @@ function applyBlob(p){
         log("⚠ LOOP STALL @ "+name+" ("+stallMs+"ms) — watchdog reset imminent");
       }
     } else {
+      if(window._stallEpisode) trailAdd("running again — stall recovered without a reset (peaked "+window._stallPeak+"ms)");
       window._stallEpisode=false; window._stallPeak=0;
       $("#stLoopState").innerHTML='<span class="pill up">running</span>';
     }
@@ -655,8 +711,22 @@ function applyBlob(p){
       hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
         reason:name, stage:(hangStage!==0xFF)?(STAGE_NAMES[hangStage]||("stage"+hangStage)):"",
         pc:hpc?hx(hpc):"", lr:hlr?hx(hlr):"", usbd:usbdW2 });
+      trailAdd("reconnected — last reset: "+name
+        +(hangStage!==0xFF?(" @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage))):"")
+        +(hpc?(" PC="+hx(hpc)):"")
+        +(pendingHang.uptimeSecs!=null?("  (up "+fmtDur(pendingHang.uptimeSecs)+" before it)"):""));
+      try{ localStorage.setItem("opk_trail_rrfp", code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16)); }catch(e){}
       pendingHang=null; renderHangLog();
     }
+    // Page wasn't open when it reset (refresh / closed browser): a fault-class boot still lands in the trail,
+    // once per distinct reset -- fingerprinted + persisted so every later page load doesn't re-log the same one.
+    else if (RR_FAULT.has(code)){
+      const fp=code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16);
+      if(localStorage.getItem("opk_trail_rrfp")!==fp){
+        try{ localStorage.setItem("opk_trail_rrfp", fp); }catch(e){}
+        trailAdd("connected — last boot was a "+label+(hpc?(" PC="+hx(hpc)):"")+" (page wasn't open to catch it live)");
+      }
+    }
     // Auto-load the flight-recorder trail once per connection when the last boot was a real crash (watchdog/
     // lockup/hardfault) -- the trail is the post-mortem of what wedged the board. Deferred to the poll loop so
     // it doesn't nest transferIn inside this refresh.
@@ -705,8 +775,11 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 let inflight=false;
 async function refresh(){
   if(!dev||inflight||capturing||backupBusy||flightBusy) return; inflight=true;
-  await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
-  inflight=false;
+  // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
+  // that would silently kill all future polls and read as a fake "heartbeat lost".
+  try{ await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
+  catch(e){ log("refresh err: "+e.message); }
+  finally{ inflight=false; }
 }
 
 // ---- Backup / clone ----
@@ -854,6 +927,42 @@ async function drainTick(){
   draining=false;
   if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
 }
+// ---- Live wedge reporter (0xA9) ----
+// The firmware's SOF callback streams this while loop() is stuck. On boards that wipe retained RAM across the
+// watchdog reset (so the flight recorder / hang stage come back blank), this is the only way to see WHERE it
+// hung -- captured live, over the panel, in the ~8s before the watchdog fires. Log once per wedge episode.
+const WEDGE_STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
+function onWedge(stage, stallMs){
+  const name=WEDGE_STAGE[stage]||("stage "+stage);
+  if(!window._wedgeEp) trailAdd("WEDGED @ "+name+" ("+stallMs+"ms — live 0xA9 report; watchdog reset imminent)");
+  if(!window._wedgeEp || stallMs > (window._wedgePeak||0)+500){
+    window._wedgeEp=true; window._wedgePeak=stallMs;
+    log("🛑 LOOP WEDGED @ "+name+" (stuck "+stallMs+"ms) — this is where it hangs; watchdog reset imminent");
+    const el=$("#flightWedge"); if(el) el.innerHTML=' &nbsp;<span class="pill dn">last wedge: '+name+' ('+stallMs+'ms)</span>';
+  }
+}
+// ---- Host-side heartbeat watchdog (hard-wedge detection) ----
+// The STALLED pill and the 0xA9 reporter both rely on the puck still SENDING while loop() is wedged -- which
+// only holds for soft wedges where the usbd task + SOF IRQ stay alive. The LOCKUP/hardfault-class hangs (#72)
+// kill the whole MCU: USB goes silent, transferIn just pends, and the panel would freeze forever showing the
+// last state it rendered ("running"). Only the host can see that class of hang, so detect it here: connected
+// but no status blob for >2.5s (poll cadence is 600ms) => flip the pill and log the episode. The capture /
+// backup / flight paths pause the blob poll legitimately, so they're excluded.
+setInterval(()=>{
+  if(!dev || !window._lastBlobTs || capturing || backupBusy || flightBusy) return;
+  const age=Date.now()-window._lastBlobTs;
+  if(age>2500){
+    const secs=Math.round(age/1000);
+    $("#stLoopState").innerHTML='<span class="pill dn">NO HEARTBEAT '+secs+'s — hard wedge</span>';
+    if(!window._hbLostEp){ window._hbLostEp=true;
+      log("🛑 heartbeat lost — no status blob for "+secs+"s: hard wedge (USB stack dead too, so the live wedge reporter can't run) — check the flight trail after the reset");
+      trailAdd("HEARTBEAT LOST — no status for "+secs+"s (hard wedge: USB silent, whole MCU likely stopped)");
+      const el=$("#flightWedge"); if(el) el.innerHTML=' &nbsp;<span class="pill dn">last wedge: hard (no heartbeat)</span>';
+    }
+  } else if(window._hbLostEp){ window._hbLostEp=false;
+    trailAdd("heartbeat back — status blobs resumed without a reset");
+  }
+}, 1000);
 // ---- Flight recorder (0xA8 stream) ----
 // The firmware keeps a .noinit ring of the last high-level events (usbd SET/GET, relays, RF up/down, radio
 // self-heal, USB mount/suspend, bond flash writes, per-250ms heartbeats) that survives a watchdog reset. On
@@ -956,8 +1065,8 @@ $("#capStop").onclick=stopCapture;
 $("#stabBtn").onclick=async()=>{
   if(!dev){ log("connect first"); return; }
   stabArmed=!stabArmed;
-  if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); stabLastRun=null; log("stability test STARTED — buzzing every 10s, timing uptime until reset"); }
-  else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); }
+  if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); stabLastRun=null; log("stability test STARTED — buzzing every 10s, timing uptime until reset"); trailAdd("stability test started (buzz every 10s)"); }
+  else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); trailAdd("stability test stopped"); }
   updateStabUI();
 };
 $("#hangClear").onclick=()=>{ hangLog=[]; renderHangLog(); };
@@ -970,7 +1079,8 @@ $("#hangCsv").onclick=()=>{
   a.download="openpuck-hanglog.csv"; a.click();
 };
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
-$("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
+$("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status");
+  trailAdd("power-off sent to controller (panel button) — stress action, correlate with what follows"); } };
 $("#debugCdc").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
   if(!confirm("Reboot with the CDC serial console enabled?\n\nThe puck reboots and comes back with a serial port (115200 baud) instead of WebUSB — this panel will disconnect and NOT reconnect until the next reboot.\n\nConnect a serial monitor to capture logs, then replug (or reboot) to return to normal WebUSB mode. The debug console auto-reverts after one boot.")) return;