浏览代码

Add Switch 2 Pro controller compatibility

ixelyth 1 月之前
父节点
当前提交
8a696af617

+ 1 - 1
Makefile

@@ -45,7 +45,7 @@ EXTRA_FLAGS ?=
 # already defines to serve WebUSB. Original Xbox mode needs the XID requests that arrive on it.
 # Overriding the weak symbol would claim the whole hook and force a copy of Adafruit's WebUSB body,
 # so we wrap: webusb_config.cpp takes XID and passes everything else to __real_.
-OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb
+OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb -Wl,--wrap=tud_descriptor_device_cb -Wl,--wrap=tud_hid_descriptor_report_cb -Wl,--wrap=tud_hid_get_report_cb -Wl,--wrap=tud_hid_set_report_cb
 # {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
 USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
 # When BUILD_PATH is set, --clean + path flags are injected; omitted for fast incremental dev builds.

+ 19 - 7
OpenPuck/OpenPuck.ino

@@ -69,8 +69,8 @@ static uint8_t g_usbCfgDesc[512];
 // Per-mode USB serial suffix (modes 1..MODE_MAX: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game
 // D=ds4game 3=ps3 O=original-xbox J=dinput I=sinput).
 // 'C' is reserved for the CDC mode, see puck_hid.cpp
-static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G',
-				    'Q', 'D', '3', 'O', 'J', 'I' };
+static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G', 'Q',
+				    'D', '3', 'O', 'J', 'I', 'H' };
 // Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
 static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
 
@@ -91,14 +91,25 @@ void usbReenumerate(uint8_t k)
 				     g_usbMode - 1 :
 				     0],
 		 (unsigned)k);
-	USBDevice.setSerialDescriptor(g_usbSerial);
+	if (g_usbMode == MODE_SW2_PRO) {
+		USBDevice.setSerialDescriptor("00");
+	} else {
+		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);
+	if (g_usbMode == MODE_SW2_PRO) {
+		// Match the captured Nintendo configuration header exactly.
+		g_usbCfgDesc[6] = 4; // iConfiguration = "Config_0"
+		USBDevice.setConfigurationAttribute(0xc0);
+		USBDevice.setConfigurationMaxPower(500);
+	} else {
+		USBDevice.setConfigurationAttribute(0x80 | 0x20);
+	}
 	USBDevice.attach();
 }
 
@@ -184,13 +195,14 @@ 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);
+	const bool consoleClean = modeIsCleanConsole(g_usbMode);
 	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;
+		s_dynWantWakeMouse = !consoleClean;
+		s_dynWantWebusb = !consoleClean;
 		USBDevice.detach();
 		delay(30);
 		USBDevice.clearConfiguration();
@@ -280,7 +292,7 @@ void setup()
 		"HIDGYRO(ds4+motion)",	 "PS5(dualsense,game/clean)",
 		"DS4(ds4,game/clean)",	 "PS3(dualshock3/sixaxis)",
 		"XBOX-OG(controller s)", "DINPUT(joystick+motion)",
-		"SINPUT(sdl-native)"
+		"SINPUT(sdl-native)",	 "SWITCH2(horipad-o/clean)"
 	};
 	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
 		      g_board,

+ 30 - 3
OpenPuck/config.cpp

@@ -109,6 +109,7 @@ struct Cfg {
 	// autonomous controller power-off on host sleep (see haptics.h g_suspendOff). 0/1; 0xFF (short
 	// pre-tail file) -> compiled default (on)
 	uint8_t suspendOff;
+	uint8_t ext[CFG_EXT_STORAGE_BYTES];
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 // Shortest cfg.bin we still accept: the layout as of CFG_MAGIC 0xCF, i.e. everything before the appended tail.
@@ -116,6 +117,21 @@ struct Cfg {
 // tail field treats as "unset" and replaces with its default.
 #define CFG_LEN_MIN (offsetof(struct Cfg, chordDpad))
 
+static uint8_t g_cfgExt[CFG_EXT_STORAGE_BYTES] = {
+	0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
+};
+
+uint8_t cfgExtRead(uint8_t index)
+{
+	return index < CFG_EXT_STORAGE_BYTES ? g_cfgExt[index] : 0xFFu;
+}
+
+void cfgExtWrite(uint8_t index, uint8_t value)
+{
+	if (index < CFG_EXT_STORAGE_BYTES)
+		g_cfgExt[index] = value;
+}
+
 void saveCfg()
 {
 	Cfg c = { CFG_MAGIC,
@@ -136,7 +152,9 @@ void saveCfg()
 		    g_chordDpad[3] },
 		  {},
 		  g_rumbleStyle,
-		  g_suspendOff };
+		  g_suspendOff,
+		  {} };
+	memcpy(c.ext, g_cfgExt, sizeof c.ext);
 	for (int i = 0; i < ET_COUNT; i++) {
 		c.type[i] = g_type[i];
 		c.padStick[i][0] = g_padStickCfg[i][0];
@@ -153,6 +171,7 @@ void saveCfg()
 void loadCfg()
 {
 	Cfg c;
+	memset(g_cfgExt, 0xFF, sizeof g_cfgExt);
 	// 0xFF prefill: bytes a SHORT (pre-tail) cfg.bin never wrote stay 0xFF, which is not a valid mode/flag, so
 	// each tail field below falls back to its compiled default instead of reading whatever was on the stack.
 	memset(&c, 0xFF, sizeof c);
@@ -164,6 +183,13 @@ void loadCfg()
 		// predating the tail keeps mode/paddles/chords instead of silently reverting to factory defaults.
 		// Anything shorter, or a stale magic, is a real layout change -> discard and use defaults.
 		if (got >= (int)CFG_LEN_MIN && c.magic == CFG_MAGIC) {
+			const int extOff = (int)offsetof(struct Cfg, ext);
+			if (got > extOff) {
+				int n = got - extOff;
+				if (n > (int)sizeof g_cfgExt)
+					n = sizeof g_cfgExt;
+				memcpy(g_cfgExt, c.ext, (size_t)n);
+			}
 			g_mDiv = c.mDiv ? c.mDiv : 64;
 			g_mFric = c.mFric;
 			for (int i = 0; i < ET_COUNT; i++)
@@ -207,8 +233,8 @@ void loadCfg()
 					if (c.padStick[i][k] <= PS_MAX)
 						g_padStickCfg[i][k] =
 							c.padStick[i][k];
-			// grow a short file to the current layout on the next save
-			if (got < (int)sizeof c)
+			// Missing extension bytes alone do not force a config rewrite.
+			if (got < (int)offsetof(struct Cfg, ext))
 				consume = true;
 
 			// lizard-suppression keepalive enable (0/1; anything else = a pre-0xCE cfg leaked
@@ -231,6 +257,7 @@ void loadCfg()
 			// suspend power-off enable (0xFF = a cfg.bin predating this tail field -> keep the on default)
 			if (c.suspendOff <= 1)
 				g_suspendOff = c.suspendOff;
+
 			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();

+ 15 - 2
OpenPuck/config.h

@@ -58,7 +58,9 @@
 // SInput: the open SDL-native gamepad protocol (docs.handheldlegend.com/s/sinput). Sticks + analog triggers +
 // gyro/accel + BOTH trackpads + battery, all bound natively by SDL3 / Steam Input with no impersonation.
 #define MODE_SINPUT 12
-#define MODE_MAX 12
+// Nintendo Switch 2 Pro Controller (057E:2069), clean console personality.
+#define MODE_SW2_PRO 13
+#define MODE_MAX 13
 
 // The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
 // controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are
@@ -68,6 +70,12 @@ static inline bool modeIsCleanPS(uint8_t m)
 	return m == MODE_PS5_GAME || m == MODE_DS4_GAME || m == MODE_PS3;
 }
 
+// Switch 2 Pro is a clean console personality with its captured composite USB topology. PS3 remains
+// separate because it has a distinct USB bmAttributes policy.
+static inline bool modeIsCleanConsole(uint8_t m)
+{
+	return modeIsCleanPS(m) || m == MODE_SW2_PRO;
+}
 static inline bool modeIsPuck(uint8_t m)
 {
 	return m == MODE_STEAM || m == MODE_LIZARD;
@@ -96,6 +104,7 @@ static inline uint8_t etypeForMode(uint8_t m)
 		return ET_XBOX;
 	case MODE_SW_HORI:
 	case MODE_SW_PRO:
+	case MODE_SW2_PRO:
 		return ET_SWITCH;
 	case MODE_HIDGYRO:
 	case MODE_DS4_GAME:
@@ -145,7 +154,7 @@ void armDebugCdcNextBoot(); // persist the one-shot (caller reboots)
 extern int g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
 
 // Per-emulated-type button config. One entry per ET_* type. back[] = paddle L4,R4,L5,R5 -> button codes
-// (0..15 standard, 16=PS Touch Click, 17=PS5 Mute, 18=Switch Capture/Screenshot). qamMap = QAM (3 dots)
+// (0..15 standard, 16=PS Touch Click, 17=PS5 Mute, 18=Switch Capture/Screenshot, 21=Switch 2 Chat). qamMap = QAM (3 dots)
 // physical button -> same code space (0 = default/unmapped). abSwap = swap A/B and X/Y (Nintendo layout).
 // padHaptics = 1 keeps the controller's autonomous trackpad haptics, 0 disables them for this type.
 // rumble = 1 enables host rumble relay (default), 0 silences it for this type.
@@ -222,6 +231,10 @@ extern uint16_t g_loopPeriodUs;
 extern uint8_t g_loopWorst;
 extern uint16_t g_loopWorstUs;
 
+#define CFG_EXT_STORAGE_BYTES 10u
+uint8_t cfgExtRead(uint8_t index);
+void cfgExtWrite(uint8_t index, uint8_t value);
+
 void loadCfg();
 void saveCfg();
 // FULL factory wipe: reformat the internal LittleFS, erasing cfg.bin (modes/tunables/chords) AND bonds.bin

+ 3 - 0
OpenPuck/controllers.cpp

@@ -3,6 +3,7 @@
 #include "puck_hid.h"
 #include "mode_xinput.h"
 #include "mode_switch_hori.h"
+#include "mode_switch2_pro.h"
 #include "mode_switch_pro.h"
 #include "mode_ps5.h"
 #include "mode_hidgyro.h"
@@ -30,6 +31,8 @@ IController *controllerFor(uint8_t mode)
 		return &g_switchHori;
 	case MODE_SW_PRO:
 		return &g_switchPro;
+	case MODE_SW2_PRO:
+		return &g_switch2Pro;
 	case MODE_PS5:
 		return &g_ps5Ctl;
 	case MODE_HIDGYRO:

+ 10 - 1
OpenPuck/haptics.cpp

@@ -588,7 +588,6 @@ void hapticReinit(uint8_t slot)
 
 	// clang-format off
 	static const uint8_t haptic_reset_data_1[] = { 
-			SETTING_IMU_MODE, 0x00, 0x00, 
 			SETTING_LEFT_TRACKPAD_MODE, 0x07, 0x00, 
 			SETTING_RIGHT_TRACKPAD_MODE, 0x07, 0x00, 
 			SETTING_WIRELESS_PACKET_VERSION, 0x02, 0x00, 
@@ -665,6 +664,16 @@ void hapticOnReconnect(int slot)
 	g_rumble80Ms[slot] = 0;
 	// Scrub haptics queued before the link came up (stale across the reconnect) -- this slot only.
 	hapticCancelPendingOn(slot);
+	if (g_usbMode == MODE_SW_PRO || g_usbMode == MODE_SW2_PRO) {
+		static const uint8_t RAW_IMU[] = {
+			SETTING_IMU_MODE,
+			SETTING_GYRO_MODE_SEND_RAW_ACCEL |
+				SETTING_GYRO_MODE_SEND_RAW_GYRO,
+			0x00
+		};
+		relayEnqueue(IBEX_CMD_SET_SETTINGS_VALUES, RAW_IMU,
+			     sizeof RAW_IMU, false, (uint8_t)slot);
+	}
 	// NO automatic haptic re-init. hapticReinit lands 0x81 (CLEAR DIGITAL MAPPINGS -- rid < 0x87 so it
 	// EXECUTES even on legacy framing). 0x81 is NON-IDEMPOTENT (ibex FUN_0001f554): EVERY call re-runs the
 	// lizard-disable event + func_0x0001bbf0 (a hardware peripheral re-arm unique to the 0x81 path) -- so

+ 1193 - 0
OpenPuck/mode_switch2_pro.cpp

@@ -0,0 +1,1193 @@
+// mode_switch2_pro.cpp -- Nintendo Switch 2 Pro Controller (057E:2069).
+//
+// The USB descriptors are byte-for-byte captures of a physical controller. The
+// native 0x09 motion packing is still undocumented, so this implementation
+// deliberately advertises zero 0x09 motion length and carries decoded Steam IMU
+// samples only in the documented common 0x05 report.
+#include "mode_switch2_pro.h"
+#include "bonds.h"
+#include "config.h"
+#include "gamepad_util.h"
+#include "haptics.h"
+#include "rf_link.h"
+#include "triton.h"
+#include "usb_app_drivers.h"
+#include "usb_mount.h"
+#include "usb_tx.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+extern "C" {
+#include "class/hid/hid_device.h"
+#include "device/usbd_pvt.h"
+}
+
+Switch2ProController g_switch2Pro;
+
+static const uint8_t SWITCH2_PRO_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x85, 0x05, 0x05, 0xff, 0x09,
+	0x01, 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x3f, 0x75, 0x08, 0x81,
+	0x02, 0x85, 0x09, 0x09, 0x01, 0x95, 0x02, 0x81, 0x02, 0x05, 0x09,
+	0x19, 0x01, 0x29, 0x15, 0x25, 0x01, 0x95, 0x15, 0x75, 0x01, 0x81,
+	0x02, 0x95, 0x01, 0x75, 0x03, 0x81, 0x03, 0x05, 0x01, 0x09, 0x01,
+	0xa1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x09, 0x33, 0x09, 0x35, 0x26,
+	0xff, 0x0f, 0x95, 0x04, 0x75, 0x0c, 0x81, 0x02, 0xc0, 0x05, 0xff,
+	0x09, 0x02, 0x26, 0xff, 0x00, 0x95, 0x34, 0x75, 0x08, 0x91, 0x02,
+	0x85, 0x02, 0x09, 0x01, 0x95, 0x3f, 0x91, 0x02, 0xc0,
+};
+static_assert(sizeof SWITCH2_PRO_HID_DESC == 97,
+	      "Switch 2 Pro HID descriptor must remain byte-exact");
+
+// Everything after the 9-byte configuration header. Interface and endpoint
+// numbers are fixed because this clean personality is the only mounted USB
+// function: IF0 HID, IF1 vendor bulk, IF2-4 Nintendo audio shape.
+static const uint8_t SWITCH2_PRO_CFG_BODY[] = {
+	0x08, 0x0b, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x09, 0x04, 0x00, 0x00,
+	0x02, 0x03, 0x00, 0x00, 0x05, 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22,
+	0x61, 0x00, 0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x04, 0x07, 0x05, 0x01,
+	0x03, 0x40, 0x00, 0x04, 0x08, 0x0b, 0x01, 0x01, 0xff, 0x00, 0x00, 0x00,
+	0x09, 0x04, 0x01, 0x00, 0x02, 0xff, 0x00, 0x00, 0x06, 0x07, 0x05, 0x02,
+	0x02, 0x40, 0x00, 0x00, 0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00, 0x08,
+	0x0b, 0x02, 0x03, 0x01, 0x01, 0x00, 0x00, 0x09, 0x04, 0x02, 0x00, 0x00,
+	0x01, 0x01, 0x00, 0x00, 0x0a, 0x24, 0x01, 0x00, 0x01, 0x47, 0x00, 0x02,
+	0x03, 0x04, 0x0c, 0x24, 0x02, 0x01, 0x01, 0x01, 0x00, 0x02, 0x03, 0x00,
+	0x00, 0x00, 0x0a, 0x24, 0x06, 0x02, 0x01, 0x01, 0x03, 0x00, 0x00, 0x00,
+	0x09, 0x24, 0x03, 0x03, 0x02, 0x03, 0x00, 0x02, 0x00, 0x0c, 0x24, 0x02,
+	0x04, 0x01, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x09, 0x24, 0x06,
+	0x05, 0x04, 0x01, 0x03, 0x00, 0x00, 0x09, 0x24, 0x03, 0x06, 0x01, 0x01,
+	0x00, 0x05, 0x00, 0x09, 0x04, 0x03, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00,
+	0x09, 0x04, 0x03, 0x01, 0x01, 0x01, 0x02, 0x00, 0x00, 0x07, 0x24, 0x01,
+	0x01, 0x00, 0x01, 0x00, 0x0b, 0x24, 0x02, 0x01, 0x02, 0x02, 0x10, 0x01,
+	0x80, 0xbb, 0x00, 0x07, 0x05, 0x03, 0x0d, 0xc0, 0x00, 0x01, 0x07, 0x25,
+	0x01, 0x00, 0x00, 0x00, 0x00, 0x09, 0x04, 0x04, 0x00, 0x00, 0x01, 0x02,
+	0x00, 0x00, 0x09, 0x04, 0x04, 0x01, 0x01, 0x01, 0x02, 0x00, 0x00, 0x07,
+	0x24, 0x01, 0x06, 0x00, 0x01, 0x00, 0x0b, 0x24, 0x02, 0x01, 0x02, 0x02,
+	0x10, 0x01, 0x80, 0xbb, 0x00, 0x07, 0x05, 0x83, 0x0d, 0xc0, 0x00, 0x01,
+	0x07, 0x25, 0x01, 0x00, 0x00, 0x00, 0x00,
+};
+static_assert(sizeof SWITCH2_PRO_CFG_BODY == 259,
+	      "Switch 2 Pro configuration body must remain byte-exact");
+
+static volatile uint8_t g_sw2ActiveReport = 0x09;
+static volatile uint8_t g_sw2Features = 0;
+static volatile uint8_t g_sw2FeatureMask = 0;
+static volatile bool g_sw2InputEnabled = false;
+static volatile uint8_t g_sw2Counter8 = 0;
+static volatile uint32_t g_sw2Counter32 = 0;
+static int8_t g_sw2LastRumbleBond = -1;
+static uint16_t g_sw2LastRumbleLeft = 0;
+static uint16_t g_sw2LastRumbleRight = 0;
+static uint8_t g_sw2Map[7] = { 9, 10, 23, 22, 18, 11, 21 };
+static bool g_sw2MapLoaded = false;
+
+static void switch2ProLoadMap()
+{
+	if (g_sw2MapLoaded)
+		return;
+	g_sw2MapLoaded = true;
+	static const uint8_t def[7] = { 9, 10, 23, 22, 18, 11, 21 };
+	bool normalize = false;
+	for (uint8_t i = 0; i < 7; i++) {
+		const uint8_t saved = cfgExtRead((uint8_t)(3u + i));
+		if (saved <= 23u) {
+			g_sw2Map[i] = saved;
+		} else {
+			g_sw2Map[i] = def[i];
+			cfgExtWrite((uint8_t)(3u + i), def[i]);
+			normalize = true;
+		}
+	}
+	if (normalize)
+		saveCfg();
+}
+
+uint8_t switch2ProMapGet(uint8_t index)
+{
+	switch2ProLoadMap();
+	return index < 7u ? g_sw2Map[index] : 0u;
+}
+
+void switch2ProMapSet(uint8_t index, uint8_t value)
+{
+	switch2ProLoadMap();
+	if (index >= 7u || value > 23u)
+		return;
+	g_sw2Map[index] = value;
+	cfgExtWrite((uint8_t)(3u + index), value);
+}
+static volatile bool g_sw2VendorCommandPending = false;
+static volatile bool g_sw2VendorInFlight = false;
+static volatile uint8_t g_sw2VendorCommandLen = 0;
+static volatile uint8_t g_sw2VendorReplyLen = 0;
+static uint8_t g_sw2VendorOut[64];
+static uint8_t g_sw2VendorReply[64];
+static uint8_t g_sw2ControlReply[64];
+static const uint8_t SW2_VENDOR_IDENTITY[64] = {
+	0x01, 0x00, 'H',  'E',	'W',  '7',  '0',  '0',	'0',  '6',  '1',
+	'6',  '9',  '7',  '8',	'0',  0x00, 0x00, 0x7e, 0x05, 0x69, 0x20,
+	0x01, 0x06, 0x01, 0x23, 0x23, 0x23, 0xa0, 0xa0, 0xa0, 0xe6, 0xe6,
+	0xe6, 0x32, 0x32, 0x32, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
+	0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
+	0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
+};
+static_assert(sizeof SW2_VENDOR_IDENTITY == 64,
+	      "Switch 2 Pro vendor identity must remain 64 bytes");
+
+static const uint8_t SW2_VENDOR_PROTOCOL[16] = {
+	0x02, 0x01, 0x04, 0x00, 0x00, 0x00, 0x0c, 0x00,
+	0x02, 0x03, 0x3d, 0x17, 0x69, 0xab, 0xa9, 0x3c,
+};
+
+static volatile uint8_t g_sw2VendorEpOut = 0;
+static volatile uint8_t g_sw2VendorEpIn = 0;
+static volatile uint8_t g_sw2Rhport = 0;
+static unsigned long g_sw2LastReportMs = 0;
+static bool g_sw2DrainRegistered = false;
+
+class Switch2ProUsbInterface : public Adafruit_USBD_Interface {
+    public:
+	uint16_t getInterfaceDescriptor(uint8_t, uint8_t *buf,
+					uint16_t bufsize) override
+	{
+		if (!buf)
+			return sizeof SWITCH2_PRO_CFG_BODY;
+		if (bufsize < sizeof SWITCH2_PRO_CFG_BODY)
+			return 0;
+
+		uint8_t first = TinyUSBDevice.allocInterface(5);
+		uint8_t hidIn = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN);
+		uint8_t hidOut = TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		uint8_t vendorOut = TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		uint8_t vendorIn = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN);
+		uint8_t audioOut = TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		uint8_t audioIn = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN);
+		if (first != 0 || hidIn != 0x81 || hidOut != 0x01 ||
+		    vendorOut != 0x02 || vendorIn != 0x82 || audioOut != 0x03 ||
+		    audioIn != 0x83)
+			return 0;
+
+		uint8_t configStr =
+			TinyUSBDevice.addStringDescriptor("Config_0");
+		uint8_t hidStr = TinyUSBDevice.addStringDescriptor("If_Hid");
+		uint8_t vendorStr = TinyUSBDevice.addStringDescriptor(
+			"Switch 2 Pro Controller");
+		if (configStr != 4 || hidStr != 5 || vendorStr != 6)
+			return 0;
+
+		memcpy(buf, SWITCH2_PRO_CFG_BODY, sizeof SWITCH2_PRO_CFG_BODY);
+		return sizeof SWITCH2_PRO_CFG_BODY;
+	}
+};
+static Switch2ProUsbInterface g_sw2Usb;
+
+static inline uint16_t sw2Stick12(int16_t v)
+{
+	return (uint16_t)(((int32_t)v + 32768) >> 4);
+}
+
+static inline void sw2PackStick(uint8_t out[3], int16_t x, int16_t y)
+{
+	uint16_t sx = sw2Stick12(x), sy = sw2Stick12(y);
+	out[0] = (uint8_t)sx;
+	out[1] = (uint8_t)((sx >> 8) | (sy << 4));
+	out[2] = (uint8_t)(sy >> 4);
+}
+
+static void sw2SetCode(uint8_t code, uint8_t b09[3], uint8_t b05[4])
+{
+	switch (code) {
+	case 1: // A
+		b09[0] |= 0x02;
+		b05[0] |= 0x08;
+		break;
+	case 2: // B
+		b09[0] |= 0x01;
+		b05[0] |= 0x04;
+		break;
+	case 3: // X
+		b09[0] |= 0x08;
+		b05[0] |= 0x02;
+		break;
+	case 4: // Y
+		b09[0] |= 0x04;
+		b05[0] |= 0x01;
+		break;
+	case 5: // L
+		b09[1] |= 0x10;
+		b05[2] |= 0x40;
+		break;
+	case 6: // R
+		b09[0] |= 0x10;
+		b05[0] |= 0x40;
+		break;
+	case 7: // left stick
+		b09[1] |= 0x80;
+		b05[1] |= 0x08;
+		break;
+	case 8: // right stick
+		b09[0] |= 0x80;
+		b05[1] |= 0x04;
+		break;
+	case 9: // minus
+		b09[1] |= 0x40;
+		b05[1] |= 0x01;
+		break;
+	case 10: // plus
+		b09[0] |= 0x40;
+		b05[1] |= 0x02;
+		break;
+	case 11: // home
+		b09[2] |= 0x01;
+		b05[1] |= 0x10;
+		break;
+	case 12: // up
+		b09[1] |= 0x08;
+		b05[2] |= 0x02;
+		break;
+	case 13: // down
+		b09[1] |= 0x01;
+		b05[2] |= 0x01;
+		break;
+	case 14: // left
+		b09[1] |= 0x04;
+		b05[2] |= 0x08;
+		break;
+	case 15: // right
+		b09[1] |= 0x02;
+		b05[2] |= 0x04;
+		break;
+	case 18: // capture
+		b09[2] |= 0x02;
+		b05[1] |= 0x20;
+		break;
+	case 19: // ZL
+		b09[1] |= 0x20;
+		b05[2] |= 0x80;
+		break;
+	case 20: // ZR
+		b09[0] |= 0x20;
+		b05[0] |= 0x80;
+		break;
+	case 21: // C
+		b09[2] |= 0x10;
+		b05[1] |= 0x40;
+		break;
+	case 22: // GR
+		b09[2] |= 0x04;
+		b05[3] |= 0x01;
+		break;
+	case 23: // GL
+		b09[2] |= 0x08;
+		b05[3] |= 0x02;
+		break;
+	default:
+		break;
+	}
+}
+
+static void sw2Buttons(uint8_t slot, uint8_t b09[3], uint8_t b05[4])
+{
+	memset(b09, 0, 3);
+	memset(b05, 0, 4);
+	uint32_t b = g_in[slot].buttons;
+	bool qam = g_qamMap && (b & TB_QAM);
+	if ((b & CHORD_BACK4) == CHORD_BACK4)
+		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y | TB_DUP | TB_DDN |
+				 TB_DLF | TB_DRT);
+	if (b & TB_A)
+		sw2SetCode(g_abSwap ? 2 : 1, b09, b05);
+	if (b & TB_B)
+		sw2SetCode(g_abSwap ? 1 : 2, b09, b05);
+	if (b & TB_X)
+		sw2SetCode(g_abSwap ? 4 : 3, b09, b05);
+	if (b & TB_Y)
+		sw2SetCode(g_abSwap ? 3 : 4, b09, b05);
+	if (b & TB_LB)
+		sw2SetCode(5, b09, b05);
+	if (b & TB_RB)
+		sw2SetCode(6, b09, b05);
+	if (g_in[slot].lt >= SW_TRIG_ON || (b & 0x8000000u))
+		sw2SetCode(19, b09, b05);
+	if (g_in[slot].rt >= SW_TRIG_ON || (b & 0x800000u))
+		sw2SetCode(20, b09, b05);
+	if (b & TB_L3)
+		sw2SetCode(7, b09, b05);
+	if (b & TB_R3)
+		sw2SetCode(8, b09, b05);
+	if (b & TB_DUP)
+		sw2SetCode(12, b09, b05);
+	if (b & TB_DDN)
+		sw2SetCode(13, b09, b05);
+	if (b & TB_DLF)
+		sw2SetCode(14, b09, b05);
+	if (b & TB_DRT)
+		sw2SetCode(15, b09, b05);
+
+	static const uint32_t source[7] = { TB_L4,   TB_R4,   TB_L5,   TB_R5,
+					    TB_VIEW, TB_MENU, TB_STEAM };
+	for (uint8_t i = 0; i < 7; i++)
+		if (b & source[i])
+			sw2SetCode(switch2ProMapGet(i), b09, b05);
+	if (qam)
+		sw2SetCode(g_qamMap, b09, b05);
+}
+
+static uint8_t sw2PowerInfo(uint8_t slot)
+{
+	uint8_t pct = slot < NSLOT ? g_battery[slot] : 0;
+	uint8_t level = pct ? (uint8_t)(((uint16_t)pct * 9u + 50u) / 100u) : 0;
+	if (level > 9)
+		level = 9;
+	return (uint8_t)(level << 2);
+}
+
+static void sw2Build09(uint8_t slot, uint8_t out[63])
+{
+	memset(out, 0, 63);
+	uint8_t b09[3], b05[4];
+	sw2Buttons(slot, b09, b05);
+	out[0] = g_sw2Counter8++;
+	out[1] = sw2PowerInfo(slot);
+	memcpy(out + 2, b09, sizeof b09);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	sw2PackStick(out + 5, lx, ly);
+	sw2PackStick(out + 8, rx, ry);
+	out[0x0b] = (g_sw2Features & (1u << 5)) ? 0x38 : 0x30;
+	out[0x0e] = 0; // native 0x09 packed motion remains unknown
+}
+
+static inline void sw2Put16(uint8_t *p, int16_t v)
+{
+	p[0] = (uint8_t)v;
+	p[1] = (uint8_t)((uint16_t)v >> 8);
+}
+
+static void sw2Build05(uint8_t slot, uint8_t out[63])
+{
+	memset(out, 0, 63);
+	uint8_t b09[3], b05[4];
+	sw2Buttons(slot, b09, b05);
+	uint32_t counter = g_sw2Counter32++;
+	memcpy(out, &counter, sizeof counter);
+	memcpy(out + 4, b05, sizeof b05);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	sw2PackStick(out + 0x0a, lx, ly);
+	sw2PackStick(out + 0x0d, rx, ry);
+	out[0x1f] = 0xd8;
+	out[0x20] = 0x0e;
+	out[0x21] = 0x34;
+	out[0x29] = 0x01;
+
+	uint8_t *m = out + 0x2a;
+	uint32_t ts = g_in[slot].imuTimestampUs;
+	if (!ts)
+		ts = micros();
+	memcpy(m, &ts, sizeof ts);
+	int16_t ax = (int16_t)(g_in[slot].ay / 4);
+	int16_t ay = (int16_t)((-(int16_t)g_in[slot].ax) / 4);
+	int16_t az = (int16_t)(g_in[slot].az / 4);
+	int16_t gx = (int16_t)g_in[slot].gy;
+	int16_t gy = (int16_t)(-(int16_t)g_in[slot].gx);
+	int16_t gz = (int16_t)g_in[slot].gz;
+	if (!g_swGyroLegacy) {
+		gx = (int16_t)(((int32_t)gx * 4) / 5);
+		gy = (int16_t)(((int32_t)gy * 9) / 10);
+		gz = (int16_t)(((int32_t)gz * 9) / 10);
+	}
+	// m[4..5] temperature stays zero; sensor axes begin at m[6].
+	sw2Put16(m + 6, ax);
+	sw2Put16(m + 8, ay);
+	sw2Put16(m + 10, az);
+	sw2Put16(m + 12, gx);
+	sw2Put16(m + 14, gy);
+	sw2Put16(m + 16, gz);
+}
+
+static void sw2AckHeader(uint8_t *out, uint8_t cmd, uint8_t transport,
+			 uint8_t sub)
+{
+	out[0] = cmd;
+	out[1] = 0x01;
+	out[2] = transport;
+	out[3] = sub;
+	out[4] = 0x00;
+	out[5] = 0xf8;
+	out[6] = 0x00;
+	out[7] = 0x00;
+}
+
+static void sw2DataHeader(uint8_t *out, uint8_t cmd, uint8_t transport,
+			  uint8_t sub)
+{
+	out[0] = cmd;
+	out[1] = 0x01;
+	out[2] = transport;
+	out[3] = sub;
+	out[4] = 0x10;
+	out[5] = 0x78;
+	out[6] = 0x00;
+	out[7] = 0x00;
+}
+
+static uint8_t sw2QueueAckHeader(uint8_t cmd, uint8_t transport, uint8_t sub,
+				 uint8_t *reply)
+{
+	sw2AckHeader(reply, cmd, transport, sub);
+	return 8;
+}
+
+static uint8_t sw2QueueDataHeader(uint8_t cmd, uint8_t transport, uint8_t sub,
+				  uint8_t *reply)
+{
+	sw2DataHeader(reply, cmd, transport, sub);
+	return 8;
+}
+
+static const uint8_t SW2_FACTORY_13040[16] = {
+	0x3b, 0xe0, 0xd3, 0x41, 0xc6, 0x60, 0x6a, 0xbc,
+	0x4d, 0xd7, 0xa2, 0xbb, 0x71, 0x1e, 0xdd, 0x37,
+};
+
+static const uint8_t SW2_FACTORY_13080[49] = {
+	0x01, 0xad, 0xd9, 0x9a, 0x55, 0x56, 0x65, 0xa0, 0x00, 0x0a,
+	0xa0, 0x00, 0x0a, 0xe2, 0x20, 0x0e, 0xe2, 0x20, 0x0e, 0x9a,
+	0xad, 0xd9, 0x9a, 0xad, 0xd9, 0x0a, 0xa5, 0x50, 0x0a, 0xa5,
+	0x50, 0x2f, 0xf6, 0x62, 0x2f, 0xf6, 0x62, 0x0a, 0xff, 0xff,
+	0xb3, 0x67, 0x83, 0x2e, 0x66, 0x5e, 0x3a, 0x06, 0x5f,
+};
+
+static const uint8_t SW2_FACTORY_130C0[49] = {
+	0x01, 0xad, 0xd9, 0x9a, 0x55, 0x56, 0x65, 0xa0, 0x00, 0x0a,
+	0xa0, 0x00, 0x0a, 0xe2, 0x20, 0x0e, 0xe2, 0x20, 0x0e, 0x9a,
+	0xad, 0xd9, 0x9a, 0xad, 0xd9, 0x0a, 0xa5, 0x50, 0x0a, 0xa5,
+	0x50, 0x2f, 0xf6, 0x62, 0x2f, 0xf6, 0x62, 0x0a, 0xff, 0xff,
+	0x2c, 0x08, 0x84, 0xd1, 0x65, 0x63, 0x2a, 0x26, 0x62,
+};
+
+static const uint8_t SW2_FACTORY_13100[24] = {
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0xa6, 0xf2, 0x62, 0xbd, 0xa8, 0x00, 0x08, 0x3d, 0x2f, 0xed, 0x20, 0x41,
+};
+
+static void sw2OverlayFlash(uint32_t address, uint8_t *out, uint8_t len,
+			    uint32_t knownAddress, const uint8_t *known,
+			    uint8_t knownLen)
+{
+	uint32_t begin = address > knownAddress ? address : knownAddress;
+	uint32_t end = address + len;
+	uint32_t knownEnd = knownAddress + knownLen;
+	if (end > knownEnd)
+		end = knownEnd;
+	if (begin >= end)
+		return;
+	memcpy(out + (begin - address), known + (begin - knownAddress),
+	       end - begin);
+}
+
+static void sw2FillFlashBlock(uint32_t address, uint8_t *block, uint8_t len)
+{
+	// Uninitialised controller flash is 0xff; overlay captured Pro factory data.
+	memset(block, 0xff, len);
+	sw2OverlayFlash(address, block, len, 0x00013000, SW2_VENDOR_IDENTITY,
+			sizeof SW2_VENDOR_IDENTITY);
+	sw2OverlayFlash(address, block, len, 0x00013040, SW2_FACTORY_13040,
+			sizeof SW2_FACTORY_13040);
+	static const uint8_t factory13060[4] = { 0x4c, 0x09, 0x00, 0x00 };
+	sw2OverlayFlash(address, block, len, 0x00013060, factory13060,
+			sizeof factory13060);
+	sw2OverlayFlash(address, block, len, 0x00013080, SW2_FACTORY_13080,
+			sizeof SW2_FACTORY_13080);
+	sw2OverlayFlash(address, block, len, 0x000130c0, SW2_FACTORY_130C0,
+			sizeof SW2_FACTORY_130C0);
+	sw2OverlayFlash(address, block, len, 0x00013100, SW2_FACTORY_13100,
+			sizeof SW2_FACTORY_13100);
+}
+
+static uint8_t sw2HandleFlashCommand(const uint8_t *cmd, uint8_t n,
+				     uint8_t *reply)
+{
+	uint8_t sub = cmd[3];
+	if (n < 16)
+		return sw2QueueDataHeader(0x02, cmd[2], sub, reply);
+
+	uint8_t requested;
+	if (sub == 0x01)
+		requested = 0x40;
+	else if (sub == 0x04)
+		requested = cmd[8];
+	else
+		return sw2QueueDataHeader(0x02, cmd[2], sub, reply);
+
+	sw2DataHeader(reply, 0x02, cmd[2], sub);
+	uint8_t copied = requested > 80 ? 80 : requested;
+	uint32_t address = (uint32_t)cmd[12] | ((uint32_t)cmd[13] << 8) |
+			   ((uint32_t)cmd[14] << 16) |
+			   ((uint32_t)cmd[15] << 24);
+	reply[8] = copied;
+	reply[12] = cmd[12];
+	reply[13] = cmd[13];
+	reply[14] = cmd[14];
+	reply[15] = cmd[15];
+	sw2FillFlashBlock(address, reply + 16, copied);
+	return (uint8_t)(16 + copied);
+}
+
+static void sw2FeatureInfo(uint8_t flags, uint8_t out[8])
+{
+	memset(out, 0, 8);
+	if (flags & 0x01)
+		out[0] = 0x07;
+	if (flags & 0x02)
+		out[1] = 0x07;
+	if (flags & 0x04)
+		out[2] = 0x01;
+	if (flags & 0x80)
+		out[3] = 0x01;
+	if (flags & 0x10)
+		out[4] = 0x01;
+	if (flags & 0x20)
+		out[5] = 0x03;
+}
+
+static uint8_t sw2HandleUsbCommand(const uint8_t *cmd, uint8_t n,
+				   uint8_t *reply)
+{
+	uint8_t sub = cmd[3];
+	sw2AckHeader(reply, 0x03, cmd[2], sub);
+	if (sub == 0x03) {
+		g_sw2InputEnabled = n >= 9 && cmd[8] != 0;
+		reply[8] = 0x01;
+		return 12;
+	}
+	if (sub == 0x0d) {
+		g_sw2InputEnabled = true;
+		reply[8] = 0x01;
+		return 12;
+	}
+	if (sub == 0x0f) {
+		reply[8] = 0x05;
+		return 12;
+	}
+	if (sub == 0x07 || sub == 0x09) {
+		sw2DataHeader(reply, 0x03, cmd[2], sub);
+		return 8;
+	}
+	if (sub == 0x0a && n >= 9 && (cmd[8] == 0x05 || cmd[8] == 0x09))
+		g_sw2ActiveReport = cmd[8];
+	return 8;
+}
+
+static uint8_t g_sw2PairLtk[16];
+static bool g_sw2PairLtkValid = false;
+static const uint8_t SW2_PAIR_PUBLIC[16] = {
+	0x5c, 0xf6, 0xee, 0x79, 0x2c, 0xdf, 0x05, 0xe1,
+	0xba, 0x2b, 0x63, 0x25, 0xc4, 0x1a, 0x5f, 0x10,
+};
+
+struct Sw2EcbBlock {
+	uint8_t key[16];
+	uint8_t clear[16];
+	uint8_t cipher[16];
+} __attribute__((aligned(4)));
+
+static bool sw2Aes128Ecb(const uint8_t key[16], const uint8_t clear[16],
+			 uint8_t cipher[16])
+{
+	static Sw2EcbBlock block;
+	memcpy(block.key, key, 16);
+	memcpy(block.clear, clear, 16);
+	NRF_ECB->TASKS_STOPECB = 1;
+	NRF_ECB->EVENTS_ENDECB = 0;
+	NRF_ECB->EVENTS_ERRORECB = 0;
+	NRF_ECB->ECBDATAPTR = (uint32_t)&block;
+	NRF_ECB->TASKS_STARTECB = 1;
+	while (!NRF_ECB->EVENTS_ENDECB && !NRF_ECB->EVENTS_ERRORECB) {
+	}
+	if (NRF_ECB->EVENTS_ERRORECB)
+		return false;
+	memcpy(cipher, block.cipher, 16);
+	return true;
+}
+
+static void sw2PairResponse(const uint8_t challenge[16], uint8_t out[16])
+{
+	uint8_t key[16], clear[16], cipher[16];
+	for (uint8_t i = 0; i < 16; i++) {
+		key[i] = g_sw2PairLtk[15 - i];
+		clear[i] = challenge[15 - i];
+	}
+	if (!g_sw2PairLtkValid || !sw2Aes128Ecb(key, clear, cipher)) {
+		memset(out, 0, 16);
+		return;
+	}
+	// Nintendo's confirmation packet uses the AES output in the order returned
+	// by the hardware after reversing the key and challenge inputs.
+	memcpy(out, cipher, 16);
+}
+
+static void sw2ControllerAddress(uint8_t out[6])
+{
+	uint32_t a = NRF_FICR->DEVICEID[0], b = NRF_FICR->DEVICEID[1];
+	out[0] = (uint8_t)a;
+	out[1] = (uint8_t)(a >> 8);
+	out[2] = (uint8_t)(a >> 16);
+	out[3] = (uint8_t)(a >> 24);
+	out[4] = (uint8_t)b;
+	out[5] = (uint8_t)(b >> 8);
+}
+
+static uint8_t sw2HandlePairing(const uint8_t *cmd, uint8_t n, uint8_t *reply)
+{
+	uint8_t sub = cmd[3];
+	sw2DataHeader(reply, 0x15, cmd[2], sub);
+	if (sub == 0x01) {
+		uint8_t addr[6];
+		sw2ControllerAddress(addr);
+		reply[8] = 0x01;
+		reply[9] = 0x04;
+		reply[10] = 0x01;
+		memcpy(reply + 11, addr, sizeof addr);
+		return 17;
+	}
+	if (sub == 0x04 && n >= 25) {
+		reply[8] = 0x01;
+		memcpy(reply + 9, SW2_PAIR_PUBLIC, sizeof SW2_PAIR_PUBLIC);
+		for (uint8_t i = 0; i < 16; i++)
+			g_sw2PairLtk[i] = cmd[9 + i] ^ SW2_PAIR_PUBLIC[i];
+		g_sw2PairLtkValid = true;
+		return 25;
+	}
+	if (sub == 0x02 && n >= 25) {
+		reply[8] = 0x01;
+		sw2PairResponse(cmd + 9, reply + 9);
+		return 25;
+	}
+	if (sub == 0x03) {
+		reply[8] = 0x01;
+		return 9;
+	}
+	reply[8] = 0x01;
+	return 9;
+}
+
+static uint8_t sw2HandleFeatures(const uint8_t *cmd, uint8_t n, uint8_t *reply)
+{
+	uint8_t sub = cmd[3], flags = n >= 9 ? cmd[8] : 0;
+	sw2DataHeader(reply, 0x0c, cmd[2], sub);
+	if (sub == 0x01) {
+		sw2FeatureInfo(flags, reply + 12);
+		return 20;
+	}
+	if (sub == 0x02)
+		g_sw2FeatureMask = flags;
+	else if (sub == 0x03) {
+		g_sw2FeatureMask = 0;
+		g_sw2Features = 0;
+	} else if (sub == 0x04) {
+		g_sw2Features |= flags & g_sw2FeatureMask;
+	} else if (sub == 0x05) {
+		g_sw2Features &= (uint8_t) ~(flags & g_sw2FeatureMask);
+	}
+	return 12;
+}
+
+static void sw2BuildVendorReply(void)
+{
+	uint8_t cmd[64];
+	uint8_t reply[96];
+	uint8_t n;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	n = g_sw2VendorCommandLen;
+	if (n > sizeof cmd)
+		n = sizeof cmd;
+	memcpy(cmd, g_sw2VendorOut, n);
+	g_sw2VendorCommandPending = false;
+	__set_PRIMASK(pm);
+
+	memset(reply, 0, sizeof reply);
+	if (n < 8 || cmd[1] != 0x91) {
+		g_sw2VendorReplyLen = 0;
+		return;
+	}
+	uint8_t id = cmd[0], sub = cmd[3], seq = cmd[2];
+	sw2AckHeader(reply, id, seq, sub);
+	uint8_t replyLen = 8;
+
+	switch (id) {
+	case 0x02:
+		replyLen = sw2HandleFlashCommand(cmd, n, reply);
+		break;
+	case 0x03:
+		replyLen = sw2HandleUsbCommand(cmd, n, reply);
+		break;
+	case 0x0c:
+		replyLen = sw2HandleFeatures(cmd, n, reply);
+		break;
+	case 0x15:
+		replyLen = sw2HandlePairing(cmd, n, reply);
+		break;
+	case 0x07:
+		replyLen = sw2QueueDataHeader(id, seq, sub, reply);
+		reply[8] = 0;
+		replyLen = 9;
+		break;
+	case 0x01:
+		if (sub == 0x0c) {
+			sw2DataHeader(reply, id, seq, sub);
+			reply[8] = 0x61;
+			reply[9] = 0x12;
+			reply[10] = 0x50;
+			reply[11] = 0x0d;
+			replyLen = 12;
+		}
+		break;
+	case 0x10:
+		if (sub == 0x01) {
+			static const uint8_t info[12] = {
+				0x02, 0x01, 0x04, 0x02, 0x0c, 0x00,
+				0x00, 0x00, 0x00, 0x02, 0x03, 0x00,
+			};
+			sw2DataHeader(reply, id, seq, sub);
+			memcpy(reply + 8, info, sizeof info);
+			replyLen = 20;
+		}
+		break;
+	case 0x11:
+		if (sub == 0x03) {
+			static const uint8_t imuCal[] = {
+				0x01, 0x20, 0x03, 0x00, 0x00, 0x0a, 0xe8, 0x1c,
+				0x3b, 0x79, 0x7d, 0x8b, 0x3a, 0x0a, 0xe8, 0x9c,
+				0x42, 0x58, 0xa0, 0x0b, 0x42, 0x0a, 0xe8, 0x9c,
+				0x41, 0x58, 0xa0, 0x0b, 0x41,
+			};
+			sw2DataHeader(reply, id, seq, sub);
+			memcpy(reply + 8, imuCal, sizeof imuCal);
+			replyLen = (uint8_t)(8 + sizeof imuCal);
+		} else if (sub == 0x01) {
+			sw2DataHeader(reply, id, seq, sub);
+			reply[8] = 0x01;
+			replyLen = 12;
+		}
+		break;
+	case 0x16:
+		if (sub == 0x01) {
+			sw2DataHeader(reply, id, seq, sub);
+			memset(reply + 8, 0, 24);
+			replyLen = 32;
+		}
+		break;
+	case 0x18:
+		if (sub == 0x01) {
+			static const uint8_t powerInfo[8] = {
+				0x00, 0x00, 0x40, 0xf0, 0x00, 0x00, 0x60, 0x00,
+			};
+			sw2DataHeader(reply, id, seq, sub);
+			memcpy(reply + 8, powerInfo, sizeof powerInfo);
+			replyLen = 16;
+		} else if (sub == 0x03 && n >= 9) {
+			sw2DataHeader(reply, id, seq, sub);
+			reply[8] = cmd[8];
+			replyLen = 9;
+		}
+		break;
+	case 0x06:
+		if (sub == 0x02) {
+			g_sw2VendorReplyLen = 0;
+			return;
+		}
+		replyLen = sw2QueueDataHeader(id, seq, sub, reply);
+		break;
+	case 0x09:
+	case 0x0a:
+	case 0x0d:
+	case 0x17:
+		replyLen = sw2QueueDataHeader(id, seq, sub, reply);
+		break;
+	case 0x0b:
+		sw2DataHeader(reply, id, seq, sub);
+		if (sub == 0x03) {
+			reply[8] = 0xa5;
+			reply[9] = 0x0e;
+			replyLen = 12;
+		} else if (sub == 0x04) {
+			reply[8] = 0x34;
+			reply[10] = 0x83;
+			replyLen = 12;
+		} else if (sub == 0x06) {
+			reply[8] = 0x11;
+			replyLen = 12;
+		}
+		break;
+	default:
+		replyLen = sw2QueueDataHeader(id, seq, sub, reply);
+		break;
+	}
+
+	uint8_t first = replyLen > sizeof g_sw2VendorReply ?
+				sizeof g_sw2VendorReply :
+				replyLen;
+	memcpy(g_sw2VendorReply, reply, first);
+	if (replyLen > first)
+		memcpy(g_sw2VendorOut, reply + first, replyLen - first);
+	g_sw2VendorReplyLen = replyLen;
+}
+
+static void sw2Drain(void)
+{
+	if (g_usbMode != MODE_SW2_PRO || g_usbMountCount == 0)
+		return;
+
+	if (g_sw2VendorCommandPending && !g_sw2VendorInFlight) {
+		sw2BuildVendorReply();
+		uint8_t n = g_sw2VendorReplyLen;
+		uint8_t first = n > sizeof g_sw2VendorReply ?
+					sizeof g_sw2VendorReply :
+					n;
+		if (first && g_sw2VendorEpIn &&
+		    usbd_edpt_xfer(g_sw2Rhport, g_sw2VendorEpIn,
+				   g_sw2VendorReply, first))
+			g_sw2VendorInFlight = true;
+	}
+
+	if (!g_sw2InputEnabled || !tud_hid_n_ready(0))
+		return;
+	if ((uint32_t)(millis() - g_sw2LastReportMs) < USB_STREAM_MS)
+		return;
+	int bond = g_usbToBond[0];
+	if (bond < 0 || bond >= NSLOT)
+		return;
+	uint8_t p[63];
+	uint8_t rid = g_sw2ActiveReport;
+	if (rid == 0x05)
+		sw2Build05((uint8_t)bond, p);
+	else {
+		rid = 0x09;
+		sw2Build09((uint8_t)bond, p);
+	}
+	if (tud_hid_n_report(0, rid, p, sizeof p))
+		g_sw2LastReportMs = millis();
+}
+
+bool switch2ProVendorControlXfer(uint8_t rhport, uint8_t stage,
+				 const tusb_control_request_t *request)
+{
+	if (g_usbMode != MODE_SW2_PRO || !request)
+		return false;
+
+	bool identity = request->bmRequestType == 0xc0 &&
+			request->bRequest == 0x03 && request->wLength >= 64;
+	bool protocol = request->bmRequestType == 0xc0 &&
+			request->bRequest == 0x02 && request->wLength >= 16;
+	bool commit = request->bmRequestType == 0x40 &&
+		      request->bRequest == 0x04 && request->wLength == 0;
+	if (!identity && !protocol && !commit)
+		return false;
+	if (stage != CONTROL_STAGE_SETUP)
+		return true;
+
+	if (commit)
+		return tud_control_status(rhport, request);
+	if (identity) {
+		memcpy(g_sw2ControlReply, SW2_VENDOR_IDENTITY,
+		       sizeof SW2_VENDOR_IDENTITY);
+		return tud_control_xfer(rhport, request, g_sw2ControlReply,
+					sizeof SW2_VENDOR_IDENTITY);
+	}
+	memcpy(g_sw2ControlReply, SW2_VENDOR_PROTOCOL,
+	       sizeof SW2_VENDOR_PROTOCOL);
+	return tud_control_xfer(rhport, request, g_sw2ControlReply,
+				sizeof SW2_VENDOR_PROTOCOL);
+}
+
+static void sw2DriverInit(void)
+{
+	g_sw2VendorEpOut = g_sw2VendorEpIn = 0;
+}
+static bool sw2DriverDeinit(void)
+{
+	return true;
+}
+static void sw2DriverReset(uint8_t rhport)
+{
+	(void)rhport;
+	g_sw2VendorEpOut = g_sw2VendorEpIn = 0;
+	g_sw2VendorCommandPending = false;
+	g_sw2VendorInFlight = false;
+	g_sw2InputEnabled = false;
+	g_sw2ActiveReport = 0x09;
+	g_sw2Features = 0;
+	g_sw2FeatureMask = 0;
+	g_sw2LastRumbleBond = -1;
+	g_sw2LastRumbleLeft = 0;
+	g_sw2LastRumbleRight = 0;
+}
+
+static uint16_t sw2DriverOpen(uint8_t rhport, tusb_desc_interface_t const *itf,
+			      uint16_t maxLen)
+{
+	if (g_usbMode != MODE_SW2_PRO)
+		return 0;
+	g_sw2Rhport = rhport;
+	if (itf->bInterfaceNumber == 0 &&
+	    itf->bInterfaceClass == TUSB_CLASS_HID)
+		return hidd_open(rhport, itf, maxLen);
+
+	if (itf->bInterfaceNumber == 1 && itf->bInterfaceClass == 0xff) {
+		uint8_t const *p = (uint8_t const *)itf;
+		uint8_t const *end = p + maxLen;
+		uint16_t used = 0;
+		uint8_t opened = 0;
+		while (p < end) {
+			uint8_t len = p[0], type = p[1];
+			if (!len)
+				return 0;
+			if (p != (uint8_t const *)itf &&
+			    (type == TUSB_DESC_INTERFACE ||
+			     type == TUSB_DESC_INTERFACE_ASSOCIATION))
+				break;
+			if (type == TUSB_DESC_ENDPOINT) {
+				auto ep = (tusb_desc_endpoint_t const *)p;
+				if (!usbd_edpt_open(rhport, ep))
+					return 0;
+				if (tu_edpt_dir(ep->bEndpointAddress) ==
+				    TUSB_DIR_IN)
+					g_sw2VendorEpIn = ep->bEndpointAddress;
+				else
+					g_sw2VendorEpOut = ep->bEndpointAddress;
+				opened++;
+			}
+			used += len;
+			p += len;
+		}
+		if (opened != 2 || !g_sw2VendorEpOut || !g_sw2VendorEpIn)
+			return 0;
+		if (!usbd_edpt_xfer(rhport, g_sw2VendorEpOut, g_sw2VendorOut,
+				    sizeof g_sw2VendorOut))
+			return 0;
+		return used;
+	}
+
+	// Preserve the captured audio-control/streaming descriptor block as one
+	// associated three-interface Nintendo function. Audio data itself is not
+	// synthesized by OpenPuck.
+	if (itf->bInterfaceNumber == 2 &&
+	    itf->bInterfaceClass == TUSB_CLASS_AUDIO)
+		return maxLen;
+	return 0;
+}
+
+static bool sw2DriverControl(uint8_t rhport, uint8_t stage,
+			     tusb_control_request_t const *req)
+{
+	if (g_usbMode != MODE_SW2_PRO)
+		return false;
+	if (req->wIndex == 0)
+		return hidd_control_xfer_cb(rhport, stage, req);
+	return stage != CONTROL_STAGE_SETUP;
+}
+
+static bool sw2DriverXfer(uint8_t rhport, uint8_t ep, xfer_result_t result,
+			  uint32_t transferred)
+{
+	if (ep == g_sw2VendorEpOut) {
+		if (result == XFER_RESULT_SUCCESS) {
+			g_sw2VendorCommandLen =
+				(uint8_t)(transferred > 64 ? 64 : transferred);
+			g_sw2VendorCommandPending = true;
+		}
+		// Intentionally do not re-arm OUT here. The shared response buffer
+		// belongs to the matching IN transfer until that transfer completes.
+		return true;
+	}
+	if (ep == g_sw2VendorEpIn) {
+		if (result == XFER_RESULT_SUCCESS && g_sw2VendorReplyLen > 64) {
+			uint8_t tail = g_sw2VendorReplyLen - 64;
+			g_sw2VendorReplyLen = tail;
+			return usbd_edpt_xfer(rhport, g_sw2VendorEpIn,
+					      g_sw2VendorOut, tail);
+		}
+		g_sw2VendorReplyLen = 0;
+		g_sw2VendorInFlight = false;
+		return usbd_edpt_xfer(rhport, g_sw2VendorEpOut, g_sw2VendorOut,
+				      sizeof g_sw2VendorOut);
+	}
+	return hidd_xfer_cb(rhport, ep, result, transferred);
+}
+
+static const usbd_class_driver_t g_sw2Driver = {
+#if CFG_TUSB_DEBUG >= 2
+	.name = "SW2-PRO",
+#endif
+	.init = sw2DriverInit,
+	.deinit = sw2DriverDeinit,
+	.reset = sw2DriverReset,
+	.open = sw2DriverOpen,
+	.control_xfer_cb = sw2DriverControl,
+	.xfer_cb = sw2DriverXfer,
+	.sof = nullptr,
+};
+
+const usbd_class_driver_t *switch2ProClassDriver(void)
+{
+	return &g_sw2Driver;
+}
+
+extern "C" uint8_t const *__real_tud_descriptor_device_cb(void);
+extern "C" uint8_t const *__wrap_tud_descriptor_device_cb(void)
+{
+	uint8_t const *real = __real_tud_descriptor_device_cb();
+	if (g_usbMode != MODE_SW2_PRO)
+		return real;
+	static tusb_desc_device_t d;
+	memcpy(&d, real, sizeof d);
+	d.bDeviceClass = 0xef;
+	d.bDeviceSubClass = 0x02;
+	d.bDeviceProtocol = 0x01;
+	return (uint8_t const *)&d;
+}
+
+extern "C" uint8_t const *__real_tud_hid_descriptor_report_cb(uint8_t itf);
+extern "C" uint8_t const *__wrap_tud_hid_descriptor_report_cb(uint8_t itf)
+{
+	if (g_usbMode == MODE_SW2_PRO && itf == 0)
+		return SWITCH2_PRO_HID_DESC;
+	return __real_tud_hid_descriptor_report_cb(itf);
+}
+
+extern "C" uint16_t __real_tud_hid_get_report_cb(uint8_t itf, uint8_t reportId,
+						 hid_report_type_t reportType,
+						 uint8_t *buffer,
+						 uint16_t reqLen);
+extern "C" uint16_t __wrap_tud_hid_get_report_cb(uint8_t itf, uint8_t reportId,
+						 hid_report_type_t reportType,
+						 uint8_t *buffer,
+						 uint16_t reqLen)
+{
+	if (g_usbMode != MODE_SW2_PRO || itf != 0)
+		return __real_tud_hid_get_report_cb(itf, reportId, reportType,
+						    buffer, reqLen);
+	(void)reportType;
+	if (!buffer || !reqLen)
+		return 0;
+	uint8_t p[63];
+	int bond = g_usbMountCount ? g_usbToBond[0] : -1;
+	if (bond < 0)
+		memset(p, 0, sizeof p);
+	else if (reportId == 0x05)
+		sw2Build05((uint8_t)bond, p);
+	else if (reportId == 0x09)
+		sw2Build09((uint8_t)bond, p);
+	else
+		return 0;
+	uint16_t n = reqLen < sizeof p ? reqLen : sizeof p;
+	memcpy(buffer, p, n);
+	return n;
+}
+
+// Switch 2 Pro output report 0x02 carries two 16-byte LRA packets after the
+// report ID: left then right. Each packet starts with a 0x50|sequence byte and
+// contains three absolute 5-byte HD-rumble frames. Steam Controller report
+// 0x80 cannot reproduce Nintendo's frequency-domain waveform, so preserve the
+// spatial left/right intent, reduce each motor to its peak encoded
+// amplitude, and route it to the corresponding Steam haptic channel.
+static uint16_t sw2RumbleFramePeak(const uint8_t frame[5])
+{
+	uint16_t highAmp = (uint16_t)(((uint16_t)(frame[1] & 0xfc) << 4) |
+				      ((uint16_t)(frame[2] & 0x0f) << 12));
+	uint16_t lowAmp =
+		(uint16_t)((frame[3] & 0xc0) | ((uint16_t)frame[4] << 8));
+	return highAmp > lowAmp ? highAmp : lowAmp;
+}
+
+static uint16_t sw2RumbleMotorPeak(const uint8_t packet[16])
+{
+	uint16_t peak = 0;
+	for (uint8_t i = 0; i < 3; i++) {
+		uint16_t amp = sw2RumbleFramePeak(packet + 1 + 5 * i);
+		if (amp > peak)
+			peak = amp;
+	}
+	return peak;
+}
+
+static void sw2Rumble(const uint8_t *payload, uint16_t size)
+{
+	if (!payload || size < 32 || !g_usbMountCount)
+		return;
+	int bond = g_usbToBond[0];
+	if (bond < 0 || bond >= NSLOT)
+		return;
+
+	uint16_t left = sw2RumbleMotorPeak(payload);
+	uint16_t right = sw2RumbleMotorPeak(payload + 16);
+	if (bond == g_sw2LastRumbleBond && left == g_sw2LastRumbleLeft &&
+	    right == g_sw2LastRumbleRight)
+		return;
+
+	if (hapticSteamRumble(left, right, (uint8_t)bond)) {
+		g_sw2LastRumbleBond = (int8_t)bond;
+		g_sw2LastRumbleLeft = left;
+		g_sw2LastRumbleRight = right;
+	}
+}
+
+extern "C" void __real_tud_hid_set_report_cb(uint8_t itf, uint8_t reportId,
+					     hid_report_type_t reportType,
+					     uint8_t const *buffer,
+					     uint16_t size);
+extern "C" void __wrap_tud_hid_set_report_cb(uint8_t itf, uint8_t reportId,
+					     hid_report_type_t reportType,
+					     uint8_t const *buffer,
+					     uint16_t size)
+{
+	if (g_usbMode == MODE_SW2_PRO && itf == 0) {
+		if (reportType == HID_REPORT_TYPE_OUTPUT) {
+			if (reportId == 0x02) {
+				sw2Rumble(buffer, size);
+			} else if (reportId == 0 && size > 0 &&
+				   buffer[0] == 0x02) {
+				sw2Rumble(buffer + 1, (uint16_t)(size - 1));
+			}
+		}
+		return;
+	}
+	__real_tud_hid_set_report_cb(itf, reportId, reportType, buffer, size);
+}
+
+void Switch2ProController::begin()
+{
+}
+uint8_t Switch2ProController::maxSlots() const
+{
+	return 1;
+}
+void Switch2ProController::usbIdentity()
+{
+	USBDevice.setID(0x057e, 0x2069);
+	USBDevice.setVersion(0x0200);
+	USBDevice.setDeviceVersion(0x0201);
+	USBDevice.setManufacturerDescriptor("Nintendo");
+	USBDevice.setProductDescriptor("Switch 2 Pro Controller");
+	USBDevice.setSerialDescriptor("00");
+}
+void Switch2ProController::beginPool()
+{
+	if (!g_sw2DrainRegistered) {
+		usbTxRegisterDrain(sw2Drain);
+		g_sw2DrainRegistered = true;
+	}
+}
+void Switch2ProController::mountSlots(uint8_t k)
+{
+	if (k)
+		USBDevice.addInterface(g_sw2Usb);
+}
+void Switch2ProController::task()
+{
+	// Report and vendor transfers are drained by usbTxPump() so endpoint
+	// submission shares the same priority-inversion protection as other modes.
+}

+ 22 - 0
OpenPuck/mode_switch2_pro.h

@@ -0,0 +1,22 @@
+// mode_switch2_pro.h -- Nintendo Switch 2 Pro USB personality.
+#pragma once
+#include "controllers.h"
+
+class Switch2ProController : 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 Switch2ProController g_switch2Pro;
+
+uint8_t switch2ProMapGet(uint8_t index);
+void switch2ProMapSet(uint8_t index, uint8_t value);

+ 16 - 15
OpenPuck/mode_switch_pro.cpp

@@ -945,6 +945,8 @@ void SwitchProController::mountSlots(uint8_t k)
 		// don't stream 0x30 before the (new) host has re-selected report mode.
 		g_swProReportMode[u] = 0;
 		g_jcQh[u] = g_jcQt[u] = 0;
+		// Discard stale queued state across re-enumeration.
+		usbTxFlushHid(&g_swPro[u]);
 		// reset the rumble decoder + relay dedup so a stale amplitude from a
 		// prior session can't carry across the reconnect
 		hdrReset(u);
@@ -955,32 +957,31 @@ void SwitchProController::mountSlots(uint8_t k)
 void SwitchProController::task()
 {
 	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
-		// is one save). Per-slot: each Pro Controller saves its own mirror.
+		uint32_t now = millis();
+		// Deferred per-slot user-cal flash write is queued by the USB ISR and
+		// debounced into one save. Persistence must not depend on endpoint readiness.
 		if (g_userCalDirty[s] &&
-		    (unsigned long)(millis() - g_userCalDirtyMs[s]) > 250u) {
+		    (unsigned long)(now - g_userCalDirtyMs[s]) > 250u) {
 			g_userCalDirty[s] = false;
 			saveUserCal((uint8_t)s);
 		}
-		// drain handshake/subcommand replies first (ordered) -- one report per slot per call so a
-		// bursty host-init doesn't starve the streamed 0x30
-		if (g_jcQh[s] != g_jcQt[s]) {
+		// Drain handshake/subcommand replies in order, one per slot per call,
+		// without letting bursty host init starve a due 0x30 state report.
+		if (g_swPro[s].ready() && g_jcQh[s] != g_jcQt[s]) {
 			JcRep *r = &g_jcQ[s][g_jcQh[s]];
 			usbTxHid(&g_swPro[s], r->rid, r->data, JC_REPLEN);
 			g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
-			// one report per slot per call; the rest next loop
-			continue;
+			// one queued reply per slot per call; the rest wait for the next loop
 		}
-		// not until the host has finished init + selected 0x30
-		if (g_swProReportMode[s] != 0x30)
-			continue;
-		if (millis() - g_swProLastMs[s] < USB_STREAM_MS)
+		// Do not stream until host init has selected report mode 0x30.
+		if (g_swProReportMode[s] != 0x30 ||
+		    now - g_swProLastMs[s] < USB_STREAM_MS)
 			continue;
-		g_swProLastMs[s] = millis();
+		g_swProLastMs[s] = now;
 		uint8_t p[63];
 		switchProBuild((uint8_t)s, p);
+		// Queue the newest state even while the endpoint is busy. usb_tx's bounded drop-oldest policy
+		// preserves current input rather than replaying stale held state after a transient stall.
 		usbTxHid(&g_swPro[s], 0x30, p, sizeof p);
 	}
 }

+ 1 - 1
OpenPuck/serial_console.cpp

@@ -201,7 +201,7 @@ void serialConsolePoll()
 					g_lizKeep ? "ON" : "off");
 			}
 
-			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean 9=ps3(dualshock3) 10=original xbox
+			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean 9=ps3(dualshock3) 10=original xbox 13=switch2-horipad-o/clean
 			else if (line[0] == 'x') {
 				uint8_t m = strtoul(line + 1, 0, 10);
 				if (modeValid(m)) {

+ 2 - 0
OpenPuck/steam_commands.h

@@ -80,6 +80,8 @@
 #define SETTING_LED_USER_BRIGHTNESS 45 // 0x2d
 #define SETTING_ENABLE_RAW_JOYSTICK 46 // 0x2e
 #define SETTING_IMU_MODE 48 // 0x30
+#define SETTING_GYRO_MODE_SEND_RAW_ACCEL 0x0008
+#define SETTING_GYRO_MODE_SEND_RAW_GYRO 0x0010
 #define SETTING_WIRELESS_PACKET_VERSION 49 // 0x31
 #define SETTING_LEFT_TRACKPAD_CLICK_PRESSURE 52 // 0x34
 #define SETTING_RIGHT_TRACKPAD_CLICK_PRESSURE 53 // 0x35

+ 3 - 0
OpenPuck/triton.h

@@ -92,6 +92,9 @@ struct PuckInput {
 	int16_t lpx, lpy, rpx, rpy; // left / right trackpad coords (int16)
 	int16_t ax, ay, az; // accelerometer
 	int16_t gx, gy, gz; // gyroscope
+
+	// Triton 0x42/0x45 IMU source clock, microseconds
+	uint32_t imuTimestampUs;
 };
 #include "bonds.h" // NSLOT
 extern PuckInput g_in[NSLOT];

+ 1 - 0
OpenPuck/usb_app_drivers.cpp

@@ -5,6 +5,7 @@ extern "C" const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
 	static const usbd_class_driver_t drivers[] = {
 		*xinputClassDriver(),
 		*xboxOgClassDriver(),
+		*switch2ProClassDriver(),
 	};
 
 	*count = sizeof(drivers) / sizeof(drivers[0]);

+ 1 - 0
OpenPuck/usb_app_drivers.h

@@ -7,3 +7,4 @@ extern "C" {
 
 const usbd_class_driver_t *xinputClassDriver(void);
 const usbd_class_driver_t *xboxOgClassDriver(void);
+const usbd_class_driver_t *switch2ProClassDriver(void);

+ 14 - 0
OpenPuck/usb_tx.cpp

@@ -73,6 +73,20 @@ void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
 	__set_PRIMASK(pm);
 }
 
+void usbTxFlushHid(Adafruit_USBD_HID *hid)
+{
+	if (!hid)
+		return;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	for (int i = 0; i < TX_CHAN_MAX; i++)
+		if (g_chan[i].hid == hid) {
+			g_chan[i].head = g_chan[i].tail = 0;
+			break;
+		}
+	__set_PRIMASK(pm);
+}
+
 // Send at most one ready report per destination per call. After a send the endpoint stays busy until the host
 // polls it, so the next report for that destination goes out from tud_hid_report_complete_cb (the instant the
 // host reads it) -- that chaining is what paces each stream to the host's poll rate. The usbtx task calls this

+ 3 - 0
OpenPuck/usb_tx.h

@@ -25,6 +25,9 @@ class Adafruit_USBD_HID;
 void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
 	      uint16_t len);
 
+// Drop all queued reports for one HID when its USB slot is remounted, preventing stale state replay.
+void usbTxFlushHid(Adafruit_USBD_HID *hid);
+
 // Kept for call-site stability; no longer does anything (the drain runs from loop() via usbTxPump). Call once.
 void usbTxBegin(void);
 

+ 25 - 2
OpenPuck/webusb_config.cpp

@@ -5,6 +5,7 @@
 #include "rf_link.h"
 #include "haptics.h"
 #include "puck_hid.h"
+#include "mode_switch2_pro.h"
 #include "triton.h" // g_in raw IMU (diagnostic readout)
 #include "lizard_map.h"
 #include "build_info.h"
@@ -22,6 +23,8 @@ Adafruit_USBD_WebUSB usb_web;
 // gives MODE_XBOX_OG first refusal on the XID requests without copying that router; whatever it declines, and
 // every other mode, must still reach __real_ -- that answers WebUSB's 0x22, whose connected flag gates
 // the panel sends.
+bool switch2ProVendorControlXfer(uint8_t rhport, uint8_t stage,
+				 const tusb_control_request_t *request);
 bool xboxOgVendorControlXfer(uint8_t rhport, uint8_t stage,
 			     const tusb_control_request_t *request);
 extern "C" bool
@@ -31,6 +34,9 @@ extern "C" bool
 __wrap_tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
 				  const tusb_control_request_t *request)
 {
+	if (g_usbMode == MODE_SW2_PRO &&
+	    switch2ProVendorControlXfer(rhport, stage, request))
+		return true;
 	if (g_usbMode == MODE_XBOX_OG &&
 	    xboxOgVendorControlXfer(rhport, stage, request))
 		return true;
@@ -100,7 +106,8 @@ static bool boardCommand(uint8_t op)
 //                [v20: p[187..194] per-type trackpad->stick map, 4x2B {left pad, right pad} (PS_OFF/LEFT/RIGHT)]
 //                [v21: p[53] rumble strength as PERCENT/2 (field 22, revived); p[195] rumble style
 //                 (field 39, RUMBLE_STYLE_* in haptics.h)]
-#define WB_PAYLEN 194
+//                p[196..202]: Switch 2 Pro L4/R4/L5/R5/View/Menu/Steam targets (fields 90..96)
+#define WB_PAYLEN 201
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -125,6 +132,7 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
+	// Switch 2 Pro mappings use fields 90..96 and blob bytes p[196..202].
 	// protocol version (21 = +rumble style (field 39, blob p[195]) and the REVIVED rumble-strength field 22
 	// at blob p[53], now carrying percent/2; 20 = +per-type trackpad->stick mapping (fields 80..87, blob
 	// p[187..194]); 19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the Switch report-rate and
@@ -135,7 +143,7 @@ static void webusbSendBlob()
 	// unknown op; 15 = +staged firmware-update ops 0x20..0x24; 14 = +landAll87 toggle; 13 = +per-slot link
 	// stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type
 	// cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 21;
+	p[2] = 22;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -317,6 +325,10 @@ static void webusbSendBlob()
 		p[187 + et * 2] = g_padStickCfg[et][0];
 		p[188 + et * 2] = g_padStickCfg[et][1];
 	}
+	// Switch 2 Pro keeps these seven source mappings separate from
+	// generic ET_SWITCH so GL/GR/C edits cannot alter Switch Pro/HORI.
+	for (int i = 0; i < 7; i++)
+		p[196 + i] = switch2ProMapGet((uint8_t)i);
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -1034,6 +1046,17 @@ void webusbPoll()
 				// (pad 0 = left, 1 = right; value PS_OFF/PS_LEFT/PS_RIGHT).
 				// Must stay clear of the per-type cfg range 40..40+ET_COUNT*9-1
 				// (40..75), which is claimed before this switch runs.
+				// 90..96: Switch 2 Pro L4/R4/L5/R5/View/Menu/Steam targets.
+				case 90:
+				case 91:
+				case 92:
+				case 93:
+				case 94:
+				case 95:
+				case 96:
+					switch2ProMapSet((uint8_t)(f - 90), v);
+					break;
+
 				case 80:
 				case 81:
 				case 82:

+ 40 - 6
docs/index.html

@@ -181,8 +181,9 @@
         <button data-mode="10" class="modebtn">Original Xbox</button>
         <button data-mode="11" class="modebtn">DirectInput (sims)</button>
         <button data-mode="12" class="modebtn">SInput (SDL native)</button>
+          <button data-mode="13" class="modebtn">Switch 2 Pro</button>
       </div>
-      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only. Original Xbox emulates one Microsoft Controller S and uses the Xbox mapping tab; LB/RB become White/Black. It's for a real Original Xbox console — a PC needs a driver for the original Xbox controller to see it as a gamepad.</p>
+      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode except Switch 2 Pro, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only. Switch 2 Pro intentionally stays clean-console and is configured from another mode. Original Xbox emulates one Microsoft Controller S and uses the Xbox mapping tab; LB/RB become White/Black. It's for a real Original Xbox console — a PC needs a driver for the original Xbox controller to see it as a gamepad.</p>
       <p class="note"><b>DirectInput</b> is for flight/space sims: every analog input is live at once, split over two joysticks (#1 sticks + triggers + 26 buttons, #2 trackpads + gyro). Trackpad axes hold their last position and re-centre on a pad click. <b>SInput</b> is the open SDL-native protocol — SDL3 / Steam Input read sticks, both analog triggers, gyro, both trackpads and battery from it natively, with rumble back from the host.</p>
       <div class="row"><label>Persist last mode</label>
         <button id="persistMode">off</button>
@@ -303,6 +304,15 @@
       <div id="typeCfgs"></div>
     </div>
 
+    <div class="card" id="sw2MapCard">
+      <h2>Switch 2 Pro special mapping</h2>
+      <div id="sw2MapBlock">
+        <p class="note" style="margin-top:0">Dedicated mappings for the Steam Controller controls that feed Switch 2 Pro's newer buttons. These settings are separate from the Switch 1 mapping above and persist across mode changes and backup/restore. Configure them here while the puck is in Steam or another WebUSB-capable mode; active Switch 2 Pro mode intentionally omits OpenPuck WebUSB.</p>
+        <div id="sw2MapList"></div>
+      </div>
+      <p id="sw2MapUnavailable" class="note hide">Configurable Switch 2 Pro mappings are unavailable on this firmware.</p>
+    </div>
+
     <div class="card" id="lizardCard" style="display:none">
       <h2>Lizard / desktop mapping</h2>
       <p class="note" style="margin-top:0">Keyboard, mouse &amp; media bindings for <strong>Lizard (always)</strong> mode — each row maps a controller input to a desktop action. (Steam-mode seamless lizard, when Steam is closed, keeps the built-in default behavior and is not affected by this map.) Edits apply after <strong>Save to device</strong>.</p>
@@ -419,7 +429,7 @@
 </div>
 
 <script>
-const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)","Original Xbox","DirectInput (sims)","SInput (SDL native)"];
+const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)","Original Xbox","DirectInput (sims)","SInput (SDL native)","Switch 2 Pro"];
 const CHORD_FIELD = [17, 18, 19];          // back4 + B/X/Y
 const CHORD_DPAD_FIELD = [34, 35, 36, 37]; // back4 + D-pad left/up/right/down (firmware protocol >= 18)
 const CHORD_DPAD_DEF = [9, 8, 7, 2];       // firmware defaults: PS3, DS4 game, PS5 game, Switch HORIPAD
@@ -574,12 +584,26 @@ renderTrail();
 const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
 const TYPE_DEFS = [
   {key:"XBOX", name:"Xbox", labels:{1:"A",2:"B",3:"X",4:"Y",5:"LB",6:"RB",7:"L3",8:"R3",9:"Back",10:"Start",11:"Guide",19:"LT",20:"RT",...DPAD}},
-  {key:"SWITCH", name:"Switch", labels:{1:"A",2:"B",3:"X",4:"Y",5:"L",6:"R",7:"L-Stick",8:"R-Stick",9:"Minus",10:"Plus",11:"Home",19:"ZL",20:"ZR",...DPAD,18:"Capture / Screenshot"}},
+  {key:"SWITCH", name:"Switch", labels:{1:"A",2:"B",3:"X",4:"Y",5:"L",6:"R",7:"L-Stick",8:"R-Stick",9:"Minus",10:"Plus",11:"Home",19:"ZL",20:"ZR",...DPAD,18:"Capture / Screenshot",21:"Switch 2 Chat"}},
   {key:"DS4", name:"DS4", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click"}},
   {key:"DS5", name:"DS5", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click",17:"Mute"}},
 ];
 // Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
-function etypeForMode(m){ switch(m){case 1:case 10:return 0; case 2:case 4:return 1; case 6:case 8:case 9:return 2; case 5:case 7:return 3; default:return -1;} }
+const SW2_SOURCE_NAMES = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)","View","Menu","Steam"];
+const SW2_TARGETS = [[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"L"],[6,"R"],[7,"L-Stick"],[8,"R-Stick"],[9,"Minus"],[10,"Plus"],[11,"Home"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"],[18,"Capture"],[19,"ZL"],[20,"ZR"],[21,"C"],[22,"GR"],[23,"GL"]];
+const sw2MapEls=[];
+(function buildSw2Map(){
+  const host=document.getElementById("sw2MapList");
+  SW2_SOURCE_NAMES.forEach((name,i)=>{
+    const row=document.createElement("div"); row.className="row";
+    const lab=document.createElement("label"); lab.textContent=name; row.appendChild(lab);
+    const sel=document.createElement("select"); sel.className="sw2Map"; sel.dataset.i=i;
+    for(const [v,label] of SW2_TARGETS){ const o=document.createElement("option"); o.value=v; o.textContent=label; sel.appendChild(o); }
+    row.appendChild(sel); host.appendChild(row); sw2MapEls.push(sel);
+  });
+})();
+
+function etypeForMode(m){ switch(m){case 1:case 10:return 0; case 2:case 4:case 13:return 1; case 6:case 8:case 9:return 2; case 5:case 7:return 3; default:return -1;} }
 
 // ===================== Lizard (desktop) binding map =====================
 // Mirrors firmware lizard_map.h. A binding is {outType, od:[7], trig, hold}. Output types and the
@@ -937,6 +961,9 @@ function applyBlob(p){
   const rumbleScale=rumbleCap?p[51]*2:200, rumbleStyle=rumbleCap?p[193]:0;
   const rssi=(p.length>37?p[37]:0);
 
+  // Seven Switch 2 Pro physical-source mappings occupy payload p[194..200].
+  const sw2MapCap=(p[0]>=22 && p.length>200);
+  const sw2Map=sw2MapCap?p.slice(194,201):[9,10,23,22,18,11,21];
   // per-slot link status (protocol v8, p.length >= 73)
   const hasSlotsData = p.length >= 73;
   if (hasSlotsData) {
@@ -1163,6 +1190,9 @@ function applyBlob(p){
   $("#dpadChords").classList.toggle("hide", !dpadCap);
   $("#dpadOld").classList.toggle("hide", dpadCap);
   if(dpadCap) for(const sel of document.querySelectorAll("select.chordD")){ if(document.activeElement!==sel) sel.value=chordD[+sel.dataset.i]; }
+  $("#sw2MapBlock").classList.toggle("hide", !sw2MapCap);
+  $("#sw2MapUnavailable").classList.toggle("hide", sw2MapCap);
+  if(sw2MapCap) sw2MapEls.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=sw2Map[i]; });
 }
 function fmtSlider(id,v){ return id==="hapBlockS" ? v+" s" : ""+v; }
 function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
@@ -1271,6 +1301,7 @@ function buildBackup(p, bp){
   // map snapshot would be empty, which would incorrectly clear bindings on restore.
   if(lizardLoaded && lizardCapable() && !lizardBusy)
     cfg.lizardMap=lizardBindings.map(b=>({outType:b.outType,od:b.od.slice(),trig:b.trig>>>0,hold:b.hold>>>0}));
+  if(p[0]>=22 && p.length>200) cfg.sw2Map=Array.from(p.slice(194,201));
   return { magic:"openpuck-backup", version:(cfg.lizardMap?2:1), bonds, config:cfg };
 }
 function downloadBackup(obj){
@@ -1316,7 +1347,7 @@ async function importBackup(file){
   // slots, and the final commit.
   const typeN=Array.isArray(c.types)?Math.min(c.types.length,4):0;
   const lizardN=(Array.isArray(c.lizardMap) && lizardCapable())?Math.min(c.lizardMap.length,LZ_MAX):0;
-  const baseN=6 + (Array.isArray(c.chordD)?4:0) + (c.swGyroLegacy!==undefined?1:0);
+  const baseN=6 + (Array.isArray(c.chordD)?4:0) + (c.swGyroLegacy!==undefined?1:0) + (Array.isArray(c.sw2Map)?7:0);
   const padStickN=Array.isArray(c.types)?c.types.slice(0,typeN).filter(t=>Array.isArray(t.padStick)).length*2:0;
   const total=baseN + typeN*9 + padStickN + lizardN + 4 + 1;
   let step=0, stage="";
@@ -1347,6 +1378,7 @@ async function importBackup(file){
     // Switch Pro gyro mapping (v19+ backups only). Backups also carry rumble/swProRate/swGyro10 from the
     // removed settings; those fields no longer exist on the puck, so they are not replayed.
     if(c.swGyroLegacy!==undefined) await sf(38, c.swGyroLegacy?1:0);
+    if(Array.isArray(c.sw2Map)) for(let i=0;i<7;i++) await sf(90+i, c.sw2Map[i]);
     // c.types is absent in some very old backups created before per-type paddle/haptic config
     // was added to the panel export; guard so a missing field never crashes the import mid-flight.
     if(typeN){
@@ -2311,13 +2343,15 @@ $("#swGyroMap").addEventListener("change", ()=>setField(38, +$("#swGyroMap").val
 for(const sel of document.querySelectorAll("select.chord")){
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
 }
+for(const sel of sw2MapEls) sel.addEventListener("change", ()=>setField(90+(+sel.dataset.i), +sel.value));
 for(const sel of document.querySelectorAll("select.chordD")){
   sel.addEventListener("change", ()=>setField(CHORD_DPAD_FIELD[+sel.dataset.i], +sel.value));
 }
 for(const b of document.querySelectorAll(".modebtn")){
   b.onclick=()=>{ const m=+b.dataset.mode;
     const clean=(m===7||m===8||m===9);
-    const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold all four back paddles (L4+R4+L5+R5) + A to return to Steam mode. The back4+D-pad chords are how you get back INTO these modes without the panel — check their assignments in the chords card first.":"");
+    const sw2=(m===13);
+    const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold all four back paddles (L4+R4+L5+R5) + A to return to Steam mode. The back4+D-pad chords are how you get back INTO these modes without the panel — check their assignments in the chords card first.":"")+(sw2?"\n\nNOTE: Switch 2 Pro drops OpenPuck WebUSB + host-wake, so THIS PANEL WILL DISCONNECT. Configure its mappings before entering mode 13; back4+A returns to Steam mode.":"");
     if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
 }
 updateUf2UI();