Selaa lähdekoodia

Merge main + fix multi-controller relay, wakeup, and comment cleanup

Merge:
- Bring in main (DFU reboot ops 0x0B/0x0C from WebUSB panel)

Critical fix — relay queue:
- Convert from one shared SPSC ring to per-slot rings (g_rq[NSLOT][RELAY_QLEN])
  The old design advanced g_rqTail past entries for other slots, silently
  consuming them. Per-slot queues make each slot's flush independent.
- relayEnqueue with slot=0xFF broadcasts by writing to every slot's ring
- rfConnFlushRelay drains only the current slot's queue (no slot-skipping logic)
- hapticCancelPendingOn scans all slot queues
- RelayMsg drops the now-redundant slot field

Bug fixes:
- rfConnTx: steamWasDown/steamDownMs converted to [NSLOT] arrays; shared
  statics caused spurious remote-wakeup firing with multiple controllers
- rfConnStep: remove dead n-count scan (second loop already handles the
  zero-slots case when slot stays -1)
- haptic82HostReport: remove unused slot parameter (Steam 0x82 is a single
  host stream; slot tracking does not apply)
- serial_console: fix double millis() call in haptic-block display
- mode_switch_pro: remove dead BT_PAIR_1_BODY array; replace with literal 0x01
- triton.h / controllers.h: fix stale "g_in[0]" comments (stream modes now
  iterate g_in[s] for each active slot)
- OpenPuck.ino: remove ghost "loadBonds no-op" comment

Comment and formatting:
- Trim verbose multi-paragraph comment blocks throughout haptics.cpp/h
- Run clang-format across all touched files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Jariullah Safi 4 kuukautta sitten
vanhempi
commit
a8c88d2c75

+ 7 - 12
OpenPuck/OpenPuck.ino

@@ -47,9 +47,7 @@ void setup()
 	genSerial();
 	ledInit();
 
-	// per-device default RF session address (per-slot). The real generation is below, AFTER loadBonds, so
-	// the address can be derived from the bond UUID for each slot. The pre-load call here is just to
-	// populate the defaults so a slot with no bond doesn't accidentally share the discovery address.
+	// seed defaults so unbonded slots don't share the discovery address
 	for (int s = 0; s < NSLOT; s++)
 		rfGenSessionAddr(s);
 	InternalFS.begin();
@@ -60,9 +58,7 @@ void setup()
 #endif
 	loadCfg();
 	loadBonds();
-	// Now that bonds are loaded, regenerate per-slot session addresses from each bond's UUID. This is
-	// deterministic (a slot's address is stable across reboots) and avoids persisting the address in
-	// bonds.bin -- no schema bump needed.
+	// regenerate per-slot session addresses from each bond UUID (deterministic, stable across reboots)
 	for (int s = 0; s < NSLOT; s++)
 		if (g_slot[s].used)
 			rfGenSessionAddr(s);
@@ -143,8 +139,6 @@ void setup()
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
-	// loadBonds() was called earlier (after the bond-UUID-derived session addresses were generated);
-	// this is a no-op now but kept for forward-compat (a future migration to lazy-loading).
 	hapticInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
@@ -171,10 +165,11 @@ void setup()
 				"#   slot %d: %02X%02X%02X%02X/%02X (uuid %02X%02X%02X%02X %02X%02X%02X%02X)\n",
 				s, g_sessBase[s][0], g_sessBase[s][1],
 				g_sessBase[s][2], g_sessBase[s][3],
-				g_sessPrefix[s], g_slot[s].rec[0], g_slot[s].rec[1],
-				g_slot[s].rec[2], g_slot[s].rec[3],
-				g_slot[s].rec[4], g_slot[s].rec[5],
-				g_slot[s].rec[6], g_slot[s].rec[7]);
+				g_sessPrefix[s], g_slot[s].rec[0],
+				g_slot[s].rec[1], g_slot[s].rec[2],
+				g_slot[s].rec[3], g_slot[s].rec[4],
+				g_slot[s].rec[5], g_slot[s].rec[6],
+				g_slot[s].rec[7]);
 		}
 	}
 	// Hardware watchdog: if loop() ever stops feeding it (wedged radio busy-wait, HardFault spin, blocked CDC

+ 1 - 1
OpenPuck/controllers.h

@@ -9,7 +9,7 @@
 //                    argument is the bond slot the report arrived on (0..NSLOT-1, or -1 for the pre-bond
 //                    catch-all). PUSH-style modes (Xbox, puck/lizard) build + send their host report here;
 //                    the puck/lizard mode uses `slot` to fan out to hid[slot]. STREAM-style modes ignore
-//                    `slot` and emit at a fixed cadence from task() reading g_in[0].
+//                    `slot`; STREAM-style modes emit at a fixed cadence from task() reading g_in[s].
 //   task()        -- called every loop(): streaming emit, handshake/subcommand draining, mode-specific upkeep.
 //   isPuck()      -- true for Steam/Lizard (keep the boot CDC composite; different USB lifecycle in setup()).
 //

+ 91 - 144
OpenPuck/haptics.cpp

@@ -55,19 +55,18 @@ static uint8_t g_reinitLeft = 0;
 // latch that engaged during/after use, even seconds after connect)
 static bool g_hapClearArmed = false;
 
-// ---- relay ring: multi-producer (USB ISR + loop-context console/xinput), single consumer (poll flush) ----
-// Producers serialize through a brief PRIMASK critical section (copy is <=62 bytes); the consumer only ever
-// touches the tail entry, which no producer writes while the ring isn't full -- so a flush can't be torn.
+// ---- relay rings: one per bond slot. Multi-producer (USB ISR + loop-context console/xinput), one consumer
+// per slot (rfConnFlushRelay on that slot's poll turn). Producers serialize under PRIMASK.
 struct RelayMsg {
-	uint8_t rid, len, slot;
+	uint8_t rid, len;
 	uint8_t data[RELAY_MAXP];
 };
 // deep enough to hold a full Steam settings/LED transaction burst without loss
 #define RELAY_QLEN 32
-static RelayMsg g_rq[RELAY_QLEN];
+static RelayMsg g_rq[NSLOT][RELAY_QLEN];
+static volatile uint8_t g_rqHead[NSLOT];
 static volatile uint8_t
-	g_rqHead = 0,
-	g_rqTail = 0; // head=next write, tail=next read; empty when equal
+	g_rqTail[NSLOT]; // head=next write, tail=next read; empty when equal
 static inline uint8_t rqNext(uint8_t i)
 {
 	return (uint8_t)((i + 1) % RELAY_QLEN);
@@ -75,30 +74,35 @@ static inline uint8_t rqNext(uint8_t i)
 
 bool relayPending()
 {
-	return g_rqHead != g_rqTail;
+	// check the current slot's queue; called from rfConnQueueHapticRelay which runs with g_curSlot set
+	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	return g_rqHead[cur] != g_rqTail[cur];
 }
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot)
 {
-	// 60B is the RF frame ceiling; longer can't be relayed
 	if (plen > RELAY_MAXP)
 		plen = RELAY_MAXP;
+	if (slot != 0xFF && slot >= NSLOT)
+		return false;
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
-	uint8_t h = g_rqHead, nx = rqNext(h);
-	// Full -> evict the OLDEST entry, never the newest. Steam sends commands as bursts where the meaningful
-	// frame comes LAST (a settings transaction ends with its commit; a haptic stream ends with its stop), so
-	// dropping the oldest guarantees the most recent command (LED commit, haptic stop) always lands.
-	if (nx == g_rqTail)
-		g_rqTail = rqNext(g_rqTail);
-	g_rq[h].rid = rid;
-	g_rq[h].len = plen;
-	g_rq[h].slot = slot;
-	if (plen)
-		memcpy(g_rq[h].data, payload, plen);
-	g_rqHead = nx;
-	// Any haptic relay (Steam OR Xbox rumble OR test) arms the idle-clear and refreshes its timer -- so the
-	// during-use buzz gets cleared in EVERY USB mode. The re-init's own 0x81/0x87 deliberately don't match.
+	// slot=0xFF: broadcast -- enqueue into every slot's ring.
+	// Full queue: evict the oldest entry, never the newest. Steam bursts end with the commit/stop, so
+	// dropping the oldest keeps the most-recent (meaningful) frame.
+	uint8_t s0 = (slot == 0xFF) ? 0 : slot;
+	uint8_t s1 = (slot == 0xFF) ? NSLOT : slot + 1;
+	for (uint8_t s = s0; s < s1; s++) {
+		uint8_t h = g_rqHead[s], nx = rqNext(h);
+		if (nx == g_rqTail[s])
+			g_rqTail[s] = rqNext(g_rqTail[s]);
+		g_rq[s][h].rid = rid;
+		g_rq[s][h].len = plen;
+		if (plen)
+			memcpy(g_rq[s][h].data, payload, plen);
+		g_rqHead[s] = nx;
+	}
+	// Any haptic relay arms the idle-clear (so the during-use latch gets cleared in every mode).
 	if (rid == 0x82 || rid == 0x80) {
 		g_haptic82Ms = millis();
 		g_hapClearArmed = true;
@@ -223,38 +227,38 @@ static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
 }
 static void hapticCancelPendingOn()
 {
-	// void queued ON entries (stale Steam haptics / translated rumble across a reconnect)
+	// void queued ON entries across all slot queues (stale haptics / rumble across a reconnect)
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
-	for (uint8_t i = g_rqTail; i != g_rqHead; i = rqNext(i)) {
-		RelayMsg &m = g_rq[i];
-		if (m.rid == 0x82) {
-			bool on = false;
-			for (uint8_t j = 2; j < m.len; j++)
-				if (m.data[j]) {
-					on = true;
-					break;
-				}
-			if (on)
-				m.rid = 0;
-		}
-		if (m.rid == 0x80) {
-			bool on = false;
-			for (uint8_t j = 0; j < m.len; j++)
-				if (m.data[j]) {
-					on = true;
-					break;
-				}
-			if (on)
-				m.rid = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		for (uint8_t i = g_rqTail[s]; i != g_rqHead[s]; i = rqNext(i)) {
+			RelayMsg &m = g_rq[s][i];
+			if (m.rid == 0x82) {
+				bool on = false;
+				for (uint8_t j = 2; j < m.len; j++)
+					if (m.data[j]) {
+						on = true;
+						break;
+					}
+				if (on)
+					m.rid = 0;
+			}
+			if (m.rid == 0x80) {
+				bool on = false;
+				for (uint8_t j = 0; j < m.len; j++)
+					if (m.data[j]) {
+						on = true;
+						break;
+					}
+				if (on)
+					m.rid = 0;
+			}
 		}
 	}
 	__set_PRIMASK(pm);
 }
-void haptic82HostReport(const uint8_t *p, uint16_t n, int slot)
+void haptic82HostReport(const uint8_t *p, uint16_t n)
 {
-	// the on/off flag is global -- one relay stream keeps the watchdog armed across slots
-	(void)slot;
 	if (n < 3)
 		return;
 	g_haptic82Ms = millis();
@@ -325,39 +329,27 @@ void rfConnQueueHapticRelay()
 			g_hapticStop--;
 	}
 }
-// rfConnFlushRelay(ch, s1): scan the queue for the first entry that targets the CURRENT slot (g_curSlot),
-// or the broadcast sentinel 0xFF. Emit it on that slot's session address (so each controller only sees
-// its own relay). With 4 connected slots the per-slot relay rate is 1/4 of the per-cycle rate; sustained
-// buzz streams are still 1 packet/cycle/slot which the controller's haptic engine handles fine.
+// 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)
 {
 	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
-	while (g_rqTail != g_rqHead) {
-		RelayMsg &m = g_rq[g_rqTail];
+	while (g_rqTail[cur] != g_rqHead[cur]) {
+		RelayMsg &m = g_rq[cur][g_rqTail[cur]];
 
-		// rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
+		// rid 0 = entry voided by hapticCancelPendingOn -> skip
 		if (m.rid) {
-			// Only relay entries for the CURRENT slot (or broadcast 0xFF). If the entry is for
-			// another slot, leave it in the queue -- its slot's turn will pick it up.
-			if (m.slot != 0xFF && m.slot != cur) {
-				// advance past it without consuming; the per-slot flush is the consumer
-				g_rqTail = rqNext(g_rqTail);
-				continue;
-			}
 			uint8_t rl = m.len;
 			if (rl > RELAY_MAXP)
 				rl = RELAY_MAXP;
-			// On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs (puck_sniffer): a command LANDS on
-			// the controller only with the type-01 + inner-len form  E3 [2+rl][01][rid][innerlen][data]; with the legacy
-			// form  E3 [1+rl][05][rid][data]  (no inner-len) the controller DISCARDS any 0x87+ command (it reads data[0]
-			// as the length).
+			// On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs: a command LANDS on
+			// the controller only with the type-01 + inner-len form E3 [2+rl][01][rid][innerlen][data];
+			// the legacy form E3 [1+rl][05][rid][data] makes the controller DISCARD any 0x87+ command.
 			//
-			// WHITELIST the landing form to EXACTLY the two commands that need it; everything else keeps legacy form:
-			//   * LED brightness  -- report 0x87 whose first register byte is 0x2D
-			//   * controller power-off -- report 0x9F ("off!")
-			// Steam ALSO sends other 0x87 passthrough writes during play -- the haptic-config block (reg 0x30 =
-			// IMU/subsystem enable, 0x34/0x35 = haptic amplitude). Landing those regresses BOTH: a landed 0x30 FREEZES
-			// the IMU (gyro stops) and landed 0x34/0x35 = the connect buzz. So keep this list tight.
+			// Whitelist type-01 to only two commands: LED brightness (0x87 reg 0x2D) and power-off
+			// (0x9F). Other 0x87 writes (e.g. reg 0x30 IMU enable, 0x34/0x35 haptic amplitude) must
+			// stay on legacy form -- landing 0x30 freezes the gyro; landing 0x34/0x35 causes the buzz.
 			bool land01 =
 				(m.rid == 0x9F) ||
 				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
@@ -378,34 +370,22 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				memcpy(p + 4, m.data, rl);
 				plen = (uint8_t)(4 + rl);
 			}
-			// log what we actually TX to the controller (slot 0xFE) for the buzz hunt
 			hapLogAdd(0xFE, m.rid, m.data, rl);
-
-			// copied out -> release the slot before the TX
-			g_rqTail = rqNext(g_rqTail);
-			// s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
-			// dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, so
-			// don't burn the full ~1.2ms reply window (that halves the poll rate during haptics).
+			// release slot before TX
+			g_rqTail[cur] = rqNext(g_rqTail[cur]);
+			// 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 (matches the real puck's pacing)
-			return;
+			return; // one relay per poll cycle
 		}
-		g_rqTail = rqNext(g_rqTail);
+		g_rqTail[cur] = rqNext(g_rqTail[cur]);
 	}
 }
 
-// Haptic-subsystem RE-INIT: the exact sequence Steam sends (captured on hardware) when it (re)takes control,
-// which clears a stuck haptic script on the controller -- a 0x81 reset action plus 0x87 writes to the haptic
-// registers (30/07/08/31/52, 18/2e/34/35). Replayed to recover from the latched-buzz the controller falls
-// into across a reconnect. Brightness (0x87 reg 2d) is deliberately OMITTED so we don't stomp the LED.
-//
-// The 0x87 frames below go out on LEGACY (type-05) framing (the whitelist lands 0x87 only for brightness
-// 0x2D), so the controller DISCARDS them -- kept verbatim only to match the captured sequence. The effective
-// re-init is the three 0x81 frames. (Landing the 0x30 here would freeze the gyro.)
-//
-// slot defaults to 0xFF (broadcast to all connected) -- the clear re-init is a settings-only reset and
-// is harmless on healthy controllers, so it's worth re-initializing every slot the firmware knows about.
+// Haptic-subsystem re-init: the captured sequence Steam sends when it (re)takes control (0x81 reset + 0x87
+// register writes). Brightness (reg 0x2D) is omitted to avoid stomping the LED. The 0x87 frames go out on
+// legacy framing so the controller discards them; the three 0x81 frames are the effective reset. slot=0xFF
+// broadcasts to all connected controllers (the re-init is settings-only and harmless on healthy controllers).
 void hapticReinit(uint8_t slot)
 {
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
@@ -433,55 +413,38 @@ void hapticReinit(uint8_t slot)
 }
 void hapticInit()
 {
-	g_rqHead = g_rqTail = 0;
 	g_haptic82On = false;
+	g_hapticStop = 0;
 	for (int s = 0; s < NSLOT; s++) {
+		g_rqHead[s] = g_rqTail[s] = 0;
 		g_rumble80On[s] = false;
 		g_rumble80Ms[s] = 0;
-	}
-	// boot: block stale Steam 0x82 until link stable (every slot)
-	for (int s = 0; s < NSLOT; s++)
+		// block stale Steam 0x82 until the link is stable after boot
 		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
-	// NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
-	// zero-gain [01 01 00] "stop". An invented stop frame at boot/connect is a likelier cause of the connect-time
-	// buzz than a cure. Be a pure pass-through of Steam's haptics, like the real puck.
-	g_hapticStop = 0;
+	}
 }
-// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called from rf_link the moment a
-// controller reply arrives after a gap (the reliable reconnect signal), and as a backup on hapticTask's
-// link-up edge. Idempotent -- safe to call repeatedly. Per-slot: only the slot that just reconnected is
-// blocked, the other slots keep relaying. The scheduled re-init is global (broadcast) so any slot's
-// latch gets cleared.
+// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called on the reliable first-reply
+// signal from rf_link and again from hapticTask's link-up edge detector. Per-slot: only the reconnected
+// slot is blocked; the broadcast re-init covers all slots.
 void hapticOnReconnect(int slot)
 {
 	if (slot < 0 || slot >= NSLOT)
 		return;
-	// no haptics relayed for the next 3s (per-slot)
 	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_haptic82On = false;
 	g_rumble80On[slot] = false;
 	g_rumble80Ms[slot] = 0;
-
-	// drop any haptic ON queued before the link came up
 	hapticCancelPendingOn();
-	// Re-init the haptic engine repeatedly across the settle window rather than waiting for the block to end: the
-	// brief connect buzz engages early (during the block, controller-internal), so a single late shot misses it.
-	// The re-init is settings only (no haptic play) and Steam haptics are blocked throughout, so it can't buzz.
-	g_reinitAt = millis() + 200u; // first reset ~200ms after (re)connect
-
-	// then every HAPTIC_REINIT_GAP_MS across the window
+	// Re-init repeatedly across the settle window: the connect buzz engages early (during the block,
+	// controller-internal), so a single late shot misses it.
+	g_reinitAt = millis() + 200u;
 	g_reinitLeft = HAPTIC_REINIT_SHOTS;
 	uint8_t mk = 2;
-
-	// capture marker: RECONNECT detected (block+reinit armed)
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 }
 void hapticTask()
 {
-	// Per-slot link-edge detect: any slot going from "down" to "up" gets the haptic block + re-init
-	// scheduled for that slot. The rf_link decode already calls hapticOnReconnect on the first F-reply
-	// after a gap (reliable); this loop is a backup for slots whose link-up the decode didn't see directly
-	// (e.g. a long gap followed by a quiet stretch).
+	// Per-slot link-edge detect (backup for hapticOnReconnect in rf_link).
 	static bool wasHapticLinkUp[NSLOT] = { 0 };
 	for (int s = 0; s < NSLOT; s++) {
 		if (!g_slot[s].used)
@@ -498,23 +461,15 @@ void hapticTask()
 		}
 		wasHapticLinkUp[s] = up;
 	}
-	// Proactive haptic re-init (scheduled at (re)connect by hapticOnReconnect): the re-init is
-	// settings-only so broadcasting to all connected slots is fine, and it's the same per-slot
-	// re-init the controller would have received on its own connect.
 	if (g_reinitAt && anySlotLinkUp() &&
-	    (int32_t)(millis() - g_reinitAt) >=
-		    0) { // proactive haptic re-init across the connect window
+	    (int32_t)(millis() - g_reinitAt) >= 0) {
 		hapticReinit();
 		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
 				     (millis() + HAPTIC_REINIT_GAP_MS) :
 				     0;
 	}
-	// Controller power-off on host SLEEP: send the power-off command (0x9F "off!") the instant the USB bus
-	// suspends, like the real puck. BUT only when USB power (VBUS) is still present -- i.e. a genuine host sleep,
-	// NOT a cable unplug. Pulling the dongle ALSO trips the suspend edge (in the brief window it runs on residual
-	// power), and we must NOT kill the controller then; it should only power off on a shutdown command or a real
-	// host sleep. VBUSDETECT is 1 while the cable still delivers 5V, 0 once unplugged. wasSusp starts true so a
-	// boot-into-suspended state never false-fires.
+	// Power-off on host sleep: only when VBUS is present (genuine sleep, not a cable unplug which also
+	// trips the suspend edge briefly). wasSusp=true at boot suppresses a false fire on boot-into-suspended.
 	static bool wasSusp = true;
 	bool susp = USBDevice.suspended();
 	bool vbus = (NRF_POWER->USBREGSTATUS &
@@ -522,24 +477,16 @@ void hapticTask()
 	if (susp && !wasSusp && vbus)
 		hapticSendShutdown();
 	wasSusp = susp;
-	// Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
-	// are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
-	// click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
+	// Steam-mode quiet timeout: mark 0x82 stream inactive. No synthesized stop -- Steam forwards its own.
 	if (!g_xbox && g_haptic82On &&
 	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
 		g_haptic82On = false;
-	// Per-slot stuck-rumble watchdog: if a controller's rumble stream has been "on" without a refresh for
-	// 2.5s, force a zero report to clear the controller's latch. The previous single-stream watchdog
-	// couldn't cover a 4-XInput scenario where only one slot is stuck (it would only fire when the
-	// merged-flag went stale).
+	// Per-slot stuck-rumble watchdog: force zero after 2.5s without a refresh.
 	for (int s = 0; s < NSLOT; s++) {
 		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
 			hapticSteamRumble(0, 0, (uint8_t)s);
 	}
-	// Haptic activity has gone idle for a while -> fire one re-init to clear any latch it left behind (the buzz
-	// that engages during/after use and won't self-clear, incl. after a mode switch). Fires only after a quiet
-	// gap, so it never interrupts active haptics; the brightness-less re-init is silent (settings, no play, no
-	// LED). Runs in ALL modes (the controller-side latch is mode-independent).
+	// Idle-clear: after haptic activity goes quiet, re-init once to clear any latch left behind.
 	if (g_hapClearArmed &&
 	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
 		g_hapClearArmed = false;

+ 7 - 13
OpenPuck/haptics.h

@@ -4,11 +4,9 @@
 // a SET sub-TLV inside the E3 poll. We do the same: handleSet()/the console enqueue with relayEnqueue(), and
 // rfConnFlushRelay() emits ONE entry per poll cycle (rf_link.cpp), never at raw loop rate.
 //
-// The queue is a small ring, NOT a single buffer: producers run in USB-ISR context while the consumer may be
-// mid-flush in loop context, and Steam writes settings/calibration as bursts of back-to-back feature reports.
-// A single buffer LOSES reports arriving before the previous flush and can be TORN mid-copy by the ISR.
-// Entries carry up to RELAY_MAXP payload bytes (the RF frame fits 60: [E3][len][05][rid] + payload <= MAXLEN
-// 64); a smaller cap chops multi-register 0x87 settings blocks (LED brightness) and calibration writes.
+// One ring per bond slot: the consumer (rfConnFlushRelay) only drains the current slot's queue, so commands
+// addressed to other slots are never consumed out of turn. ISR producers write under PRIMASK; the consumer
+// runs in loop context and never races the same slot's head pointer.
 //
 // The controller's haptic LATCHES until told to stop. If the host's stop is lost over RF (or the link drops
 // mid-buzz) the actuator whines forever -- so we send 0x82-zero stop bursts on reconnect, but ONLY when a
@@ -43,11 +41,8 @@
 #define HAPTIC_SHUTDOWN_SHOTS 3u
 
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
-// Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
-// goes on the air). `slot` is the originating bond slot (0..NSLOT-1) or 0xFF for "broadcast to every
-// connected controller" (used by hapticSendShutdown / hapticReinit / test haptics -- the ones that must
-// reach every controller, not just the one that triggered them). ISR-safe (brief PRIMASK critical
-// section). Returns false (dropped) when the ring is full.
+// Enqueue one host->controller report. `slot` = bond slot (0..NSLOT-1) or 0xFF to broadcast to every
+// connected controller (used by hapticSendShutdown / hapticReinit / test haptics). ISR-safe (PRIMASK).
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot = 0xFF);
 
@@ -94,7 +89,7 @@ static inline bool hapLogPull(uint32_t *, uint8_t *, uint8_t *, uint8_t *,
 bool hapticLinkUp(int slot = -1);
 bool haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
-void haptic82HostReport(const uint8_t *p, uint16_t n, int slot = -1);
+void haptic82HostReport(const uint8_t *p, uint16_t n);
 // queue a Steam/Triton 0x80 rumble frame. `slot` = bond slot of the originating controller (0..NSLOT-1);
 // defaults to 0 for the legacy single-controller callers. Per-slot so each connected controller can have its
 // own active rumble stream when the host presents multiple gamepads (e.g. 4 XInput devices).
@@ -102,8 +97,7 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // 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. The flush only emits
-// entries tagged with the CURRENT slot (set by rfConnStep), so each controller only sees its own relay.
+// 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);
 

+ 1 - 5
OpenPuck/mode_switch_pro.cpp

@@ -365,10 +365,6 @@ static void jcBuildStickCal()
 // Manual-pairing (subcommand 0x01) reply payloads. A real Switch runs this 3-stage BT key exchange over USB so it
 // can register the pad (Steam/hid-nintendo do not). The Switch only validates the shape, not the key contents. Each
 // is the 31-byte reply body following the 0x21 input prefix + ack(0x81) + echoed-subcommand(0x01).
-// type 1: echo type + controller BT addr + "Pro Controller" name
-// 31-byte payload uses the per-slot MAC; we fill this on demand.
-static const uint8_t BT_PAIR_1_BODY[7] = { 0x01, 0x00, 0x00, 0x00,
-					   0x00, 0x00, 0x00 };
 static const uint8_t BT_PAIR_2[31] = { // type 2: LTK exchange
 	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
 	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -512,7 +508,7 @@ static void jcSubcmd(uint8_t slot, uint8_t sub, const uint8_t *args,
 		p[12] = 0x81; // pairing ACK
 		if (t == 1) {
 			// echo type + this slot's MAC + "Pro Controller" name
-			p[14] = BT_PAIR_1_BODY[0];
+			p[14] = 0x01;
 			memcpy(&p[15], g_jcMac[slot], 6);
 			p[21] = 0x00;
 			p[22] = 0x25;

+ 6 - 5
OpenPuck/puck_hid.cpp

@@ -163,7 +163,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				// BLOCKED stop must leave "on" set (controller may be latched -> reconnect stop-burst),
 				// a blocked ON must not set it (nothing reached the controller -> no spurious clicks).
 				if (rid == 0x82)
-					haptic82HostReport(b, n, slot);
+					haptic82HostReport(b, n);
 			}
 		}
 
@@ -218,7 +218,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 
 			// track from RELAYED frames only (see the OUTPUT path)
 			if (haptic82)
-				haptic82HostReport(pl, len, slot);
+				haptic82HostReport(pl, len);
 		}
 	}
 	if (Serial.availableForWrite() > 80) {
@@ -475,7 +475,7 @@ static void wakeNudgeTask()
 		hid_mouse_report_t m;
 		m.buttons = 0;
 		m.x = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX :
-					     -NUDGE_JIGGLE_PX;
+					      -NUDGE_JIGGLE_PX;
 		m.y = 0;
 		m.wheel = 0;
 		m.pan = 0;
@@ -511,7 +511,7 @@ void SteamPuckController::task()
 	// slot. The real puck's per-slot edge-triggered 0x79 prevents re-triggering Steam's connect-chime loop.
 	static bool usbConn[NSLOT] = { 0 };
 	static unsigned long last79[NSLOT] = { 0 }, last7B[NSLOT] = { 0 },
-			      connEdgeMs[NSLOT] = { 0 };
+			     connEdgeMs[NSLOT] = { 0 };
 	for (int s = 0; s < NSLOT; s++) {
 		if (!g_slot[s].used || !hid[s].ready())
 			continue;
@@ -522,7 +522,8 @@ void SteamPuckController::task()
 		// Steam consumes 0x45 -> a loop of connect-time haptic buzzes. Resending until acked still covers
 		// "Steam missed the edge".
 		bool steamAcked = g_steamAliveMs &&
-				  (int32_t)(g_steamAliveMs - connEdgeMs[s]) >= 0;
+				  (int32_t)(g_steamAliveMs - connEdgeMs[s]) >=
+					  0;
 		if (conn != usbConn[s] ||
 		    (conn && !steamAcked && millis() - last79[s] >= 750)) {
 			if (conn && !usbConn[s])

+ 92 - 64
OpenPuck/rf_link.cpp

@@ -308,45 +308,48 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 						bool fresh =
-							(rep[1] != g_lastSeq[g_curSlot]);
+							(rep[1] !=
+							 g_lastSeq[g_curSlot]);
 						// genuine new report vs stale poll-repeat
 						if (fresh) {
 							g_stNew++;
-							g_lastSeq[g_curSlot] = rep[1];
+							g_lastSeq[g_curSlot] =
+								rep[1];
 						}
 						uint32_t bb = btnsOf(rep);
 						// USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
 						// the user is powering off the controller, so we ignore it.
 						{
-							static bool steamWasDown =
-								false;
-							static unsigned long
-								steamDownMs = 0;
+							// per-slot: with round-robin polling, a shared static gets
+							// reset by other slots' reports between press and release.
+							static bool steamWasDown
+								[NSLOT] = {};
+							static unsigned long steamDownMs
+								[NSLOT] = {};
 							if (fresh) {
 								bool steamNow =
 									(bb &
 									 TB_STEAM) !=
 									0;
-								// rising edge: record press time
 								if (steamNow &&
-								    !steamWasDown)
-									steamDownMs =
+								    !steamWasDown
+									    [g_curSlot])
+									steamDownMs[g_curSlot] =
 										millis();
-								// falling edge within 1 s -> short press -> wake
 								if (!steamNow &&
-								    steamWasDown &&
-								    millis() - steamDownMs <
+								    steamWasDown
+									    [g_curSlot] &&
+								    millis() - steamDownMs[g_curSlot] <
 									    1000u &&
 								    USBDevice
 									    .suspended()) {
 									USBDevice
 										.remoteWakeup();
 									ledWakePulse();
-									// post-resume nudge (host needs real input to actually wake)
 									if (g_active)
 										g_active->wakeEvent();
 								}
-								steamWasDown =
+								steamWasDown[g_curSlot] =
 									steamNow;
 							}
 						}
@@ -364,10 +367,16 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							// used slot, so the timer never reaches 2s.
 							static unsigned long
 								offHoldMs[NSLOT] = {
-									0, 0, 0, 0};
-							static bool offFired[NSLOT] = {
-								false, false, false,
-								false};
+									0, 0, 0,
+									0
+								};
+							static bool offFired
+								[NSLOT] = {
+									false,
+									false,
+									false,
+									false
+								};
 							if ((bb & (TB_STEAM |
 								   TB_Y)) ==
 							    (TB_STEAM | TB_Y)) {
@@ -387,7 +396,8 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 								if (g_usbMode !=
 								    MODE_STEAM) {
 									// stream modes read g_in
-									g_in[g_curSlot].buttons &=
+									g_in[g_curSlot]
+										.buttons &=
 										~(uint32_t)(TB_STEAM |
 											    TB_Y);
 									// push modes read btnsOf(rep): TB_Y in rep[2],
@@ -404,32 +414,40 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 											   16);
 								}
 							} else {
-								offHoldMs[g_curSlot] = 0;
+								offHoldMs[g_curSlot] =
+									0;
 								offFired[g_curSlot] =
 									false;
 							}
 						}
 						g_in[g_curSlot].lx =
 							(int16_t)s16off(rep, 8);
-						g_in[g_curSlot].ly = (int16_t)s16off(rep,
-									  10);
-						g_in[g_curSlot].rx = (int16_t)s16off(rep,
-									  12);
-						g_in[g_curSlot].ry = (int16_t)s16off(rep,
-									  14);
+						g_in[g_curSlot].ly =
+							(int16_t)s16off(rep,
+									10);
+						g_in[g_curSlot].rx =
+							(int16_t)s16off(rep,
+									12);
+						g_in[g_curSlot].ry =
+							(int16_t)s16off(rep,
+									14);
 						g_in[g_curSlot].lt =
 							trigU8(u16off(rep, 4));
 						// for the Switch digital-trigger threshold
 						g_in[g_curSlot].rt =
 							trigU8(u16off(rep, 6));
-						g_in[g_curSlot].lpx = (int16_t)s16off(rep,
-									   16);
-						g_in[g_curSlot].lpy = (int16_t)s16off(rep,
-									   18);
-						g_in[g_curSlot].rpx = (int16_t)s16off(rep,
-									   22);
-						g_in[g_curSlot].rpy = (int16_t)s16off(rep,
-									   24);
+						g_in[g_curSlot].lpx =
+							(int16_t)s16off(rep,
+									16);
+						g_in[g_curSlot].lpy =
+							(int16_t)s16off(rep,
+									18);
+						g_in[g_curSlot].rpx =
+							(int16_t)s16off(rep,
+									22);
+						g_in[g_curSlot].rpy =
+							(int16_t)s16off(rep,
+									24);
 						// IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report was
 						// actually received -- bounded by `end` (the received length), NOT sizeof(rfrx). The outer gate is
 						// tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
@@ -438,12 +456,20 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						if (tlen >= 46 &&
 						    (size_t)(idx + 2) + 46 <=
 							    (size_t)end)
-							imuFrom45(rep, &g_in[g_curSlot].ax,
-								  &g_in[g_curSlot].ay,
-								  &g_in[g_curSlot].az,
-								  &g_in[g_curSlot].gx,
-								  &g_in[g_curSlot].gy,
-								  &g_in[g_curSlot].gz);
+							imuFrom45(
+								rep,
+								&g_in[g_curSlot]
+									 .ax,
+								&g_in[g_curSlot]
+									 .ay,
+								&g_in[g_curSlot]
+									 .az,
+								&g_in[g_curSlot]
+									 .gx,
+								&g_in[g_curSlot]
+									 .gy,
+								&g_in[g_curSlot]
+									 .gz);
 						// Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in[g_curSlot].buttons stays
 						// intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
 						if ((bb & CHORD_BACK4) ==
@@ -457,8 +483,8 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						// g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
 						if (g_active)
 							g_active->onReport45(
-								g_curSlot, rep, fresh,
-								tlen);
+								g_curSlot, rep,
+								fresh, tlen);
 						lastRep = rep;
 						lastTlen = tlen;
 					} else if (ttype == 6 &&
@@ -474,12 +500,16 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 						// 0x43 body[1] (~0x5e=94) reads as battery % (sniff-derived)
-						if (rep[0] == 0x43 && tlen >= 3 && g_curSlot >= 0 &&
+						if (rep[0] == 0x43 &&
+						    tlen >= 3 &&
+						    g_curSlot >= 0 &&
 						    g_curSlot < NSLOT)
-							g_battery[g_curSlot] = rep[2];
+							g_battery[g_curSlot] =
+								rep[2];
 						if (g_active)
 							g_active->onAuxReport(
-								g_curSlot, rep[0], rep + 1,
+								g_curSlot,
+								rep[0], rep + 1,
 								(uint8_t)(tlen -
 									  1));
 					}
@@ -493,22 +523,28 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 					// slots' non-chord reports reset the counter on every iteration. The counter could never
 					// reach 12 with multiple controllers, making the chord effectively dead.
 					static uint8_t chWant[NSLOT] = {
-						0xFF, 0xFF, 0xFF, 0xFF};
-					static uint8_t chCnt[NSLOT] = {
-						0, 0, 0, 0};
+						0xFF, 0xFF, 0xFF, 0xFF
+					};
+					static uint8_t chCnt[NSLOT] = { 0, 0, 0,
+									0 };
 					uint8_t want = 0xFF;
-					if ((g_in[g_curSlot].buttons & CHORD_BACK4) ==
-					    CHORD_BACK4) {
-						if (g_in[g_curSlot].buttons & TB_A)
+					if ((g_in[g_curSlot].buttons &
+					     CHORD_BACK4) == CHORD_BACK4) {
+						if (g_in[g_curSlot].buttons &
+						    TB_A)
 							want = MODE_STEAM;
-						else if (g_in[g_curSlot].buttons & TB_B)
+						else if (g_in[g_curSlot].buttons &
+							 TB_B)
 							want = g_chordBtn[0];
-						else if (g_in[g_curSlot].buttons & TB_X)
+						else if (g_in[g_curSlot].buttons &
+							 TB_X)
 							want = g_chordBtn[1];
-						else if (g_in[g_curSlot].buttons & TB_Y)
+						else if (g_in[g_curSlot].buttons &
+							 TB_Y)
 							want = g_chordBtn[2];
 					}
-					if (want != 0xFF && want == chWant[g_curSlot]) {
+					if (want != 0xFF &&
+					    want == chWant[g_curSlot]) {
 						if (++chCnt[g_curSlot] >= 12 &&
 						    want != g_usbMode &&
 						    modeValid(want) &&
@@ -569,15 +605,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 // and consumed by the decode (g_in[g_curSlot]) and the haptic flush.
 static void rfConnStep()
 {
-	// count + find the next used slot, starting AFTER the previous cycle's slot (round-robin).
-	int n = 0;
-	for (int s = 0; s < NSLOT; s++)
-		if (g_slot[s].used)
-			n++;
-	if (n == 0) {
-		g_curSlot = -1;
-		return;
-	}
+	// find the next used slot, starting AFTER the previous cycle's slot (round-robin).
 	int start = (g_curSlot < 0) ? 0 : ((g_curSlot + 1) % NSLOT);
 	int slot = -1;
 	for (int k = 0; k < NSLOT; k++) {

+ 35 - 26
OpenPuck/serial_console.cpp

@@ -299,41 +299,50 @@ void serialConsolePoll()
 			} else if (line[0] == 'U') {
 				// Per-slot debug dump: which controllers are connected, their RSSI, haptic-block
 				// state, and session address. Quick at-a-glance view for multi-controller testing.
-				Serial.printf("# session ch%u, curSlot=%d\n", g_sessCh,
-					      g_curSlot);
+				Serial.printf("# session ch%u, curSlot=%d\n",
+					      g_sessCh, g_curSlot);
 				for (int s = 0; s < NSLOT; s++) {
 					bool live = g_slot[s].used &&
-						     g_connReplyMs[s] != 0;
-					unsigned long age = live ?
-								     (unsigned long)(millis() -
-										     g_connReplyMs[s]) :
-								     0;
-					int rssi = g_linkRssi[s] ?
-							   (int)g_linkRssi[s] -
-								   RSSI_DBM_OFFSET :
-							   0;
-					unsigned long blk = g_hapticBlockUntil[s] ?
-								    (unsigned long)((int32_t)(g_hapticBlockUntil[s] -
-											       millis()) >
-										     0 ?
-									     (g_hapticBlockUntil[s] -
-									      millis()) :
-									     0) :
-								    0;
+						    g_connReplyMs[s] != 0;
+					unsigned long age =
+						live ? (unsigned long)(millis() -
+								       g_connReplyMs
+									       [s]) :
+						       0;
+					int rssi =
+						g_linkRssi[s] ?
+							(int)g_linkRssi[s] -
+								RSSI_DBM_OFFSET :
+							0;
+					unsigned long now_ms = millis();
+					unsigned long blk =
+						(g_hapticBlockUntil[s] &&
+						 (int32_t)(g_hapticBlockUntil[s] -
+							   now_ms) > 0) ?
+							(g_hapticBlockUntil[s] -
+							 now_ms) :
+							0;
 					Serial.printf(
 						"# slot %d: %s addr %02X%02X%02X%02X/%02X rssi=%ddBm conn=%lums ago batt=%u%% haptic-block=%lums uuid %02X%02X%02X%02X %02X%02X%02X%02X\n",
 						s,
 						g_slot[s].used ?
-							(live ? "LIVE" : "BONDED") :
+							(live ? "LIVE" :
+								"BONDED") :
 							"empty",
-						g_sessBase[s][0], g_sessBase[s][1],
-						g_sessBase[s][2], g_sessBase[s][3],
+						g_sessBase[s][0],
+						g_sessBase[s][1],
+						g_sessBase[s][2],
+						g_sessBase[s][3],
 						g_sessPrefix[s], rssi, age,
 						g_battery[s], blk,
-						g_slot[s].rec[0], g_slot[s].rec[1],
-						g_slot[s].rec[2], g_slot[s].rec[3],
-						g_slot[s].rec[4], g_slot[s].rec[5],
-						g_slot[s].rec[6], g_slot[s].rec[7]);
+						g_slot[s].rec[0],
+						g_slot[s].rec[1],
+						g_slot[s].rec[2],
+						g_slot[s].rec[3],
+						g_slot[s].rec[4],
+						g_slot[s].rec[5],
+						g_slot[s].rec[6],
+						g_slot[s].rec[7]);
 				}
 			} else if (line[0] == 'r') {
 				g_rxWin = strtoul(line + 1, 0, 10);

+ 1 - 1
OpenPuck/triton.h

@@ -74,7 +74,7 @@ void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 
 // ---- shared decoded input (filled by rf_link.cpp once per fresh report 0x45, read by every mode) ----
 // One PuckInput per bond slot: each controller in a multi-slot puck has its own decoded input. The stream
-// modes (Switch, PS5, DS4) are single-controller and read g_in[0] from task(); the puck/lizard mode reads
+// modes (Switch, PS5, DS4) read g_in[s] for each active slot in their task() loop; the puck/lizard mode reads
 // g_in[slot] inside onReport45/onAuxReport to forward to hid[slot].
 struct PuckInput {
 	// raw Triton buttons (TB_*); per-mode builders apply their own chord masking

+ 19 - 6
OpenPuck/webusb_config.cpp

@@ -27,8 +27,7 @@ static void webusbSendBlob()
 	// presents to Steam on 0x79). The blob is sent on the panel's poll, so the slot + up flag change
 	// 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);
+	bool up = (g_curSlot >= 0 && (millis() - g_connReplyMs[cs]) < 300);
 	uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
@@ -73,9 +72,10 @@ static void webusbSendBlob()
 	p[36] = (uint8_t)(g_pollPeriodUs >>
 			  8); // measured poll period (intended 4000)
 	p[37] = OPK_LOG; // logging build? panel shows/hides its log UI
-	p[38] = g_battery[g_curSlot >= 0 && g_curSlot < NSLOT ?
-				  g_curSlot :
-				  0]; // controller battery % (report 0x43); 0=unknown
+	p[38] = g_battery
+		[g_curSlot >= 0 && g_curSlot < NSLOT ?
+			 g_curSlot :
+			 0]; // controller battery % (report 0x43); 0=unknown
 	p[39] = g_linkRssi[g_curSlot >= 0 && g_curSlot < NSLOT ?
 				   g_curSlot :
 				   0]; // RAW signal strength |dBm| (0=no sample)
@@ -169,7 +169,7 @@ void webusbPoll()
 				       (op == 0x03 || op == 0x05) ? 2 :
 				       (op == 0x0A)		  ? 4 :
 								    1;
-			if (op < 0x01 || op > 0x0A) { // resync: drop one byte
+			if (op < 0x01 || op > 0x0C) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
@@ -209,6 +209,19 @@ void webusbPoll()
 					delay(40);
 					NVIC_SystemReset();
 				}
+
+				// reboot into serial DFU (adafruit-nrfutil)
+			} else if (op == 0x0B) {
+				usb_web.flush();
+				delay(40);
+				enterSerialDfu();
+
+				// reboot into UF2 bootloader (USB mass storage)
+			} else if (op == 0x0C) {
+				usb_web.flush();
+				delay(40);
+				enterUf2Dfu();
+
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 

+ 2 - 0
OpenPuck/webusb_config.h

@@ -3,6 +3,8 @@
 //   host->dev:  0x01                 GET  -> reply status blob
 //               0x02 <field> <value> SET one byte field (see the switch in webusb_config.cpp)
 //               0x03 <mode>          switch USB mode (0..6): persist + reboot
+//               0x0B                 reboot into serial DFU (adafruit-nrfutil)
+//               0x0C                 reboot into UF2 bootloader (USB mass storage)
 //   dev->host:  0xA5 <len> <payload>  status blob (mode/tunables/link state/rates)
 // No setLandingPage() on purpose -- it would pop a Chrome "open <url>?" notification on every plug-in.
 #pragma once

+ 18 - 0
docs/index.html

@@ -102,6 +102,14 @@
       <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
         <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path Steam's "turn off controller" and host-suspend use. Watch the link status to see it disconnect.</span>
       </div>
+      <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px">
+        <button id="dfuSerial">Reboot to Serial DFU</button>
+        <span class="note" style="flex:1">Reboots the puck into serial DFU mode (adafruit-nrfutil). The device drops off USB — flash with <code>adafruit-nrfutil dfu serial …</code> then replug.</span>
+      </div>
+      <div class="row" style="margin:10px 0 0">
+        <button id="dfuUf2">Reboot to UF2 Bootloader</button>
+        <span class="note" style="flex:1">Reboots the puck into the UF2 mass-storage bootloader. It mounts as a USB drive — drag the <code>.uf2</code> file onto it to flash, then it reboots automatically.</span>
+      </div>
       <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
         <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
         <span class="note" style="flex:1"><strong style="color:#ff9bb0">Danger:</strong> wipes <em>all</em> persistent storage on the copycat — the paired controller bond <em>and</em> every saved setting (mode, chords, back-paddle map, sensitivity). The copycat reboots to factory defaults and you must <strong>re-pair the controller</strong>. This cannot be undone.</span>
@@ -390,6 +398,16 @@ $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;
 $("#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"); } };
+$("#dfuSerial").onclick=async()=>{
+  if(!dev){ log("not connected"); return; }
+  if(!confirm("Reboot into serial DFU?\n\nThe puck will disconnect immediately. Flash with adafruit-nrfutil, then replug.")) return;
+  await send([0x0B]); log("serial DFU reboot sent — device disconnecting");
+};
+$("#dfuUf2").onclick=async()=>{
+  if(!dev){ log("not connected"); return; }
+  if(!confirm("Reboot into UF2 bootloader?\n\nThe puck will disconnect and mount as a USB drive. Drag the .uf2 file onto it to flash.")) return;
+  await send([0x0C]); log("UF2 bootloader reboot sent — device disconnecting");
+};
 $("#factoryErase").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
   // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.