Browse Source

feat: support connecting multiple controllers to the puck

Joshua LeBlanc 4 tháng trước cách đây
mục cha
commit
c05fa62867

+ 28 - 6
OpenPuck/OpenPuck.ino

@@ -47,8 +47,11 @@ void setup()
 	genSerial();
 	ledInit();
 
-	// per-device unique RF session address (advertised in the host frame; isolates pucks)
-	rfGenSessionAddr();
+	// per-device default RF session address (per-slot). The real generation is below, AFTER loadBonds, so
+	// the address can be derived from the bond UUID for each slot. The pre-load call here is just to
+	// populate the defaults so a slot with no bond doesn't accidentally share the discovery address.
+	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 +59,13 @@ void setup()
 	factoryResetOnce(OPK_GIT_HASH);
 #endif
 	loadCfg();
+	loadBonds();
+	// Now that bonds are loaded, regenerate per-slot session addresses from each bond's UUID. This is
+	// deterministic (a slot's address is stable across reboots) and avoids persisting the address in
+	// bonds.bin -- no schema bump needed.
+	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);
@@ -133,7 +143,8 @@ void setup()
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
-	loadBonds();
+	// loadBonds() was called earlier (after the bond-UUID-derived session addresses were generated);
+	// this is a no-op now but kept for forward-compat (a future migration to lazy-loading).
 	hapticInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
@@ -152,9 +163,20 @@ 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.

+ 1 - 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;
 

+ 7 - 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,10 +19,11 @@ 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;
 

+ 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` and emit at a fixed cadence from task() reading g_in[0].
 //   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;

+ 2 - 2
OpenPuck/gamepad_util.cpp

@@ -185,8 +185,8 @@ uint32_t psButtonsFromSteam(uint32_t raw)
 uint8_t psShouldersByte(uint32_t b)
 {
 	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) |
+	       ((g_in[0].lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
+	       ((g_in[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);

+ 93 - 46
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)
@@ -55,7 +58,7 @@ static bool g_hapClearArmed = false;
 // 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.
 struct RelayMsg {
-	uint8_t rid, len;
+	uint8_t rid, len, slot;
 	uint8_t data[RELAY_MAXP];
 };
 // deep enough to hold a full Steam settings/LED transaction burst without loss
@@ -73,7 +76,8 @@ bool relayPending()
 {
 	return g_rqHead != g_rqTail;
 }
-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)
@@ -88,6 +92,7 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen)
 		g_rqTail = rqNext(g_rqTail);
 	g_rq[h].rid = rid;
 	g_rq[h].len = plen;
+	g_rq[h].slot = slot;
 	if (plen)
 		memcpy(g_rq[h].data, payload, plen);
 	g_rqHead = nx;
@@ -138,8 +143,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 +187,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)
 {
@@ -236,8 +250,9 @@ static void hapticCancelPendingOn()
 	}
 	__set_PRIMASK(pm);
 }
-void haptic82HostReport(const uint8_t *p, uint16_t n)
+void haptic82HostReport(const uint8_t *p, uint16_t n, int slot)
 {
+	(void)slot; // the on/off flag is global -- one relay stream keeps the watchdog armed across slots
 	if (n < 3)
 		return;
 	g_haptic82Ms = millis();
@@ -279,13 +294,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, 0))
 		return false;
 	g_rumble80Ms = millis();
 	g_rumble80On = 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,20 +311,32 @@ 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): scan the queue for the first entry that targets the CURRENT slot (g_curSlot),
+// or the broadcast sentinel 0xFF. Emit it on that slot's session address (so each controller only sees
+// its own relay). With 4 connected slots the per-slot relay rate is 1/4 of the per-cycle rate; sustained
+// buzz streams are still 1 packet/cycle/slot which the controller's haptic engine handles fine.
 void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 {
+	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	while (g_rqTail != g_rqHead) {
 		RelayMsg &m = g_rq[g_rqTail];
 
 		// rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
 		if (m.rid) {
+			// Only relay entries for the CURRENT slot (or broadcast 0xFF). If the entry is for
+			// another slot, leave it in the queue -- its slot's turn will pick it up.
+			if (m.slot != 0xFF && m.slot != cur) {
+				// advance past it without consuming; the per-slot flush is the consumer
+				g_rqTail = rqNext(g_rqTail);
+				continue;
+			}
 			uint8_t rl = m.len;
 			if (rl > RELAY_MAXP)
 				rl = RELAY_MAXP;
@@ -366,7 +396,10 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 // 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()
+//
+// slot defaults to 0xFF (broadcast to all connected) -- the clear re-init is a settings-only reset and
+// is harmless on healthy controllers, so it's worth re-initializing every slot the firmware knows about.
+void hapticReinit(uint8_t slot)
 {
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
 				       0x00, 0x08, 0x07, 0x00, 0x31,
@@ -382,22 +415,23 @@ 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;
+	// boot: block stale Steam 0x82 until link stable (every slot)
+	for (int s = 0; s < NSLOT; s++)
+		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	// NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
 	// zero-gain [01 01 00] "stop". An invented stop frame at boot/connect is a likelier cause of the connect-time
 	// buzz than a cure. Be a pure pass-through of Steam's haptics, like the real puck.
@@ -405,11 +439,15 @@ void hapticInit()
 }
 // 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()
+// link-up edge. Idempotent -- safe to call repeatedly. Per-slot: only the slot that just reconnected is
+// blocked, the other slots keep relaying. The scheduled re-init is global (broadcast) so any slot's
+// latch gets cleared.
+void hapticOnReconnect(int slot)
 {
-	// no haptics relayed for the next 3s
-	g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	// no haptics relayed for the next 3s (per-slot)
+	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_haptic82On = false;
 	g_rumble80On = false;
 
@@ -429,21 +467,30 @@ void hapticOnReconnect()
 }
 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: any slot going from "down" to "up" gets the haptic block + re-init
+	// scheduled for that slot. The rf_link decode already calls hapticOnReconnect on the first F-reply
+	// after a gap (reliable); this loop is a backup for slots whose link-up the decode didn't see directly
+	// (e.g. a long gap followed by a quiet stretch).
+	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 &&
+	// Proactive haptic re-init (scheduled at (re)connect by hapticOnReconnect): the re-init is
+	// settings-only so broadcasting to all connected slots is fine, and it's the same per-slot
+	// re-init the controller would have received on its own connect.
+	if (g_reinitAt && anySlotLinkUp() &&
 	    (int32_t)(millis() - g_reinitAt) >=
 		    0) { // proactive haptic re-init across the connect window
 		hapticReinit();

+ 20 - 10
OpenPuck/haptics.h

@@ -18,6 +18,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
@@ -43,8 +44,12 @@
 
 // ---- 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);
+// goes on the air). `slot` is the originating bond slot (0..NSLOT-1) or 0xFF for "broadcast to every
+// connected controller" (used by hapticSendShutdown / hapticReinit / test haptics -- the ones that must
+// reach every controller, not just the one that triggered them). ISR-safe (brief PRIMASK critical
+// section). Returns false (dropped) when the ring is full.
+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 +58,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,17 +91,18 @@ 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);
+void haptic82HostReport(const uint8_t *p, uint16_t n, int slot = -1);
 bool hapticSteamRumble(
 	uint16_t lowFreq,
 	uint16_t highFreq); // queue Steam/Triton output report 0x80 rumble
 
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
-// PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
+// PID counter), so the controller doesn't dedup the GET as a retransmit of the relay. The flush only emits
+// entries tagged with the CURRENT slot (set by rfConnStep), so each controller only sees its own relay.
 void rfConnQueueHapticRelay();
 void rfConnFlushRelay(uint8_t ch, uint8_t s1);
 
@@ -105,9 +112,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);

+ 21 - 21
OpenPuck/mode_hidgyro.cpp

@@ -95,39 +95,39 @@ static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 
 static void hidGyroBuild(uint8_t out[63])
 {
-	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	uint32_t b = psButtonsFromSteam(g_in[0].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[0].lx, false);
+	out[1] = swStick(g_in[0].ly, true);
+	out[2] = swStick(g_in[0].rx, false);
+	out[3] = swStick(g_in[0].ry, true);
 	out[4] = psHatNibble(b) | psFaceNibble(b);
 	out[5] = psShouldersByte(b);
 	static uint8_t ctr = 0;
 	out[6] = ((ctr++ & 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[0].lt;
+	out[8] = g_in[0].rt;
+	out[12] = g_in[0].gx & 0xFF;
+	out[13] = g_in[0].gx >> 8;
+	out[14] = g_in[0].gz & 0xFF;
+	out[15] = g_in[0].gz >> 8;
+	out[16] = (-g_in[0].gy) & 0xFF;
+	out[17] = (-g_in[0].gy) >> 8;
+	out[18] = g_in[0].ax & 0xFF;
+	out[19] = g_in[0].ax >> 8;
+	out[20] = g_in[0].ay & 0xFF;
+	out[21] = g_in[0].ay >> 8;
+	out[22] = g_in[0].az & 0xFF;
+	out[23] = g_in[0].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);
+		steamPadsToTouch(b, DS4_TOUCH_H, g_in[0].lpx, g_in[0].lpy, g_in[0].rpx,
+				 g_in[0].rpy, &lx, &ly, &rx, &ry);
 		static uint8_t tstamp = 0;
 		out[32] = 1;
 		out[33] = tstamp++;

+ 20 - 20
OpenPuck/mode_ps5.cpp

@@ -115,16 +115,16 @@ static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 
 static void ps5Build(uint8_t out[63])
 {
-	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	uint32_t b = psButtonsFromSteam(g_in[0].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;
+	out[0] = swStick(g_in[0].lx, false);
+	out[1] = swStick(g_in[0].ly, true);
+	out[2] = swStick(g_in[0].rx, false);
+	out[3] = swStick(g_in[0].ry, true);
+	out[4] = g_in[0].lt;
+	out[5] = g_in[0].rt;
 	static uint8_t seq = 0;
 	out[6] = seq++;
 	out[7] = psHatNibble(b) | psFaceNibble(b);
@@ -132,20 +132,20 @@ static void ps5Build(uint8_t out[63])
 	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[0].gx & 0xFF;
+	out[16] = g_in[0].gx >> 8;
+	out[17] = g_in[0].gz & 0xFF;
+	out[18] = g_in[0].gz >> 8;
+	out[19] = (-g_in[0].gy) & 0xFF;
+	out[20] = (-g_in[0].gy) >> 8;
+	out[21] = g_in[0].ax & 0xFF;
+	out[22] = g_in[0].ax >> 8;
+	out[23] = g_in[0].ay & 0xFF;
+	out[24] = g_in[0].ay >> 8;
+	out[25] = g_in[0].az & 0xFF;
+	out[26] = g_in[0].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,
+	steamPadsToTouch(b, PS5_TOUCH_H, g_in[0].lpx, g_in[0].lpy, g_in[0].rpx, g_in[0].rpy,
 			 &lx, &ly, &rx, &ry);
 	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
 	out[52] = PS5_STATUS_USB;

+ 7 - 7
OpenPuck/mode_switch_hori.cpp

@@ -76,7 +76,7 @@ static inline void backCodeToHatDirs(uint8_t c, bool &u, bool &d, bool &l,
 // 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])
 {
-	uint32_t b = g_in.buttons;
+	uint32_t b = g_in[0].buttons;
 	uint16_t btn = 0;
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
@@ -101,9 +101,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[0].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		btn |= 0x40;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[0].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		btn |= 0x80;
 	if (b & TB_MENU)
 		btn |= 0x100;
@@ -154,10 +154,10 @@ 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[0].lx, false);
+	out[4] = swStick(g_in[0].ly, true); // HID Y is down-positive -> invert
+	out[5] = swStick(g_in[0].rx, false);
+	out[6] = swStick(g_in[0].ry, true);
 	out[7] = 0;
 }
 

+ 11 - 11
OpenPuck/mode_switch_pro.cpp

@@ -190,7 +190,7 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
 static uint8_t g_jcTimer = 0;
 static void jcInputPrefix(uint8_t *out)
 {
-	uint32_t b = g_in.buttons;
+	uint32_t b = g_in[0].buttons;
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
 		b |= tritonFromCode(g_qamMap);
@@ -214,9 +214,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[0].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		jc |= JC_BTN_ZL;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[0].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		jc |= JC_BTN_ZR;
 	if (b & TB_VIEW)
 		jc |= JC_BTN_PLUS;
@@ -256,8 +256,8 @@ 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[0].lx, g_in[0].ly);
+	jcPackStick(out + 8, g_in[0].rx, g_in[0].ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 }
@@ -278,14 +278,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[0].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[0].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[0].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[0].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[0].gx));
+	int16_t gyaw = gscale((int16_t)g_in[0].gz);
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
 		out[o + 0] = aX & 0xFF;

+ 2 - 1
OpenPuck/mode_xinput.cpp

@@ -373,9 +373,10 @@ void XboxController::begin()
 	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)slot; // xinput mode is single-controller by design
 	(void)fresh;
 	(void)bodyTlen;
 	rfXboxGamepad(rep);

+ 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

+ 98 - 66
OpenPuck/puck_hid.cpp

@@ -153,16 +153,17 @@ 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).
 				if (rid == 0x82)
-					haptic82HostReport(b, n);
+					haptic82HostReport(b, n, slot);
 			}
 		}
 
@@ -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,11 +214,11 @@ 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)
-				haptic82HostReport(pl, len);
+				haptic82HostReport(pl, len, slot);
 		}
 	}
 	if (Serial.availableForWrite() > 80) {
@@ -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,70 @@ 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);
+	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 +506,32 @@ 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 +543,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];

+ 122 - 75
OpenPuck/rf_link.cpp

@@ -46,16 +46,23 @@ 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;
 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 };
@@ -68,9 +75,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)
@@ -93,13 +111,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;
@@ -145,19 +164,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)
 {
@@ -168,8 +195,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;
@@ -210,24 +239,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) :
@@ -276,11 +308,11 @@ 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
@@ -319,10 +351,10 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							}
 						}
 						// 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).
 						{
@@ -349,7 +381,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 								if (g_usbMode !=
 								    MODE_STEAM) {
 									// stream modes read g_in
-									g_in.buttons &=
+									g_in[g_curSlot].buttons &=
 										~(uint32_t)(TB_STEAM |
 											    TB_Y);
 									// push modes read btnsOf(rep): TB_Y in rep[2],
@@ -371,26 +403,26 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 									false;
 							}
 						}
-						g_in.lx =
+						g_in[g_curSlot].lx =
 							(int16_t)s16off(rep, 8);
-						g_in.ly = (int16_t)s16off(rep,
+						g_in[g_curSlot].ly = (int16_t)s16off(rep,
 									  10);
-						g_in.rx = (int16_t)s16off(rep,
+						g_in[g_curSlot].rx = (int16_t)s16off(rep,
 									  12);
-						g_in.ry = (int16_t)s16off(rep,
+						g_in[g_curSlot].ry = (int16_t)s16off(rep,
 									  14);
-						g_in.lt =
+						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,
+						g_in[g_curSlot].lpx = (int16_t)s16off(rep,
 									   16);
-						g_in.lpy = (int16_t)s16off(rep,
+						g_in[g_curSlot].lpy = (int16_t)s16off(rep,
 									   18);
-						g_in.rpx = (int16_t)s16off(rep,
+						g_in[g_curSlot].rpx = (int16_t)s16off(rep,
 									   22);
-						g_in.rpy = (int16_t)s16off(rep,
+						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
@@ -400,13 +432,13 @@ 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)
@@ -419,7 +451,7 @@ 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,
+								g_curSlot, rep, fresh,
 								tlen);
 						lastRep = rep;
 						lastTlen = tlen;
@@ -436,11 +468,12 @@ 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(
-								rep[0], rep + 1,
+								g_curSlot, rep[0], rep + 1,
 								(uint8_t)(tlen -
 									  1));
 					}
@@ -450,15 +483,15 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				{
 					static uint8_t chWant = 0xFF, chCnt = 0;
 					uint8_t want = 0xFF;
-					if ((g_in.buttons & CHORD_BACK4) ==
+					if ((g_in[g_curSlot].buttons & CHORD_BACK4) ==
 					    CHORD_BACK4) {
-						if (g_in.buttons & TB_A)
+						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) {
@@ -515,19 +548,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.
+// Drive the connected-mode sequence one step per call. Round-robins the used slots, doing ONE slot's worth
+// of E7/relay/E3 per cycle. Per-slot poll rate = g_pollUs * usedSlotCount; with 1 controller 4ms/250Hz, with
+// 4 16ms/62.5Hz (matches the real puck's reported 4-controller pacing). g_curSlot is set before every TX
+// and consumed by the decode (g_in[g_curSlot]) and the haptic flush.
 static void rfConnStep()
 {
-	g_connSlot = -1;
+	// count + find the next used slot, starting AFTER the previous cycle's slot (round-robin).
+	int n = 0;
 	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used)
+			n++;
+	if (n == 0) {
+		g_curSlot = -1;
+		return;
+	}
+	int start = (g_curSlot < 0) ? 0 : ((g_curSlot + 1) % NSLOT);
+	int slot = -1;
+	for (int k = 0; k < NSLOT; k++) {
+		int s = (start + k) % NSLOT;
 		if (g_slot[s].used) {
-			g_connSlot = s;
+			slot = s;
 			break;
 		}
-	// need a bonded slot (session established by host frame)
-	if (g_connSlot < 0)
+	}
+	if (slot < 0) {
+		g_curSlot = -1;
 		return;
+	}
+	g_curSlot = slot;
 	uint8_t ch = g_sessCh;
 	// announce HOST AWAKE: E7 00 00, a few times
 	if (g_connSt == 0) {
@@ -601,8 +650,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)
 		if (millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
@@ -623,10 +671,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();
@@ -637,7 +684,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();
@@ -670,7 +717,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

@@ -12,6 +12,7 @@
 // sub-TLV. 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)
@@ -37,11 +38,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;
@@ -60,15 +69,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

+ 41 - 1
OpenPuck/serial_console.cpp

@@ -246,7 +246,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);
@@ -295,6 +296,45 @@ 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 blk = g_hapticBlockUntil[s] ?
+								    (unsigned long)((int32_t)(g_hapticBlockUntil[s] -
+											       millis()) >
+										     0 ?
+									     (g_hapticBlockUntil[s] -
+									      millis()) :
+									     0) :
+								    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) are single-controller and read g_in[0] from task(); 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];

+ 14 - 5
OpenPuck/webusb_config.cpp

@@ -23,7 +23,12 @@ 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;
@@ -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,12 @@ 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,7 +96,7 @@ 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)
 	usb_web.write(p, sizeof p);

+ 2 - 2
README.md

@@ -56,14 +56,14 @@ 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)
 - Design a 3D printable housing
 
 # 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