Explorar o código

All inputs work now

Jariullah Safi hai 4 meses
pai
achega
fe61a21312

+ 2 - 0
OpenController/OpenController.ino

@@ -79,4 +79,6 @@ void loop()
 	ctrlLinkTask();
 	// report bond + link state back to the Deck UI
 	deckStatusTask();
+	// flush the newest relayed haptic to the Deck (rate-limited, off the RF path)
+	deckHapticTask();
 }

+ 22 - 0
OpenController/ctrl_bonds.cpp

@@ -56,3 +56,25 @@ int ctrlBondMatch(const uint8_t *proteus_uuid, const uint8_t *ibex_uuid)
 	}
 	return -1;
 }
+
+int ctrlBondFindOrAlloc(const uint8_t *rec24)
+{
+	// re-writing a known puck (same 8-byte uuid key) updates its slot in place
+	for (int i = 0; i < NBOND; i++)
+		if (g_bond[i].used && memcmp(g_bond[i].rec, rec24, 8) == 0)
+			return i;
+	// otherwise take the first free slot
+	for (int i = 0; i < NBOND; i++)
+		if (!g_bond[i].used)
+			return i;
+	return -1; // bond table full
+}
+
+void ctrlBondClear(int slot)
+{
+	if (slot < 0 || slot >= NBOND)
+		return;
+	g_bond[slot].used = false;
+	memset(g_bond[slot].rec, 0, 24);
+	g_bondDirty = true;
+}

+ 9 - 3
OpenController/ctrl_bonds.h

@@ -1,15 +1,17 @@
 // ctrl_bonds.h -- the emulated controller's bonds to pucks.
 //
 // A real Steam Controller bonds to up to two pucks under the Zephyr settings keys `esb/bond` (slot 0)
-// and `esb/bond_2` (slot 1). We mirror that: two slots, each holding the 24-byte bond record
+// and `esb/bond_2` (slot 1). We support MORE: an array of slots, each holding the 24-byte bond record
 //   rec = [proteus_uuid 4 LE][ibex_uuid 4 LE][puck_serial 16]
 // -- the SAME 24-byte shape Steam writes to the puck via 0xA2, except the trailing serial is the
 // PUCK's (the peer), not the controller's. The 8-byte uuid key is identical on both sides and is what
-// matches the puck's E1 host frame at reconnect. Persists to LittleFS `/ctrlbond.bin`.
+// matches the puck's E1 host frame at reconnect. Pairing a new puck find-or-allocates a free slot by
+// uuid (so an arbitrary number can be bonded); the Deck GUI can clear a slot. Persists to LittleFS
+// `/ctrlbond.bin`.
 #pragma once
 #include <stdint.h>
 
-#define NBOND 2
+#define NBOND 8
 
 struct CtrlBond {
 	uint8_t rec[24]; // [proteus_uuid 4][ibex_uuid 4][puck_serial 16]
@@ -26,3 +28,7 @@ void loadCtrlBonds();
 void saveCtrlBonds();
 // Find a bond slot matching a puck E1 host frame's [proteus_uuid 4][ibex_uuid 4]; -1 if none.
 int ctrlBondMatch(const uint8_t *proteus_uuid, const uint8_t *ibex_uuid);
+// Slot for a 24-byte record: reuse the slot whose uuid (rec[0..8]) matches, else first free; -1 if full.
+int ctrlBondFindOrAlloc(const uint8_t *rec24);
+// Clear (un-bond) a slot and mark the store dirty. Out-of-range slots are ignored.
+void ctrlBondClear(int slot);

+ 195 - 5
OpenController/ctrl_link.cpp

@@ -1,6 +1,7 @@
 #include "ctrl_link.h"
 #include "radio.h"
 #include "ctrl_bonds.h"
+#include "ctrl_usb.h"
 #include "report45.h"
 #include "deck_input.h"
 #include "triton.h"
@@ -38,6 +39,21 @@ static unsigned long g_huntStepMs = 0;
 #define RECONNECT_MS \
 	4000u // dry this long -> abandon the session, re-catch E1 on ch2
 
+// connected-mode frame-type diagnostics (reset + printed each second in the status line)
+static uint16_t g_cntE2, g_cntE3, g_cntE7, g_cntE4, g_cntOut, g_cntOther;
+// of the E3 polls: how many were bare/0x45 INPUT polls vs feature/identity GETs (0x83/0xAE/0x87). Tells
+// us whether the puck is actually polling for input or stuck looping enumeration GETs.
+static uint16_t g_cntE3in, g_cntE3get;
+// last non-0x45 report id the puck asked for via an E3 GET sub-TLV (0 = none)
+static uint8_t g_getReq = 0;
+// distinct channels seen in E4 channel-map commands this second (128-bit) -> reveals the hop pattern:
+// a small fixed set is followable; a large/changing set is the unsolved fast-AFH sequence.
+static uint8_t g_e4set[16];
+// 256-bit: frame types we've already dumped a sample of
+static uint8_t g_seenDump[32];
+// 256-bit: GET report ids we've dumped a poll+response sample for
+static uint8_t g_seenReq[32];
+
 // ---- build replies into rftx (ESB DPL RAM layout: [LENGTH][S1][payload...]) ----
 static void buildF1()
 {
@@ -58,16 +74,39 @@ static void buildF1()
 	}
 }
 
+// Build an F1 carrying a feature/command RESPONSE (type-6 TLV value = [cmd][len][payload], the
+// command-channel form a real controller returns -- WITH the inner length byte, unlike the 0x45 input
+// report) for when the puck relays one of Steam's enumeration GETs (0x83 attrs / 0xAE serial / 0x87
+// settings) over RF. The puck relays this value straight back to Steam as the feature response.
+static void buildFeatureF1(uint8_t reqid, const uint8_t *param, uint8_t plen)
+{
+	rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
+	rftx[2] = 0xF1;
+	rftx[4] = 6; // TLV type 6
+	uint8_t v[64];
+	uint8_t vlen = ctrlFeatureResp(reqid, param, plen, v);
+	if (vlen > 60)
+		vlen = 60;
+	rftx[3] = vlen; // TLV len
+	memcpy(rftx + 5, v, vlen);
+	rftx[0] = (uint8_t)(3 + vlen); // LENGTH (payload byte count)
+}
+
 static void buildF3()
 {
-	// reply to E7 awake/version probe: rf_link reads the version at rfrx[6] (payload byte 4).
+	// Reply to the puck's E7 awake/version probe with a STATUS report. The real puck's RX parser
+	// (proteus FUN_0000ba38, F3 branch) reads the connected-state byte at payload[1] (== rftx[3]) and
+	// only raises its internal "controller connected" signal if that byte is NONZERO -- so leaving it 0
+	// makes the puck bond us but never ACTIVATE us (no enumeration). Set payload[1]=1 (connected). Also
+	// set payload[4] (rftx[6]=1) which is where OpenPuck reads its F3 version, so this works both ways.
 	rftx[0] = 5;
 	rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
 	rftx[2] = 0xF3;
-	rftx[3] = 0;
+	// payload[1] = connected state (real puck reads param_2[6]) -- MUST be nonzero
+	rftx[3] = 1;
 	rftx[4] = 0;
 	rftx[5] = 0;
-	rftx[6] = 1; // protocol/version byte
+	rftx[6] = 1; // payload[4] = version (OpenPuck reads rfrx[6])
 }
 
 // Transmit whatever is staged in rftx on the currently-configured channel/address, then return to RX
@@ -111,17 +150,138 @@ static uint8_t rxOnce(const uint8_t *base, uint8_t prefix, uint8_t ch,
 		if (crcok && rxlen && rxlen <= 96) {
 			type = rfrx[2];
 			if (reply) {
+				// DIAGNOSTIC: tally what the puck polls us with, and dump a sample of any frame we
+				// DON'T answer with input -- this is how we find identity/feature queries (the likely
+				// reason Steam can't enumerate the controller). Surfaced in the 1Hz status line.
+				if (type == 0xE2)
+					g_cntE2++;
+				else if (type == 0xE3) {
+					g_cntE3++;
+					// E3 may carry a GET sub-TLV [len][01=GET][reportid]; split input vs feature GET
+					if (rxlen >= 4 && rfrx[4] == 0x01 &&
+					    rfrx[5] != 0x45) {
+						g_getReq = rfrx[5];
+						g_cntE3get++;
+					} else
+						g_cntE3in++;
+				} else if (type == 0xE7)
+					g_cntE7++;
+				else if (type == 0xE4)
+					g_cntE4++;
+				else if (type >= 0x80 && type <= 0x89)
+					g_cntOut++;
+				else if (type != 0xE1)
+					g_cntOther++;
+				// dump first sample of any non-input-poll type (E1/E4/E7/output/other)
+				if (type != 0xE2 && type != 0xE3 &&
+				    !(g_seenDump[type >> 3] &
+				      (1 << (type & 7)))) {
+					g_seenDump[type >> 3] |=
+						(1 << (type & 7));
+					if (Serial.availableForWrite() > 70) {
+						Serial.printf(
+							"# RFtype %02X len%u:",
+							type, rxlen);
+						uint8_t m = rxlen + 2 < 26 ?
+								    rxlen + 2 :
+								    26;
+						for (uint8_t i = 0; i < m; i++)
+							Serial.printf("%02X",
+								      rfrx[i]);
+						Serial.println();
+					}
+				}
+
 				// E1 = host-frame keepalive (no reply expected); follow channel hops.
 				if (type == 0xE1) {
 					uint8_t newCh = rfrx[11];
 					if (newCh && newCh != g_sessCh)
 						g_sessCh = newCh;
+				} else if (type == 0xE4) {
+					// channel-map update [E4][ch][02][50]. Once we report connected (F3), the
+					// real puck switches to SYNCHRONIZED freq+ADDRESS hopping: per RF_CONNECTED.md
+					// it rotates (base,prefix) AND channel each timeslot from a sequence seeded by
+					// the session address -- the channel-map generator the RE never cracked. E4's
+					// [ch] is only the channel; we'd also need to rotate the prefix in lockstep, so
+					// jumping to E4's channel alone lands us on the WRONG ADDRESS -> rx=0 -> flap.
+					// So DON'T chase it -- just record it; the hunt/park logic re-finds the puck.
+					// (Real-puck connected mode needs that hop generator; use an OpenPuck for a
+					// working single-channel link.)
+					uint8_t nc = rfrx[3];
+					if (rxlen >= 2 && nc >= 1 && nc <= 80)
+						g_e4set[nc >> 3] |=
+							(1 << (nc & 7));
+				} else if (!deckForwarding()) {
+					// NOT forwarding -> stay SILENT (no F3/F1). We still hear the puck's polls
+					// (so the UI shows the puck "available"), but we never answer, so the puck
+					// never marks us connected and the HOST shows no controller. The controller
+					// only appears once the user taps to forward (deckForwarding() -> true).
 				} else if (type == 0xE7) {
 					buildF3();
 					txStaged();
 				} else if (type >= 0xE0 && type <= 0xEF) {
-					// E3 GET (and any other E-type poll): answer with input.
-					buildF1();
+					// E3 GET: if it requests a NON-0x45 report (Steam enumeration -> 0x83
+					// attrs / 0xAE serial / 0x87 settings), answer THAT report; a bare poll
+					// or a 0x45 request gets the input report.
+					if (type == 0xE3 && rxlen >= 4 &&
+					    rfrx[4] == 0x05) {
+						// Relayed Steam OUTPUT (SET): E3 [tlvlen][05][rid][data]. The puck
+						// forwards Steam's rumble/haptic/LED writes this way (legacy type-05
+						// form). Hand rumble (0x80) / haptic pulses (0x82/0x8F) to the Deck so
+						// it buzzes; relays are NO_ACK so we DON'T reply.
+						uint8_t rl =
+							(rfrx[3] >= 1) ?
+								(uint8_t)(rfrx[3] -
+									  1) :
+								0;
+						deckForwardHaptic(rfrx[5],
+								  &rfrx[6], rl);
+					} else if (type == 0xE3 && rxlen >= 4 &&
+						   rfrx[4] == 0x01 &&
+						   rfrx[5] != 0x45) {
+						uint8_t plen =
+							(rxlen >= 5) ?
+								(uint8_t)(rxlen -
+									  4) :
+								0;
+						buildFeatureF1(rfrx[5],
+							       &rfrx[6], plen);
+						// dump the GET poll + our response once per reqid (verify framing)
+						uint8_t rq = rfrx[5];
+						if (!(g_seenReq[rq >> 3] &
+						      (1 << (rq & 7))) &&
+						    Serial.availableForWrite() >
+							    100) {
+							g_seenReq[rq >> 3] |=
+								(1 << (rq & 7));
+							Serial.printf(
+								"# GET %02X poll[",
+								rq);
+							uint8_t m =
+								rxlen + 2 < 20 ?
+									rxlen + 2 :
+									20;
+							for (uint8_t i = 0;
+							     i < m; i++)
+								Serial.printf(
+									"%02X",
+									rfrx[i]);
+							Serial.print("] resp[");
+							uint8_t r =
+								rftx[0] + 2 < 30 ?
+									rftx[0] +
+										2 :
+									30;
+							for (uint8_t i = 0;
+							     i < r; i++)
+								Serial.printf(
+									"%02X",
+									rftx[i]);
+							Serial.println("]");
+						}
+					} else {
+						buildF1();
+					}
 					txStaged();
 				}
 			}
@@ -218,7 +378,37 @@ void ctrlLinkTask()
 				ctrlLinkUp() ? "CONNECTED" : "searching",
 				g_sessCh, g_parkCh,
 				(unsigned long)(g_rfRxCount - lastRx));
+			// poll-type histogram. e3in = INPUT polls (we send 0x45); e3get = identity/feature GETs.
+			// If e3in stays ~0 while e3get is high, the puck is stuck enumerating and never asks for
+			// input -> the controller can't show as active no matter what we send.
+			Serial.printf(
+				"# polls e3in=%u e3get=%u e2=%u e7=%u e4=%u out=%u oth=%u getReq=%02X\n",
+				g_cntE3in, g_cntE3get, g_cntE2, g_cntE7,
+				g_cntE4, g_cntOut, g_cntOther, g_getReq);
+			// if the puck flooded E4 this second, list the distinct channels it commanded
+			if (g_cntE4) {
+				char cb[160];
+				int n = 0;
+				for (int c = 0;
+				     c <= 100 && n < (int)sizeof(cb) - 5; c++)
+					if (g_e4set[c >> 3] & (1 << (c & 7)))
+						n += snprintf(cb + n,
+							      sizeof(cb) - n,
+							      "%d ", c);
+				cb[n] = 0;
+				Serial.printf("# E4 channels: %s\n", cb);
+			}
+			memset(g_e4set, 0, sizeof g_e4set);
+			g_cntE2 = g_cntE3 = g_cntE7 = g_cntE4 = g_cntOut =
+				g_cntOther = g_cntE3in = g_cntE3get = 0;
 			lastRx = g_rfRxCount;
+			// re-arm the one-shot frame/GET dumps every ~8s so they stay capturable in the app log
+			static uint8_t reArm = 0;
+			if (++reArm >= 8) {
+				reArm = 0;
+				memset(g_seenReq, 0, sizeof g_seenReq);
+				memset(g_seenDump, 0, sizeof g_seenDump);
+			}
 		}
 	}
 

+ 55 - 5
OpenController/ctrl_usb.cpp

@@ -72,6 +72,54 @@ static void writeBond(int slot, const uint8_t *rec24)
 	g_bondDirty = true;
 }
 
+// 0xAE string attribute by index, matching a real controller: 0=board serial, 1=unit serial (both our
+// unique generated values), 3=the Valve constant "7054257d2da7" Steam checks, everything else "NA".
+static const char *aeString(uint8_t idx)
+{
+	return (idx == 0) ? g_board :
+	       (idx == 1) ? g_unit :
+	       (idx == 3) ? "7054257d2da7" :
+			    "NA";
+}
+
+// Build the controller's reply to a feature/command GET as [cmd][len][payload] -- the SAME shape a real
+// controller returns over USB (`83 19 …`, `AE 14 idx …`), confirmed by scmd. The inner length byte IS
+// required (these are command-channel replies, NOT the 0x45 input report). The puck relays this value
+// straight back to Steam as the feature response. ctrl_link wraps it in the F1 type-6 TLV. Returns the
+// byte count written to out (out must hold >= 63).
+uint8_t ctrlFeatureResp(uint8_t cmd, const uint8_t *param, uint8_t plen,
+			uint8_t *out)
+{
+	switch (cmd) {
+	case 0x83: { // product/attribute blob: [0x83][len][ATTR83...]
+		uint8_t alen = ATTR83_LEN > 58 ? 58 : (uint8_t)ATTR83_LEN;
+		out[0] = 0x83;
+		out[1] = alen;
+		memcpy(out + 2, ATTR83, alen);
+		return (uint8_t)(2 + alen);
+	}
+	case 0xAE: { // string attr: [0xAE][0x14][idx][serial 19]
+		uint8_t idx = plen > 0 ? param[0] : 1;
+		const char *s = aeString(idx);
+		out[0] = 0xAE;
+		out[1] = 0x14;
+		out[2] = idx;
+		memset(out + 3, 0, 0x13);
+		memcpy(out + 3, s, strlen(s) < 0x13 ? strlen(s) : 0x13);
+		return (uint8_t)(2 + 0x14);
+	}
+	case 0xB4: // wireless transport state: [0xB4][len=1][state]
+		out[0] = 0xB4;
+		out[1] = 0x01;
+		out[2] = ctrlLinkUp() ? 0x02 : 0x01;
+		return 3;
+	default: // unknown query -> [cmd][len=0]
+		out[0] = cmd;
+		out[1] = 0;
+		return 2;
+	}
+}
+
 // Feature SET: framing [cmd][len][payload...]. rid (1 or 2) is not significant for our commands.
 static void handleSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 		      uint16_t n)
@@ -105,11 +153,10 @@ static void handleSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 		g_resp_len = 63;
 		break;
 	}
-	case 0xAE: { // string attributes: 0=board, 1=unit serial
+	// string attributes: 0=board, 1=unit serial, 3="7054257d2da7"
+	case 0xAE: {
 		uint8_t idx = pln > 0 ? pl[0] : 1;
-		const char *s = (idx == 0) ? g_board :
-				(idx == 1) ? g_unit :
-					     "NA";
+		const char *s = aeString(idx);
 		g_resp[0] = 0xAE;
 		g_resp[1] = 0x14;
 		g_resp[2] = idx;
@@ -132,7 +179,10 @@ static void handleSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 		const uint8_t *rec = pl + k + 1; // skip the NUL
 		uint16_t reclen =
 			(pln > (uint16_t)(k + 1)) ? (uint16_t)(pln - k - 1) : 0;
-		int sk = slotForKey(key);
+		// Find-or-allocate by the record's uuid key rather than the Zephyr settings key, so we can
+		// bond an arbitrary number of pucks (not just the two legacy esb/bond[_2] keys).
+		int sk = (reclen >= 24) ? ctrlBondFindOrAlloc(rec) :
+					  slotForKey(key);
 		if (reclen >= 24)
 			writeBond(sk, rec);
 		if (Serial.availableForWrite() > 40)

+ 8 - 0
OpenController/ctrl_usb.h

@@ -9,8 +9,16 @@
 //   0x95  reboot into wireless mode  (magic 0xA427AF52) -> NVIC_SystemReset after the flash flush
 // See protocol/USB_COMMANDS.md for the full steamless flow.
 #pragma once
+#include <stdint.h>
 
 // register the HID interface (call from setup, before USBDevice.attach)
 void ctrlUsbBegin();
 // flush dirty bonds + honor a pending reboot (call each loop)
 void ctrlUsbPoll();
+
+// Build the controller's reply to a feature/command GET (0x83 attrs, 0xAE serial, 0xB4 wireless state)
+// as [cmd][len][payload] -- the command-channel form a real controller returns over USB (WITH the inner
+// length byte). ctrl_link uses this to answer the queries the puck relays over RF as E3 GETs (Steam's
+// enumeration). `param`/`plen` are the GET parameters (e.g. the 0xAE index). Returns out length.
+uint8_t ctrlFeatureResp(uint8_t cmd, const uint8_t *param, uint8_t plen,
+			uint8_t *out);

+ 91 - 15
OpenController/deck_input.cpp

@@ -30,9 +30,13 @@ static inline uint32_t rd_u32(const uint8_t *p)
 	return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
 	       ((uint32_t)p[3] << 24);
 }
+static inline uint16_t rd_u16(const uint8_t *p)
+{
+	return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
+}
 
-// INPUT payload = buttons u32, lx ly rx ry s16, lt rt u8, lpx lpy rpx rpy s16, ax ay az gx gy gz s16.
-#define DECK_INPUT_LEN 34
+// INPUT payload = buttons u32, lx ly rx ry s16, lt rt u8, lpx lpy rpx rpy s16, lpp rpp u16, IMU 6h.
+#define DECK_INPUT_LEN 38
 static void applyInput(const uint8_t *p, uint8_t n)
 {
 	if (n < DECK_INPUT_LEN)
@@ -48,12 +52,14 @@ static void applyInput(const uint8_t *p, uint8_t n)
 	g_in.lpy = rd_s16(p + 16);
 	g_in.rpx = rd_s16(p + 18);
 	g_in.rpy = rd_s16(p + 20);
-	g_in.ax = rd_s16(p + 22);
-	g_in.ay = rd_s16(p + 24);
-	g_in.az = rd_s16(p + 26);
-	g_in.gx = rd_s16(p + 28);
-	g_in.gy = rd_s16(p + 30);
-	g_in.gz = rd_s16(p + 32);
+	g_in.lpp = rd_u16(p + 22);
+	g_in.rpp = rd_u16(p + 24);
+	g_in.ax = rd_s16(p + 26);
+	g_in.ay = rd_s16(p + 28);
+	g_in.az = rd_s16(p + 30);
+	g_in.gx = rd_s16(p + 32);
+	g_in.gy = rd_s16(p + 34);
+	g_in.gz = rd_s16(p + 36);
 	g_lastInputMs = millis();
 }
 
@@ -66,6 +72,9 @@ static void handleFrame(uint8_t type, const uint8_t *p, uint8_t n)
 	case DECK_T_CONTROL:
 		if (n >= 2 && p[0] == 0x01)
 			g_forwarding = (p[1] != 0);
+		else if (n >= 2 &&
+			 p[0] == 0x02) // clear-bond [slot] (GUI "remove")
+			ctrlBondClear(p[1]);
 		break;
 	case DECK_T_REQ_STATUS:
 		g_lastStatusMs = 0; // force an immediate status emit
@@ -146,29 +155,96 @@ void deckText(const char *s)
 	sendFrame(DECK_T_TEXT, (const uint8_t *)s, (uint8_t)n);
 }
 
-// STATUS payload: [flags][linkSlot][sessCh][nbond] + per-bond [used][alive][puuid4][iuuid4][serial16]
+// Latest relayed haptic, STAGED by deckForwardHaptic (called from the RF poll path) and flushed over CDC
+// by deckHapticTask() at loop level. Staging-then-flushing (a) keeps the CDC write OUT of the RF receive
+// path, and (b) COALESCES a burst to the newest state -- Steam streams rumble at the poll rate, and
+// replaying every frame one-behind is what caused the lag; newest-wins removes the backlog.
+static uint8_t g_hapPayload[8];
+static volatile bool g_hapPending = false;
+static unsigned long g_lastHapMs = 0;
+// cap CDC haptic frames to ~60 Hz: enough for smooth rumble, but no flood of the CDC link / Deck USB.
+#define HAPTIC_MIN_MS 16u
+
+void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n)
+{
+	// Only while forwarding -- otherwise the Deck isn't grabbed and there's nothing to buzz.
+	if (!g_forwarding)
+		return;
+	uint16_t intensity = 0, left = 0, right = 0;
+	uint8_t lgain = 0, rgain = 0;
+	if (rid == 0x80 && n >= 9) {
+		// SDL Triton rumble report 0x80: [type][intensity u16][left u16][lgain][right u16][rgain].
+		// type 0 = off/zero report (everything stays 0).
+		if (d[0] != 0) {
+			intensity = (uint16_t)(d[1] | (d[2] << 8));
+			left = (uint16_t)(d[3] | (d[4] << 8));
+			lgain = d[5];
+			right = (uint16_t)(d[6] | (d[7] << 8));
+			rgain = d[8];
+		}
+	} else if (rid == 0x82 || rid == 0x8F) {
+		// discrete haptic click/pulse (data ~ [01 01 F7]; trailing byte F7=on, 00=off). The Deck has no
+		// per-pad clicker, so synthesize a short mid-strength buzz on both motors.
+		bool on = (n >= 3) ? (d[2] != 0) : (n >= 1 && d[0] != 0);
+		if (on) {
+			intensity = 0x6000;
+			left = right = 0x6000;
+		}
+	} else {
+		return; // not a haptic the Deck can render
+	}
+	// stage newest (coalesce). The actual sendFrame happens in deckHapticTask, off the RF path.
+	g_hapPayload[0] = (uint8_t)(intensity & 0xFF);
+	g_hapPayload[1] = (uint8_t)(intensity >> 8);
+	g_hapPayload[2] = (uint8_t)(left & 0xFF);
+	g_hapPayload[3] = (uint8_t)(left >> 8);
+	g_hapPayload[4] = (uint8_t)(right & 0xFF);
+	g_hapPayload[5] = (uint8_t)(right >> 8);
+	g_hapPayload[6] = lgain;
+	g_hapPayload[7] = rgain;
+	g_hapPending = true;
+}
+
+void deckHapticTask()
+{
+	if (!g_hapPending)
+		return;
+	if (millis() - g_lastHapMs < HAPTIC_MIN_MS)
+		return; // rate-limit the newest staged haptic to ~60 Hz
+	g_hapPending = false;
+	g_lastHapMs = millis();
+	sendFrame(DECK_T_HAPTIC, g_hapPayload, sizeof g_hapPayload);
+}
+
+// STATUS payload: [flags][linkSlot][sessCh][nbondMax][count] + per USED bond
+//   [slotIdx][alive][puuid4][iuuid4][serial16] (26 B). Only used bonds are sent, so the typical 1-2-puck
+// frame stays small; the slot index lets the GUI target a bond for removal.
 void deckStatusTask()
 {
 	if (millis() - g_lastStatusMs < 250)
 		return;
 	g_lastStatusMs = millis();
 
-	uint8_t p[4 + NBOND * 26];
-	memset(p, 0, sizeof p);
+	uint8_t p[5 + NBOND * 26];
 	bool linkUp = ctrlLinkUp();
 	p[0] = (uint8_t)((g_forwarding ? 1 : 0) | (linkUp ? 2 : 0));
 	p[1] = (g_linkSlot >= 0) ? (uint8_t)g_linkSlot : 0xFF;
 	p[2] = g_sessCh;
 	p[3] = NBOND;
-	uint8_t *q = p + 4;
+	uint8_t count = 0;
+	uint8_t *q = p + 5;
 	for (int i = 0; i < NBOND; i++) {
 		CtrlBond &b = g_bond[i];
-		q[0] = b.used ? 1 : 0;
-		q[1] = (b.used && i == g_linkSlot && linkUp) ? 1 : 0;
+		if (!b.used)
+			continue;
+		q[0] = (uint8_t)i; // firmware slot index (for GUI removal)
+		q[1] = (i == g_linkSlot && linkUp) ? 1 : 0;
 		memcpy(q + 2, b.rec + 0, 4); // proteus_uuid
 		memcpy(q + 6, b.rec + 4, 4); // ibex_uuid
 		memcpy(q + 10, b.rec + 8, 16); // puck serial
 		q += 26;
+		count++;
 	}
-	sendFrame(DECK_T_STATUS, p, sizeof p);
+	p[4] = count;
+	sendFrame(DECK_T_STATUS, p, (uint8_t)(5 + count * 26));
 }

+ 10 - 3
OpenController/deck_input.h

@@ -8,9 +8,9 @@
 //
 //   frame = [0xA5][0x5A][type][len][payload(len)][sum8(type,len,payload...)]
 //
-//   Deck -> nRF:  0x01 INPUT (34B fields), 0x02 CONTROL ([sub][..]: sub 0x01=set-forwarding[0/1]),
-//                 0x03 REQUEST_STATUS (no payload)
-//   nRF -> Deck:  0x10 STATUS, 0x1F TEXT(debug)
+//   Deck -> nRF:  0x01 INPUT (38B fields), 0x02 CONTROL ([sub][..]: sub 0x01=set-forwarding[0/1],
+//                 sub 0x02=clear-bond[slot]), 0x03 REQUEST_STATUS (no payload)
+//   nRF -> Deck:  0x10 STATUS, 0x11 HAPTIC (relayed Steam rumble), 0x1F TEXT(debug)
 #pragma once
 #include <stdint.h>
 
@@ -21,15 +21,22 @@
 #define DECK_T_CONTROL 0x02
 #define DECK_T_REQ_STATUS 0x03
 #define DECK_T_STATUS 0x10
+#define DECK_T_HAPTIC 0x11
 #define DECK_T_TEXT 0x1F
 
 // Pump the CDC RX state machine: parse any complete frames, fill g_in, update forwarding state.
 void deckInputPoll();
 // Emit a STATUS frame (bond list + link state) to the Deck. Rate-limited internally; call each loop.
 void deckStatusTask();
+// Flush the newest staged haptic (from deckForwardHaptic) to the Deck. Rate-limited; call each loop.
+void deckHapticTask();
 // Send a debug text line as a 0x1F frame (skipped by the app unless it shows debug).
 void deckText(const char *s);
 
+// Forward a relayed Steam haptic/rumble to the Deck as a HAPTIC frame (the app plays it as Deck rumble).
+// rid = the relayed report id (0x80 SDL Triton rumble, 0x82/0x8F haptic pulse); d/n = its raw payload.
+void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n);
+
 // Is the app currently forwarding (user tapped a puck)? When false, ctrl_link sends a neutral report.
 bool deckForwarding();
 // millis() of the last INPUT frame; ctrl_link sends neutral if input goes stale while forwarding.

+ 8 - 11
OpenController/identity.cpp

@@ -6,18 +6,15 @@
 char g_unit[16];
 char g_board[16];
 
-// 0x83 attributes for the CONTROLLER, as [tag][u32-LE] records (the format Steam's GET_ATTRIBUTES
-// returns): tag 01 = product id (0x1302, vs the puck's 0x1304); tag 02 = capabilities; tag 0A = build
-// id; tag 04 = radio/firmware build = 0x6A18D057 (the IBEX image version @0x120, NOT the puck's
-// 0x6A18D053 -- reporting the puck build is what made Steam nag "update firmware" and refuse to pair);
-// tag 09 = board rev. The puck blob is otherwise carried over.
-// LIVE-ITERATION: a real controller's 0x83 may carry EXTRA records (e.g. a separate main-firmware build)
-// this puck-derived blob lacks. If Steam still won't pair or still nags, capture the real controller's
-// response (`scmd 1302 --up 1 83`) and paste its exact bytes here -- the array length auto-propagates
-// via sizeof, so a longer blob needs no other change.
+// 0x83 attributes for the CONTROLLER -- the EXACT 25-byte blob read from a real Steam Controller
+// (28DE:1302) over USB: `scmd 1302 --up 1 83` -> `01 83 19 <these 25 bytes>`. As [tag][u32-LE] records:
+// tag 01 = product 0x1302; tag 02 = capabilities; tag 0A = bootloader build 0x68D2F92E; tag 04 = fw
+// build 0x6A18D057; tag 09 = board rev 0x48. These are firmware/model values (shared across units, not
+// per-device), so verbatim is correct; the per-unit serial lives in 0xAE instead. Steam validates these
+// byte-for-byte -- two wrong bytes (the puck's bootloader build + board rev) were why it kept re-asking.
 const uint8_t ATTR83[] = { 0x01, 0x02, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
-			   0x00, 0x0A, 0xF2, 0xF9, 0xD2, 0x68, 0x04, 0x57, 0xD0,
-			   0x18, 0x6A, 0x09, 0x47, 0x00, 0x00, 0x00 };
+			   0x00, 0x0A, 0x2E, 0xF9, 0xD2, 0x68, 0x04, 0x57, 0xD0,
+			   0x18, 0x6A, 0x09, 0x48, 0x00, 0x00, 0x00 };
 const uint16_t ATTR83_LEN = sizeof ATTR83;
 
 void genSerial()

+ 3 - 1
OpenController/report45.cpp

@@ -38,11 +38,13 @@ void buildReport45(uint8_t *out, uint8_t seq)
 	put_s16(out, 12, g_in.rx);
 	put_s16(out, 14, g_in.ry);
 
-	// trackpads: lpx@16 lpy@18 rpx@22 rpy@24 (offsets 20/26 unused by the decode)
+	// trackpads: LPad x@16 y@18 pressure@20, RPad x@22 y@24 pressure@26 (per PROTOCOL.md report 0x45)
 	put_s16(out, 16, g_in.lpx);
 	put_s16(out, 18, g_in.lpy);
+	put_u16(out, 20, g_in.lpp);
 	put_s16(out, 22, g_in.rpx);
 	put_s16(out, 24, g_in.rpy);
+	put_u16(out, 26, g_in.rpp);
 
 	// IMU: accel @0x22 (offset 32), gyro @0x28 (offset 38) -- inverse of imuFrom45
 	put_s16(out, 32, g_in.ax);

+ 1 - 0
OpenController/triton.h

@@ -74,6 +74,7 @@ struct PuckInput {
 	int16_t lx, ly, rx, ry; // sticks (int16, center 0)
 	uint8_t lt, rt; // triggers scaled 0..255 (trigU8)
 	int16_t lpx, lpy, rpx, rpy; // left / right trackpad coords (int16)
+	uint16_t lpp, rpp; // left / right trackpad pressure (u16)
 	int16_t ax, ay, az; // accelerometer
 	int16_t gx, gy, gz; // gyroscope
 };

+ 17 - 4
OpenControllerDeckSide/README.md

@@ -6,20 +6,33 @@ which relays them over 2.4 GHz to a puck — a real Valve puck **or** an [OpenPu
 puck presents a Steam Controller 2 to whatever host it's plugged into.
 
 ```
-Deck controls ──evdev grab──▶ openctrl.py ──USB-CDC──▶ nRF (28DE:1302) ──RF──▶ puck ──USB──▶ host
+Deck controls ──libusb detach + raw DeckState──▶ openctrl.py ──USB-CDC──▶ nRF (28DE:1302) ──RF──▶ puck ──USB──▶ host
 ```
 
+## Input: USB-level detach (the right way)
+The Deck controller is USB `28DE:1205`; Steam reads its 64-byte "DeckState" report on interface 2.
+You **can't** block Steam by reading `/dev/hidraw3` (it's broadcast), so `openctrl` uses **libusb
+(pyusb)** to **detach the kernel driver from the controller's HID interfaces and read EP `0x83`
+directly** — that genuinely takes the pad away from Steam (so **Steam/QAM forward** instead of opening
+the Deck UI) and decodes the **raw report correctly** (trackpads, sticks, triggers, gyro, all buttons —
+no more trackpad→stick/dpad bleed or Start/Select swap). Stopping forwarding reattaches it to Steam.
+Modeled on `~/Work/joverlay`. (`--evdev` is a lower-fidelity fallback that can't detach at the USB level.)
+
+**Calibrate the report layout for your unit:** `sudo python3 input_source.py` live-dumps the raw
+DeckState and shows which bytes/bits change as you press each control — edit `DECK_BTN_MAP` / the
+analog offsets in `input_source.py` if anything's off.
+
 ## Pieces
 
 | File | What |
 |---|---|
 | `openctrl.py` | Main app: opens the nRF CDC port, shows the touchscreen UI, forwards input. |
 | `ui.py` | Fullscreen pygame UI — a tappable tile per paired puck. |
-| `input_source.py` | Reads the Deck controls (evdev + optional `hid-steam` hidraw for gyro/trackpads). |
+| `input_source.py` | Reads the Deck controls — **libusb DeckSource (detach + raw decode)** + evdev fallback + capture tool. |
 | `frame.py` | The USB-CDC wire protocol (kept in lock-step with `../OpenController/deck_input.*`). |
 | `scpair.py` | Linux hidraw pairing tool (the on-Deck equivalent of `pairtui`). |
-| `run.sh` | `uv` launcher — add this as a non-Steam game. |
-| `setup.sh` | Per-boot `sudo` device-permission grant for the read-only SteamOS rootfs. |
+| `run.sh` | `uv` launcher (`pyserial`+`pygame`+`pyusb`) — add this as a non-Steam game. |
+| `setup.sh` | Per-boot `sudo`: opens `/dev/ttyACM*` + the controller's usbfs node (read-only SteamOS). |
 
 ## Run with `uv` (no system install, read-only SteamOS friendly)
 

+ 31 - 11
OpenControllerDeckSide/frame.py

@@ -17,11 +17,13 @@ T_REQ_STATUS = 0x03
 # nRF -> Deck
 T_STATUS = 0x10
 T_TEXT = 0x1F
+# nRF -> Deck haptic: [intensity u16][left u16][right u16][lgain u8][rgain u8] (Deck rumble cmd 0xEB)
+T_HAPTIC = 0x11
 
-# INPUT payload: buttons u32, lx ly rx ry s16, lt rt u8, lpx lpy rpx rpy s16, ax ay az gx gy gz s16.
-# 4 + 8 + 2 + 8 + 12 = 34 bytes, little-endian. Matches applyInput() in deck_input.cpp.
-_INPUT = struct.Struct("<I4hBB4h6h")
-INPUT_LEN = _INPUT.size  # 34
+# INPUT payload: buttons u32, lx ly rx ry s16, lt rt u8, lpx lpy rpx rpy s16, lpp rpp u16, axyz/gxyz s16.
+# 4 + 8 + 2 + 8 + 4 + 12 = 38 bytes, little-endian. Matches applyInput() in deck_input.cpp.
+_INPUT = struct.Struct("<I4hBB4hHH6h")
+INPUT_LEN = _INPUT.size  # 38
 
 
 def _sum8(type_, payload):
@@ -45,35 +47,52 @@ def build_input(st):
         g("lx", 0), g("ly", 0), g("rx", 0), g("ry", 0),
         g("lt", 0) & 0xFF, g("rt", 0) & 0xFF,
         g("lpx", 0), g("lpy", 0), g("rpx", 0), g("rpy", 0),
+        g("lpp", 0) & 0xFFFF, g("rpp", 0) & 0xFFFF,
         g("ax", 0), g("ay", 0), g("az", 0), g("gx", 0), g("gy", 0), g("gz", 0),
     )
     return build(T_INPUT, payload)
 
 
+def parse_haptic(payload):
+    """Decode a T_HAPTIC payload -> dict for the Deck rumble cmd. Mirrors deck_input.cpp."""
+    if len(payload) < 8:
+        return None
+    intensity, left, right = struct.unpack_from("<HHH", payload, 0)
+    lgain, rgain = payload[6], payload[7]
+    return {"intensity": intensity, "left": left, "right": right,
+            "lgain": lgain, "rgain": rgain}
+
+
 def build_set_forwarding(on):
     return build(T_CONTROL, bytes([0x01, 1 if on else 0]))
 
 
+def build_clear_bond(slot):
+    """Tell the firmware to un-bond (remove) slot `slot` -- the GUI "remove" affordance."""
+    return build(T_CONTROL, bytes([0x02, slot & 0xFF]))
+
+
 def build_req_status():
     return build(T_REQ_STATUS)
 
 
 def parse_status(payload):
-    """Decode a T_STATUS payload into a dict. Mirrors deckStatusTask() in deck_input.cpp."""
-    if len(payload) < 4:
+    """Decode a T_STATUS payload into a dict. Mirrors deckStatusTask() in deck_input.cpp.
+    Only USED bonds are sent; each carries its firmware slot index (for removal)."""
+    if len(payload) < 5:
         return None
-    flags, link_slot, sess_ch, nbond = payload[0], payload[1], payload[2], payload[3]
+    flags, link_slot, sess_ch, nbond_max, count = payload[:5]
     bonds = []
-    off = 4
-    for _ in range(nbond):
+    off = 5
+    for _ in range(count):
         if off + 26 > len(payload):
             break
-        used = payload[off]
+        slot = payload[off]
         alive = payload[off + 1]
         puuid = struct.unpack_from("<I", payload, off + 2)[0]
         iuuid = struct.unpack_from("<I", payload, off + 6)[0]
         serial = payload[off + 10:off + 26].split(b"\x00")[0].decode("latin1", "replace")
-        bonds.append({"used": bool(used), "alive": bool(alive),
+        bonds.append({"slot": slot, "used": True, "alive": bool(alive),
                       "puuid": puuid, "iuuid": iuuid, "serial": serial})
         off += 26
     return {
@@ -81,6 +100,7 @@ def parse_status(payload):
         "link_up": bool(flags & 2),
         "link_slot": None if link_slot == 0xFF else link_slot,
         "sess_ch": sess_ch,
+        "nbond_max": nbond_max,
         "bonds": bonds,
     }
 

+ 295 - 204
OpenControllerDeckSide/input_source.py

@@ -1,14 +1,21 @@
 """input_source.py -- read the Steam Deck's built-in controls and normalize to Triton fields.
 
-Pure stdlib: talks the Linux **evdev** ioctl ABI on /dev/input/event* directly (no `evdev` package,
-so `uv` needs no compiler -- only pyserial + pygame for the app). EVIOCGRAB *detaches* the controls
-from everything else while forwarding; release to hand them back. Reliable in Desktop Mode.
-
-Gyro + trackpads: richer than evdev and best read from the Steam Deck's `hid-steam` hidraw report --
-that decode is hardware-specific and OFF by default (use_hidraw=True / --hidraw). Offsets in HidrawImu
-are a documented starting point to verify on hardware.
-
-Triton field layout matches triton.h (TB_* masks, int16 sticks/pads, 0..255 triggers, int16 IMU).
+TWO sources:
+
+* **DeckSource (default, libusb/pyusb)** -- the CORRECT way, modeled on ~/Work/joverlay. The Deck
+  controller is USB 28DE:1205; Steam reads its 64-byte "DeckState" vendor report on interface 2,
+  EP 0x83. We **detach the kernel driver from interfaces 0/1/2 and claim them** -> the controller
+  vanishes from the kernel/Steam (true USB-level detach, so Steam stops eating Steam/QAM), and we read
+  EP 0x83 ourselves and decode the raw report (trackpads, sticks, triggers, gyro, ALL buttons incl.
+  Steam/QAM). Release + reattach hands it back to Steam. Needs access to the usbfs node (see setup.sh:
+  `chmod` the /dev/bus/usb node, or run as root).
+
+* **EvdevSource (fallback, --evdev)** -- pure-stdlib evdev. Works without libusb but the Deck exposes
+  trackpads as sticks/dpad and can't see Steam/QAM, hence the original mapping problems.
+
+DeckState layout: buttons in bytes 8-19 (the bits below are from joverlay's live captures + hid-steam.c /
+SDL_hidapi_steamdeck.c); analog (pads/sticks/triggers/IMU) at byte 20+. Run `python3 input_source.py`
+to live-dump the raw report and verify/correct the offsets on your unit.
 """
 from __future__ import annotations
 import fcntl
@@ -40,11 +47,211 @@ TB_LB = 0x80000
 
 NEUTRAL = {
     "buttons": 0, "lx": 0, "ly": 0, "rx": 0, "ry": 0, "lt": 0, "rt": 0,
-    "lpx": 0, "lpy": 0, "rpx": 0, "rpy": 0,
+    "lpx": 0, "lpy": 0, "rpx": 0, "rpy": 0, "lpp": 0, "rpp": 0,
     "ax": 0, "ay": 0, "az": 0, "gx": 0, "gy": 0, "gz": 0,
 }
 
-# ---- Linux input ABI constants (stable kernel ABI) ----
+DECK_VID = 0x28DE
+DECK_PID = 0x1205
+
+# ─────────────────────────── DeckState decode ───────────────────────────
+# Each entry: (byte_offset, bit_mask, Triton mask). EXACT byte/bit from the Linux kernel
+# drivers/hid/hid-steam.c steam_do_deck_input_event() -- authoritative, not guessed.
+DECK_BTN_MAP = [
+    # byte 8 -- digital triggers (full-pull) + bumpers + face buttons
+    (8, 0x01, 0x00800000),  # R2 full-pull -> RTrig click (triton bit 23)
+    (8, 0x02, 0x08000000),  # L2 full-pull -> LTrig click (triton bit 27)
+    (8, 0x04, TB_RB),       # R1
+    (8, 0x08, TB_LB),       # L1
+    (8, 0x10, TB_Y),
+    (8, 0x20, TB_B),
+    (8, 0x40, TB_X),
+    (8, 0x80, TB_A),
+    # byte 9 -- dpad + system buttons + lower-left grip
+    (9, 0x01, TB_DUP),
+    (9, 0x02, TB_DRT),
+    (9, 0x04, TB_DLF),
+    (9, 0x08, TB_DDN),
+    # SELECT (byte9 bit4) and START (byte9 bit6) are SWAPPED vs the Triton View/Menu so they read
+    # right on the host (kernel calls bit4 "menu left"/SELECT, bit6 "menu right"/START).
+    (9, 0x10, TB_MENU),    # ⊟  (BTN_SELECT)  -> Triton Menu
+    (9, 0x20, TB_STEAM),   # Steam (BTN_MODE)
+    (9, 0x40, TB_VIEW),    # ☰  (BTN_START)   -> Triton View
+    (9, 0x80, TB_L5),      # GRIPL2 -- lower-left back paddle
+    # byte 10 -- lower-right grip + pad click/TOUCH + left stick click
+    (10, 0x01, TB_R5),         # GRIPR2 -- lower-right back paddle
+    (10, 0x02, 0x04000000),    # BTN_THUMB  -> left pad CLICK  (TB_LPADC)
+    (10, 0x04, 0x00400000),    # BTN_THUMB2 -> right pad CLICK (TB_RPADC)
+    (10, 0x08, 0x02000000),    # left pad TOUCH  (TB_LPADT bit25) -- needed for pad->mouse
+    (10, 0x10, 0x00200000),    # right pad TOUCH (TB_RPADT bit21)
+    (10, 0x40, TB_L3),         # BTN_THUMBL -> left stick click
+    # byte 11 -- right stick click
+    (11, 0x04, TB_R3),         # BTN_THUMBR
+    # byte 13 -- upper back paddles + thumbstick capacitive touch
+    (13, 0x02, TB_L4),         # BTN_GRIPL  (upper-left)
+    (13, 0x04, TB_R4),         # BTN_GRIPR  (upper-right)
+    (13, 0x40, 0x01000000),    # left thumbstick TOUCH  (LJoy touch, bit24)
+    (13, 0x80, 0x00100000),    # right thumbstick TOUCH (RJoy touch, bit20)
+    # byte 14 -- QAM (•••)
+    (14, 0x04, TB_QAM),        # BTN_BASE
+]
+
+# analog block: little-endian 16-bit, EXACT offsets from hid-steam.c steam_do_deck_input_event().
+_OFF_LPAD_X, _OFF_LPAD_Y = 16, 18   # s16 left trackpad
+_OFF_RPAD_X, _OFF_RPAD_Y = 20, 22   # s16 right trackpad
+_OFF_ACCEL = 24                     # s16 ax,ay,az (24/26/28)
+_OFF_GYRO = 30                      # s16 gx,gy,gz (30/32/34)
+_OFF_TRIG_L, _OFF_TRIG_R = 44, 46   # u16 analog triggers
+_OFF_LSTICK_X, _OFF_LSTICK_Y = 48, 50
+_OFF_RSTICK_X, _OFF_RSTICK_Y = 52, 54
+_OFF_LPAD_P, _OFF_RPAD_P = 56, 58   # u16 trackpad pressure
+
+
+def _s16(b, off):
+    return struct.unpack_from("<h", b, off)[0]
+
+
+def _u16(b, off):
+    return struct.unpack_from("<H", b, off)[0]
+
+
+def decode_deck(rpt, state):
+    """Decode a 64-byte DeckState report (rpt) into the Triton `state` dict. Returns False if not a
+    0x09 state report."""
+    if len(rpt) < 60 or rpt[2] != 0x09:
+        return False
+    btn = 0
+    for off, mask, tmask in DECK_BTN_MAP:
+        if tmask and (rpt[off] & mask):
+            btn |= tmask
+    state["buttons"] = btn
+    # sticks (int16, center 0). Deck Y is up-positive; Triton expects up-positive too -> pass through.
+    state["lx"] = _s16(rpt, _OFF_LSTICK_X)
+    state["ly"] = _s16(rpt, _OFF_LSTICK_Y)
+    state["rx"] = _s16(rpt, _OFF_RSTICK_X)
+    state["ry"] = _s16(rpt, _OFF_RSTICK_Y)
+    # analog triggers u16 -> 0..255
+    state["lt"] = min(255, _u16(rpt, _OFF_TRIG_L) >> 7)
+    state["rt"] = min(255, _u16(rpt, _OFF_TRIG_R) >> 7)
+    # trackpads (int16) -> Triton pads (this is the FIX: pads no longer bleed into sticks/dpad)
+    state["lpx"] = _s16(rpt, _OFF_LPAD_X)
+    state["lpy"] = _s16(rpt, _OFF_LPAD_Y)
+    state["rpx"] = _s16(rpt, _OFF_RPAD_X)
+    state["rpy"] = _s16(rpt, _OFF_RPAD_Y)
+    state["lpp"] = _u16(rpt, _OFF_LPAD_P)   # trackpad pressure (u16)
+    state["rpp"] = _u16(rpt, _OFF_RPAD_P)
+    # IMU
+    state["ax"] = _s16(rpt, _OFF_ACCEL)
+    state["ay"] = _s16(rpt, _OFF_ACCEL + 2)
+    state["az"] = _s16(rpt, _OFF_ACCEL + 4)
+    state["gx"] = _s16(rpt, _OFF_GYRO)
+    state["gy"] = _s16(rpt, _OFF_GYRO + 2)
+    state["gz"] = _s16(rpt, _OFF_GYRO + 4)
+    return True
+
+
+# ─────────────────────────── USB source (default) ───────────────────────────
+class DeckSource:
+    """Read + DETACH the Deck controller via libusb (pyusb). grab() detaches it from the kernel/Steam
+    and starts reading EP 0x83; ungrab() hands it back."""
+
+    IFACES = (0, 1, 2)   # claim all HID interfaces so the pad fully leaves the kernel (mouse/kbd/steam)
+    EP_IN = 0x83
+    IFACE_READ = 2
+
+    def __init__(self, path=None):
+        import time as _time
+        import usb.core  # imported here so --help / --evdev work without pyusb
+        self._time = _time
+        self._usbutil = __import__("usb.util", fromlist=["util"])
+        self.dev = usb.core.find(idVendor=DECK_VID, idProduct=DECK_PID)
+        if self.dev is None:
+            raise RuntimeError("Deck controller 28DE:1205 not found on USB")
+        self.state = dict(NEUTRAL)
+        self._claimed = False
+        self._USBError = usb.core.USBError
+        self._last_rumble = None      # last (int,left,right,lg,rg) we sent -> dedupe
+        self._last_rumble_t = 0.0     # throttle clock
+
+    @property
+    def name(self):
+        return "Steam Deck controller (libusb 28DE:1205)"
+
+    def grab(self):
+        if self._claimed:
+            return
+        for i in self.IFACES:
+            try:
+                if self.dev.is_kernel_driver_active(i):
+                    self.dev.detach_kernel_driver(i)
+            except Exception:
+                pass
+            try:
+                self._usbutil.claim_interface(self.dev, i)
+            except Exception:
+                pass
+        self._claimed = True
+
+    def ungrab(self):
+        if not self._claimed:
+            return
+        for i in self.IFACES:
+            try:
+                self._usbutil.release_interface(self.dev, i)
+            except Exception:
+                pass
+            try:
+                self.dev.attach_kernel_driver(i)
+            except Exception:
+                pass
+        self._claimed = False
+
+    def pump(self):
+        # drain whatever reports are queued; keep the freshest decoded state
+        for _ in range(8):
+            try:
+                rpt = self.dev.read(self.EP_IN, 64, timeout=2)
+            except self._USBError:
+                break  # timeout / no data
+            decode_deck(bytes(rpt), self.state)
+        return self.state
+
+    # don't issue rumble feature-reports faster than this -- protects the Deck pad's control endpoint
+    # (hammering it at the poll rate is what wedged the pad). A repeated identical state is also dropped.
+    _RUMBLE_MIN_DT = 0.015
+
+    def rumble(self, intensity, left, right, lgain=0, rgain=0):
+        """Play a rumble on the Deck's haptics via the 0xEB feature report (hid-steam SET_REPORT).
+        intensity/left/right are the SC2 rumble u16s relayed back from Steam; 0 stops it. Rate-limited
+        + deduped + short-timeout so a rumble burst can never flood or block the input loop."""
+        if not self._claimed:
+            return
+        cur = (intensity & 0xFFFF, left & 0xFFFF, right & 0xFFFF, lgain & 0xFF, rgain & 0xFF)
+        now = self._time.monotonic()
+        # always let an OFF (all-zero) through promptly; otherwise dedupe + throttle
+        if cur != (0, 0, 0, 0, 0):
+            if cur == self._last_rumble:
+                return
+            if now - self._last_rumble_t < self._RUMBLE_MIN_DT:
+                return
+        self._last_rumble = cur
+        self._last_rumble_t = now
+        buf = bytearray(64)
+        buf[0] = 0xEB          # CMD_TRIGGER_HAPTIC_RUMBLE
+        buf[1] = 0x09          # payload length
+        buf[2] = 0x00          # reserved
+        struct.pack_into("<HHH", buf, 3, cur[0], cur[1], cur[2])
+        buf[9] = cur[3]
+        buf[10] = cur[4]
+        try:
+            # SET_REPORT(Feature), report id 0, on the DeckState control interface (iface 2). Short
+            # timeout: never block the input/UI loop if the endpoint is busy.
+            self.dev.ctrl_transfer(0x21, 0x09, 0x0300, self.IFACE_READ, bytes(buf), timeout=20)
+        except Exception:
+            pass
+
+
+# ─────────────────────────── evdev fallback ───────────────────────────
 EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
 BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
 BTN_TL, BTN_TR = 0x136, 0x137
@@ -52,52 +259,39 @@ BTN_SELECT, BTN_START, BTN_MODE = 0x13A, 0x13B, 0x13C
 BTN_THUMBL, BTN_THUMBR = 0x13D, 0x13E
 ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ = 0, 1, 2, 3, 4, 5
 ABS_HAT0X, ABS_HAT0Y = 0x10, 0x11
-
-# evdev key code -> Triton button mask
 KEYMAP = {
     BTN_SOUTH: TB_A, BTN_EAST: TB_B, BTN_WEST: TB_X, BTN_NORTH: TB_Y,
     BTN_TL: TB_LB, BTN_TR: TB_RB,
     BTN_SELECT: TB_VIEW, BTN_START: TB_MENU, BTN_MODE: TB_STEAM,
     BTN_THUMBL: TB_L3, BTN_THUMBR: TB_R3,
 }
-
-# struct input_event { __kernel_long tv_sec, tv_usec; __u16 type, code; __s32 value; } = 24 bytes (64-bit)
 _EVENT = struct.Struct("qqHHi")
-EVENT_SZ = _EVENT.size  # 24
-
-# ---- ioctl number assembly (asm-generic _IOC) ----
-_IOC_NONE, _IOC_WRITE, _IOC_READ = 0, 1, 2
+EVENT_SZ = _EVENT.size
 
 
 def _IOC(d, t, nr, size):
     return (d << 30) | (size << 16) | (ord(t) << 8) | nr
 
 
-EVIOCGID = _IOC(_IOC_READ, 'E', 0x02, 8)          # struct input_id (4x u16)
+EVIOCGID = _IOC(2, 'E', 0x02, 8)
 
 
 def EVIOCGNAME(length):
-    return _IOC(_IOC_READ, 'E', 0x06, length)
+    return _IOC(2, 'E', 0x06, length)
 
 
 def EVIOCGBIT(ev, length):
-    return _IOC(_IOC_READ, 'E', 0x20 + ev, length)
+    return _IOC(2, 'E', 0x20 + ev, length)
 
 
-def EVIOCGABS(abscode):
-    return _IOC(_IOC_READ, 'E', 0x40 + abscode, 24)  # struct input_absinfo (6x s32)
+EVIOCGRAB = _IOC(1, 'E', 0x90, 4)
 
 
-EVIOCGRAB = _IOC(_IOC_WRITE, 'E', 0x90, 4)
-
-
-def _test_bit(bitmask, bit):
-    return bool(bitmask[bit // 8] & (1 << (bit % 8)))
+def _test_bit(bm, bit):
+    return bool(bm[bit // 8] & (1 << (bit % 8)))
 
 
 class _RawDev:
-    """Minimal pure-stdlib evdev device."""
-
     def __init__(self, path):
         self.path = path
         self.fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
@@ -112,36 +306,19 @@ class _RawDev:
         buf = bytearray(8)
         try:
             fcntl.ioctl(self.fd, EVIOCGID, buf, True)
-            _bus, vid, pid, _ver = struct.unpack("<HHHH", buf)
+            _b, vid, pid, _v = struct.unpack("<HHHH", buf)
             return vid, pid
         except Exception:
             return (0, 0)
 
-    def name(self):
-        buf = bytearray(256)
-        try:
-            fcntl.ioctl(self.fd, EVIOCGNAME(256), buf, True)
-            return buf.split(b"\x00")[0].decode("latin1", "replace")
-        except Exception:
-            return ""
-
     def has_key(self, code):
-        buf = bytearray(96)  # (KEY_MAX/8)+1
+        buf = bytearray(96)
         try:
             fcntl.ioctl(self.fd, EVIOCGBIT(EV_KEY, len(buf)), buf, True)
             return _test_bit(buf, code)
         except Exception:
             return False
 
-    def abs_range(self, code):
-        buf = bytearray(24)
-        try:
-            fcntl.ioctl(self.fd, EVIOCGABS(code), buf, True)
-            _val, mn, mx, _fz, _flat, _res = struct.unpack("<6i", buf)
-            return (mn, mx)
-        except Exception:
-            return (-32768, 32767)
-
     def grab(self):
         fcntl.ioctl(self.fd, EVIOCGRAB, 1)
 
@@ -152,7 +329,6 @@ class _RawDev:
             pass
 
     def read_events(self):
-        """Yield (type, code, value); non-blocking."""
         try:
             data = os.read(self.fd, EVENT_SZ * 64)
         except (BlockingIOError, OSError):
@@ -162,75 +338,32 @@ class _RawDev:
             yield typ, code, val
 
 
-# Deck controller USB PIDs: 0x1205 = the PHYSICAL hid-steam controller (grabbing this DETACHES the pad,
-# once Steam Input is off for the foreground app); 0x11FF = the VIRTUAL pad Steam Input synthesizes
-# (only present while Steam Input is ON; grabbing it does NOT detach -- the physical pad keeps feeding
-# Steam). So we prefer the physical node. Override with EvdevSource(path=...) / openctrl --input.
-PHYS_PID = 0x1205
-VIRT_PID = 0x11FF
-
-
-def find_deck_controller():
-    """Best gamepad event node to grab. Prefer the physical Deck controller (28de:1205); fall back to
-    the virtual Steam pad (28de:11ff), then any other gamepad. Returns a path or None."""
-    cands = []  # (priority, path) -- lower is better
+def find_deck_evdev():
     for path in sorted(glob.glob("/dev/input/event*")):
         try:
             d = _RawDev(path)
         except Exception:
             continue
         try:
-            if not d.has_key(BTN_SOUTH):
-                continue
-            vid, pid = d.ids()
-            nm = d.name().lower()
-            valve = vid == 0x28DE or "steam" in nm or "deck" in nm
-            if valve and pid == PHYS_PID:
-                prio = 0
-            elif valve and pid == VIRT_PID:
-                prio = 2
-            elif valve:
-                prio = 1
-            else:
-                prio = 3  # generic gamepad, last resort
-            cands.append((prio, path))
+            if d.has_key(BTN_SOUTH):
+                return path
         finally:
             d.close()
-    cands.sort()
-    return cands[0][1] if cands else None
-
-
-def _scale_stick(v, lo, hi):
-    if hi <= lo:
-        return 0
-    mid = (lo + hi) / 2.0
-    half = (hi - lo) / 2.0
-    return int(max(-32768, min(32767, (v - mid) / half * 32767)))
-
-
-def _scale_trig(v, lo, hi):
-    if hi <= lo:
-        return 0
-    return int(max(0, min(255, (v - lo) / (hi - lo) * 255)))
+    return None
 
 
 class EvdevSource:
-    """Buttons / sticks / triggers via raw evdev, with grab() / ungrab() for detach."""
-
     def __init__(self, path=None):
-        self.path = path or find_deck_controller()
+        self.path = path or find_deck_evdev()
         if not self.path:
-            raise RuntimeError("no Deck gamepad event device found under /dev/input")
+            raise RuntimeError("no gamepad evdev device found")
         self.dev = _RawDev(self.path)
-        self._name = self.dev.name() or self.path
-        self._range = {c: self.dev.abs_range(c)
-                       for c in (ABS_X, ABS_Y, ABS_RX, ABS_RY, ABS_Z, ABS_RZ)}
         self.state = dict(NEUTRAL)
         self._grabbed = False
 
     @property
     def name(self):
-        return self._name
+        return "evdev " + self.path
 
     def grab(self):
         if not self._grabbed:
@@ -242,114 +375,72 @@ class EvdevSource:
             self.dev.ungrab()
             self._grabbed = False
 
-    def pump(self):
-        for typ, code, val in self.dev.read_events():
-            self._apply(typ, code, val)
-        return self.state
+    def rumble(self, intensity, left, right, lgain=0, rgain=0):
+        # evdev fallback has no haptic path (FF could be added, but the libusb source is the real one)
+        pass
 
-    def _apply(self, typ, code, val):
+    def pump(self):
         s = self.state
-        if typ == EV_KEY:
-            mask = KEYMAP.get(code)
-            if mask is not None:
-                if val:
-                    s["buttons"] |= mask
-                else:
-                    s["buttons"] &= ~mask
-        elif typ == EV_ABS:
-            if code == ABS_X:
-                s["lx"] = _scale_stick(val, *self._range[ABS_X])
-            elif code == ABS_Y:
-                s["ly"] = -_scale_stick(val, *self._range[ABS_Y])  # evdev Y is down-positive
-            elif code == ABS_RX:
-                s["rx"] = _scale_stick(val, *self._range[ABS_RX])
-            elif code == ABS_RY:
-                s["ry"] = -_scale_stick(val, *self._range[ABS_RY])
-            elif code == ABS_Z:
-                s["lt"] = _scale_trig(val, *self._range[ABS_Z])
-            elif code == ABS_RZ:
-                s["rt"] = _scale_trig(val, *self._range[ABS_RZ])
-            elif code == ABS_HAT0X:
-                s["buttons"] &= ~(TB_DLF | TB_DRT)
-                if val < 0:
-                    s["buttons"] |= TB_DLF
-                elif val > 0:
-                    s["buttons"] |= TB_DRT
-            elif code == ABS_HAT0Y:
-                s["buttons"] &= ~(TB_DUP | TB_DDN)
-                if val < 0:
-                    s["buttons"] |= TB_DUP
-                elif val > 0:
-                    s["buttons"] |= TB_DDN
-
-
-class HidrawImu:
-    """Optional gyro + trackpad reader from the Deck's hid-steam hidraw node (PID 0x1205).
-
-    DISABLED by default. The Steam Deck input report layout must be confirmed on hardware -- the offsets
-    below are a documented starting point, not verified bytes. Until then forwarding works with evdev
-    buttons/sticks/triggers and zero IMU.
-    """
-
-    PID = 0x1205
-
-    def __init__(self):
-        self.path = self._find()
-        self.fd = open(self.path, "rb", buffering=0) if self.path else None
-
-    def _find(self):
-        for p in glob.glob("/dev/hidraw*"):
+        for typ, code, val in self.dev.read_events():
+            if typ == EV_KEY:
+                m = KEYMAP.get(code)
+                if m is not None:
+                    s["buttons"] = (s["buttons"] | m) if val else (s["buttons"] & ~m)
+            elif typ == EV_ABS:
+                if code == ABS_X:
+                    s["lx"] = val
+                elif code == ABS_Y:
+                    s["ly"] = -val
+                elif code == ABS_RX:
+                    s["rx"] = val
+                elif code == ABS_RY:
+                    s["ry"] = -val
+                elif code == ABS_Z:
+                    s["lt"] = max(0, min(255, val))
+                elif code == ABS_RZ:
+                    s["rt"] = max(0, min(255, val))
+        return s
+
+
+def open_source(prefer_evdev=False, path=None):
+    """Pick the best input source: libusb DeckSource by default, evdev if requested/unavailable."""
+    if not prefer_evdev:
+        try:
+            return DeckSource(path)
+        except Exception as ex:
+            print("DeckSource (libusb) unavailable (%s); falling back to evdev" % ex)
+    return EvdevSource(path)
+
+
+# ─────────────────────────── capture / calibration tool ───────────────────────────
+def _capture():
+    """Live-dump the raw DeckState report; press one control at a time and watch which bytes/bits move,
+    so you can correct DECK_BTN_MAP and the analog offsets for your unit. Ctrl-C to stop."""
+    src = DeckSource()
+    print("detaching controller (libusb) ... press ONE control at a time; Ctrl-C to stop")
+    src.grab()
+    base = None
+    try:
+        while True:
             try:
-                ue = open("/sys/class/hidraw/%s/device/uevent" % p.split("/")[-1]).read().upper()
+                rpt = bytes(src.dev.read(src.EP_IN, 64, timeout=200))
             except Exception:
                 continue
-            if "28DE" in ue and "1205" in ue:
-                return p
-        return None
-
-    def read_into(self, state):
-        if not self.fd:
-            return False
-        try:
-            rpt = self.fd.read(64)
-        except Exception:
-            return False
-        if not rpt or len(rpt) < 64:
-            return False
-        # Placeholder offsets per the SDL hid_steam Deck report; VERIFY on hardware.
-        try:
-            ax, ay, az, gx, gy, gz = struct.unpack_from("<6h", rpt, 28)
-            state["ax"], state["ay"], state["az"] = ax, ay, az
-            state["gx"], state["gy"], state["gz"] = gx, gy, gz
-            lpx, lpy, rpx, rpy = struct.unpack_from("<4h", rpt, 16)
-            state["lpx"], state["lpy"], state["rpx"], state["rpy"] = lpx, lpy, rpx, rpy
-            return True
-        except Exception:
-            return False
-
-
-def _list_inputs():
-    """Diagnostic: print every /dev/input gamepad candidate and which one we'd grab.
-
-        python3 input_source.py
-    """
-    pick = find_deck_controller()
-    for path in sorted(glob.glob("/dev/input/event*")):
-        try:
-            d = _RawDev(path)
-        except Exception as ex:
-            print("%-22s (open failed: %s)" % (path, ex))
-            continue
-        try:
-            vid, pid = d.ids()
-            print("%-22s vid=%04x pid=%04x gamepad=%s  %s%s"
-                  % (path, vid, pid, d.has_key(BTN_SOUTH), d.name(),
-                     "   <== WILL GRAB" if path == pick else ""))
-        finally:
-            d.close()
-    print("\nGrab target: %s" % (pick or
-          "NONE found — is Steam holding the controller? try Desktop Mode"))
+            if len(rpt) < 60 or rpt[2] != 0x09:
+                continue
+            if base is None:
+                base = rpt
+                print("baseline:", base.hex())
+                continue
+            diff = [(i, base[i], rpt[i]) for i in range(8, 60) if base[i] != rpt[i]]
+            if diff:
+                print(" ".join("b%d:%02x->%02x" % d for d in diff))
+    except KeyboardInterrupt:
+        pass
+    finally:
+        src.ungrab()
+        print("\nreattached controller to the kernel.")
 
 
 if __name__ == "__main__":
-    _list_inputs()
+    _capture()

+ 130 - 31
OpenControllerDeckSide/openctrl.py

@@ -10,11 +10,13 @@ link, and control returns to the Deck.
     python3 openctrl.py                 # fullscreen touchscreen UI
     python3 openctrl.py --debug         # headless: print status, no UI (for bring-up)
     python3 openctrl.py --port /dev/ttyACM0   # override CDC port autodetect
-    python3 openctrl.py --hidraw        # also read gyro/trackpads from the Deck hidraw (see input_source)
+    python3 openctrl.py --evdev         # use the evdev fallback input source (lower fidelity)
 
-Deps: pyserial (required), evdev (for input), pygame (for the UI). See README.md.
+The dongle (nRF CDC) is hot-pluggable: launch with or without it, it appears/disappears live.
+Deps: pyserial + pygame (UI) + pyusb (input/USB detach). See README.md / run.sh.
 """
 import argparse
+import os
 import sys
 import time
 
@@ -45,16 +47,16 @@ class App:
     def __init__(self, args):
         self.args = args
         import serial  # imported here so --help works without pyserial
-        port = args.port or find_port()
-        if not port:
-            raise SystemExit("OpenController CDC port not found (Valve 28DE:1302). "
-                             "Plug in the nRF, or pass --port.")
-        self.port_name = port
-        self.ser = serial.Serial(port, 115200, timeout=0)
+        self._serial = serial
+
+        # The dongle (nRF CDC) is HOT-PLUGGABLE: we don't require it at launch and we re-open it
+        # whenever it (re)appears, and tear down cleanly when it goes away.
+        self.ser = None
+        self.port_name = None
         self.reader = frame.Reader()
+        self._last_open_try = 0.0
 
         self.input = None
-        self.hidraw = None
         self.forwarding = False
         self.fwd_slot = None
         self.last_input = 0.0
@@ -62,44 +64,128 @@ class App:
         # model surfaced to the UI
         self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
                        "bonds": [], "forwarding": False}
-        self.note = "connected to %s" % port
+        self.note = "waiting for OpenController dongle (28DE:1302)…"
         self.log = []  # firmware '#' diagnostic lines (the in-app serial monitor)
 
-    # ---- serial I/O ----
+        # file log: everything (firmware '#' lines + app events) with timestamps, for sharing.
+        self.logf = None
+        try:
+            self.logf = open(args.logfile, "a", buffering=1)  # line-buffered
+            self._flog("==== openctrl start ====")
+        except Exception as ex:
+            print("logfile open failed (%s); continuing without it" % ex)
+
+    def _flog(self, line):
+        if self.logf:
+            try:
+                self.logf.write("%.3f %s\n" % (time.monotonic(), line))
+            except Exception:
+                pass
+
+    @property
+    def dongle_connected(self):
+        return self.ser is not None
+
+    # ---- serial I/O (hot-plug aware) ----
+    def _open_serial(self):
+        """Try to (re)open the dongle CDC port. Throttled to ~2 Hz. Sets self.ser on success."""
+        if self.ser is not None:
+            return
+        now = time.monotonic()
+        if now - self._last_open_try < 0.5:
+            return
+        self._last_open_try = now
+        port = self.args.port or find_port()
+        if not port:
+            return
+        try:
+            self.ser = self._serial.Serial(port, 115200, timeout=0)
+            self.port_name = port
+            self.reader = frame.Reader()  # fresh framer for the new connection
+            # forget any stale link state from a previous dongle session
+            self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
+                           "bonds": [], "forwarding": False}
+            self.note = "dongle connected: %s" % port
+            self._flog("dongle connected: %s" % port)
+        except Exception:
+            self.ser = None  # e.g. permission / busy; will retry
+
+    def _drop_serial(self, why):
+        """Tear down on disconnect: stop forwarding, close the port, reset the model."""
+        if self.forwarding:
+            self.stop_forwarding()
+        try:
+            if self.ser:
+                self.ser.close()
+        except Exception:
+            pass
+        self.ser = None
+        self.port_name = None
+        self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
+                       "bonds": [], "forwarding": False}
+        self.note = "dongle disconnected (%s) — waiting…" % why
+        self._flog("dongle disconnected: %s" % why)
+
     def pump_serial(self):
+        if self.ser is None:
+            self._open_serial()
+            return
+        # A yanked CDC often makes pyserial return b'' silently rather than raise, so the port node
+        # disappearing is the reliable disconnect signal. Check it (throttled).
+        now = time.monotonic()
+        if now - self._last_open_try >= 0.5:
+            self._last_open_try = now
+            if self.port_name and not os.path.exists(self.port_name):
+                self._drop_serial("unplugged")
+                return
         try:
             data = self.ser.read(512)
-        except Exception:
-            data = b""
+        except Exception as ex:
+            # device yanked / I/O error -> clean teardown, then we'll re-detect it
+            self._drop_serial(str(ex) or "read error")
+            return
+        last_haptic = None
         for typ, payload in self.reader.feed(data):
             if typ == frame.T_STATUS:
                 st = frame.parse_status(payload)
                 if st:
                     self.status = st
+            elif typ == frame.T_HAPTIC:
+                # coalesce a burst to the newest state -- replaying every frame is what lagged the rumble
+                last_haptic = frame.parse_haptic(payload)
             elif typ == frame.T_TEXT:
                 line = payload.decode("latin1", "replace").rstrip()
                 if line:
                     self.note = line
                     self.log.append(line)
                     self.log = self.log[-300:]
+                    self._flog(line)
                     if self.args.debug:
                         print(line)  # stream the firmware log like a serial monitor
+        # play only the newest haptic of this batch, and only while we're actually forwarding
+        if last_haptic and self.forwarding and self.input:
+            try:
+                self.input.rumble(last_haptic["intensity"], last_haptic["left"],
+                                  last_haptic["right"], last_haptic["lgain"],
+                                  last_haptic["rgain"])
+            except Exception:
+                pass
 
     def send(self, b):
+        if self.ser is None:
+            return
         try:
             self.ser.write(b)
         except Exception:
-            pass
+            self._drop_serial("write error")
 
     # ---- forwarding control ----
     def _ensure_input(self):
         if self.input is None:
-            self.input = isrc.EvdevSource(path=self.args.input)
-            if self.args.hidraw:
-                try:
-                    self.hidraw = isrc.HidrawImu()
-                except Exception:
-                    self.hidraw = None
+            # libusb DeckSource by default (detaches the pad from Steam + decodes the raw report
+            # correctly); --evdev forces the lower-fidelity fallback.
+            self.input = isrc.open_source(prefer_evdev=self.args.evdev,
+                                          path=self.args.input)
         return self.input
 
     def start_forwarding(self, slot):
@@ -112,6 +198,7 @@ class App:
         self.fwd_slot = slot
         self.send(frame.build_set_forwarding(True))
         self.note = "forwarding -> slot %s (%s)" % (slot, self.input.name)
+        self._flog("APP forwarding START slot=%s input=%s" % (slot, self.input.name))
 
     def stop_forwarding(self):
         self.forwarding = False
@@ -120,20 +207,28 @@ class App:
         if self.input:
             self.input.ungrab()
         self.note = "control returned to Deck"
+        self._flog("APP forwarding STOP")
 
     def toggle(self, slot):
         """Tap handler: a tappable tile is one whose puck is live (alive)."""
         if self.forwarding:
             self.stop_forwarding()
-        else:
+        elif self.dongle_connected:
             self.start_forwarding(slot)
 
+    def remove_bond(self, slot):
+        """GUI "remove": un-bond a puck slot on the firmware. Refused while forwarding to it."""
+        if self.forwarding and self.fwd_slot == slot:
+            return
+        self.send(frame.build_clear_bond(slot))
+        # drop it from the local model immediately so the tile disappears without waiting for STATUS
+        self.status["bonds"] = [b for b in self.status.get("bonds", []) if b["slot"] != slot]
+        self._flog("APP remove bond slot=%s" % slot)
+
     def pump_input(self):
         if not self.forwarding or not self.input:
             return
         st = self.input.pump()
-        if self.hidraw:
-            self.hidraw.read_into(st)
         now = time.monotonic()
         if now - self.last_input >= 1.0 / INPUT_HZ:
             self.last_input = now
@@ -145,7 +240,7 @@ class App:
             # only release if the slot we're forwarding to is no longer alive
             slot = self.fwd_slot
             bonds = self.status.get("bonds", [])
-            alive = slot is not None and slot < len(bonds) and bonds[slot]["alive"]
+            alive = any(b["slot"] == slot and b["alive"] for b in bonds)
             if not alive:
                 self.stop_forwarding()
 
@@ -160,10 +255,11 @@ class App:
                 last = time.monotonic()
                 s = self.status
                 tiles = ", ".join(
-                    "[%d %s%s]" % (i, b["serial"] or "?", " LIVE" if b["alive"] else "")
-                    for i, b in enumerate(s["bonds"]) if b["used"]) or "(no bonds)"
-                print("link=%s fwd=%s ch=%s %s | %s" %
-                      (s["link_up"], self.forwarding, s["sess_ch"], tiles, self.note))
+                    "[%d %s%s]" % (b["slot"], b["serial"] or "?", " LIVE" if b["alive"] else "")
+                    for b in s["bonds"]) or "(no bonds)"
+                print("dongle=%s link=%s fwd=%s ch=%s %s | %s" %
+                      ("yes" if self.dongle_connected else "NO", s["link_up"],
+                       self.forwarding, s["sess_ch"], tiles, self.note))
             time.sleep(0.002)
 
     def run_ui(self):
@@ -174,10 +270,13 @@ class App:
 def main():
     ap = argparse.ArgumentParser(description="Steam Deck forwarder for OpenController")
     ap.add_argument("--port", help="CDC port (default: autodetect Valve 28DE:1302)")
-    ap.add_argument("--input", help="force the Deck controller evdev node, e.g. /dev/input/event12 "
-                    "(default: prefer the physical 28de:1205 controller)")
+    ap.add_argument("--input", help="force an evdev node, e.g. /dev/input/event12 (only with --evdev)")
+    ap.add_argument("--evdev", action="store_true",
+                    help="use the evdev fallback instead of the libusb DeckSource (lower fidelity, "
+                    "no USB-level detach)")
     ap.add_argument("--debug", action="store_true", help="headless status print, no UI")
-    ap.add_argument("--hidraw", action="store_true", help="also read gyro/trackpads via hid-steam hidraw")
+    ap.add_argument("--logfile", default="openctrl.log",
+                    help="append firmware log + app events here (default: openctrl.log in this dir)")
     args = ap.parse_args()
     app = App(args)
     try:

+ 6 - 4
OpenControllerDeckSide/run.sh

@@ -7,10 +7,12 @@
 # Then, in that shortcut's gear -> Properties -> Controller, set "Disable Steam Input" so Steam doesn't
 # fight us for the built-in pad while we grab it.
 #
-# Dependencies are pulled by uv on first run (pyserial + pygame -- both prebuilt wheels, no compiler).
-# uv also provisions its own Python, so nothing touches the read-only SteamOS rootfs.
+# Dependencies are pulled by uv on first run (pyserial + pygame + pyusb -- prebuilt wheels; pyusb uses
+# the system libusb-1.0.so). uv also provisions its own Python, so nothing touches the read-only rootfs.
 #
-# If you get a serial permission error on /dev/ttyACM*, run `sudo ./setup.sh` once per boot (see README).
+# USB-level detach (DeckSource) needs access to the controller's usbfs node, and the CDC link needs
+# /dev/ttyACM*. Run `sudo ./setup.sh` once per boot to open both (see README). Without it, fall back to
+# `./run.sh --evdev` (no USB detach; Steam/QAM won't forward, trackpads are lower fidelity).
 set -euo pipefail
 cd "$(dirname "$(readlink -f "$0")")"
 
@@ -18,4 +20,4 @@ UV="$(command -v uv || true)"
 [ -n "$UV" ] || UV="$HOME/.local/bin/uv"
 [ -x "$UV" ] || { echo "uv not found. Install it: https://docs.astral.sh/uv/  (curl -LsSf https://astral.sh/uv/install.sh | sh)"; exit 1; }
 
-exec "$UV" run --with pyserial --with pygame python openctrl.py "$@"
+exec "$UV" run --with pyserial --with pygame --with pyusb python openctrl.py "$@"

+ 22 - 6
OpenControllerDeckSide/setup.sh

@@ -2,11 +2,27 @@
 # Per-boot device-permission grant for SteamOS (read-only rootfs -> no persistent udev rule).
 # Run ONCE PER BOOT from Desktop Mode:   sudo ./setup.sh
 #
-# /dev/input/event* is already granted to the active session user (logind ACL) -- that's how the evdev
-# grab works without this. But /dev/ttyACM* (the nRF CDC link) and /dev/hidraw* are not, so this opens
-# them. It does NOT persist across reboots (nothing on the read-only FS changes); re-run after a reboot.
+# Opens, for this boot only (nothing on the read-only FS changes -- re-run after a reboot):
+#   * /dev/ttyACM*                      -- the nRF (OpenController) CDC link
+#   * the Deck controller's usbfs node  -- so the app's libusb DeckSource can detach + read it
+#                                          (28DE:1205); this is what lets it take the pad from Steam
+# /dev/input/event* is already granted to the active session (logind ACL) for the --evdev fallback.
 set -e
 shopt -s nullglob
-for f in /dev/ttyACM*; do chmod a+rw "$f" && echo "opened $f"; done
-for f in /dev/hidraw*; do chmod a+rw "$f"; done   # only needed for --hidraw gyro/trackpads
-echo "done. /dev/input is already session-ACL'd; ttyACM*/hidraw* opened for this boot."
+
+for f in /dev/ttyACM*; do chmod a+rw "$f" && echo "opened CDC $f"; done
+
+# Find the Steam Deck controller (Valve 28DE:1205) usbfs node and open it for libusb.
+opened=0
+for dd in /sys/bus/usb/devices/*; do
+	[ -r "$dd/idVendor" ] || continue
+	if [ "$(cat "$dd/idVendor" 2>/dev/null)" = "28de" ] &&
+	   [ "$(cat "$dd/idProduct" 2>/dev/null)" = "1205" ]; then
+		busnum=$(cat "$dd/busnum"); devnum=$(cat "$dd/devnum")
+		node=$(printf "/dev/bus/usb/%03d/%03d" "$busnum" "$devnum")
+		chmod a+rw "$node" && echo "opened controller usbfs $node ($dd)" && opened=1
+	fi
+done
+[ "$opened" = 1 ] || echo "WARNING: Deck controller 28DE:1205 not found on USB (is it the built-in pad?)"
+
+echo "done (this boot only)."

+ 39 - 11
OpenControllerDeckSide/ui.py

@@ -36,32 +36,45 @@ def run(app):
     f_small = _font(int(H * 0.022))
 
     tile_rects = []  # (rect, slot, tappable)
+    del_rects = []   # (rect, slot) -- the per-tile "remove bond" ✕ target
 
     def draw():
-        nonlocal tile_rects
+        nonlocal tile_rects, del_rects
         screen.fill(BG)
         s = app.status
-        # title bar
-        screen.blit(f_title.render("⬡  OpenController", True, ACCENT), (40, 28))
-        link = "● linked  ch%d" % s["sess_ch"] if s["link_up"] else "○ searching…"
-        col = LIVE if s["link_up"] else DIM
-        t = f_med.render(link, True, col)
-        screen.blit(t, (W - t.get_width() - 40, 38))
+        connected = app.dongle_connected
+        # compact status line (no big title banner): dongle presence first, then RF link
+        if not connected:
+            badge, col = "⨯ no dongle", WARN
+        elif s["link_up"]:
+            badge, col = "● linked  ch%d" % s["sess_ch"], LIVE
+        else:
+            badge, col = "○ searching…", DIM
+        t = f_med.render(badge, True, col)
+        screen.blit(t, (W - t.get_width() - 40, 24))
 
-        # tiles
+        # tiles -- live pucks float to the top, then by slot index
         tile_rects = []
-        bonds = [(i, b) for i, b in enumerate(s["bonds"]) if b["used"]]
+        del_rects = []
+        bonds = sorted(s["bonds"], key=lambda b: (not b["alive"], b["slot"]))
         top = int(H * 0.16)
         gap = int(H * 0.025)
         th = int((H * 0.42 - gap * max(0, len(bonds) - 1)) / max(1, len(bonds)))
         th = min(th, int(H * 0.20))
-        if not bonds:
+        if not connected:
+            msg = f_big.render("Plug in the OpenController dongle", True, WARN)
+            screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
+            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
+                                 True, DIM)
+            screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
+        elif not bonds:
             msg = f_big.render("No paired pucks", True, DIM)
             screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
             sub = f_small.render("Pair on a computer with Steam or PairTUI (scpair.py), then plug the puck into your host.",
                                  True, DIM)
             screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
-        for n, (slot, b) in enumerate(bonds):
+        for n, b in enumerate(bonds):
+            slot = b["slot"]
             y = top + n * (th + gap)
             rect = pygame.Rect(40, y, W - 80, th)
             sel = app.forwarding and app.fwd_slot == slot
@@ -82,6 +95,16 @@ def run(app):
             screen.blit(f_small.render("slot %d · %s" % (slot, state), True, DIM),
                         (rect.x + 90, rect.y + th - 38))
             tile_rects.append((rect, slot, tappable or sel))
+            # remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
+            if not app.forwarding:
+                dsz = min(th - 24, 64)
+                drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
+                pygame.draw.rect(screen, TILE, drect, border_radius=12)
+                pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
+                x = f_big.render("✕", True, STOP)
+                screen.blit(x, (drect.centerx - x.get_width() // 2,
+                                drect.centery - x.get_height() // 2))
+                del_rects.append((drect, slot))
 
         # firmware log panel (the in-app serial monitor) — fills the area below the tiles
         log_top = int(H * 0.60)
@@ -113,6 +136,11 @@ def run(app):
         if app.forwarding:
             app.stop_forwarding()
             return
+        # the remove-bond ✕ takes priority over the tile body it sits on
+        for drect, slot in del_rects:
+            if drect.collidepoint(pos):
+                app.remove_bond(slot)
+                return
         for rect, slot, tappable in tile_rects:
             if rect.collidepoint(pos) and tappable:
                 app.toggle(slot)