Explorar o código

reconnects work

Jari hai 3 meses
pai
achega
549ea139b7

+ 11 - 0
PicoPuck/CMakeLists.txt

@@ -46,6 +46,17 @@ target_sources(picopuck PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/personality.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/relay.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/triton.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/chord.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/gamepad_util.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/emu.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/emu_present.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/mode_switch_hori.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/mode_ds4.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/mode_ps5.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/mode_ps3.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/mode_switch_pro.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/xinput.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/sys/settings.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_control_stub.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_host.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_valve.c

+ 27 - 7
PicoPuck/README.md

@@ -59,13 +59,33 @@ been run on real hardware** yet. Everything below is correct-by-construction
 (ported from OpenPuck / joypad-os byte layouts and the reversed SC2 protocol);
 expect to iterate once flashed.
 
-### Follow-on drivers / features (same interfaces, deferred)
-
-- **DualSense (DS5)** — Classic, needs the CRC32-sealed output report and
-  adaptive-trigger/touchpad handling; add a `drv_ds5` implementing `input_driver_t`.
-- **Switch Pro** — Classic, needs the subcommand init handshake before it reports.
-- **Battery** — read the BLE Battery Service (and the SC2's battery channel) into
-  `g_battery[slot]` so Steam shows a level instead of the wired glyph.
+### Output modes (profile switching)
+
+PicoPuck presents whichever controller the active **mode** selects, fed by the
+connected Bluetooth controller's canonical input — the same `g_in` → report
+path as OpenPuck. Switch modes from the panel's *Output mode* selector or by a
+controller chord (hold **L2+R2+LB+RB** on a generic pad, or all four back
+paddles on an SC2, then A=Steam / B=PS5 / X=DS4 / Y=Switch); the device persists
+the mode and reboots into it.
+
+- [x] **Steam / Lizard** — Valve puck (transparent SC2 / synth for generic pads)
+- [x] **PS5 DualSense** — `054C:0CE6`, gyro + split trackpad + rumble
+- [x] **DS4 / HID gyro** — `054C:05C4`, gyro + trackpad + rumble
+- [x] **Switch HORIPAD** — `0F0D:0092`
+- [x] **Xbox 360 (XInput)** — `045E:028E`, custom vendor-class driver (`xinput.c`) alongside WebUSB + rumble
+- [x] **Switch Pro** — `057E:2009`, subcommand handshake + SPI cal + HD-rumble decoder
+- [x] **PS3 / Sixaxis** — `054C:0268`, feature-report console enable handshake + rumble
+
+### Follow-on Bluetooth *input-source* drivers (deferred)
+
+These are additional controllers PicoPuck could *read* over Bluetooth (each an
+`input_driver_t` decoding into `g_in`) — separate from the USB *output* modes
+above, which are all implemented.
+
+- **DualSense (DS5) as a source** — Classic, needs the CRC32-sealed output report
+  and adaptive-trigger/touchpad handling; add a `drv_ds5`.
+- **Switch Pro as a source** — Classic, needs the subcommand init handshake
+  before it reports.
 - Concurrency/bandwidth and soak testing (see risks).
 
 ## Known risks / open questions

+ 6 - 0
PicoPuck/src/bt/bt_control.h

@@ -39,6 +39,12 @@ void bt_scan_diag(uint16_t *adv_seen, uint8_t *flags, uint8_t *hci_state,
 // Copy up to `max` scan entries into `out`; returns the count written.
 uint8_t bt_scan_list(bt_scan_entry_t *out, uint8_t max);
 
+// Enumerate persisted LE bonds that are NOT currently connected, so the panel
+// can show paired controllers that are simply offline (e.g. right after a reboot
+// or mode switch, before they reconnect). Fills addr[i][0..5] + type[i]; returns
+// the count. Bonds are global — they are not tied to the active output mode.
+uint8_t bt_bond_offline_list(uint8_t addr[][6], uint8_t *type, uint8_t max);
+
 bool bt_pair(const uint8_t addr[6], uint8_t addr_type);
 void bt_forget(const uint8_t addr[6], uint8_t addr_type);
 void bt_forget_all(void);

+ 58 - 2
PicoPuck/src/bt/bt_host.c

@@ -15,6 +15,8 @@
 #include "bt/bt_hogp.h"
 #include "puck/personality.h"
 #include "puck/triton.h"
+#include "puck/slots.h"
+#include "sys/settings.h"
 #include "config/picopuck_config.h"
 
 #include <stdio.h>
@@ -66,6 +68,7 @@ static struct {
 } s_connecting;
 
 static uint16_t s_adv_seen;  // diagnostic: raw advertising reports since scan start
+static uint32_t s_last_rssi_ms;
 static uint8_t s_bt_rep[PP_NSLOT];  // diagnostic: input reports received from BT per slot
 uint8_t bt_report_count(int slot)
 {
@@ -384,6 +387,10 @@ static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		if (s_last_state == HCI_STATE_WORKING) {
 			s_ready = true;
 			printf("[bt] host up (dual-mode)\n");
+			// Load bonded devices into the controller resolving list so a
+			// returning RPA controller resolves to its bonded identity address
+			// (which addr_bonded_le then matches). Must run before scanning.
+			gap_load_resolving_list_from_le_device_db();
 			scan_ensure();  // begin reconnect scan if bonds exist
 		}
 		break;
@@ -402,6 +409,16 @@ static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		    HCI_SUBEVENT_LE_CONNECTION_COMPLETE)
 			handle_le_connection_complete(packet);
 		break;
+	case GAP_EVENT_RSSI_MEASUREMENT: {
+		hci_con_handle_t h = gap_event_rssi_measurement_get_con_handle(packet);
+		int slot = slot_by_handle(h);
+		if (slot >= 0) {
+			int8_t r = (int8_t)gap_event_rssi_measurement_get_rssi(packet);
+			g_conn[slot].rssi = r;
+			g_link_rssi[slot] = r;
+		}
+		break;
+	}
 	case HCI_EVENT_DISCONNECTION_COMPLETE: {
 		hci_con_handle_t h =
 			hci_event_disconnection_complete_get_connection_handle(packet);
@@ -445,6 +462,9 @@ static void sm_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 				sm_event_pairing_complete_get_handle(packet));
 			if (slot >= 0)
 				resolve_name(slot);
+			// Add the freshly-bonded device to the resolving list so it
+			// resolves on its next reconnect (RPA pads rotate their address).
+			gap_load_resolving_list_from_le_device_db();
 		}
 		break;
 	}
@@ -469,8 +489,12 @@ static void route_connection(int slot)
 	c->hid_started = true;
 	c->is_valve = (strstr(c->name, "Steam") != NULL);
 	c->drv = input_driver_match(0, 0, c->name, true);
-	puck_set_bond(slot, c->addr, c->drv ? c->drv->kind : 0);
+	uint8_t kind = c->drv ? c->drv->kind : 0;
+	puck_set_bond(slot, c->addr, kind);
 	puck_set_connected(slot, true);
+	// Persist the label so this pad still shows its real name/kind while offline
+	// (bonds outlive reboots; the BT stack doesn't keep the name).
+	settings_bond_remember(c->addr, (uint8_t)c->addr_type, kind, c->name);
 
 	if (c->is_valve)
 		valve_start(slot, c->handle);       // SC2 proprietary Valve GATT
@@ -561,6 +585,7 @@ static void classic_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		c->active = true;
 		c->is_classic = true;
 		c->hids_cid = cid;
+		c->handle = hid_subevent_connection_opened_get_con_handle(packet);
 		hid_subevent_connection_opened_get_bd_addr(packet, c->addr);
 		memcpy(c->name, s_connecting.name, sizeof(c->name));
 		s_connecting.busy = false;
@@ -574,6 +599,7 @@ static void classic_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		printf("[bt] Classic slot %d driver '%s'\n", slot, c->drv->name);
 		puck_set_bond(slot, c->addr, c->drv->kind);
 		puck_set_connected(slot, true);
+		settings_bond_remember(c->addr, 0 /*Classic*/, c->drv->kind, c->name);
 		break;
 	}
 	case HID_SUBEVENT_REPORT: {
@@ -666,6 +692,16 @@ void bt_host_task(void)
 
 	scan_ensure();     // keep reconnect scan matched to need
 	valve_periodic();  // SC2 lizard-off keepalive + IMU enable
+	hogp_periodic();   // HOGP battery poll
+
+	// Sample link RSSI ~1 Hz for the signal-strength report.
+	uint32_t nrssi = now_ms();
+	if (nrssi - s_last_rssi_ms >= 1000) {
+		s_last_rssi_ms = nrssi;
+		for (int i = 0; i < PP_NSLOT; i++)
+			if (g_conn[i].active && g_conn[i].handle)
+				gap_read_rssi(g_conn[i].handle);
+	}
 
 	// Flush latched rumble, rate-limited.
 	uint32_t t = now_ms();
@@ -727,6 +763,25 @@ bool bt_slot_info(int slot, uint8_t *kind, int8_t *rssi, uint8_t addr[6],
 	return true;
 }
 
+uint8_t bt_bond_offline_list(uint8_t addr[][6], uint8_t *type, uint8_t max)
+{
+	uint8_t n = 0;
+	int dbmax = le_device_db_max_count();
+	for (int i = 0; i < dbmax && n < max; i++) {
+		int t;
+		bd_addr_t a;
+		le_device_db_info(i, &t, a, NULL);
+		if (t == (int)BD_ADDR_TYPE_UNKNOWN)
+			continue;      // empty DB entry
+		if (addr_connected(a))
+			continue;      // already shown as a live slot
+		memcpy(addr[n], a, 6);
+		type[n] = (uint8_t)t;
+		n++;
+	}
+	return n;
+}
+
 void bt_scan_start(uint16_t seconds)
 {
 	s_scan_n = 0;
@@ -832,9 +887,10 @@ void bt_forget(const uint8_t addr[6], uint8_t addr_type)
 	for (int i = 0; i < PP_NSLOT; i++)
 		if (g_conn[i].active && memcmp(g_conn[i].addr, addr, 6) == 0)
 			conn_disconnect(&g_conn[i]);
-	// Drop both bond stores (whichever holds it).
+	// Drop both bond stores (whichever holds it) and our persisted label.
 	gap_delete_bonding((bd_addr_type_t)addr_type, (uint8_t *)addr);
 	gap_drop_link_key_for_bd_addr((uint8_t *)addr);
+	settings_bond_forget(addr);
 }
 
 void bt_forget_all(void)

+ 4 - 1
PicoPuck/src/bt/bt_valve.c

@@ -124,7 +124,10 @@ static void notify_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		len = sizeof(rep) - 1;
 	rep[0] = v->report_id;
 	memcpy(rep + 1, val, len);
-	puck_present_raw(v->slot, rep, (uint8_t)(len + 1));  // transparent forward
+	// Decode into g_in so emulated modes work with an SC2 too; puck mode also
+	// forwards the raw report verbatim (transparent). rep[1] is seq.
+	triton_decode45(rep, (uint8_t)(len + 1), &g_in[v->slot]);
+	puck_present_raw(v->slot, rep, (uint8_t)(len + 1));
 }
 
 static void write_cb(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size)

+ 2 - 2
PicoPuck/src/bt/drv_xinput.c

@@ -73,8 +73,8 @@ static bool xi_decode(uint8_t rid, const uint8_t *d, uint16_t len,
 	if (b1 & 0x40) b |= TB_LB;
 	if (b1 & 0x80) b |= TB_RB;
 	if (len > 14) {
-		if (b2 & 0x04) b |= TB_VIEW;   // View / Select (⧉)
-		if (b2 & 0x08) b |= TB_MENU;   // Menu / Start (☰)
+		if (b2 & 0x04) b |= TB_MENU;   // Xbox "View" (⧉) → SC2 Menu (Back/Select)
+		if (b2 & 0x08) b |= TB_VIEW;   // Xbox "Menu" (☰) → SC2 View (Start)
 		if (b2 & 0x10) b |= TB_STEAM;  // Xbox / Guide
 		if (b2 & 0x20) b |= TB_L3;     // Left stick click
 		if (b2 & 0x40) b |= TB_R3;     // Right stick click

+ 6 - 0
PicoPuck/src/config/btstack_config.h

@@ -26,6 +26,12 @@
 // central-only build fails to compile without it. Matches joypad-os's config.
 #define ENABLE_LE_PERIPHERAL
 #define ENABLE_LE_SECURE_CONNECTIONS
+// Resolve bonded controllers' resolvable private addresses (SC2, DualSense, most
+// modern BLE pads rotate their RPA), so auto-reconnect can match a returning pad
+// against its bond after a reboot. Without this the advert address never equals
+// the stored identity address and the reconnect scan never fires. The controller
+// resolving list is loaded from the LE device DB at HCI-working + after pairing.
+#define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION
 
 #ifndef ENABLE_CLASSIC
 #define ENABLE_CLASSIC

+ 29 - 14
PicoPuck/src/main.c

@@ -1,18 +1,23 @@
 // PicoPuck main — core0 cooperative loop.
 //
-// Phase 1: bring up the puck USB identity (four HID slots + WebUSB) and answer
-// Steam's command channel with no controllers connected. Bluetooth (BTstack
-// host) is layered on in later phases; the loop already reserves its slot.
+// Presents either the Valve puck (Steam/Lizard) or an emulated controller
+// (Xbox/Switch/PS5/PS3/…) over USB, fed by controllers connected over Bluetooth.
+// The active mode is loaded from flash at boot; switching it persists + reboots.
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
 #include "config/picopuck_config.h"
+#include "config/modes.h"
 #include "puck/identity.h"
 #include "puck/slots.h"
 #include "puck/personality.h"
+#include "puck/emu_present.h"
+#include "puck/xinput.h"
 #include "usb/usb_tx.h"
+#include "usb/usb_descriptors.h"
 #include "usb/webusb.h"
 #include "bt/bt_host.h"
+#include "sys/settings.h"
 
 #include "pico/stdlib.h"
 #include "pico/cyw43_arch.h"
@@ -23,23 +28,26 @@ int main(void)
 {
 	stdio_init_all();
 
+	settings_load();  // active USB mode (before descriptors)
 	identity_init();
 	slots_init();
 	puck_personality_init();
 	webusb_init();
 
-	// USB device stack (puck HID slots + WebUSB vendor interface).
+	// Build the descriptor set for the active mode, then start USB.
+	usb_descriptors_init();
 	tud_init(0);
 
+	bool puck = mode_is_puck(settings_mode());
+	bool xinput = mode_is_xinput(settings_mode());
+
 	// The user LED hangs off the CYW43 chip; this init also hands the radio to
-	// BTstack in a later phase (btstack_cyw43_init on the async_context).
+	// BTstack (btstack_cyw43_init on the async_context).
 	bool have_radio = (cyw43_arch_init() == 0);
-
-	// Bring up the Bluetooth host on the radio (dual-mode Classic + BLE).
 	bool have_bt = have_radio && bt_host_init();
 
-	printf("\n[picopuck] boot commit=%s board=%d radio=%d bt=%d\n",
-	       PICOPUCK_GIT_COMMIT, PP_BOARD, have_radio, have_bt);
+	printf("\n[picopuck] boot commit=%s board=%d mode=%d radio=%d bt=%d\n",
+	       PICOPUCK_GIT_COMMIT, PP_BOARD, settings_mode(), have_radio, have_bt);
 
 	watchdog_enable(PP_WATCHDOG_MS, /*pause_on_debug=*/true);
 
@@ -47,14 +55,21 @@ int main(void)
 	bool led_on = false;
 
 	while (1) {
-		tud_task();               // USB device events
-		usb_tx_pump();            // drain queued HID reports
-		puck_personality_task();  // 0x79 / 0x7B / 0x43 status
-		webusb_task();            // panel commands
+		tud_task();       // USB device events
+		usb_tx_pump();    // drain queued HID reports
+
+		if (puck)
+			puck_personality_task();  // puck 0x79 / 0x7B / 0x43 + synth stream
+		else if (xinput)
+			xinput_task();            // Xbox 360 XInput report stream
+		else
+			emu_present_task();       // emulated controller report stream
+
+		webusb_task();    // panel commands (works in every mode)
 
 		if (have_bt) {
 			cyw43_arch_poll();  // services BTstack on its async_context
-			bt_host_task();     // scan timeout + rumble flush
+			bt_host_task();     // scan / battery / rssi / rumble
 		} else if (have_radio) {
 			cyw43_arch_poll();
 		}

+ 23 - 3
PicoPuck/src/puck/personality.c

@@ -11,8 +11,12 @@
 #include "puck/identity.h"
 #include "puck/relay.h"
 #include "puck/triton.h"
+#include "puck/emu_present.h"
+#include "puck/chord.h"
 #include "usb/usb_tx.h"
 #include "config/picopuck_config.h"
+#include "config/modes.h"
+#include "sys/settings.h"
 
 #include <string.h>
 #include <stdio.h>
@@ -102,17 +106,24 @@ void puck_present_synth(int slot)
 {
 	if (slot < 0 || slot >= PP_NSLOT)
 		return;
+	chord_check(slot, false);  // generic pad: L2+R2+LB+RB guard
 	s_src[slot] = 1;
-	emit_synth(slot);  // immediate on change; the streamer fills idle gaps
+	slot_note_input(slot, now_ms());
+	// Only the puck personality emits the SC2 report; in an emulated mode the
+	// input just updates g_in (already done by the caller) and emu_present emits.
+	if (mode_is_puck(settings_mode()))
+		emit_synth(slot);
 }
 
 void puck_present_raw(int slot, const uint8_t *rep, uint8_t len)
 {
 	if (slot < 0 || slot >= PP_NSLOT || len < 1)
 		return;
+	chord_check(slot, true);  // SC2: back-paddle guard
 	s_src[slot] = 2;
-	usb_tx_hid((uint8_t)slot, rep[0], rep + 1, (uint16_t)(len - 1));
 	slot_note_input(slot, now_ms());
+	if (mode_is_puck(settings_mode()))
+		usb_tx_hid((uint8_t)slot, rep[0], rep + 1, (uint16_t)(len - 1));
 }
 
 void puck_set_connected(int slot, bool connected)
@@ -324,6 +335,8 @@ uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
 			       hid_report_type_t report_type, uint8_t *buffer,
 			       uint16_t reqlen)
 {
+	if (!mode_is_puck(settings_mode()))
+		return emu_get_report(report_id, report_type, buffer, reqlen);
 	return handle_get(instance, report_id, report_type, buffer, reqlen);
 }
 
@@ -331,6 +344,10 @@ void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
 			   hid_report_type_t report_type, uint8_t const *buffer,
 			   uint16_t bufsize)
 {
+	if (!mode_is_puck(settings_mode())) {
+		emu_set_report(report_id, report_type, buffer, bufsize);
+		return;
+	}
 	handle_set(instance, report_id, report_type, buffer, bufsize);
 }
 
@@ -362,10 +379,13 @@ void puck_personality_task(void)
 		if (!live)
 			continue;
 
-		// 0x7B status (RSSI template) every 750 ms.
+		// 0x7B status (RSSI) every 750 ms. Byte 8 is the controller signal
+		// strength as signed dBm; patch in the live sample (0 = keep template).
 		if (t - s_last_7b_ms[s] >= 750) {
 			uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
 					    0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
+			if (g_link_rssi[s])
+				s7b[8] = (uint8_t)g_link_rssi[s];
 			usb_tx_hid((uint8_t)s, 0x7B, s7b, sizeof(s7b));
 			s_last_7b_ms[s] = t;
 		}

+ 16 - 0
PicoPuck/src/puck/relay.c

@@ -27,3 +27,19 @@ void relay_enqueue(int slot, uint8_t report_id, const uint8_t *body, uint16_t le
 	g_relay_last_id = report_id;
 	bt_relay(slot, report_id, body, len);
 }
+
+void puck_rumble(int slot, uint16_t lo, uint16_t hi)
+{
+	uint16_t inten = (lo > hi) ? lo : hi;
+	uint8_t p[9];
+	p[0] = inten ? 0x04 : 0x00;  // HAPTIC_TYPE_RUMBLE / off
+	p[1] = (uint8_t)(inten & 0xFF);
+	p[2] = (uint8_t)(inten >> 8);
+	p[3] = (uint8_t)(lo & 0xFF);
+	p[4] = (uint8_t)(lo >> 8);
+	p[5] = 0;
+	p[6] = (uint8_t)(hi & 0xFF);
+	p[7] = (uint8_t)(hi >> 8);
+	p[8] = 0;
+	relay_enqueue(slot, 0x80, p, 9);
+}

+ 5 - 0
PicoPuck/src/puck/relay.h

@@ -20,6 +20,11 @@
 extern volatile uint16_t g_relay_count;
 extern volatile uint8_t g_relay_last_id;
 
+// Build the SC2 0x80 rumble report from 16-bit motor magnitudes and relay it to
+// the controller bound to `slot` (OpenPuck's hapticSteamRumble). Emulated modes
+// call this from their host output-report handlers.
+void puck_rumble(int slot, uint16_t lo, uint16_t hi);
+
 // Forward the HID report [report_id][body...] to the controller bound to `slot`
 // (SC2: written verbatim to the Valve report characteristic; generic pad: 0x80
 // rumble mapped to the pad's output). No length byte is re-inserted — pass the

+ 2 - 0
PicoPuck/src/puck/slots.c

@@ -7,6 +7,7 @@
 puck_slot_t g_slot[PP_NSLOT];
 uint8_t g_battery[PP_NSLOT];
 uint8_t g_battery_state[PP_NSLOT];
+int8_t g_link_rssi[PP_NSLOT];
 
 // A slot counts as "live" for a short window after its last input report, so a
 // brief BT hiccup doesn't bounce the connection state Steam sees. Mirrors the
@@ -18,6 +19,7 @@ void slots_init(void)
 	memset(g_slot, 0, sizeof(g_slot));
 	memset(g_battery, 0, sizeof(g_battery));
 	memset(g_battery_state, 0, sizeof(g_battery_state));
+	memset(g_link_rssi, 0, sizeof(g_link_rssi));
 }
 
 bool slot_is_live(int slot, uint32_t now_ms)

+ 3 - 0
PicoPuck/src/puck/slots.h

@@ -36,6 +36,9 @@ extern puck_slot_t g_slot[PP_NSLOT];
 extern uint8_t g_battery[PP_NSLOT];
 extern uint8_t g_battery_state[PP_NSLOT];
 
+// Per-slot link RSSI in dBm (negative; 0 = no sample yet), from the BT stack.
+extern int8_t g_link_rssi[PP_NSLOT];
+
 // True while a controller is currently presented on this slot (connected and
 // producing input recently). Drives 0xB4 / 0x79.
 bool slot_is_live(int slot, uint32_t now_ms);

+ 41 - 0
PicoPuck/src/puck/triton.c

@@ -57,6 +57,47 @@ uint8_t puck_synth45(const puck_input_t *in, uint8_t seq, uint32_t usec,
 	return PUCK45_LEN;
 }
 
+// Field decoders: offset is relative to rep[2] (the buttons low byte), matching
+// OpenPuck's s16off/u16off/trigU8.
+static inline int rd_s16(const uint8_t *r, int off)
+{
+	int v = r[2 + off] | (r[2 + off + 1] << 8);
+	return (v & 0x8000) ? v - 0x10000 : v;
+}
+static inline int rd_u16(const uint8_t *r, int off)
+{
+	return r[2 + off] | (r[2 + off + 1] << 8);
+}
+static inline uint8_t trig_u8(int u16v)
+{
+	int v = u16v >> 7;
+	return (uint8_t)(v > 255 ? 255 : v);
+}
+
+void triton_decode45(const uint8_t *rep, uint8_t tlen, puck_input_t *io)
+{
+	io->buttons = (uint32_t)rep[2] | ((uint32_t)rep[3] << 8) |
+		      ((uint32_t)rep[4] << 16) | ((uint32_t)rep[5] << 24);
+	io->lt = trig_u8(rd_u16(rep, 4));
+	io->rt = trig_u8(rd_u16(rep, 6));
+	io->lx = (int16_t)rd_s16(rep, 8);
+	io->ly = (int16_t)rd_s16(rep, 10);
+	io->rx = (int16_t)rd_s16(rep, 12);
+	io->ry = (int16_t)rd_s16(rep, 14);
+	io->lpx = (int16_t)rd_s16(rep, 16);
+	io->lpy = (int16_t)rd_s16(rep, 18);
+	io->rpx = (int16_t)rd_s16(rep, 22);
+	io->rpy = (int16_t)rd_s16(rep, 24);
+	if (tlen >= 46) {
+		io->ax = (int16_t)rd_s16(rep, 32);
+		io->ay = (int16_t)rd_s16(rep, 34);
+		io->az = (int16_t)rd_s16(rep, 36);
+		io->gx = (int16_t)rd_s16(rep, 38);
+		io->gy = (int16_t)rd_s16(rep, 40);
+		io->gz = (int16_t)rd_s16(rep, 42);
+	}
+}
+
 uint32_t triton_hat_bits(uint8_t hat)
 {
 	static const uint32_t T[9] = {

+ 7 - 0
PicoPuck/src/puck/triton.h

@@ -39,6 +39,9 @@
 #define TB_LPADC 0x4000000u
 #define TB_R2 0x800000u
 #define TB_L2 0x8000000u
+// Virtual PS targets (remap-only; bits the SC2 0x45 report never uses).
+#define TB_TOUCH 0x40000000u  // PS touchpad click
+#define TB_MUTE 0x80000000u   // PS5 mute
 
 // Decoded controller input, one per slot.
 typedef struct {
@@ -64,4 +67,8 @@ uint8_t puck_synth45(const puck_input_t *in, uint8_t seq, uint32_t usec,
 // 8-way HID hat (0 idle, 1..8 clockwise from N) → TB_ dpad bits.
 uint32_t triton_hat_bits(uint8_t hat);
 
+// Decode an SC2 report 0x45 / 0x42 (rep[0]=id, rep[1]=seq) into `io`. `tlen` is
+// the full report length incl id; IMU is decoded only when tlen >= 46.
+void triton_decode45(const uint8_t *rep, uint8_t tlen, puck_input_t *io);
+
 #endif // PICOPUCK_TRITON_H

+ 191 - 112
PicoPuck/src/usb/usb_descriptors.c

@@ -1,10 +1,10 @@
-// usb_descriptors.c — TinyUSB descriptor callbacks for the puck identity.
+// usb_descriptors.c — mode-aware TinyUSB descriptors.
 //
-// Presents VID/PID 28DE:1304 with four HID slot interfaces (each the cloned
-// puck report descriptor) plus a WebUSB vendor interface (WinUSB-bound via an
-// MS OS 2.0 descriptor so Chrome can open it without a manual driver). bcdDevice
-// is 0x03xx to distinguish PicoPuck from the nRF puck in the panel and in
-// Windows' per-(VID,PID,bcdDevice) descriptor cache.
+// Puck modes (Steam/Lizard) present VID/PID 28DE:1304 with four HID slot
+// interfaces (the cloned puck descriptor) + a WebUSB vendor interface. Emulated
+// modes (Xbox/Switch/PS5/PS3/…) present that mode's controller as ONE HID
+// gamepad + the same WebUSB vendor interface (so the panel works in every mode).
+// The active mode is read from flash at boot; a mode switch persists + reboots.
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include <string.h>
@@ -12,61 +12,190 @@
 #include "usb/usb_descriptors.h"
 #include "usb/puck_desc.h"
 #include "puck/identity.h"
+#include "puck/emu.h"
+#include "config/modes.h"
+#include "sys/settings.h"
 
-// Set by the panel's class request (0x22) on the vendor interface.
 void webusb_set_connected(bool connected);
 
-// ---- device descriptor -----------------------------------------------------
 #if CFG_TUD_CDC
 #define PP_BCD PP_BCD_DEVICE_CDC
 #else
 #define PP_BCD PP_BCD_DEVICE
 #endif
 
-static const tusb_desc_device_t desc_device = {
-	.bLength = sizeof(tusb_desc_device_t),
-	.bDescriptorType = TUSB_DESC_DEVICE,
-	.bcdUSB = 0x0210,  // 2.1 → device advertises a BOS descriptor
-#if CFG_TUD_CDC
-	.bDeviceClass = TUSB_CLASS_MISC,
-	.bDeviceSubClass = MISC_SUBCLASS_COMMON,
-	.bDeviceProtocol = MISC_PROTOCOL_IAD,
-#else
-	.bDeviceClass = 0x00,
-	.bDeviceSubClass = 0x00,
-	.bDeviceProtocol = 0x00,
-#endif
-	.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
-	.idVendor = PP_USB_VID,
-	.idProduct = PP_USB_PID,
-	.bcdDevice = PP_BCD,
-	.iManufacturer = 0x01,
-	.iProduct = 0x02,
-	.iSerialNumber = 0x03,
-	.bNumConfigurations = 0x01,
+// ---- cached per-boot presentation ------------------------------------------
+static uint8_t s_mode;
+static const emu_mode_t *s_emu;  // NULL for puck/xinput modes
+static bool s_xinput;            // MODE_XBOX: custom vendor class, not HID
+static tusb_desc_device_t s_dev;
+static uint8_t s_emu_cfg[80];
+static uint8_t s_vendor_itf;
+
+#define MS_OS_20_DESC_LEN 0xB2
+static uint8_t s_msos[MS_OS_20_DESC_LEN];
+
+// Build the emulated-mode config descriptor: HID gamepad (itf 0, IN-only) plus
+// the WebUSB vendor interface (itf 1, bulk IN/OUT). Returns total length.
+static uint16_t build_emu_config(uint8_t *b, const emu_mode_t *emu)
+{
+	uint16_t rlen = emu->report_desc_len;
+	uint8_t poll = emu->poll_ms ? emu->poll_ms : 1;
+	// itf0 HID (2 endpoints: IN + OUT) + itf1 vendor (2 endpoints).
+	const uint16_t total = 9 + 9 + 9 + 7 + 7 + 9 + 7 + 7;  // 64
+	uint8_t *p = b;
+	// configuration
+	*p++ = 9; *p++ = TUSB_DESC_CONFIGURATION;
+	*p++ = (uint8_t)(total & 0xFF); *p++ = (uint8_t)(total >> 8);
+	*p++ = 2; *p++ = 1; *p++ = 0; *p++ = 0x80; *p++ = 250;
+	// HID interface (itf 0) — IN + OUT so rumble / Switch-Pro subcommands ride
+	// the interrupt OUT pipe (hosts like hid-nintendo require the OUT endpoint;
+	// output reports still also work via EP0 SET_REPORT).
+	*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = 0; *p++ = 0; *p++ = 2;
+	*p++ = TUSB_CLASS_HID; *p++ = 0; *p++ = 0; *p++ = 0;
+	// HID descriptor
+	*p++ = 9; *p++ = HID_DESC_TYPE_HID; *p++ = 0x11; *p++ = 0x01; *p++ = 0;
+	*p++ = 1; *p++ = HID_DESC_TYPE_REPORT;
+	*p++ = (uint8_t)(rlen & 0xFF); *p++ = (uint8_t)(rlen >> 8);
+	// HID IN endpoint
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x81; *p++ = 0x03;
+	*p++ = 64; *p++ = 0; *p++ = poll;
+	// HID OUT endpoint
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x01; *p++ = 0x03;
+	*p++ = 64; *p++ = 0; *p++ = poll;
+	// vendor interface (itf 1) — WebUSB
+	*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = 1; *p++ = 0; *p++ = 2;
+	*p++ = TUSB_CLASS_VENDOR_SPECIFIC; *p++ = 0; *p++ = 0; *p++ = 0;
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x02; *p++ = 0x02;
+	*p++ = 64; *p++ = 0; *p++ = 0;
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x82; *p++ = 0x02;
+	*p++ = 64; *p++ = 0; *p++ = 0;
+	return total;
+}
+
+// Build the XInput config: the WebUSB vendor interface FIRST (itf 0), so the
+// built-in vendor class driver claims it and — with only one vendor instance —
+// leaves the XInput interface (itf 1, class 0xFF/0x5D/0x01) for the custom
+// XInput app driver. Endpoints: vendor bulk 0x02/0x82; XInput interrupt IN
+// 0x83 / OUT 0x03. Returns total length.
+static uint16_t build_xinput_config(uint8_t *b)
+{
+	const uint16_t total = 9 + (9 + 7 + 7) + (9 + 17 + 7 + 7);  // 72
+	uint8_t *p = b;
+	// configuration (2 interfaces)
+	*p++ = 9; *p++ = TUSB_DESC_CONFIGURATION;
+	*p++ = (uint8_t)(total & 0xFF); *p++ = (uint8_t)(total >> 8);
+	*p++ = 2; *p++ = 1; *p++ = 0; *p++ = 0x80; *p++ = 250;
+	// vendor interface (itf 0) — WebUSB
+	*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = 0; *p++ = 0; *p++ = 2;
+	*p++ = TUSB_CLASS_VENDOR_SPECIFIC; *p++ = 0; *p++ = 0; *p++ = 4;
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x02; *p++ = 0x02;
+	*p++ = 64; *p++ = 0; *p++ = 0;
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x82; *p++ = 0x02;
+	*p++ = 64; *p++ = 0; *p++ = 0;
+	// XInput interface (itf 1) — vendor class 0xFF/0x5D/0x01, 2 interrupt eps
+	*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = 1; *p++ = 0; *p++ = 2;
+	*p++ = 0xFF; *p++ = 0x5D; *p++ = 0x01; *p++ = 0;
+	// XInput "unknown" 0x21 descriptor (verbatim; bytes 6/13 = IN/OUT ep addr)
+	*p++ = 0x11; *p++ = 0x21; *p++ = 0x00; *p++ = 0x01; *p++ = 0x01; *p++ = 0x25;
+	*p++ = 0x83; *p++ = 0x14; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00;
+	*p++ = 0x13; *p++ = 0x03; *p++ = 0x08; *p++ = 0x00; *p++ = 0x00;
+	// XInput IN endpoint (interrupt, 0x83, 1 ms)
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x83; *p++ = 0x03;
+	*p++ = 0x20; *p++ = 0; *p++ = 1;
+	// XInput OUT endpoint (interrupt, 0x03, 8 ms)
+	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x03; *p++ = 0x03;
+	*p++ = 0x20; *p++ = 0; *p++ = 8;
+	return total;
+}
+
+// MS OS 2.0 template (WINUSB compatible id + DeviceInterfaceGUIDs). The
+// function-subset "first interface" byte is patched per mode at init.
+static const uint8_t k_msos_template[MS_OS_20_DESC_LEN] = {
+	U16_TO_U8S_LE(0x000A), U16_TO_U8S_LE(MS_OS_20_SET_HEADER_DESCRIPTOR),
+	U32_TO_U8S_LE(0x06030000), U16_TO_U8S_LE(MS_OS_20_DESC_LEN),
+	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_CONFIGURATION),
+	0, 0, U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A),
+	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_FUNCTION),
+	0 /* first interface, patched */, 0,
+	U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08),
+	U16_TO_U8S_LE(0x0014), U16_TO_U8S_LE(MS_OS_20_FEATURE_COMPATBLE_ID),
+	'W', 'I', 'N', 'U', 'S', 'B', 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08 - 0x08 - 0x14),
+	U16_TO_U8S_LE(MS_OS_20_FEATURE_REG_PROPERTY),
+	U16_TO_U8S_LE(0x0007), U16_TO_U8S_LE(0x002A),
+	'D', 0x00, 'e', 0x00, 'v', 0x00, 'i', 0x00, 'c', 0x00, 'e', 0x00, 'I', 0x00,
+	'n', 0x00, 't', 0x00, 'e', 0x00, 'r', 0x00, 'f', 0x00, 'a', 0x00, 'c', 0x00,
+	'e', 0x00, 'G', 0x00, 'U', 0x00, 'I', 0x00, 'D', 0x00, 's', 0x00, 0x00, 0x00,
+	U16_TO_U8S_LE(0x0050),
+	'{', 0x00, '9', 0x00, '7', 0x00, '5', 0x00, 'F', 0x00, '4', 0x00, '4', 0x00,
+	'D', 0x00, '9', 0x00, '-', 0x00, '0', 0x00, 'D', 0x00, '0', 0x00, '8', 0x00,
+	'-', 0x00, '4', 0x00, '3', 0x00, 'F', 0x00, 'D', 0x00, '-', 0x00, '8', 0x00,
+	'B', 0x00, '3', 0x00, 'E', 0x00, '-', 0x00, '1', 0x00, '2', 0x00, '7', 0x00,
+	'C', 0x00, 'A', 0x00, '8', 0x00, 'A', 0x00, 'F', 0x00, 'F', 0x00, 'F', 0x00,
+	'9', 0x00, 'D', 0x00, '}', 0x00, 0x00, 0x00, 0x00, 0x00,
 };
 
+void usb_descriptors_init(void)
+{
+	s_mode = settings_mode();
+	s_xinput = mode_is_xinput(s_mode);
+	s_emu = (mode_is_puck(s_mode) || s_xinput) ? NULL : emu_mode_for(s_mode);
+	if (!mode_is_puck(s_mode) && !s_xinput && !s_emu)
+		s_mode = MODE_STEAM;  // unknown/unimplemented emu mode → puck fallback
+
+	memset(&s_dev, 0, sizeof(s_dev));
+	s_dev.bLength = sizeof(tusb_desc_device_t);
+	s_dev.bDescriptorType = TUSB_DESC_DEVICE;
+	s_dev.bcdUSB = 0x0210;
+	s_dev.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE;
+	s_dev.iManufacturer = 0x01;
+	s_dev.iProduct = 0x02;
+	s_dev.iSerialNumber = 0x03;
+	s_dev.bNumConfigurations = 0x01;
+	if (s_xinput) {
+		// Xbox 360 wired: Windows xusb / Linux xpad / SDL bind 045E:028E.
+		s_dev.idVendor = 0x045E;
+		s_dev.idProduct = 0x028E;
+		s_dev.bcdDevice = 0x0120;
+		build_xinput_config(s_emu_cfg);
+		s_vendor_itf = 0;  // WebUSB vendor is itf 0 in the XInput config
+	} else if (s_emu) {
+		s_dev.idVendor = s_emu->vid;
+		s_dev.idProduct = s_emu->pid;
+		s_dev.bcdDevice = s_emu->bcd;
+		build_emu_config(s_emu_cfg, s_emu);
+		s_vendor_itf = 1;
+	} else {
+		s_dev.idVendor = PP_USB_VID;
+		s_dev.idProduct = PP_USB_PID;
+		s_dev.bcdDevice = PP_BCD;
+		s_vendor_itf = ITF_NUM_VENDOR;
+	}
+
+	memcpy(s_msos, k_msos_template, MS_OS_20_DESC_LEN);
+	s_msos[22] = s_vendor_itf;  // function-subset "first interface"
+}
+
 const uint8_t *tud_descriptor_device_cb(void)
 {
-	return (const uint8_t *)&desc_device;
+	return (const uint8_t *)&s_dev;
 }
 
-// ---- HID report descriptor (all four instances share the puck descriptor) --
 const uint8_t *tud_hid_descriptor_report_cb(uint8_t instance)
 {
 	(void)instance;
-	return PUCK_HID_DESC;
+	return s_emu ? s_emu->report_desc : PUCK_HID_DESC;
 }
 
-// ---- configuration descriptor ----------------------------------------------
-#define CONFIG_TOTAL_LEN                                                       \
+// ---- puck configuration descriptor -----------------------------------------
+#define PUCK_CONFIG_TOTAL_LEN                                                  \
 	(TUD_CONFIG_DESC_LEN + 4 * TUD_HID_DESC_LEN + TUD_VENDOR_DESC_LEN +     \
 	 CFG_TUD_CDC * TUD_CDC_DESC_LEN)
 
-static const uint8_t desc_configuration[] = {
-	TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 250),
-
-	// Four puck slot interfaces: IN-only, 1 ms poll, sharing PUCK_HID_DESC.
+static const uint8_t desc_configuration_puck[] = {
+	TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, PUCK_CONFIG_TOTAL_LEN, 0x00, 250),
 	TUD_HID_DESCRIPTOR(ITF_NUM_HID0, 0, HID_ITF_PROTOCOL_NONE,
 			   PUCK_HID_DESC_SIZE, EPNUM_HID0, CFG_TUD_HID_EP_BUFSIZE, 1),
 	TUD_HID_DESCRIPTOR(ITF_NUM_HID1, 0, HID_ITF_PROTOCOL_NONE,
@@ -75,10 +204,7 @@ static const uint8_t desc_configuration[] = {
 			   PUCK_HID_DESC_SIZE, EPNUM_HID2, CFG_TUD_HID_EP_BUFSIZE, 1),
 	TUD_HID_DESCRIPTOR(ITF_NUM_HID3, 0, HID_ITF_PROTOCOL_NONE,
 			   PUCK_HID_DESC_SIZE, EPNUM_HID3, CFG_TUD_HID_EP_BUFSIZE, 1),
-
-	// WebUSB config channel (bulk IN/OUT). String index 4 names it.
 	TUD_VENDOR_DESCRIPTOR(ITF_NUM_VENDOR, 4, EPNUM_VENDOR_OUT, EPNUM_VENDOR_IN, 64),
-
 #if CFG_TUD_CDC
 	TUD_CDC_DESCRIPTOR(ITF_NUM_CDC, 5, EPNUM_CDC_NOTIF, 8, EPNUM_CDC_OUT,
 			   EPNUM_CDC_IN, 64),
@@ -88,20 +214,16 @@ static const uint8_t desc_configuration[] = {
 const uint8_t *tud_descriptor_configuration_cb(uint8_t index)
 {
 	(void)index;
-	return desc_configuration;
+	return (s_emu || s_xinput) ? s_emu_cfg : desc_configuration_puck;
 }
 
-// ---- BOS + MS OS 2.0 (WinUSB binding for the vendor interface) -------------
-#define MS_OS_20_DESC_LEN 0xB2
-
+// ---- BOS + MS OS 2.0 --------------------------------------------------------
 #define BOS_TOTAL_LEN                                                          \
 	(TUD_BOS_DESC_LEN + TUD_BOS_WEBUSB_DESC_LEN +                          \
 	 TUD_BOS_MICROSOFT_OS_DESC_LEN)
 
 static const uint8_t desc_bos[] = {
 	TUD_BOS_DESCRIPTOR(BOS_TOTAL_LEN, 2),
-	// WebUSB: vendor request code, landing-page index 0 = no landing page
-	// (the panel opens the device directly; avoids a Chrome popup).
 	TUD_BOS_WEBUSB_DESCRIPTOR(VENDOR_REQUEST_WEBUSB, 0),
 	TUD_BOS_MS_OS_20_DESCRIPTOR(MS_OS_20_DESC_LEN, VENDOR_REQUEST_MICROSOFT),
 };
@@ -111,40 +233,6 @@ const uint8_t *tud_descriptor_bos_cb(void)
 	return desc_bos;
 }
 
-static const uint8_t desc_ms_os_20[] = {
-	// Set header
-	U16_TO_U8S_LE(0x000A), U16_TO_U8S_LE(MS_OS_20_SET_HEADER_DESCRIPTOR),
-	U32_TO_U8S_LE(0x06030000), U16_TO_U8S_LE(MS_OS_20_DESC_LEN),
-	// Configuration subset header
-	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_CONFIGURATION),
-	0, 0, U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A),
-	// Function subset header (binds the vendor interface)
-	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_FUNCTION),
-	ITF_NUM_VENDOR, 0, U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08),
-	// Compatible ID: WINUSB
-	U16_TO_U8S_LE(0x0014), U16_TO_U8S_LE(MS_OS_20_FEATURE_COMPATBLE_ID),
-	'W', 'I', 'N', 'U', 'S', 'B', 0x00, 0x00,
-	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
-	// Registry property: DeviceInterfaceGUIDs
-	U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08 - 0x08 - 0x14),
-	U16_TO_U8S_LE(MS_OS_20_FEATURE_REG_PROPERTY),
-	U16_TO_U8S_LE(0x0007), U16_TO_U8S_LE(0x002A),
-	'D', 0x00, 'e', 0x00, 'v', 0x00, 'i', 0x00, 'c', 0x00, 'e', 0x00, 'I', 0x00,
-	'n', 0x00, 't', 0x00, 'e', 0x00, 'r', 0x00, 'f', 0x00, 'a', 0x00, 'c', 0x00,
-	'e', 0x00, 'G', 0x00, 'U', 0x00, 'I', 0x00, 'D', 0x00, 's', 0x00, 0x00, 0x00,
-	U16_TO_U8S_LE(0x0050),
-	'{', 0x00, '9', 0x00, '7', 0x00, '5', 0x00, 'F', 0x00, '4', 0x00, '4', 0x00,
-	'D', 0x00, '9', 0x00, '-', 0x00, '0', 0x00, 'D', 0x00, '0', 0x00, '8', 0x00,
-	'-', 0x00, '4', 0x00, '3', 0x00, 'F', 0x00, 'D', 0x00, '-', 0x00, '8', 0x00,
-	'B', 0x00, '3', 0x00, 'E', 0x00, '-', 0x00, '1', 0x00, '2', 0x00, '7', 0x00,
-	'C', 0x00, 'A', 0x00, '8', 0x00, 'A', 0x00, 'F', 0x00, 'F', 0x00, 'F', 0x00,
-	'9', 0x00, 'D', 0x00, '}', 0x00, 0x00, 0x00, 0x00, 0x00,
-};
-
-TU_VERIFY_STATIC(sizeof(desc_ms_os_20) == MS_OS_20_DESC_LEN, "MS OS 2.0 size");
-
-// Single vendor control-transfer callback: WebUSB/MS-OS descriptor requests and
-// the panel's class "connect" request (0x22, Adafruit convention).
 bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
 				tusb_control_request_t const *request)
 {
@@ -153,29 +241,20 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
 
 	switch (request->bmRequestType_bit.type) {
 	case TUSB_REQ_TYPE_VENDOR:
-		switch (request->bRequest) {
-		case VENDOR_REQUEST_MICROSOFT:
-			if (request->wIndex == 7) {
-				uint16_t total_len;
-				memcpy(&total_len, desc_ms_os_20 + 8, 2);
-				return tud_control_xfer(
-					rhport, request,
-					(void *)(uintptr_t)desc_ms_os_20,
-					total_len);
-			}
-			return false;
-		default:
-			break;
+		if (request->bRequest == VENDOR_REQUEST_MICROSOFT &&
+		    request->wIndex == 7) {
+			uint16_t total_len;
+			memcpy(&total_len, s_msos + 8, 2);
+			return tud_control_xfer(rhport, request,
+						(void *)(uintptr_t)s_msos, total_len);
 		}
-		break;
-
+		return false;
 	case TUSB_REQ_TYPE_CLASS:
 		if (request->bRequest == 0x22) {
 			webusb_set_connected(request->wValue != 0);
 			return tud_control_status(rhport, request);
 		}
 		break;
-
 	default:
 		break;
 	}
@@ -184,15 +263,6 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
 
 // ---- string descriptors ----------------------------------------------------
 enum { STRID_LANGID = 0, STRID_MANUFACTURER, STRID_PRODUCT, STRID_SERIAL, STRID_VENDOR };
-
-static const char *const string_desc_arr[] = {
-	[STRID_LANGID] = (const char[]){ 0x09, 0x04 },  // English (0x0409)
-	[STRID_MANUFACTURER] = "Valve Software",
-	[STRID_PRODUCT] = "Steam Controller Puck",
-	[STRID_SERIAL] = NULL,  // filled from g_usb_serial at runtime
-	[STRID_VENDOR] = "PicoPuck WebUSB",
-};
-
 static uint16_t desc_str[32];
 
 const uint16_t *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
@@ -201,19 +271,28 @@ const uint16_t *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
 	size_t chr_count;
 
 	if (index == STRID_LANGID) {
-		memcpy(&desc_str[1], string_desc_arr[0], 2);
+		desc_str[1] = 0x0409;
 		chr_count = 1;
 	} else {
 		const char *str;
-		if (index == STRID_SERIAL)
+		switch (index) {
+		case STRID_MANUFACTURER:
+			str = s_emu ? "PicoPuck" : "Valve Software";
+			break;
+		case STRID_PRODUCT:
+			str = s_emu ? s_emu->product : "Steam Controller Puck";
+			break;
+		case STRID_SERIAL:
 			str = g_usb_serial;
-		else if (index < TU_ARRAY_SIZE(string_desc_arr))
-			str = string_desc_arr[index];
-		else
+			break;
+		case STRID_VENDOR:
+			str = "PicoPuck WebUSB";
+			break;
+		default:
 			return NULL;
+		}
 		if (!str)
 			return NULL;
-
 		chr_count = strlen(str);
 		if (chr_count > 31)
 			chr_count = 31;

+ 3 - 0
PicoPuck/src/usb/usb_descriptors.h

@@ -40,4 +40,7 @@ enum {
 #define VENDOR_REQUEST_WEBUSB 1
 #define VENDOR_REQUEST_MICROSOFT 2
 
+// Cache the active mode's descriptors. Call once at boot, before tud_init().
+void usb_descriptors_init(void);
+
 #endif // PICOPUCK_USB_DESCRIPTORS_H

+ 192 - 170
PicoPuck/src/usb/webusb.c

@@ -1,231 +1,248 @@
-// webusb.c — WebUSB config channel (see webusb.h).
+// webusb.c — WebUSB config channel.
 //
-// Host→device opcodes (each a single bulk-OUT packet):
-//   0x01                       get status          → 0xA5 frame
-//   0x02 [secs]                scan start
-//   0x03                       scan stop
-//   0x04                       get scan list       → 0xA6 frame
-//   0x05 [addr6][type]         pair / connect
-//   0x06 [addr6][type]         forget one bond
-//   0x07                       forget all bonds
-//   0x08 [slot]                disconnect a slot
-//   0x0A                       reboot
-//   0x0B [0x42 0x4C]           reboot to UF2 bootloader (BOOTSEL)
-//   0x0C [0xDE 0xAD]           factory reset (bonds + settings)
-// Device→host frames: 0xA5 status, 0xA6 scan list.
+// PicoPuck speaks OpenPuck's WebUSB protocol so the same panel drives the common
+// cards identically: the status blob (marker 0xA5, protocol v17, 180-byte
+// payload) and the standard opcode set (0x01 status, 0x02 set-field, 0x03 mode
+// switch, 0x0A erase, 0x0C UF2 bootloader, …). Bluetooth pairing — the only
+// PicoPuck-specific UI — rides opcodes 0x16-0x19 and device→host marker 0xAD,
+// which OpenPuck doesn't use, so nothing collides.
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "usb/webusb.h"
 #include "config/picopuck_config.h"
+#include "config/modes.h"
 #include "puck/slots.h"
 #include "puck/relay.h"
 #include "puck/personality.h"
-#include "usb/usb_tx.h"
+#include "sys/settings.h"
 #include "bt/bt_control.h"
 
-// From bt_valve.c / bt_host.c — declared here to avoid pulling BTstack's HID
-// headers (which clash with TinyUSB's) into this TinyUSB translation unit.
-extern volatile uint16_t g_valve_writes;
-uint8_t bt_report_count(int slot);
-
 #include <string.h>
 #include "tusb.h"
 #include "pico/bootrom.h"
 #include "hardware/watchdog.h"
 
-static bool s_connected;
+// From other TUs (declared locally to avoid pulling BTstack headers into this
+// TinyUSB translation unit).
+extern volatile uint16_t g_valve_writes;
+uint8_t bt_report_count(int slot);
 
-// Slot record: state, kind, battery, rssi, bd_addr[6], name[16] = 26 bytes.
-#define SLOT_REC_LEN 26
-// Scan record: kind, addr_type, rssi, bd_addr[6], name[16] = 25 bytes.
-#define SCAN_REC_LEN 25
-#define SCAN_MAX 12
+#define WB_VER 17
+#define WB_PAYLEN 180
 
-void webusb_init(void)
-{
-	s_connected = false;
-}
+static bool s_connected;
 
-void webusb_set_connected(bool connected)
-{
-	s_connected = connected;
-}
+void webusb_init(void) { s_connected = false; }
+void webusb_set_connected(bool c) { s_connected = c; }
 
-// ---- frame senders ---------------------------------------------------------
-static void send_frame(const uint8_t *frame, uint16_t len)
+static void send_frame(const uint8_t *f, uint16_t len)
 {
-	// Drop rather than block if the panel isn't draining (avoids wedging loop).
 	if (tud_vendor_write_available() < len)
 		return;
-	tud_vendor_write(frame, len);
+	tud_vendor_write(f, len);
 	tud_vendor_write_flush();
 }
 
-static void send_status(void)
+static int active_slot(void)
 {
-	uint8_t f[2 + 17 + PP_NSLOT * SLOT_REC_LEN + 11 + PP_NSLOT * 5];
-	uint8_t *p = f;
-	*p++ = 0xA5;
-	uint8_t *plen = p++;  // payload length, filled below
-	uint8_t *start = p;
-
-	*p++ = PP_WEBUSB_VER;
-	*p++ = PP_BOARD;
-	*p++ = 0;  // reserved (git-dirty flag)
+	for (int s = 0; s < PP_NSLOT; s++)
+		if (g_slot[s].connected)
+			return s;
+	return -1;
+}
 
-	char build[12] = { 0 };
-	strncpy(build, PICOPUCK_GIT_COMMIT, sizeof(build));
-	memcpy(p, build, 12);
-	p += 12;
+// ---- 0xA5 status blob (OpenPuck v17 layout; payload index = fw index − 2) ----
+static void send_blob(void)
+{
+	uint8_t f[2 + WB_PAYLEN];
+	memset(f, 0, sizeof(f));
+	f[0] = 0xA5;
+	f[1] = WB_PAYLEN;
+	uint8_t *p = f + 2;  // payload; p[i] == OpenPuck panel index i
+	const pp_cfg_t *c = settings();
 
-	*p++ = bt_scan_active() ? 1 : 0;
-	*p++ = PP_NSLOT;
+	int as = active_slot();
+	const pp_type_cfg_t *at = &c->type[0];  // active-type mirror (kept simple)
 
-	uint32_t now = 0;  // (state doesn't need a timestamp here)
-	(void)now;
+	p[0] = WB_VER;
+	p[1] = c->mode;
+	p[2] = c->m_div;
+	p[3] = c->m_fric;
+	p[4] = at->qam;
+	p[5] = at->ab_swap;
+	p[6] = at->back[0]; p[7] = at->back[1]; p[8] = at->back[2]; p[9] = at->back[3];
+	p[10] = (as < 0) ? 0xFF : (uint8_t)as;
+	p[11] = (as >= 0);
+	p[20] = 1;  // fwd-new-only
+	p[22] = c->persist_mode;
+	p[23] = c->chord[0]; p[24] = c->chord[1]; p[25] = c->chord[2];
+	p[36] = (as >= 0) ? g_battery[as] : 0;
+	p[37] = (as >= 0 && g_link_rssi[as]) ? (uint8_t)(-g_link_rssi[as]) : 0;
+	strncpy((char *)(p + 39), PICOPUCK_GIT_COMMIT, 12);
+	p[51] = c->rumble_scale;
+	p[52] = c->sw_pro_rate;
+	p[53] = c->sw_gyro10;
+	int bonded = 0;
+	for (int s = 0; s < PP_NSLOT; s++)
+		if (g_slot[s].connected)
+			bonded++;
+	p[60] = (uint8_t)bonded;
 	for (int s = 0; s < PP_NSLOT; s++) {
-		uint8_t kind = 0;
-		int8_t rssi = 0;
-		uint8_t addr[6] = { 0 };
-		char name[16] = { 0 };
-		bool bound = bt_slot_info(s, &kind, &rssi, addr, name);
-		uint8_t state = g_slot[s].connected ? 2 :
-				((bound || g_slot[s].used) ? 1 : 0);
-
-		*p++ = state;
-		*p++ = kind;
-		*p++ = g_battery[s];
-		*p++ = (uint8_t)rssi;
-		memcpy(p, addr, 6);
-		p += 6;
-		memcpy(p, name, 16);
-		p += 16;
+		uint8_t *ss = p + 61 + s * 3;
+		ss[0] = g_slot[s].connected;
+		ss[1] = g_battery[s];
+		ss[2] = g_link_rssi[s] ? (uint8_t)(-g_link_rssi[s]) : 0;
 	}
-
-	// Diagnostics tail: [advSeen 2][flags 1][hciState 1][hciEvents 2].
-	uint16_t adv = 0, hev = 0;
-	uint8_t flags = 0, hci_state = 0;
-	bt_scan_diag(&adv, &flags, &hci_state, &hev);
-	*p++ = (uint8_t)(adv & 0xFF);
-	*p++ = (uint8_t)(adv >> 8);
-	*p++ = flags;
-	*p++ = hci_state;
-	*p++ = (uint8_t)(hev & 0xFF);
-	*p++ = (uint8_t)(hev >> 8);
-	// Relay diagnostics: host→controller relays (Steam sending haptics?) and
-	// GATT writes issued to SC2s (did we forward them?).
-	*p++ = (uint8_t)(g_relay_count & 0xFF);
-	*p++ = (uint8_t)(g_relay_count >> 8);
-	*p++ = g_relay_last_id;
-	*p++ = (uint8_t)(g_valve_writes & 0xFF);
-	*p++ = (uint8_t)(g_valve_writes >> 8);
-	// Per-slot feature access: [getN][setN][lastSetCmd] ×4 — shows which slot
-	// interfaces Steam actually polls/pairs.
-	for (int s = 0; s < PP_NSLOT; s++) {
-		uint8_t g = 0, st = 0, lc = 0;
-		puck_slot_io(s, &g, &st, &lc);
-		*p++ = g;
-		*p++ = st;
-		*p++ = lc;
-		*p++ = bt_report_count(s);  // BT input reports received
-		*p++ = usb_tx_count(s);     // reports Steam drained
+	for (int et = 0; et < 4; et++) {
+		uint8_t *tt = p + 73 + et * 9;
+		const pp_type_cfg_t *t = &c->type[et];
+		tt[0] = t->back[0]; tt[1] = t->back[1]; tt[2] = t->back[2]; tt[3] = t->back[3];
+		tt[4] = t->qam; tt[5] = t->ab_swap; tt[6] = t->pad_haptics;
+		tt[7] = t->led_bright; tt[8] = t->rumble;
 	}
+	p[126] = 0xFF;  // hang stage = none
+	p[179] = c->land_all_87;
 
-	*plen = (uint8_t)(p - start);
-	send_frame(f, (uint16_t)(p - f));
+	send_frame(f, sizeof(f));
 }
 
-static void send_scan_list(void)
+// ---- 0xAD Bluetooth status frame (PicoPuck-specific pairing UI) -------------
+// NOTE: this frame carries a 16-BIT little-endian length (marker + 2 length
+// bytes + payload), unlike the 1-byte-length OpenPuck frames — with a full scan
+// list the payload exceeds 255 bytes, so a 1-byte length would wrap and truncate
+// the frame (dropping slots / the scan list intermittently). The panel's
+// readFrame() special-cases 0xAD to read the wide length.
+#define BT_SLOT_REC 26
+#define BT_SCAN_REC 25
+#define BT_SCAN_MAX 10
+static void send_bt_frame(void)
 {
-	bt_scan_entry_t ents[SCAN_MAX];
-	uint8_t n = bt_scan_list(ents, SCAN_MAX);
-
-	uint8_t f[2 + 1 + SCAN_MAX * SCAN_REC_LEN];
+	uint8_t f[3 + 3 + PP_NSLOT * BT_SLOT_REC + 1 + BT_SCAN_MAX * BT_SCAN_REC + 8];
 	uint8_t *p = f;
-	*p++ = 0xA6;
-	uint8_t *plen = p++;
+	*p++ = 0xAD;
+	uint8_t *plen = p; p += 2;   // 16-bit LE length, backfilled below
 	uint8_t *start = p;
+	*p++ = 1;             // frame version
+	*p++ = PP_BOARD;
+	*p++ = bt_scan_active() ? 1 : 0;
+
+	// Controller list (PP_NSLOT recs): live connections first, then persisted
+	// bonds that are offline (so pairings show after a reboot / mode switch
+	// before they reconnect), padded with empty. state: 0 empty,1 bonded,2 live.
+	struct { uint8_t state, kind, batt; int8_t rssi; uint8_t addr[6]; char name[16]; }
+		rec[PP_NSLOT];
+	memset(rec, 0, sizeof(rec));
+	int nr = 0;
+	for (int s = 0; s < PP_NSLOT && nr < PP_NSLOT; s++) {
+		uint8_t kind = 0; int8_t rssi = 0; uint8_t addr[6] = { 0 };
+		char name[16] = { 0 };
+		if (g_slot[s].connected && bt_slot_info(s, &kind, &rssi, addr, name)) {
+			rec[nr].state = 2; rec[nr].kind = kind;
+			rec[nr].batt = g_battery[s]; rec[nr].rssi = rssi;
+			memcpy(rec[nr].addr, addr, 6); memcpy(rec[nr].name, name, 16);
+			nr++;
+		}
+	}
+	uint8_t baddr[PP_NSLOT][6], btype[PP_NSLOT];
+	uint8_t nb = bt_bond_offline_list(baddr, btype, PP_NSLOT);
+	for (uint8_t i = 0; i < nb && nr < PP_NSLOT; i++) {
+		rec[nr].state = 1;
+		// Recover the persisted name/kind; fall back to a generic BLE pad label.
+		uint8_t k = 2; char nm[16] = { 0 };
+		if (settings_bond_lookup(baddr[i], &k, nm)) {
+			rec[nr].kind = k;
+			memcpy(rec[nr].name, nm, 16);
+		} else {
+			rec[nr].kind = 2;
+		}
+		memcpy(rec[nr].addr, baddr[i], 6);
+		nr++;
+	}
+	for (int r = 0; r < PP_NSLOT; r++) {
+		*p++ = rec[r].state; *p++ = rec[r].kind;
+		*p++ = rec[r].batt; *p++ = (uint8_t)rec[r].rssi;
+		memcpy(p, rec[r].addr, 6); p += 6;
+		memcpy(p, rec[r].name, 16); p += 16;
+	}
+
+	bt_scan_entry_t ents[BT_SCAN_MAX];
+	uint8_t n = bt_scan_list(ents, BT_SCAN_MAX);
 	*p++ = n;
 	for (uint8_t i = 0; i < n; i++) {
 		*p++ = ents[i].kind;
 		*p++ = ents[i].addr_type;
 		*p++ = (uint8_t)ents[i].rssi;
-		memcpy(p, ents[i].addr, 6);
-		p += 6;
-		memcpy(p, ents[i].name, 16);
-		p += 16;
+		memcpy(p, ents[i].addr, 6); p += 6;
+		memcpy(p, ents[i].name, 16); p += 16;
 	}
-	*plen = (uint8_t)(p - start);
+	// Diagnostics tail.
+	uint16_t adv = 0, hev = 0; uint8_t flags = 0, hci = 0;
+	bt_scan_diag(&adv, &flags, &hci, &hev);
+	*p++ = (uint8_t)(adv & 0xFF); *p++ = (uint8_t)(adv >> 8);
+	*p++ = flags; *p++ = hci;
+	*p++ = (uint8_t)(g_valve_writes & 0xFF); *p++ = (uint8_t)(g_valve_writes >> 8);
+
+	uint16_t pl = (uint16_t)(p - start);
+	plen[0] = (uint8_t)(pl & 0xFF); plen[1] = (uint8_t)(pl >> 8);
 	send_frame(f, (uint16_t)(p - f));
 }
 
-// ---- opcode dispatch -------------------------------------------------------
-// Expected total length (incl. opcode) for each known opcode, or 0 if unknown.
+// ---- opcode lengths (incl. opcode byte); 0 = unknown → resync -------------
 static uint8_t opcode_len(uint8_t op)
 {
 	switch (op) {
-	case 0x01: return 1;
-	case 0x02: return 2;
-	case 0x03: return 1;
-	case 0x04: return 1;
-	case 0x05: return 8;
-	case 0x06: return 8;
-	case 0x07: return 1;
-	case 0x08: return 2;
-	case 0x0A: return 1;
-	case 0x0B: return 3;
-	case 0x0C: return 3;
+	case 0x01: case 0x07: case 0x08: case 0x09: case 0x0B: case 0x0C:
+	case 0x11: case 0x14: case 0x15: case 0x22: case 0x23: case 0x24:
+	case 0x19:
+		return 1;
+	case 0x02: return 3;
+	case 0x03: case 0x05: case 0x0E: case 0x0F: case 0x10: case 0x13:
+	case 0x16:
+		return 2;
+	case 0x0A: return 4;
+	case 0x25: return 5;
+	case 0x20: return 9;
+	case 0x12: return 18;
+	case 0x0D: return 27;
+	case 0x17: case 0x18: return 8;
+	// 0x21 is variable (6 + data) — handled in the parser.
 	default: return 0;
 	}
 }
 
-static void dispatch(const uint8_t *c, uint8_t n)
+static void dispatch(const uint8_t *c)
 {
 	switch (c[0]) {
-	case 0x01:
-		send_status();
-		break;
-	case 0x02:
-		bt_scan_start(c[1]);
-		break;
+	case 0x01: send_blob(); break;
+	case 0x02: settings_set_field(c[1], c[2]); send_blob(); break;
 	case 0x03:
-		bt_scan_stop();
-		break;
-	case 0x04:
-		send_scan_list();
-		break;
-	case 0x05:
-		bt_pair(&c[1], c[7]);
-		break;
-	case 0x06:
-		bt_forget(&c[1], c[7]);
-		break;
-	case 0x07:
-		bt_forget_all();
+		if (c[1] <= MODE_MAX) { settings_set_mode(c[1]); watchdog_reboot(0, 0, 1); }
 		break;
 	case 0x08:
-		bt_disconnect_slot(c[1]);
+		// Power off the connected SC2 (ID_TURN_OFF); ignored by generic pads.
+		for (int s = 0; s < PP_NSLOT; s++)
+			if (g_slot[s].connected)
+				relay_enqueue(s, 0x9F, (const uint8_t *)"off!", 4);
 		break;
 	case 0x0A:
-		watchdog_reboot(0, 0, 10);
-		break;
-	case 0x0B:
-		if (c[1] == 0x42 && c[2] == 0x4C) {
-			// Disarm the watchdog first so it can't reset the chip back out
-			// of BOOTSEL, then reboot into the UF2 bootloader with both the
-			// mass-storage and PICOBOOT interfaces enabled.
-			watchdog_disable();
-			reset_usb_boot(0, 0);
+		if (c[1] == 0x45 && c[2] == 0x52 && c[3] == 0x53) {  // "ERS"
+			bt_forget_all();
+			settings_factory_reset();
+			watchdog_reboot(0, 0, 1);
 		}
 		break;
-	case 0x0C:
-		if (c[1] == 0xDE && c[2] == 0xAD)
-			bt_forget_all();  // settings wipe added with the settings module
-		break;
-	default:
+	case 0x0B:
+	case 0x0C:  // reboot to UF2 bootloader (PicoPuck has no serial DFU)
+		watchdog_disable();
+		reset_usb_boot(0, 0);
 		break;
+	// --- Bluetooth pairing (PicoPuck-specific) ---
+	case 0x16: if (c[1]) bt_scan_start(c[1]); else bt_scan_stop(); break;
+	case 0x17: bt_pair(&c[1], c[7]); break;
+	case 0x18: bt_forget(&c[1], c[7]); break;
+	case 0x19: send_bt_frame(); break;
+	default: break;  // stubs (0x07/0x09/0x0D/0x0E/0x0F/0x10/0x11-0x15/0x20-0x25)
 	}
-	(void)n;
 }
 
 void webusb_task(void)
@@ -243,19 +260,24 @@ void webusb_task(void)
 
 	uint8_t off = 0;
 	while (off < rxlen) {
-		uint8_t need = opcode_len(rx[off]);
+		uint8_t op = rx[off];
+		uint8_t need;
+		if (op == 0x21) {  // fw chunk: 6 header + data[5]
+			if ((uint8_t)(rxlen - off) < 6)
+				break;
+			need = (uint8_t)(6 + rx[off + 5]);
+		} else {
+			need = opcode_len(op);
+		}
 		if (need == 0) {
-			// Unknown opcode: resync by dropping one byte.
-			off++;
+			off++;  // unknown opcode → resync
 			continue;
 		}
 		if ((uint8_t)(rxlen - off) < need)
-			break;  // wait for the rest of this command
-		dispatch(&rx[off], need);
+			break;
+		dispatch(&rx[off]);
 		off += need;
 	}
-
-	// Keep any partial trailing command for the next poll.
 	if (off > 0 && off < rxlen)
 		memmove(rx, rx + off, (size_t)(rxlen - off));
 	rxlen -= off;

+ 108 - 78
docs/index.html

@@ -23,8 +23,9 @@
   /* ReversePuck dongle: hide every puck-only Controller card, keep just the paired-pucks card (this leaves
      the debugonly/lizard inline visibility rules untouched, so switching back to a puck restores them). */
   body.dongle #tabMain > .cards > .card:not(#pairedCard){display:none !important}
-  /* PicoPuck (Pico W / Pico 2 W): a Bluetooth puck. Show only its controllers card. */
-  body.picopuck #tabMain > .cards > .card:not(#picopuckCard){display:none !important}
+  /* PicoPuck (Pico W / Pico 2 W): a Bluetooth puck. It speaks the full puck
+     protocol, so it shows the SAME cards as a puck PLUS its Bluetooth card;
+     nothing is hidden. */
   .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
   .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
   .row label{flex:0 0 150px;color:var(--fg)}
@@ -146,10 +147,17 @@
         <button id="ppScanBtn">Scan</button>
         <button id="ppBootBtn" title="Reboot into the UF2 bootloader to flash new firmware">Update firmware</button>
       </div>
-      <div id="ppSlots"></div>
-      <h3 style="font-size:12px;color:var(--mut);margin:14px 0 6px">Discovered</h3>
-      <div id="ppScanList"><p class="note">Press <em>Scan</em> to look for controllers in pairing mode.</p></div>
-      <p class="note" style="margin-top:8px">This is a <strong>PicoPuck</strong> (Pico W / Pico 2 W). It connects to controllers over Bluetooth and presents them to Steam as a normal puck. Put a controller in pairing mode, press <em>Scan</em>, then pair it into one of the four slots. Use the <em>Firmware update</em> tab (reboot to bootloader) to flash a new build.</p>
+      <div style="display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start">
+        <div style="flex:1;min-width:240px">
+          <h3 style="font-size:12px;color:var(--mut);margin:8px 0 6px">Discovered</h3>
+          <div id="ppScanList"><p class="note">Press <em>Scan</em> to look for controllers in pairing mode.</p></div>
+        </div>
+        <div style="flex:1;min-width:240px">
+          <h3 style="font-size:12px;color:var(--mut);margin:8px 0 6px">Paired controllers</h3>
+          <div id="ppSlots"><p class="note">No controllers paired yet.</p></div>
+        </div>
+      </div>
+      <p class="note" style="margin-top:8px">This is a <strong>PicoPuck</strong> (Pico W / Pico 2 W). Pair a controller here; <em>everything else</em> — output mode, rumble, mappings — uses the same cards above as a wired puck. Put a controller in pairing mode, press <em>Scan</em>, then pair it. Pairings persist across reboots and mode changes.</p>
     </div>
     <div class="card">
       <h2>Version</h2>
@@ -736,6 +744,18 @@ async function openDevice(d){
     isDongle = (dev.productId === 0x1302); // ReversePuck controller dongle vs a puck (0x1304 / real)
     // PicoPuck shares the puck PID but bumps bcdDevice to 0x03xx (nRF puck = 0x02xx).
     isPicoPuck = (dev.productId === 0x1304 && dev.deviceVersionMajor === 3);
+    // In an emulated output mode PicoPuck enumerates with the emulated controller's
+    // VID/PID (e.g. 054C:0CE6), so productId alone can't identify it — the puck card
+    // would vanish on a mode switch. Probe the PicoPuck-only 0xAD Bluetooth frame to
+    // confirm it in ANY mode. Only for the emulated VIDs (a real controller of the
+    // same VID never reaches here — it lacks the WebUSB bulk vendor interface above);
+    // guarded by a timeout so a non-responder can't hang the connect.
+    if(!isPicoPuck && [0x045E,0x057E,0x0F0D,0x054C].includes(dev.vendorId)){
+      try{ await send([0x19]);
+        const bt=await Promise.race([readFrame(0xAD,3,512), new Promise(r=>setTimeout(()=>r(null),700))]);
+        if(bt) isPicoPuck=true;
+      }catch(e){}
+    }
     const kind = isDongle ? "ReversePuck" : isPicoPuck ? "PicoPuck" : "puck";
     $("#connState").textContent="connected"; $("#connState").className="pill up";
     $("#connState").title = dev.serialNumber ? (kind+" serial "+dev.serialNumber) : "";
@@ -826,12 +846,16 @@ async function readFrame(marker, minLen, readLen){
       if(d[w]===0xA9 && d[w+1]===3){ onWedge(d[w+2], d[w+3]|(d[w+4]<<8)); break; }
     }
     let i=0; while(i<d.length && d[i]!==marker) i++;
-    if(i+2>d.length) return null;
-    const len=d[i+1];
+    // The PicoPuck 0xAD Bluetooth frame uses a 16-bit LE length (slots + a full
+    // scan list exceed 255 B); every other frame uses a single length byte.
+    const wide = (marker===0xAD);
+    const hdr = wide ? 3 : 2;
+    if(i+hdr>d.length) return null;
+    const len = wide ? (d[i+1]|(d[i+2]<<8)) : d[i+1];
     // truncated read (transfer ended mid-frame): applying a half blob silently skips every late field --
     // reset cause, pendingHang classification, stack stats -- so drop the whole frame and retry next poll.
-    if(i+2+len>d.length) return null;
-    const p=d.slice(i+2, i+2+len);
+    if(i+hdr+len>d.length) return null;
+    const p=d.slice(i+hdr, i+hdr+len);
     return p.length>=(minLen||0) ? p : null;
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
@@ -842,7 +866,9 @@ function applyBlob(p){
   // Lazy, capability-gated lizard-map load: only after a blob proves the puck speaks v16+ (the op-0x11 dump).
   // On older firmware 0x11 is dropped silently and readLizard() would block the endpoint forever, so we must
   // NEVER send it blind at connect. One-shot per connection (reset in openDevice).
-  if(!lizardLoaded && p[0]>=16){ lizardLoaded=true; loadLizard(); }
+  // PicoPuck shares the v17 status blob but does NOT implement the lizard opcodes — sending 0x11 would hang
+  // readLizard() forever, latching lizardBusy and wedging the whole status poll (dead scan list / UI). Skip it.
+  if(!lizardLoaded && p[0]>=16 && !isPicoPuck){ lizardLoaded=true; loadLizard(); }
   const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
   const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
@@ -1085,8 +1111,11 @@ async function refresh(){
   // that would silently kill all future polls and read as a fake "heartbeat lost".
   try{
     if(isDongle){ await send([0x01]); const p=await readFrame(0xAC, 3, 256); if(p) applyDongleStatus(p); }
-    else if(isPicoPuck){ await send([0x01]); const p=await readFrame(0xA5, 3, 256); if(p) applyPicoPuckStatus(p); }
-    else { await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
+    else { await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
+      // PicoPuck speaks the full puck blob (handled by applyBlob above); it just
+      // adds a Bluetooth-pairing frame (0xAD) for its extra card.
+      if(isPicoPuck){ await send([0x19]); const bt=await readFrame(0xAD,3,512); if(bt) applyBtFrame(bt); }
+    }
   }
   catch(e){ log("refresh err: "+e.message); }
   finally{ inflight=false; }
@@ -1137,90 +1166,88 @@ function renderPaired(bonds, flags){
 }
 
 // ---- PicoPuck: Bluetooth controllers (0xA5 status ver 1, 0xA6 scan list) ----
-// Status payload (header stripped): [ver][board][rsvd][build12][scanActive][nSlot]
-//   then nSlot * 26B slot recs: [state][kind][battery][rssi][addr6][name16].
+// 0xAD Bluetooth status frame (PicoPuck-only pairing UI; the rest of the panel
+// is driven by the shared status blob). Payload (header stripped):
+//   [0]=ver [1]=board [2]=scanActive
+//   PP_NSLOT(4) × 26B slot recs at 3+s*26: [state][kind][battery][rssi:s8][addr6][name16]
+//   [scanN] at 3+4*26 (=107), then scanN × 25B recs at 108+i*25:
+//     [kind][addrType][rssi:s8][addr6][name16]
+//   diag tail (6B): [adv lo][adv hi][flags][hci][gattW lo][gattW hi]
 // state: 0 empty, 1 bonded, 2 connected. kind: 0 none,1 SC2,2 BLE pad,3 Classic pad.
 const PP_KIND=["—","Steam Controller 2","Bluetooth gamepad","Classic gamepad"];
 const PP_ADDRT=["Classic","BLE","BLE"];
 function ppHex(a){ return [...a].map(x=>x.toString(16).padStart(2,"0")).join(":"); }
 function ppName(a){ const s=new TextDecoder("latin1").decode(new Uint8Array(a)); return s.replace(/[^\x20-\x7e]/g,"").trim(); }
-function applyPicoPuckStatus(p){
-  window._lastBlobTs=Date.now(); // heartbeat (mirrors applyBlob/applyDongleStatus)
-  if(pendingHang) pendingHang=null;
-  const board=p[1], scanActive=p[15], nSlot=p[16]||4;
+// kind 3 (Classic pad) uses a BR/EDR address; everything else is BLE.
+function ppAddrType(kind){ return kind===3 ? 0 : 1; }
+let ppScanning=false;
+// Send a PicoPuck Bluetooth command (scan/pair/forget) and immediately refresh the
+// 0xAD frame, serialized against the status poll (they share the one bulk pipe) via
+// the same `inflight` guard refresh() uses — so the pairing UI reacts on the click,
+// not on the next 600 ms tick.
+async function ppBtCommand(bytes){
+  if(!dev) return;
+  for(let i=0;i<50 && inflight;i++) await new Promise(r=>setTimeout(r,15));
+  inflight=true;
+  try{
+    await send(bytes);
+    await send([0x19]); const bt=await readFrame(0xAD,3,512); if(bt) applyBtFrame(bt);
+  }catch(e){ log("bt cmd err: "+e.message); }
+  finally{ inflight=false; }
+}
+function applyBtFrame(p){
+  const board=p[1], scanActive=p[2];
+  ppScanning=!!scanActive;
   $("#ppBoard").textContent = board===2 ? "Pico 2 W" : "Pico W";
-  // Diagnostics tail after the slot records: [advSeen lo][hi][flags].
-  const dg=17+nSlot*26;
-  let diag="";
-  if(p.length>=dg+11){
-    const adv=p[dg]|(p[dg+1]<<8), fl=p[dg+2], hs=p[dg+3], hev=p[dg+4]|(p[dg+5]<<8);
-    const relay=p[dg+6]|(p[dg+7]<<8), relayId=p[dg+8], vw=p[dg+9]|(p[dg+10]<<8);
-    const HS=["off","initialising","working","halting","sleeping"];
-    diag = ` · ${adv} adv · ${(fl&1)?"scan on":"scan off"}`
-      + ` · hci=${HS[hs]||hs}(evts ${hev})`
-      + ` · relays ${relay} (last 0x${relayId.toString(16)}) · gattW ${vw}`
-      + `${(fl&8)?"":" · INIT FAIL"}${(fl&16)?"":" · no power-on"}${(fl&2)?"":" · NOT READY"}${(fl&4)?" · connecting":""}`;
-    // Per-slot: [getN][setN][lastSetCmd][btRep][usbTx] ×nSlot.
-    const io=dg+11;
-    if(p.length>=io+nSlot*5){
-      let parts=[];
-      for(let s=0;s<nSlot;s++){
-        const b=io+s*5, g=p[b], st=p[b+1], lc=p[b+2], btr=p[b+3], utx=p[b+4];
-        if(g||st||btr||utx) parts.push(`i${s} g${g}/s${st}/0x${lc.toString(16)} bt${btr} tx${utx}`);
-      }
-      if(parts.length) diag += " · Steam[" + parts.join(", ") + "]";
-    }
-  } else if(p.length>=dg+6){
-    const adv=p[dg]|(p[dg+1]<<8), fl=p[dg+2], hs=p[dg+3], hev=p[dg+4]|(p[dg+5]<<8);
-    const HS=["off","initialising","working","halting","sleeping"];
-    diag = ` · ${adv} adv · ${(fl&1)?"scan on":"scan off"} · hci=${HS[hs]||hs}(evts ${hev})`;
-  } else if(p.length>=dg+3){
-    const adv=p[dg]|(p[dg+1]<<8), fl=p[dg+2];
-    diag = ` · ${adv} adv · ${(fl&1)?"scan on":"scan off"}${(fl&2)?"":" · NOT READY"}`;
-  }
-  $("#ppState").textContent = (scanActive ? "scanning for controllers…" : "idle") + diag;
-  $("#ppScanBtn").textContent = scanActive ? "Stop" : "Scan";
   const slots=[];
-  for(let i=0;i<nSlot;i++){
-    const q=17+i*26; if(q+26>p.length) break;
+  for(let i=0;i<4;i++){
+    const q=3+i*26; if(q+26>p.length) break;
     slots.push({state:p[q],kind:p[q+1],battery:p[q+2],rssi:(p[q+3]<<24>>24),
       addr:p.slice(q+4,q+10),name:ppName(p.slice(q+10,q+26))});
   }
   renderPicoPuckSlots(slots);
-  if(scanActive) ppRequestScanList();
+  const sbase=3+4*26;               // 107
+  const scanN=p[sbase]||0, ents=[];
+  for(let i=0;i<scanN;i++){
+    const q=sbase+1+i*25; if(q+25>p.length) break;
+    ents.push({kind:p[q],addrType:p[q+1],rssi:(p[q+2]<<24>>24),
+      addr:p.slice(q+3,q+9),name:ppName(p.slice(q+9,q+25))});
+  }
+  renderPicoPuckScan(ents);
+  const dg=sbase+1+scanN*25; let diag="";
+  if(p.length>=dg+6){
+    const adv=p[dg]|(p[dg+1]<<8), fl=p[dg+2], hs=p[dg+3], vw=p[dg+4]|(p[dg+5]<<8);
+    const HS=["off","initialising","working","halting","sleeping"];
+    diag = ` · ${adv} adv · hci=${HS[hs]||hs} · gattW ${vw}`
+      + `${(fl&8)?"":" · INIT FAIL"}${(fl&16)?"":" · no power-on"}${(fl&2)?"":" · NOT READY"}${(fl&4)?" · connecting":""}`;
+  }
+  $("#ppState").textContent = (scanActive ? "scanning for controllers…" : "idle") + diag;
+  $("#ppScanBtn").textContent = scanActive ? "Stop" : "Scan";
 }
 function renderPicoPuckSlots(slots){
   const el=$("#ppSlots"); if(!el) return;
+  // Only paired entries (live or bonded-offline); empty slots are omitted so this
+  // reads as a clean list of persisted controllers.
+  const paired=slots.filter(s=>s.state>0);
+  if(!paired.length){ el.innerHTML='<p class="note">No controllers paired yet.</p>'; return; }
   el.innerHTML="";
-  slots.forEach((s,i)=>{
+  paired.forEach((s)=>{
     const row=document.createElement("div"); row.className="row"; row.style.cssText="align-items:center;gap:10px;margin:6px 0";
     const live=s.state===2;
     const badge=live?'<span class="pill up" style="flex:0 0 auto">live</span>'
-      :s.state===1?'<span class="pill" style="flex:0 0 auto">bonded</span>'
-      :'<span class="pill" style="flex:0 0 auto">empty</span>';
-    const title=s.state? (s.name||PP_KIND[s.kind]||"controller") : "(empty)";
-    const meta=s.state? `${PP_KIND[s.kind]||"?"} · ${ppHex(s.addr)}`+(live?` · batt ${s.battery}% · ${s.rssi} dBm`:"") : `slot ${i}`;
+      :'<span class="pill" style="flex:0 0 auto" title="paired, currently offline">bonded</span>';
+    const title=s.name||PP_KIND[s.kind]||"controller";
+    const meta=`${PP_KIND[s.kind]||"?"} · ${ppHex(s.addr)}`+(live?` · batt ${s.battery}% · ${s.rssi} dBm`:" · offline");
     row.innerHTML=`${badge}<div style="flex:1"><div style="font-weight:600">${title}</div>`
       +`<div class="note" style="font-family:ui-monospace,Menlo,monospace">${meta}</div></div>`;
-    if(s.state){
-      const rm=document.createElement("button"); rm.textContent="Forget"; rm.className="danger"; rm.style.flex="0 0 auto";
-      rm.onclick=async()=>{ if(!confirm(`Forget "${title}"?`)) return; await send([0x06, ...s.addr, 1]); log("forgot slot "+i); };
-      row.appendChild(rm);
-    }
+    const rm=document.createElement("button"); rm.textContent="Forget"; rm.className="danger"; rm.style.flex="0 0 auto";
+    rm.onclick=async()=>{ if(!confirm(`Forget "${title}"?`)) return; await ppBtCommand([0x18, ...s.addr, ppAddrType(s.kind)]); log("forgot "+ppHex(s.addr)); };
+    row.appendChild(rm);
     el.appendChild(row);
   });
 }
-async function ppRequestScanList(){
-  try{ await send([0x04]); const p=await readFrame(0xA6, 1, 256); if(p) renderPicoPuckScan(p); }catch(e){}
-}
-function renderPicoPuckScan(p){
+function renderPicoPuckScan(ents){
   const el=$("#ppScanList"); if(!el) return;
-  const count=p[0], ents=[];
-  for(let i=0;i<count;i++){
-    const q=1+i*25; if(q+25>p.length) break;
-    ents.push({kind:p[q],addrType:p[q+1],rssi:(p[q+2]<<24>>24),
-      addr:p.slice(q+3,q+9),name:ppName(p.slice(q+9,q+25))});
-  }
   if(!ents.length){ el.innerHTML='<p class="note">No controllers found yet — make sure the controller is in Bluetooth pairing mode.</p>'; return; }
   el.innerHTML="";
   for(const e of ents){
@@ -1228,20 +1255,22 @@ function renderPicoPuckScan(p){
     row.innerHTML=`<div style="flex:1"><div style="font-weight:600">${e.name||PP_KIND[e.kind]||"controller"}</div>`
       +`<div class="note" style="font-family:ui-monospace,Menlo,monospace">${PP_KIND[e.kind]||"?"} · ${PP_ADDRT[e.addrType]||"?"} · ${ppHex(e.addr)} · ${e.rssi} dBm</div></div>`;
     const pair=document.createElement("button"); pair.textContent="Pair"; pair.style.flex="0 0 auto";
-    pair.onclick=async()=>{ await send([0x05, ...e.addr, e.addrType]); log("pairing "+ppHex(e.addr)); };
+    pair.onclick=async()=>{ await ppBtCommand([0x17, ...e.addr, e.addrType]); log("pairing "+ppHex(e.addr)); };
     row.appendChild(pair); el.appendChild(row);
   }
 }
 function ppInitCard(){
   const btn=$("#ppScanBtn"); if(!btn||btn._wired) return; btn._wired=true;
   btn.onclick=async()=>{
-    if(btn.textContent==="Stop"){ await send([0x03]); }
-    else { await send([0x02, 30]); await ppRequestScanList(); }
+    const scanning = btn.textContent==="Stop";
+    btn.textContent = scanning ? "Scan" : "Stop";  // optimistic — confirmed by the 0xAD frame
+    if(!scanning) $("#ppScanList").innerHTML='<p class="note">Scanning…</p>';
+    await ppBtCommand(scanning ? [0x16, 0] : [0x16, 30]);
   };
   const boot=$("#ppBootBtn");
   if(boot) boot.onclick=async()=>{
     if(!confirm("Reboot the PicoPuck into its UF2 bootloader?\n\nIt will appear as a USB drive; drag the new picopuck_*.uf2 onto it.")) return;
-    await send([0x0B, 0x42, 0x4C]); log("rebooting to bootloader…");
+    await send([0x0C]); log("rebooting to bootloader…");
   };
 }
 
@@ -1583,7 +1612,8 @@ async function stopCapture(){
 async function startPolling(){
   polling=true; await refresh();
   while(polling && dev){
-    await new Promise(r=>setTimeout(r,600)); await refresh();
+    // Poll faster while a PicoPuck scan is active so discovered controllers populate promptly.
+    await new Promise(r=>setTimeout(r,(isPicoPuck && ppScanning)?300:600)); await refresh();
     if(window._autoFlight){ window._autoFlight=false; await loadFlightTrail(); }
   }
 }