Procházet zdrojové kódy

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 před 4 měsíci
rodič
revize
a8c88d2c75

+ 7 - 12
OpenPuck/OpenPuck.ino

@@ -47,9 +47,7 @@ void setup()
 	genSerial();
 	genSerial();
 	ledInit();
 	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++)
 	for (int s = 0; s < NSLOT; s++)
 		rfGenSessionAddr(s);
 		rfGenSessionAddr(s);
 	InternalFS.begin();
 	InternalFS.begin();
@@ -60,9 +58,7 @@ void setup()
 #endif
 #endif
 	loadCfg();
 	loadCfg();
 	loadBonds();
 	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++)
 	for (int s = 0; s < NSLOT; s++)
 		if (g_slot[s].used)
 		if (g_slot[s].used)
 			rfGenSessionAddr(s);
 			rfGenSessionAddr(s);
@@ -143,8 +139,6 @@ void setup()
 		USBDevice.remoteWakeup();
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
 	} // 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();
 	hapticInit();
 	static const char *MODE_NAME[] = {
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
 		"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",
 				"#   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],
 				s, g_sessBase[s][0], g_sessBase[s][1],
 				g_sessBase[s][2], g_sessBase[s][3],
 				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
 	// 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
 //                    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;
 //                    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
 //                    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.
 //   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()).
 //   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)
 // latch that engaged during/after use, even seconds after connect)
 static bool g_hapClearArmed = false;
 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 {
 struct RelayMsg {
-	uint8_t rid, len, slot;
+	uint8_t rid, len;
 	uint8_t data[RELAY_MAXP];
 	uint8_t data[RELAY_MAXP];
 };
 };
 // deep enough to hold a full Steam settings/LED transaction burst without loss
 // deep enough to hold a full Steam settings/LED transaction burst without loss
 #define RELAY_QLEN 32
 #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
 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)
 static inline uint8_t rqNext(uint8_t i)
 {
 {
 	return (uint8_t)((i + 1) % RELAY_QLEN);
 	return (uint8_t)((i + 1) % RELAY_QLEN);
@@ -75,30 +74,35 @@ static inline uint8_t rqNext(uint8_t i)
 
 
 bool relayPending()
 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,
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot)
 		  uint8_t slot)
 {
 {
-	// 60B is the RF frame ceiling; longer can't be relayed
 	if (plen > RELAY_MAXP)
 	if (plen > RELAY_MAXP)
 		plen = RELAY_MAXP;
 		plen = RELAY_MAXP;
+	if (slot != 0xFF && slot >= NSLOT)
+		return false;
 	uint32_t pm = __get_PRIMASK();
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
 	__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) {
 	if (rid == 0x82 || rid == 0x80) {
 		g_haptic82Ms = millis();
 		g_haptic82Ms = millis();
 		g_hapClearArmed = true;
 		g_hapClearArmed = true;
@@ -223,38 +227,38 @@ static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
 }
 }
 static void hapticCancelPendingOn()
 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();
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
 	__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);
 	__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)
 	if (n < 3)
 		return;
 		return;
 	g_haptic82Ms = millis();
 	g_haptic82Ms = millis();
@@ -325,39 +329,27 @@ void rfConnQueueHapticRelay()
 			g_hapticStop--;
 			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)
 void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 {
 {
 	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	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) {
 		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;
 			uint8_t rl = m.len;
 			if (rl > RELAY_MAXP)
 			if (rl > RELAY_MAXP)
 				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 =
 			bool land01 =
 				(m.rid == 0x9F) ||
 				(m.rid == 0x9F) ||
 				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
 				(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);
 				memcpy(p + 4, m.data, rl);
 				plen = (uint8_t)(4 + 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);
 			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);
 			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)
 void hapticReinit(uint8_t slot)
 {
 {
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
@@ -433,55 +413,38 @@ void hapticReinit(uint8_t slot)
 }
 }
 void hapticInit()
 void hapticInit()
 {
 {
-	g_rqHead = g_rqTail = 0;
 	g_haptic82On = false;
 	g_haptic82On = false;
+	g_hapticStop = 0;
 	for (int s = 0; s < NSLOT; s++) {
 	for (int s = 0; s < NSLOT; s++) {
+		g_rqHead[s] = g_rqTail[s] = 0;
 		g_rumble80On[s] = false;
 		g_rumble80On[s] = false;
 		g_rumble80Ms[s] = 0;
 		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;
 		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)
 void hapticOnReconnect(int slot)
 {
 {
 	if (slot < 0 || slot >= NSLOT)
 	if (slot < 0 || slot >= NSLOT)
 		return;
 		return;
-	// no haptics relayed for the next 3s (per-slot)
 	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_haptic82On = false;
 	g_haptic82On = false;
 	g_rumble80On[slot] = false;
 	g_rumble80On[slot] = false;
 	g_rumble80Ms[slot] = 0;
 	g_rumble80Ms[slot] = 0;
-
-	// drop any haptic ON queued before the link came up
 	hapticCancelPendingOn();
 	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;
 	g_reinitLeft = HAPTIC_REINIT_SHOTS;
 	uint8_t mk = 2;
 	uint8_t mk = 2;
-
-	// capture marker: RECONNECT detected (block+reinit armed)
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 }
 }
 void hapticTask()
 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 };
 	static bool wasHapticLinkUp[NSLOT] = { 0 };
 	for (int s = 0; s < NSLOT; s++) {
 	for (int s = 0; s < NSLOT; s++) {
 		if (!g_slot[s].used)
 		if (!g_slot[s].used)
@@ -498,23 +461,15 @@ void hapticTask()
 		}
 		}
 		wasHapticLinkUp[s] = up;
 		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() &&
 	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();
 		hapticReinit();
 		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
 		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
 				     (millis() + HAPTIC_REINIT_GAP_MS) :
 				     (millis() + HAPTIC_REINIT_GAP_MS) :
 				     0;
 				     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;
 	static bool wasSusp = true;
 	bool susp = USBDevice.suspended();
 	bool susp = USBDevice.suspended();
 	bool vbus = (NRF_POWER->USBREGSTATUS &
 	bool vbus = (NRF_POWER->USBREGSTATUS &
@@ -522,24 +477,16 @@ void hapticTask()
 	if (susp && !wasSusp && vbus)
 	if (susp && !wasSusp && vbus)
 		hapticSendShutdown();
 		hapticSendShutdown();
 	wasSusp = susp;
 	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 &&
 	if (!g_xbox && g_haptic82On &&
 	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
 	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
 		g_haptic82On = false;
 		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++) {
 	for (int s = 0; s < NSLOT; s++) {
 		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
 		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
 			hapticSteamRumble(0, 0, (uint8_t)s);
 			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 &&
 	if (g_hapClearArmed &&
 	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
 	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
 		g_hapClearArmed = false;
 		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
 // 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.
 // 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
 // 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
 // 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
 #define HAPTIC_SHUTDOWN_SHOTS 3u
 
 
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // ---- 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,
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot = 0xFF);
 		  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 hapticLinkUp(int slot = -1);
 bool haptic82Blocked(int slot = -1);
 bool haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
 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);
 // 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
 // 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).
 // 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).
 // 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
 // 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 rfConnQueueHapticRelay();
 void rfConnFlushRelay(uint8_t ch, uint8_t s1);
 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
 // 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
 // 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).
 // 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
 static const uint8_t BT_PAIR_2[31] = { // type 2: LTK exchange
 	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
 	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
 	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
 	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
 		p[12] = 0x81; // pairing ACK
 		if (t == 1) {
 		if (t == 1) {
 			// echo type + this slot's MAC + "Pro Controller" name
 			// 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);
 			memcpy(&p[15], g_jcMac[slot], 6);
 			p[21] = 0x00;
 			p[21] = 0x00;
 			p[22] = 0x25;
 			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),
 				// 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).
 				// a blocked ON must not set it (nothing reached the controller -> no spurious clicks).
 				if (rid == 0x82)
 				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)
 			// track from RELAYED frames only (see the OUTPUT path)
 			if (haptic82)
 			if (haptic82)
-				haptic82HostReport(pl, len, slot);
+				haptic82HostReport(pl, len);
 		}
 		}
 	}
 	}
 	if (Serial.availableForWrite() > 80) {
 	if (Serial.availableForWrite() > 80) {
@@ -475,7 +475,7 @@ static void wakeNudgeTask()
 		hid_mouse_report_t m;
 		hid_mouse_report_t m;
 		m.buttons = 0;
 		m.buttons = 0;
 		m.x = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX :
 		m.x = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX :
-					     -NUDGE_JIGGLE_PX;
+					      -NUDGE_JIGGLE_PX;
 		m.y = 0;
 		m.y = 0;
 		m.wheel = 0;
 		m.wheel = 0;
 		m.pan = 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.
 	// slot. The real puck's per-slot edge-triggered 0x79 prevents re-triggering Steam's connect-chime loop.
 	static bool usbConn[NSLOT] = { 0 };
 	static bool usbConn[NSLOT] = { 0 };
 	static unsigned long last79[NSLOT] = { 0 }, last7B[NSLOT] = { 0 },
 	static unsigned long last79[NSLOT] = { 0 }, last7B[NSLOT] = { 0 },
-			      connEdgeMs[NSLOT] = { 0 };
+			     connEdgeMs[NSLOT] = { 0 };
 	for (int s = 0; s < NSLOT; s++) {
 	for (int s = 0; s < NSLOT; s++) {
 		if (!g_slot[s].used || !hid[s].ready())
 		if (!g_slot[s].used || !hid[s].ready())
 			continue;
 			continue;
@@ -522,7 +522,8 @@ void SteamPuckController::task()
 		// Steam consumes 0x45 -> a loop of connect-time haptic buzzes. Resending until acked still covers
 		// Steam consumes 0x45 -> a loop of connect-time haptic buzzes. Resending until acked still covers
 		// "Steam missed the edge".
 		// "Steam missed the edge".
 		bool steamAcked = g_steamAliveMs &&
 		bool steamAcked = g_steamAliveMs &&
-				  (int32_t)(g_steamAliveMs - connEdgeMs[s]) >= 0;
+				  (int32_t)(g_steamAliveMs - connEdgeMs[s]) >=
+					  0;
 		if (conn != usbConn[s] ||
 		if (conn != usbConn[s] ||
 		    (conn && !steamAcked && millis() - last79[s] >= 750)) {
 		    (conn && !steamAcked && millis() - last79[s] >= 750)) {
 			if (conn && !usbConn[s])
 			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 =
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 							&rfrx[idx + 2];
 						bool fresh =
 						bool fresh =
-							(rep[1] != g_lastSeq[g_curSlot]);
+							(rep[1] !=
+							 g_lastSeq[g_curSlot]);
 						// genuine new report vs stale poll-repeat
 						// genuine new report vs stale poll-repeat
 						if (fresh) {
 						if (fresh) {
 							g_stNew++;
 							g_stNew++;
-							g_lastSeq[g_curSlot] = rep[1];
+							g_lastSeq[g_curSlot] =
+								rep[1];
 						}
 						}
 						uint32_t bb = btnsOf(rep);
 						uint32_t bb = btnsOf(rep);
 						// USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
 						// 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.
 						// 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) {
 							if (fresh) {
 								bool steamNow =
 								bool steamNow =
 									(bb &
 									(bb &
 									 TB_STEAM) !=
 									 TB_STEAM) !=
 									0;
 									0;
-								// rising edge: record press time
 								if (steamNow &&
 								if (steamNow &&
-								    !steamWasDown)
-									steamDownMs =
+								    !steamWasDown
+									    [g_curSlot])
+									steamDownMs[g_curSlot] =
 										millis();
 										millis();
-								// falling edge within 1 s -> short press -> wake
 								if (!steamNow &&
 								if (!steamNow &&
-								    steamWasDown &&
-								    millis() - steamDownMs <
+								    steamWasDown
+									    [g_curSlot] &&
+								    millis() - steamDownMs[g_curSlot] <
 									    1000u &&
 									    1000u &&
 								    USBDevice
 								    USBDevice
 									    .suspended()) {
 									    .suspended()) {
 									USBDevice
 									USBDevice
 										.remoteWakeup();
 										.remoteWakeup();
 									ledWakePulse();
 									ledWakePulse();
-									// post-resume nudge (host needs real input to actually wake)
 									if (g_active)
 									if (g_active)
 										g_active->wakeEvent();
 										g_active->wakeEvent();
 								}
 								}
-								steamWasDown =
+								steamWasDown[g_curSlot] =
 									steamNow;
 									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.
 							// used slot, so the timer never reaches 2s.
 							static unsigned long
 							static unsigned long
 								offHoldMs[NSLOT] = {
 								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 |
 							if ((bb & (TB_STEAM |
 								   TB_Y)) ==
 								   TB_Y)) ==
 							    (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 !=
 								if (g_usbMode !=
 								    MODE_STEAM) {
 								    MODE_STEAM) {
 									// stream modes read g_in
 									// stream modes read g_in
-									g_in[g_curSlot].buttons &=
+									g_in[g_curSlot]
+										.buttons &=
 										~(uint32_t)(TB_STEAM |
 										~(uint32_t)(TB_STEAM |
 											    TB_Y);
 											    TB_Y);
 									// push modes read btnsOf(rep): TB_Y in rep[2],
 									// 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);
 											   16);
 								}
 								}
 							} else {
 							} else {
-								offHoldMs[g_curSlot] = 0;
+								offHoldMs[g_curSlot] =
+									0;
 								offFired[g_curSlot] =
 								offFired[g_curSlot] =
 									false;
 									false;
 							}
 							}
 						}
 						}
 						g_in[g_curSlot].lx =
 						g_in[g_curSlot].lx =
 							(int16_t)s16off(rep, 8);
 							(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 =
 						g_in[g_curSlot].lt =
 							trigU8(u16off(rep, 4));
 							trigU8(u16off(rep, 4));
 						// for the Switch digital-trigger threshold
 						// for the Switch digital-trigger threshold
 						g_in[g_curSlot].rt =
 						g_in[g_curSlot].rt =
 							trigU8(u16off(rep, 6));
 							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
 						// 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
 						// 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
 						// 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 &&
 						if (tlen >= 46 &&
 						    (size_t)(idx + 2) + 46 <=
 						    (size_t)(idx + 2) + 46 <=
 							    (size_t)end)
 							    (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
 						// 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.
 						// intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
 						if ((bb & CHORD_BACK4) ==
 						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.
 						// g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
 						if (g_active)
 						if (g_active)
 							g_active->onReport45(
 							g_active->onReport45(
-								g_curSlot, rep, fresh,
-								tlen);
+								g_curSlot, rep,
+								fresh, tlen);
 						lastRep = rep;
 						lastRep = rep;
 						lastTlen = tlen;
 						lastTlen = tlen;
 					} else if (ttype == 6 &&
 					} 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 =
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 							&rfrx[idx + 2];
 						// 0x43 body[1] (~0x5e=94) reads as battery % (sniff-derived)
 						// 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_curSlot < NSLOT)
-							g_battery[g_curSlot] = rep[2];
+							g_battery[g_curSlot] =
+								rep[2];
 						if (g_active)
 						if (g_active)
 							g_active->onAuxReport(
 							g_active->onAuxReport(
-								g_curSlot, rep[0], rep + 1,
+								g_curSlot,
+								rep[0], rep + 1,
 								(uint8_t)(tlen -
 								(uint8_t)(tlen -
 									  1));
 									  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
 					// slots' non-chord reports reset the counter on every iteration. The counter could never
 					// reach 12 with multiple controllers, making the chord effectively dead.
 					// reach 12 with multiple controllers, making the chord effectively dead.
 					static uint8_t chWant[NSLOT] = {
 					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;
 					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;
 							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];
 							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];
 							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];
 							want = g_chordBtn[2];
 					}
 					}
-					if (want != 0xFF && want == chWant[g_curSlot]) {
+					if (want != 0xFF &&
+					    want == chWant[g_curSlot]) {
 						if (++chCnt[g_curSlot] >= 12 &&
 						if (++chCnt[g_curSlot] >= 12 &&
 						    want != g_usbMode &&
 						    want != g_usbMode &&
 						    modeValid(want) &&
 						    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.
 // and consumed by the decode (g_in[g_curSlot]) and the haptic flush.
 static void rfConnStep()
 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 start = (g_curSlot < 0) ? 0 : ((g_curSlot + 1) % NSLOT);
 	int slot = -1;
 	int slot = -1;
 	for (int k = 0; k < NSLOT; k++) {
 	for (int k = 0; k < NSLOT; k++) {

+ 35 - 26
OpenPuck/serial_console.cpp

@@ -299,41 +299,50 @@ void serialConsolePoll()
 			} else if (line[0] == 'U') {
 			} else if (line[0] == 'U') {
 				// Per-slot debug dump: which controllers are connected, their RSSI, haptic-block
 				// 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.
 				// 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++) {
 				for (int s = 0; s < NSLOT; s++) {
 					bool live = g_slot[s].used &&
 					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(
 					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",
 						"# 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,
 						s,
 						g_slot[s].used ?
 						g_slot[s].used ?
-							(live ? "LIVE" : "BONDED") :
+							(live ? "LIVE" :
+								"BONDED") :
 							"empty",
 							"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_sessPrefix[s], rssi, age,
 						g_battery[s], blk,
 						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') {
 			} else if (line[0] == 'r') {
 				g_rxWin = strtoul(line + 1, 0, 10);
 				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) ----
 // ---- 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
 // 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].
 // g_in[slot] inside onReport45/onAuxReport to forward to hid[slot].
 struct PuckInput {
 struct PuckInput {
 	// raw Triton buttons (TB_*); per-mode builders apply their own chord masking
 	// 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
 	// 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.
 	// every ~250ms in normal use.
 	int cs = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	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];
 	uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 	p[1] = WB_PAYLEN;
@@ -73,9 +72,10 @@ static void webusbSendBlob()
 	p[36] = (uint8_t)(g_pollPeriodUs >>
 	p[36] = (uint8_t)(g_pollPeriodUs >>
 			  8); // measured poll period (intended 4000)
 			  8); // measured poll period (intended 4000)
 	p[37] = OPK_LOG; // logging build? panel shows/hides its log UI
 	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 ?
 	p[39] = g_linkRssi[g_curSlot >= 0 && g_curSlot < NSLOT ?
 				   g_curSlot :
 				   g_curSlot :
 				   0]; // RAW signal strength |dBm| (0=no sample)
 				   0]; // RAW signal strength |dBm| (0=no sample)
@@ -169,7 +169,7 @@ void webusbPoll()
 				       (op == 0x03 || op == 0x05) ? 2 :
 				       (op == 0x03 || op == 0x05) ? 2 :
 				       (op == 0x0A)		  ? 4 :
 				       (op == 0x0A)		  ? 4 :
 								    1;
 								    1;
-			if (op < 0x01 || op > 0x0A) { // resync: drop one byte
+			if (op < 0x01 || op > 0x0C) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				memmove(buf, buf + 1, --n);
 				continue;
 				continue;
 			}
 			}
@@ -209,6 +209,19 @@ void webusbPoll()
 					delay(40);
 					delay(40);
 					NVIC_SystemReset();
 					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) {
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 				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
 //   host->dev:  0x01                 GET  -> reply status blob
 //               0x02 <field> <value> SET one byte field (see the switch in webusb_config.cpp)
 //               0x02 <field> <value> SET one byte field (see the switch in webusb_config.cpp)
 //               0x03 <mode>          switch USB mode (0..6): persist + reboot
 //               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)
 //   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.
 // No setLandingPage() on purpose -- it would pop a Chrome "open <url>?" notification on every plug-in.
 #pragma once
 #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>
       <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>
         <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>
+      <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">
       <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>
         <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>
         <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;
 $("#capStop").onclick=stopCapture;
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
 $("#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"); } };
+$("#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()=>{
 $("#factoryErase").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
   if(!dev){ log("not connected"); return; }
   // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.
   // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.