Переглянути джерело

non steam controllers working

Jari 3 місяців тому
батько
коміт
27fcf9096e

+ 1 - 0
PicoPuck/CMakeLists.txt

@@ -49,6 +49,7 @@ target_sources(picopuck PRIVATE
     ${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
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_hogp.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/input_driver.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/drv_xinput.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/drv_sc2.c

+ 83 - 83
PicoPuck/src/bt/bt_host.c

@@ -12,6 +12,7 @@
 #include "bt/bt_control.h"
 #include "bt/input_driver.h"
 #include "bt/bt_valve.h"
+#include "bt/bt_hogp.h"
 #include "puck/personality.h"
 #include "puck/triton.h"
 #include "config/picopuck_config.h"
@@ -19,7 +20,6 @@
 #include <stdio.h>
 #include <string.h>
 #include "btstack.h"
-#include "ble/gatt-service/hids_client.h"
 #include "classic/hid_host.h"
 #include "pico/cyw43_arch.h"
 #include "pico/time.h"
@@ -34,7 +34,6 @@
 static btstack_packet_callback_registration_t s_hci_cb;
 static btstack_packet_callback_registration_t s_sm_cb;
 static bool s_ready;
-static uint8_t s_hid_desc_storage[1024];    // BLE hids_client
 static uint8_t s_classic_desc_storage[1024]; // Classic hid_host
 
 // Per-slot connection state.
@@ -67,6 +66,17 @@ static struct {
 } s_connecting;
 
 static uint16_t s_adv_seen;  // diagnostic: raw advertising reports since scan start
+static uint8_t s_bt_rep[PP_NSLOT];  // diagnostic: input reports received from BT per slot
+uint8_t bt_report_count(int slot)
+{
+	return (slot >= 0 && slot < PP_NSLOT) ? s_bt_rep[slot] : 0;
+}
+// Called by the HOGP/Valve clients (other TUs) when an input report arrives.
+void bt_host_note_report(int slot)
+{
+	if (slot >= 0 && slot < PP_NSLOT && s_bt_rep[slot] < 255)
+		s_bt_rep[slot]++;
+}
 static uint16_t s_hci_events;  // diagnostic: total HCI events seen (run-loop alive?)
 static uint8_t s_last_state;   // diagnostic: last BTSTACK_EVENT_STATE value
 static bool s_bt_init_ok;      // btstack_cyw43_init returned success
@@ -100,14 +110,6 @@ static int slot_by_handle(hci_con_handle_t h)
 			return i;
 	return -1;
 }
-static int slot_by_cid(uint16_t cid)
-{
-	for (int i = 0; i < PP_NSLOT; i++)
-		if (g_conn[i].active && !g_conn[i].is_classic &&
-		    g_conn[i].hids_cid == cid)
-			return i;
-	return -1;
-}
 static int slot_by_hid_cid(uint16_t cid)
 {
 	for (int i = 0; i < PP_NSLOT; i++)
@@ -131,6 +133,12 @@ static void slot_release(int slot)
 	puck_set_connected(slot, false);
 }
 
+// The input driver bound to a slot (used by the HOGP client in another TU).
+const input_driver_t *bt_slot_driver(int slot)
+{
+	return (slot >= 0 && slot < PP_NSLOT) ? g_conn[slot].drv : NULL;
+}
+
 static bool any_free_slot(void)
 {
 	for (int i = 0; i < PP_NSLOT; i++)
@@ -195,9 +203,8 @@ static void scan_ensure(void)
 }
 
 // ---- forward decl ----------------------------------------------------------
-static void hids_handler(uint8_t type, uint16_t channel, uint8_t *packet,
-			 uint16_t size);
-static void start_hids(int slot);
+static void resolve_name(int slot);
+static void route_connection(int slot);
 
 // ---- HCI events ------------------------------------------------------------
 static void handle_advertising_report(uint8_t *packet)
@@ -267,10 +274,10 @@ static void handle_advertising_report(uint8_t *packet)
 	}
 
 	// Connect if this is our pending pair target, or a bonded device coming back
-	// online while a slot is free (auto-reconnect). A driver match needs the
-	// name, so for auto-reconnect wait for a report that carries it.
+	// online while a slot is free (auto-reconnect). The name is resolved over
+	// GATT after connecting, so a nameless reconnect advert is fine here.
 	bool want = (s_pair_pending && memcmp(addr, s_pair_addr, 6) == 0);
-	if (!want && !s_connecting.busy && name[0] && any_free_slot() &&
+	if (!want && !s_connecting.busy && any_free_slot() &&
 	    addr_bonded_le(addr) && !addr_connected(addr))
 		want = true;
 	if (want && !s_connecting.busy) {
@@ -403,6 +410,8 @@ static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 			printf("[bt] disconnect slot %d\n", slot);
 			if (g_conn[slot].is_valve)
 				valve_disconnected(h);
+			else
+				hogp_disconnected(h);
 			slot_release(slot);
 			scan_ensure();  // resume reconnect scan
 		}
@@ -435,7 +444,7 @@ static void sm_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 			int slot = slot_by_handle(
 				sm_event_pairing_complete_get_handle(packet));
 			if (slot >= 0)
-				start_hids(slot);
+				resolve_name(slot);
 		}
 		break;
 	}
@@ -443,7 +452,7 @@ static void sm_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		int slot = slot_by_handle(
 			sm_event_reencryption_complete_get_handle(packet));
 		if (slot >= 0)
-			start_hids(slot);
+			resolve_name(slot);
 		break;
 	}
 	default:
@@ -451,82 +460,73 @@ static void sm_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 	}
 }
 
-static void start_hids(int slot)
+// Decide SC2-vs-HOGP from the (now known) name and start the right client.
+static void route_connection(int slot)
 {
 	conn_t *c = &g_conn[slot];
 	if (c->hid_started)
 		return;
 	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);
+	puck_set_connected(slot, true);
 
-	// SC2 speaks a proprietary Valve GATT service, not HOGP.
-	if (c->is_valve) {
-		c->drv = input_driver_match(0, 0, c->name, true);  // kind 1 (SC2)
-		puck_set_connected(slot, true);
-		valve_start(slot, c->handle);
-		return;
-	}
-
-	uint8_t r = hids_client_connect(c->handle, hids_handler,
-					HID_PROTOCOL_MODE_REPORT, &c->hids_cid);
-	if (r != ERROR_CODE_SUCCESS) {
-		printf("[bt] hids_client_connect failed %d\n", r);
-		c->hid_started = false;
-	}
+	if (c->is_valve)
+		valve_start(slot, c->handle);       // SC2 proprietary Valve GATT
+	else
+		hogp_start(slot, c->handle);        // standard HID-over-GATT (Xbox, …)
 }
 
-// ---- HID-over-GATT client events -------------------------------------------
-static void hids_handler(uint8_t type, uint16_t channel, uint8_t *packet,
-			 uint16_t size)
+// GAP Device Name read completion — controllers (Xbox especially) often omit the
+// name from the advertisement, and reconnect adverts always do, so read it over
+// GATT before routing. Falls through to routing on any result.
+static void name_read_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			      uint16_t size)
 {
 	(void)channel;
 	(void)size;
-	if (type != HCI_EVENT_PACKET ||
-	    hci_event_packet_get_type(packet) != HCI_EVENT_GATTSERVICE_META)
+	if (type != HCI_EVENT_PACKET)
 		return;
-
-	switch (hci_event_gattservice_meta_get_subevent_code(packet)) {
-	case GATTSERVICE_SUBEVENT_HID_SERVICE_CONNECTED: {
-		uint16_t cid = gattservice_subevent_hid_service_connected_get_hids_cid(packet);
-		uint8_t status = gattservice_subevent_hid_service_connected_get_status(packet);
-		int slot = slot_by_cid(cid);
+	uint8_t ev = hci_event_packet_get_type(packet);
+	if (ev == GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT) {
+		hci_con_handle_t h =
+			gatt_event_characteristic_value_query_result_get_handle(packet);
+		int slot = slot_by_handle(h);
 		if (slot < 0)
-			break;
-		conn_t *c = &g_conn[slot];
-		if (status != ERROR_CODE_SUCCESS) {
-			printf("[bt] HID connect failed slot %d status 0x%02X\n", slot,
-			       status);
-			gap_disconnect(c->handle);
-			break;
-		}
-		c->drv = input_driver_match(0, 0, c->name, c->is_ble);
-		if (!c->drv) {
-			printf("[bt] no driver for '%s' — dropping\n", c->name);
-			gap_disconnect(c->handle);
-			break;
-		}
-		printf("[bt] slot %d driver '%s' ready\n", slot, c->drv->name);
-		puck_set_connected(slot, true);
-		hids_client_enable_notifications(cid);
-		break;
-	}
-	case GATTSERVICE_SUBEVENT_HID_REPORT: {
-		uint16_t cid = gattservice_subevent_hid_report_get_hids_cid(packet);
-		int slot = slot_by_cid(cid);
-		if (slot < 0 || !g_conn[slot].drv || !g_conn[slot].drv->decode)
-			break;
-		// hids_client delivers [report_id][body...]; drivers want body-only.
-		const uint8_t *rep = gattservice_subevent_hid_report_get_report(packet);
-		uint16_t rlen = gattservice_subevent_hid_report_get_report_len(packet);
-		if (rlen < 1)
-			break;
-		if (g_conn[slot].drv->decode(rep[0], rep + 1, (uint16_t)(rlen - 1),
-					     &g_in[slot]))
-			puck_present_synth(slot);
-		break;
+			return;
+		uint16_t vlen =
+			gatt_event_characteristic_value_query_result_get_value_length(packet);
+		const uint8_t *val =
+			gatt_event_characteristic_value_query_result_get_value(packet);
+		uint8_t n = vlen < sizeof(g_conn[slot].name) - 1
+				    ? (uint8_t)vlen
+				    : sizeof(g_conn[slot].name) - 1;
+		memcpy(g_conn[slot].name, val, n);
+		g_conn[slot].name[n] = 0;
+	} else if (ev == GATT_EVENT_QUERY_COMPLETE) {
+		int slot = slot_by_handle(
+			gatt_event_query_complete_get_handle(packet));
+		if (slot >= 0)
+			route_connection(slot);
 	}
-	default:
-		break;
+}
+
+static void resolve_name(int slot)
+{
+	conn_t *c = &g_conn[slot];
+	if (c->hid_started)
+		return;
+	// If the advertisement already gave us a usable name, route immediately;
+	// otherwise read the GAP Device Name (0x2A00) characteristic first.
+	if (c->name[0]) {
+		route_connection(slot);
+		return;
 	}
+	uint8_t r = gatt_client_read_value_of_characteristics_by_uuid16(
+		name_read_handler, c->handle, 0x0001, 0xFFFF, 0x2A00);
+	if (r != ERROR_CODE_SUCCESS)
+		route_connection(slot);  // fall back: route with whatever we have
 }
 
 // ---- Classic HID host ------------------------------------------------------
@@ -572,6 +572,7 @@ static void classic_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 			break;
 		}
 		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);
 		break;
 	}
@@ -583,6 +584,8 @@ static void classic_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		uint16_t rlen = hid_subevent_report_get_report_len(packet);
 		if (rlen < 1)
 			break;
+		if (s_bt_rep[slot] < 255)
+			s_bt_rep[slot]++;
 		// Classic HID interrupt reports carry the report-id as byte 0.
 		if (g_conn[slot].drv->decode(r[0], r + 1, (uint16_t)(rlen - 1),
 					     &g_in[slot]))
@@ -619,7 +622,6 @@ bool bt_host_init(void)
 	sm_set_authentication_requirements(SM_AUTHREQ_SECURE_CONNECTION |
 					   SM_AUTHREQ_BONDING);
 	gatt_client_init();
-	hids_client_init(s_hid_desc_storage, sizeof(s_hid_desc_storage));
 
 	// Classic HID host (DS4/DS5/etc). Discoverable + connectable so Sony pads
 	// can initiate the connection inbound.
@@ -682,9 +684,7 @@ void bt_host_task(void)
 							 c->drv->rumble_report_id,
 							 buf, n);
 			else
-				hids_client_send_write_report(
-					c->hids_cid, c->drv->rumble_report_id,
-					HID_REPORT_TYPE_OUTPUT, buf, n);
+				hogp_send_output(i, buf, n);  // BLE HOGP rumble
 			c->rum_last_ms = t;
 			c->rum_pending = false;
 		}

+ 40 - 12
PicoPuck/src/bt/bt_valve.c

@@ -42,6 +42,9 @@ typedef enum {
 	V_READY,
 } valve_state_t;
 
+#define VALVE_WQ 8       // pending host→controller writes
+#define VALVE_WMAX 62    // max report bytes per write
+
 typedef struct {
 	valve_state_t state;
 	int slot;
@@ -53,10 +56,19 @@ typedef struct {
 	gatt_client_notification_t notify;
 	uint32_t last_keepalive_ms;
 	bool imu_enabled;
+
+	// Relayed feature/output reports awaiting a free GATT slot (one write in
+	// flight at a time). Drop-oldest when full so a stall can't wedge input.
+	uint8_t wq[VALVE_WQ][VALVE_WMAX];
+	uint8_t wq_len[VALVE_WQ];
+	uint8_t wq_head, wq_tail, wq_count;
 } valve_t;
 
 static valve_t s_valve[PP_NSLOT];
 
+// Diagnostic: GATT feature writes actually issued to SC2 controllers.
+volatile uint16_t g_valve_writes;
+
 static uint32_t now_ms(void)
 {
 	return to_ms_since_boot(get_absolute_time());
@@ -219,6 +231,18 @@ void valve_periodic(void)
 		if (!gatt_client_is_ready(v->handle))
 			continue;  // one GATT op at a time
 
+		// Relayed host writes (haptics/rumble/config) take priority for latency.
+		if (v->wq_count) {
+			uint8_t *rep = v->wq[v->wq_tail];
+			uint8_t rl = v->wq_len[v->wq_tail];
+			v->wq_tail = (uint8_t)((v->wq_tail + 1) % VALVE_WQ);
+			v->wq_count--;
+			gatt_client_write_value_of_characteristic(
+				write_cb, v->handle, v->report_value_handle, rl, rep);
+			g_valve_writes++;
+			continue;
+		}
+
 		// Enable IMU once — but only when Steam isn't configuring it itself
 		// (its own 0x87 writes are relayed through; don't fight them).
 		if (!v->imu_enabled && !puck_steam_active()) {
@@ -238,22 +262,26 @@ void valve_periodic(void)
 	}
 }
 
-void valve_feature_write(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len)
+// Queue the HID report [report_id][body] verbatim for the SC2's report
+// characteristic (drop-oldest if the queue is full). Drained in valve_periodic.
+void valve_feature_write(int slot, uint8_t report_id, const uint8_t *body, uint16_t len)
 {
 	if (slot < 0 || slot >= PP_NSLOT)
 		return;
 	valve_t *v = &s_valve[slot];
 	if (v->state != V_READY || v->report_value_handle == 0)
 		return;
-	if (!gatt_client_is_ready(v->handle))
-		return;  // busy → drop (Steam re-sends); keeps it simple and non-blocking
-	if (len > 60)
-		len = 60;
-	uint8_t buf[64];
-	buf[0] = cmd;
-	buf[1] = (uint8_t)len;
-	memcpy(buf + 2, payload, len);
-	gatt_client_write_value_of_characteristic(write_cb, v->handle,
-						  v->report_value_handle,
-						  (uint16_t)(len + 2), buf);
+	if (len > VALVE_WMAX - 1)
+		len = VALVE_WMAX - 1;
+
+	if (v->wq_count == VALVE_WQ) {
+		v->wq_tail = (uint8_t)((v->wq_tail + 1) % VALVE_WQ);
+		v->wq_count--;
+	}
+	uint8_t *dst = v->wq[v->wq_head];
+	dst[0] = report_id;
+	memcpy(dst + 1, body, len);
+	v->wq_len[v->wq_head] = (uint8_t)(len + 1);
+	v->wq_head = (uint8_t)((v->wq_head + 1) % VALVE_WQ);
+	v->wq_count++;
 }

+ 6 - 3
PicoPuck/src/bt/bt_valve.h

@@ -16,6 +16,9 @@
 #include <stdbool.h>
 #include "btstack.h"
 
+// Diagnostic (read by the WebUSB panel): GATT feature writes issued to SC2s.
+extern volatile uint16_t g_valve_writes;
+
 // Begin Valve GATT discovery for a freshly-secured SC2 on `slot`.
 void valve_start(int slot, hci_con_handle_t handle);
 
@@ -25,8 +28,8 @@ void valve_disconnected(hci_con_handle_t handle);
 // Periodic: resend the lizard-off keepalive (~2 s) and enable IMU once. Call each loop.
 void valve_periodic(void);
 
-// Relay one host command (cmd + payload) to the SC2's report characteristic as
-// the on-air feature form [cmd][len][payload].
-void valve_feature_write(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len);
+// Forward the HID report [report_id][body] verbatim to the SC2's report
+// characteristic (queued; drained one write at a time).
+void valve_feature_write(int slot, uint8_t report_id, const uint8_t *body, uint16_t len);
 
 #endif // PICOPUCK_BT_VALVE_H

+ 15 - 11
PicoPuck/src/bt/drv_xinput.c

@@ -61,23 +61,27 @@ static bool xi_decode(uint8_t rid, const uint8_t *d, uint16_t len,
 	uint8_t b2 = (len > 14) ? d[14] : 0;
 	uint8_t b3 = (len > 15) ? d[15] : 0;
 
+	// Real Xbox One S / Series Bluetooth HID button layout — NOT contiguous.
+	// Byte 13: A=b0 B=b1 X=b3 Y=b4 LB=b6 RB=b7 (b2/b5 unused).
+	// Byte 14: View=b2 Menu=b3 Guide=b4 LS=b5 RS=b6.
+	// Byte 15 (Series 1914): Share=b0.
 	uint32_t b = triton_hat_bits(d[12]);
 	if (b1 & 0x01) b |= TB_A;
 	if (b1 & 0x02) b |= TB_B;
-	if (b1 & 0x04) b |= TB_X;
-	if (b1 & 0x08) b |= TB_Y;
-	if (b1 & 0x10) b |= TB_LB;
-	if (b1 & 0x20) b |= TB_RB;
-	if (b1 & 0x40) b |= TB_VIEW;
-	if (b1 & 0x80) b |= TB_MENU;
+	if (b1 & 0x08) b |= TB_X;
+	if (b1 & 0x10) b |= TB_Y;
+	if (b1 & 0x40) b |= TB_LB;
+	if (b1 & 0x80) b |= TB_RB;
 	if (len > 14) {
-		if (b2 & 0x01) b |= TB_STEAM;  // fw 5.x: guide in-band
-		if (b2 & 0x02) b |= TB_L3;
-		if (b2 & 0x04) b |= TB_R3;
+		if (b2 & 0x04) b |= TB_VIEW;   // View / Select (⧉)
+		if (b2 & 0x08) b |= TB_MENU;   // Menu / 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
 	} else {
-		b |= io->buttons & TB_STEAM;  // 14-byte: guide latched from report 0x02
+		b |= io->buttons & TB_STEAM;  // 14-byte variant: guide on report 0x02
 	}
-	if (b3 & 0x01) b |= TB_QAM;  // Series share → QAM
+	if (b3 & 0x01) b |= TB_QAM;  // Series share → QAM (…)
 	if (lt >= 1000) b |= TB_L2;
 	if (rt >= 1000) b |= TB_R2;
 

+ 5 - 0
PicoPuck/src/bt/input_driver.c

@@ -25,5 +25,10 @@ const input_driver_t *input_driver_match(uint16_t vid, uint16_t pid,
 		if (d->match && d->match(vid, pid, devname, is_ble))
 			return d;
 	}
+	// Safety net: a BLE HOGP device we still can't name (GATT name read failed)
+	// is almost certainly the gamepad the user paired — the Xbox layout is the
+	// most common. SC2 is routed separately by the host, so it won't reach here.
+	if (is_ble)
+		return &drv_xinput;
 	return NULL;
 }

+ 95 - 8
PicoPuck/src/puck/personality.c

@@ -15,6 +15,7 @@
 #include "config/picopuck_config.h"
 
 #include <string.h>
+#include <stdio.h>
 #include "tusb.h"
 #include "pico/time.h"
 
@@ -25,6 +26,20 @@ static uint8_t s_conn_state[PP_NSLOT];  // last emitted 0x79 state (0 none/1 dis
 static uint32_t s_last_7b_ms[PP_NSLOT];
 static uint32_t s_last_43_ms[PP_NSLOT];
 
+// Diagnostics: per-slot feature GET/SET counts (does Steam poll this interface?)
+// and the last SET command byte (does Steam pair it via 0xA2 / send haptics?).
+static uint8_t s_get_n[PP_NSLOT];
+static uint8_t s_set_n[PP_NSLOT];
+static uint8_t s_last_set_cmd[PP_NSLOT];
+void puck_slot_io(int slot, uint8_t *g, uint8_t *s, uint8_t *last)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	if (g) *g = s_get_n[slot];
+	if (s) *s = s_set_n[slot];
+	if (last) *last = s_last_set_cmd[slot];
+}
+
 static inline uint32_t now_ms(void)
 {
 	return to_ms_since_boot(get_absolute_time());
@@ -54,21 +69,48 @@ bool puck_steam_active(void)
 }
 
 static uint8_t s_seq[PP_NSLOT];
+// Per-slot source: 0 none, 1 synth (generic pad — we must stream it), 2 raw (SC2
+// forwards its own continuous stream). s_last_emit_ms drives the synth streamer.
+static uint8_t s_src[PP_NSLOT];
+static uint32_t s_last_emit_ms[PP_NSLOT];
+
+// Continuous 0x45 stream cadence for synth slots (~125 Hz). A real SC2 streams
+// continuously; a HOGP pad only reports on change, so we re-emit its current
+// state at this rate to keep Steam's controller mounted and fed.
+#define SYNTH_STREAM_MS 8u
+
+static void emit_synth(int slot)
+{
+	// Build the 0x45 layout, then present it under BOTH main-input report ids:
+	// 0x45 (legacy fw) and 0x42 (SC2 beta fw ~2026-07, 53-byte body = the same
+	// layout plus 8 trailing zero bytes). Steam's driver parses only the id its
+	// firmware expects and ignores the other, so sending both covers both
+	// firmwares without us having to detect which. Content is identical, so even
+	// a driver that reads both just sees the same state.
+	uint8_t rep[1 + 53];
+	memset(rep, 0, sizeof(rep));
+	uint8_t seq = s_seq[slot]++;
+	uint32_t us = time_us_32();
+	puck_synth45(&g_in[slot], seq, us, rep);  // fills rep[0]=0x45, rep[1..45]
+	usb_tx_hid((uint8_t)slot, 0x45, rep + 1, PUCK45_LEN - 1);  // 45-byte body
+	usb_tx_hid((uint8_t)slot, 0x42, rep + 1, 53);              // 53-byte body
+	slot_note_input(slot, now_ms());
+	s_last_emit_ms[slot] = now_ms();
+}
 
 void puck_present_synth(int slot)
 {
 	if (slot < 0 || slot >= PP_NSLOT)
 		return;
-	uint8_t rep[PUCK45_LEN];
-	puck_synth45(&g_in[slot], s_seq[slot]++, time_us_32(), rep);
-	usb_tx_hid((uint8_t)slot, rep[0], rep + 1, PUCK45_LEN - 1);
-	slot_note_input(slot, now_ms());
+	s_src[slot] = 1;
+	emit_synth(slot);  // immediate on change; the streamer fills idle gaps
 }
 
 void puck_present_raw(int slot, const uint8_t *rep, uint8_t len)
 {
 	if (slot < 0 || slot >= PP_NSLOT || len < 1)
 		return;
+	s_src[slot] = 2;
 	usb_tx_hid((uint8_t)slot, rep[0], rep + 1, (uint16_t)(len - 1));
 	slot_note_input(slot, now_ms());
 }
@@ -78,8 +120,34 @@ void puck_set_connected(int slot, bool connected)
 	if (slot < 0 || slot >= PP_NSLOT)
 		return;
 	g_slot[slot].connected = connected;
-	if (!connected)
+	if (!connected) {
 		g_slot[slot].conn_reply_ms = 0;
+		s_src[slot] = 0;
+		// Drop the synthetic bond so a re-used slot starts clean.
+		g_slot[slot].used = false;
+		memset(g_slot[slot].rec, 0, PP_BOND_REC_LEN);
+	}
+}
+
+void puck_set_bond(int slot, const uint8_t addr[6], uint8_t kind)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	puck_slot_t *S = &g_slot[slot];
+	memset(S->rec, 0, PP_BOND_REC_LEN);
+	// 8-byte uuid (kind + address) then a 16-byte SC2-style serial. Steam keys
+	// the slot on the serial, so it must be unique per controller and identical
+	// between the 0xA3 (bond) and 0xAE (controller serial) answers.
+	S->rec[0] = kind;
+	memcpy(S->rec + 1, addr, 6);
+	uint32_t h = 2166136261u;
+	for (int i = 0; i < 6; i++)
+		h = (h ^ addr[i]) * 16777619u;
+	char serial[17];
+	snprintf(serial, sizeof serial, "FXA99602%05lX",
+		 (unsigned long)(h & 0xFFFFF));
+	memcpy(S->rec + 8, serial, 16);
+	S->used = true;
 }
 
 // ---- feature / output writes (host → puck/controller) ----------------------
@@ -90,12 +158,19 @@ static void handle_set(int slot, uint8_t rid, hid_report_type_t type,
 		return;
 	puck_slot_t *S = &g_slot[slot];
 
+	if (s_set_n[slot] < 255)
+		s_set_n[slot]++;
+	s_last_set_cmd[slot] =
+		(type == HID_REPORT_TYPE_FEATURE && n >= 1) ? b[0] : rid;
+
 	// OUTPUT reports 0x80-0x89: haptics/actuators. Relay the 0x80-0x86 range to
 	// the bound controller; the rest are config that rides the feature path.
 	if (type == HID_REPORT_TYPE_OUTPUT) {
 		if (rid >= 0x80 && rid <= 0x89)
 			s_steam_alive_ms = now_ms();
-		if (rid >= 0x80 && rid <= 0x86 && n >= 1)
+		// Forward the whole actuator/config output range verbatim (the SC2 is a
+		// real controller; a generic pad's relay only acts on 0x80 rumble).
+		if (rid >= 0x80 && rid <= 0x89 && n >= 1)
 			relay_enqueue(slot, rid, b, n);
 		return;
 	}
@@ -120,8 +195,12 @@ static void handle_set(int slot, uint8_t rid, hid_report_type_t type,
 			 cmd == 0xA2 || cmd == 0xA3 || cmd == 0xAD ||
 			 cmd == 0xB4 || cmd == 0xED || cmd == 0xA4);
 		if (!local_answer) {
-			uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
-			relay_enqueue(slot, cmd, pl, rl);
+			// Forward the whole report verbatim: report id = cmd, body =
+			// everything after it (b[1..]). relay_enqueue's contract is
+			// "send HID report [report_id][body] to the controller", so no
+			// length byte is re-inserted downstream. (For a 0x87 settings
+			// write b[1] is the setting-count byte, which stays in the body.)
+			relay_enqueue(slot, cmd, b + 1, (uint16_t)(n - 1));
 		}
 	}
 
@@ -230,6 +309,8 @@ static uint16_t handle_get(int slot, uint8_t rid, hid_report_type_t type,
 	(void)rid;
 	if (type != HID_REPORT_TYPE_FEATURE || slot < 0 || slot >= PP_NSLOT)
 		return 0;
+	if (s_get_n[slot] < 255)
+		s_get_n[slot]++;
 	puck_slot_t *S = &g_slot[slot];
 	uint16_t n = S->resp_len ? S->resp_len : 63;
 	if (n > reqlen)
@@ -262,6 +343,12 @@ void puck_personality_task(void)
 	uint32_t t = now_ms();
 
 	for (int s = 0; s < PP_NSLOT; s++) {
+		// Keep synth (generic-pad) slots streaming so Steam sees a continuous
+		// controller like a real SC2 does, not just on-change bursts.
+		if (s_src[s] == 1 && g_slot[s].connected &&
+		    (t - s_last_emit_ms[s]) >= SYNTH_STREAM_MS)
+			emit_synth(s);
+
 		bool live = slot_is_live(s, t);
 		uint8_t state = live ? 0x02 : 0x01;
 

+ 9 - 0
PicoPuck/src/puck/personality.h

@@ -30,4 +30,13 @@ void puck_present_raw(int slot, const uint8_t *rep, uint8_t len);
 // Mark a slot connected/disconnected (BT link up/down).
 void puck_set_connected(int slot, bool connected);
 
+// Diagnostics: per-slot feature GET/SET counts + last SET command byte, so the
+// panel can show whether Steam is polling/pairing each slot's interface.
+void puck_slot_io(int slot, uint8_t *get_n, uint8_t *set_n, uint8_t *last_set_cmd);
+
+// Give a slot a synthetic bond record with a UNIQUE serial derived from the
+// controller's BT address, so Steam mounts each connected controller as a
+// distinct device (0xA3 bond read + 0xAE serial read return this identity).
+void puck_set_bond(int slot, const uint8_t addr[6], uint8_t kind);
+
 #endif // PICOPUCK_PERSONALITY_H

+ 13 - 6
PicoPuck/src/puck/relay.c

@@ -7,16 +7,23 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "puck/relay.h"
 
-__attribute__((weak)) void bt_relay(int slot, uint8_t cmd, const uint8_t *payload,
-				    uint16_t len)
+// Diagnostics: how many host→controller relays we've seen, and the last report
+// id. Lets the panel confirm Steam is actually sending haptics/rumble.
+volatile uint16_t g_relay_count;
+volatile uint8_t g_relay_last_id;
+
+__attribute__((weak)) void bt_relay(int slot, uint8_t report_id,
+				    const uint8_t *body, uint16_t len)
 {
 	(void)slot;
-	(void)cmd;
-	(void)payload;
+	(void)report_id;
+	(void)body;
 	(void)len;
 }
 
-void relay_enqueue(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len)
+void relay_enqueue(int slot, uint8_t report_id, const uint8_t *body, uint16_t len)
 {
-	bt_relay(slot, cmd, payload, len);
+	g_relay_count++;
+	g_relay_last_id = report_id;
+	bt_relay(slot, report_id, body, len);
 }

+ 11 - 2
PicoPuck/src/puck/relay.h

@@ -14,7 +14,16 @@
 
 #include <stdint.h>
 
-// Queue one command (cmd byte + payload) for the controller bound to `slot`.
-void relay_enqueue(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len);
+#include <stdint.h>
+
+// Diagnostics (read by the WebUSB panel): total relays and the last report id.
+extern volatile uint16_t g_relay_count;
+extern volatile uint8_t g_relay_last_id;
+
+// 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
+// exact bytes that should land on the controller after the report-id.
+void relay_enqueue(int slot, uint8_t report_id, const uint8_t *body, uint16_t len);
 
 #endif // PICOPUCK_RELAY_H

+ 5 - 3
PicoPuck/src/puck/slots.c

@@ -22,11 +22,13 @@ void slots_init(void)
 
 bool slot_is_live(int slot, uint32_t now_ms)
 {
+	(void)now_ms;
 	if (slot < 0 || slot >= PP_NSLOT)
 		return false;
-	const puck_slot_t *s = &g_slot[slot];
-	return s->connected && s->conn_reply_ms &&
-	       (now_ms - s->conn_reply_ms < SLOT_LIVE_WINDOW_MS);
+	// The BT stack manages connect/disconnect explicitly, so "live" == the link
+	// is up. (Unlike the nRF puck's RF link, absence of recent input does NOT
+	// mean disconnected — a HOGP pad only reports on change.)
+	return g_slot[slot].connected;
 }
 
 void slot_note_input(int slot, uint32_t now_ms)

+ 7 - 0
PicoPuck/src/usb/usb_tx.c

@@ -24,6 +24,11 @@ typedef struct {
 } tx_ring_t;
 
 static tx_ring_t s_ring[PP_NSLOT];
+static uint8_t s_sent[PP_NSLOT];  // diagnostic: reports Steam actually drained
+uint8_t usb_tx_count(int slot)
+{
+	return (slot >= 0 && slot < PP_NSLOT) ? s_sent[slot] : 0;
+}
 
 bool usb_tx_hid(uint8_t inst, uint8_t report_id, const uint8_t *body, uint16_t len)
 {
@@ -61,6 +66,8 @@ void usb_tx_pump(void)
 		if (tud_hid_n_report(inst, m->report_id, m->body, m->len)) {
 			r->tail = (uint8_t)((r->tail + 1) % TX_RING_DEPTH);
 			r->count--;
+			if (s_sent[inst] < 255)
+				s_sent[inst]++;
 		}
 		// If the send failed, leave it queued and retry next pump.
 	}

+ 4 - 0
PicoPuck/src/usb/usb_tx.h

@@ -20,4 +20,8 @@ bool usb_tx_hid(uint8_t inst, uint8_t report_id, const uint8_t *body, uint16_t l
 // Send queued reports for every instance that is ready. Call each loop.
 void usb_tx_pump(void);
 
+// Diagnostic: count of reports Steam has drained from a slot's endpoint
+// (saturating at 255). Nonzero ⇒ Steam is reading that slot's input.
+uint8_t usb_tx_count(int slot);
+
 #endif // PICOPUCK_USB_TX_H

+ 33 - 2
PicoPuck/src/usb/webusb.c

@@ -18,8 +18,16 @@
 #include "usb/webusb.h"
 #include "config/picopuck_config.h"
 #include "puck/slots.h"
+#include "puck/relay.h"
+#include "puck/personality.h"
+#include "usb/usb_tx.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"
@@ -55,7 +63,7 @@ static void send_frame(const uint8_t *frame, uint16_t len)
 
 static void send_status(void)
 {
-	uint8_t f[2 + 17 + PP_NSLOT * SLOT_REC_LEN + 6];
+	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
@@ -104,6 +112,24 @@ static void send_status(void)
 	*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
+	}
 
 	*plen = (uint8_t)(p - start);
 	send_frame(f, (uint16_t)(p - f));
@@ -184,8 +210,13 @@ static void dispatch(const uint8_t *c, uint8_t n)
 		watchdog_reboot(0, 0, 10);
 		break;
 	case 0x0B:
-		if (c[1] == 0x42 && c[2] == 0x4C)
+		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);
+		}
 		break;
 	case 0x0C:
 		if (c[1] == 0xDE && c[2] == 0xAD)

+ 17 - 1
docs/index.html

@@ -1152,12 +1152,28 @@ function applyPicoPuckStatus(p){
   // Diagnostics tail after the slot records: [advSeen lo][hi][flags].
   const dg=17+nSlot*26;
   let diag="";
-  if(p.length>=dg+6){
+  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"}`;