Prechádzať zdrojové kódy

dynamic mounting for multiple controllers

Jari 4 mesiacov pred
rodič
commit
ddaea1949c

+ 99 - 43
OpenPuck/OpenPuck.ino

@@ -34,6 +34,9 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "serial_console.h"
 #include "wake_hid.h"
 #include "status_led.h"
+#include "usb_mount.h"
+#include "identity.h"
+#include <stdio.h>
 
 #if CFG_TUD_HID < 4
 #error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
@@ -42,6 +45,37 @@ using namespace Adafruit_LittleFS_Namespace;
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
 static uint8_t g_usbCfgDesc[512];
 
+// Per-mode USB serial suffix (modes 1..8: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game).
+static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D' };
+// Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
+static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
+
+// Dynamic re-enumeration (NO MCU reboot): tear down + rebuild the config descriptor presenting `k` connected-
+// controller slots, replaying the fixed interfaces in locked HID-instance order (wake mouse = instance 0,
+// before the slot pool; WebUSB is vendor-class so order-free), then re-attach. RF/firmware state survives.
+// g_usbToBond must already be built for k connected controllers. Called at boot and by usb_mount's watcher.
+void usbReenumerate(uint8_t k)
+{
+	USBDevice.detach();
+	delay(20);
+	USBDevice.clearConfiguration();
+	USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
+	g_active->usbIdentity(); // clearConfiguration reset VID/PID/strings -- restore them
+	// serial carries the mounted count so the host invalidates its cached config descriptor on a change
+	snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
+		 MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= 8) ? g_usbMode - 1 :
+							         0],
+		 (unsigned)k);
+	USBDevice.setSerialDescriptor(g_usbSerial);
+	if (s_dynWantWakeMouse)
+		wakeHidAddInterface(); // HID instance 0
+	g_active->mountSlots(k); // mode's fixed HIDs (if any) + k slot interfaces
+	if (s_dynWantWebusb)
+		USBDevice.addInterface(usb_web);
+	USBDevice.setConfigurationAttribute(0x80 | 0x20);
+	USBDevice.attach();
+}
+
 void setup()
 {
 	genSerial();
@@ -81,59 +115,79 @@ void setup()
 	// clean-PS modes skip BOTH the wake mouse and WebUSB -- no config panel / host-wake; chord back to Steam
 	// (back-paddle 4 + A) to reach the panel. Normal MODE_PS5 / MODE_HIDGYRO keep wake + panel.
 	const bool psClean = modeIsCleanPS(g_usbMode);
-	USBDevice.detach();
-	delay(30);
-	if (keepCdc) {
+	const bool dynamic = g_active->dynamicMount();
+
+	if (dynamic) {
+		// Dynamic mount: present only ACTIVELY-CONNECTED controllers; usbReenumerate re-attaches (no reboot)
+		// as the set changes. Emulated modes are never puck; clean-PS drops the wake mouse + WebUSB.
+		s_dynWantWakeMouse = !psClean;
+		s_dynWantWebusb = !psClean;
+		USBDevice.detach();
+		delay(30);
+		USBDevice.clearConfiguration();
 		USBDevice.setConfigurationBuffer(g_usbCfgDesc,
 						 sizeof g_usbCfgDesc);
+		// Lock TinyUSB HID instance indices ONCE, canonical order: wake mouse (HID 0) then the mode's pool.
+		if (s_dynWantWakeMouse)
+			wakeHidBegin();
+		g_active->beginPool();
+		if (s_dynWantWebusb)
+			usb_web.begin();
+		usbMountEnable(true, g_active->maxSlots());
+		usbMountRebuildMap(); // initial connected set (usually empty at cold boot)
+		usbReenumerate(g_usbMountCount); // build the live descriptor + attach
 	} else {
-		USBDevice.clearConfiguration();
+		USBDevice.detach();
+		delay(30);
+		if (keepCdc) {
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		} else {
+			USBDevice.clearConfiguration();
 
-		// headroom over the default 256 B cap
-		USBDevice.setConfigurationBuffer(g_usbCfgDesc,
-						 sizeof g_usbCfgDesc);
-	}
+			// headroom over the default 256 B cap
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		}
 
-	// Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
-	// identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
-	// identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
-	static const char MODE_SUFFIX[] = {
-		'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D'
-	}; // modes 1..8 (Q=PS5 game, D=DS4 game)
-	if (puckMode) {
-		USBDevice.setSerialDescriptor(g_unit);
-	} else {
-		// 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);
-	}
+		// Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
+		// identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
+		// identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
+		if (puckMode) {
+			USBDevice.setSerialDescriptor(g_unit);
+		} else {
+			// 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);
+		}
 
-	// SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
-	// and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
-	if (puckMode)
-		usb_web.begin();
-	if (puckMode && !keepCdc)
-		wakeHidBegin();
+		// SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
+		// and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
+		if (puckMode)
+			usb_web.begin();
+		if (puckMode && !keepCdc)
+			wakeHidBegin();
 
-	g_active->begin();
+		g_active->begin();
 
-	// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
-	// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
-	// modes so the device stays a single clean HID gamepad (see psClean above).
-	if (!puckMode && !keepCdc && !psClean)
-		wakeHidBegin();
+		// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
+		// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
+		// modes so the device stays a single clean HID gamepad (see psClean above).
+		if (!puckMode && !keepCdc && !psClean)
+			wakeHidBegin();
 
-	// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
-	// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
-	if (!puckMode && !psClean)
-		usb_web.begin();
-	// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
-	USBDevice.setConfigurationAttribute(0x80 | 0x20);
+		// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
+		// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
+		if (!puckMode && !psClean)
+			usb_web.begin();
+		// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
+		USBDevice.setConfigurationAttribute(0x80 | 0x20);
 
-	// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
-	USBDevice.attach();
+		// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
+		USBDevice.attach();
+	}
 	Serial.begin(115200);
 	for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
 		delay(10); // wait up to 3s for USB mount, but NEVER hang
@@ -230,6 +284,7 @@ void loop()
 	t = micros();
 	ledTask();
 	acc[6] += (uint32_t)(micros() - t);
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	loops++;
 	if (millis() - secMs >= 1000) {
 		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
@@ -256,5 +311,6 @@ void loop()
 	rfLinkTask();
 	hapticTask();
 	ledTask();
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 #endif
 }

+ 30 - 0
OpenPuck/controllers.h

@@ -55,6 +55,36 @@ class IController {
 	{
 		return false;
 	}
+
+	// ---- Dynamic USB mounting (see usb_mount.h). Emulated modes that present only CONNECTED controllers
+	//      override these; the defaults keep the legacy "begin() mounts everything" behavior for puck/lizard
+	//      and any not-yet-converted mode. ----
+	// true => this mode uses the dynamic mount/unmount path (usbReenumerate driven by the connectivity watcher)
+	// rather than registering its slot interfaces in begin(). maxSlots() then caps the mounted count.
+	virtual bool dynamicMount() const
+	{
+		return false;
+	}
+	// HID-instance budget for slot interfaces (CFG_TUD_HID minus any fixed HID like the wake mouse).
+	virtual uint8_t maxSlots() const
+	{
+		return 0;
+	}
+	// Re-apply this mode's USB identity (VID/PID, manufacturer/product strings, bcdDevice). Called by
+	// usbReenumerate after clearConfiguration (which resets them). begin() also calls it.
+	virtual void usbIdentity()
+	{
+	}
+	// One-time at boot: create the slot interface/HID object pool and lock their TinyUSB instance indices
+	// (begin() each). Adds them to the throwaway descriptor; usbReenumerate then presents the live prefix.
+	virtual void beginPool()
+	{
+	}
+	// (Re)add the first k slot interfaces (a dense prefix of the pool) to the freshly-cleared config descriptor.
+	virtual void mountSlots(uint8_t k)
+	{
+		(void)k;
+	}
 };
 
 // The singleton for a given mode (nullptr if the mode is unknown). Defined in controllers.cpp.

+ 41 - 20
OpenPuck/mode_hidgyro.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -84,7 +85,6 @@ static uint16_t hidGyroGetCommon(uint8_t slot, uint8_t rid,
 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;
@@ -102,8 +102,12 @@ static void hidGyroSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
 	// DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
 	if (id != 0x05 || pn < 5)
 		return;
+	// `slot` is the USB slot the report arrived on -> route rumble to its mapped bond slot.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
 	hapticSteamRumble((uint16_t)p[4] * 257u, (uint16_t)p[3] * 257u,
-			  slot); // DS4: left=low, right=high
+			  (uint8_t)bond); // 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
@@ -134,7 +138,8 @@ static ds4_getcb_t const DS4_GETCB[NSLOT] = { hidGyroGet0, hidGyroGet1,
 static ds4_setcb_t const DS4_SETCB[NSLOT] = { hidGyroSet0, hidGyroSet1,
 					      hidGyroSet2, hidGyroSet3 };
 
-static void hidGyroBuild(uint8_t slot, uint8_t out[63])
+// usbSlot drives the per-HID counters; slot (bond) is the controller whose decoded input feeds the report.
+static void hidGyroBuild(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
 	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
@@ -147,7 +152,7 @@ static void hidGyroBuild(uint8_t slot, uint8_t out[63])
 	out[4] = psHatNibble(b) | psFaceNibble(b);
 	out[5] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	static uint8_t ctr[NSLOT] = { 0 };
-	out[6] = ((ctr[slot]++ & 0x0F) << 4) |
+	out[6] = ((ctr[usbSlot]++ & 0x0F) << 4) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_STEAM) ? 0x01 : 0);
 	out[7] = g_in[slot].lt;
@@ -172,7 +177,7 @@ static void hidGyroBuild(uint8_t slot, uint8_t out[63])
 				 &ry);
 		static uint8_t tstamp[NSLOT] = { 0 };
 		out[32] = 1;
-		out[33] = tstamp[slot]++;
+		out[33] = tstamp[usbSlot]++;
 		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
 	} else {
 		out[32] = 0;
@@ -182,21 +187,29 @@ static void hidGyroBuild(uint8_t slot, uint8_t out[63])
 	}
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void HidGyroController::begin()
+{
+}
+// HID budget: clean DS4 (MODE_DS4_GAME) has no wake mouse; normal HIDGYRO keeps it (1 HID).
+uint8_t HidGyroController::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void HidGyroController::usbIdentity()
 {
 	USBDevice.setID(0x054C, 0x05C4);
-
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0120);
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setProductDescriptor("Wireless Controller");
+}
+void HidGyroController::beginPool()
+{
 	initDs4Macs();
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		g_hidGyro[s].enableOutEndpoint(true);
 		g_hidGyro[s].setReportCallback(DS4_GETCB[s], DS4_SETCB[s]);
 		g_hidGyro[s].setReportDescriptor(GYRO_HID_DESC,
@@ -205,16 +218,24 @@ void HidGyroController::begin()
 		g_hidGyro[s].begin();
 	}
 }
+void HidGyroController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_hidGyro[u]);
+}
 void HidGyroController::task()
 {
-	for (int s = 0; s < NSLOT; s++) {
-		if (!g_hidGyro[s].ready())
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_hidGyro[u].ready())
+			continue;
+		if (millis() - g_gyroLastMs[u] < USB_STREAM_MS)
 			continue;
-		if (millis() - g_gyroLastMs[s] < USB_STREAM_MS)
+		int bond = g_usbToBond[u];
+		if (bond < 0)
 			continue;
-		g_gyroLastMs[s] = millis();
+		g_gyroLastMs[u] = millis();
 		uint8_t p[63];
-		hidGyroBuild((uint8_t)s, p);
-		g_hidGyro[s].sendReport(0x01, p, sizeof p);
+		hidGyroBuild(u, (uint8_t)bond, p);
+		g_hidGyro[u].sendReport(0x01, p, sizeof p);
 	}
 }

+ 8 - 0
OpenPuck/mode_hidgyro.h

@@ -10,5 +10,13 @@ class HidGyroController : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern HidGyroController g_hidGyroCtl;

+ 42 - 18
OpenPuck/mode_ps5.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -124,7 +125,12 @@ static void ps5SetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
 	}
 	if (id != 0x02 || pn < 4)
 		return;
-	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u, slot);
+	// `slot` here is the USB slot the report arrived on -> route rumble to the bond slot it's mapped to.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
+	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u,
+			  (uint8_t)bond);
 	// DualSense: left=low, right=high
 }
 #define PS5CB(N)                                                               \
@@ -153,7 +159,8 @@ static ps5_getcb_t const PS5_GETCB[NSLOT] = { ps5Get0, ps5Get1, ps5Get2,
 static ps5_setcb_t const PS5_SETCB[NSLOT] = { ps5Set0, ps5Set1, ps5Set2,
 					      ps5Set3 };
 
-static void ps5Build(uint8_t slot, uint8_t out[63])
+// usbSlot drives the per-HID sequence counter; bond is the controller whose decoded input feeds the report.
+static void ps5Build(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
 	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
@@ -166,7 +173,7 @@ static void ps5Build(uint8_t slot, uint8_t out[63])
 	out[4] = g_in[slot].lt;
 	out[5] = g_in[slot].rt;
 	static uint8_t seq[NSLOT] = { 0 };
-	out[6] = seq[slot]++;
+	out[6] = seq[usbSlot]++;
 	out[7] = psHatNibble(b) | psFaceNibble(b);
 	out[8] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
@@ -191,21 +198,30 @@ static void ps5Build(uint8_t slot, uint8_t out[63])
 	out[52] = PS5_STATUS_USB;
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void Ps5Controller::begin()
+{
+}
+// HID budget: clean PS modes have no wake mouse (CFG_TUD_HID slots); normal PS5 keeps the wake mouse (1 HID).
+uint8_t Ps5Controller::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void Ps5Controller::usbIdentity()
 {
 	USBDevice.setID(0x054C, 0x0CE6);
-
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0110 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0110);
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setProductDescriptor("DualSense Wireless Controller");
+}
+// One-time: create the DualSense HID pool and lock instance indices (wake mouse, if any, was begun first).
+void Ps5Controller::beginPool()
+{
 	initPs5Macs();
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		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);
@@ -213,16 +229,24 @@ void Ps5Controller::begin()
 		g_ps5[s].begin();
 	}
 }
+void Ps5Controller::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_ps5[u]);
+}
 void Ps5Controller::task()
 {
-	for (int s = 0; s < NSLOT; s++) {
-		if (!g_ps5[s].ready())
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_ps5[u].ready())
+			continue;
+		if (millis() - g_ps5LastMs[u] < USB_STREAM_MS)
 			continue;
-		if (millis() - g_ps5LastMs[s] < USB_STREAM_MS)
+		int bond = g_usbToBond[u];
+		if (bond < 0)
 			continue;
-		g_ps5LastMs[s] = millis();
+		g_ps5LastMs[u] = millis();
 		uint8_t p[63];
-		ps5Build((uint8_t)s, p);
-		g_ps5[s].sendReport(0x01, p, sizeof p);
+		ps5Build(u, (uint8_t)bond, p);
+		g_ps5[u].sendReport(0x01, p, sizeof p);
 	}
 }

+ 8 - 0
OpenPuck/mode_ps5.h

@@ -10,5 +10,13 @@ class Ps5Controller : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern Ps5Controller g_ps5Ctl;

+ 33 - 18
OpenPuck/mode_switch_hori.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -170,20 +171,27 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 	out[7] = 0;
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void SwitchHoriController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the HORIPAD pool.
+uint8_t SwitchHoriController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchHoriController::usbIdentity()
 {
 	USBDevice.setID(0x0F0D, 0x0092);
-
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0210 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0210);
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+}
+void SwitchHoriController::beginPool()
+{
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		g_switch[s].enableOutEndpoint(true);
 		g_switch[s].setReportDescriptor(SWITCH_HID_DESC,
 						sizeof SWITCH_HID_DESC);
@@ -191,20 +199,27 @@ void SwitchHoriController::begin()
 		g_switch[s].begin();
 	}
 }
+void SwitchHoriController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_switch[u]);
+}
 void SwitchHoriController::task()
 {
-	// 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())
+	// stream the 8-byte HORIPAD report at ~250Hz per CONNECTED controller. usbSlot u -> bond slot for input;
+	// switchBuildHoripad reads g_in[bond] (no per-USB-slot state in the HORIPAD report).
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_switch[u].ready())
+			continue;
+		if (millis() - g_swLastMs[u] < USB_STREAM_MS)
 			continue;
-		if (millis() - g_swLastMs[s] < USB_STREAM_MS)
+		int bond = g_usbToBond[u];
+		if (bond < 0)
 			continue;
-		g_swLastMs[s] = millis();
+		g_swLastMs[u] = millis();
 		uint8_t p[8];
-		switchBuildHoripad((uint8_t)s, p);
-		g_switch[s].sendReport(0, p,
+		switchBuildHoripad((uint8_t)bond, p);
+		g_switch[u].sendReport(0, p,
 				       sizeof p); // report-id-less descriptor
 	}
 }

+ 8 - 0
OpenPuck/mode_switch_hori.h

@@ -11,5 +11,13 @@ class SwitchHoriController : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern SwitchHoriController g_switchHori;

+ 53 - 25
OpenPuck/mode_switch_pro.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -22,7 +23,7 @@ SwitchProController g_switchPro;
 #define SW_ACCEL_DIV 4
 
 uint8_t g_swProRate =
-	1; // 0 = 66Hz, 1 = 120Hz (default), 2 = full (~250Hz / USB_STREAM_MS)
+	2; // 0 = 66Hz, 1 = 120Hz, 2 = full (~250Hz / USB_STREAM_MS, default)
 uint8_t g_swGyroScale10 = 10; // gyro sensitivity x10 (10 = 1.0x)
 
 // Persist the two Switch Pro motion settings in their own tiny file so they never trigger a global-Cfg reset.
@@ -44,8 +45,8 @@ static void swProLoadCfg()
 	uint8_t b[3];
 	if (f.open(SWPRO_CFG_FILE, FILE_O_READ)) {
 		if (f.read(b, 3) == 3 && b[0] == 0x01) {
-			// 0=66Hz, 1=120Hz, 2=full; bad value -> default 120Hz
-			g_swProRate = (b[1] <= 2) ? b[1] : 1;
+			// 0=66Hz, 1=120Hz, 2=full; bad value -> default full
+			g_swProRate = (b[1] <= 2) ? b[1] : 2;
 			g_swGyroScale10 =
 				(b[2] >= 5 && b[2] <= 30) ?
 					b[2] :
@@ -167,6 +168,14 @@ static void initJcMacs()
 	}
 	g_jcMacInit = true;
 }
+// Dynamic mount: the handshake/state arrays are keyed by USB slot, but the controller INPUT (and rumble target)
+// belongs to the bond slot the USB slot is mapped to. This resolves usbSlot -> bondSlot (identity fallback if
+// somehow unmapped, e.g. legacy paths).
+static inline uint8_t jcBondOf(uint8_t usbSlot)
+{
+	int b = (usbSlot < NSLOT) ? g_usbToBond[usbSlot] : -1;
+	return (b >= 0) ? (uint8_t)b : usbSlot;
+}
 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
@@ -192,7 +201,7 @@ static void jcRumble(uint8_t slot, const uint8_t *p, uint16_t pn)
 		return;
 	g_jcLastLo[slot] = lo;
 	g_jcLastHi[slot] = hi;
-	hapticSteamRumble(lo, hi, slot);
+	hapticSteamRumble(lo, hi, jcBondOf(slot)); // route to the mapped controller
 }
 static int jcStick12(int16_t v, bool inv)
 { // steam int16 (center 0) -> 12-bit (center 0x800), clamped
@@ -211,7 +220,8 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
 // shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
 static void jcInputPrefix(uint8_t slot, uint8_t *out)
 {
-	uint32_t b = g_in[slot].buttons;
+	uint8_t bond = jcBondOf(slot); // input data comes from the mapped controller; timer/state stay per USB slot
+	uint32_t b = g_in[bond].buttons;
 	// QAM (3 dots) remap -> applied via codeToJc below like a back paddle (so Capture(18)/any target work).
 	bool qam = g_qamMap && (b & TB_QAM);
 	if ((b & CHORD_BACK4) == CHORD_BACK4)
@@ -233,9 +243,9 @@ static void jcInputPrefix(uint8_t slot, uint8_t *out)
 		jc |= JC_BTN_L;
 	if (b & TB_RB)
 		jc |= JC_BTN_R;
-	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[bond].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		jc |= JC_BTN_ZL;
-	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[bond].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		jc |= JC_BTN_ZR;
 	if (b & TB_VIEW)
 		jc |= JC_BTN_PLUS;
@@ -277,13 +287,14 @@ static void jcInputPrefix(uint8_t slot, 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[slot].lx, g_in[slot].ly);
-	jcPackStick(out + 8, g_in[slot].rx, g_in[slot].ry);
+	jcPackStick(out + 5, g_in[bond].lx, g_in[bond].ly);
+	jcPackStick(out + 8, g_in[bond].rx, g_in[bond].ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 }
 static void switchProBuild(uint8_t slot, uint8_t out[63])
 {
+	uint8_t bond = jcBondOf(slot); // IMU data comes from the mapped controller
 	memset(out, 0, 63);
 	jcInputPrefix(slot, out);
 	// Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Source routing (a proper
@@ -299,14 +310,14 @@ static void switchProBuild(uint8_t slot, 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[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);
+	int16_t aX = (int16_t)(g_in[bond].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[bond].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[bond].az / SW_ACCEL_DIV);
 
 	// gyro outputs scaled by the user sensitivity factor
-	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);
+	int16_t groll = gscale((int16_t)g_in[bond].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[bond].gx));
+	int16_t gyaw = gscale((int16_t)g_in[bond].gz);
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
 		out[o + 0] = aX & 0xFF;
@@ -683,24 +694,31 @@ 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 };
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void SwitchProController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the Pro Controller pool.
+uint8_t SwitchProController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchProController::usbIdentity()
 {
 	USBDevice.setID(0x057E, 0x2009);
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0220 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0220);
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");
+}
+void SwitchProController::beginPool()
+{
 	jcBuildStickCal();
-
 	loadUserCal();
 	swProLoadCfg();
 	initJcMacs();
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		g_swPro[s].enableOutEndpoint(true);
 		g_swPro[s].setReportCallback(NULL, JC_SETCB[s]);
 		g_swPro[s].setReportDescriptor(SWPRO_HID_DESC,
@@ -709,9 +727,19 @@ void SwitchProController::begin()
 		g_swPro[s].begin();
 	}
 }
+void SwitchProController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++) {
+		// Each re-enumeration restarts the host handshake -- clear this USB slot's gate + reply FIFO so we
+		// don't stream 0x30 before the (new) host has re-selected report mode.
+		g_swProReportMode[u] = 0;
+		g_jcQh[u] = g_jcQt[u] = 0;
+		USBDevice.addInterface(g_swPro[u]);
+	}
+}
 void SwitchProController::task()
 {
-	for (int s = 0; s < NSLOT; s++) {
+	for (uint8_t s = 0; s < g_usbMountCount; s++) {
 		if (!g_swPro[s].ready())
 			continue;
 		// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst

+ 8 - 0
OpenPuck/mode_switch_pro.h

@@ -12,5 +12,13 @@ class SwitchProController : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern SwitchProController g_switchPro;

+ 45 - 23
OpenPuck/mode_xinput.cpp

@@ -5,15 +5,17 @@
 // 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.
+// Multi-controller: one XInput interface per CONNECTED controller (dynamic mount -- see usb_mount.h; the device
+// re-enumerates without rebooting as controllers connect/disconnect). The single class driver routes xfer/open
+// callbacks by endpoint address into the per-bond-slot state; xi_open claims slots in connection order. The
+// right-pad mouse is a single shared interface (only bond slot 0's right-pad drives it).
 #include "mode_xinput.h"
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -62,6 +64,9 @@ struct XiSlot {
 	volatile bool inUse;
 };
 static XiSlot g_xiSlot[NSLOT];
+// Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
+// u-th interface claims the u-th connected controller (g_usbToBond[u]). Reset on every (re)enumeration.
+static uint8_t g_xiOpenU = 0;
 // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
 #define RUMBLE_STUCK_MS 2500u
 
@@ -77,6 +82,7 @@ static bool xi_deinit(void)
 static void xi_reset(uint8_t rhport)
 {
 	(void)rhport;
+	g_xiOpenU = 0; // restart per-interface claim ordering for this enumeration
 	for (int s = 0; s < NSLOT; s++) {
 		g_xiSlot[s].itf = 0;
 		g_xiSlot[s].epIn = g_xiSlot[s].epOut = 0;
@@ -92,15 +98,18 @@ static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
 	      itf->bInterfaceProtocol == 0x01))
 		return 0;
-	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;
-		}
+	// The u-th XInput interface in the descriptor serves the u-th connected controller. g_xiSlot is keyed by
+	// BOND slot so onReport45(bond)/rumble route straight through; only connected bonds get an interface.
+	uint8_t u = g_xiOpenU++;
+	int slot = (u < g_usbMountCount) ? g_usbToBond[u] : -1;
+	if (slot < 0) { // fallback (e.g. map not built): first free slot
+		for (int s = 0; s < NSLOT; s++)
+			if (!g_xiSlot[s].inUse) {
+				slot = s;
+				break;
+			}
 	}
-	if (slot < 0)
+	if (slot < 0 || slot >= NSLOT || g_xiSlot[slot].inUse)
 		return 0;
 	XiSlot &S = g_xiSlot[slot];
 	S.itf = itf->bInterfaceNumber;
@@ -408,29 +417,42 @@ static void rfXboxMouse(const uint8_t *r)
 }
 
 // ===================== IController =====================
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void XboxController::begin()
+{
+}
+// XInput slot interfaces are a custom (non-HID) class, so they don't draw on the CFG_TUD_HID budget (only the
+// wake mouse + right-pad mouse do). Slots are limited by NSLOT / USB endpoints, not HID instances.
+uint8_t XboxController::maxSlots() const
+{
+	return (uint8_t)NSLOT;
+}
+void XboxController::usbIdentity()
 {
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
-	// 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.setDeviceVersion(0x0120);
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
-	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();
-	}
+}
+void XboxController::beginPool()
+{
+	// Lock the right-pad mouse HID instance (wake mouse was begun first by setup -> this is HID instance 1).
 	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();
+	for (int s = 0; s < NSLOT; s++)
+		g_xinput[s].setStringDescriptor("Controller");
+}
+void XboxController::mountSlots(uint8_t k)
+{
+	// Right-pad mouse first (fixed HID), then one XInput interface per connected controller (claimed in
+	// order by xi_open). The wake mouse + WebUSB are added around this by usbReenumerate.
+	USBDevice.addInterface(g_mouse);
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_xinput[u]);
 }
 void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)

+ 8 - 0
OpenPuck/mode_xinput.h

@@ -16,5 +16,13 @@ class XboxController : public IController {
 
 	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern XboxController g_xboxCtl;

+ 100 - 0
OpenPuck/usb_mount.cpp

@@ -0,0 +1,100 @@
+#include "usb_mount.h"
+#include "bonds.h"
+#include <Arduino.h>
+
+uint8_t g_usbMountCount = 0;
+int8_t g_usbToBond[NSLOT];
+int8_t g_bondToUsb[NSLOT];
+
+static bool g_dynOn = false;
+static uint8_t g_maxSlots = NSLOT;
+
+// A controller counts as "connected" (mount-worthy) while it has replied recently. More lenient than the
+// 300ms link-up check so a brief RF gap doesn't unmount it; the debounce below adds further hysteresis.
+#define CONN_UP_MS 1200u
+// The connected set must hold steady this long before we re-enumerate -- absorbs RF blips and the staggered
+// way multiple controllers connect at boot (one re-enumeration once the set settles, not one per controller).
+#define MOUNT_DEBOUNCE_MS 2000u
+
+static uint8_t connectedMask()
+{
+	unsigned long now = millis();
+	uint8_t m = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] != 0 &&
+		    (now - g_connReplyMs[s]) < CONN_UP_MS)
+			m |= (uint8_t)(1u << s);
+	return m;
+}
+
+static uint8_t mountedMask()
+{
+	uint8_t m = 0;
+	for (uint8_t u = 0; u < g_usbMountCount; u++)
+		if (g_usbToBond[u] >= 0)
+			m |= (uint8_t)(1u << g_usbToBond[u]);
+	return m;
+}
+
+// Rebuild the map from a bitmask of bond slots, in ascending bond-slot order (deterministic). Capped at the
+// mode's HID budget so we never register more interfaces than CFG_TUD_HID allows.
+static void buildMapFromMask(uint8_t mask)
+{
+	for (int i = 0; i < NSLOT; i++) {
+		g_usbToBond[i] = -1;
+		g_bondToUsb[i] = -1;
+	}
+	uint8_t u = 0;
+	for (int s = 0; s < NSLOT && u < g_maxSlots; s++) {
+		if (mask & (1u << s)) {
+			g_usbToBond[u] = (int8_t)s;
+			g_bondToUsb[s] = (int8_t)u;
+			u++;
+		}
+	}
+	g_usbMountCount = u;
+}
+
+void usbMountRebuildMap()
+{
+	buildMapFromMask(connectedMask());
+}
+
+void usbMountEnable(bool on, uint8_t maxSlots)
+{
+	g_dynOn = on;
+	g_maxSlots = maxSlots ? (maxSlots <= NSLOT ? maxSlots : NSLOT) : 1;
+	for (int i = 0; i < NSLOT; i++)
+		g_usbToBond[i] = g_bondToUsb[i] = -1;
+	g_usbMountCount = 0;
+}
+
+void usbMountTask()
+{
+	if (!g_dynOn)
+		return;
+	static uint8_t lastMask = 0xFF; // force first compare
+	static unsigned long stableSince = 0;
+	unsigned long now = millis();
+	uint8_t want = connectedMask();
+	if (want != lastMask) { // set is moving -> restart the stability timer
+		lastMask = want;
+		stableSince = now;
+		return;
+	}
+	// `want` has been steady since stableSince. Re-enumerate once it has held long enough AND differs from
+	// what is mounted (compare capped: a mask bit beyond g_maxSlots can't be mounted, so mask off the excess).
+	if ((now - stableSince) < MOUNT_DEBOUNCE_MS)
+		return;
+	// cap `want` to the first g_maxSlots set bits so the comparison matches what we can actually mount
+	uint8_t capped = 0, cnt = 0;
+	for (int s = 0; s < NSLOT && cnt < g_maxSlots; s++)
+		if (want & (1u << s)) {
+			capped |= (uint8_t)(1u << s);
+			cnt++;
+		}
+	if (capped == mountedMask())
+		return; // already mounted exactly this set
+	buildMapFromMask(want);
+	usbReenumerate(g_usbMountCount);
+}

+ 38 - 0
OpenPuck/usb_mount.h

@@ -0,0 +1,38 @@
+// usb_mount.h -- dynamic USB mounting of ACTIVELY-CONNECTED controllers (emulated modes only).
+//
+// Problem: a puck can be bonded to several controllers but only some are powered on / linked at a time. The
+// emulated modes used to mount one USB HID gamepad per BONDED slot at boot, so the host saw phantom pads for
+// controllers that were off. This module makes the USB device present only the CONNECTED controllers, and
+// re-enumerate (NO MCU reboot -- RF links stay up) whenever that set changes.
+//
+// Model: a fixed pool of per-mode USB-slot objects (HID/XInput interfaces) is created ONCE at boot (locking
+// their TinyUSB instance indices). At any moment the k connected controllers are presented as a DENSE PREFIX
+// usbSlot 0..k-1 of that pool; usbSlot u is fed from bond slot g_usbToBond[u]. USB can't hold an interface
+// "gap", so the prefix is compacted -> player identity follows CONNECTION ORDER (a mid-set disconnect shifts
+// the rest down; a reconnect appends). Re-enumeration is debounced so an RF blip doesn't reshuffle.
+#pragma once
+#include <stdint.h>
+#include "bonds.h" // NSLOT
+
+// Active mount map. usbSlot (0..g_usbMountCount-1) -> bond slot; and the inverse for output-report routing.
+extern uint8_t g_usbMountCount; // # of currently-mounted (connected) controllers
+extern int8_t g_usbToBond[NSLOT]; // usbSlot -> bondSlot (-1 = unused)
+extern int8_t g_bondToUsb[NSLOT]; // bondSlot -> usbSlot (-1 = not mounted)
+
+// Enable the dynamic watcher for this boot (emulated modes call this; puck/lizard leave it off so their begin()
+// keeps the legacy behavior). maxSlots caps the mounted count to the mode's HID-instance budget (CFG_TUD_HID
+// minus the wake mouse, etc.). When disabled, usbMountTask() is a no-op.
+void usbMountEnable(bool on, uint8_t maxSlots);
+
+// Build g_usbToBond/g_bondToUsb + g_usbMountCount from the CURRENT connected set (bond-slot order). Called by
+// the boot enumerate and on every accepted change.
+void usbMountRebuildMap();
+
+// Per-loop watcher: detect a debounced change in the connected set and trigger usbReenumerate(k). No-op unless
+// dynamic mounting is enabled.
+void usbMountTask();
+
+// Provided by OpenPuck.ino: tear down + rebuild the USB config descriptor presenting `k` slot interfaces
+// (fixed interfaces -- WebUSB / wake mouse -- replayed too), then re-attach. NO MCU reboot. Call with the map
+// already rebuilt for k connected controllers.
+void usbReenumerate(uint8_t k);

+ 5 - 0
OpenPuck/wake_hid.cpp

@@ -16,3 +16,8 @@ void wakeHidBegin()
 	g_wakeHid.setPollInterval(10);
 	g_wakeHid.begin();
 }
+
+void wakeHidAddInterface()
+{
+	TinyUSBDevice.addInterface(g_wakeHid);
+}

+ 5 - 1
OpenPuck/wake_hid.h

@@ -14,5 +14,9 @@
 // CDC and skips this interface (no endpoint room for both) -- see config.h (g_debugCdcThisBoot).
 #pragma once
 
-// register the boot-keyboard wake interface (call from setup() for clean modes)
+// register the boot-keyboard wake interface (call from setup() for clean modes). Locks its TinyUSB HID
+// instance index -- for dynamic-mount modes call this BEFORE the slot pool so the wake mouse is HID instance 0.
 void wakeHidBegin();
+// Re-add the (already-begun) wake-mouse interface to a freshly-cleared config descriptor, for a dynamic
+// re-enumeration that doesn't reboot. begin() is once-only; this re-emits the interface descriptor.
+void wakeHidAddInterface();

+ 1 - 1
OpenPuck/webusb_config.cpp

@@ -369,7 +369,7 @@ void webusbPoll()
 
 				// Switch Pro report rate (0=66Hz,1=120Hz,2=full)
 				case 23:
-					g_swProRate = (v <= 2) ? v : 1;
+					g_swProRate = (v <= 2) ? v : 2;
 					swProSaveCfg();
 					persist = false;
 					break;

+ 26 - 11
docs/index.html

@@ -154,7 +154,7 @@
     <div class="card">
       <h2>Switch Pro motion</h2>
       <div class="row"><label>Report rate</label><select id="swRate"></select></div>
-      <p class="note" style="margin:0 0 6px">120 Hz (default) is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback. Full rate (~250 Hz) is lowest-latency but non-genuine — use only if your console tolerates it.</p>
+      <p class="note" style="margin:0 0 6px">Full rate (~250 Hz, default) is lowest-latency. 120 Hz is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback — drop to it only if your console rejects the faster rates.</p>
       <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
     </div>
@@ -210,14 +210,26 @@ function mkSelect(def, includeNone){
     const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
   return sel;
 }
+let curTab=0, tabInited=false;
+function setTab(et){
+  curTab=et;
+  typeEls.forEach((rec,i)=>{
+    rec.sec.style.display = (i===et) ? "" : "none";
+    rec.tab.classList.toggle("active", i===et);
+  });
+}
 (function buildTypeCfgs(){
   const host=document.getElementById("typeCfgs");
+  const bar=document.createElement("div"); bar.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px";
+  host.appendChild(bar);
   TYPE_DEFS.forEach((def,et)=>{
-    const sec=document.createElement("div"); sec.className="card"; sec.style.cssText="background:#0e1017;margin:0 0 12px;padding:12px 14px";
-    const h=document.createElement("div"); h.style.cssText="font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:8px";
-    h.innerHTML='<span>'+def.name+'</span><span class="active-tag" style="display:none;font-size:11px;color:var(--acc)">● current mode</span>';
-    sec.appendChild(h);
-    const rec={sec,activeTag:h.querySelector(".active-tag"),back:[],qam:null,abSwap:null,pad:null};
+    // tab button
+    const tab=document.createElement("button"); tab.className="slot-tab"; tab.style.cursor="pointer";
+    tab.innerHTML='<span>'+def.name+'</span><span class="active-dot" style="display:none;font-size:11px;color:var(--acc)"> ●</span>';
+    tab.onclick=()=>setTab(et); bar.appendChild(tab);
+    // panel
+    const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
+    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null};
     // A/B swap + trackpad haptics toggles
     const tog=document.createElement("div"); tog.className="row";
     tog.innerHTML='<label>A/B + X/Y swap</label>';
@@ -244,13 +256,14 @@ function mkSelect(def, includeNone){
       rec.qam=sel; }
     host.appendChild(sec); typeEls.push(rec);
   });
+  setTab(0);
 })();
 for(const sel of document.querySelectorAll("select.chord")){
   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); });
 }
 { const sel=document.getElementById("swRate");
-  for(const [lbl,v] of [["120 Hz (default)",1],["66 Hz (compatibility)",0],["Full rate (~250 Hz)",2]]){
+  for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
 { const sel=document.getElementById("swGyro");
@@ -317,7 +330,7 @@ async function connect(){
   }catch(e){ log("connect failed: "+e.message); }
 }
 function onGone(){
-  polling=false; dev=null;
+  polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
   $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
   log("device disconnected (likely a mode-switch reboot — reconnect)");
@@ -356,7 +369,7 @@ function applyBlob(p){
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
   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 swProRate=(p.length>52?p[52]:2);
   const swGyro10=(p.length>53?p[53]:10);
   const rssi=(p.length>37?p[37]:0);
 
@@ -464,9 +477,11 @@ function applyBlob(p){
       if(document.activeElement!==rec.qam) rec.qam.value=qam;
       rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
       rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
-      rec.activeTag.style.display = (et===activeEt) ? "" : "none";
-      rec.sec.style.borderColor = (et===activeEt) ? "var(--acc2)" : "#272d3d";
+      // dot marks the type matching the current puck mode
+      rec.activeDot.style.display = (et===activeEt) ? "" : "none";
     });
+    // open the tab for the current mode's type on first load (then leave the user's choice alone)
+    if(!tabInited){ tabInited=true; setTab(activeEt>=0?activeEt:0); }
   }
   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);