Jari 4 달 전
부모
커밋
bd472e2ea1

+ 4 - 0
OpenPuck/OpenPuck.ino

@@ -37,6 +37,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "usb_mount.h"
 #include "identity.h"
 #include "fault_diag.h"
+#include "usb_tx.h"
 #include <stdio.h>
 
 #if CFG_TUD_HID < 4
@@ -213,6 +214,9 @@ void setup()
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
+	// Route all device->host HID sends through the usbd task (SOF drain) so loop() never calls tud_* directly
+	// -> the cross-task blocking-defer that deadlocked the loop under comms load can no longer happen.
+	usbTxBegin();
 	hapticInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",

+ 3 - 3
OpenPuck/config.cpp

@@ -158,10 +158,10 @@ void loadCfg()
 			}
 			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 :
+			g_hapticBlockMs = (uint16_t)((c.hapticBlockS > 60 ?
+							      60 :
 							      c.hapticBlockS) *
-					   1000u);
+						     1000u);
 		}
 		f.close();
 	}

+ 5 - 5
OpenPuck/fault_diag.cpp

@@ -11,9 +11,8 @@ static uint8_t g_reason = RR_UNKNOWN;
 static uint32_t g_resetReas = 0;
 
 static const char *const REASON_STR[RR_COUNT] = {
-	"unknown",	   "power-on",	   "pin/replug",
-	"WATCHDOG (hang)", "CPU lockup",   "HARDFAULT",
-	"reboot",	   "soft reset",   "wake-from-off",
+	"unknown",   "power-on", "pin/replug", "WATCHDOG (hang)", "CPU lockup",
+	"HARDFAULT", "reboot",	 "soft reset", "wake-from-off",
 };
 
 // HardFault override. The Adafruit core's default handler (cores/.../debug.cpp) already does NVIC_SystemReset();
@@ -36,7 +35,8 @@ void faultDiagArmIntentionalReset()
 
 void faultDiagBoot()
 {
-	uint32_t rr = readResetReason(); // latched + cleared by the core's init()
+	uint32_t rr =
+		readResetReason(); // latched + cleared by the core's init()
 	uint8_t g2 = (uint8_t)NRF_POWER->GPREGRET2;
 	NRF_POWER->GPREGRET2 = 0; // consume the marker for this boot cycle
 	g_resetReas = rr;
@@ -51,7 +51,7 @@ void faultDiagBoot()
 	else if (rr & POWER_RESETREAS_LOCKUP_Msk)
 		reason = RR_LOCKUP;
 	else if (rr & POWER_RESETREAS_SREQ_Msk)
-		reason = (g2 == G2_FAULT) ? RR_HARDFAULT :
+		reason = (g2 == G2_FAULT)  ? RR_HARDFAULT :
 			 (g2 == G2_INTENT) ? RR_REBOOT :
 					     RR_SOFT;
 	else if (rr & POWER_RESETREAS_OFF_Msk)

+ 2 - 1
OpenPuck/haptics.cpp

@@ -398,7 +398,8 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			// 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
 			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
-			rfConnTx(ch, s1, p, plen, 80); // one relay per poll cycle
+			rfConnTx(ch, s1, p, plen,
+				 80); // one relay per poll cycle
 		}
 	}
 }

+ 4 - 2
OpenPuck/haptics.h

@@ -49,8 +49,10 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 
 // Post-connect haptic block (persisted, panel-controlled): when g_hapticBlockOn, Steam haptics are dropped for
 // g_hapticBlockMs after a (re)connect so the controller's haptic engine settles before the first real haptic.
-extern uint8_t g_hapticBlockOn; // 1 = block enabled, 0 = relay haptics immediately on connect (default)
-extern uint16_t g_hapticBlockMs; // block duration in ms (default HAPTIC_BLOCK_MS_DEFAULT)
+extern uint8_t
+	g_hapticBlockOn; // 1 = block enabled, 0 = relay haptics immediately on connect (default)
+extern uint16_t
+	g_hapticBlockMs; // block duration in ms (default HAPTIC_BLOCK_MS_DEFAULT)
 
 // anything still queued (xinput uses it to pace rumble re-queues)
 bool relayPending();

+ 2 - 1
OpenPuck/mode_hidgyro.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -236,6 +237,6 @@ void HidGyroController::task()
 		g_gyroLastMs[u] = millis();
 		uint8_t p[63];
 		hidGyroBuild(u, (uint8_t)bond, p);
-		g_hidGyro[u].sendReport(0x01, p, sizeof p);
+		usbTxHid(&g_hidGyro[u], 0x01, p, sizeof p);
 	}
 }

+ 6 - 5
OpenPuck/mode_lizard.cpp

@@ -1,6 +1,7 @@
 #include "mode_lizard.h"
 #include "triton.h"
 #include "config.h"
+#include "usb_tx.h"
 
 void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid)
@@ -84,7 +85,7 @@ void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 		m.wheel = (int8_t)dw;
 		m.pan = 0;
 		if (mdev->ready())
-			mdev->sendReport(mrid, &m, sizeof m);
+			usbTxHid(mdev, mrid, &m, sizeof m);
 	}
 	// --- keyboard: modifiers + up to 6 keycodes ---
 	uint8_t mod = 0, kc[6] = { 0, 0, 0, 0, 0, 0 }, nk = 0;
@@ -120,17 +121,17 @@ void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 		if (nL5 && !prevL5) {
 			uint8_t cc = 0x02;
 			if (mdev->ready())
-				mdev->sendReport(0x03, &cc, 1);
+				usbTxHid(mdev, 0x03, &cc, 1);
 		}
 		if (nR5 && !prevR5) {
 			uint8_t cc = 0x01;
 			if (mdev->ready())
-				mdev->sendReport(0x03, &cc, 1);
+				usbTxHid(mdev, 0x03, &cc, 1);
 		}
 		if ((!nL5 && prevL5) || (!nR5 && prevR5)) {
 			uint8_t cc = 0x00;
 			if (mdev->ready())
-				mdev->sendReport(0x03, &cc, 1);
+				usbTxHid(mdev, 0x03, &cc, 1);
 		}
 		prevL5 = nL5;
 		prevR5 = nR5;
@@ -169,6 +170,6 @@ void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 		uint8_t krep[8] = { mod,   0,	  kc[0], kc[1],
 				    kc[2], kc[3], kc[4], kc[5] };
 		if (kdev->ready())
-			kdev->sendReport(krid, krep, 8);
+			usbTxHid(kdev, krid, krep, 8);
 	}
 }

+ 2 - 1
OpenPuck/mode_ps5.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -247,6 +248,6 @@ void Ps5Controller::task()
 		g_ps5LastMs[u] = millis();
 		uint8_t p[63];
 		ps5Build(u, (uint8_t)bond, p);
-		g_ps5[u].sendReport(0x01, p, sizeof p);
+		usbTxHid(&g_ps5[u], 0x01, p, sizeof p);
 	}
 }

+ 3 - 2
OpenPuck/mode_switch_hori.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -223,7 +224,7 @@ void SwitchHoriController::task()
 		g_swLastMs[u] = millis();
 		uint8_t p[8];
 		switchBuildHoripad((uint8_t)bond, p);
-		g_switch[u].sendReport(0, p,
-				       sizeof p); // report-id-less descriptor
+		usbTxHid(&g_switch[u], 0, p,
+			 sizeof p); // report-id-less descriptor
 	}
 }

+ 4 - 3
OpenPuck/mode_switch_pro.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -760,8 +761,8 @@ void SwitchProController::task()
 		// bursty host-init doesn't starve the streamed 0x30
 		if (g_jcQh[s] != g_jcQt[s]) {
 			JcRep *r = &g_jcQ[s][g_jcQh[s]];
-			if (g_swPro[s].sendReport(r->rid, r->data, JC_REPLEN))
-				g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
+			usbTxHid(&g_swPro[s], r->rid, r->data, JC_REPLEN);
+			g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
 			// one report per slot per call; the rest next loop
 			continue;
 		}
@@ -781,6 +782,6 @@ void SwitchProController::task()
 		g_swProLastMs[s] = millis();
 		uint8_t p[63];
 		switchProBuild((uint8_t)s, p);
-		g_swPro[s].sendReport(0x30, p, sizeof p);
+		usbTxHid(&g_swPro[s], 0x30, p, sizeof p);
 	}
 }

+ 30 - 4
OpenPuck/mode_xinput.cpp

@@ -16,6 +16,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -62,6 +63,9 @@ struct XiSlot {
 	volatile unsigned long
 		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
 	volatile bool inUse;
+	// inBuf holds a built-but-not-yet-sent 20B XInput report; the actual usbd_edpt_xfer is issued from the
+	// usbd task (usbTxDrainHook), never from loop(), so the cross-task blocking-defer can't stall the loop.
+	volatile bool txPending;
 };
 static XiSlot g_xiSlot[NSLOT];
 // Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
@@ -232,6 +236,8 @@ static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
 	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
 		return;
 	XiSlot &S = g_xiSlot[slot];
+	// Only refill inBuf when the IN endpoint is idle: while a transfer is in flight (busy) the DMA is still
+	// reading inBuf, so leave it untouched (this also coalesces -- newest state wins, like a real pad).
 	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
 		return;
 	uint8_t *r = S.inBuf;
@@ -250,9 +256,29 @@ static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
 	r[12] = ry & 0xFF;
 	r[13] = ry >> 8;
 	memset(r + 14, 0, 6);
-	if (usbd_edpt_claim(0, S.epIn)) {
-		if (!usbd_edpt_xfer(0, S.epIn, r, 20))
-			usbd_edpt_release(0, S.epIn);
+	// Hand off to the usbd task: the xfer is issued from usbTxDrainHook() (SOF), not here, so loop() never
+	// calls into the dcd transfer path (which can block on the device event queue under load).
+	S.txPending = true;
+}
+
+// usbd-task drain (called from tud_sof_cb via usbTxDrainHook). Issues the deferred XInput IN transfers. Runs at
+// higher priority than loop(), so the claim->xfer is atomic w.r.t. xinputSend() refilling inBuf -- no torn DMA.
+extern "C" void usbTxDrainHook(void)
+{
+	if (!tud_mounted())
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		XiSlot &S = g_xiSlot[s];
+		if (!S.inUse || !S.txPending || S.epIn == 0)
+			continue;
+		if (usbd_edpt_busy(0, S.epIn))
+			continue;
+		if (usbd_edpt_claim(0, S.epIn)) {
+			if (usbd_edpt_xfer(0, S.epIn, S.inBuf, 20))
+				S.txPending = false;
+			else
+				usbd_edpt_release(0, S.epIn);
+		}
 	}
 }
 
@@ -430,7 +456,7 @@ static void rfXboxMouse(const uint8_t *r)
 		m.wheel = 0;
 		m.pan = 0;
 		if (g_mouse.ready())
-			g_mouse.sendReport(0, &m, sizeof m);
+			usbTxHid(&g_mouse, 0, &m, sizeof m);
 	}
 }
 

+ 9 - 9
OpenPuck/puck_hid.cpp

@@ -7,6 +7,7 @@
 #include "triton.h"
 #include "mode_lizard.h"
 #include "wake_hid.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -461,9 +462,8 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
 		// stair-step. g_fwdNewOnly toggles for A/B.
 		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
-			hid[slot].sendReport(
-				0x45, rep + 1,
-				blen); // Steam/SDL Triton: input report 0x45
+			usbTxHid(&hid[slot], 0x45, rep + 1,
+				 blen); // Steam/SDL Triton: input report 0x45
 	}
 }
 
@@ -487,7 +487,7 @@ void SteamPuckController::onAuxReport(int slot, uint8_t rid,
 		// capture the pushed status report (0x43 battery / 0x44) device->host for the WebUSB panel: this is
 		// the channel Steam actually reads battery from; marker 0xFB = "->host push".
 		hapLogAdd(0xFB, rid, data, n);
-		hid[slot].sendReport(rid, data, n);
+		usbTxHid(&hid[slot], rid, data, n);
 	}
 }
 
@@ -565,8 +565,8 @@ static void wakeNudgeTask()
 		m.y = 0;
 		m.wheel = 0;
 		m.pan = 0;
-		hid[s].sendReport(0x40, &m,
-				  sizeof m); // jiggle right, then back
+		usbTxHid(&hid[s], 0x40, &m,
+			 sizeof m); // jiggle right, then back
 		g_nudgeStep[s] = (g_nudgeStep[s] >= 2) ?
 					 0 :
 					 (uint8_t)(g_nudgeStep[s] + 1);
@@ -616,7 +616,7 @@ void SteamPuckController::task()
 				connEdgeMs[s] = millis();
 			uint8_t st = conn ? 0x02 : 0x01;
 			hapLogAdd(0xFB, 0x79, &st, 1); // ->host push (capture)
-			hid[s].sendReport(0x79, &st, 1);
+			usbTxHid(&hid[s], 0x79, &st, 1);
 			usbConn[s] = conn;
 			last79[s] = millis();
 		} else if (conn && millis() - last7B[s] >= 2000) {
@@ -640,7 +640,7 @@ void SteamPuckController::task()
 				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
 			}
 			hapLogAdd(0xFB, 0x7B, s7b, 12); // ->host push (capture)
-			hid[s].sendReport(0x7B, s7b, 12);
+			usbTxHid(&hid[s], 0x7B, s7b, 12);
 			last7B[s] = millis();
 		}
 		// Synthesized 0x43 = ID_TRITON_BATTERY_STATUS for SDL/Steam's gamepad driver. The verbatim forward of
@@ -658,7 +658,7 @@ void SteamPuckController::task()
 			b43[0] = st; // ucChargeState
 			b43[1] = g_battery[s]; // ucBatteryLevel (percent)
 			hapLogAdd(0xFB, 0x43, b43, 14); // ->host push (capture)
-			hid[s].sendReport(0x43, b43, sizeof b43);
+			usbTxHid(&hid[s], 0x43, b43, sizeof b43);
 			last43[s] = millis();
 		}
 	}

+ 116 - 0
OpenPuck/usb_tx.cpp

@@ -0,0 +1,116 @@
+#include "usb_tx.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+// One outbound ring per active HID destination. Only one USB mode is live at a time, and a mode presents at
+// most CFG_TUD_HID HID interfaces plus the wake mouse, so a handful of channels covers every case. Channels are
+// allocated lazily on first send to a given Adafruit_USBD_HID* and never freed (the object set is fixed for the
+// session). The producer side runs under PRIMASK (loop AND the RF-decode path enqueue); the consumer is the
+// single usbd task (tud_sof_cb), so head/tail need no further locking beyond the enqueue critical section.
+#define TX_CHAN_MAX 8
+#define TX_RING_N \
+	4 // depth per destination; 250 Hz produced, ~1 kHz SOF drain -> stays shallow
+#define TX_DATA_MAX \
+	64 // CFG_TUD_HID_EP_BUFSIZE -- the largest report any mode sends (63 B + id)
+
+struct TxItem {
+	uint8_t rid;
+	uint8_t len;
+	uint8_t data[TX_DATA_MAX];
+};
+struct TxChan {
+	Adafruit_USBD_HID *hid; // nullptr = free slot
+	TxItem ring[TX_RING_N];
+	volatile uint8_t head,
+		tail; // head=next write, tail=next read; empty when equal
+};
+static TxChan g_chan[TX_CHAN_MAX];
+
+static inline uint8_t txNext(uint8_t i)
+{
+	return (uint8_t)((i + 1) % TX_RING_N);
+}
+
+void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
+	      uint16_t len)
+{
+	if (!hid)
+		return;
+	if (len > TX_DATA_MAX)
+		len = TX_DATA_MAX;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	// find this destination's channel, or claim a free one
+	TxChan *c = nullptr;
+	for (int i = 0; i < TX_CHAN_MAX; i++) {
+		if (g_chan[i].hid == hid) {
+			c = &g_chan[i];
+			break;
+		}
+	}
+	if (!c) {
+		for (int i = 0; i < TX_CHAN_MAX; i++) {
+			if (!g_chan[i].hid) {
+				c = &g_chan[i];
+				c->hid = hid;
+				c->head = c->tail = 0;
+				break;
+			}
+		}
+	}
+	if (c) {
+		uint8_t h = c->head, nx = txNext(h);
+		// full -> drop the oldest (advance tail), never the newest
+		if (nx == c->tail)
+			c->tail = txNext(c->tail);
+		c->ring[h].rid = rid;
+		c->ring[h].len = (uint8_t)len;
+		if (len)
+			memcpy(c->ring[h].data, data, len);
+		c->head = nx;
+	}
+	__set_PRIMASK(pm);
+}
+
+// usbd task only (tud_sof_cb). Send at most one ready report per destination per SOF: after a send the
+// endpoint stays busy until the host polls it (~1 ms), and SOF fires every ~1 ms, so this naturally paces each
+// stream to the host's poll rate without ever blocking. A destination whose endpoint is busy is simply left
+// for the next frame; it does not hold up the others.
+void usbTxDrain(void)
+{
+	for (int i = 0; i < TX_CHAN_MAX; i++) {
+		TxChan *c = &g_chan[i];
+		if (!c->hid)
+			continue;
+		uint8_t t = c->tail;
+		if (t == c->head)
+			continue; // empty
+		if (!c->hid->ready())
+			continue; // endpoint busy / not mounted -> retry next SOF
+		TxItem &it = c->ring[t];
+		// sendReport copies into the endpoint buffer, so releasing the slot right after is safe.
+		if (c->hid->sendReport(it.rid, it.data, it.len))
+			c->tail = txNext(t);
+	}
+}
+
+// Weak no-op default; mode_xinput overrides it to flush its raw custom-class IN endpoints from the usbd task.
+extern "C" __attribute__((weak)) void usbTxDrainHook(void)
+{
+}
+
+// TinyUSB dispatches tud_sof_cb from tud_task() on the usbd task once per USB (micro)frame. Overriding the weak
+// default routes that into our drain. Keep it minimal -- it runs every ~1 ms.
+extern "C" void tud_sof_cb(uint32_t frame_count)
+{
+	(void)frame_count;
+	usbTxDrain();
+	usbTxDrainHook();
+}
+
+void usbTxBegin(void)
+{
+	// SOF callbacks are off by default (they are high frequency); turn them on so usbTxDrain() runs.
+	tud_sof_cb_enable(true);
+}

+ 38 - 0
OpenPuck/usb_tx.h

@@ -0,0 +1,38 @@
+// usb_tx.h -- marshal device->host HID reports onto the TinyUSB "usbd" task.
+//
+// THE PROBLEM this fixes (see ARCHITECTURE / the usbd-queue-deadlock notes): TinyUSB's tud_task() runs in a
+// dedicated high-priority "usbd" FreeRTOS task, but this firmware historically called hid.sendReport() (i.e.
+// tud_hid_*_report) straight from the LOW-priority loop() task. When the nRF52 USBD's single shared EasyDMA is
+// momentarily busy, that cross-task send takes TinyUSB's usbd_defer_func() path, which does a BLOCKING
+// osal_queue_send on the device event queue. Under a comms burst the queue saturates, the loop task blocks
+// there, stops feeding the ~8 s watchdog, and the MCU resets (the controller "disconnects").
+//
+// THE FIX: loop-context code never calls tud_* directly. It enqueues the report here (non-blocking, ISR/PRIMASK
+// safe), and the actual sendReport() happens from tud_sof_cb() -- which TinyUSB dispatches ON THE usbd TASK
+// once per USB frame (~1 ms). Every tud_* call is therefore back in the single context TinyUSB expects, so the
+// blocking-defer path can never stall the loop. Reports are keyed by their Adafruit_USBD_HID* (NOT the HID
+// instance index, which is mode-dependent and fragile -- see the "HID instance order vs dynamic mount" note).
+#pragma once
+#include <stdint.h>
+
+class Adafruit_USBD_HID;
+
+// Queue one HID report for `hid` to be sent from the usbd task. Safe from any context (loop or usbd). The
+// payload is copied, so the caller's buffer can be reused immediately (tud_hid_*_report copies into the
+// endpoint buffer too, so this preserves the original fire-and-forget semantics). Never blocks: if that
+// destination's ring is full the oldest queued report is dropped (same policy as the haptic relay ring) --
+// for a 250 Hz gamepad stream the newest state is what matters. `len` is clamped to the 64 B report ceiling.
+void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
+	      uint16_t len);
+
+// Arm the SOF-driven drain. Call once, after USBDevice.attach(), from setup(). Idempotent.
+void usbTxBegin(void);
+
+// Drain pending reports (one ready report per destination per call). usbd-task only -- invoked by tud_sof_cb.
+void usbTxDrain(void);
+
+// usbd-task drain hook for senders that don't go through Adafruit_USBD_HID (the XInput custom-class endpoint).
+// tud_sof_cb calls this right after usbTxDrain(), on the usbd task. A weak no-op default lives in usb_tx.cpp;
+// mode_xinput overrides it to flush its raw IN endpoints, so its gamepad report is also issued off the loop
+// task. extern "C" so the override links regardless of translation unit.
+extern "C" void usbTxDrainHook(void);

+ 11 - 2
OpenPuck/wake_hid.cpp

@@ -1,4 +1,5 @@
 #include "wake_hid.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 
 // Boot MOUSE descriptor -- proven to enumerate and wake Windows. The host enumerates a "HID-compliant mouse"
@@ -34,8 +35,16 @@ bool wakeHidMove(int8_t dx, int8_t dy)
 {
 	if (!wakeHidReady())
 		return false;
-	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click
-	return g_wakeHid.mouseReport(0, 0, dx, dy, 0, 0);
+	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click. Queued for
+	// the usbd task (usbTxHid) rather than sent inline, like every other report -- loop() issues no tud_* call.
+	hid_mouse_report_t m;
+	m.buttons = 0;
+	m.x = dx;
+	m.y = dy;
+	m.wheel = 0;
+	m.pan = 0;
+	usbTxHid(&g_wakeHid, 0, &m, sizeof m);
+	return true;
 }
 
 void wakeHidAddInterface()

+ 3 - 2
OpenPuck/webusb_config.cpp

@@ -410,7 +410,7 @@ void webusbPoll()
 				// lands late under haptic load; narrower allows a higher poll rate. Clamp 600..3000us.
 				case 25: {
 					uint32_t w = (uint32_t)v * 10;
-					g_rxWin = w < 600 ? 600 :
+					g_rxWin = w < 600  ? 600 :
 						  w > 3000 ? 3000 :
 							     w;
 					break;
@@ -424,7 +424,8 @@ void webusbPoll()
 				// post-connect haptic block duration, in seconds (clamp 0..60)
 				case 28:
 					g_hapticBlockMs =
-						(uint16_t)((v > 60 ? 60 : v) * 1000u);
+						(uint16_t)((v > 60 ? 60 : v) *
+							   1000u);
 					break;
 				}
 				if (persist)