소스 검색

Multiple controllers (#66)

* feat: support connecting multiple controllers to the puck

* fix: emulate multiple controllers in non-steam modes

* fix: 4 back chords weren't working on all controllers

* limited slots for limited controllers + polling rate fix

* Hopefully more stable connection

* more fixes

* Align discovery-beacon cadence to main (fix multi-ctrl link churn)

The merge left the branch's RF behavior matching main EXCEPT one
air-budget deviation: the ch2 discovery beacon ran every 40ms whenever a
controller was connected but another bonded slot's controller was off
(allUp=false), vs main's 200ms. ch2 discovery shares g_sessCh's air time
while a controller is linked, so the extra ~25 beacons/s stole reply
windows from the connected controller -> reply-rate sag -> >1500ms reply
gaps -> hapticOnReconnect re-fired its re-init burst (the spurious
"onesey twosey" clicks) + link churn.

Restore main's exact cadence: every-loop when nothing is connected,
200ms once any slot is linked. A late joiner still enumerates in ~1s.

With this, every remaining difference from main reduces to identical
single-controller behavior (poll first slot; F2 cooldown; unique
per-puck session address) -- the only true divergences left are the
per-bond addresses and per-cycle multi-slot polling that simultaneous
controllers fundamentally require.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jariullah Safi <jari@simberobotics.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Josh LeBlanc 4 달 전
부모
커밋
bffa2410ef

+ 27 - 8
OpenPuck/OpenPuck.ino

@@ -47,8 +47,9 @@ void setup()
 	genSerial();
 	ledInit();
 
-	// per-device unique RF session address (advertised in the host frame; isolates pucks)
-	rfGenSessionAddr();
+	// seed defaults so unbonded slots don't share the discovery address
+	for (int s = 0; s < NSLOT; s++)
+		rfGenSessionAddr(s);
 	InternalFS.begin();
 #if OPK_FACTORY_RESET
 	// Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
@@ -56,6 +57,11 @@ void setup()
 	factoryResetOnce(OPK_GIT_HASH);
 #endif
 	loadCfg();
+	loadBonds();
+	// 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);
 
 	// decide USB presentation BEFORE registering interfaces
 	g_xbox = !modeIsPuck(g_usbMode);
@@ -97,8 +103,10 @@ void setup()
 	if (puckMode) {
 		USBDevice.setSerialDescriptor(g_unit);
 	} else {
-		snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit,
-			 MODE_SUFFIX[g_usbMode - 1]);
+		// Bond count in serial so Windows invalidates its cached config descriptor if the count changes.
+		snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%d", g_unit,
+			 MODE_SUFFIX[g_usbMode - 1],
+			 bondedSlotCount() > 0 ? bondedSlotCount() : 1);
 		USBDevice.setSerialDescriptor(g_usbSerial);
 	}
 
@@ -133,7 +141,6 @@ void setup()
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
-	loadBonds();
 	hapticInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
@@ -152,9 +159,21 @@ void setup()
 				"DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
 				"normal (CDC off, wake mouse on)");
 	Serial.printf(
-		"# session addr %02X%02X%02X%02X/%02X ch%u (discovery on ibex/ch2)\n",
-		g_sessBase[0], g_sessBase[1], g_sessBase[2], g_sessBase[3],
-		g_sessPrefix, g_sessCh);
+		"# session ch%u (discovery on ibex/ch2); per-slot session addrs:\n",
+		g_sessCh);
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used) {
+			Serial.printf(
+				"#   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]);
+		}
+	}
 	// Hardware watchdog: if loop() ever stops feeding it (wedged radio busy-wait, HardFault spin, blocked CDC
 	// write) the WDT resets the nRF52 after ~8s, re-enumerating USB + re-initialising RF, so a hang no longer
 	// needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.

+ 10 - 2
OpenPuck/bonds.cpp

@@ -4,8 +4,7 @@
 using namespace Adafruit_LittleFS_Namespace;
 
 Slot g_slot[NSLOT];
-int g_connSlot = -1;
-unsigned long g_connReplyMs = 0;
+unsigned long g_connReplyMs[NSLOT] = { 0 };
 volatile bool g_dirty = false;
 bool g_pairing = false;
 
@@ -46,3 +45,12 @@ void loadBonds()
 		}
 	f.close();
 }
+
+int bondedSlotCount()
+{
+	int n = 0;
+	for (int i = 0; i < NSLOT; i++)
+		if (g_slot[i].used)
+			n++;
+	return n;
+}

+ 8 - 6
OpenPuck/bonds.h

@@ -3,8 +3,8 @@
 // The real puck holds four controller bonds and exposes one HID control interface per slot (interface N owns
 // slot N). We mirror that: g_slot[N] holds the bond record [8 uuid][16 serial] plus a staging buffer for that
 // interface's pending feature-report reply (filled by puck_hid.cpp's command channel). Bonds persist to
-// flash (bonds.bin). g_connSlot / g_connReplyMs are the live RF link state, shared here so the USB feature
-// handler can report per-slot connection status without depending on the RF layer.
+// flash (bonds.bin). g_connReplyMs (per-slot, rf_link sets the active one) is the live RF link state, shared
+// here so the USB feature handler can report per-slot connection status without depending on the RF layer.
 #pragma once
 #include <stdint.h>
 
@@ -19,13 +19,15 @@ struct Slot {
 };
 extern Slot g_slot[NSLOT];
 
-// bonded slot being polled (== the USB-connected slot); -1 = none
-extern int g_connSlot;
-extern unsigned long
-	g_connReplyMs; // millis of last RF reply (link-alive timestamp)
+// Per-slot link state. g_connReplyMs[i] is the millis() of the last F-type reply on slot i (the
+// "controller is alive" timestamp). g_linkRssi (rf_link.h) is also per-slot. Steam reads each slot's
+// B4/0x79/0x7B against its own entry here, so the four interfaces present four independent controllers
+// (each marked connected on its own).
+extern unsigned long g_connReplyMs[NSLOT];
 extern volatile bool g_dirty; // bonds changed -> flush to flash from loop()
 extern bool g_pairing;
 
 bool recEmpty(const uint8_t *r);
 void loadBonds();
 void saveBonds();
+int bondedSlotCount();

+ 4 - 0
OpenPuck/config.h

@@ -101,6 +101,10 @@ void swProSaveCfg();
 #define POLL_US_DEFAULT 4000u
 // host-side HID stream cadence for translated modes (~250 Hz)
 #define USB_STREAM_MS 4u
+// How long a USB suspend must PERSIST before we power the controllers off. A brief selective-suspend
+// (host idle power-management) resumes in <1s and must not trigger a self-inflicted power-off -> the
+// resulting disconnect/reconnect churn looked like random controller drops. Real host sleep persists.
+#define SUSPEND_OFF_MS 4000u
 // RF poll cadence (us). FIXED -- not configurable (see loadCfg).
 extern const uint32_t g_pollUs;
 

+ 10 - 5
OpenPuck/controllers.h

@@ -5,9 +5,11 @@
 //   begin()       -- called once from setup(): set the USB VID/PID/strings and register this mode's
 //                    interface(s). Shared USB lifecycle (detach, clear/keep CDC, serial suffix, WebUSB,
 //                    attach) stays in setup(); begin() only adds what's specific to this controller.
-//   onReport45()  -- called from rf_link.cpp each time a controller input report 0x45 is decoded. PUSH-style
-//                    modes (Xbox, puck/lizard) build + send their host report here. STREAM-style modes ignore
-//                    it and emit at a fixed cadence from task(); they read the decoded g_in directly.
+//   onReport45()  -- called from rf_link.cpp each time a controller input report 0x45 is decoded. The `slot`
+//                    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`; 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()).
 //
@@ -22,9 +24,10 @@ class IController {
 	{
 	}
 	virtual void begin() = 0;
-	virtual void onReport45(const uint8_t *rep, bool fresh,
+	virtual void onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 	{
+		(void)slot;
 		(void)rep;
 		(void)fresh;
 		(void)bodyTlen;
@@ -32,8 +35,10 @@ class IController {
 	// Other controller->host input reports decoded from the F1 reply (NOT 0x45): power/battery status report
 	// 0x43, status event 0x44. The real puck forwards these to Steam verbatim -- that's how Steam reads battery.
 	// rid = report id, data/n = body after the id. Default no-op (clean modes don't expose these reports).
-	virtual void onAuxReport(uint8_t rid, const uint8_t *data, uint8_t n)
+	virtual void onAuxReport(int slot, uint8_t rid, const uint8_t *data,
+				 uint8_t n)
 	{
+		(void)slot;
 		(void)rid;
 		(void)data;
 		(void)n;

+ 5 - 4
OpenPuck/gamepad_util.cpp

@@ -181,12 +181,13 @@ uint32_t psButtonsFromSteam(uint32_t raw)
 		psOrBackCode(&b, g_back[3]);
 	return b;
 }
-// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start)
-uint8_t psShouldersByte(uint32_t b)
+// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start). The analog trigger values come from
+// the per-slot `g_in[slot]` (pass lt/rt explicitly so a single shoulder byte never leaks across slots).
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt)
 {
 	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
-	       ((g_in.lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
-	       ((g_in.rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
+	       ((lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
+	       ((rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
 	       ((b & TB_MENU) ? 0x10 : 0) |
 	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
 	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);

+ 3 - 1
OpenPuck/gamepad_util.h

@@ -86,6 +86,8 @@ static inline uint32_t tritonFromCode(uint8_t c)
 // PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
 uint32_t psButtonsFromSteam(
 	uint32_t raw); // apply back-paddle + chord-guard + QAM remapping
-uint8_t psShouldersByte(uint32_t b); // L1..R3 + Create/Options byte
+// L1..R3 + Create/Options byte. lt/rt are the per-slot analog trigger values so the digital ZL/ZR trip
+// doesn't leak across slots when several slots are active.
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt);
 uint8_t psHatNibble(uint32_t b); // d-pad -> 8-way hat (8 = neutral)
 uint8_t psFaceNibble(uint32_t b); // face buttons (with A/B + X/Y swap)

+ 189 - 165
OpenPuck/haptics.cpp

@@ -14,8 +14,8 @@ uint8_t g_relayOp = 0xE3; // E3 poll
 uint8_t g_relaySub = 0x05;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
-unsigned long g_hapticBlockUntil =
-	0; // drop Steam haptics briefly during reconnect settle
+// Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
+unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 
 // Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck: Steam's "turn off
 // controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The dongle
@@ -25,8 +25,11 @@ unsigned long g_hapticBlockUntil =
 void hapticSendShutdown()
 {
 	static const uint8_t OFF[4] = { 0x6f, 0x66, 0x66, 0x21 }; // "off!"
+	// broadcast: every connected controller should power off, not just the slot the shutdown was triggered
+	// from (the trigger can come from a Steam interface, the test button, or host-suspend -- any of which
+	// "logically" means "all controllers off").
 	for (uint8_t i = 0; i < HAPTIC_SHUTDOWN_SHOTS; i++)
-		relayEnqueue(0x9F, OFF, sizeof OFF);
+		relayEnqueue(0x9F, OFF, sizeof OFF, 0xFF);
 }
 
 // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
@@ -35,11 +38,12 @@ static unsigned long g_haptic82Ms = 0;
 // a non-zero 0x82 haptic is currently active (awaiting host stop)
 static bool g_haptic82On = false;
 
-// millis of last translated host rumble (0x80)
-static unsigned long g_rumble80Ms = 0;
+// millis of last translated host rumble (0x80), per-slot (4 XInput interfaces each have their own stream)
+static unsigned long g_rumble80Ms[NSLOT] = { 0 };
 
-// Steam/Triton rumble is latched on until an explicit zero report
-static bool g_rumble80On = false;
+// Steam/Triton rumble is latched on until an explicit zero report; tracked per-slot so each controller's
+// stuck-rumble watchdog is independent
+static bool g_rumble80On[NSLOT] = { false, false, false, false };
 
 // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
 static unsigned long g_reinitAt = 0;
@@ -51,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;
 	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);
@@ -71,28 +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)
+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;
-	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;
@@ -138,8 +148,8 @@ void hapticDumpLog()
 {
 	const uint16_t N = HAPLOG_N;
 	uint32_t now = millis();
-	Serial.printf("# --- capture history (now=%lu, connSlot=%d) ---\n",
-		      (unsigned long)now, g_connSlot);
+	Serial.printf("# --- capture history (now=%lu, curSlot=%d) ---\n",
+		      (unsigned long)now, g_curSlot);
 	for (uint16_t i = 0; i < N; i++) {
 		HapLog &e = g_hapLog[(uint16_t)((g_hapHead + i) % N)];
 		if (!e.ms && !e.rid)
@@ -182,19 +192,28 @@ bool hapLogPull(uint32_t *logMs, uint8_t *slot, uint8_t *rid, uint8_t *n,
 }
 #endif // OPK_LOG
 
-bool hapticLinkUp()
+// Per-slot helpers. slot==-1 (default) checks the CURRENT poll slot (g_curSlot), used by flush-time code
+// paths that don't have a slot in hand. Callers with a real slot pass it in.
+bool hapticLinkUp(int slot)
 {
-	return g_connSlot >= 0 && (millis() - g_connReplyMs) < 300;
+	int s = (slot >= 0) ? slot : g_curSlot;
+	if (s < 0 || s >= NSLOT)
+		return false;
+	return g_slot[s].used && (millis() - g_connReplyMs[s]) < 300;
 }
-bool haptic82Blocked()
+bool haptic82Blocked(int slot)
 {
-	return !hapticLinkUp() ||
-	       (g_hapticBlockUntil &&
-		(int32_t)(millis() - g_hapticBlockUntil) < 0);
+	int s = (slot >= 0) ? slot : g_curSlot;
+	if (s < 0 || s >= NSLOT)
+		return true;
+	return !hapticLinkUp(s) ||
+	       (g_hapticBlockUntil[s] &&
+		(int32_t)(millis() - g_hapticBlockUntil[s]) < 0);
 }
+// "Is haptics from this USB interface's slot allowed through?" -- the slot must be currently connected.
 bool hapticRelaySlotOk(int slot)
 {
-	return g_connSlot >= 0 && slot == g_connSlot;
+	return slot >= 0 && slot < NSLOT && hapticLinkUp(slot);
 }
 static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
 {
@@ -208,30 +227,32 @@ 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);
@@ -248,8 +269,10 @@ void haptic82HostReport(const uint8_t *p, uint16_t n)
 	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
 	g_haptic82On = haptic82PayloadOn(p, n);
 }
-bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 {
+	if (slot >= NSLOT)
+		return false;
 	// user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
 	if (g_rumbleScale != 100) {
 		uint32_t l = (uint32_t)lowFreq * g_rumbleScale / 100,
@@ -258,9 +281,12 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}
 	bool on = lowFreq || highFreq;
-	if (on && haptic82Blocked())
-		return false; // same settle gate as native Steam haptics
-	if (!on && !hapticLinkUp())
+	// Per-slot settle gate (the per-slot reconnect block + link-up check). 0x82 haptics in Steam mode use the
+	// same gate; for XInput, the host only sends a stream while a controller is connected, so this also doubles
+	// as "no controller here, no relay".
+	if (on && haptic82Blocked(slot))
+		return false;
+	if (!on && !hapticLinkUp(slot))
 		return false;
 
 	// SDL's current Steam/Triton structs define output report 0x80 as:
@@ -279,13 +305,16 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
 	p[6] = (uint8_t)(highFreq & 0xFF);
 	p[7] = (uint8_t)(highFreq >> 8);
 	p[8] = 0;
-	if (!relayEnqueue(0x80, p, sizeof p))
+	if (!relayEnqueue(0x80, p, sizeof p, slot))
 		return false;
-	g_rumble80Ms = millis();
-	g_rumble80On = on;
+	g_rumble80Ms[slot] = millis();
+	g_rumble80On[slot] = on;
 	return true;
 }
-// Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
+// Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate). Test
+// haptics broadcast to all connected slots (slot 0xFF); the stop frame is broadcast too (a stuck latch can
+// affect any controller, and the haptic-engine clear-re-init is settings-only so it's harmless on healthy
+// ones).
 void rfConnQueueHapticRelay()
 {
 	if (relayPending())
@@ -293,34 +322,34 @@ void rfConnQueueHapticRelay()
 	static const uint8_t HAP_ON[3] = { 0x01, 0x01, 0xF7 };
 	static const uint8_t HAP_OFF[3] = { 0x01, 0x01, 0x00 };
 	if (g_testHaptic) {
-		if (relayEnqueue(0x82, HAP_ON, 3))
+		if (relayEnqueue(0x82, HAP_ON, 3, 0xFF))
 			g_testHaptic--;
 	} else if (g_hapticStop && !g_xbox) {
-		if (relayEnqueue(0x82, HAP_OFF, 3))
+		if (relayEnqueue(0x82, HAP_OFF, 3, 0xFF))
 			g_hapticStop--;
 	}
 }
+// 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)
 {
-	while (g_rqTail != g_rqHead) {
-		RelayMsg &m = g_rq[g_rqTail];
+	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	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) {
 			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);
@@ -341,32 +370,23 @@ 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.)
-void hapticReinit()
+// 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,
 				       0x00, 0x08, 0x07, 0x00, 0x31,
@@ -382,100 +402,104 @@ void hapticReinit()
 		0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
 	};
 	// reset action (FUN_0001f554) -- Steam sends this first
-	relayEnqueue(0x81, nullptr, 0);
-	relayEnqueue(0x87, H30, sizeof H30);
+	relayEnqueue(0x81, nullptr, 0, slot);
+	relayEnqueue(0x87, H30, sizeof H30, slot);
 
 	// haptic config (enabled/amplifier/gain): the part that clears a latch
-	relayEnqueue(0x87, H18, sizeof H18);
-	relayEnqueue(0x87, H35, sizeof H35);
-	relayEnqueue(0x81, T81A, sizeof T81A);
-	relayEnqueue(0x81, T81B, sizeof T81B);
+	relayEnqueue(0x87, H18, sizeof H18, slot);
+	relayEnqueue(0x87, H35, sizeof H35, slot);
+	relayEnqueue(0x81, T81A, sizeof T81A, slot);
+	relayEnqueue(0x81, T81B, sizeof T81B, slot);
 }
 void hapticInit()
 {
-	g_rqHead = g_rqTail = 0;
 	g_haptic82On = false;
-	g_rumble80On = false;
-	// boot: block stale Steam 0x82 until link stable
-	g_hapticBlockUntil = 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;
+	for (int s = 0; s < NSLOT; s++) {
+		g_rqHead[s] = g_rqTail[s] = 0;
+		g_rumble80On[s] = false;
+		g_rumble80Ms[s] = 0;
+		// block stale Steam 0x82 until the link is stable after boot
+		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	}
 }
-// 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.
-void hapticOnReconnect()
+// 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)
 {
-	// no haptics relayed for the next 3s
-	g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_haptic82On = false;
-	g_rumble80On = false;
-
-	// drop any haptic ON queued before the link came up
+	g_rumble80On[slot] = false;
+	g_rumble80Ms[slot] = 0;
 	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()
 {
-	static bool wasHapticLinkUp = false;
-	bool up = hapticLinkUp();
-	// Link-edge markers are diagnostic only -- the block/re-init is armed reliably from rf_link
-	// (hapticOnReconnect) on the first reply after a gap, which fires even when this 300ms edge doesn't.
-	if (up && !wasHapticLinkUp) {
-		uint8_t mk = 1;
-		hapLogAdd(0xFD, 0xEE, &mk, 1);
-		hapticOnReconnect();
-	}
-	if (!up && wasHapticLinkUp) {
-		uint8_t mk = 0;
-		hapLogAdd(0xFD, 0xEE, &mk, 1);
+	// 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)
+			continue;
+		bool up = hapticLinkUp(s);
+		if (up && !wasHapticLinkUp[s]) {
+			uint8_t mk = 1;
+			hapLogAdd(0xFD, 0xEE, &mk, 1);
+			hapticOnReconnect(s);
+		}
+		if (!up && wasHapticLinkUp[s]) {
+			uint8_t mk = 0;
+			hapLogAdd(0xFD, 0xEE, &mk, 1);
+		}
+		wasHapticLinkUp[s] = up;
 	}
-	wasHapticLinkUp = up;
-	if (g_reinitAt && up &&
-	    (int32_t)(millis() - g_reinitAt) >=
-		    0) { // proactive haptic re-init across the connect window
+	if (g_reinitAt && anySlotLinkUp() &&
+	    (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) AND the suspend has PERSISTED >= SUSPEND_OFF_MS. A brief USB
+	// selective-suspend (host idle power-management) resumes in <1s; firing the power-off on its edge
+	// powered the controllers off ourselves -> random drop/reconnect churn. Arm only on a genuine
+	// resume->suspend edge (wasSusp=true at boot suppresses a false fire on boot-into-suspended).
 	static bool wasSusp = true;
+	static unsigned long suspSinceMs = 0;
+	static bool suspArmed = false;
 	bool susp = USBDevice.suspended();
 	bool vbus = (NRF_POWER->USBREGSTATUS &
 		     POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
-	if (susp && !wasSusp && vbus)
+	if (susp && !wasSusp) {
+		suspSinceMs = millis();
+		suspArmed = true;
+	}
+	if (!susp)
+		suspArmed = false;
+	if (suspArmed && vbus && (millis() - suspSinceMs) >= SUSPEND_OFF_MS) {
 		hapticSendShutdown();
+		suspArmed = false; // fire once per suspend
+	}
 	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;
-	if (g_rumble80On && millis() - g_rumble80Ms > 2500u)
-		hapticSteamRumble(0, 0);
-	// 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).
+	// 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);
+	}
+	// 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;

+ 22 - 17
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
@@ -18,6 +16,7 @@
 #pragma once
 #include <stdint.h>
 #include "config.h" // OPK_LOG
+#include "bonds.h" // NSLOT
 
 // after this much host silence, consider the current 0x82 haptic stream inactive
 #define HAPTIC_QUIET_MS 300u
@@ -42,9 +41,10 @@
 #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). ISR-safe (brief PRIMASK critical section). Returns false (dropped) when the ring is full.
-bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen);
+// 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);
 
 // anything still queued (xinput uses it to pace rumble re-queues)
 bool relayPending();
@@ -53,7 +53,8 @@ extern uint8_t g_relaySub; // relay sub-TLV type byte = SET
 extern volatile uint8_t g_testHaptic; // 't<n>' injects n test haptics
 // pending haptic-STOP frames to relay (kill a latched whine)
 extern volatile uint8_t g_hapticStop;
-extern unsigned long g_hapticBlockUntil;
+// Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for HAPTIC_RECONNECT_BLOCK_MS.
+extern unsigned long g_hapticBlockUntil[NSLOT];
 
 // relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
 void hapticSendShutdown();
@@ -85,13 +86,14 @@ static inline bool hapLogPull(uint32_t *, uint8_t *, uint8_t *, uint8_t *,
 }
 #endif
 
-bool hapticLinkUp();
-bool haptic82Blocked();
+bool hapticLinkUp(int slot = -1);
+bool haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
 void haptic82HostReport(const uint8_t *p, uint16_t n);
-bool hapticSteamRumble(
-	uint16_t lowFreq,
-	uint16_t highFreq); // queue Steam/Triton output report 0x80 rumble
+// 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).
+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
@@ -105,9 +107,12 @@ void hapticInit();
 // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
 void hapticTask();
 
-// replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz
-void hapticReinit();
+// replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz.
+// `slot` defaults to 0xFF (broadcast to all connected) -- the 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.
+void hapticReinit(uint8_t slot = 0xFF);
 // Called from rf_link the instant a controller (re)connect is detected (an F-reply after a gap): blocks haptic
 // relays for HAPTIC_RECONNECT_BLOCK_MS and schedules a re-init just after, to keep the freshly-booted
 // controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
-void hapticOnReconnect();
+// Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
+void hapticOnReconnect(int slot);

+ 125 - 74
OpenPuck/mode_hidgyro.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -10,35 +11,47 @@
 HidGyroController g_hidGyroCtl;
 
 static const uint8_t GYRO_HID_DESC[] = {
-	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
-	0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
-	0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
-	0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
-	0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
-	0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
-	0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
-	0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
-	0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
-	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
-	0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09,
+	0x31, 0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x25, 0x07, 0x75, 0x04,
+	0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29,
+	0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0E, 0x81, 0x02,
+	0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95, 0x01, 0x15, 0x00,
+	0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09, 0x34, 0x15,
+	0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, 0x06,
+	0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
+	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24,
+	0xB1, 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
 };
 #define DS4_TOUCH_H 942
 #define DS4_STATUS_USB 0x1B // cable + level 11 (full)
-static unsigned long g_gyroLastMs = 0;
-static Adafruit_USBD_HID g_hidGyro;
+static unsigned long g_gyroLastMs[NSLOT] = { 0 };
+// NSLOT HID instances: 4 DS4 gamepads on the wire, one per bond slot. The host enumerates each as a
+// separate input device (hid-playstation, hid-sony, SDL, Steam Input).
+static Adafruit_USBD_HID g_hidGyro[NSLOT];
 
-static const uint8_t DS4_MAC[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, 0x53 };
+// Per-slot MAC base: 4 distinct NICs so the host sees 4 different devices (real DS4s have unique MACs).
+// OUI 0x001BDC is Sony's; vary the last byte per slot.
+static const uint8_t DS4_MAC_BASE[5] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55 };
+static uint8_t g_ds4Mac[NSLOT][6];
+static bool g_ds4MacInit = false;
+static void initDs4Macs()
+{
+	if (g_ds4MacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_ds4Mac[s], DS4_MAC_BASE, 5);
+		g_ds4Mac[s][5] = (uint8_t)(0x50 + s); // 0x50, 0x51, 0x52, 0x53
+	}
+	g_ds4MacInit = true;
+}
 
-// GET_FEATURE handler. SteamOS drives the DS4 (054C:05C4) with hid-playstation (older kernels: hid-sony); both
-// issue GET_FEATURE requests during probe (calibration + MAC + firmware) and ABORT the bind if any STALLs or
-// returns the wrong length. ps_get_report enforces, on every bus: the response is the exact report size with the
-// report id as its first byte (data fields then start at buf[1]); CRC is only checked over Bluetooth. macOS/SDL
-// are lenient (re-derive), which is why the DS4 shows up there but not on a Deck. TinyUSB writes the report id
-// itself and hands us the buffer PAST it, so we fill only the PAYLOAD and return size-1. We satisfy both drivers'
-// report ids. (Sizes per drivers/hid/hid-playstation.c: 0x02=37, 0x12=16, 0xA3=49; legacy hid-sony MAC 0x81=7.)
-static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
-			   uint16_t reqlen)
+// GET_FEATURE handler. Per-slot dispatch via per-instance callback (ps5Get##N passes the slot index).
+// Sizes per drivers/hid/hid-playstation.c: 0x02=37, 0x12=16, 0xA3=49; legacy hid-sony MAC 0x81=7.
+static uint16_t hidGyroGetCommon(uint8_t slot, uint8_t rid,
+				 hid_report_type_t type, uint8_t *buf,
+				 uint16_t reqlen)
 {
+	(void)slot;
 	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
 		return 0;
 	memset(buf, 0, reqlen);
@@ -51,12 +64,12 @@ static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 	case 0x12: // pairing info / MAC, hid-playstation (16 incl id)
 		if (reqlen < 15)
 			return 0;
-		memcpy(buf, DS4_MAC, 6);
+		memcpy(buf, g_ds4Mac[slot], 6);
 		return 15;
 	case 0x81: // MAC, legacy hid-sony USB (7 incl id)
 		if (reqlen < 6)
 			return 0;
-		memcpy(buf, DS4_MAC, 6);
+		memcpy(buf, g_ds4Mac[slot], 6);
 		return 6;
 	case 0xA3: // firmware / hardware info (49 incl id)
 		if (reqlen < 48)
@@ -68,10 +81,10 @@ static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		return 0;
 	}
 }
-
-static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		       uint16_t n)
+static void hidGyroSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			     uint8_t const *b, uint16_t n)
 {
+	(void)slot;
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
 	uint8_t id;
@@ -89,48 +102,77 @@ static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	// DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
 	if (id != 0x05 || pn < 5)
 		return;
-	hapticSteamRumble((uint16_t)p[4] * 257u,
-			  (uint16_t)p[3] * 257u); // DS4: left=low, right=high
+	hapticSteamRumble((uint16_t)p[4] * 257u, (uint16_t)p[3] * 257u,
+			  slot); // DS4: left=low, right=high
 }
+// Per-slot callback trampolines -- the Adafruit HID class has ONE _get/_set pair shared across instances,
+// and the lib's tud_hid_*_cb doesn't pass the interface index to the user callback. Generate a
+// per-instance trampoline that closes over the slot index, matching the pattern in puck_hid.cpp.
+#define HIDGYROCB(N)                                                  \
+	static uint16_t hidGyroGet##N(uint8_t r, hid_report_type_t t, \
+				      uint8_t *bf, uint16_t rl)       \
+	{                                                             \
+		return hidGyroGetCommon(N, r, t, bf, rl);             \
+	}                                                             \
+	static void hidGyroSet##N(uint8_t r, hid_report_type_t t,     \
+				  uint8_t const *b, uint16_t n)       \
+	{                                                             \
+		hidGyroSetCommon(N, r, t, b, n);                      \
+	}
+// clang-format off
+HIDGYROCB(0)
+HIDGYROCB(1)
+HIDGYROCB(2)
+HIDGYROCB(3)
+// clang-format on
+typedef uint16_t (*ds4_getcb_t)(uint8_t, hid_report_type_t, uint8_t *,
+				uint16_t);
+typedef void (*ds4_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			    uint16_t);
+static ds4_getcb_t const DS4_GETCB[NSLOT] = { hidGyroGet0, hidGyroGet1,
+					      hidGyroGet2, hidGyroGet3 };
+static ds4_setcb_t const DS4_SETCB[NSLOT] = { hidGyroSet0, hidGyroSet1,
+					      hidGyroSet2, hidGyroSet3 };
 
-static void hidGyroBuild(uint8_t out[63])
+static void hidGyroBuild(uint8_t slot, uint8_t out[63])
 {
-	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
-	out[0] = swStick(g_in.lx, false);
-	out[1] = swStick(g_in.ly, true);
-	out[2] = swStick(g_in.rx, false);
-	out[3] = swStick(g_in.ry, true);
+	out[0] = swStick(g_in[slot].lx, false);
+	out[1] = swStick(g_in[slot].ly, true);
+	out[2] = swStick(g_in[slot].rx, false);
+	out[3] = swStick(g_in[slot].ry, true);
 	out[4] = psHatNibble(b) | psFaceNibble(b);
-	out[5] = psShouldersByte(b);
-	static uint8_t ctr = 0;
-	out[6] = ((ctr++ & 0x0F) << 4) |
+	out[5] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
+	static uint8_t ctr[NSLOT] = { 0 };
+	out[6] = ((ctr[slot]++ & 0x0F) << 4) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_STEAM) ? 0x01 : 0);
-	out[7] = g_in.lt;
-	out[8] = g_in.rt;
-	out[12] = g_in.gx & 0xFF;
-	out[13] = g_in.gx >> 8;
-	out[14] = g_in.gz & 0xFF;
-	out[15] = g_in.gz >> 8;
-	out[16] = (-g_in.gy) & 0xFF;
-	out[17] = (-g_in.gy) >> 8;
-	out[18] = g_in.ax & 0xFF;
-	out[19] = g_in.ax >> 8;
-	out[20] = g_in.ay & 0xFF;
-	out[21] = g_in.ay >> 8;
-	out[22] = g_in.az & 0xFF;
-	out[23] = g_in.az >> 8;
+	out[7] = g_in[slot].lt;
+	out[8] = g_in[slot].rt;
+	out[12] = g_in[slot].gx & 0xFF;
+	out[13] = g_in[slot].gx >> 8;
+	out[14] = g_in[slot].gz & 0xFF;
+	out[15] = g_in[slot].gz >> 8;
+	out[16] = (-g_in[slot].gy) & 0xFF;
+	out[17] = (-g_in[slot].gy) >> 8;
+	out[18] = g_in[slot].ax & 0xFF;
+	out[19] = g_in[slot].ax >> 8;
+	out[20] = g_in[slot].ay & 0xFF;
+	out[21] = g_in[slot].ay >> 8;
+	out[22] = g_in[slot].az & 0xFF;
+	out[23] = g_in[slot].az >> 8;
 	out[29] = DS4_STATUS_USB;
 	if (lTouch || rTouch) {
 		uint16_t lx, ly, rx, ry;
-		steamPadsToTouch(b, DS4_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx,
-				 g_in.rpy, &lx, &ly, &rx, &ry);
-		static uint8_t tstamp = 0;
+		steamPadsToTouch(b, DS4_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+				 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx,
+				 &ry);
+		static uint8_t tstamp[NSLOT] = { 0 };
 		out[32] = 1;
-		out[33] = tstamp++;
+		out[33] = tstamp[slot]++;
 		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
 	} else {
 		out[32] = 0;
@@ -144,26 +186,35 @@ void HidGyroController::begin()
 {
 	USBDevice.setID(0x054C, 0x05C4);
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setProductDescriptor("Wireless Controller");
-	g_hidGyro.enableOutEndpoint(true);
-	g_hidGyro.setReportCallback(hidGyroGet, hidGyroSet);
-	g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_hidGyro.setPollInterval(1);
-	g_hidGyro.begin();
+	initDs4Macs();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		g_hidGyro[s].enableOutEndpoint(true);
+		g_hidGyro[s].setReportCallback(DS4_GETCB[s], DS4_SETCB[s]);
+		g_hidGyro[s].setReportDescriptor(GYRO_HID_DESC,
+						 sizeof GYRO_HID_DESC);
+		g_hidGyro[s].setPollInterval(1);
+		g_hidGyro[s].begin();
+	}
 }
 void HidGyroController::task()
 {
-	if (!g_hidGyro.ready())
-		return;
-	if (millis() - g_gyroLastMs < USB_STREAM_MS)
-		return;
-	g_gyroLastMs = millis();
-	uint8_t p[63];
-	hidGyroBuild(p);
-	g_hidGyro.sendReport(0x01, p, sizeof p);
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_hidGyro[s].ready())
+			continue;
+		if (millis() - g_gyroLastMs[s] < USB_STREAM_MS)
+			continue;
+		g_gyroLastMs[s] = millis();
+		uint8_t p[63];
+		hidGyroBuild((uint8_t)s, p);
+		g_hidGyro[s].sendReport(0x01, p, sizeof p);
+	}
 }

+ 106 - 58
OpenPuck/mode_ps5.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -36,18 +37,33 @@ static const uint8_t PS5_HID_DESC[] = {
 };
 #define PS5_TOUCH_H 1080
 #define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
-static unsigned long g_ps5LastMs = 0;
-static Adafruit_USBD_HID g_ps5;
-static const uint8_t PS5_MAC[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, 0x54 };
+static unsigned long g_ps5LastMs[NSLOT] = { 0 };
+// NSLOT DualSense HID instances, one per bond slot. The host enumerates each as a separate DualSense
+// (hid-playstation on Linux/SteamOS, SDL on Windows).
+static Adafruit_USBD_HID g_ps5[NSLOT];
 
-// GET_FEATURE handler. SteamOS's hid-playstation reads calibration (0x05), pairing/MAC (0x09) and firmware
-// (0x20) during probe and ABORTS the bind unless each returns its EXACT size with the report id as the first
-// byte; CRC is checked only over Bluetooth. macOS/SDL are lenient, so the DualSense shows up there but not on a
-// Deck. TinyUSB writes the report id itself and hands us the buffer PAST it, so we fill only the PAYLOAD and
-// return size-1. Sizes per drivers/hid/hid-playstation.c: 0x05=41, 0x09=20, 0x20=64.
-static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
-		       uint16_t reqlen)
+// Per-slot MAC base: 4 distinct NICs. OUI 0x001BDC is Sony's; vary the last byte per slot.
+static const uint8_t PS5_MAC_BASE[5] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55 };
+static uint8_t g_ps5Mac[NSLOT][6];
+static bool g_ps5MacInit = false;
+static void initPs5Macs()
 {
+	if (g_ps5MacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_ps5Mac[s], PS5_MAC_BASE, 5);
+		g_ps5Mac[s][5] = (uint8_t)(0x60 + s); // 0x60, 0x61, 0x62, 0x63
+	}
+	g_ps5MacInit = true;
+}
+
+// GET_FEATURE handler. Per-slot dispatch via per-instance callback. Sizes per drivers/hid/hid-playstation.c:
+// 0x05=41, 0x09=20, 0x20=64. TinyUSB writes the report id itself and hands us the buffer PAST it, so we
+// fill only the PAYLOAD and return size-1.
+static uint16_t ps5GetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			     uint8_t *buf, uint16_t reqlen)
+{
+	(void)slot;
 	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
 		return 0;
 	memset(buf, 0, reqlen);
@@ -72,7 +88,7 @@ static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		if (reqlen < 19)
 			return 0;
 		// MAC at kernel buf[1..6] = payload[0..5]
-		memcpy(buf, PS5_MAC, 6);
+		memcpy(buf, g_ps5Mac[slot], 6);
 		return 19;
 	case 0x20: // firmware info (64 incl id)
 		if (reqlen < 63)
@@ -84,8 +100,8 @@ static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		return 0;
 	}
 }
-static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		   uint16_t n)
+static void ps5SetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			 uint8_t const *b, uint16_t n)
 {
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
@@ -108,45 +124,69 @@ static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	}
 	if (id != 0x02 || pn < 4)
 		return;
-	hapticSteamRumble((uint16_t)p[3] * 257u,
-			  (uint16_t)p[2] *
-				  257u); // DualSense: left=low, right=high
+	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u, slot);
+	// DualSense: left=low, right=high
 }
+#define PS5CB(N)                                                               \
+	static uint16_t ps5Get##N(uint8_t r, hid_report_type_t t, uint8_t *bf, \
+				  uint16_t rl)                                 \
+	{                                                                      \
+		return ps5GetCommon(N, r, t, bf, rl);                          \
+	}                                                                      \
+	static void ps5Set##N(uint8_t r, hid_report_type_t t,                  \
+			      uint8_t const *b, uint16_t n)                    \
+	{                                                                      \
+		ps5SetCommon(N, r, t, b, n);                                   \
+	}
+// clang-format off
+PS5CB(0)
+PS5CB(1)
+PS5CB(2)
+PS5CB(3)
+// clang-format on
+typedef uint16_t (*ps5_getcb_t)(uint8_t, hid_report_type_t, uint8_t *,
+				uint16_t);
+typedef void (*ps5_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			    uint16_t);
+static ps5_getcb_t const PS5_GETCB[NSLOT] = { ps5Get0, ps5Get1, ps5Get2,
+					      ps5Get3 };
+static ps5_setcb_t const PS5_SETCB[NSLOT] = { ps5Set0, ps5Set1, ps5Set2,
+					      ps5Set3 };
 
-static void ps5Build(uint8_t out[63])
+static void ps5Build(uint8_t slot, uint8_t out[63])
 {
-	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
-	out[0] = swStick(g_in.lx, false);
-	out[1] = swStick(g_in.ly, true);
-	out[2] = swStick(g_in.rx, false);
-	out[3] = swStick(g_in.ry, true);
-	out[4] = g_in.lt;
-	out[5] = g_in.rt;
-	static uint8_t seq = 0;
-	out[6] = seq++;
+	out[0] = swStick(g_in[slot].lx, false);
+	out[1] = swStick(g_in[slot].ly, true);
+	out[2] = swStick(g_in[slot].rx, false);
+	out[3] = swStick(g_in[slot].ry, true);
+	out[4] = g_in[slot].lt;
+	out[5] = g_in[slot].rt;
+	static uint8_t seq[NSLOT] = { 0 };
+	out[6] = seq[slot]++;
 	out[7] = psHatNibble(b) | psFaceNibble(b);
-	out[8] = psShouldersByte(b);
+	out[8] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_MUTE) ? 0x04 : 0);
-	out[15] = g_in.gx & 0xFF;
-	out[16] = g_in.gx >> 8;
-	out[17] = g_in.gz & 0xFF;
-	out[18] = g_in.gz >> 8;
-	out[19] = (-g_in.gy) & 0xFF;
-	out[20] = (-g_in.gy) >> 8;
-	out[21] = g_in.ax & 0xFF;
-	out[22] = g_in.ax >> 8;
-	out[23] = g_in.ay & 0xFF;
-	out[24] = g_in.ay >> 8;
-	out[25] = g_in.az & 0xFF;
-	out[26] = g_in.az >> 8;
+	out[15] = g_in[slot].gx & 0xFF;
+	out[16] = g_in[slot].gx >> 8;
+	out[17] = g_in[slot].gz & 0xFF;
+	out[18] = g_in[slot].gz >> 8;
+	out[19] = (-g_in[slot].gy) & 0xFF;
+	out[20] = (-g_in[slot].gy) >> 8;
+	out[21] = g_in[slot].ax & 0xFF;
+	out[22] = g_in[slot].ax >> 8;
+	out[23] = g_in[slot].ay & 0xFF;
+	out[24] = g_in[slot].ay >> 8;
+	out[25] = g_in[slot].az & 0xFF;
+	out[26] = g_in[slot].az >> 8;
 	uint16_t lx, ly, rx, ry;
-	steamPadsToTouch(b, PS5_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx, g_in.rpy,
-			 &lx, &ly, &rx, &ry);
+	steamPadsToTouch(b, PS5_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+			 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx, &ry);
 	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
 	out[52] = PS5_STATUS_USB;
 }
@@ -155,26 +195,34 @@ void Ps5Controller::begin()
 {
 	USBDevice.setID(0x054C, 0x0CE6);
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0110 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setProductDescriptor("DualSense Wireless Controller");
-	g_ps5.enableOutEndpoint(true);
-	g_ps5.setReportCallback(ps5Get, ps5Set);
-	g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_ps5.setPollInterval(1);
-	g_ps5.begin();
+	initPs5Macs();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		g_ps5[s].enableOutEndpoint(true);
+		g_ps5[s].setReportCallback(PS5_GETCB[s], PS5_SETCB[s]);
+		g_ps5[s].setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
+		g_ps5[s].setPollInterval(1);
+		g_ps5[s].begin();
+	}
 }
 void Ps5Controller::task()
 {
-	if (!g_ps5.ready())
-		return;
-	if (millis() - g_ps5LastMs < USB_STREAM_MS)
-		return;
-	g_ps5LastMs = millis();
-	uint8_t p[63];
-	ps5Build(p);
-	g_ps5.sendReport(0x01, p, sizeof p);
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_ps5[s].ready())
+			continue;
+		if (millis() - g_ps5LastMs[s] < USB_STREAM_MS)
+			continue;
+		g_ps5LastMs[s] = millis();
+		uint8_t p[63];
+		ps5Build((uint8_t)s, p);
+		g_ps5[s].sendReport(0x01, p, sizeof p);
+	}
 }

+ 43 - 27
OpenPuck/mode_switch_hori.cpp

@@ -2,6 +2,7 @@
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -26,8 +27,9 @@ static const uint8_t SWITCH_HID_DESC[] = {
 	// vendor (output, 8 bytes; unused)
 	0x0A, 0x21, 0x26, 0x95, 0x08, 0x91, 0x02, 0xC0
 };
-static Adafruit_USBD_HID g_switch; // HORIPAD gamepad HID interface
-static unsigned long g_swLastMs = 0;
+// NSLOT HORIPAD HID instances -- one per bond slot. The Switch console binds each as a separate gamepad.
+static Adafruit_USBD_HID g_switch[NSLOT];
+static unsigned long g_swLastMs[NSLOT] = { 0 };
 
 // back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
 static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
@@ -74,9 +76,10 @@ static inline void backCodeToHatDirs(uint8_t c, bool &u, bool &d, bool &l,
 		r = true;
 }
 // HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
-static void switchBuildHoripad(uint8_t out[8])
+// Per-slot: each controller's decoded input is in g_in[slot].
+static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 {
-	uint32_t b = g_in.buttons;
+	uint32_t b = g_in[slot].buttons;
 	uint16_t btn = 0;
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
@@ -101,9 +104,9 @@ static void switchBuildHoripad(uint8_t out[8])
 	if (b & TB_RB)
 		btn |= 0x20; // L, R
 	// ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
-	if ((g_in.lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		btn |= 0x40;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		btn |= 0x80;
 	if (b & TB_MENU)
 		btn |= 0x100;
@@ -154,10 +157,11 @@ static void switchBuildHoripad(uint8_t out[8])
 	out[0] = btn & 0xFF;
 	out[1] = btn >> 8;
 	out[2] = hat;
-	out[3] = swStick(g_in.lx, false);
-	out[4] = swStick(g_in.ly, true); // HID Y is down-positive -> invert
-	out[5] = swStick(g_in.rx, false);
-	out[6] = swStick(g_in.ry, true);
+	out[3] = swStick(g_in[slot].lx, false);
+	out[4] = swStick(g_in[slot].ly,
+			 true); // HID Y is down-positive -> invert
+	out[5] = swStick(g_in[slot].rx, false);
+	out[6] = swStick(g_in[slot].ry, true);
 	out[7] = 0;
 }
 
@@ -165,25 +169,37 @@ void SwitchHoriController::begin()
 {
 	USBDevice.setID(0x0F0D, 0x0092);
 
-	// Windows caches config by VID:PID:bcdDevice, so any interface change MUST bump this
-	USBDevice.setDeviceVersion(0x0202);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0210 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
-	g_switch.enableOutEndpoint(true);
-	g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_switch.setPollInterval(1);
-	g_switch.begin();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		g_switch[s].enableOutEndpoint(true);
+		g_switch[s].setReportDescriptor(SWITCH_HID_DESC,
+						sizeof SWITCH_HID_DESC);
+		g_switch[s].setPollInterval(1);
+		g_switch[s].begin();
+	}
 }
 void SwitchHoriController::task()
-{ // stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)
-	if (!g_switch.ready())
-		return;
-	if (millis() - g_swLastMs < USB_STREAM_MS)
-		return;
-	g_swLastMs = millis();
-	uint8_t p[8];
-	switchBuildHoripad(p);
-	g_switch.sendReport(0, p, sizeof p); // report-id-less descriptor
+{
+	// stream the 8-byte HORIPAD report at ~250Hz per controller. Per-slot last-ms so each HID is paced
+	// independently (same as the single-controller case); sending all 4 back-to-back in one loop keeps the
+	// 4ms target per controller while running at the full loop rate.
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_switch[s].ready())
+			continue;
+		if (millis() - g_swLastMs[s] < USB_STREAM_MS)
+			continue;
+		g_swLastMs[s] = millis();
+		uint8_t p[8];
+		switchBuildHoripad((uint8_t)s, p);
+		g_switch[s].sendReport(0, p,
+				       sizeof p); // report-id-less descriptor
+	}
 }

+ 231 - 141
OpenPuck/mode_switch_pro.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -140,13 +141,29 @@ static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
 		return 0;
 	}
 }
-static unsigned long g_swProLastMs = 0;
-static Adafruit_USBD_HID g_swPro;
+// NSLOT Pro-Controller HIDs (one per bond slot) + per-slot handshake state (timer, report-mode gate,
+// subcommand-reply FIFO), per-slot reply queue indices, per-slot last-stream millis, per-slot MAC, and
+// per-slot user-cal SPI mirror.
+static Adafruit_USBD_HID g_swPro[NSLOT];
+static unsigned long g_swProLastMs[NSLOT] = { 0 };
 // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
-static uint8_t g_swProReportMode = 0;
-static const uint8_t JC_MAC[6] = {
-	0x7C, 0xBB, 0x8A, 0x00, 0x00, 0x01
-}; // synthetic but stable controller MAC
+static uint8_t g_swProReportMode[NSLOT] = { 0 };
+static uint8_t g_jcTimer[NSLOT] = { 0 };
+// Per-slot MAC. The console uses the BT addr it reads from subcommand 0x02 to identify the controller;
+// real Pro Controllers each have their own. Last byte varies per slot.
+static const uint8_t JC_MAC_BASE[5] = { 0x7C, 0xBB, 0x8A, 0x00, 0x00 };
+static uint8_t g_jcMac[NSLOT][6];
+static bool g_jcMacInit = false;
+static void initJcMacs()
+{
+	if (g_jcMacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_jcMac[s], JC_MAC_BASE, 5);
+		g_jcMac[s][5] = (uint8_t)(0x10 + s);
+	}
+	g_jcMacInit = true;
+}
 static uint16_t jcRumbleAmp(const uint8_t r[4])
 {
 	// Nintendo packs a high band (r[0],r[1]) and low band (r[2],r[3]) of frequency+amplitude. We only need a
@@ -158,19 +175,21 @@ static uint16_t jcRumbleAmp(const uint8_t r[4])
 	uint8_t a = hf > lf ? hf : lf; // 0..0x7F
 	return (uint16_t)a << 9; // scale to ~16-bit motor speed
 }
-static void jcRumble(const uint8_t *p, uint16_t pn)
+// Per-slot: each Pro Controller has its own rumble stream, so the "last" relay tracking must be per-slot.
+static uint16_t g_jcLastLo[NSLOT] = { 0 };
+static uint16_t g_jcLastHi[NSLOT] = { 0 };
+static void jcRumble(uint8_t slot, const uint8_t *p, uint16_t pn)
 {
 	if (pn < 9)
 		return; // [timer][left rumble x4][right rumble x4]
 	uint16_t lo = jcRumbleAmp(p + 1), hi = jcRumbleAmp(p + 5);
-	static uint16_t lastLo = 0, lastHi = 0;
 	// only relay on change: the Switch streams rumble every frame; re-sending
 	// unchanged values would flood the RF relay and loop the motor
-	if (lo == lastLo && hi == lastHi)
+	if (lo == g_jcLastLo[slot] && hi == g_jcLastHi[slot])
 		return;
-	lastLo = lo;
-	lastHi = hi;
-	hapticSteamRumble(lo, hi);
+	g_jcLastLo[slot] = lo;
+	g_jcLastHi[slot] = hi;
+	hapticSteamRumble(lo, hi, slot);
 }
 static int jcStick12(int16_t v, bool inv)
 { // steam int16 (center 0) -> 12-bit (center 0x800), clamped
@@ -187,10 +206,9 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
 }
 // Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
 // shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
-static uint8_t g_jcTimer = 0;
-static void jcInputPrefix(uint8_t *out)
+static void jcInputPrefix(uint8_t slot, uint8_t *out)
 {
-	uint32_t b = g_in.buttons;
+	uint32_t b = g_in[slot].buttons;
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
 		b |= tritonFromCode(g_qamMap);
@@ -214,9 +232,9 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= JC_BTN_L;
 	if (b & TB_RB)
 		jc |= JC_BTN_R;
-	if ((g_in.lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		jc |= JC_BTN_ZL;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		jc |= JC_BTN_ZR;
 	if (b & TB_VIEW)
 		jc |= JC_BTN_PLUS;
@@ -244,7 +262,7 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
-	out[0] = g_jcTimer++;
+	out[0] = g_jcTimer[slot]++;
 
 	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
 	// A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too.
@@ -256,15 +274,15 @@ static void jcInputPrefix(uint8_t *out)
 	// charging_grip bit (button "common" byte, bit7): genuine Pro Controller always sets it on USB; real
 	// Switch uses it to recognise a wired controller. Not a button, so hid-nintendo ignores it.
 	out[3] |= 0x80;
-	jcPackStick(out + 5, g_in.lx, g_in.ly);
-	jcPackStick(out + 8, g_in.rx, g_in.ry);
+	jcPackStick(out + 5, g_in[slot].lx, g_in[slot].ly);
+	jcPackStick(out + 8, g_in[slot].rx, g_in[slot].ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 }
-static void switchProBuild(uint8_t out[63])
+static void switchProBuild(uint8_t slot, uint8_t out[63])
 {
 	memset(out, 0, 63);
-	jcInputPrefix(out);
+	jcInputPrefix(slot, out);
 	// Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Source routing (a proper
 	// rotation, det +1): SwitchX<-+gy, SwitchY<--gx, SwitchZ<-+gz.
 	//
@@ -278,14 +296,14 @@ static void switchProBuild(uint8_t out[63])
 	// ~1g. Without this the console reads gravity as ~4g, REJECTS the accel for drift correction (it must be linear
 	// accel, not gravity), and gyro roll error accumulates into a slow ~45deg lean. Gyro is left at native scale.
 	// accel X <- +ay, accel Y <- -ax, accel Z <- +az (signs match gyro)
-	int16_t aX = (int16_t)(g_in.ay / SW_ACCEL_DIV);
-	int16_t aY = (int16_t)((-(int16_t)g_in.ax) / SW_ACCEL_DIV);
-	int16_t aZ = (int16_t)(g_in.az / SW_ACCEL_DIV);
+	int16_t aX = (int16_t)(g_in[slot].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[slot].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[slot].az / SW_ACCEL_DIV);
 
 	// gyro outputs scaled by the user sensitivity factor
-	int16_t groll = gscale((int16_t)g_in.gy);
-	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in.gx));
-	int16_t gyaw = gscale((int16_t)g_in.gz);
+	int16_t groll = gscale((int16_t)g_in[slot].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[slot].gx));
+	int16_t gyaw = gscale((int16_t)g_in[slot].gz);
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
 		out[o + 0] = aX & 0xFF;
@@ -317,7 +335,8 @@ static const uint8_t SPI_PARAMS2[18] = { 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
 					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
 static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
 				       0x32, 0x32, 0x32, 0x32, 0x32, 0xFF };
-// built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
+// built at boot: left[9]+right[9] factory stick calibration (packed 12-bit). Shared across slots -- it's a
+// factory stub the host reads to seed its cal, and we present the same neutral stub for all controllers.
 static uint8_t g_spiStickCal[18];
 static void jcPack12(uint8_t *o9, const uint16_t v[6])
 { // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
@@ -346,14 +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
-static const uint8_t BT_PAIR_1[31] = {
-	0x01, JC_MAC[0], JC_MAC[1], JC_MAC[2], JC_MAC[3], JC_MAC[4], JC_MAC[5],
-	0x00, 0x25,	 0x08,	    0x50,      0x72,	  0x6F,	     0x20,
-	0x43, 0x6F,	 0x6E,	    0x74,      0x72,	  0x6F,	     0x6C,
-	0x6C, 0x65,	 0x72, // "Pro Controller"
-	0x00, 0x00,	 0x00,	    0x00,      0x00,	  0x68
-};
 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,
@@ -364,53 +375,77 @@ static const uint8_t BT_PAIR_3[31] = {
 }; // type 3: save pairing (all zero body)
 // User-calibration SPI mirror (0x8000-0x80FF). A real Pro Controller stores the gyro/accel (and stick) calibration
 // the Switch writes during "Calibrate Motion Controls" here, then reads it back and applies it -- that's how a
-// resting IMU/neutral offset gets cancelled. We store writes and serve them back, persisted to flash so it survives
-// unplug/replug. 0xFF = blank -> Switch falls back to our factory block at 0x6020 (default until cal'd).
-#define SWCAL_FILE "/swimucal.bin"
-static uint8_t g_userCal[0x100];
+// resting IMU/neutral offset gets cancelled. Per-slot: each Pro Controller has its own SPI mirror, so each
+// gets its own persisted file (NSLOT files; the same 0x100 bytes as before, partitioned by slot index).
+#define SWCAL_FILE_BASE "/swimucal"
+static uint8_t g_userCal[NSLOT][0x100];
+static bool g_userCalLoaded[NSLOT] = { false, false, false, false };
+static void swCalFileName(uint8_t slot, char *out)
+{
+	// "/swimucal0.bin" .. "/swimucal3.bin"
+	const char *base = SWCAL_FILE_BASE;
+	int i = 0;
+	while (base[i]) {
+		out[i] = base[i];
+		i++;
+	}
+	out[i++] = (char)('0' + slot);
+	out[i++] = '.';
+	out[i++] = 'b';
+	out[i++] = 'i';
+	out[i++] = 'n';
+	out[i] = '\0';
+}
 static void loadUserCal()
 {
-	memset(g_userCal, 0xFF, sizeof g_userCal);
-	File f(InternalFS);
-	if (f.open(SWCAL_FILE, FILE_O_READ)) {
-		f.read(g_userCal, sizeof g_userCal);
-		f.close();
+	for (int s = 0; s < NSLOT; s++) {
+		memset(g_userCal[s], 0xFF, sizeof g_userCal[s]);
+		char fn[16];
+		swCalFileName((uint8_t)s, fn);
+		File f(InternalFS);
+		if (f.open(fn, FILE_O_READ)) {
+			f.read(g_userCal[s], sizeof g_userCal[s]);
+			f.close();
+		}
+		g_userCalLoaded[s] = true;
 	}
 }
-static void saveUserCal()
+static void saveUserCal(uint8_t slot)
 {
-	InternalFS.remove(SWCAL_FILE);
+	char fn[16];
+	swCalFileName(slot, fn);
+	InternalFS.remove(fn);
 	File f(InternalFS);
-	if (f.open(SWCAL_FILE, FILE_O_WRITE)) {
-		f.write(g_userCal, sizeof g_userCal);
+	if (f.open(fn, FILE_O_WRITE)) {
+		f.write(g_userCal[slot], sizeof g_userCal[slot]);
 		f.close();
 	}
 }
 // jcSpiWrite runs in the USB ISR (via jcSet). NEVER do flash I/O here -- a blocking LittleFS erase/write in the
 // interrupt wedges USB + the RF poll and corrupts state (device drops into a bad state needing a replug). Only
 // update the RAM mirror and flag it dirty; task() (main loop) does the actual save, debounced so a calibration
-// write-burst coalesces into one flash write.
-static volatile bool g_userCalDirty = false;
-static volatile unsigned long g_userCalDirtyMs = 0;
-static void jcSpiWrite(uint32_t addr, uint8_t len, const uint8_t *data,
-		       uint16_t avail)
+// write-burst coalesces into one flash write. Per-slot: each Pro Controller's ISR write targets its own mirror.
+static volatile bool g_userCalDirty[NSLOT] = { false, false, false, false };
+static volatile unsigned long g_userCalDirtyMs[NSLOT] = { 0 };
+static void jcSpiWrite(uint8_t slot, uint32_t addr, uint8_t len,
+		       const uint8_t *data, uint16_t avail)
 {
 	bool changed = false;
 	for (uint8_t i = 0; i < len && i < avail; i++) {
 		uint32_t a = addr + i;
 		if (a >= 0x8000 && a < 0x8100) {
-			if (g_userCal[a - 0x8000] != data[i]) {
-				g_userCal[a - 0x8000] = data[i];
+			if (g_userCal[slot][a - 0x8000] != data[i]) {
+				g_userCal[slot][a - 0x8000] = data[i];
 				changed = true;
 			}
 		}
 	}
 	if (changed) {
-		g_userCalDirty = true;
-		g_userCalDirtyMs = millis();
+		g_userCalDirty[slot] = true;
+		g_userCalDirtyMs[slot] = millis();
 	} // defer the flash write to task()
 }
-static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
+static void spiRead(uint8_t slot, uint32_t addr, uint8_t len, uint8_t *dst)
 {
 	for (uint8_t i = 0; i < len; i++) {
 		uint32_t a = addr + i;
@@ -427,52 +462,74 @@ static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
 			v = SPI_PARAMS2[a - 0x6098];
 		else if (a >= 0x8000 && a < 0x8100)
 			// persisted user cal (0xFF blank -> factory fallback)
-			v = g_userCal[a - 0x8000];
+			v = g_userCal[slot][a - 0x8000];
 		dst[i] = v;
 	}
 }
 // Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
-// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Every reply
-// is sent as a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter
-// than the descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
+// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Per-slot: each
+// Pro Controller runs its own handshake state machine, so each has its own reply queue. Every reply is sent as
+// a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter than the
+// descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
 #define JC_REPLEN 63
 struct JcRep {
 	uint8_t rid;
 	uint8_t data[JC_REPLEN];
 };
 #define JCQ_N 8
-static volatile uint8_t g_jcQh = 0, g_jcQt = 0;
-static JcRep g_jcQ[JCQ_N];
-static void jcEnq(uint8_t rid, const uint8_t *d, uint8_t len)
+static volatile uint8_t g_jcQh[NSLOT] = { 0, 0, 0, 0 };
+static volatile uint8_t g_jcQt[NSLOT] = { 0, 0, 0, 0 };
+static JcRep g_jcQ[NSLOT][JCQ_N];
+static void jcEnq(uint8_t slot, uint8_t rid, const uint8_t *d, uint8_t len)
 {
-	uint8_t nt = (uint8_t)((g_jcQt + 1) % JCQ_N);
-	if (nt == g_jcQh)
+	uint8_t t = g_jcQt[slot];
+	uint8_t nt = (uint8_t)((t + 1) % JCQ_N);
+	if (nt == g_jcQh[slot])
 		return; // full -> drop; the host re-requests on timeout
 	if (len > JC_REPLEN)
 		len = JC_REPLEN;
-	g_jcQ[g_jcQt].rid = rid;
-	memset(g_jcQ[g_jcQt].data, 0, JC_REPLEN);
-	memcpy(g_jcQ[g_jcQt].data, d, len); // zero-pad to full report length
-	g_jcQt = nt;
+	g_jcQ[slot][t].rid = rid;
+	memset(g_jcQ[slot][t].data, 0, JC_REPLEN);
+	memcpy(g_jcQ[slot][t].data, d, len); // zero-pad to full report length
+	g_jcQt[slot] = nt;
 }
 // Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
-static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
+static void jcSubcmd(uint8_t slot, uint8_t sub, const uint8_t *args,
+		     uint16_t alen)
 {
 	uint8_t p[JC_REPLEN];
 	memset(p, 0, sizeof p);
-	jcInputPrefix(p);
+	jcInputPrefix(slot, p);
 	p[13] = sub;
 	switch (sub) {
 	// manual BT pairing: 3-stage key exchange a real Switch runs over USB
 	case 0x01: {
-		const uint8_t *d = BT_PAIR_3;
 		uint8_t t = (alen >= 1) ? args[0] : 3;
-		if (t == 1)
-			d = BT_PAIR_1;
-		else if (t == 2)
-			d = BT_PAIR_2;
 		p[12] = 0x81; // pairing ACK
-		memcpy(&p[14], d, 31);
+		if (t == 1) {
+			// echo type + this slot's MAC + "Pro Controller" name
+			p[14] = 0x01;
+			memcpy(&p[15], g_jcMac[slot], 6);
+			p[21] = 0x00;
+			p[22] = 0x25;
+			p[23] = 0x08;
+			// "Pro Controller" = 50 72 6F 20 43 6F 6E 74 72 6F 6C 6C 65 72
+			static const uint8_t NAME[14] = {
+				0x50, 0x72, 0x6F, 0x20, 0x43, 0x6F, 0x6E,
+				0x74, 0x72, 0x6F, 0x6C, 0x6C, 0x65, 0x72
+			};
+			memcpy(&p[24], NAME, 14);
+			p[38] = 0x00;
+			p[39] = 0x00;
+			p[40] = 0x00;
+			p[41] = 0x00;
+			p[42] = 0x00;
+			p[43] = 0x68;
+		} else if (t == 2) {
+			memcpy(&p[14], BT_PAIR_2, 31);
+		} else {
+			memcpy(&p[14], BT_PAIR_3, 31);
+		}
 		break;
 	}
 	case 0x02: // request device info
@@ -483,7 +540,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[15] = 0x48;
 		p[16] = 0x03; // controller type: Pro Controller
 		p[17] = 0x02; // fixed
-		memcpy(&p[18], JC_MAC, 6);
+		memcpy(&p[18], g_jcMac[slot], 6);
 		p[24] = 0x01; // colors stored in SPI (0x6050)
 		p[25] = 0x01; // fixed
 		break;
@@ -504,7 +561,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[16] = args[2];
 		p[17] = args[3];
 		p[18] = rl;
-		spiRead(a, rl, &p[19]);
+		spiRead(slot, a, rl, &p[19]);
 		break;
 	}
 	// SPI flash write -> persist user calibration so Switch motion-cal sticks
@@ -514,7 +571,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 				     ((uint32_t)args[1] << 8) |
 				     ((uint32_t)args[2] << 16) |
 				     ((uint32_t)args[3] << 24);
-			jcSpiWrite(a, args[4], &args[5],
+			jcSpiWrite(slot, a, args[4], &args[5],
 				   (alen > 5) ? (uint16_t)(alen - 5) : 0);
 		}
 		p[12] = 0x80; // write ACK
@@ -523,7 +580,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 	// set input report mode (0x30 = standard full) -> begin streaming
 	case 0x03:
 		if (alen >= 1 && args[0] == 0x30)
-			g_swProReportMode = 0x30;
+			g_swProReportMode[slot] = 0x30;
 		p[12] = 0x80;
 		break;
 	// trigger buttons elapsed time -> canned reply (genuine pad returns data)
@@ -545,13 +602,12 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		// 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
 		break;
 	}
-	jcEnq(0x21, p, JC_REPLEN);
+	jcEnq(slot, 0x21, p, JC_REPLEN);
 }
-// USB-ISR callback for the Pro Controller's OUT endpoint. Data arrives via the interrupt OUT endpoint, where
-// this TinyUSB core passes report_id=0 and leaves the real report id in buffer[0] (it does NOT strip it). Also
-// accept the control SET_REPORT shape (id already in `rid`) for safety. report 0x80 = USB handshake; 0x01 = subcommand.
-static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		  uint16_t n)
+// USB-ISR callback for the Pro Controller's OUT endpoint. Per-slot dispatch via per-instance callback
+// (jcSet##N closes over the slot index). Report 0x80 = USB handshake; 0x01 = subcommand.
+static void jcSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			uint8_t const *b, uint16_t n)
 {
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
@@ -572,18 +628,24 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 		if (pn < 1)
 			return;
 		if (p[0] == 0x01) {
-			uint8_t d[9] = { 0x01,	    0x00,      0x03,
-					 JC_MAC[0], JC_MAC[1], JC_MAC[2],
-					 JC_MAC[3], JC_MAC[4], JC_MAC[5] };
-			jcEnq(0x81, d, 9);
+			uint8_t d[9] = { 0x01,
+					 0x00,
+					 0x03,
+					 g_jcMac[slot][0],
+					 g_jcMac[slot][1],
+					 g_jcMac[slot][2],
+					 g_jcMac[slot][3],
+					 g_jcMac[slot][4],
+					 g_jcMac[slot][5] };
+			jcEnq(slot, 0x81, d, 9);
 		} // device type + MAC
 		else if (p[0] == 0x02) {
 			uint8_t d[1] = { 0x02 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // handshake
 		else if (p[0] == 0x03) {
 			uint8_t d[1] = { 0x03 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // set baudrate
 		// 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
 		return;
@@ -591,68 +653,96 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	if (id == 0x01) { // [timer][rumble x8][subcmd][args...]
 		if (pn < 10)
 			return;
-		jcRumble(p, pn);
-		jcSubcmd(p[9], p + 10, (pn > 10) ? (uint16_t)(pn - 10) : 0);
+		jcRumble(slot, p, pn);
+		jcSubcmd(slot, p[9], p + 10,
+			 (pn > 10) ? (uint16_t)(pn - 10) : 0);
 		return;
 	}
 	if (id == 0x10) {
-		jcRumble(p, pn);
+		jcRumble(slot, p, pn);
 		return;
 	}
 	// report 0x82: ignored
 }
+#define JCCB(N)                                                                \
+	static void jcSet##N(uint8_t r, hid_report_type_t t, uint8_t const *b, \
+			     uint16_t n)                                       \
+	{                                                                      \
+		jcSetCommon(N, r, t, b, n);                                    \
+	}
+// clang-format off
+JCCB(0)
+JCCB(1)
+JCCB(2)
+JCCB(3)
+// clang-format on
+typedef void (*jc_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			   uint16_t);
+static jc_setcb_t const JC_SETCB[NSLOT] = { jcSet0, jcSet1, jcSet2, jcSet3 };
 
 void SwitchProController::begin()
 {
 	USBDevice.setID(0x057E, 0x2009);
-	// bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
-	USBDevice.setDeviceVersion(0x0212);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0220 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");
 	jcBuildStickCal();
 
-	// restore any persisted Switch motion calibration (per-unit IMU bias correction)
 	loadUserCal();
-	swProLoadCfg(); // restore report-rate + gyro-scale settings
-	g_swPro.enableOutEndpoint(true);
-	// answer the Nintendo USB handshake + subcommands (else Steam never binds it)
-	g_swPro.setReportCallback(NULL, jcSet);
-	g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_swPro.setPollInterval(1);
-	g_swPro.begin();
+	swProLoadCfg();
+	initJcMacs();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		g_swPro[s].enableOutEndpoint(true);
+		g_swPro[s].setReportCallback(NULL, JC_SETCB[s]);
+		g_swPro[s].setReportDescriptor(SWPRO_HID_DESC,
+					       sizeof SWPRO_HID_DESC);
+		g_swPro[s].setPollInterval(1);
+		g_swPro[s].begin();
+	}
 }
 void SwitchProController::task()
 {
-	if (!g_swPro.ready())
-		return;
-	// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst is one save).
-	if (g_userCalDirty &&
-	    (unsigned long)(millis() - g_userCalDirtyMs) > 250u) {
-		g_userCalDirty = false;
-		saveUserCal();
-	}
-	if (g_jcQh !=
-	    g_jcQt) { // drain handshake/subcommand replies first (ordered)
-		JcRep *r = &g_jcQ[g_jcQh];
-		if (g_swPro.sendReport(r->rid, r->data, JC_REPLEN))
-			g_jcQh = (uint8_t)((g_jcQh + 1) % JCQ_N);
-		return; // one report per call; the rest next loop
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_swPro[s].ready())
+			continue;
+		// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst
+		// is one save). Per-slot: each Pro Controller saves its own mirror.
+		if (g_userCalDirty[s] &&
+		    (unsigned long)(millis() - g_userCalDirtyMs[s]) > 250u) {
+			g_userCalDirty[s] = false;
+			saveUserCal((uint8_t)s);
+		}
+		// drain handshake/subcommand replies first (ordered) -- one report per slot per call so a
+		// bursty host-init doesn't starve the streamed 0x30
+		if (g_jcQh[s] != g_jcQt[s]) {
+			JcRep *r = &g_jcQ[s][g_jcQh[s]];
+			if (g_swPro[s].sendReport(r->rid, r->data, JC_REPLEN))
+				g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
+			// one report per slot per call; the rest next loop
+			continue;
+		}
+		// not until the host has finished init + selected 0x30
+		if (g_swProReportMode[s] != 0x30)
+			continue;
+		// The Switch integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores
+		// the timer byte and assumes a 3-samples-per-15ms genuine cadence). Streaming faster (e.g. 250 Hz x 3
+		// = 750 samples/s) over-credits gyro rotation ~3.75x, so residual bias accumulates into the slow
+		// orientation lean that builds over minutes and resets on replug. 15 ms matches the genuine push,
+		// making integration 1:1.
+		uint32_t interval = (g_swProRate == 2) ? USB_STREAM_MS :
+				    (g_swProRate == 1) ? SW_PRO_REPORT_MS_120 :
+							 SW_PRO_REPORT_MS;
+		if (millis() - g_swProLastMs[s] < interval)
+			continue;
+		g_swProLastMs[s] = millis();
+		uint8_t p[63];
+		switchProBuild((uint8_t)s, p);
+		g_swPro[s].sendReport(0x30, p, sizeof p);
 	}
-	if (g_swProReportMode != 0x30)
-		return; // not until the host has finished init + selected 0x30
-	// The Switch integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores the timer
-	// byte and assumes a 3-samples-per-15ms genuine cadence). Streaming faster (e.g. 250 Hz x 3 = 750 samples/s)
-	// over-credits gyro rotation ~3.75x, so residual bias accumulates into the slow orientation lean that builds over
-	// minutes and resets on replug. 15 ms matches the genuine push, making integration 1:1.
-	uint32_t interval = (g_swProRate == 2) ? USB_STREAM_MS :
-			    (g_swProRate == 1) ? SW_PRO_REPORT_MS_120 :
-						 SW_PRO_REPORT_MS;
-	if (millis() - g_swProLastMs < interval)
-		return;
-	g_swProLastMs = millis();
-	uint8_t p[63];
-	switchProBuild(p);
-	g_swPro.sendReport(0x30, p, sizeof p);
 }

+ 122 - 52
OpenPuck/mode_xinput.cpp

@@ -1,3 +1,13 @@
+// mode_xinput.cpp -- Xbox 360 wired (XInput) personality (MODE_XBOX): one XInput gamepad per bonded controller.
+//
+// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
+// 20-byte XInput report. A custom TinyUSB class driver serves that interface plus a second boot-mouse interface
+// for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
+// rumble, relayed to the controller as a haptic by task().
+//
+// Multi-controller: one XInput interface per bonded controller slot. The single class driver routes xfer/open
+// callbacks by endpoint address into the per-slot state. The right-pad mouse is a single shared interface
+// (only slot 0's right-pad drives it). Number of registered interfaces matches bondedSlotCount() at boot.
 #include "mode_xinput.h"
 #include "triton.h"
 #include "gamepad_util.h"
@@ -35,21 +45,30 @@ enum {
 
 // ===================== XInput custom TinyUSB class driver =====================
 // Custom class driver + an Adafruit_USBD_Interface subclass emitting the interface + 0x21-blob + 2 endpoints.
+// Per-slot state lives in g_xiSlot[]: each XInput interface gets one. The class driver dispatches open/xfer
+// by endpoint address; xinputSend() targets a specific slot's IN endpoint.
 #define XINPUT_DESC_LEN \
 	(9 + 17 + 7 + 7) // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
-static uint8_t g_xiItf = 0xFF, g_xiEpIn = 0, g_xiEpOut = 0;
-static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
-
-// XInput rumble strengths from the host's OUT packet; translated to Steam/Triton report 0x80.
-static volatile uint16_t g_rumbleLow = 0, g_rumbleHigh = 0;
-static volatile unsigned long g_rumbleMs =
-	0; // millis of last rumble OUT packet (stuck-rumble watchdog)
 
+// per-slot state. `inUse` is set in xi_open from the USB ISR and read in xinputSend from the loop -- mark
+// volatile so the loop doesn't observe a stale 0 across a freshly-attached interface.
+struct XiSlot {
+	uint8_t itf, epIn, epOut;
+	uint8_t inBuf[32], outBuf[32];
+	volatile uint16_t rumbleLow,
+		rumbleHigh; // last host rumble values, x 257
+	volatile unsigned long
+		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
+	volatile bool inUse;
+};
+static XiSlot g_xiSlot[NSLOT];
 // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
 #define RUMBLE_STUCK_MS 2500u
 
 static void xi_init(void)
 {
+	for (int s = 0; s < NSLOT; s++)
+		g_xiSlot[s].inUse = false;
 }
 static bool xi_deinit(void)
 {
@@ -58,15 +77,33 @@ static bool xi_deinit(void)
 static void xi_reset(uint8_t rhport)
 {
 	(void)rhport;
-	g_xiEpIn = g_xiEpOut = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		g_xiSlot[s].itf = 0;
+		g_xiSlot[s].epIn = g_xiSlot[s].epOut = 0;
+		g_xiSlot[s].inUse = false;
+	}
 }
+// TinyUSB calls xi_open once per XInput interface in the config descriptor. Claim the first free BONDED
+// slot so the xiSlot index matches the bond-slot index (begin() registers them in ascending bond-slot order).
+// Fallback to any free slot when there are no bonds (fresh device).
 static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 			uint16_t max_len)
 {
 	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
 	      itf->bInterfaceProtocol == 0x01))
 		return 0;
-	g_xiItf = itf->bInterfaceNumber;
+	int nBonds = bondedSlotCount();
+	int slot = -1;
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_xiSlot[s].inUse && (nBonds == 0 || g_slot[s].used)) {
+			slot = s;
+			break;
+		}
+	}
+	if (slot < 0)
+		return 0;
+	XiSlot &S = g_xiSlot[slot];
+	S.itf = itf->bInterfaceNumber;
 	uint8_t const *p = (uint8_t const *)itf;
 	uint8_t const *end = p + max_len;
 	uint16_t used = itf->bLength;
@@ -79,17 +116,18 @@ static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 				(tusb_desc_endpoint_t const *)p;
 			usbd_edpt_open(rhport, ep);
 			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
-				g_xiEpIn = ep->bEndpointAddress;
+				S.epIn = ep->bEndpointAddress;
 			else
-				g_xiEpOut = ep->bEndpointAddress;
+				S.epOut = ep->bEndpointAddress;
 			opened++;
 		}
 		used += blen;
 		p += blen;
 	}
-	if (g_xiEpOut)
-		usbd_edpt_xfer(rhport, g_xiEpOut, g_xiOutBuf,
-			       sizeof g_xiOutBuf); // arm OUT (rumble/LED)
+	S.inUse = true;
+	// arm OUT (rumble/LED)
+	if (S.epOut)
+		usbd_edpt_xfer(rhport, S.epOut, S.outBuf, sizeof S.outBuf);
 	return used;
 }
 static bool xi_ctrl(uint8_t rhport, uint8_t stage,
@@ -99,21 +137,30 @@ static bool xi_ctrl(uint8_t rhport, uint8_t stage,
 	(void)req;
 	return stage != CONTROL_STAGE_SETUP;
 }
+// Route an endpoint xfer callback to the slot that owns that endpoint.
 static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
 {
 	(void)res;
-	if (ep == g_xiEpOut) {
-		// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
-		if (n >= 5 && g_xiOutBuf[0] == 0x00 && g_xiOutBuf[1] == 0x08) {
-			uint8_t big = g_xiOutBuf[3], sml = g_xiOutBuf[4];
-			g_rumbleLow = (uint16_t)big * 257u;
-			g_rumbleHigh = (uint16_t)sml * 257u;
-			g_rumbleMs =
-				millis(); // stamp for the stuck-rumble watchdog
-			hapticSteamRumble(g_rumbleLow, g_rumbleHigh);
+	for (int s = 0; s < NSLOT; s++) {
+		XiSlot &S = g_xiSlot[s];
+		if (!S.inUse)
+			continue;
+		if (ep == S.epOut) {
+			// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
+			if (n >= 5 && S.outBuf[0] == 0x00 &&
+			    S.outBuf[1] == 0x08) {
+				uint8_t big = S.outBuf[3], sml = S.outBuf[4];
+				uint16_t lo = (uint16_t)big * 257u;
+				uint16_t hi = (uint16_t)sml * 257u;
+				S.rumbleLow = lo;
+				S.rumbleHigh = hi;
+				S.rumbleMs = millis();
+				hapticSteamRumble(lo, hi, (uint8_t)s);
+			}
+			usbd_edpt_xfer(rhport, S.epOut, S.outBuf,
+				       sizeof S.outBuf);
+			return true;
 		}
-		usbd_edpt_xfer(rhport, g_xiEpOut, g_xiOutBuf,
-			       sizeof g_xiOutBuf); // re-arm OUT
 	}
 	return true;
 }
@@ -166,13 +213,18 @@ class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
 		return TinyUSBDevice.addInterface(*this);
 	}
 };
-static Adafruit_USBD_XInput g_xinput;
-static void xinputSend(uint16_t buttons, uint8_t lt, uint8_t rt, int16_t lx,
-		       int16_t ly, int16_t rx, int16_t ry)
+// NSLOT instances: each registers its own XInput interface (own itfnum + IN/OUT ep pair) during begin().
+// begin() in XboxController::begin() runs in order 0..NSLOT-1, so xi_open's first-free assignment lines up.
+static Adafruit_USBD_XInput g_xinput[NSLOT];
+static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
+		       int16_t lx, int16_t ly, int16_t rx, int16_t ry)
 {
-	if (!tud_mounted() || g_xiEpIn == 0 || usbd_edpt_busy(0, g_xiEpIn))
+	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
 		return;
-	uint8_t *r = g_xiInBuf;
+	XiSlot &S = g_xiSlot[slot];
+	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
+		return;
+	uint8_t *r = S.inBuf;
 	r[0] = 0x00;
 	r[1] = 0x14;
 	r[2] = buttons & 0xFF;
@@ -188,15 +240,16 @@ static void xinputSend(uint16_t buttons, uint8_t lt, uint8_t rt, int16_t lx,
 	r[12] = ry & 0xFF;
 	r[13] = ry >> 8;
 	memset(r + 14, 0, 6);
-	if (usbd_edpt_claim(0, g_xiEpIn)) {
-		if (!usbd_edpt_xfer(0, g_xiEpIn, r, 20))
-			usbd_edpt_release(0, g_xiEpIn);
+	if (usbd_edpt_claim(0, S.epIn)) {
+		if (!usbd_edpt_xfer(0, S.epIn, r, 20))
+			usbd_edpt_release(0, S.epIn);
 	}
 }
 
 // ===================== right-pad mouse interface =====================
 static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
-static Adafruit_USBD_HID g_mouse; // Xbox-mode mouse interface (right trackpad)
+static Adafruit_USBD_HID
+	g_mouse; // Xbox-mode mouse interface (right trackpad, slot 0 only)
 
 // ===================== report 0x45 -> XInput + mouse =====================
 // button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright
@@ -237,7 +290,7 @@ static uint16_t codeToXB(uint8_t c)
 		return 0;
 	}
 }
-static void rfXboxGamepad(const uint8_t *r)
+static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 {
 	uint32_t b = btnsOf(r);
 	if (g_qamMap && (b & TB_QAM)) {
@@ -290,13 +343,14 @@ static void rfXboxGamepad(const uint8_t *r)
 	uint8_t lt = trigU8(u16off(r, 4)),
 		rt = trigU8(u16off(
 			r, 6)); // triggers u16 (half-scale) -> full-range u8
-	xinputSend(btn, lt, rt, (int16_t)s16off(r, 8),
+	xinputSend(slot, btn, lt, rt, (int16_t)s16off(r, 8),
 		   (int16_t)s16off(r, 10), // L stick X/Y
 		   (int16_t)s16off(r, 12),
 		   (int16_t)s16off(r, 14)); // R stick X/Y
 }
 // Right pad -> mouse on a second HID-mouse interface alongside the XInput gamepad. Same glide model as Lizard's
-// right pad; RPad click = left button, LPad click = right.
+// right pad; RPad click = left button, LPad click = right. SINGLE shared mouse: the desktop can only consume
+// one mouse. Slot 0's right-pad drives it; other slots' right-pad input is intentionally ignored here.
 static void rfXboxMouse(const uint8_t *r)
 {
 	uint32_t b = btnsOf(r);
@@ -356,37 +410,53 @@ static void rfXboxMouse(const uint8_t *r)
 // ===================== IController =====================
 void XboxController::begin()
 {
-	g_rumbleLow = g_rumbleHigh = 0;
-
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
-	// Windows caches the config descriptor by VID:PID:bcdDevice, so any interface change here MUST bump bcdDevice
-	// or Windows serves a stale descriptor.
-	USBDevice.setDeviceVersion(0x0115);
+	// bcdDevice encodes the bonded count so Windows re-reads the config descriptor when the count changes.
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
-	g_xinput.setStringDescriptor("Controller");
-	g_xinput.begin();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		g_xinput[s].setStringDescriptor("Controller");
+		g_xinput[s].begin();
+	}
 	g_mouse.setStringDescriptor("OpenPuck Mouse");
 	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
 	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
 	g_mouse.setPollInterval(1);
 	g_mouse.begin();
 }
-void XboxController::onReport45(const uint8_t *rep, bool fresh,
+void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 {
 	(void)fresh;
 	(void)bodyTlen;
-	rfXboxGamepad(rep);
-	rfXboxMouse(rep);
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	rfXboxGamepad((uint8_t)slot, rep);
+	// Shared desktop mouse: slot 0's right-pad only. Other slots' right-pad is still part of the
+	// per-slot XInput (TB_RPADT in the gamepad report is unused today but kept for forward-compat).
+	if (slot == 0)
+		rfXboxMouse(rep);
 }
-// Lost-stop watchdog: Steam/Triton rumble is latched, so force a zero report if the host stops refreshing.
+// Lost-stop watchdog per slot: Steam/Triton rumble is latched, so force a zero report if the host stops
+// refreshing on a particular XInput. The haptics.cpp global watchdog fires too, but it skips while the slot
+// is in the post-reconnect block -- this catches a host that kept streaming while we were blocked.
 void XboxController::task()
 {
-	if ((g_rumbleLow || g_rumbleHigh) &&
-	    millis() - g_rumbleMs > RUMBLE_STUCK_MS) {
-		g_rumbleLow = g_rumbleHigh = 0;
-		hapticSteamRumble(0, 0);
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_xiSlot[s].inUse)
+			continue;
+		if ((g_xiSlot[s].rumbleLow || g_xiSlot[s].rumbleHigh) &&
+		    millis() - g_xiSlot[s].rumbleMs > RUMBLE_STUCK_MS) {
+			g_xiSlot[s].rumbleLow = g_xiSlot[s].rumbleHigh = 0;
+			hapticSteamRumble(0, 0, (uint8_t)s);
+		}
 	}
 }

+ 1 - 1
OpenPuck/mode_xinput.h

@@ -11,7 +11,7 @@
 class XboxController : public IController {
     public:
 	void begin() override;
-	void onReport45(const uint8_t *rep, bool fresh,
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
 			uint8_t bodyTlen) override;
 
 	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog

+ 100 - 64
OpenPuck/puck_hid.cpp

@@ -153,11 +153,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
-			if (!haptic82Blocked()) {
+			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
 					     (uint8_t)(n > RELAY_MAXP ?
 							       RELAY_MAXP :
-							       n));
+							       n),
+					     (uint8_t)slot);
 				// Track on/off from what was actually RELAYED (= the controller's believed state): a
 				// 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).
@@ -204,7 +205,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
 		bool relayOk = hapticRelaySlotOk(slot) &&
 			       !(haptic82 && (lizardActive() || muted));
-		if (relayOk && (!haptic82 || !haptic82Blocked())) {
+		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
 			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
 			// multi-register 0x87 settings blocks (LED brightness) and calibration writes exceed the old
 			// 18B cap, and the chopped frames were why those settings never landed on the controller.
@@ -213,7 +214,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				Serial.printf(
 					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
 					cmd, len, (unsigned)RELAY_MAXP);
-			relayEnqueue(cmd, pl, rl);
+			relayEnqueue(cmd, pl, rl, (uint8_t)slot);
 
 			// track from RELAYED frames only (see the OUTPUT path)
 			if (haptic82)
@@ -256,8 +257,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		hostStampAlive();
 		S.resp[0] = 0xB4;
 		S.resp[1] = 0x01;
-		S.resp[2] = (slot == g_connSlot && !g_xbox &&
-			     (millis() - g_connReplyMs < 500)) ?
+		S.resp[2] = (slot >= 0 && slot < NSLOT && !g_xbox &&
+			     g_slot[slot].used &&
+			     (millis() - g_connReplyMs[slot] < 500)) ?
 				    0x02 :
 				    0x01;
 		S.resp_len = 63;
@@ -376,10 +378,15 @@ void SteamPuckController::begin()
 }
 
 // Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. PURELY a
-// USB-side decision -- changes nothing about the RF poll or the host->controller relay.
-void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
+// USB-side decision -- changes nothing about the RF poll or the host->controller relay. Per-slot: each
+// controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard remains
+// single-controller (slot 0); the other slots stay quiet in lizard mode -- one mouse + one keyboard is
+// the only thing a desktop can consume, and the comment in mode_lizard.h documents this.
+void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				     uint8_t bodyTlen)
 {
+	(void)fresh;
+	(void)bodyTlen;
 	// Host asleep -> forward NOTHING. While suspended, every sendReport attempt can translate into a host wake,
 	// making the PC wake on any controller movement. Waking is an explicit gesture only (Steam-button short
 	// press / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
@@ -389,10 +396,15 @@ void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
 	// the freshly-woken desktop.
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 		return;
-	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	if (!g_slot[slot].used)
 		return;
 	if (lizardActive()) {
-		rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
+		// Lizard single-controller: only slot 0's interface drives the mouse/keyboard. Other slots
+		// stay quiet in lizard mode.
+		if (slot == 0)
+			rfLizard(rep, &hid[0], &hid[0], 0x40, 0x41);
 	} else {
 		uint8_t blen = bodyTlen - 1;
 		if (blen > 45)
@@ -400,9 +412,8 @@ void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
 		// forward the puck's raw pad coords untouched (Steam does its own interpolation/smoothing). Forward
 		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
 		// stair-step. g_fwdNewOnly toggles for A/B.
-		if ((fresh || !g_fwdNewOnly) && g_slot[g_connSlot].used &&
-		    hid[g_connSlot].ready())
-			hid[g_connSlot].sendReport(
+		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
+			hid[slot].sendReport(
 				0x45, rep + 1,
 				blen); // Steam/SDL Triton: input report 0x45
 	}
@@ -410,58 +421,73 @@ void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
 
 // Forward the controller's NON-input status reports (0x43 power/battery, 0x44) to Steam verbatim -- the real
 // puck does this and it's how Steam reads battery. Same host-asleep / post-resume gating as 0x45; no lizard
-// path (status reports aren't input, so they forward regardless of the lizard decision).
-void SteamPuckController::onAuxReport(uint8_t rid, const uint8_t *data,
-				      uint8_t n)
+// path (status reports aren't input, so they forward regardless of the lizard decision). Per-slot: each
+// controller's status goes to its own hid[slot].
+void SteamPuckController::onAuxReport(int slot, uint8_t rid,
+				      const uint8_t *data, uint8_t n)
 {
 	if (USBDevice.suspended())
 		return;
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 		return;
-	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+	if (slot < 0 || slot >= NSLOT)
 		return;
-	if (g_slot[g_connSlot].used && hid[g_connSlot].ready())
-		hid[g_connSlot].sendReport(rid, data, n);
+	// Forward the controller's status report VERBATIM (the real puck does this; it's how the host reads
+	// battery). Padding the report to the descriptor-declared length broke battery in both lizard and
+	// Steam, so it's reverted -- send exactly what the controller sent.
+	if (g_slot[slot].used && hid[slot].ready())
+		hid[slot].sendReport(rid, data, n);
 }
 
 // wake nudge: a bare USB resume signal is NOT enough to wake some hosts (Windows in particular) -- they only
 // wake when actual mouse/keyboard input follows. So on a deliberate wake gesture we play a HARMLESS mouse
 // JIGGLE (move a few px right, then back -- NET ZERO cursor, NO button): real mouse activity wakes the host
 // but clicks/activates nothing (an open Start menu stays open). Queued by wakeEvent() (rf_link, on a Steam
-// short press / controller connect while suspended); delivered once the suspended bus has resumed.
-static uint8_t g_nudgeStep = 0; // 0=idle; 1=jiggle+, 2=jiggle-
-static unsigned long g_nudgeMs = 0;
+// short press / controller connect while suspended); delivered once the suspended bus has resumed. Per-slot
+// so every connected interface gets the jiggle (Windows can credit any of them with the wake).
+static uint8_t g_nudgeStep[NSLOT] = { 0 }; // 0=idle; 1=jiggle+, 2=jiggle-
+static unsigned long g_nudgeMs[NSLOT] = { 0 };
 #define NUDGE_JIGGLE_PX 10
 void SteamPuckController::wakeEvent()
 {
-	g_nudgeStep = 1;
-	g_nudgeMs = millis();
+	// arm the jiggle on every connected slot -- any one of them is enough to wake Windows
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used) {
+			g_nudgeStep[s] = 1;
+			g_nudgeMs[s] = millis();
+		}
+	}
 }
 static void wakeNudgeTask()
 {
-	if (!g_nudgeStep)
-		return;
-	if (millis() - g_nudgeMs > 5000) {
-		g_nudgeStep = 0;
-		return;
-	} // bus never resumed -> drop the nudge
 	if (USBDevice.suspended())
 		return; // wait for resume; reports can't cross a suspended bus
-	if (g_connSlot < 0 || g_connSlot >= NSLOT || !hid[g_connSlot].ready())
-		return;
-	static unsigned long stepMs = 0;
-	if (millis() - stepMs < 15)
-		return; // pace the edges
-	stepMs = millis();
-	hid_mouse_report_t m;
-	m.buttons = 0;
-	m.x = (g_nudgeStep == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX;
-	m.y = 0;
-	m.wheel = 0;
-	m.pan = 0;
-	hid[g_connSlot].sendReport(0x40, &m,
-				   sizeof m); // jiggle right, then back
-	g_nudgeStep = (g_nudgeStep >= 2) ? 0 : (uint8_t)(g_nudgeStep + 1);
+	static unsigned long stepMs[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_nudgeStep[s])
+			continue;
+		if (millis() - g_nudgeMs[s] > 5000) {
+			g_nudgeStep[s] = 0;
+			continue;
+		} // bus never resumed -> drop the nudge
+		if (!hid[s].ready())
+			continue;
+		if (millis() - stepMs[s] < 15)
+			continue; // pace the edges
+		stepMs[s] = millis();
+		hid_mouse_report_t m;
+		m.buttons = 0;
+		m.x = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX :
+					      -NUDGE_JIGGLE_PX;
+		m.y = 0;
+		m.wheel = 0;
+		m.pan = 0;
+		hid[s].sendReport(0x40, &m,
+				  sizeof m); // jiggle right, then back
+		g_nudgeStep[s] = (g_nudgeStep[s] >= 2) ?
+					 0 :
+					 (uint8_t)(g_nudgeStep[s] + 1);
+	}
 }
 
 // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
@@ -483,26 +509,33 @@ void SteamPuckController::task()
 	// no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
 	if (USBDevice.suspended())
 		return;
-	static bool usbConn = false;
-	static unsigned long last79 = 0, last7B = 0, connEdgeMs = 0;
-	bool conn = (g_connSlot >= 0 && millis() - g_connReplyMs < 300);
-	if (g_connSlot >= 0 && g_connSlot < NSLOT && hid[g_connSlot].ready()) {
+	// Per-slot 0x79/0x7B: each connected slot reports its OWN edge and its OWN status. State arrays are
+	// per-slot so each controller's "connected" edge fires once and is re-sent only until Steam acks THAT
+	// 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 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_slot[s].used || !hid[s].ready())
+			continue;
+		bool conn = (millis() - g_connReplyMs[s] < 300);
 		// 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
 		// settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; an
 		// unconditional forever-resend re-triggers Steam's connect handling (connect chime) every 750ms before
 		// 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) >= 0;
-		if (conn != usbConn ||
-		    (conn && !steamAcked && millis() - last79 >= 750)) {
-			if (conn && !usbConn)
-				connEdgeMs = millis();
+				  (int32_t)(g_steamAliveMs - connEdgeMs[s]) >=
+					  0;
+		if (conn != usbConn[s] ||
+		    (conn && !steamAcked && millis() - last79[s] >= 750)) {
+			if (conn && !usbConn[s])
+				connEdgeMs[s] = millis();
 			uint8_t st = conn ? 0x02 : 0x01;
-			hid[g_connSlot].sendReport(0x79, &st, 1);
-			usbConn = conn;
-			last79 = millis();
-		} else if (conn && millis() - last7B >= 2000) {
+			hid[s].sendReport(0x79, &st, 1);
+			usbConn[s] = conn;
+			last79[s] = millis();
+		} else if (conn && millis() - last7B[s] >= 2000) {
 			// 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed
 			// dBm (capture showed 0xDD = -35) -- patch in the smoothed RSSI the radio samples on each
 			// controller reply (rf_link). 0 = no sample yet -> keep the capture value rather than garbage.
@@ -514,17 +547,20 @@ void SteamPuckController::task()
 			// so the bar tracks usable range, not antenna gain. Clamp keeps it in a sane window.
 			uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
 					    0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
-			if (g_linkRssi) {
-				int mag = (int)g_linkRssi - RSSI_DBM_OFFSET;
+			if (g_linkRssi[s]) {
+				int mag = (int)g_linkRssi[s] - RSSI_DBM_OFFSET;
 				if (mag < 25)
 					mag = 25;
 				else if (mag > 95)
 					mag = 95;
 				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
 			}
-			hid[g_connSlot].sendReport(0x7B, s7b, 12);
-			last7B = millis();
+			hid[s].sendReport(0x7B, s7b, 12);
+			last7B[s] = millis();
 		}
-	} else if (!conn)
-		usbConn = false;
+	}
+	// Reset edge state for slots that are no longer used/ready (so a re-bond sees a fresh edge).
+	for (int s = 0; s < NSLOT; s++)
+		if (!g_slot[s].used || !hid[s].ready())
+			usbConn[s] = false;
 }

+ 2 - 2
OpenPuck/puck_hid.h

@@ -21,9 +21,9 @@ extern uint8_t g_fwdNewOnly;
 class SteamPuckController : public IController {
     public:
 	void begin() override;
-	void onReport45(const uint8_t *rep, bool fresh,
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
 			uint8_t bodyTlen) override;
-	void onAuxReport(uint8_t rid, const uint8_t *data, uint8_t n)
+	void onAuxReport(int slot, uint8_t rid, const uint8_t *data, uint8_t n)
 		override; // forward controller 0x43/0x44 status to Steam
 	void task() override;
 

+ 51 - 16
OpenPuck/radio.cpp

@@ -1,4 +1,5 @@
 #include "radio.h"
+#include "bonds.h" // g_slot[]
 
 // Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key preceding "ibex" is a DIFFERENT key,
 // NOT the discovery address.
@@ -10,32 +11,66 @@ uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
 // data bursts -> reply-rate crash. Tunable 'C'.
 uint8_t g_sessCh = 18;
 
-// Safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
-uint8_t g_sessBase[4] = { 0x69, 0x62, 0x65, 0x78 };
-uint8_t g_sessPrefix = 0x10;
-
-void rfGenSessionAddr()
+// Safe defaults; rfGenSessionAddr(slot) overwrites each used slot at boot. Discovery base "ibex" as a
+// degenerate starting point means an UN-bonded slot (and a not-yet-initialized one) won't accidentally
+// cross-talk with itself before the real address is set.
+uint8_t g_sessBase[NSLOT][4];
+uint8_t g_sessPrefix[NSLOT];
+static void sessSetDefault(int slot)
+{
+	for (int i = 0; i < 4; i++)
+		g_sessBase[slot][i] = g_rfBase[i];
+	g_sessPrefix[slot] = g_rfPrefix;
+}
+void rfGenSessionAddr(int slot)
 {
-	// Mix the 64-bit FICR DEVICEID into a 4-byte base + prefix: deterministic per chip, unique across chips
-	// -> two OpenPucks never collide on the connected session. Avoid degenerate address bytes (0x00/0xFF) for
-	// clean preamble correlation, and never reproduce the shared discovery base "ibex" (else isolation is lost).
-	uint32_t h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^
+	if (slot < 0 || slot >= NSLOT) {
+		sessSetDefault(0);
+		return;
+	}
+	sessSetDefault(slot);
+	// Per-bond UUID (rec[0..8]) + FICR DEVICEID (8 bytes) -> 5-byte on-air address. Mixing the bond UUID in
+	// makes the address deterministic per (puck, controller) pair: a slot's address is stable across reboots
+	// without storing anything new in bonds.bin, and the same controller bonded to two OpenPucks gets two
+	// different session addresses (DEVICEID differs) so the two pucks never collide on-air. The original
+	// generator (UUID-free) is preserved as the no-UUID fallback for an empty/zero bond record.
+	const uint8_t *uuid = g_slot[slot].rec;
+	bool uuidLive = false;
+	for (int i = 0; i < 8; i++)
+		if (uuid[i]) {
+			uuidLive = true;
+			break;
+		}
+	uint32_t h, h2;
+	if (uuidLive) {
+		h = (uint32_t)uuid[0] * 0x9E3779B1u ^ (uint32_t)uuid[2] * 0x85EBCA6Bu ^
+		    (uint32_t)uuid[4] * 0xC2B2AE35u ^ (uint32_t)uuid[6] ^
+		    NRF_FICR->DEVICEID[0];
+		h2 = (uint32_t)uuid[1] * 0x27D4EB2Fu ^ (uint32_t)uuid[3] * 0x165667B1u ^
+		     (uint32_t)uuid[5] ^ (uint32_t)uuid[7] * 0x9E3779B1u ^
 		     NRF_FICR->DEVICEID[1];
-	uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
-		      NRF_FICR->DEVICEID[0];
+	} else {
+		h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
+		h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
+	}
+	// Scrub degenerate bytes (0x00/0xFF) for clean preamble correlation.
 	for (int i = 0; i < 4; i++) {
 		uint8_t b = (uint8_t)(h >> (i * 8));
 		if (b == 0x00 || b == 0xFF)
 			b ^= 0x5A;
-		g_sessBase[i] = b;
+		g_sessBase[slot][i] = b;
 	}
 	uint8_t p = (uint8_t)(h2 >> 16);
 	if (p == 0x00 || p == 0xFF)
 		p = 0x5C;
-	g_sessPrefix = p;
-	if (g_sessBase[0] == g_rfBase[0] && g_sessBase[1] == g_rfBase[1] &&
-	    g_sessBase[2] == g_rfBase[2] && g_sessBase[3] == g_rfBase[3])
-		g_sessBase[0] ^= 0x80; // collided with discovery base "ibex"
+	g_sessPrefix[slot] = p;
+	// Never reproduce the shared discovery base "ibex" (else the on-air address is shared with every other
+	// controller scanning the rendezvous -> isolation is lost).
+	if (g_sessBase[slot][0] == g_rfBase[0] &&
+	    g_sessBase[slot][1] == g_rfBase[1] &&
+	    g_sessBase[slot][2] == g_rfBase[2] &&
+	    g_sessBase[slot][3] == g_rfBase[3])
+		g_sessBase[slot][0] ^= 0x80;
 }
 
 uint8_t rfrx[100], rftx[100];

+ 11 - 8
OpenPuck/radio.h

@@ -27,16 +27,19 @@ extern const uint8_t PAIR_BASE[4]; // "ibex"
 extern uint8_t g_rfPrefix; // discovery prefix (rodata byte @0x56f98)
 extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
 extern uint8_t g_rfBase[4]; // "ibex"
-// connected-session channel: a CLEAN data channel off the congested adv ch2
+// connected-session channel: a CLEAN data channel off the congested adv ch2 (shared by all slots on a puck)
 extern uint8_t g_sessCh;
 
-// Per-device SESSION address (base+prefix). Discovery stays on the SHARED "ibex" address (g_rfBase) so any
-// controller can find us; the host frame advertises THIS unique address and the controller adopts it for the
-// session. Two OpenPucks therefore never share an on-air session address -> no crosstalk, no cross-wake.
-// Derived from the FICR DEVICEID (stable per chip, unique across chips).
-extern uint8_t g_sessBase[4];
-extern uint8_t g_sessPrefix;
-void rfGenSessionAddr();
+// Per-bond SESSION address (base+prefix). Discovery stays on the SHARED "ibex" address (g_rfBase) so any
+// controller can find us; the host frame advertises THIS slot's unique address and the controller adopts it
+// for the session. Two OpenPucks never share an on-air session address, and two slots on the SAME puck have
+// distinct addresses so each controller only hears its own polls -> no crosstalk, no cross-wake, no per-slot
+// relay broadcast. Derived from the bond UUID (8 bytes) + FICR DEVICEID (8 bytes) so the address is
+// deterministic per (puck, controller) pair and stable across reboots without a bonds.bin schema bump.
+#include "bonds.h" // NSLOT
+extern uint8_t g_sessBase[NSLOT][4];
+extern uint8_t g_sessPrefix[NSLOT];
+void rfGenSessionAddr(int slot);
 
 // RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
 extern uint8_t rfrx[100], rftx[100];

+ 298 - 168
OpenPuck/rf_link.cpp

@@ -63,19 +63,33 @@ uint16_t g_pollsps = 0;
 uint16_t g_pollPeriodUs = 0;
 static uint32_t g_pollDtSum = 0;
 static uint16_t g_pollDtCnt = 0;
-// smoothed |dBm| of the controller's replies (0 = none yet)
-volatile uint8_t g_linkRssi = 0;
-// battery % from the controller's report 0x43 (body[1]); 0 = none yet
-volatile uint8_t g_battery = 0;
+// smoothed |dBm| of the controller's replies, per slot (0 = none yet)
+volatile uint8_t g_linkRssi[NSLOT] = { 0 };
+// battery % from the controller's report 0x43 (body[1]); 0 = none yet. Per-slot -- the active controller's
+// battery is the most recently seen one (other slots' values stay in their own array slots).
+volatile uint8_t g_battery[NSLOT] = { 0 };
+
+// Slot the poll loop is currently driving. Set by rfConnStep before each E7/relay/E3, consumed by the
+// decode (g_in[g_curSlot]), the haptic flush (per-slot session address), and the per-second stat dump.
+int g_curSlot = -1;
 
 // ---- internal counters / timers ----
-static uint8_t g_e3pid = 0;
+// ESB PID is 2 bits. The controller dequeues a FRESH report only when the poll's PID differs from the
+// last one it saw on that pipe; a repeated PID reads as a retransmit and returns the SAME (stale) report.
+// PER SLOT, because a single shared counter advances by (2 * nWarm) per cycle -- with 2 controllers that's
+// +4 per cycle = 0 mod 4, so each slot's GET PID is constant => the controller never dequeues => ~60 new/s
+// instead of ~400. Each slot's counter increments once per poll-of-that-slot so it cycles 0,1,2,3 cleanly.
+static uint8_t g_pollPid[NSLOT] = {};
+static uint8_t g_relayPid[NSLOT] = {};
 static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
 static unsigned long g_stMs = 0;
-static uint8_t g_lastSeq = 0;
+// Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
+// slots into a single sequence makes one controller "swallow" the other's frame).
+static uint8_t g_lastSeq[NSLOT] = { 0 };
 static uint32_t g_stNew = 0;
 static uint32_t g_stCrc = 0, g_stNoRx = 0;
 static uint32_t g_chF1[3] = { 0, 0, 0 };
+// Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz.
 static uint32_t g_lastPollUs = 0;
 static uint32_t g_connRx = 0;
 static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
@@ -85,9 +99,20 @@ static unsigned long g_lastStream = 0;
 // proteus_uuid, b[10..14]=ibex_uuid). Built like PROTEUS FUN_00027e9a. Sent on the shared rendezvous addr;
 // the controller filters by the uuids in the payload, then connects.
 // Transmit one host frame. `discovery`=true sends it on the SHARED rendezvous address ("ibex"/ch2) where a
-// searching controller looks; =false sends it on this puck's unique SESSION address (the keepalive once the
-// controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel, so
-// the controller always learns the unique address to connect on.
+// searching controller looks; =false sends it on this slot's unique SESSION address (the keepalive once the
+// controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel,
+// so the controller always learns the unique address to connect on.
+
+// Any-slot link helper: true if ANY bonded slot is currently hearing F-type replies (within 300 ms).
+// Used for the "we're connected to at least one controller" decisions (beacon pacing, wake detect).
+bool anySlotLinkUp()
+{
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && millis() - g_connReplyMs[s] < 300)
+			return true;
+	return false;
+}
+
 static void rfHostFrameOnce(int slot, bool discovery)
 {
 	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
@@ -110,13 +135,14 @@ static void rfHostFrameOnce(int slot, bool discovery)
 	// payload[9] session channel: controller runs the session on this clean
 	// channel (adopts buf[0xe]); discovery beacon still TXes on ch2
 	rftx[11] = g_sessCh;
-	// payload[13..17] session base  (the per-device UNIQUE address)
-	memcpy(rftx + 15, g_sessBase, 4);
-	rftx[19] = g_sessPrefix; // payload[17] session prefix
-	// TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses our unique address
-	// (where the controller now listens). The advertised session params (above) are identical either way.
-	const uint8_t *txBase = discovery ? g_rfBase : g_sessBase;
-	uint8_t txPfx = discovery ? g_rfPrefix : g_sessPrefix;
+	// payload[13..17] session base  (the per-bond UNIQUE address; each controller adopts its own)
+	memcpy(rftx + 15, g_sessBase[slot], 4);
+	rftx[19] = g_sessPrefix[slot]; // payload[17] session prefix
+	// TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses this slot's
+	// unique address (where THIS controller now listens). The advertised session params (above) are
+	// identical either way -- so the discovery frame can also double as a re-advertisement if needed.
+	const uint8_t *txBase = discovery ? g_rfBase : g_sessBase[slot];
+	uint8_t txPfx = discovery ? g_rfPrefix : g_sessPrefix[slot];
 	rfConfig(g_rfCh);
 	rfSetAddr(txBase, txPfx);
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
@@ -162,19 +188,27 @@ static void rfHostFrameOnce(int slot, bool discovery)
 
 void rfHopTo(uint8_t newCh)
 {
-	if (g_connSlot < 0 || newCh == g_sessCh)
+	// QoS hop is shared across all slots -- we run all connected sessions on the same channel for simplicity.
+	// The per-slot session ADDRESS is what isolates the controllers from each other; the channel is global.
+	if (g_curSlot < 0 || newCh == g_sessCh)
 		return;
 	uint8_t cur = g_sessCh, savedRfCh = g_rfCh;
 	g_sessCh = newCh;
-	// host frame now advertises newCh but is TXed on cur (session addr)
+	// host frame now advertises newCh but is TXed on cur (per-slot session addr)
 	g_rfCh = cur;
-	for (int k = 0; k < 6; k++) {
-		rfHostFrameOnce(g_connSlot, false);
-		delayMicroseconds(700);
-	}
+	for (int s = 0; s < NSLOT; s++)
+		for (int k = 0; k < 6; k++) {
+			rfHostFrameOnce(s, false);
+			delayMicroseconds(700);
+		}
 	g_rfCh = savedRfCh; // poll + session beacon now run on g_sessCh=newCh
 }
 
+// TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
+// rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
+// no reply, so they don't burn a full ~1.2ms window of dead air per haptic. Per-slot: the connected poll runs
+// on this slot's UNIQUE session address (one address per bonded controller); replies are demuxed by which
+// address the controller answers on (each controller only hears its own).
 uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 		 uint16_t rxWinUs)
 {
@@ -185,8 +219,10 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 	rftx[1] = s1; // S1 (type-specific)
 	memcpy(rftx + 2, payload, plen); // payload[0]=type byte, then data/TLVs
 	rfConfig(ch);
-	// connected poll runs on this puck's UNIQUE session addr
-	rfSetAddr(g_sessBase, g_sessPrefix);
+	// connected poll runs on this slot's UNIQUE session addr (per-bond). g_curSlot is set by rfConnStep
+	// before each call; fall back to slot 0 if not (e.g. rf_diag paths).
+	int slot = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	rfSetAddr(g_sessBase[slot], g_sessPrefix[slot]);
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
 	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
 			    RADIO_SHORTS_END_DISABLE_Msk;
@@ -227,24 +263,27 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			// 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
 			// connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
 			if (rtype >= 0xF0) {
+				int s = (g_curSlot >= 0 && g_curSlot < NSLOT) ?
+						g_curSlot :
+						0;
 				// A reply after a long gap (or the first ever) = a (re)connect. Arm the haptic block + re-init HERE,
 				// directly off the reply stream -- reliable even when hapticTask's 300ms link-up edge doesn't fire
 				// (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
-				if (g_connReplyMs == 0 ||
-				    (uint32_t)(millis() - g_connReplyMs) >
+				if (g_connReplyMs[s] == 0 ||
+				    (uint32_t)(millis() - g_connReplyMs[s]) >
 					    1500u)
-					hapticOnReconnect();
+					hapticOnReconnect(s);
 				g_connRx++;
 				// link alive -> loop() suppresses the redundant E1 beacon
-				g_connReplyMs = millis();
+				g_connReplyMs[s] = millis();
 				// |dBm| of this reply (started by the ADDRESS short)
 				uint8_t rs =
 					(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
-				// EWMA, ~8-sample horizon
+				// EWMA, ~8-sample horizon, per-slot
 				if (rs)
-					g_linkRssi =
-						g_linkRssi ?
-							(uint8_t)((g_linkRssi *
+					g_linkRssi[s] =
+						g_linkRssi[s] ?
+							(uint8_t)((g_linkRssi[s] *
 									   7u +
 								   rs + 4u) /
 								  8u) :
@@ -252,9 +291,20 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			}
 			if (rtype == 0xF1)
 				g_stF1++;
-			// controller disconnecting/powering off -> back off 2.5s
-			if (rtype == 0xF2)
-				g_connCooldown = millis();
+			// controller disconnecting/powering off -> back off 2.5s so we don't immediately re-wake it.
+			// BUT only when no OTHER slot is still live: g_connCooldown is global and gates ALL polling +
+			// beacons, so backing off because ONE controller powered off would drop every other connected
+			// controller for 2.5s (a real multi-controller disconnect). The powering-off slot goes silent
+			// and ages out via SLOT_COLD on its own.
+			if (rtype == 0xF2) {
+				int others = 0;
+				for (int i = 0; i < NSLOT; i++)
+					if (i != g_curSlot && g_slot[i].used &&
+					    millis() - g_connReplyMs[i] < 300)
+						others++;
+				if (others == 0)
+					g_connCooldown = millis();
+			}
 			// F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
 			if (rtype == 0xF3) {
 				g_stF3++;
@@ -293,80 +343,96 @@ 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);
+							(rep[1] !=
+							 g_lastSeq[g_curSlot]);
 						// genuine new report vs stale poll-repeat
 						if (fresh) {
 							g_stNew++;
-							g_lastSeq = 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;
 							}
 						}
 						// Decode the report into the shared g_in (one source, read by every IController).
-						g_in.buttons = bb;
+						g_in[g_curSlot].buttons = bb;
 						// Global power-off chord: Steam + Y held 2 s -> shut the controller down (any mode). Detect on the raw
 						// `bb` (pre-mask), time-based (poll rate varies), fires once per hold, re-arms only after release. While
-						// held, mask Steam+Y out of g_in.buttons so the press doesn't leak to the host -- in EVERY mode except
+						// held, mask Steam+Y out of g_in[g_curSlot].buttons so the press doesn't leak to the host -- in EVERY mode except
 						// regular Steam (mode 0 forwards the raw 0x45 to Steam, which owns the Steam button). Runs before
 						// onReport45 below so push modes (Xbox) see the mask too; stream modes read g_in in task() (also masked).
 						{
+							// Per-slot: each controller's Steam+Y hold is independent, so the debounce timer must
+							// be per-slot. With multiple controllers, a shared timer gets reset every time ANOTHER
+							// slot's poll (without the hold) runs -- and the round-robin poll cycles through every
+							// used slot, so the timer never reaches 2s.
 							static unsigned long
-								offHoldMs = 0;
-							static bool offFired =
-								false;
+								offHoldMs[NSLOT] = {
+									0, 0, 0,
+									0
+								};
+							static bool offFired
+								[NSLOT] = {
+									false,
+									false,
+									false,
+									false
+								};
 							if ((bb & (TB_STEAM |
 								   TB_Y)) ==
 							    (TB_STEAM | TB_Y)) {
-								if (offHoldMs ==
+								if (offHoldMs[g_curSlot] ==
 								    0)
-									offHoldMs =
+									offHoldMs[g_curSlot] =
 										millis();
 								else if (
-									!offFired &&
+									!offFired[g_curSlot] &&
 									(unsigned long)(millis() -
-											offHoldMs) >=
+											offHoldMs[g_curSlot]) >=
 										2000u) {
-									offFired =
+									offFired[g_curSlot] =
 										true;
 									hapticSendShutdown();
 								}
 								if (g_usbMode !=
 								    MODE_STEAM) {
 									// stream modes read g_in
-									g_in.buttons &=
+									g_in[g_curSlot]
+										.buttons &=
 										~(uint32_t)(TB_STEAM |
 											    TB_Y);
 									// push modes read btnsOf(rep): TB_Y in rep[2],
@@ -383,32 +449,40 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 											   16);
 								}
 							} else {
-								offHoldMs = 0;
-								offFired =
+								offHoldMs[g_curSlot] =
+									0;
+								offFired[g_curSlot] =
 									false;
 							}
 						}
-						g_in.lx =
+						g_in[g_curSlot].lx =
 							(int16_t)s16off(rep, 8);
-						g_in.ly = (int16_t)s16off(rep,
-									  10);
-						g_in.rx = (int16_t)s16off(rep,
-									  12);
-						g_in.ry = (int16_t)s16off(rep,
-									  14);
-						g_in.lt =
+						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.rt =
+						g_in[g_curSlot].rt =
 							trigU8(u16off(rep, 6));
-						g_in.lpx = (int16_t)s16off(rep,
-									   16);
-						g_in.lpy = (int16_t)s16off(rep,
-									   18);
-						g_in.rpx = (int16_t)s16off(rep,
-									   22);
-						g_in.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
@@ -417,13 +491,21 @@ 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.ax,
-								  &g_in.ay,
-								  &g_in.az,
-								  &g_in.gx,
-								  &g_in.gy,
-								  &g_in.gz);
-						// Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in.buttons stays
+							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) ==
 						    CHORD_BACK4)
@@ -436,8 +518,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(
-								rep, fresh,
-								tlen);
+								g_curSlot, rep,
+								fresh, tlen);
 						lastRep = rep;
 						lastTlen = tlen;
 					} else if (ttype == 6 &&
@@ -453,10 +535,15 @@ 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_battery = rep[2];
+						if (rep[0] == 0x43 &&
+						    tlen >= 3 &&
+						    g_curSlot >= 0 &&
+						    g_curSlot < NSLOT)
+							g_battery[g_curSlot] =
+								rep[2];
 						if (g_active)
 							g_active->onAuxReport(
+								g_curSlot,
 								rep[0], rep + 1,
 								(uint8_t)(tlen -
 									  1));
@@ -465,21 +552,35 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				}
 				// mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
 				{
-					static uint8_t chWant = 0xFF, chCnt = 0;
+					// Per-slot debounce: the chord input is per-slot (g_in[g_curSlot]), so the debounce counter
+					// must be too. The shared-static form worked with 1 controller because slot 0 polled
+					// back-to-back; with N>1, the round-robin poll cycles through every used slot, and the OTHER
+					// 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 };
 					uint8_t want = 0xFF;
-					if ((g_in.buttons & CHORD_BACK4) ==
-					    CHORD_BACK4) {
-						if (g_in.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.buttons & TB_B)
+						else if (g_in[g_curSlot].buttons &
+							 TB_B)
 							want = g_chordBtn[0];
-						else if (g_in.buttons & TB_X)
+						else if (g_in[g_curSlot].buttons &
+							 TB_X)
 							want = g_chordBtn[1];
-						else if (g_in.buttons & TB_Y)
+						else if (g_in[g_curSlot].buttons &
+							 TB_Y)
 							want = g_chordBtn[2];
 					}
-					if (want != 0xFF && want == chWant) {
-						if (++chCnt >= 12 &&
+					if (want != 0xFF &&
+					    want == chWant[g_curSlot]) {
+						if (++chCnt[g_curSlot] >= 12 &&
 						    want != g_usbMode &&
 						    modeValid(want) &&
 						    !USBDevice.suspended()) {
@@ -488,8 +589,9 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							NVIC_SystemReset();
 						}
 					} else {
-						chWant = want;
-						chCnt = (want != 0xFF) ? 1 : 0;
+						chWant[g_curSlot] = want;
+						chCnt[g_curSlot] =
+							(want != 0xFF) ? 1 : 0;
 					}
 				}
 				// compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
@@ -532,23 +634,35 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 	return rxlen;
 }
 
-// Drive the connected-mode sequence one step per call. Camps on g_sessCh (the host-frame channel the
-// controller connected on); the host-frame beacon runs in parallel as keepalive.
+// Throttle: bonded slots that haven't replied in SLOT_COLD_MS are polled at most every SLOT_COLD_RETRY_MS
+// instead of every cycle. This keeps the online controllers at full 250 Hz while barely touching offline ones.
+#define SLOT_COLD_MS 5000u
+#define SLOT_COLD_RETRY_MS 2000u
+static unsigned long g_slotLastAttemptMs[NSLOT] = {};
+
+// Drive the connected-mode sequence. The cycle gate fires once per g_pollUs (250 Hz); each fire
+// polls EVERY warm slot back-to-back so all bonded controllers run at full rate regardless of count.
+// "Cold" means the slot WAS connected but has been silent for > SLOT_COLD_MS; slots that have never
+// replied stay warm so new controllers can connect at any time after boot. Cold slots retry every
+// SLOT_COLD_RETRY_MS.
 static void rfConnStep()
 {
-	g_connSlot = -1;
-	for (int s = 0; s < NSLOT; s++)
-		if (g_slot[s].used) {
-			g_connSlot = s;
+	int firstUsed = -1;
+	for (int k = 0; k < NSLOT; k++)
+		if (g_slot[k].used) {
+			firstUsed = k;
 			break;
 		}
-	// need a bonded slot (session established by host frame)
-	if (g_connSlot < 0)
+	if (firstUsed < 0) {
+		g_curSlot = -1;
 		return;
+	}
+
 	uint8_t ch = g_sessCh;
 	// announce HOST AWAKE: E7 00 00, a few times. The real puck does NOT do this (the controller streams F1 to a
 	// bare E3 with no E7) -- skipped unless g_e7announce ('n') re-enables the legacy handshake.
 	if (g_connSt == 0) {
+		g_curSlot = firstUsed;
 		if (g_e7announce) {
 			uint8_t p[3] = { 0xE7, 0x00, g_e7b };
 			rfConnTx(ch, 0x01, p, 3);
@@ -563,66 +677,79 @@ static void rfConnStep()
 			g_connSt = 1;
 			g_connStep = 0;
 		}
-		// poll loop: E3 (bare, or +GET report 0x45) every poll; re-assert awake periodically if g_e7announce
-	} else {
-		// CONTROLLED CADENCE: poll ~every g_pollUs
-		if ((uint32_t)(micros() - g_lastPollUs) < g_pollUs)
-			return;
-		// FIXED-RATE schedule: advance the deadline by exactly one interval (NOT reset-to-now, which would add
-		// each cycle's ~1ms of poll work + loop jitter to the next interval -- the period drifts to ~5000us
-		// instead of the 4000us target). The resync guard prevents a catch-up burst after a real stall.
-		{
-			uint32_t now = micros();
-			static uint32_t lastFire = 0;
-			// MEASURED actual period
-			if (lastFire) {
-				g_pollDtSum += (uint32_t)(now - lastFire);
-				g_pollDtCnt++;
-			}
-			lastFire = now;
-			g_lastPollUs += g_pollUs;
-			// fell >1 interval behind -> resync
-			if ((uint32_t)(now - g_lastPollUs) >= g_pollUs)
-				g_lastPollUs = now;
+		return;
+	}
+
+	// Cycle gate: fires once per g_pollUs (250 Hz). On each fire we poll EVERY warm slot
+	// back-to-back so all bonded controllers run at full rate -- the real puck services all
+	// controllers per cycle. Polling one-slot-per-call instead tied the per-slot rate to the
+	// loop frequency AND split it across slots, collapsing 2 controllers to ~90 Hz.
+	uint32_t nowUs = micros();
+	if ((uint32_t)(nowUs - g_lastPollUs) < (uint32_t)g_pollUs)
+		return;
+	{
+		// Cycle period stat: time between gate fires (= each slot's poll period, intended g_pollUs).
+		static uint32_t lastCycleUs = 0;
+		if (lastCycleUs) {
+			g_pollDtSum += (uint32_t)(nowUs - lastCycleUs);
+			g_pollDtCnt++;
 		}
+		lastCycleUs = nowUs;
+	}
+	g_lastPollUs += g_pollUs;
+	if ((uint32_t)(nowUs - g_lastPollUs) >= (uint32_t)g_pollUs)
+		g_lastPollUs = nowUs; // catch-up reset when a cycle overran
+
+	unsigned long nowMs = millis();
+
+	// Inline poll helper; emits E7 re-assert (every 32 polls, bounded reply window so a
+	// missed F3 doesn't burn the whole slot budget), queues haptics, flushes relay, sends E3 GET.
+	auto doPoll = [&](int k) {
+		g_slotLastAttemptMs[k] = nowMs;
+		g_curSlot = k;
+		// re-assert awake/version every 32 polls (legacy; real puck never sends E7 -- gated by g_e7announce)
 		if (g_e7announce && (g_connPoll & 0x1F) == 0) {
 			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
-			rfConnTx(ch, 0x01, pa, 3);
-		} // re-assert awake/version (legacy; real puck never sends E7)
+			rfConnTx(ch, 0x01, pa, 3, 600);
+		}
 		rfConnQueueHapticRelay();
-		// Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
-		// and the controller never dedups the GET as a retransmit of the relay (that was dropping ~half the replies
-		// during haptics). Advancing g_e3pid even when no relay is pending just skips a PID value (harmless).
 		{
-			uint8_t rs1 = (uint8_t)((((g_e3pid++) & 3) << 1) | 1);
+			uint8_t rs1 =
+				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
 			rfConnFlushRelay(ch, rs1);
 		}
-		{
-			// cycle PID (S1 1,3,5,7), NO_ACK=1
-			// cycle PID (S1 0,2,4,6), NO_ACK=0
-			// fixed (matches captured puck poll)
-			uint8_t s1 =
-				(g_e3mode == 1) ?
-					(uint8_t)((((g_e3pid++) & 3) << 1) |
-						  1) :
-				(g_e3mode == 2) ?
-					(uint8_t)(((g_e3pid++) & 3) << 1) :
-					0x07;
-			uint8_t rx;
-			if (g_pollGet) {
-				// legacy: E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
-				uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45,
-						 g_getParam };
-				rx = rfConnTx(ch, s1, p, 5);
-			} else {
-				// real puck: BARE E3 (just the opcode) -- the controller streams F1 to any E3 ack
-				uint8_t p[1] = { 0xE3 };
-				rx = rfConnTx(ch, s1, p, 1);
-			}
-			if (rx)
-				g_chF1[0]++;
+		// per-slot PID cycle so each bonded controller's polls stay distinct
+		uint8_t pidv = g_pollPid[k]++;
+		uint8_t s1 = (g_e3mode == 1) ?
+				     (uint8_t)(((pidv & 3) << 1) | 1) :
+			     (g_e3mode == 2) ? (uint8_t)((pidv & 3) << 1) :
+					       0x07;
+		uint8_t rx;
+		if (g_pollGet) {
+			// legacy: E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
+			uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45, g_getParam };
+			rx = rfConnTx(ch, s1, p, 5);
+		} else {
+			// real puck: BARE E3 (just the opcode) -- the controller streams F1 to any E3 ack
+			uint8_t p[1] = { 0xE3 };
+			rx = rfConnTx(ch, s1, p, 1);
 		}
+		if (rx)
+			g_chF1[0]++;
 		g_connPoll++;
+	};
+
+	// Poll every warm slot this cycle. "Cold" = was connected, now silent > SLOT_COLD_MS;
+	// those are retried only every SLOT_COLD_RETRY_MS. Never-replied slots count as warm so
+	// a controller that connects at any time gets the full poll rate to establish its link.
+	for (int k = 0; k < NSLOT; k++) {
+		if (!g_slot[k].used)
+			continue;
+		bool cold = g_connReplyMs[k] != 0 &&
+			    nowMs - g_connReplyMs[k] > SLOT_COLD_MS;
+		if (cold && nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+			continue;
+		doPoll(k);
 	}
 }
 
@@ -633,8 +760,7 @@ void rfLinkTask()
 	// drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect cooldown so a controller
 	// that's powering off isn't immediately re-woken/reconnected.
 	if (g_rfHost && millis() - g_connCooldown > 2500) {
-		bool connNow =
-			(g_connSlot >= 0 && millis() - g_connReplyMs < 300);
+		bool connNow = anySlotLinkUp();
 		// session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
 		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll). The real puck
 		// sends NO E1 on its session channel; gated by g_e1keepalive ('m') so this can be A/B'd on hardware --
@@ -646,7 +772,12 @@ void rfLinkTask()
 			for (int s = 0; s < NSLOT; s++)
 				rfHostFrameOnce(s, false);
 		}
-		// discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
+		// discovery beacon on ch2 (where a searching controller looks): every loop when nothing is
+		// connected (fastest cold-boot/late-joiner connect), every 200ms once ANY controller is linked.
+		// Matches main: while a slot is connected, ch2 discovery shares g_sessCh's air budget, so beaconing
+		// faster than 200ms (the old allUp-gated 40ms path) just steals reply windows from the connected
+		// controller -> reply-rate sag -> >1500ms gaps -> spurious hapticOnReconnect re-init. A late joiner
+		// still enumerates within ~1s at 200ms.
 		if (millis() - g_lastDisc >= (connNow ? 200u : 0u)) {
 			g_lastDisc = millis();
 			g_rfCh = 2;
@@ -658,10 +789,9 @@ void rfLinkTask()
 		rfConnStep();
 	} // connected-mode: poll controller, read input
 	{
-		// remote wakeup on new RF controller connection
+		// remote wakeup on new RF controller connection (any slot)
 		static bool wasRfConn = false;
-		bool nowRfConn =
-			(g_connSlot >= 0 && millis() - g_connReplyMs < 300);
+		bool nowRfConn = anySlotLinkUp();
 		if (nowRfConn && !wasRfConn && USBDevice.suspended()) {
 			USBDevice.remoteWakeup();
 			ledWakePulse();
@@ -672,7 +802,7 @@ void rfLinkTask()
 		wasRfConn = nowRfConn;
 	}
 	// QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
-	if (g_qos && g_connSlot >= 0 && millis() - g_qosCheckMs >= 600) {
+	if (g_qos && g_curSlot >= 0 && millis() - g_qosCheckMs >= 600) {
 		uint16_t bad = g_qosBad;
 		g_qosBad = 0;
 		g_qosCheckMs = millis();
@@ -705,7 +835,7 @@ void rfLinkTask()
 				(unsigned long)g_stNew, (unsigned long)g_stF3,
 				(int8_t)g_connF3v, g_e7b,
 				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
-				g_connSlot);
+				g_curSlot);
 		g_stPoll = 0;
 		g_stF1 = 0;
 		g_stNew = 0;

+ 20 - 8
OpenPuck/rf_link.h

@@ -17,6 +17,7 @@
 // F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
 #pragma once
 #include <stdint.h>
+#include "bonds.h" // NSLOT
 
 // ---- operational toggles + tunables (set from the CDC console / WebUSB; read here + by webusb status) ----
 // auto-start host beacon on boot (resumes puck role after a USB replug)
@@ -47,11 +48,19 @@ extern unsigned long g_connCooldown;
 // connected-mode state (reset by the 'k' console toggle)
 extern uint8_t g_connSt, g_connStep;
 extern uint16_t g_connPoll;
-extern uint32_t g_connF1; // count of 0xF1 input reports seen
+extern uint32_t g_connF1; // count of 0xF1 input reports seen (aggregate across all slots)
 
-// last protocol version the controller reported in an F3 reply (0xFF=none)
+// last protocol version the controller reported in an F3 reply (0xFF=none) -- aggregate (last writer wins)
 extern uint8_t g_connF3v;
 
+// Slot the poll loop is currently driving (set by rfConnStep before each E7/relay/E3). -1 = idle. Used by
+// the decode path (g_in[g_curSlot]), the haptic flush (per-slot session address), the wake detect, and the
+// stat dump.
+extern int g_curSlot;
+// True if ANY bonded slot is currently link-up (heard an F-reply within 300 ms). Used by the haptic
+// re-init gating (we only re-init on a real link) and the beacon pacing.
+bool anySlotLinkUp();
+
 // QoS adaptive channel hopping
 extern uint8_t g_qos; // 0=off (static g_sessCh), 1=auto-hop on degradation
 extern uint8_t g_hopIdx;
@@ -70,15 +79,18 @@ extern uint16_t g_pollsps;
 extern uint16_t g_pollPeriodUs;
 
 // Smoothed controller->puck signal strength, sampled by the radio (RSSISAMPLE) on each CRC-good controller
-// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. puck_hid reports it
-// to Steam in status report 0x7B byte 8 (signed dBm), after subtracting RSSI_DBM_OFFSET.
-extern volatile uint8_t g_linkRssi;
+// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. Per-slot: each
+// controller's RSSI is reported on its own 0x7B byte 8 (puck_hid reports it to Steam, after subtracting
+// RSSI_DBM_OFFSET).
+extern volatile uint8_t g_linkRssi[NSLOT];
 // dB to subtract from our raw RSSI magnitude before reporting, to match the real puck's close-range -35dBm
 // (compensates the Pro Micro antenna vs Valve's front-end). Tune against one known-distance reading.
 #define RSSI_DBM_OFFSET 20
-// Battery percent from the controller's periodic report 0x43 (body[1]); 0 = none yet. Surfaced in the WebUSB
-// panel; the raw 0x43 report is also forwarded to Steam verbatim (puck_hid onAuxReport) so Steam reads it itself.
-extern volatile uint8_t g_battery;
+// Battery percent from the controller's periodic report 0x43 (body[1]); 0 = none yet. Per-slot: each
+// controller's battery is read on its own 0x43 reply and stored against its slot index. The WebUSB panel
+// reads g_battery[g_curSlot] (the most recently active one). The raw 0x43 report is also forwarded to
+// Steam verbatim (puck_hid onAuxReport) so Steam reads it itself.
+extern volatile uint8_t g_battery[NSLOT];
 
 // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
 // rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect

+ 50 - 1
OpenPuck/serial_console.cpp

@@ -250,7 +250,8 @@ void serialConsolePoll()
 				uint16_t val = sp ? strtoul(sp, 0, 0) : 0;
 				uint8_t pl[3] = { id, (uint8_t)(val & 0xFF),
 						  (uint8_t)(val >> 8) };
-				relayEnqueue(0x87, pl, 3);
+				// console-injected writes go to all connected controllers (no specific slot in scope)
+				relayEnqueue(0x87, pl, 3, 0xFF);
 				Serial.printf(
 					"# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",
 					id, val);
@@ -299,6 +300,54 @@ void serialConsolePoll()
 				Serial.printf(
 					"# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",
 					g_qos ? "ON" : "off");
+			} 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);
+				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 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") :
+							"empty",
+						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]);
+				}
 			} else if (line[0] == 'r') {
 				g_rxWin = strtoul(line + 1, 0, 10);
 				if (g_rxWin < 150)

+ 1 - 1
OpenPuck/triton.cpp

@@ -1,6 +1,6 @@
 #include "triton.h"
 
-PuckInput g_in;
+PuckInput g_in[NSLOT];
 
 void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	       int16_t *gx, int16_t *gy, int16_t *gz)

+ 5 - 1
OpenPuck/triton.h

@@ -73,6 +73,9 @@ void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	       int16_t *gx, int16_t *gy, int16_t *gz);
 
 // ---- 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) 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
 	uint32_t buttons;
@@ -82,4 +85,5 @@ struct PuckInput {
 	int16_t ax, ay, az; // accelerometer
 	int16_t gx, gy, gz; // gyroscope
 };
-extern PuckInput g_in;
+#include "bonds.h" // NSLOT
+extern PuckInput g_in[NSLOT];

+ 33 - 10
OpenPuck/webusb_config.cpp

@@ -11,25 +11,30 @@
 
 Adafruit_USBD_WebUSB usb_web;
 
-// blob payload = [ver=2][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
+// blob payload = [ver=8][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
 //                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
-// keep the blob under 64 bytes total (readBlob reads one 64-byte packet)
-#define WB_PAYLEN 60
+//                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
+// v8 extends to 73 bytes (75 total incl header); browser reads with transferIn(128) to span the two USB-FS packets.
+#define WB_PAYLEN 73
 static void webusbSendBlob()
 {
 	if (!usb_web.connected())
 		return;
-	bool up = (g_connSlot >= 0 && (millis() - g_connReplyMs) < 300);
+	// "connected" = the most recent poll cycle's slot had a fresh F-reply (matches what the firmware
+	// 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);
 	uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (7 = +raw accel; 6 = +swPro120/gyroScale; 5 = +rumbleScale)
-	p[2] = 7;
+	// protocol version (8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 8;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -39,7 +44,7 @@ static void webusbSendBlob()
 	p[9] = g_back[1];
 	p[10] = g_back[2];
 	p[11] = g_back[3];
-	p[12] = (g_connSlot >= 0) ? (uint8_t)g_connSlot : 0xFF;
+	p[12] = (g_curSlot >= 0) ? (uint8_t)g_curSlot : 0xFF;
 	p[13] = up ? 1 : 0;
 	p[14] = (uint8_t)g_f1ps;
 	p[15] = (uint8_t)(g_f1ps >> 8);
@@ -68,8 +73,13 @@ 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; // controller battery % (report 0x43); 0=unknown
-	p[39] = g_linkRssi; // RAW signal strength |dBm| (0=no sample)
+	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)
 	// git commit this firmware was built from + dirty flag; injected at build time
 	// (build_info.h / gen_version.sh); "unknown" if the version header wasn't generated.
 	p[40] = OPK_GIT_DIRTY ? 1 : 0;
@@ -87,9 +97,22 @@ static void webusbSendBlob()
 	// Switch Pro gyro sensitivity x10 (protocol v6)
 	p[55] = g_swGyroScale10;
 	{
-		int16_t a[3] = { g_in.ax, g_in.ay, g_in.az };
+		int16_t a[3] = { g_in[0].ax, g_in[0].ay, g_in[0].az };
 		memcpy(&p[56], a, 6);
 	} // raw accelerometer for scale diagnostics (protocol v7)
+
+	// per-slot link status for all bond slots (protocol v8)
+	p[62] = (uint8_t)bondedSlotCount();
+	{
+		unsigned long nowMs = millis();
+		for (int s = 0; s < NSLOT; s++) {
+			bool sup = g_slot[s].used && g_connReplyMs[s] != 0 &&
+				   (nowMs - g_connReplyMs[s]) < 300u;
+			p[63 + s * 3] = sup ? 1 : 0;
+			p[64 + s * 3] = g_battery[s];
+			p[65 + s * 3] = g_linkRssi[s];
+		}
+	}
 	usb_web.write(p, sizeof p);
 	usb_web.flush();
 }

+ 2 - 2
README.md

@@ -68,13 +68,13 @@ A webusb based configuration UI is available [here](https://safijari.github.io/o
 
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
-- Ensure multiple controllers can function correctly (if I can ever buy one)
+- Multi-controller support is now working in Steam/Lizard mode (up to 4 controllers, see the `U` CDC command for a per-slot status dump). Non-puck modes (Xbox, Switch, PS5, DS4) remain single-controller by design — the user can pair one controller and use the appropriate mode.
 - Design the charging portion (and make it short proof)
 
 # Contributions
 The firmware is split into small, single-responsibility modules under `OpenPuck/` (one file per emulated controller, plus the RF, config, and host-interface layers). Start with [ARCHITECTURE.md](./ARCHITECTURE.md) for the map of how it all fits together and how to add a new USB personality.
 
-I have tested this software fairly extensively but I have limited resources. I only have one controller so I actually don't know if multiple controllers even work correctly (in fact I'm almost sure they will not). Please submit issues with any issues you find. PRs also welcome of course.
+I have tested this software fairly extensively but I have limited resources. I only have one controller so I've been unable to do full end-to-end multi-controller hardware testing — the per-slot session address, per-slot USB fanout, and per-slot haptic demux are in place and the design is sound, but the `U` CDC command exists specifically so anyone with a second controller can verify the per-slot state at a glance. Please submit issues with any issues you find. PRs also welcome of course.
 
 # Acknowledgements
 - Valve for putting out the amazing controller

+ 3 - 0
docs/PROTOCOL.md

@@ -189,6 +189,9 @@ Semantics:
 - `proteus_uuid` is the first 4 bytes of the bond record.
 - `ibex_uuid` is the next 4 bytes of the bond record.
 - The host chooses the connected-session channel and address and advertises them here.
+- The session base/prefix are PER-BOND (one address per controller, derived from the bond UUID + the
+  puck's FICR DEVICEID so two pucks that bond the same controller still isolate). The real puck
+  supports up to four simultaneous controllers on the same channel; OpenPuck mirrors that.
 
 ## 7. Connected session
 

+ 165 - 50
docs/index.html

@@ -33,6 +33,20 @@
   .note{color:var(--mut);font-size:12px;margin-top:10px}
   #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:120px;overflow:auto}
   .hide{display:none}
+
+  /* ---- controller slot tabs ---- */
+  .slot-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
+  .slot-tab{display:flex;align-items:center;gap:7px;padding:5px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#0e1017;border:1px solid #232a3a;color:var(--mut);cursor:pointer;transition:border-color .15s,color .15s,background .15s}
+  .slot-tab:hover{border-color:var(--acc2);color:var(--fg)}
+  .slot-tab.active{background:#111c2e;border-color:var(--acc2);color:var(--fg)}
+  .slot-tab .dot{width:7px;height:7px;border-radius:50%;background:var(--bad);flex-shrink:0;transition:background .3s}
+  .slot-tab .dot.up{background:var(--acc);animation:pulse-link 2.2s ease-in-out infinite}
+  @keyframes pulse-link{
+    0%,100%{box-shadow:0 0 0 0 rgba(91,214,160,.5)}
+    55%{box-shadow:0 0 0 5px rgba(91,214,160,0)}
+  }
+  @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
+  .per-slot-grid{margin-bottom:12px}
 </style>
 </head>
 <body>
@@ -59,9 +73,6 @@
         <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
         <button data-mode="5" class="modebtn">PS5 DualSense</button>
         <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
-        <!-- modes 7/8 (PS5/DS4 "game/clean") hidden from the panel for now; still reachable via serial console -->
-        <!-- <button data-mode="7" class="modebtn">PS5 (game/clean)</button> -->
-        <!-- <button data-mode="8" class="modebtn">DS4 (game/clean)</button> -->
       </div>
       <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only.</p>
       <div class="row"><label>Persist last mode</label>
@@ -80,11 +91,19 @@
 
     <div class="card">
       <h2>Link status</h2>
-      <div class="statgrid">
+
+      <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
+      <div class="slot-tabs" id="slotTabs" style="display:none"></div>
+
+      <!-- Per-slot: RF link / Battery / Signal -->
+      <div class="statgrid per-slot-grid">
         <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
         <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
         <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
-        <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
+      </div>
+
+      <!-- Global stats -->
+      <div class="statgrid">
         <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
         <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
         <div class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
@@ -92,7 +111,10 @@
         <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
         <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
         <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
+        <div class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
       </div>
+      <p class="note" style="margin-top:6px"><strong>Reset history</strong> (newest first, persists across power-off): <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
+        <em>watchdog</em>/<em>LOCKUP</em> = a fault (loop hang / crash); <em>soft reboot</em> = an intentional reset (mode change); <em>power-on</em> = normal plug-in.</p>
       <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
         Raw SC2 accel (before scaling). This controller is a ±2 g sensor (~16384 = 1 g); the Switch report divides it by 4 to present the genuine ±8 g (4096 = 1 g) scale so the console's gravity-correction engages and the gyro stops drifting.</p>
       <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
@@ -137,7 +159,6 @@
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
     </div>
 
-
     <div class="card">
       <h2>Button mapping</h2>
       <div class="row"><label>A/B + X/Y swap</label>
@@ -185,31 +206,60 @@ for(const sel of document.querySelectorAll("select.back")){
   for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
 }
 for(const sel of document.querySelectorAll("select.chord")){
-  MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;   // hide PS5/DS4 "game/clean" modes from the panel for now
+  MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
-// Switch Pro report-rate options (0=66Hz, 1=120Hz default, 2=full ~250Hz)
 { const sel=document.getElementById("swRate");
   for(const [lbl,v] of [["120 Hz (default)",1],["66 Hz (compatibility)",0],["Full rate (~250 Hz)",2]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
-// Switch Pro gyro-scale options (value = scale x10, so it fits in one byte)
 { const sel=document.getElementById("swGyro");
   for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
 
+// ---- per-slot tab state ----
+let g_activeSlot = 0;
+let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
+
+function updateSlotDisplay(slotIdx) {
+  const d = g_slotData[slotIdx];
+  if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—"; return; }
+  $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+  $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
+  $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
+}
+
+function renderSlotTabs(bondedCount) {
+  const bar = $("#slotTabs");
+  if (bondedCount <= 1) { bar.style.display="none"; updateSlotDisplay(g_activeSlot); return; }
+  bar.style.display="flex";
+  bar.innerHTML="";
+  for (let s=0; s<4; s++) {
+    const d = g_slotData[s];
+    if (!d) continue;
+    const btn = document.createElement("button");
+    btn.className = "slot-tab" + (s===g_activeSlot ? " active" : "");
+    const dot = document.createElement("span");
+    dot.className = "dot" + (d.up ? " up" : "");
+    btn.appendChild(dot);
+    btn.appendChild(document.createTextNode("Controller "+(s+1)));
+    btn.onclick = () => { g_activeSlot=s; renderSlotTabs(bondedCount); };
+    bar.appendChild(btn);
+  }
+  updateSlotDisplay(g_activeSlot);
+}
+
 async function connect(){
   try{
     dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
     await dev.open();
     if(dev.configuration===null) await dev.selectConfiguration(1);
-    // WebUSB = vendor class 0xFF with bulk IN + bulk OUT (skip XInput 0xFF/5D/01 which uses interrupt only)
     let found=null;
     for(const itf of dev.configuration.interfaces){
       const a=itf.alternate;
       if(a.interfaceClass!==0xFF) continue;
-      if(a.interfaceSubClass===0x5D) continue;   // Xbox XInput vendor
+      if(a.interfaceSubClass===0x5D) continue;
       let bin=0,bout=0;
       for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
       if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
@@ -217,14 +267,11 @@ async function connect(){
     if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); return; }
     ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
     await dev.claimInterface(ifNum);
-    // WebUSB "connect" handshake: Adafruit_USBD_WebUSB only enables write() / connected()
-    // after the host sends SET_CONTROL_LINE_STATE (bRequest 0x22, value=1) on this interface.
     await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
     $("#connState").textContent="connected"; $("#connState").className="pill up";
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
     log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
-    dev.onUSBConnectionEvent;
     navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
     startPolling();
   }catch(e){ log("connect failed: "+e.message); }
@@ -242,10 +289,10 @@ async function send(bytes){
 }
 async function readBlob(){
   try{
-    const r = await dev.transferIn(epIn, 64);
+    // v8 blob is 75 bytes (two USB-FS packets); read 128 to capture both in one call.
+    const r = await dev.transferIn(epIn, 128);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
-    // find 0xA5 frame
     let i=0; while(i<d.length && d[i]!==0xA5) i++;
     if(i+2>d.length) return null;
     const len=d[i+1]; const p=d.slice(i+2, i+2+len);
@@ -253,52 +300,123 @@ async function readBlob(){
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 function applyBlob(p){
-  // [ver][mode][mDiv][mFric][rsvd][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100(unused)][newlo][newhi]
-  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];   // p[4] = QAM remap code (0 = default)
+  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
-  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];   // v2 blob: chordBtn after qos/persist (Y defaults to Switch Pro=4)
-  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);          // poll TX rate
-  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);         // loop diag
+  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
+  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
+  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
   const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
   const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
-  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);         // MEASURED poll period
-  const pollIntended=(p.length>14?p[14]*100:0);            // intended poll interval (g_pollUs)
-  const logEnabled=(p.length>35?p[35]:0);                  // is this a logging build (-DOPK_LOG=1)?
+  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);
+  const pollIntended=(p.length>14?p[14]*100:0);
+  const logEnabled=(p.length>35?p[35]:0);
   for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
-  // mode buttons
   for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
-  // status
-  const battery=(p.length>36?p[36]:0);                    // controller battery % (report 0x43); 0 = unknown
-  const rumbleScale=(p.length>51?p[51]:200);              // rumble strength % (protocol v5+; older blob -> default)
-  const swProRate=(p.length>52?p[52]:1);                  // Switch Pro report rate 0/1/2 (protocol v6+; default 120Hz)
-  const swGyro10=(p.length>53?p[53]:10);                  // Switch Pro gyro scale x10 (protocol v6+)
-  const rssi=(p.length>37?p[37]:0);                       // RAW signal strength |dBm| (0 = no sample)
-  $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
-  $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
-  $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
-  $("#stSlot").textContent = slot===0xFF? "—" : slot;
+  const battery=(p.length>36?p[36]:0);
+  const rumbleScale=(p.length>51?p[51]:200);
+  const swProRate=(p.length>52?p[52]:1);
+  const swGyro10=(p.length>53?p[53]:10);
+  const rssi=(p.length>37?p[37]:0);
+
+  // per-slot link status (protocol v8, p.length >= 73)
+  const hasSlotsData = p.length >= 73;
+  if (hasSlotsData) {
+    const bondedN = p[60];
+    g_slotData = [];
+    for (let s=0; s<4; s++) {
+      const base = 61 + s*3;
+      if (p.length >= base+3) {
+        // only include bonded slots (a slot with up=0 could be bonded but offline)
+        // check if the slot appears at all in the bonded map by checking if bondedN covers it;
+        // the firmware only sets non-zero battery/rssi for actually-bonded slots
+        const slotUp = !!p[base];
+        const slotBatt = p[base+1];
+        const slotRssi = p[base+2];
+        // slot is "bonded" if firmware included it in bondedN coverage
+        // we populate slotData for all 4 and renderSlotTabs skips gaps
+        g_slotData[s] = {up: slotUp, battery: slotBatt, rssi: slotRssi};
+      }
+    }
+    // keep only bonded slots by cross-referencing bondedN
+    // firmware sends bondedSlotCount but not a slot bitmask; use battery>0 || rssi>0 || up as presence hint
+    // simpler: just count how many of the 4 slots have any non-zero data OR are marked up
+    // Actually the firmware always writes all 4 slots; we filter by the bond system's "used" flag via presence.
+    // For clean display: we'll show tabs only for slots that have ever had a reply OR are bonded (battery/rssi set).
+    // The firmware sets g_battery[s]=0 and g_linkRssi[s]=0 for unused slots, so 0/0/0 = not bonded.
+    // But a bonded offline slot also has 0/0/0 initially. We rely on bondedN for count and show the first bondedN slots
+    // that have g_slot[s].used — but we can't easily know which slots are used from the blob.
+    // Best heuristic: show all 4 slots if bondedN==4, otherwise use slot indices where battery or rssi != 0 OR up==1,
+    // OR fall back to showing slots 0..bondedN-1 if none have data yet (device just booted).
+    let bondedSlots = [];
+    for (let s=0; s<4; s++) {
+      if (g_slotData[s] && (g_slotData[s].up || g_slotData[s].battery || g_slotData[s].rssi)) bondedSlots.push(s);
+    }
+    // fallback: if no data yet (all offline, just booted), show slots 0..bondedN-1
+    if (bondedSlots.length === 0 && bondedN > 0) {
+      for (let s=0; s<bondedN; s++) bondedSlots.push(s);
+    }
+    // remove slotData for non-bonded slots so tabs don't appear for them
+    const bondedSet = new Set(bondedSlots);
+    for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
+    // clamp active slot to a valid bonded slot
+    if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
+    renderSlotTabs(bondedN);
+  } else {
+    // old firmware: single-slot display
+    $("#slotTabs").style.display="none";
+    $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+    $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
+    $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
+  }
+
+  // global stats
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
   $("#stPolls").textContent = up? polls+" /s" : "—";
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
-  // build provenance (protocol v4+): dirty flag at p[38], 12-byte NUL-padded git hash at p[39..50]. Independent
-  // of link state. Older firmware sends a shorter blob -> stays "—".
+  // reset-cause history (protocol v9): JS p[73]=count, p[74..83]=last 10 causes (newest first).
+  // Persisted in puck flash, so a reboot that happened on a Switch is still readable here after replugging.
+  function resetReasonText(rr){
+    if (rr === 0) return "power-on";
+    const r = [];
+    if (rr & 0x02) r.push("watchdog");      // DOG: loop wedge / hang
+    if (rr & 0x08) r.push("LOCKUP");        // CPU lockup = HardFault escalation (real crash)
+    if (rr & 0x04) r.push("soft reboot");   // SREQ: NVIC_SystemReset (mode change etc.)
+    if (rr & 0x01) r.push("reset pin");
+    return r.length ? r.join("+") : ("0x"+rr.toString(16));
+  }
+  if (p.length > 73) {
+    const n = Math.min(p[73], 10);
+    const hist = [];
+    for (let i=0; i<n; i++) hist.push(p[74+i]);
+    const fault = (rr)=>!!(rr & 0x0A); // watchdog or lockup = a real fault
+    if (n === 0) { $("#stReset").textContent = "—"; }
+    else {
+      const newest = hist[0];
+      const head = fault(newest) ? ('<span class="pill dn">'+resetReasonText(newest)+'</span>') : resetReasonText(newest);
+      $("#stReset").innerHTML = head;
+      // full history list under the tile (newest -> oldest)
+      const items = hist.map((rr,i)=>{
+        const t = resetReasonText(rr);
+        return (i===0?'<strong>':'')+ '#'+(n-i)+' '+(fault(rr)?('<span style="color:#ff6b6b">'+t+'</span>'):t) +(i===0?' (latest)</strong>':'');
+      });
+      const el = $("#resetHist"); if (el) el.innerHTML = items.join(' &nbsp;·&nbsp; ');
+    }
+  }
   const gitDirty=(p.length>38?p[38]:0); let gitHash="";
   for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = gitHash
     ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     : "—";
-  // sliders (only if not being dragged)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
   { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
-  // raw accelerometer diagnostic (protocol v7): 3x s16 LE at p[54..59]
   if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
     const ax=s16(54),ay=s16(56),az=s16(58);
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
@@ -313,7 +431,7 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing) return; inflight=true;   // don't let the status poll steal capture frames
+  if(!dev||inflight||capturing) return; inflight=true;
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
 }
@@ -322,9 +440,9 @@ async function setField(field,value){
 }
 function fmtCapEntry(e,maxMs){
   const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
-  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;   // age vs the newest entry (consistent across drain batches)
+  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;
   if(e.slot===0xFD){ const k=e.bytes[0]; return `${t}════════ ${k===2?"RECONNECT (block+reinit armed)":k===1?"RF LINK UP":"RF LINK DOWN"} ════════`; }
-  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;                        // 0xFE = frame WE sent the controller
+  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;
   return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")}  n=${e.nb}:  ${hex}`;
 }
 function downloadCap(){
@@ -333,11 +451,10 @@ function downloadCap(){
   a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
   a.download="puck-capture.txt"; a.click();
 }
-// ---- live Start/Stop capture: poll the firmware's drain (op 0x06) on a timer and accumulate in the browser ----
-let liveTimer=null, draining=false, capLines=[];   // capLines = array of {ms,slot,rid,nb,bytes}
+let liveTimer=null, draining=false, capLines=[];
 function renderCap(){
   if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
-  const maxMs=capLines[capLines.length-1].ms;       // entries are chronological -> newest is last
+  const maxMs=capLines[capLines.length-1].ms;
   const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
   $("#capOut").textContent=t; window._lastCap=t;
 }
@@ -372,15 +489,15 @@ async function drainTick(){
 }
 async function startCapture(){
   if(!dev||capturing) return;
-  capturing=true; capLines=[];                       // capturing=true pauses the status poll so it can't steal frames
+  capturing=true; capLines=[];
   $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
   $("#capStart").disabled=true; $("#capStop").disabled=false;
-  await send([0x05,1]);                               // rewind drain to OLDEST -> dumps the whole ring from boot
+  await send([0x05,1]);
   liveTimer=setInterval(drainTick,100);
 }
 async function stopCapture(){
   if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
-  await drainTick();                                  // final flush
+  await drainTick();
   try{ await send([0x05,0]); }catch(e){}
   capturing=false;
   $("#capStart").disabled=false; $("#capStop").disabled=true;
@@ -410,12 +527,10 @@ $("#dfuUf2").onclick=async()=>{
 };
 $("#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.
   if(!confirm("Factory erase?\n\nThis wipes ALL persistent storage on the copycat:\n  • the paired-controller bond (you'll have to re-pair)\n  • every saved setting (mode, chords, back paddles, sensitivity)\n\nThe copycat reboots to factory defaults. This CANNOT be undone.")) return;
   if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
   const typed=prompt('Final confirmation — type  ERASE  (all caps) to wipe everything:');
   if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
-  // op 0x0A + magic "ERS" (0x45,0x52,0x53) -- firmware reformats the FS and reboots; the device drops off USB.
   await send([0x0A,0x45,0x52,0x53]);
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };