Sfoglia il codice sorgente

Add firmware and deck side app

Jariullah Safi 4 mesi fa
parent
commit
d81e479ab9

+ 82 - 0
OpenController/OpenController.ino

@@ -0,0 +1,82 @@
+// OpenController.ino -- emulated Steam Controller 2 ("Triton"/IBEX) for an nRF52840 Pro Micro.
+//
+// The mirror image of OpenPuck: instead of receiving a real controller over RF and presenting it to a
+// host, this firmware RECEIVES input from a Steam Deck over USB-CDC and TRANSMITS it over the puck RF
+// link, so a puck (real Valve or OpenPuck) presents a "Steam Controller 2026" to whatever host the puck
+// is plugged into. See ARCHITECTURE.md (OpenPuck) for the puck side; the protocol is symmetric.
+//
+//   identity / ctrl_bonds   -- FXA controller serial, the (up to 2) bonds to pucks (esb/bond[_2])
+//   radio / ctrl_link       -- shared bare-metal RADIO; the inverse RF role (answer the puck's polls)
+//   triton / report45       -- the shared input model g_in + the report-0x45 ENCODER
+//   deck_input              -- the USB-CDC frame link to the deck/ forwarder app
+//   ctrl_usb                -- the USB 28DE:1302 face + steamless pairing command channel
+//
+// Build:  arduino-cli compile -b adafruit:nrf52:feather52840 \
+//           --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" OpenController
+#include <Adafruit_TinyUSB.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+#include "identity.h"
+#include "radio.h"
+#include "ctrl_bonds.h"
+#include "ctrl_usb.h"
+#include "ctrl_link.h"
+#include "deck_input.h"
+
+// CDC (Deck link) + one HID control interface fit well within the default config buffer; use a modest
+// fixed buffer so enumeration is deterministic.
+static uint8_t g_usbCfgDesc[256];
+
+void setup()
+{
+	genSerial();
+	// safe default; the real session addr is adopted from the puck's E1
+	rfGenSessionAddr();
+	InternalFS.begin();
+	loadCtrlBonds();
+
+	// ---- USB: keep CDC (the Deck link) + add the Valve control HID; enumerate as 28DE:1302 ----
+	USBDevice.detach();
+	delay(30);
+	USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
+	// FXA… controller pairing identity
+	USBDevice.setSerialDescriptor(g_unit);
+	ctrlUsbBegin();
+	USBDevice.setConfigurationAttribute(
+		0x80 | 0x20); // bus-powered | remote-wakeup
+	USBDevice.attach();
+
+	Serial.begin(115200);
+	for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
+		delay(10); // wait up to 3s for mount, but NEVER hang
+
+	int nb = 0;
+	for (int i = 0; i < NBOND; i++)
+		if (g_bond[i].used)
+			nb++;
+	Serial.printf("# OpenController up: unit=%s bonds=%d (28DE:1302)\n",
+		      g_unit, nb);
+
+	// Hardware watchdog (~8s): a wedged RX busy-wait or blocked CDC write self-recovers without a replug.
+	NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
+			  (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
+	NRF_WDT->CRV = 8UL * 32768UL - 1;
+	NRF_WDT->RREN = WDT_RREN_RR0_Msk;
+	NRF_WDT->TASKS_START = 1;
+}
+
+void loop()
+{
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload; // feed the watchdog
+
+	// flush dirty bonds / honor a pending reboot-to-wireless
+	ctrlUsbPoll();
+	// drain CDC: parse Deck input frames into g_in + forwarding state
+	deckInputPoll();
+	// RF: hunt/adopt the puck's E1, answer one poll with report 0x45
+	ctrlLinkTask();
+	// report bond + link state back to the Deck UI
+	deckStatusTask();
+}

+ 58 - 0
OpenController/ctrl_bonds.cpp

@@ -0,0 +1,58 @@
+#include "ctrl_bonds.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+#include <string.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+CtrlBond g_bond[NBOND];
+volatile bool g_bondDirty = false;
+
+#define CTRLBOND_FILE "/ctrlbond.bin"
+
+bool ctrlRecEmpty(const uint8_t *r)
+{
+	for (int i = 0; i < 24; i++)
+		if (r[i])
+			return false;
+	return true;
+}
+
+void saveCtrlBonds()
+{
+	InternalFS.remove(CTRLBOND_FILE);
+	File f(InternalFS);
+	if (f.open(CTRLBOND_FILE, FILE_O_WRITE)) {
+		for (int i = 0; i < NBOND; i++) {
+			uint8_t u = g_bond[i].used ? 1 : 0;
+			f.write(&u, 1);
+			f.write(g_bond[i].rec, 24);
+		}
+		f.close();
+	}
+}
+
+void loadCtrlBonds()
+{
+	File f(InternalFS);
+	if (f.open(CTRLBOND_FILE, FILE_O_READ))
+		for (int i = 0; i < NBOND; i++) {
+			uint8_t u = 0;
+			if (f.read(&u, 1) == 1) {
+				g_bond[i].used = u;
+				f.read(g_bond[i].rec, 24);
+			}
+		}
+	f.close();
+}
+
+int ctrlBondMatch(const uint8_t *proteus_uuid, const uint8_t *ibex_uuid)
+{
+	for (int i = 0; i < NBOND; i++) {
+		if (!g_bond[i].used)
+			continue;
+		if (memcmp(g_bond[i].rec + 0, proteus_uuid, 4) == 0 &&
+		    memcmp(g_bond[i].rec + 4, ibex_uuid, 4) == 0)
+			return i;
+	}
+	return -1;
+}

+ 28 - 0
OpenController/ctrl_bonds.h

@@ -0,0 +1,28 @@
+// 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
+//   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`.
+#pragma once
+#include <stdint.h>
+
+#define NBOND 2
+
+struct CtrlBond {
+	uint8_t rec[24]; // [proteus_uuid 4][ibex_uuid 4][puck_serial 16]
+	bool used;
+	// feature-report reply staging (filled in ISR, read on GET)
+	uint8_t resp[63];
+	uint16_t resp_len;
+};
+extern CtrlBond g_bond[NBOND];
+extern volatile bool g_bondDirty; // bonds changed -> flush to flash from loop()
+
+bool ctrlRecEmpty(const uint8_t *r);
+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);

+ 280 - 0
OpenController/ctrl_link.cpp

@@ -0,0 +1,280 @@
+#include "ctrl_link.h"
+#include "radio.h"
+#include "ctrl_bonds.h"
+#include "report45.h"
+#include "deck_input.h"
+#include "triton.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+int g_linkSlot = -1;
+unsigned long g_linkAliveMs = 0;
+
+// Link is considered up while we've heard a poll within this window (the puck polls ~250 Hz).
+#define LINK_ALIVE_MS 1000u
+// RX dwell per loop. One transaction (catch a poll, reply) per loop -> ~200 Hz, plenty for input, and
+// short enough that USB/CDC servicing isn't starved. Tunable on hardware.
+#define RX_WIN_US 4500u
+// Time the puck's RX window stays open for our reply; keep the turnaround well under this.
+static uint8_t g_seq = 0;
+// Last time we caught a frame on the SESSION address (NOT the ch2 discovery beacon). This is the
+// truthful "we're actually exchanging with the puck" signal that drives ctrlLinkUp() / the UI
+// "available" tile -- hearing the discovery beacon alone does NOT mean the session works.
+static unsigned long g_sessRxMs = 0;
+
+// SESSION-CHANNEL HUNT. The E1 host frame advertises a "primary" channel, but that channel mostly
+// carries the puck's E1/E2 keepalive -- the ACTIVE session (E3 input poll + our F1 reply) rides a small
+// channel set, NOT necessarily the advertised primary (proven by the real-puck capture sniff1.json:
+// E1/E2 on ch68, the E3/F1 session on ch48; and by puck_sniffer.ino's session-hunt). So once we adopt a
+// session we DWELL across {primary, then these candidates} on the session address until a poll (E2/E3)
+// appears, then PARK on that channel. If the parked channel goes dry (QoS hop / puck moved), un-park and
+// hunt again. Mirrors puck_sniffer.ino HUNT_CH.
+static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52, 55, 56, 57 };
+static uint8_t g_huntIdx = 0;
+static uint8_t g_parkCh = 0; // 0 = hunting; non-zero = parked on this channel
+static unsigned long g_huntStepMs = 0;
+#define HUNT_DWELL_MS 40u // per-channel dwell while hunting
+#define RECONNECT_MS \
+	4000u // dry this long -> abandon the session, re-catch E1 on ch2
+
+// ---- build replies into rftx (ESB DPL RAM layout: [LENGTH][S1][payload...]) ----
+static void buildF1()
+{
+	// payload = [0xF1][tlen][ttype=6][report45(46)]  -> length = 3 + REPORT45_LEN
+	rftx[0] = (uint8_t)(3 + REPORT45_LEN); // LENGTH (payload byte count)
+	rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1); // S1: PID<<1 | NO_ACK
+	rftx[2] = 0xF1;
+	rftx[3] = REPORT45_LEN; // TLV len
+	rftx[4] = 6; // TLV type 6 (HID report)
+	// neutral report (idle controller) when not forwarding or input has gone stale
+	bool fresh = deckForwarding() && (millis() - deckLastInputMs() < 200u);
+	if (fresh) {
+		buildReport45(rftx + 5, g_seq++);
+	} else {
+		memset(rftx + 5, 0, REPORT45_LEN);
+		rftx[5] = 0x45;
+		rftx[6] = g_seq++;
+	}
+}
+
+static void buildF3()
+{
+	// reply to E7 awake/version probe: rf_link reads the version at rfrx[6] (payload byte 4).
+	rftx[0] = 5;
+	rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
+	rftx[2] = 0xF3;
+	rftx[3] = 0;
+	rftx[4] = 0;
+	rftx[5] = 0;
+	rftx[6] = 1; // protocol/version byte
+}
+
+// Transmit whatever is staged in rftx on the currently-configured channel/address, then return to RX
+// config. Mirrors rf_link's TX path; radio is left disabled at exit.
+static void txStaged()
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}
+
+// One RX->maybe-reply transaction on (base,prefix,ch). Returns the received type byte (rfrx[2]) or 0.
+// `reply` selects whether we answer polls (true once connected) or just listen (discovery).
+static uint8_t rxOnce(const uint8_t *base, uint8_t prefix, uint8_t ch,
+		      uint16_t winUs, bool reply)
+{
+	rfConfig(ch);
+	rfSetAddr(base, prefix);
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (uint32_t)(micros() - t0) < winUs) {
+	}
+	uint8_t type = 0;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		g_rfRxCount++;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t rxlen = rfrx[0];
+		if (crcok && rxlen && rxlen <= 96) {
+			type = rfrx[2];
+			if (reply) {
+				// 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 == 0xE7) {
+					buildF3();
+					txStaged();
+				} else if (type >= 0xE0 && type <= 0xEF) {
+					// E3 GET (and any other E-type poll): answer with input.
+					buildF1();
+					txStaged();
+				}
+			}
+			return type;
+		}
+	}
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	return type;
+}
+
+// Listen on the shared "ibex"/ch2 rendezvous for a discovery E1 that matches a stored bond. On a match,
+// adopt the advertised session base/prefix/channel and switch to connected.
+static void discoveryScan()
+{
+	uint8_t t = rxOnce(g_rfBase, g_rfPrefix, 2, RX_WIN_US, false);
+	if (t != 0xE1)
+		return;
+	const uint8_t *proteus =
+		rfrx + 3; // payload [proteus_uuid 4][ibex_uuid 4]
+	const uint8_t *ibex = rfrx + 7;
+	int slot = ctrlBondMatch(proteus, ibex);
+	if (slot < 0) {
+		// We hear the puck's beacon but its UUIDs don't match any stored bond -- the controller
+		// side of the pairing didn't land, or we're paired to a different puck. Very useful signal.
+		static unsigned long lastMiss = 0;
+		if (Serial.availableForWrite() > 40 &&
+		    millis() - lastMiss > 1000) {
+			lastMiss = millis();
+			Serial.printf(
+				"# E1 heard, no bond match: puuid=%02X%02X%02X%02X iuuid=%02X%02X%02X%02X\n",
+				proteus[0], proteus[1], proteus[2], proteus[3],
+				ibex[0], ibex[1], ibex[2], ibex[3]);
+		}
+		return;
+	}
+	// adopt the advertised session address (rfHostFrameOnce layout: ch@[11], base@[15..19], prefix@[19])
+	uint8_t advCh = rfrx[11];
+	memcpy(g_sessBase, rfrx + 15, 4);
+	g_sessPrefix = rfrx[19];
+	if (advCh)
+		g_sessCh = advCh;
+	g_linkSlot = slot;
+	g_linkAliveMs = millis();
+	g_parkCh = 0; // start hunting the session channel set from scratch
+	g_huntIdx = 0;
+	g_huntStepMs = millis();
+	deckText("link: adopted session");
+	if (Serial.availableForWrite() > 40)
+		Serial.printf(
+			"# LINK adopted slot=%d base=%02X%02X%02X%02X/%02X ch=%u\n",
+			slot, g_sessBase[0], g_sessBase[1], g_sessBase[2],
+			g_sessBase[3], g_sessPrefix, g_sessCh);
+}
+
+bool ctrlLinkUp()
+{
+	// "up" = we caught a real SESSION-address frame recently (a poll/keepalive on the adopted
+	// base+channel), not merely the ch2 discovery beacon -- so the UI tile only goes tappable when
+	// the puck is genuinely polling us.
+	return g_linkSlot >= 0 && (millis() - g_sessRxMs) < LINK_ALIVE_MS;
+}
+
+void ctrlLinkTask()
+{
+	// nothing to do until at least one puck is bonded
+	bool anyBond = false;
+	for (int i = 0; i < NBOND; i++)
+		if (g_bond[i].used)
+			anyBond = true;
+	if (!anyBond) {
+		static unsigned long lastNb = 0;
+		if (Serial.availableForWrite() > 40 &&
+		    millis() - lastNb > 2000) {
+			lastNb = millis();
+			Serial.println(
+				"# no bonds stored -- pair first (controller never got 0xEE)");
+		}
+		g_linkSlot = -1;
+		return;
+	}
+
+	// 1 Hz status line for a serial monitor: searching vs connected, channel, RX count.
+	{
+		static unsigned long stMs = 0;
+		static uint32_t lastRx = 0;
+		if (Serial.availableForWrite() > 50 &&
+		    millis() - stMs >= 1000) {
+			stMs = millis();
+			Serial.printf(
+				"# link slot=%d %s ch=%u park=%u rx=%lu/s\n",
+				g_linkSlot,
+				ctrlLinkUp() ? "CONNECTED" : "searching",
+				g_sessCh, g_parkCh,
+				(unsigned long)(g_rfRxCount - lastRx));
+			lastRx = g_rfRxCount;
+		}
+	}
+
+	// No session adopted yet -> hunt the E1 host frame on the shared ch2/"ibex" rendezvous.
+	if (g_linkSlot < 0) {
+		discoveryScan();
+		return;
+	}
+
+	// Adopted a session, but the puck has gone fully dry for a long time -> abandon it and re-catch the
+	// E1 (its primary channel may have changed). Only after RECONNECT_MS so a hunt sweep has a chance.
+	if ((millis() - g_linkAliveMs) >= RECONNECT_MS) {
+		g_linkSlot = -1;
+		g_parkCh = 0;
+		g_huntIdx = 0;
+		deckText("link: lost, rescanning");
+		if (Serial.availableForWrite() > 40)
+			Serial.println("# LINK lost, rescanning ch2");
+		return;
+	}
+
+	// Pick the channel to listen on: parked channel if we have one, else sweep {primary, HUNT_CH...}.
+	uint8_t tryCh = g_parkCh ? g_parkCh :
+				   (g_huntIdx == 0 ?
+					    g_sessCh :
+					    HUNT_CH[(g_huntIdx - 1) %
+						    (uint8_t)sizeof HUNT_CH]);
+
+	uint8_t t = rxOnce(g_sessBase, g_sessPrefix, tryCh, RX_WIN_US, true);
+
+	if (t == 0xE1) {
+		// keepalive beacon on the session base: refreshes the advertised primary channel (rxOnce already
+		// copied it into g_sessCh) + proves the puck is present, but it is NOT a poll -> keep hunting.
+		g_linkAliveMs = millis();
+	} else if (t >= 0xE0 && t <= 0xEF) {
+		// a real poll (E2/E3/E7) -> the session lives on THIS channel; PARK and answer it from here.
+		bool wasUp = (millis() - g_sessRxMs) < LINK_ALIVE_MS;
+		g_parkCh = tryCh;
+		g_sessCh = tryCh;
+		g_linkAliveMs = g_sessRxMs = millis();
+		if (!wasUp) {
+			deckText("link: session live");
+			if (Serial.availableForWrite() > 40)
+				Serial.printf(
+					"# SESSION live: type=%02X ch=%u base=%02X%02X%02X%02X/%02X\n",
+					t, tryCh, g_sessBase[0], g_sessBase[1],
+					g_sessBase[2], g_sessBase[3],
+					g_sessPrefix);
+		}
+	} else {
+		// nothing this dwell. parked channel gone dry -> resume hunting (QoS hop / puck moved)
+		if (g_parkCh && (millis() - g_sessRxMs) >= LINK_ALIVE_MS)
+			g_parkCh = 0;
+		if (!g_parkCh && (millis() - g_huntStepMs) >= HUNT_DWELL_MS) {
+			g_huntStepMs = millis();
+			g_huntIdx++;
+		}
+	}
+}

+ 19 - 0
OpenController/ctrl_link.h

@@ -0,0 +1,19 @@
+// ctrl_link.h -- the inverse of OpenPuck's rf_link.cpp: the CONTROLLER side of the puck link.
+//
+// rf_link.cpp (puck) advertises an E1 host frame, then POLLS the controller (E7 awake, E3 GET 0x45) and
+// decodes the controller's 0xF1 reply. Here we run the mirror image: we RX the puck's E1 (adopt its
+// advertised session base/prefix/channel when it matches a stored bond), then ANSWER its polls -- F1
+// carrying report 0x45 built from the Deck's input (report45.cpp), F3 to the E7 awake/version probe.
+//
+// No RF pairing handshake: pairing is done over USB (Steam writes both sides), reconnect is purely
+// "hear an E1 with my UUIDs -> adopt -> answer polls" -- the same model rf_link relies on.
+#pragma once
+#include <stdint.h>
+
+// connected bond index (-1 = searching) and the last time we heard a poll on the session address.
+extern int g_linkSlot;
+extern unsigned long g_linkAliveMs;
+
+void ctrlLinkTask();
+// connected AND recently polled (drives the Deck UI "available" indicator + neutral-report decision).
+bool ctrlLinkUp();

+ 218 - 0
OpenController/ctrl_usb.cpp

@@ -0,0 +1,218 @@
+#include "ctrl_usb.h"
+#include "ctrl_bonds.h"
+#include "ctrl_link.h"
+#include "identity.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+// Same Valve HID descriptor shape the puck uses (mouse 0x40 + keyboard 0x41 + vendor input reports +
+// the 63-byte FEATURE command reports on id 1/2). The first collection is Generic Desktop (usagePage
+// 0x01), so the host classifies this as the usagePage-0x01 control interface Steam/PairTUI write to.
+static const uint8_t CTRL_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
+	0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
+	0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
+	0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
+	0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
+	0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
+	0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
+	0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
+	0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
+	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
+	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
+	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
+	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
+	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0
+};
+
+static Adafruit_USBD_HID g_hid;
+static uint8_t g_resp[63];
+static uint16_t g_resp_len = 0;
+static volatile bool g_rebootPending = false;
+
+// Select bond slot from a Zephyr settings key string: "esb/bond" -> 0, "esb/bond_2" -> 1.
+static int slotForKey(const char *key)
+{
+	if (strcmp(key, "esb/bond") == 0)
+		return 0;
+	if (strcmp(key, "esb/bond_2") == 0)
+		return 1;
+	return -1;
+}
+
+static void writeBond(int slot, const uint8_t *rec24)
+{
+	if (slot < 0 || slot >= NBOND)
+		return;
+	if (ctrlRecEmpty(rec24)) {
+		g_bond[slot].used = false;
+		memset(g_bond[slot].rec, 0, 24);
+	} else {
+		memcpy(g_bond[slot].rec, rec24, 24);
+		g_bond[slot].used = true;
+	}
+	g_bondDirty = true;
+}
+
+// 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)
+{
+	(void)rid;
+	if (type != HID_REPORT_TYPE_FEATURE || n < 1)
+		return;
+	uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
+	const uint8_t *pl = b + 2;
+	uint16_t pln = (n >= 2) ? n - 2 : 0;
+	(void)len;
+
+	// Plain-text diagnostics for a serial monitor during pairing. The Deck app resyncs on the frame
+	// sync word, so these unframed lines are ignored by it -- only a human monitor sees them.
+	if (Serial.availableForWrite() > 40) {
+		Serial.printf("# SET rid=%u cmd=%02X len=%u pln=%u:", rid, cmd,
+			      len, (unsigned)pln);
+		for (uint16_t i = 0; i < n && i < 12; i++)
+			Serial.printf(" %02X", b[i]);
+		Serial.println();
+	}
+
+	memset(g_resp, 0, sizeof g_resp);
+	g_resp_len = 0;
+	switch (cmd) {
+	case 0x83: { // product attributes (0x1302)
+		uint16_t alen = ATTR83_LEN > 60 ? 60 : ATTR83_LEN;
+		g_resp[0] = 0x83;
+		g_resp[1] = (uint8_t)alen;
+		memcpy(g_resp + 2, ATTR83, alen);
+		g_resp_len = 63;
+		break;
+	}
+	case 0xAE: { // string attributes: 0=board, 1=unit serial
+		uint8_t idx = pln > 0 ? pl[0] : 1;
+		const char *s = (idx == 0) ? g_board :
+				(idx == 1) ? g_unit :
+					     "NA";
+		g_resp[0] = 0xAE;
+		g_resp[1] = 0x14;
+		g_resp[2] = idx;
+		memcpy(g_resp + 3, s, strlen(s));
+		g_resp_len = 63;
+		break;
+	}
+	case 0xB4: // wireless transport state (1 = ok)
+		g_resp[0] = 0xB4;
+		g_resp[1] = 0x01;
+		g_resp[2] = ctrlLinkUp() ? 0x02 : 0x01;
+		g_resp_len = 63;
+		break;
+	case 0xEE: { // keyed bond write: ["key"\0][r1 4][r2 4][puck_serial 16]
+		char key[24];
+		uint16_t k = 0;
+		while (k < pln && k < sizeof(key) - 1 && pl[k] != 0)
+			key[k] = (char)pl[k], k++;
+		key[k] = 0;
+		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);
+		if (reclen >= 24)
+			writeBond(sk, rec);
+		if (Serial.availableForWrite() > 40)
+			Serial.printf(
+				"# EE key='%s' slot=%d reclen=%u stored=%d\n",
+				key, sk, (unsigned)reclen,
+				(sk >= 0 && reclen >= 24) ? 1 : 0);
+		g_resp[0] = 0xEE;
+		g_resp_len = 63;
+		break;
+	}
+	case 0xEF: // commit keyed value (we write-through on 0xEE, so just ack)
+		g_resp[0] = 0xEF;
+		g_resp_len = 63;
+		break;
+	case 0xA2: // direct 24-byte bond write to slot 0 (fallback shape)
+		if (pln >= 24)
+			writeBond(0, pl);
+		g_resp[0] = 0xA2;
+		g_resp_len = 63;
+		break;
+	case 0xA3: // read slot 0 bond
+		g_resp[0] = 0xA3;
+		g_resp[1] = 0x18;
+		if (g_bond[0].used)
+			memcpy(g_resp + 2, g_bond[0].rec, 24);
+		g_resp_len = 63;
+		break;
+	// reboot into wireless mode (magic 0xA427AF52); the reset happens after the flash flush
+	case 0x95:
+		g_rebootPending = true;
+		if (Serial.availableForWrite() > 40)
+			Serial.println("# 0x95 reboot-to-wireless requested");
+		g_resp[0] = 0x95;
+		g_resp_len = 63;
+		break;
+	default:
+		g_resp[0] = cmd;
+		g_resp[1] = len;
+		if (pln)
+			memcpy(g_resp + 2, pl, pln > 60 ? 60 : pln);
+		g_resp_len = 63;
+		break;
+	}
+}
+
+static uint16_t handleGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
+			  uint16_t reqlen)
+{
+	(void)rid;
+	if (type != HID_REPORT_TYPE_FEATURE)
+		return 0;
+	uint16_t nn = g_resp_len ? g_resp_len : 63;
+	if (nn > reqlen)
+		nn = reqlen;
+	memcpy(buf, g_resp, nn);
+	return nn;
+}
+
+void ctrlUsbBegin()
+{
+	USBDevice.setID(0x28DE, 0x1302);
+	USBDevice.setDeviceVersion(0x0101);
+	USBDevice.setManufacturerDescriptor("Valve Software");
+	USBDevice.setProductDescriptor("Steam Controller");
+	g_hid.setReportDescriptor(CTRL_HID_DESC, sizeof CTRL_HID_DESC);
+	g_hid.setReportCallback(handleGet, handleSet);
+	g_hid.setPollInterval(1);
+	g_hid.begin();
+}
+
+void ctrlUsbPoll()
+{
+	if (g_bondDirty) {
+		g_bondDirty = false;
+		saveCtrlBonds();
+	}
+	// reboot only AFTER the freshly-written bond is on flash (else it reloads a stale bond on boot).
+	if (g_rebootPending && !g_bondDirty) {
+		delay(40);
+		NVIC_SystemReset();
+	}
+}

+ 16 - 0
OpenController/ctrl_usb.h

@@ -0,0 +1,16 @@
+// ctrl_usb.h -- the emulated controller's USB face (Valve 28DE:1302) + pairing command channel.
+//
+// Presents a single Valve HID control interface (usagePage 0x01, feature reports id 1/2) so Steam and
+// PairTUI treat us as a dockable Steam Controller. We ANSWER identity reads (0x83 product, 0xAE serial,
+// 0xB4 wireless state) and ACCEPT the steamless pairing writes that target the controller:
+//   0xEE  keyed value write   payload ["esb/bond"\0 or "esb/bond_2"\0][r1 4][r2 4][puck_serial 16]
+//   0xEF  keyed value commit  payload ["esb/bond"\0]
+//   0xA2  direct 24-byte bond write to slot 0 (PairTUI/Steam fallback shape)
+//   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
+
+// register the HID interface (call from setup, before USBDevice.attach)
+void ctrlUsbBegin();
+// flush dirty bonds + honor a pending reboot (call each loop)
+void ctrlUsbPoll();

+ 174 - 0
OpenController/deck_input.cpp

@@ -0,0 +1,174 @@
+#include "deck_input.h"
+#include "triton.h"
+#include "ctrl_bonds.h"
+#include "ctrl_link.h"
+#include "radio.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+static bool g_forwarding = false;
+static unsigned long g_lastInputMs = 0;
+static unsigned long g_lastStatusMs = 0;
+
+bool deckForwarding()
+{
+	return g_forwarding;
+}
+unsigned long deckLastInputMs()
+{
+	return g_lastInputMs;
+}
+
+// ---- little-endian payload readers ----
+static inline int16_t rd_s16(const uint8_t *p)
+{
+	return (int16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
+}
+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);
+}
+
+// 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
+static void applyInput(const uint8_t *p, uint8_t n)
+{
+	if (n < DECK_INPUT_LEN)
+		return;
+	g_in.buttons = rd_u32(p + 0);
+	g_in.lx = rd_s16(p + 4);
+	g_in.ly = rd_s16(p + 6);
+	g_in.rx = rd_s16(p + 8);
+	g_in.ry = rd_s16(p + 10);
+	g_in.lt = p[12];
+	g_in.rt = p[13];
+	g_in.lpx = rd_s16(p + 14);
+	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_lastInputMs = millis();
+}
+
+static void handleFrame(uint8_t type, const uint8_t *p, uint8_t n)
+{
+	switch (type) {
+	case DECK_T_INPUT:
+		applyInput(p, n);
+		break;
+	case DECK_T_CONTROL:
+		if (n >= 2 && p[0] == 0x01)
+			g_forwarding = (p[1] != 0);
+		break;
+	case DECK_T_REQ_STATUS:
+		g_lastStatusMs = 0; // force an immediate status emit
+		break;
+	default:
+		break;
+	}
+}
+
+// ---- RX state machine: resync on the 2-byte sync word ----
+void deckInputPoll()
+{
+	// state: 0 wait sync0, 1 wait sync1, 2 type, 3 len, 4 payload, 5 sum
+	static uint8_t st = 0;
+	static uint8_t type = 0, len = 0, idx = 0, sum = 0;
+	static uint8_t buf[64];
+	while (Serial.available()) {
+		uint8_t c = (uint8_t)Serial.read();
+		switch (st) {
+		case 0:
+			if (c == DECK_SYNC0)
+				st = 1;
+			break;
+		case 1:
+			st = (c == DECK_SYNC1) ? 2 : (c == DECK_SYNC0 ? 1 : 0);
+			break;
+		case 2:
+			type = c;
+			sum = c;
+			st = 3;
+			break;
+		case 3:
+			len = c;
+			sum += c;
+			idx = 0;
+			if (len > sizeof buf) { // oversized -> drop, resync
+				st = 0;
+				break;
+			}
+			st = (len == 0) ? 5 : 4;
+			break;
+		case 4:
+			buf[idx++] = c;
+			sum += c;
+			if (idx >= len)
+				st = 5;
+			break;
+		case 5:
+			if (c == sum)
+				handleFrame(type, buf, len);
+			st = 0;
+			break;
+		}
+	}
+}
+
+// ---- frame TX ----
+static void sendFrame(uint8_t type, const uint8_t *p, uint8_t n)
+{
+	// need room for the whole frame or skip (never block the RF loop on CDC backpressure)
+	if ((int)Serial.availableForWrite() < n + 5)
+		return;
+	uint8_t hdr[4] = { DECK_SYNC0, DECK_SYNC1, type, n };
+	uint8_t sum = (uint8_t)(type + n);
+	for (uint8_t i = 0; i < n; i++)
+		sum += p[i];
+	Serial.write(hdr, 4);
+	if (n)
+		Serial.write(p, n);
+	Serial.write(&sum, 1);
+}
+
+void deckText(const char *s)
+{
+	size_t n = strlen(s);
+	if (n > 60)
+		n = 60;
+	sendFrame(DECK_T_TEXT, (const uint8_t *)s, (uint8_t)n);
+}
+
+// STATUS payload: [flags][linkSlot][sessCh][nbond] + per-bond [used][alive][puuid4][iuuid4][serial16]
+void deckStatusTask()
+{
+	if (millis() - g_lastStatusMs < 250)
+		return;
+	g_lastStatusMs = millis();
+
+	uint8_t p[4 + NBOND * 26];
+	memset(p, 0, sizeof p);
+	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;
+	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;
+		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;
+	}
+	sendFrame(DECK_T_STATUS, p, sizeof p);
+}

+ 36 - 0
OpenController/deck_input.h

@@ -0,0 +1,36 @@
+// deck_input.h -- the USB-CDC link to the Steam Deck forwarder app.
+//
+// The Deck app (deck/) streams the Deck's grabbed controls as INPUT frames; we decode them into g_in
+// (report45.cpp then encodes the RF report 0x45). We stream STATUS frames back so the app's UI can show
+// which bonded pucks are live. One framed binary protocol both ways; the reader resynchronizes on a
+// 2-byte sync word so a serial monitor's stray text is harmless. The wire format is mirrored exactly in
+// deck/frame.py.
+//
+//   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)
+#pragma once
+#include <stdint.h>
+
+#define DECK_SYNC0 0xA5
+#define DECK_SYNC1 0x5A
+
+#define DECK_T_INPUT 0x01
+#define DECK_T_CONTROL 0x02
+#define DECK_T_REQ_STATUS 0x03
+#define DECK_T_STATUS 0x10
+#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();
+// Send a debug text line as a 0x1F frame (skipped by the app unless it shows debug).
+void deckText(const char *s);
+
+// 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.
+unsigned long deckLastInputMs();

+ 30 - 0
OpenController/identity.cpp

@@ -0,0 +1,30 @@
+#include "identity.h"
+#include <Arduino.h>
+#include <stdio.h>
+#include <string.h>
+
+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.
+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 };
+const uint16_t ATTR83_LEN = sizeof ATTR83;
+
+void genSerial()
+{
+	uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
+	snprintf(g_unit, sizeof g_unit, "FXA99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
+	snprintf(g_board, sizeof g_board, "MXA99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
+}

+ 18 - 0
OpenController/identity.h

@@ -0,0 +1,18 @@
+// identity.h -- emulated-controller identity derived from the nRF52 FICR DEVICEID.
+//
+// Mirrors OpenPuck/identity.h but for the CONTROLLER (IBEX, USB PID 0x1302). A real wired Steam
+// Controller enumerates as 28DE:1302 with an "FXA99602…" unit serial (HANDOFF.md). We generate a
+// unique FXA/MXA serial so we never clash with a real controller, and expose a 0x1302 product blob.
+#pragma once
+#include <stdint.h>
+
+// "FXA99602xxxxx" -- the controller's pairing identity (USB serial)
+extern char g_unit[16];
+extern char g_board[16]; // "MXA99602xxxxx"
+
+// 0x83 attributes, returned by the feature command channel (product code 0x1302). Unsized so a
+// captured real-controller blob of any length drops in; ATTR83_LEN carries the true byte count.
+extern const uint8_t ATTR83[];
+extern const uint16_t ATTR83_LEN;
+
+void genSerial();

+ 107 - 0
OpenController/radio.cpp

@@ -0,0 +1,107 @@
+#include "radio.h"
+
+// *** VERBATIM COPY of OpenPuck/radio.cpp -- keep byte-identical (see radio.h header note). ***
+// On the controller emulator rfGenSessionAddr() only seeds a safe default; the operational session
+// address is ADOPTED from the puck's E1 host frame in ctrl_link.cpp.
+
+const uint8_t PAIR_BASE[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+uint8_t g_rfPrefix = 0x10;
+uint8_t g_rfCh = 2;
+uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+// ch18: clean channel in the real puck's active hop set {18,2,80}. Tunable.
+uint8_t g_sessCh = 18;
+
+// Safe default; adopted from the puck's E1 host frame at connect time.
+uint8_t g_sessBase[4] = { 0x69, 0x62, 0x65, 0x78 };
+uint8_t g_sessPrefix = 0x10;
+
+void rfGenSessionAddr()
+{
+	uint32_t h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^
+		     NRF_FICR->DEVICEID[1];
+	uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
+		      NRF_FICR->DEVICEID[0];
+	for (int i = 0; i < 4; i++) {
+		uint8_t b = (uint8_t)(h >> (i * 8));
+		if (b == 0x00 || b == 0xFF)
+			b ^= 0x5A;
+		g_sessBase[i] = b;
+	}
+	uint8_t p = (uint8_t)(h2 >> 16);
+	if (p == 0x00 || p == 0xFF)
+		p = 0x5C;
+	g_sessPrefix = p;
+	if (g_sessBase[0] == g_rfBase[0] && g_sessBase[1] == g_rfBase[1] &&
+	    g_sessBase[2] == g_rfBase[2] && g_sessBase[3] == g_rfBase[3])
+		g_sessBase[0] ^= 0x80;
+}
+
+uint8_t rfrx[100], rftx[100];
+uint32_t g_rfRxCount = 0;
+
+// Puck-link config decoded from real-puck capture: Ble_2Mbit, ENDIAN=Big, NO whitening, addr "ibex",
+// CRC16 0x11021/0xFFFF; static-length frame.
+uint32_t g_crcinit = 0xFFFF;
+uint8_t g_whiteiv = 37;
+uint8_t g_mode = RADIO_MODE_MODE_Ble_2Mbit;
+
+// S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint32_t g_pcnf0 = 0x00030008UL;
+uint8_t g_statlen = 0x20;
+
+// ENDIAN=Big, BALEN4, MAXLEN=96.
+uint32_t g_pcnf1 = 0x01040060;
+uint32_t g_crcpoly = 0x11021UL;
+uint16_t g_crccnf = 0x2; // CRC16, address included
+uint8_t g_pid = 0;
+uint8_t g_balen = 4; // ESB 5-byte addr
+bool g_prefixRaw = false;
+
+uint8_t rfBitrev8(uint8_t x)
+{
+	uint8_t y = 0;
+	for (int i = 0; i < 8; i++) {
+		y = (y << 1) | (x & 1);
+		x >>= 1;
+	}
+	return y;
+}
+
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix)
+{
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(b4[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1u << 0;
+}
+
+void rfConfig(uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->TXPOWER =
+		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
+#endif
+	NRF_RADIO->PCNF0 = g_pcnf0;
+
+	NRF_RADIO->PCNF1 =
+		g_pcnf1 ? g_pcnf1 :
+			  ((1u << RADIO_PCNF1_ENDIAN_Pos) |
+			   ((uint32_t)g_balen << RADIO_PCNF1_BALEN_Pos) |
+			   ((uint32_t)g_statlen << RADIO_PCNF1_STATLEN_Pos) |
+			   ((uint32_t)g_statlen << RADIO_PCNF1_MAXLEN_Pos));
+	NRF_RADIO->CRCCNF = g_crccnf; // CRC16, address included
+	NRF_RADIO->CRCPOLY = g_crcpoly;
+	NRF_RADIO->CRCINIT = g_crcinit;
+	NRF_RADIO->DATAWHITEIV = g_whiteiv;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}

+ 64 - 0
OpenController/radio.h

@@ -0,0 +1,64 @@
+// radio.h -- bare-metal nRF52 RADIO (ESB-style) hardware layer.
+//
+// *** VERBATIM COPY of OpenPuck/radio.h -- keep byte-identical. ***
+// The controller emulator speaks the SAME 2.4 GHz PHY/CRC/address layer as the puck; only the
+// roles are inverted (we answer polls instead of issuing them). Arduino compiles a sketch folder
+// flat, so this file is duplicated rather than shared. If you change the radio layer in OpenPuck,
+// mirror it here (and vice-versa) -- the two MUST agree on the air.
+//
+// Register-confirmed config (decoded from real-puck capture): PHY=Ble_2Mbit, ENDIAN=Big (MSB-first),
+// whitening off, addr "ibex", CRC16 poly 0x11021 init 0xFFFF (address included). On-air address =
+// bitrev8 of stored bytes.
+#pragma once
+#include <stdint.h>
+#include <Arduino.h>
+
+// Bounded radio-disable wait: NEVER spin forever. A wedged RADIO would hang the main loop (USB stops
+// being serviced -> device "dies" until replug). On timeout we bail; the next rfConfig re-inits.
+#define RWAIT_DISABLED()                                   \
+	do {                                               \
+		uint32_t _w = micros();                    \
+		while (!NRF_RADIO->EVENTS_DISABLED &&      \
+		       (uint32_t)(micros() - _w) < 3000) { \
+		}                                          \
+	} while (0)
+
+// Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
+//   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
+extern const uint8_t PAIR_BASE[4]; // "ibex"
+extern uint8_t g_rfPrefix; // discovery prefix
+extern uint8_t g_rfCh; // current TX/RX channel
+extern uint8_t g_rfBase[4]; // "ibex"
+// connected-session channel: a CLEAN data channel off the congested adv ch2
+extern uint8_t g_sessCh;
+
+// Session address (base+prefix). On the puck this is per-device and advertised; on the controller
+// emulator it is ADOPTED from the puck's E1 host frame (the puck advertises it; we follow).
+extern uint8_t g_sessBase[4];
+extern uint8_t g_sessPrefix;
+void rfGenSessionAddr();
+
+// RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
+extern uint8_t rfrx[100], rftx[100];
+extern uint32_t g_rfRxCount;
+
+// ---- tunable radio parameters ----
+extern uint32_t g_crcinit;
+extern uint8_t g_whiteiv;
+extern uint8_t g_mode; // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
+extern uint32_t g_pcnf0; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
+extern uint8_t g_statlen; // static-mode payload length
+extern uint32_t g_pcnf1; // ENDIAN=Big, BALEN4, MAXLEN96 -- CRC-VALIDATED
+extern uint32_t g_crcpoly;
+extern uint16_t g_crccnf; // CRC16, address included
+extern uint8_t g_pid;
+extern uint8_t g_balen; // ESB 5-byte addr
+// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+extern bool g_prefixRaw;
+
+uint8_t rfBitrev8(uint8_t x);
+// Access address (IBEX FUN_00037530): BASE0 = bitrev8 each base byte packed big-endian; PREFIX0 = raw
+// or bitrev8 prefix per g_prefixRaw. "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix);
+// Program MODE/FREQUENCY/PCNF/CRC/whitening from the tunables above onto channel ch (radio left disabled).
+void rfConfig(uint8_t ch);

+ 54 - 0
OpenController/report45.cpp

@@ -0,0 +1,54 @@
+#include "report45.h"
+#include "triton.h"
+#include <string.h>
+
+// Write a signed 16-bit value little-endian at rep[2+off] (the same indexing triton.h's s16off reads).
+static inline void put_s16(uint8_t *rep, int off, int16_t v)
+{
+	rep[2 + off] = (uint8_t)(v & 0xFF);
+	rep[2 + off + 1] = (uint8_t)((v >> 8) & 0xFF);
+}
+// Write an unsigned 16-bit value little-endian at rep[2+off] (matches u16off).
+static inline void put_u16(uint8_t *rep, int off, uint16_t v)
+{
+	rep[2 + off] = (uint8_t)(v & 0xFF);
+	rep[2 + off + 1] = (uint8_t)((v >> 8) & 0xFF);
+}
+
+void buildReport45(uint8_t *out, uint8_t seq)
+{
+	memset(out, 0, REPORT45_LEN);
+	out[0] = 0x45;
+	out[1] = seq;
+
+	// buttons u32 at rep[2..5] (inverse of btnsOf)
+	out[2] = (uint8_t)(g_in.buttons & 0xFF);
+	out[3] = (uint8_t)((g_in.buttons >> 8) & 0xFF);
+	out[4] = (uint8_t)((g_in.buttons >> 16) & 0xFF);
+	out[5] = (uint8_t)((g_in.buttons >> 24) & 0xFF);
+
+	// triggers: the controller's analog trigger is a u16 that trigU8 maps via >>7 (saturating). Inverse:
+	// u16 = lt << 7 (so a full 0xFF pull -> 0x7F80 -> trigU8 -> 0xFF). offset 4 = LT, offset 6 = RT.
+	put_u16(out, 4, (uint16_t)(g_in.lt << 7));
+	put_u16(out, 6, (uint16_t)(g_in.rt << 7));
+
+	// sticks (int16, center 0): lx@8 ly@10 rx@12 ry@14
+	put_s16(out, 8, g_in.lx);
+	put_s16(out, 10, g_in.ly);
+	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)
+	put_s16(out, 16, g_in.lpx);
+	put_s16(out, 18, g_in.lpy);
+	put_s16(out, 22, g_in.rpx);
+	put_s16(out, 24, g_in.rpy);
+
+	// IMU: accel @0x22 (offset 32), gyro @0x28 (offset 38) -- inverse of imuFrom45
+	put_s16(out, 32, g_in.ax);
+	put_s16(out, 34, g_in.ay);
+	put_s16(out, 36, g_in.az);
+	put_s16(out, 38, g_in.gx);
+	put_s16(out, 40, g_in.gy);
+	put_s16(out, 42, g_in.gz);
+}

+ 15 - 0
OpenController/report45.h

@@ -0,0 +1,15 @@
+// report45.h -- encode g_in into the controller's HID input report 0x45.
+//
+// The exact inverse of the field offsets in triton.h (s16off/u16off/trigU8/btnsOf/imuFrom45). The
+// puck's rf_link.cpp decodes this same byte layout, so the round-trip Deck -> g_in -> 0x45 -> puck ->
+// Steam is symmetric. Output is REPORT45_LEN bytes: [0x45][seq][buttons u32][...sticks/pads/IMU].
+#pragma once
+#include <stdint.h>
+
+// 46 bytes: id + 45 payload bytes (matches the puck descriptor's report-0x45 count 0x2D and the
+// IMU tail at rep[34..46] that rf_link decodes when tlen >= 46).
+#define REPORT45_LEN 46
+
+// Build a fresh report 0x45 from g_in into out[REPORT45_LEN]. seq is the rolling frame counter
+// (rep[1]); the puck treats a changed seq as a fresh report.
+void buildReport45(uint8_t *out, uint8_t seq);

+ 17 - 0
OpenController/triton.cpp

@@ -0,0 +1,17 @@
+#include "triton.h"
+
+// Copied from OpenPuck/triton.cpp. g_in is the shared input struct; imuFrom45 documents the IMU byte
+// offsets that report45.cpp inverts when encoding.
+
+PuckInput g_in;
+
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz)
+{
+	*ax = s16off(r, 32);
+	*ay = s16off(r, 34);
+	*az = s16off(r, 36);
+	*gx = s16off(r, 38);
+	*gy = s16off(r, 40);
+	*gz = s16off(r, 42);
+}

+ 80 - 0
OpenController/triton.h

@@ -0,0 +1,80 @@
+// triton.h -- the controller's native input model + report 0x45 field offsets.
+//
+// *** Copied from OpenPuck/triton.h (the shared input contract). ***
+// On the PUCK, rf_link.cpp DECODES an incoming report 0x45 into g_in. On the CONTROLLER emulator we go
+// the other way: deck_input.cpp fills g_in from the Steam Deck, and report45.cpp ENCODES g_in back into
+// a report 0x45 to transmit. The decoders below are kept so the offsets are documented in one place and
+// the round-trip is provably symmetric. report 0x45 layout:
+//   [0]=0x45 [1]=seq [2..5]=buttons u32; analog offsets below are from the buttons low byte (rep[2]).
+#pragma once
+#include <stdint.h>
+
+// ---- Triton button masks (the 32-bit field at rep[2..5]) ----
+#define TB_A 0x1u
+#define TB_B 0x2u
+#define TB_X 0x4u
+#define TB_Y 0x8u
+#define TB_QAM 0x10u
+#define TB_R3 0x20u
+#define TB_VIEW 0x40u
+#define TB_R4 0x80u
+#define TB_R5 0x100u
+#define TB_RB 0x200u
+#define TB_DDN 0x400u
+#define TB_DRT 0x800u
+#define TB_DLF 0x1000u
+#define TB_DUP 0x2000u
+#define TB_MENU 0x4000u
+#define TB_L3 0x8000u
+#define TB_STEAM 0x10000u
+#define TB_L4 0x20000u
+#define TB_L5 0x40000u
+#define TB_LB 0x80000u
+#define TB_RPADT 0x200000u
+#define TB_RPADC 0x400000u
+#define TB_LPADT 0x2000000u
+#define TB_LPADC 0x4000000u
+
+// all four back paddles held -> mode-switch chord guard
+#define CHORD_BACK4 (TB_R4 | TB_L4 | TB_R5 | TB_L5)
+
+// analog-trigger fraction (of 0xFF) at which digital ZL/ZR (Switch) etc. trip
+#define SW_TRIG_ON 40
+
+// ---- report 0x45 field decoders (offsets relative to rep[2], the buttons low byte) ----
+static inline int s16off(const uint8_t *r, int off)
+{
+	int v = r[2 + off] | (r[2 + off + 1] << 8);
+	return (v & 0x8000) ? v - 0x10000 : v;
+}
+static inline int u16off(const uint8_t *r, int off)
+{
+	return r[2 + off] | (r[2 + off + 1] << 8);
+}
+// Controller trigger u16 tops out near half-scale (~0x8000) at a full pull, so a straight >>8 reads only
+// ~0x80. Scale x2 (>>7) and saturate so a full pull maps to 0xFF.
+static inline uint8_t trigU8(int u16v)
+{
+	int v = u16v >> 7;
+	return (uint8_t)(v > 255 ? 255 : v);
+}
+static inline uint32_t btnsOf(const uint8_t *r)
+{
+	return (uint32_t)r[2] | ((uint32_t)r[3] << 8) | ((uint32_t)r[4] << 16) |
+	       ((uint32_t)r[5] << 24);
+}
+// report 0x45 IMU offsets (PROTOCOL.md §8): accel @0x22, gyro @0x28 from report start.
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz);
+
+// ---- shared decoded input (filled by deck_input.cpp, read by report45.cpp) ----
+struct PuckInput {
+	// raw Triton buttons (TB_*)
+	uint32_t buttons;
+	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)
+	int16_t ax, ay, az; // accelerometer
+	int16_t gx, gy, gz; // gyroscope
+};
+extern PuckInput g_in;

+ 93 - 0
OpenControllerDeckSide/README.md

@@ -0,0 +1,93 @@
+# OpenController — Steam Deck forwarder
+
+Turn a Steam Deck into a Steam Controller 2. The Deck's built-in controls are **grabbed** (detached) and
+streamed over USB-CDC to an **OpenController** nRF52840 (firmware in [`../OpenController/`](../OpenController)),
+which relays them over 2.4 GHz to a puck — a real Valve puck **or** an [OpenPuck](../OpenPuck) — so the
+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
+```
+
+## 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). |
+| `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 with `uv` (no system install, read-only SteamOS friendly)
+
+`run.sh` launches the app via [`uv`](https://docs.astral.sh/uv/), which provisions its own Python and
+the two deps (`pyserial` + `pygame`, both prebuilt wheels) into uv's cache — **nothing touches the
+read-only rootfs**. Input is read with pure-stdlib evdev ioctls, so there's no `evdev` C-extension to
+compile.
+
+```bash
+# one-time: install uv (lands in ~/.local/bin, no root)
+curl -LsSf https://astral.sh/uv/install.sh | sh
+
+./run.sh            # fullscreen touchscreen UI (first run pulls deps)
+./run.sh --debug    # headless status print (bring-up)
+./run.sh --hidraw   # also forward gyro + trackpads (offsets unverified, see notes)
+```
+
+### Add as a non-Steam game
+Add `run.sh` as a non-Steam game (Target = `run.sh`, **Start In** = this folder). In the shortcut's
+**Properties → Controller**, set **Disable Steam Input** so Steam doesn't fight us for the built-in pad.
+
+### Device permissions (read-only rootfs, no persistent udev)
+`/dev/input/event*` is already granted to the active-session user (logind ACL), so the evdev grab works
+without any setup. The nRF CDC (`/dev/ttyACM*`) is not, so if you get a serial permission error, run
+once per boot from Desktop Mode:
+
+```bash
+sudo ./setup.sh     # chmod a+rw /dev/ttyACM* (+ /dev/hidraw* for --hidraw); not persistent
+```
+
+(We can't install a udev rule because `/etc` is read-only and you can't disable that.)
+
+## Pair the controller to a puck
+
+Pairing writes a shared key to **both** devices, so both must be on USB **at the same time**, on one
+computer. Two ways:
+
+- **Steam / PairTUI (any Mac/PC):** plug in the nRF (enumerates as Valve `28DE:1302`, a "Steam
+  Controller") and the puck (`28DE:1304`), then pair as usual — `../pairtui/pairtui.py` already treats
+  `1302` as a dockable controller and `1304` as the puck.
+- **On the Deck (Linux):** plug both into the Deck and run
+  ```bash
+  python3 scpair.py list          # show puck slots + controllers
+  python3 scpair.py pair           # pair into the first free slot
+  python3 scpair.py unpair --slot 0
+  ```
+  `pair` mints a fresh key, writes the puck slot (`0xA2`) and the controller bond (`esb/bond` via
+  `0xEE`/`0xEF`), then reboots the controller into wireless (`0x95`). Move the puck to your host.
+
+## Using it
+
+Tap a puck tile that shows **● available** to detach the Deck controls and start forwarding. Tap
+anywhere (or unplug the puck) to hand control back to the Deck. `Esc`/`Q` quits.
+
+Validate the RF link first with `./run.sh --debug` (headless): you want a line like
+`link=True fwd=False ch=18 [0 FXB99602xxxxx LIVE]`. Keep the puck on USB (so it beacons) and the nRF
+plugged in.
+
+## Notes / known iteration points
+
+- **Gyro + trackpads** come from the Deck's `hid-steam` hidraw report (`--hidraw`). The report offsets
+  in `input_source.HidrawImu` are a documented starting point and **must be confirmed against a real
+  Deck capture** — until then forwarding works with buttons/sticks/triggers (evdev) and zero IMU.
+- **Game Mode / gamescope** — evdev `EVIOCGRAB` detaches cleanly in **Desktop Mode**. In Game Mode,
+  Steam Input owns the controller, so detach behaves differently; run from Desktop Mode for now.
+- **Button mapping** lives in `input_source.KEYMAP` / `EvdevSource._apply`; adjust if your Deck maps
+  face buttons or back paddles differently.
+- **Real Valve puck interop** — the RF reconnect (`../OpenController/ctrl_link.cpp`) adopts the session
+  address the puck advertises in its `E1` host frame; this matches OpenPuck exactly and should match a
+  real puck (OpenPuck reproduces the real puck's `E1`). If a real puck won't connect, the `E1` field
+  offsets are the first thing to check on a capture.

+ 142 - 0
OpenControllerDeckSide/frame.py

@@ -0,0 +1,142 @@
+"""frame.py -- the USB-CDC wire protocol shared with OpenController/deck_input.cpp.
+
+    frame = [0xA5][0x5A][type][len][payload(len)][sum8(type,len,payload...)]
+
+The reader resynchronizes on the 2-byte sync word, so any stray bytes (a boot banner, a serial
+monitor) are skipped harmlessly. Keep this file in lock-step with deck_input.{h,cpp}.
+"""
+import struct
+
+SYNC0 = 0xA5
+SYNC1 = 0x5A
+
+# Deck -> nRF
+T_INPUT = 0x01
+T_CONTROL = 0x02
+T_REQ_STATUS = 0x03
+# nRF -> Deck
+T_STATUS = 0x10
+T_TEXT = 0x1F
+
+# 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
+
+
+def _sum8(type_, payload):
+    s = (type_ + len(payload)) & 0xFF
+    for b in payload:
+        s = (s + b) & 0xFF
+    return s
+
+
+def build(type_, payload=b""):
+    """Serialize one frame."""
+    payload = bytes(payload)
+    return bytes([SYNC0, SYNC1, type_, len(payload)]) + payload + bytes([_sum8(type_, payload)])
+
+
+def build_input(st):
+    """st: an object/dict with Triton fields -> an INPUT frame."""
+    g = st.get if isinstance(st, dict) else (lambda k, d=0: getattr(st, k, d))
+    payload = _INPUT.pack(
+        g("buttons", 0) & 0xFFFFFFFF,
+        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("ax", 0), g("ay", 0), g("az", 0), g("gx", 0), g("gy", 0), g("gz", 0),
+    )
+    return build(T_INPUT, payload)
+
+
+def build_set_forwarding(on):
+    return build(T_CONTROL, bytes([0x01, 1 if on else 0]))
+
+
+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:
+        return None
+    flags, link_slot, sess_ch, nbond = payload[0], payload[1], payload[2], payload[3]
+    bonds = []
+    off = 4
+    for _ in range(nbond):
+        if off + 26 > len(payload):
+            break
+        used = 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),
+                      "puuid": puuid, "iuuid": iuuid, "serial": serial})
+        off += 26
+    return {
+        "forwarding": bool(flags & 1),
+        "link_up": bool(flags & 2),
+        "link_slot": None if link_slot == 0xFF else link_slot,
+        "sess_ch": sess_ch,
+        "bonds": bonds,
+    }
+
+
+class Reader:
+    """Feed raw bytes; yields (type, payload) tuples for each complete, checksum-valid frame.
+
+    Bytes that fall *between* frames are the firmware's plain-text `#` diagnostic lines (Serial.printf,
+    which the framed protocol resyncs past). We capture those too and surface them as (T_TEXT, line) so
+    the app can show the firmware log without a serial monitor.
+    """
+
+    def __init__(self):
+        self._st = 0
+        self._type = 0
+        self._len = 0
+        self._buf = bytearray()
+        self._sum = 0
+        self._text = bytearray()
+
+    def _flush_text(self, out):
+        line = self._text.rstrip(b"\r\n")
+        self._text = bytearray()
+        if line and all(9 <= b < 127 or b in (10, 13) for b in line):
+            out.append((T_TEXT, bytes(line)))
+
+    def feed(self, data):
+        out = []
+        for c in data:
+            st = self._st
+            if st == 0:
+                if c == SYNC0:
+                    self._st = 1
+                else:
+                    # inter-frame byte: accumulate the firmware's plain-text diagnostic lines
+                    self._text.append(c)
+                    if c == 0x0A or len(self._text) > 240:
+                        self._flush_text(out)
+            elif st == 1:
+                self._st = 2 if c == SYNC1 else (1 if c == SYNC0 else 0)
+            elif st == 2:
+                self._type = c
+                self._sum = c
+                self._st = 3
+            elif st == 3:
+                self._len = c
+                self._sum = (self._sum + c) & 0xFF
+                self._buf = bytearray()
+                self._st = 5 if c == 0 else 4
+            elif st == 4:
+                self._buf.append(c)
+                self._sum = (self._sum + c) & 0xFF
+                if len(self._buf) >= self._len:
+                    self._st = 5
+            elif st == 5:
+                if c == self._sum:
+                    out.append((self._type, bytes(self._buf)))
+                self._st = 0
+        return out

+ 355 - 0
OpenControllerDeckSide/input_source.py

@@ -0,0 +1,355 @@
+"""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).
+"""
+from __future__ import annotations
+import fcntl
+import glob
+import os
+import struct
+
+# ---- Triton button masks (mirror triton.h) ----
+TB_A = 0x1
+TB_B = 0x2
+TB_X = 0x4
+TB_Y = 0x8
+TB_QAM = 0x10
+TB_R3 = 0x20
+TB_VIEW = 0x40
+TB_R4 = 0x80
+TB_R5 = 0x100
+TB_RB = 0x200
+TB_DDN = 0x400
+TB_DRT = 0x800
+TB_DLF = 0x1000
+TB_DUP = 0x2000
+TB_MENU = 0x4000
+TB_L3 = 0x8000
+TB_STEAM = 0x10000
+TB_L4 = 0x20000
+TB_L5 = 0x40000
+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,
+    "ax": 0, "ay": 0, "az": 0, "gx": 0, "gy": 0, "gz": 0,
+}
+
+# ---- Linux input ABI constants (stable kernel ABI) ----
+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
+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
+
+
+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)
+
+
+def EVIOCGNAME(length):
+    return _IOC(_IOC_READ, 'E', 0x06, length)
+
+
+def EVIOCGBIT(ev, length):
+    return _IOC(_IOC_READ, 'E', 0x20 + ev, length)
+
+
+def EVIOCGABS(abscode):
+    return _IOC(_IOC_READ, 'E', 0x40 + abscode, 24)  # struct input_absinfo (6x s32)
+
+
+EVIOCGRAB = _IOC(_IOC_WRITE, 'E', 0x90, 4)
+
+
+def _test_bit(bitmask, bit):
+    return bool(bitmask[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)
+
+    def close(self):
+        try:
+            os.close(self.fd)
+        except Exception:
+            pass
+
+    def ids(self):
+        buf = bytearray(8)
+        try:
+            fcntl.ioctl(self.fd, EVIOCGID, buf, True)
+            _bus, vid, pid, _ver = 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
+        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)
+
+    def ungrab(self):
+        try:
+            fcntl.ioctl(self.fd, EVIOCGRAB, 0)
+        except Exception:
+            pass
+
+    def read_events(self):
+        """Yield (type, code, value); non-blocking."""
+        try:
+            data = os.read(self.fd, EVENT_SZ * 64)
+        except (BlockingIOError, OSError):
+            return
+        for i in range(0, len(data) - EVENT_SZ + 1, EVENT_SZ):
+            _s, _u, typ, code, val = _EVENT.unpack_from(data, i)
+            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
+    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))
+        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)))
+
+
+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()
+        if not self.path:
+            raise RuntimeError("no Deck gamepad event device found under /dev/input")
+        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
+
+    def grab(self):
+        if not self._grabbed:
+            self.dev.grab()
+            self._grabbed = True
+
+    def ungrab(self):
+        if self._grabbed:
+            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 _apply(self, typ, code, val):
+        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*"):
+            try:
+                ue = open("/sys/class/hidraw/%s/device/uevent" % p.split("/")[-1]).read().upper()
+            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 __name__ == "__main__":
+    _list_inputs()

+ 196 - 0
OpenControllerDeckSide/openctrl.py

@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+"""openctrl.py -- Steam Deck forwarder for the OpenController emulated Steam Controller 2.
+
+Runs on the Steam Deck (Desktop Mode). Opens the nRF (Valve 28DE:1302) CDC port, reads its STATUS
+stream (which bonded pucks are live), and shows a fullscreen touchscreen UI of tappable puck tiles.
+Tap an *available* puck -> the Deck's controls are grabbed (detached) and streamed to the nRF, which
+relays them over RF so the puck presents a Steam Controller 2 to its host. Tap again, or lose the RF
+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)
+
+Deps: pyserial (required), evdev (for input), pygame (for the UI). See README.md.
+"""
+import argparse
+import sys
+import time
+
+import frame
+import input_source as isrc
+
+VID = 0x28DE
+PID = 0x1302  # the OpenController emulated controller
+INPUT_HZ = 250.0
+
+
+def find_port():
+    try:
+        from serial.tools import list_ports
+    except Exception:
+        return None
+    for p in list_ports.comports():
+        if (p.vid, p.pid) == (VID, PID):
+            return p.device
+    # fall back: any Valve CDC
+    for p in list_ports.comports():
+        if p.vid == VID:
+            return p.device
+    return None
+
+
+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.reader = frame.Reader()
+
+        self.input = None
+        self.hidraw = None
+        self.forwarding = False
+        self.fwd_slot = None
+        self.last_input = 0.0
+
+        # 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.log = []  # firmware '#' diagnostic lines (the in-app serial monitor)
+
+    # ---- serial I/O ----
+    def pump_serial(self):
+        try:
+            data = self.ser.read(512)
+        except Exception:
+            data = b""
+        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_TEXT:
+                line = payload.decode("latin1", "replace").rstrip()
+                if line:
+                    self.note = line
+                    self.log.append(line)
+                    self.log = self.log[-300:]
+                    if self.args.debug:
+                        print(line)  # stream the firmware log like a serial monitor
+
+    def send(self, b):
+        try:
+            self.ser.write(b)
+        except Exception:
+            pass
+
+    # ---- 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
+        return self.input
+
+    def start_forwarding(self, slot):
+        try:
+            self._ensure_input().grab()
+        except Exception as ex:
+            self.note = "grab failed: %s" % ex
+            return
+        self.forwarding = True
+        self.fwd_slot = slot
+        self.send(frame.build_set_forwarding(True))
+        self.note = "forwarding -> slot %s (%s)" % (slot, self.input.name)
+
+    def stop_forwarding(self):
+        self.forwarding = False
+        self.fwd_slot = None
+        self.send(frame.build_set_forwarding(False))
+        if self.input:
+            self.input.ungrab()
+        self.note = "control returned to Deck"
+
+    def toggle(self, slot):
+        """Tap handler: a tappable tile is one whose puck is live (alive)."""
+        if self.forwarding:
+            self.stop_forwarding()
+        else:
+            self.start_forwarding(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
+            self.send(frame.build_input(st))
+
+    def auto_release_on_drop(self):
+        """If the RF link drops while forwarding, hand control back to the Deck."""
+        if self.forwarding and not self.status.get("link_up"):
+            # 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"]
+            if not alive:
+                self.stop_forwarding()
+
+    # ---- loops ----
+    def run_debug(self):
+        last = 0
+        while True:
+            self.pump_serial()
+            self.pump_input()
+            self.auto_release_on_drop()
+            if time.monotonic() - last > 0.5:
+                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))
+            time.sleep(0.002)
+
+    def run_ui(self):
+        import ui
+        ui.run(self)
+
+
+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("--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")
+    args = ap.parse_args()
+    app = App(args)
+    try:
+        if args.debug:
+            app.run_debug()
+        else:
+            app.run_ui()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        if app.forwarding:
+            app.stop_forwarding()
+
+
+if __name__ == "__main__":
+    main()

+ 21 - 0
OpenControllerDeckSide/run.sh

@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# OpenController Steam Deck forwarder launcher (uv-based; nothing installed system-wide).
+#
+# Add as a NON-STEAM GAME:
+#   Target:      /home/deck/…/deck/run.sh
+#   Start In:    /home/deck/…/deck/         (the directory this script is in)
+# 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.
+#
+# If you get a serial permission error on /dev/ttyACM*, run `sudo ./setup.sh` once per boot (see README).
+set -euo pipefail
+cd "$(dirname "$(readlink -f "$0")")"
+
+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 "$@"

+ 270 - 0
OpenControllerDeckSide/scpair.py

@@ -0,0 +1,270 @@
+#!/usr/bin/env python3
+"""scpair.py -- Linux hidraw steamless pairing for the Steam Controller 2 puck + (emulated) controller.
+
+The Linux equivalent of pairtui/scmd.c (which is macOS/IOKit). Talks the Valve feature-report command
+channel on the usagePage-0x01 control interface over /dev/hidraw* (HIDIOCSFEATURE / HIDIOCGFEATURE).
+Used to pair on the Steam Deck itself when BOTH the puck (PID 1304) and the OpenController (PID 1302)
+are plugged into the Deck -- or run it on any Linux box.
+
+Pairing flow (protocol/USB_COMMANDS.md, == pairtui.pair_full):
+  1. fresh 8-byte key (r1,r2)
+  2. puck   slot N  (rid 2):  0xA2 [r1][r2][controller_serial 16]
+  3. ctrl   (rid 1):          0xEE ["esb/bond"\\0][r1][r2][puck_serial 16]  then  0xEF ["esb/bond"\\0]
+  4. ctrl   (rid 1):          0x95 52 AF 27 A4   (reboot into wireless)
+
+    sudo python3 scpair.py list
+    sudo python3 scpair.py pair [--slot N]
+    sudo python3 scpair.py unpair --slot N
+Needs read/write on /dev/hidraw* (run with sudo or install the udev rule in README.md).
+"""
+import argparse
+import array
+import fcntl
+import glob
+import os
+import struct
+import sys
+import time
+
+# ---- Linux ioctl numbers for hidraw (asm-generic _IOC) ----
+_IOC_NONE, _IOC_WRITE, _IOC_READ = 0, 1, 2
+
+
+def _IOC(d, t, nr, size):
+    return (d << 30) | (size << 16) | (ord(t) << 8) | nr
+
+
+def _IOR(t, nr, size):
+    return _IOC(_IOC_READ, t, nr, size)
+
+
+def _IOC_SF(length):
+    return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x06, length)  # HIDIOCSFEATURE
+
+
+def _IOC_GF(length):
+    return _IOC(_IOC_READ | _IOC_WRITE, 'H', 0x07, length)  # HIDIOCGFEATURE
+
+
+HIDIOCGRDESCSIZE = _IOR('H', 0x01, 4)
+# HIDIOCGRDESC: struct hidraw_report_descriptor { __u32 size; __u8 value[4096]; }
+HIDIOCGRDESC = _IOR('H', 0x02, 4 + 4096)
+
+VALVE_VID = 0x28DE
+PUCK_PID = 0x1304
+CTRL_PIDS = (0x1302, 0x1301, 0x1303, 0x1205)
+FEAT_LEN = 64  # Valve feature reports are exactly 64 bytes
+
+
+def _sysfs_ids(node):
+    """Return (vid, pid) for a /dev/hidrawN node, or (None, None)."""
+    name = os.path.basename(node)
+    try:
+        # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304
+        ue = open("/sys/class/hidraw/%s/device/uevent" % name).read()
+    except Exception:
+        return (None, None)
+    for line in ue.splitlines():
+        if line.startswith("HID_ID="):
+            parts = line.split(":")
+            try:
+                return (int(parts[1], 16) & 0xFFFF, int(parts[2], 16) & 0xFFFF)
+            except Exception:
+                return (None, None)
+    return (None, None)
+
+
+def _first_usage_page(fd):
+    """Parse the report descriptor's first Usage Page item. 0x01=generic desktop (control), 0xFF00=vendor."""
+    try:
+        size = array.array('i', [0])
+        fcntl.ioctl(fd, HIDIOCGRDESCSIZE, size, True)
+        buf = bytearray(4 + 4096)
+        struct.pack_into("<I", buf, 0, size[0])
+        fcntl.ioctl(fd, HIDIOCGRDESC, buf, True)
+        n = struct.unpack_from("<I", buf, 0)[0]
+        d = buf[4:4 + n]
+        i = 0
+        while i < len(d):
+            b = d[i]
+            if b == 0x05:  # Usage Page, 1-byte
+                return d[i + 1]
+            if b == 0x06:  # Usage Page, 2-byte
+                return d[i + 1] | (d[i + 2] << 8)
+            # skip item by its size (low 2 bits)
+            i += 1 + (b & 0x03)
+    except Exception:
+        pass
+    return None
+
+
+class HidDev:
+    def __init__(self, node, vid, pid):
+        self.node = node
+        self.vid = vid
+        self.pid = pid
+        self.fd = os.open(node, os.O_RDWR)
+        self.usage_page = _first_usage_page(self.fd)
+
+    def close(self):
+        try:
+            os.close(self.fd)
+        except Exception:
+            pass
+
+    def set_feature(self, rid, cmd, payload=b""):
+        buf = bytearray(FEAT_LEN)
+        buf[0] = rid
+        buf[1] = cmd
+        buf[2] = len(payload)
+        buf[3:3 + len(payload)] = bytes(payload)
+        fcntl.ioctl(self.fd, _IOC_SF(FEAT_LEN), buf, True)
+
+    def get_feature(self, rid):
+        buf = bytearray(FEAT_LEN)
+        buf[0] = rid
+        fcntl.ioctl(self.fd, _IOC_GF(FEAT_LEN), buf, True)
+        return bytes(buf)
+
+    def read_serial(self, rid):
+        """0xAE idx 1 -> unit serial string."""
+        try:
+            self.set_feature(rid, 0xAE, bytes([0x01]))
+            r = self.get_feature(rid)
+            # reply [rid][AE][len][idx][serial...]
+            return r[4:4 + 16].split(b"\x00")[0].decode("latin1", "replace")
+        except Exception:
+            return ""
+
+
+def enumerate_devices():
+    """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
+    out = {"puck": [], "ctrl": []}
+    for node in sorted(glob.glob("/dev/hidraw*")):
+        vid, pid = _sysfs_ids(node)
+        if vid != VALVE_VID:
+            continue
+        try:
+            dev = HidDev(node, vid, pid)
+        except PermissionError:
+            print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
+            continue
+        except Exception:
+            continue
+        # control interface only (usagePage 0x01); skip the vendor 0xFF00 diagnostic interface
+        if dev.usage_page not in (0x01, None):
+            dev.close()
+            continue
+        if pid == PUCK_PID:
+            out["puck"].append(dev)
+        elif pid in CTRL_PIDS:
+            out["ctrl"].append(dev)
+        else:
+            dev.close()
+    return out
+
+
+def _ser16(serial):
+    b = serial.encode("latin1", "replace")[:16]
+    return b + b"\x00" * (16 - len(b))
+
+
+def read_slots(puck):
+    """Read the puck's 4 bond slots (each puck control interface == one slot). Returns list of dicts."""
+    slots = []
+    for i, dev in enumerate(sorted(puck, key=lambda d: d.node)):
+        try:
+            dev.set_feature(2, 0xA3)
+            r = dev.get_feature(2)  # [02 A3 18 <8 uuid><16 serial>]
+            rec = r[3:3 + 24]
+            used = any(rec[0:8])
+            serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else ""
+            slots.append({"idx": i, "used": used, "serial": serial, "rec": rec})
+        except Exception as ex:
+            slots.append({"idx": i, "used": False, "serial": "", "rec": b"", "err": str(ex)})
+    return slots
+
+
+def pair(slot=None):
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=lambda d: d.node)
+    ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
+    if not pucks:
+        raise SystemExit("no puck (28DE:1304) control interface found")
+    if not ctrls:
+        raise SystemExit("no controller (28DE:1302) control interface found — plug the OpenController in")
+    ctrl = ctrls[0]
+    puck_serial = pucks[0].read_serial(2) or "FXB000000000"
+    ctrl_serial = ctrl.read_serial(1) or "FXA000000000"
+
+    if slot is None:
+        slots = read_slots(pucks)
+        slot = next((s["idx"] for s in slots if not s["used"]), 0)
+    if slot >= len(pucks):
+        raise SystemExit("slot %d has no puck control interface" % slot)
+
+    r = os.urandom(8)
+    key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
+    print("pairing puck %s slot %d <-> controller %s  key=%s" %
+          (puck_serial, slot, ctrl_serial, r.hex()))
+
+    # puck slot: 0xA2 [r1 r2][ctrl serial]
+    pucks[slot].set_feature(2, 0xA2, r + _ser16(ctrl_serial))
+    # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0]
+    ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial))
+    ctrl.set_feature(1, 0xEF, key)
+    time.sleep(0.1)
+    # reboot controller into wireless mode (magic 0xA427AF52)
+    ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
+    print("paired. controller rebooting into wireless — move the puck to your host.")
+
+
+def unpair(slot):
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=lambda d: d.node)
+    if slot >= len(pucks):
+        raise SystemExit("slot %d has no puck control interface" % slot)
+    pucks[slot].set_feature(2, 0xA2, b"\x00" * 24)  # clear puck slot
+    # also clear the controller bond if present
+    for ctrl in devs["ctrl"]:
+        key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
+        try:
+            ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
+            ctrl.set_feature(1, 0xEF, key)
+        except Exception:
+            pass
+    print("cleared slot %d" % slot)
+
+
+def cmd_list():
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=lambda d: d.node)
+    ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
+    print("controllers:", [(d.node, "%04X" % d.pid, d.read_serial(1)) for d in ctrls] or "(none)")
+    if pucks:
+        print("puck:", pucks[0].read_serial(2))
+        for s in read_slots(pucks):
+            print("  slot %d: %s" % (s["idx"], (s["serial"] + " (bonded)") if s["used"] else "empty"))
+    else:
+        print("puck: (none)")
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Linux hidraw steamless pairing")
+    sub = ap.add_subparsers(dest="cmd", required=True)
+    sub.add_parser("list")
+    p = sub.add_parser("pair")
+    p.add_argument("--slot", type=int, default=None)
+    u = sub.add_parser("unpair")
+    u.add_argument("--slot", type=int, required=True)
+    args = ap.parse_args()
+    if args.cmd == "list":
+        cmd_list()
+    elif args.cmd == "pair":
+        pair(args.slot)
+    elif args.cmd == "unpair":
+        unpair(args.slot)
+
+
+if __name__ == "__main__":
+    main()

+ 12 - 0
OpenControllerDeckSide/setup.sh

@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# 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.
+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."

+ 140 - 0
OpenControllerDeckSide/ui.py

@@ -0,0 +1,140 @@
+"""ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
+
+Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
+Tapping a live tile detaches the Deck controls and forwards to that puck; tapping anywhere while
+forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
+"""
+import time
+import pygame
+
+BG = (16, 18, 24)
+FG = (230, 232, 238)
+DIM = (120, 126, 140)
+ACCENT = (90, 170, 255)
+LIVE = (70, 200, 120)
+WARN = (235, 180, 70)
+STOP = (235, 90, 90)
+TILE = (32, 36, 46)
+TILE_LIVE = (28, 54, 40)
+TILE_SEL = (40, 70, 110)
+
+
+def _font(sz, bold=False):
+    f = pygame.font.SysFont("noto sans,dejavusans,sans", sz, bold=bold)
+    return f
+
+
+def run(app):
+    pygame.init()
+    pygame.display.set_caption("OpenController · Steam Deck")
+    screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
+    W, H = screen.get_size()
+    clock = pygame.time.Clock()
+    f_title = _font(int(H * 0.05), bold=True)
+    f_big = _font(int(H * 0.045), bold=True)
+    f_med = _font(int(H * 0.03))
+    f_small = _font(int(H * 0.022))
+
+    tile_rects = []  # (rect, slot, tappable)
+
+    def draw():
+        nonlocal tile_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))
+
+        # tiles
+        tile_rects = []
+        bonds = [(i, b) for i, b in enumerate(s["bonds"]) if b["used"]]
+        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:
+            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):
+            y = top + n * (th + gap)
+            rect = pygame.Rect(40, y, W - 80, th)
+            sel = app.forwarding and app.fwd_slot == slot
+            tappable = b["alive"] and not app.forwarding
+            color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
+            pygame.draw.rect(screen, color, rect, border_radius=18)
+            pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
+            # glyph + serial
+            glyph = "●" if b["alive"] else "○"
+            gcol = LIVE if b["alive"] else DIM
+            screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
+            screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
+                        (rect.x + 90, rect.y + 20))
+            state = ("FORWARDING — tap to stop" if sel
+                     else "available — tap to forward" if tappable
+                     else "live (forwarding elsewhere)" if b["alive"]
+                     else "paired · offline")
+            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))
+
+        # firmware log panel (the in-app serial monitor) — fills the area below the tiles
+        log_top = int(H * 0.60)
+        log_bot = H - int(H * (0.12 if app.forwarding else 0.02))
+        lh = f_small.get_height() + 2
+        nlines = max(1, (log_bot - log_top - 24) // lh)
+        screen.blit(f_small.render("firmware log", True, DIM), (40, log_top))
+        lines = app.log[-nlines:]
+        for i, ln in enumerate(lines):
+            # colorize the lines that matter for connecting
+            c = DIM
+            if "SESSION live" in ln or "CONNECTED" in ln:
+                c = LIVE
+            elif "adopted" in ln or "E1 heard" in ln:
+                c = ACCENT
+            elif "lost" in ln or "no bond" in ln or "fail" in ln:
+                c = WARN
+            screen.blit(f_small.render(ln[:120], True, c), (40, log_top + 24 + i * lh))
+
+        # forwarding banner (drawn last so it sits on top)
+        if app.forwarding:
+            bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
+            pygame.draw.rect(screen, STOP, bar)
+            msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
+            screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
+        pygame.display.flip()
+
+    def handle_tap(pos):
+        if app.forwarding:
+            app.stop_forwarding()
+            return
+        for rect, slot, tappable in tile_rects:
+            if rect.collidepoint(pos) and tappable:
+                app.toggle(slot)
+                return
+
+    running = True
+    while running:
+        for ev in pygame.event.get():
+            if ev.type == pygame.QUIT:
+                running = False
+            elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
+                running = False
+            elif ev.type == pygame.MOUSEBUTTONDOWN:
+                handle_tap(ev.pos)
+            elif ev.type == pygame.FINGERDOWN:
+                handle_tap((int(ev.x * W), int(ev.y * H)))
+
+        app.pump_serial()
+        app.pump_input()
+        app.auto_release_on_drop()
+        draw()
+        # high tick so forwarding input stays smooth; UI redraw is cheap
+        clock.tick(120)
+
+    pygame.quit()