Ver código fonte

Fix stuck inputs when changing modes, stuck haptics, and reversepuck (#150)

* Fix stuck inputs

* fmt

* potential fix for dropped haptics

* Fix reversepuck

* ci fix
Jari 3 meses atrás
pai
commit
43d1b286ec

+ 1 - 1
.github/workflows/build.yml

@@ -111,7 +111,7 @@ jobs:
             --clean \
             --build-path build/cache/reversepuck \
             --output-dir build/reversepuck \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" \
             ReversePuckFirmware
 
       - name: Generate UF2 (ReversePuckFirmware)

+ 1 - 1
.github/workflows/release.yml

@@ -129,7 +129,7 @@ jobs:
             --clean \
             --build-path build/cache/reversepuck \
             --output-dir build/reversepuck \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" \
             ReversePuckFirmware
 
       - name: Generate UF2 (ReversePuckFirmware)

+ 23 - 2
Makefile

@@ -44,10 +44,16 @@ USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID)
 # (No auto-detect -- uploading to a guessed serial port risks writing to the wrong device. List with
 # `arduino-cli board list`.) FLASH_PORT = whatever goal isn't one of our real targets; the catch-all rule at
 # the bottom swallows it so make doesn't try to build the port path as a target.
-FLASH_PORT := $(filter-out format format-check check build build-recovery flash deploy,$(MAKECMDGOALS))
+FLASH_PORT := $(filter-out format format-check check build build-recovery reversepuck reversepuck-flash reversepuck-deploy flash deploy,$(MAKECMDGOALS))
 UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
 
-.PHONY: format format-check check build build-recovery flash deploy
+# ReversePuck (controller dongle, 28DE:1302) build flags. It has ONE HID interface (core default 2 is fine),
+# so it doesn't need CFG_TUD_HID; it DOES need the deeper vendor TX FIFO to hold the 0xAC paired-pucks list
+# whole, and the deeper usbd task queue keeps loop()-context WebUSB/CDC/HID sends off the watchdog path.
+RP_USB_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
+RP_UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" ReversePuckFirmware
+
+.PHONY: format format-check check build build-recovery reversepuck reversepuck-flash reversepuck-deploy flash deploy
 
 ## Compile the firmware with the required USB flags baked in. Override CFG_TUD_HID / CFG_TUD_TASK_QUEUE_SZ /
 ## EXTRA_FLAGS / FQBN as make variables if needed.
@@ -58,6 +64,21 @@ build:
 build-recovery:
 	$(MAKE) build EXTRA_FLAGS="$(EXTRA_FLAGS) -DOPK_FACTORY_RESET=1"
 
+## Compile the ReversePuck controller dongle firmware (28DE:1302) with its WebUSB vendor flags baked in.
+reversepuck:
+	arduino-cli compile -b $(FQBN) --build-property "build.extra_flags=$(RP_USB_FLAGS)" ReversePuckFirmware
+
+## Upload the most recent ReversePuck build. Usage: make reversepuck-flash <port>
+reversepuck-flash:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make reversepuck-flash <port>   e.g. make reversepuck-flash /dev/cu.usbmodem1101"; exit 1; }
+	$(RP_UPLOAD)
+
+## Build then upload the ReversePuck dongle in one step. Usage: make reversepuck-deploy <port>
+reversepuck-deploy:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make reversepuck-deploy <port>   e.g. make reversepuck-deploy /dev/cu.usbmodem1101"; exit 1; }
+	$(MAKE) reversepuck
+	$(RP_UPLOAD)
+
 ## Upload the most recent build. Usage: make flash <port>   e.g.  make flash /dev/cu.usbmodem1101
 ## The board may need DFU/bootloader mode first (double-tap RST): puck (Steam/Lizard) mode drops the CDC port
 ## so arduino-cli can't auto-reset it -- see docs/BUILD_AND_DEPLOY.md.

+ 19 - 0
OpenPuck/OpenPuck.ino

@@ -99,6 +99,25 @@ void usbReenumerate(uint8_t k)
 	USBDevice.attach();
 }
 
+// Persist a new USB personality and reboot into it. The clean DETACH before the reset is the point: a bare
+// NVIC_SystemReset drops the D+ pullup only for the ~ms the reset takes -- too briefly for some hosts to
+// register a real unplug -- so when switching between DIFFERENT personalities (e.g. Steam <-> Xbox, which are
+// distinct VID/PID + class drivers), the OUTGOING device can linger as a ghost until a physical replug, and
+// any input held at the instant of the switch stays stuck on it. Detaching first makes the reboot behave like
+// that replug: the host tears the old device down (releasing all its input) before the new one enumerates.
+// Used by every user-facing mode change (back-4 chord, WebUSB panel, CDC console).
+void modeSwitchReboot(uint8_t mode)
+{
+	if (modeValid(mode))
+		saveMode(
+			mode); // 0xFF / invalid => keep the current mode (bond-import reboot)
+	if (USBDevice.mounted())
+		USBDevice.detach();
+	delay(60); // let the host see the disconnect before the pullup returns on reset
+	faultDiagArmIntentionalReset();
+	NVIC_SystemReset();
+}
+
 void setup()
 {
 	// full-board wipe (debug-only "erase everything"): if the panel armed one, this never returns -- it erases

+ 21 - 1
OpenPuck/haptics.cpp

@@ -79,6 +79,11 @@ static unsigned long g_rumble80Ms[NSLOT] = { 0 };
 // stuck-rumble watchdog is independent
 static bool g_rumble80On[NSLOT] = { false, false, false, false };
 
+// A rumble STOP is relayed as this many copies over successive poll cycles (see hapticSteamRumble): the relay
+// is NO-ACK, and a lost final stop leaves the controller latched rumbling. 3 gives temporal diversity a
+// single-frame RF loss can't wipe out without meaningfully changing steady-state traffic.
+#define RUMBLE_STOP_REPS 3
+
 // ---- relay rings: one per bond slot. Multi-producer (USB ISR + loop-context console/xinput), one consumer
 // per slot (rfConnFlushRelay on that slot's poll turn). Producers serialize under PRIMASK.
 struct RelayMsg {
@@ -347,7 +352,22 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 	p[6] = (uint8_t)(highFreq & 0xFF);
 	p[7] = (uint8_t)(highFreq >> 8);
 	p[8] = 0;
-	if (!relayEnqueue(0x80, p, sizeof p, slot))
+	// The RF relay is NO-ACK -- any single frame can be lost. For an ON that's self-healing: a continuous
+	// stream of rumble commands follows during play, so a dropped frame is corrected microseconds later. A
+	// STOP is the dangerous one: if the host's FINAL zero (game/stream quit) -- or the watchdog's stop below
+	// -- is the last frame on the wire and it's lost, the controller stays latched rumbling with nothing left
+	// to correct it (the reported "constant rumble that didn't stop after closing GFN", cleared only by a
+	// replug). And once we optimistically mark g_rumble80On=false, the watchdog can't rescue it either. So
+	// relay a STOP as a short BURST: rfConnFlushRelay drains one ring entry per poll cycle, so N copies go out
+	// on successive cycles (~4ms apart) -- temporal diversity that a single-frame RF loss can't wipe out.
+	// Bursting only the on->off transition leaves steady-state (repeated-ON / repeated-OFF) traffic unchanged.
+	bool stopping = !on && g_rumble80On[slot];
+	uint8_t reps = stopping ? RUMBLE_STOP_REPS : 1;
+	bool queued = false;
+	for (uint8_t i = 0; i < reps; i++)
+		if (relayEnqueue(0x80, p, sizeof p, slot))
+			queued = true;
+	if (!queued)
 		return false;
 	g_rumble80Ms[slot] = millis();
 	g_rumble80On[slot] = on;

+ 57 - 10
OpenPuck/mode_lizard.cpp

@@ -89,6 +89,22 @@ static uint32_t lizardButtons(const PuckInput &in)
 	return buttons;
 }
 
+// Per-slot glide/scroll integrators + last-sent report state for rfLizard. These are FILE-SCOPE (not
+// function-local statics) so rfLizardRelease() can reset them on a lizard->Steam handoff: without that reset
+// a key/mouse-button that was DOWN the instant Steam takes over is never released (the change-dedup below
+// thinks it is still "down") and sticks on the desktop until a reconnect/power-cycle.
+static int prx[NSLOT] = { 0 }, pry[NSLOT] = { 0 };
+static bool prt[NSLOT] = { false };
+static float vx[NSLOT] = { 0 }, vy[NSLOT] = { 0 }, rmx = 0, rmy = 0;
+static int ply[NSLOT] = { 0 };
+static bool plt[NSLOT] = { false };
+static float sacc = 0;
+static uint8_t pmbtn = 0; // last-sent mouse buttons
+static uint8_t prevCC = 0; // last-sent consumer bits
+static uint8_t pmod = 0, pkc[6] = {
+	0, 0, 0, 0, 0, 0
+}; // last-sent kbd modifier + keycodes
+
 void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 	      uint8_t krid)
 {
@@ -110,10 +126,8 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 
 	// ---- right pad -> mouse motion with glide ----
 	// Per-slot velocity/last-position so each controller's pad integrates independently; the resulting
-	// deltas are SUMMED into one cursor (sub-pixel carry rmx/rmy is shared -- one mouse).
-	static int prx[NSLOT] = { 0 }, pry[NSLOT] = { 0 };
-	static bool prt[NSLOT] = { false };
-	static float vx[NSLOT] = { 0 }, vy[NSLOT] = { 0 }, rmx = 0, rmy = 0;
+	// deltas are SUMMED into one cursor (sub-pixel carry rmx/rmy is shared -- one mouse). State is
+	// file-scope (see the block above rfLizard) so rfLizardRelease() can zero it on a handoff.
 	int dx = 0, dy = 0;
 	if (doRpadMouse) {
 		float sumx = 0, sumy = 0;
@@ -165,9 +179,7 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 
 	// ---- left pad -> scroll wheel ----
 	// Per-slot last-position; accumulator shared (one wheel). Reset only when NO controller is scrolling.
-	static int ply[NSLOT] = { 0 };
-	static bool plt[NSLOT] = { false };
-	static float sacc = 0;
+	// State is file-scope (see the block above rfLizard) so rfLizardRelease() can zero it on a handoff.
 	int dw = 0;
 	if (doLpadScroll) {
 		bool anyTouch = false;
@@ -198,7 +210,6 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 	}
 
 	// ---- mouse report ----
-	static uint8_t pmbtn = 0;
 	if (dx || dy || dw || outMBtn != pmbtn) {
 		pmbtn = outMBtn;
 		hid_mouse_report_t m;
@@ -212,7 +223,6 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 	}
 
 	// ---- consumer control (edge-triggered) ----
-	static uint8_t prevCC = 0;
 	if (g_usbMode == MODE_LIZARD) {
 		if (consumerBits != prevCC) {
 			if (mdev->ready())
@@ -222,7 +232,6 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 	}
 
 	// ---- keyboard report ----
-	static uint8_t pmod = 0, pkc[6] = { 0, 0, 0, 0, 0, 0 };
 	bool chg = (outMod != pmod);
 	for (int i = 0; i < 6; i++)
 		if (outKeys[i] != pkc[i])
@@ -238,3 +247,41 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 			kdev->sendReport(krid, krep, 8);
 	}
 }
+
+// Release everything lizard may currently be holding on the host, then clear the last-sent dedup + glide
+// state so a later re-entry re-asserts from scratch. Called by puck_hid when Steam TAKES OVER (the
+// lizard->gamepad handoff): that transition is a pure runtime flip with NO USB re-enumeration, so a
+// keyboard key / mouse button / consumer key that was DOWN at that instant would otherwise stay latched on
+// the desktop until a reconnect or power-cycle (the reported "stuck input after switching modes"). Sends the
+// neutral reports unconditionally (not change-gated) because the whole point is to override a stale held
+// state that the dedup would suppress. Same direct-sendReport path rfLizard uses.
+void rfLizardRelease(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev,
+		     uint8_t mrid, uint8_t krid)
+{
+	uint8_t krep[8] = { 0, 0, 0, 0, 0, 0, 0, 0 }; // all keys + modifiers up
+	if (kdev->ready())
+		kdev->sendReport(krid, krep, 8);
+	hid_mouse_report_t m = { 0, 0, 0, 0, 0 }; // no buttons, no motion/wheel
+	if (mdev->ready())
+		mdev->sendReport(mrid, &m, sizeof m);
+	if (g_usbMode == MODE_LIZARD) {
+		uint8_t cc = 0; // release any held consumer-control key
+		if (mdev->ready())
+			mdev->sendReport(0x03, &cc, 1);
+	}
+	// Zero the integrators + last-sent shadows so re-entry starts clean (no cursor fling from stale
+	// velocity, and held keys/buttons re-send correctly on the next rfLizard pass).
+	for (int s = 0; s < NSLOT; s++) {
+		prx[s] = pry[s] = 0;
+		prt[s] = false;
+		vx[s] = vy[s] = 0;
+		ply[s] = 0;
+		plt[s] = false;
+	}
+	rmx = rmy = sacc = 0;
+	pmbtn = 0;
+	prevCC = 0;
+	pmod = 0;
+	for (int i = 0; i < 6; i++)
+		pkc[i] = 0;
+}

+ 7 - 0
OpenPuck/mode_lizard.h

@@ -20,3 +20,10 @@
 // object (puck mode sends both reports to hid[0]).
 void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 	      uint8_t krid);
+
+// Release any keyboard/mouse/consumer input lizard is currently holding on the host and reset rfLizard's
+// internal last-sent + glide state. Call on the lizard->Steam handoff (Steam takes over the gamepad with no
+// USB re-enumeration): without it a key/button held at that instant stays latched on the desktop until a
+// reconnect or power-cycle. mdev/kdev may be the same object (puck mode uses hid[0] for both).
+void rfLizardRelease(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev,
+		     uint8_t mrid, uint8_t krid);

+ 29 - 0
OpenPuck/puck_hid.cpp

@@ -885,6 +885,35 @@ void SteamPuckController::task()
 	// no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
 	if (USBDevice.suspended())
 		return;
+
+	// Lizard<->Steam handoff: release whatever the OUTGOING path was holding. lizardActive() is a pure
+	// runtime decision -- Steam opening/closing (its ~3s settings heartbeat starting/stopping) flips it with
+	// NO USB re-enumeration -- so a key/mouse-button held when Steam TAKES OVER, or a gamepad button held
+	// when Steam CLOSES, is otherwise never released and sticks on the host until a reconnect or power-cycle
+	// (the reported "stuck input after switching modes"). The link-drop neutral in rf_link only covers an RF
+	// outage, not this handoff. Fire a neutral through the path we're leaving on each edge. Placed after the
+	// suspend early-return: while suspended nothing can be sent, and a flip across a sleep is covered on
+	// resume (this edge fires once, plus the post-resume input mute). MODE_LIZARD never leaves lizard, so
+	// this is inert there.
+	{
+		static bool wasLizard = lizardActive();
+		bool nowLizard = lizardActive();
+		if (nowLizard != wasLizard) {
+			if (wasLizard) {
+				// leaving lizard -> release held desktop keyboard/mouse/consumer
+				rfLizardRelease(&hid[0], &hid[0], 0x40, 0x41);
+			} else {
+				// leaving gamepad-forward -> release held 0x45 buttons/sticks/triggers on every slot
+				static const uint8_t neutral45[45] = { 0 };
+				for (int s = 0; s < NSLOT; s++)
+					if (g_slot[s].used && hid[s].ready())
+						usbTxHid(&hid[s], 0x45,
+							 neutral45,
+							 sizeof neutral45);
+			}
+			wasLizard = nowLizard;
+		}
+	}
 	// Per-slot 0x79/0x7B: each connected slot reports its OWN edge and its OWN status. State arrays are
 	// per-slot so each controller's "connected" edge fires once and is re-sent only until Steam acks THAT
 	// slot. The real puck's per-slot edge-triggered 0x79 prevents re-triggering Steam's connect-chime loop.

+ 4 - 4
OpenPuck/rf_link.cpp

@@ -8,6 +8,7 @@
 #include "controllers.h"
 #include "status_led.h"
 #include "fault_diag.h"
+#include "usb_mount.h" // modeSwitchReboot()
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -720,10 +721,9 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						    want != g_usbMode &&
 						    modeValid(want) &&
 						    !USBDevice.suspended()) {
-							saveMode(want);
-							delay(40);
-							faultDiagArmIntentionalReset();
-							NVIC_SystemReset();
+							// clean detach + reboot into the new mode (releases any held
+							// input on the outgoing device -- see modeSwitchReboot)
+							modeSwitchReboot(want);
 						}
 					} else {
 						chWant[g_curSlot] = want;

+ 3 - 4
OpenPuck/serial_console.cpp

@@ -7,6 +7,7 @@
 #include "config.h"
 #include "puck_hid.h" // g_cmdCapture (feature-command capture toggle)
 #include "fault_diag.h"
+#include "usb_mount.h" // modeSwitchReboot()
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <stdlib.h>
@@ -185,10 +186,8 @@ void serialConsolePoll()
 							"# switch mode %u (reboot)\n",
 							m);
 						delay(20);
-						saveMode(m);
-						delay(40);
-						faultDiagArmIntentionalReset();
-						NVIC_SystemReset();
+						// clean detach + reboot (releases held input on the outgoing device)
+						modeSwitchReboot(m);
 					}
 				}
 			} else if (line[0] == 'c') {

+ 6 - 0
OpenPuck/usb_mount.h

@@ -36,3 +36,9 @@ void usbMountTask();
 // (fixed interfaces -- WebUSB / wake mouse -- replayed too), then re-attach. NO MCU reboot. Call with the map
 // already rebuilt for k connected controllers.
 void usbReenumerate(uint8_t k);
+
+// Persist `mode` and reboot into it, cleanly detaching USB first so the host tears down the outgoing
+// personality (releasing any held input) before the new one enumerates -- otherwise the ~ms reset-disconnect
+// can be too brief for the host to notice, leaving a ghost device with stuck input until a physical replug.
+// Does not return. Call for every user-facing mode switch (chord / panel / console).
+void modeSwitchReboot(uint8_t mode);

+ 8 - 10
OpenPuck/webusb_config.cpp

@@ -10,6 +10,7 @@
 #include "fault_diag.h"
 #include "fw_update.h"
 #include "usb_tx.h"
+#include "usb_mount.h" // modeSwitchReboot()
 #include <Arduino.h>
 #include <string.h>
 
@@ -733,12 +734,10 @@ void webusbPoll()
 			} else if (op == 0x0E) {
 				uint8_t mode = buf[1];
 				saveBonds();
-				if (modeValid(mode))
-					saveMode(mode);
 				usb_web.flush();
-				delay(40);
-				faultDiagArmIntentionalReset();
-				NVIC_SystemReset();
+				// clean detach + reboot into `mode` (0xFF = keep current); regenerates RF
+				// session addrs from the imported bonds and releases any held input.
+				modeSwitchReboot(mode);
 
 				// ---- lizard (desktop) binding map editor ----
 				// Renumbered to 0x11..0x15 on the merge with main: main already owns 0x0D (bond-slot
@@ -977,12 +976,11 @@ void webusbPoll()
 				uint8_t m = buf[1];
 				if (modeValid(m) && !USBDevice.suspended()) {
 					// best-effort status to the panel before the reboot; the SOF drain
-					// sends it during the delay(40) below (no blocking flush on loop()).
+					// sends it during the flush below (no blocking flush on loop()).
 					g_blobRequest = true;
-					saveMode(m);
-					delay(40);
-					faultDiagArmIntentionalReset();
-					NVIC_SystemReset();
+					usb_web.flush();
+					// clean detach + reboot (releases held input on the outgoing device)
+					modeSwitchReboot(m);
 				}
 			}
 			memmove(buf, buf + need, n - need);

+ 20 - 3
ReversePuck/README.md

@@ -44,11 +44,12 @@ analog offsets in `input_source.py` if anything's off. The IMU axis remap (`IMU_
 
 The `ReversePuck` launcher runs the app via [`uv`](https://docs.astral.sh/uv/), which provisions its own
 Python and the three deps (`pyserial` + `pygame` + `pyusb`, all prebuilt wheels; `pyusb` uses the system
-`libusb-1.0.so`) into uv's cache — **nothing touches the read-only rootfs**. You might need to install this
-before ReversePuck will function.
+`libusb-1.0.so`) into uv's cache — **nothing touches the read-only rootfs**. The launcher **auto-installs
+`uv` on first run** if it isn't already present (into `~/.local/bin`, no root), so the manual step below is
+only needed if you'd rather install it yourself or the auto-install can't reach the network.
 
 ```bash
-# one-time: install uv (lands in ~/.local/bin, no root)
+# optional: the launcher does this for you on first run (lands in ~/.local/bin, no root)
 curl -LsSf https://astral.sh/uv/install.sh | sh
 
 ./ReversePuck            # fullscreen touchscreen UI (first run pulls deps)
@@ -116,6 +117,22 @@ Validate the RF link first with `./ReversePuck --debug` (headless): you want a l
 `link=True fwd=False ch=18 [0 FXB99602xxxxx LIVE]`. Keep the puck on USB (so it beacons) and the nRF
 plugged in.
 
+## Manage & flash the dongle from the browser panel
+
+The nRF dongle firmware (`../ReversePuckFirmware`) now exposes a WebUSB vendor interface, so the OpenPuck
+browser panel (`docs/index.html`, hosted at the project's GitHub Pages) connects to it just like it does a
+puck. Plug the dongle into any Chrome/Edge machine, click **Connect**, and pick the `28DE:1302` device. The
+panel detects the controller and shows a reduced UI:
+
+- **Paired pucks** — every puck the dongle is bonded to, with a live/offline badge and a **Remove**
+  (un-bond) button. Same list the Deck app shows over CDC, just over WebUSB.
+- **Firmware update** — drop a `ReversePuckFirmware` `.uf2` on the card to stream it over WebUSB (verified
+  on-device, applied on an automatic reboot), or use **UF2 DFU** / **Serial DFU** in the top bar to reboot
+  into the bootloader for drag-and-drop / `adafruit-nrfutil` flashing.
+
+Build a flashable dongle image with `make reversepuck` (from the repo root); flash a connected dongle with
+`make reversepuck-deploy <port>` (double-tap reset for the bootloader first — see `docs/BUILD_AND_DEPLOY.md`).
+
 ## Notes / known iteration points
 
 - **IMU (gyro + accel)** is read straight from the libusb `DeckSource` and remapped to the SC2 axes via

+ 26 - 3
ReversePuck/ReversePuck

@@ -16,8 +16,31 @@
 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; }
+find_uv() {
+	local uv
+	uv="$(command -v uv || true)"
+	[ -n "$uv" ] || { [ -x "$HOME/.local/bin/uv" ] && uv="$HOME/.local/bin/uv"; }
+	[ -x "$uv" ] && { printf '%s' "$uv"; return 0; }
+	return 1
+}
+
+UV="$(find_uv || true)"
+if [ -z "$UV" ]; then
+	# Bootstrap uv on first run. SteamOS' rootfs is read-only, but the official installer drops a
+	# self-contained binary under ~/.local/bin (writable), so nothing touches the system.
+	echo "uv not found — installing it (one-time) via https://astral.sh/uv …"
+	if command -v curl >/dev/null 2>&1; then
+		curl -LsSf https://astral.sh/uv/install.sh | sh
+	elif command -v wget >/dev/null 2>&1; then
+		wget -qO- https://astral.sh/uv/install.sh | sh
+	else
+		echo "Cannot bootstrap uv: neither curl nor wget is available." >&2
+		echo "Install uv manually: https://docs.astral.sh/uv/" >&2
+		exit 1
+	fi
+	UV="$(find_uv || true)"
+	[ -n "$UV" ] || { echo "uv install completed but the binary was not found on PATH or in ~/.local/bin." >&2; exit 1; }
+	echo "uv installed: $UV"
+fi
 
 exec "$UV" run --with pyserial --with pygame --with pyusb python openctrl.py "$@"

+ 17 - 2
ReversePuckFirmware/ReversePuckFirmware.ino

@@ -10,9 +10,13 @@
 //   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
+//   webusb_ctrl / fw_update -- the browser-panel vendor channel: list/remove paired pucks, DFU, flash new fw
 //
-// Build:  arduino-cli compile -b adafruit:nrf52:feather52840 \
-//           --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" ReversePuckFirmware
+// Build (the vendor TX FIFO must hold the 0xAC bond list whole -- see webusb_ctrl.cpp):
+//   arduino-cli compile -b adafruit:nrf52:feather52840 \
+//     --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_VENDOR_TX_BUFSIZE=256" \
+//     ReversePuckFirmware
+// or: make reversepuck   (top-level Makefile target, bakes the flag in)
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -24,6 +28,8 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "ctrl_usb.h"
 #include "ctrl_link.h"
 #include "deck_input.h"
+#include "webusb_ctrl.h"
+#include "fw_update.h"
 
 // CDC (Deck link) + one HID control interface fit well within the default config buffer; use a modest
 // fixed buffer so enumeration is deterministic.
@@ -31,6 +37,12 @@ static uint8_t g_usbCfgDesc[256];
 
 void setup()
 {
+	// FIRST, before anything touches flash or USB: apply a panel-staged firmware update / full-board wipe if
+	// one was committed on the previous boot. A pending wipe is honored before an apply. Both run from RAM and
+	// never return when armed (they reset); the common path (nothing armed) is a couple of flash reads.
+	fwupWipeIfArmed();
+	fwupApplyIfArmed();
+
 	genSerial();
 	// safe default; the real session addr is adopted from the puck's E1
 	rfGenSessionAddr();
@@ -44,6 +56,7 @@ void setup()
 	// FXA… controller pairing identity
 	USBDevice.setSerialDescriptor(g_unit);
 	ctrlUsbBegin();
+	webusbCtrlBegin(); // vendor interface for the browser panel (manage pucks / DFU / flash)
 	USBDevice.setConfigurationAttribute(
 		0x80 | 0x20); // bus-powered | remote-wakeup
 	USBDevice.attach();
@@ -74,6 +87,8 @@ void loop()
 
 	// flush dirty bonds / honor a pending reboot-to-wireless
 	ctrlUsbPoll();
+	// browser panel over WebUSB: list/remove paired pucks, DFU, stream a firmware update
+	webusbCtrlPoll();
 	// 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

+ 474 - 0
ReversePuckFirmware/fw_update.cpp

@@ -0,0 +1,474 @@
+// Staged firmware update: WebUSB streams the new app image into unused upper app-region flash; a verified
+// meta-page commit arms it; the next boot applies it from RAM. See fw_update.h for the protocol and the
+// corruption-safety invariant every step here preserves.
+// *** Copied from OpenPuck/fw_update.cpp -- the nRF52840 flash map + staged-UF2 protocol are identical on
+// the controller dongle, so keep this byte-for-byte in step with the puck's copy. The ONLY divergence is
+// that the dongle has no fault_diag module: the loop-heartbeat that the puck fed via faultDiagBeat() during
+// the long full-image CRC is here a bare watchdog reload (fwupBeat), which is all that call site needs. ***
+#include "fw_update.h"
+#include <Arduino.h>
+
+// Feed the hardware watchdog during the multi-hundred-ms flash CRC so the ~8 s WDT (armed in setup()) never
+// trips mid-verify. Replaces the puck's faultDiagBeat(); same effect for our purpose (no loop-stall reporter).
+static inline void fwupBeat(void)
+{
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+}
+#include <string.h>
+
+// nRF52840 flash map (Adafruit UF2 bootloader layout): MBR 0x0-0x1000, SoftDevice to 0x26000, app region
+// 0x26000-0xED000, internal LittleFS 0xED000-0xF4000, bootloader 0xF4000+, its settings page at 0xFF000.
+// We manage the TOP of the app region: the meta/commit page at 0xEC000 and the staged image right below it,
+// growing downward. Nothing outside [APP_BASE, 0xED000) is ever written except FWUP_BL_SETTINGS (see apply).
+#define FWUP_APP_BASE 0x26000UL
+#define FWUP_APP_END 0xED000UL
+#define FWUP_META 0xEC000UL
+// internal LittleFS (cfg.bin + bonds.bin) sits between the app region and the bootloader; wiped whole by the
+// full-board erase (config.cpp's factoryErase() only reformats it -- this leaves it raw-erased).
+#define FWUP_FS_BASE 0xED000UL
+#define FWUP_FS_END 0xF4000UL
+#define FWUP_BL_SETTINGS 0xFF000UL
+#define FWUP_PAGE 4096UL
+// Image cap. Also what makes the apply copy safe from self-overlap: dst ends at most at 0x26000+0x60000 =
+// 0x86000, while the staged source starts at (0xEC000-size)&~0xFFF >= 0x8C000 -- disjoint by >=24 KiB.
+#define FWUP_MAX_IMG 0x60000UL
+
+// Capability tag, searched for BY THE PANEL inside any .uf2 it is about to flash: an image without this
+// exact string predates panel updates, so flashing it silently locks future updates back to UF2-DFU
+// drag-and-drop -- the panel warns before letting that happen. Bump the suffix only on a breaking protocol
+// change (the panel searches for this exact value). The asm reference below keeps it through --gc-sections.
+static const char FWUP_TAG[] = "OPK-FWUP-v1";
+
+#define FWUP_META_MAGIC 0x32465055UL // "UPF2"
+// Full-board-wipe arm marker, written to the meta page instead of a FwupMeta. Distinct magic + its bitwise
+// complement in the next word so neither an erased page (0xFFFFFFFF) nor a stray FwupMeta can spoof the arm.
+#define FWUP_WIPE_MAGIC 0x57495045UL // "WIPE"
+struct FwupMeta {
+	uint32_t magic, size, crc, staged;
+	uint32_t metaCrc; // CRC32 over the four fields above
+};
+static_assert(sizeof(FwupMeta) == 20, "FwupMeta layout");
+
+// end of the flash the RUNNING image occupies: code/rodata (__etext) plus the .data init image loaded right
+// after it (.data is the last flash-loaded section in the core's linker script). Staging must stay above it.
+extern "C" {
+extern uint32_t __etext[];
+extern uint32_t __data_start__[];
+extern uint32_t __data_end__[];
+}
+static uint32_t flashUsedEnd(void)
+{
+	return (uint32_t)__etext +
+	       ((uint32_t)__data_end__ - (uint32_t)__data_start__);
+}
+
+// ---- CRC32 (IEEE, reflected, init/xorout 0xFFFFFFFF) -- must match the panel's JS implementation ----------
+static uint32_t crc32Step(uint32_t crc, const volatile uint8_t *p, uint32_t n)
+{
+	while (n--) {
+		crc ^= *p++;
+		for (int k = 0; k < 8; k++)
+			crc = (crc >> 1) ^ (0xEDB88320UL & (0UL - (crc & 1)));
+	}
+	return crc;
+}
+// CRC a flash range. Beats the loop-heartbeat between blocks: verifying a full-size image takes ~200 ms of
+// CPU, and without beats the fault_diag live-wedge reporter (>300 ms stall) would push an unsolicited status
+// blob into the IN pipe right when the panel is waiting for the 0x22 ack.
+static uint32_t crc32Flash(uint32_t addr, uint32_t len)
+{
+	uint32_t crc = 0xFFFFFFFFUL;
+	while (len) {
+		uint32_t blk = len > 16384 ? 16384 : len;
+		crc = crc32Step(crc, (const volatile uint8_t *)addr, blk);
+		addr += blk;
+		len -= blk;
+		fwupBeat();
+	}
+	return ~crc;
+}
+
+// ---- NVMC helpers (flash-resident; used for staging + meta only, never on the live app region) ------------
+// NVMC ops halt the CPU (page erase ~85 ms) -- callers keep it to at most one erase per loop() pass so the
+// 8 s watchdog and the RF link only see brief hiccups.
+static void nvmcErasePage(uint32_t page)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->ERASEPAGE = page;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+}
+static void nvmcWriteWords(uint32_t addr, const uint32_t *w, uint32_t nWords)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
+	while (!NRF_NVMC->READY) {
+	}
+	for (uint32_t i = 0; i < nWords; i++) {
+		((volatile uint32_t *)addr)[i] = w[i];
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+}
+
+// staged image must start with a plausible Cortex-M vector table: initial SP word-aligned in RAM (256 KiB on
+// the nRF52840), reset vector Thumb within the app region. Last line of defense against arming a non-app blob.
+static bool vectorsPlausible(uint32_t imgAddr, uint32_t size)
+{
+	if (size < 8)
+		return false;
+	uint32_t sp = ((const volatile uint32_t *)imgAddr)[0];
+	uint32_t rv = ((const volatile uint32_t *)imgAddr)[1];
+	return sp >= 0x20000000UL && sp <= 0x20040000UL && (sp & 3) == 0 &&
+	       (rv & 1) && rv >= FWUP_APP_BASE && rv < FWUP_APP_END;
+}
+
+// ---- staging state (transfer is strictly sequential; every ack carries s_off so the panel can resync) -----
+static bool s_active = false;
+static uint32_t s_size, s_crc, s_base, s_off, s_erased;
+
+uint32_t fwupNextOff(void)
+{
+	return s_off;
+}
+
+uint8_t fwupBegin(uint32_t size, uint32_t crc32)
+{
+	s_active = false;
+	// any BEGIN first disarms a previously staged update -- a restarted transfer must never leave a stale
+	// commit behind (the staged bytes it points at are about to be overwritten).
+	nvmcErasePage(FWUP_META);
+	if (size == 0 || size > FWUP_MAX_IMG || (size & 3))
+		return FWUP_ERR_BOUNDS;
+	uint32_t base = (FWUP_META - size) & ~(FWUP_PAGE - 1);
+	// keep a page of slack above the running image so staging can NEVER touch live code/rodata/.data-init
+	if (base < flashUsedEnd() + FWUP_PAGE)
+		return FWUP_ERR_BOUNDS;
+	s_size = size;
+	s_crc = crc32;
+	s_base = base;
+	s_off = 0;
+	s_erased =
+		base; // pages erased lazily, one per chunk at most (85 ms CPU stall each)
+	s_active = true;
+	return FWUP_OK;
+}
+
+uint8_t fwupChunk(uint32_t off, const uint8_t *d, uint8_t len)
+{
+	if (!s_active)
+		return FWUP_ERR_STATE;
+	if (len == 0 || len > 128 || (len & 3) || off + len > s_size)
+		return FWUP_ERR_BOUNDS;
+	if (off != s_off) {
+		// duplicate of already-written data (panel retried a chunk whose ack got lost): don't rewrite
+		// the words (nRF52 flash allows only 2 writes per word between erases) -- just re-ack.
+		if (off < s_off && off + len <= s_off)
+			return FWUP_OK;
+		return FWUP_ERR_OFFSET; // gap/desync: ack carries s_off, panel resends from there
+	}
+	while (s_erased < s_base + off + len) {
+		nvmcErasePage(s_erased);
+		s_erased += FWUP_PAGE;
+	}
+	uint32_t w[32]; // chunk arrives unaligned in the command buffer; NVMC wants word writes
+	memcpy(w, d, len);
+	nvmcWriteWords(s_base + off, w, len / 4);
+	s_off = off + len;
+	return FWUP_OK;
+}
+
+uint8_t fwupEnd(void)
+{
+	if (!s_active) {
+		// duplicate END (the panel retried because our ack got lost): if the commit it is asking about
+		// is exactly the one already on the meta page, re-report success instead of ERR_STATE.
+		const volatile FwupMeta *m =
+			(const volatile FwupMeta *)FWUP_META;
+		if (s_size && m->magic == FWUP_META_MAGIC &&
+		    m->size == s_size && m->crc == s_crc)
+			return FWUP_OK;
+		return FWUP_ERR_STATE;
+	}
+	if (s_off != s_size)
+		return FWUP_ERR_STATE;
+	s_active = false;
+	// verify what actually landed in flash, not what we were sent
+	if (crc32Flash(s_base, s_size) != s_crc)
+		return FWUP_ERR_CRC;
+	if (!vectorsPlausible(s_base, s_size))
+		return FWUP_ERR_VECTOR;
+	// COMMIT: this meta page write is the single arming action of the whole protocol
+	FwupMeta m = { FWUP_META_MAGIC, s_size, s_crc, s_base, 0 };
+	m.metaCrc = ~crc32Step(0xFFFFFFFFUL, (const uint8_t *)&m, 16);
+	nvmcErasePage(FWUP_META);
+	nvmcWriteWords(FWUP_META, (const uint32_t *)&m, 5);
+	return FWUP_OK;
+}
+
+void fwupAbort(void)
+{
+	s_active = false;
+	nvmcErasePage(FWUP_META);
+}
+
+// Arm a full-board wipe: erase the meta page and stamp the wipe marker (self-complementary pair). Runs at
+// RUNTIME on the meta page only -- the same NVMC path fwupBegin()/fwupChunk() already use during a live
+// transfer, never the app region we execute from. Any staged update is dropped (its bytes are about to be
+// erased anyway). The caller reboots; fwupWipeIfArmed() does the erase from RAM at the next boot.
+void fwupArmFullWipe(void)
+{
+	s_active = false;
+	uint32_t w[2] = { FWUP_WIPE_MAGIC, ~FWUP_WIPE_MAGIC };
+	nvmcErasePage(FWUP_META);
+	nvmcWriteWords(FWUP_META, w, 2);
+}
+
+// ---- boot-time apply ---------------------------------------------------------------------------------------
+// Runs entirely from RAM (.data section => copied out of flash by the startup code) with interrupts off: it
+// erases and rewrites the very flash the rest of the firmware executes from, so from the first erase to the
+// reset it must not touch ANY flash-resident code or data -- only registers, its arguments, and the two
+// flash regions it copies between. No calls, no libc, volatile pointers so the compiler can't lift the loops
+// into memcpy/memcmp libcalls.
+//
+// Power-cut safety is in the ordering: all target pages are erased up front (the vector page first), pages
+// 1..N-1 are written forward, then the vector page is written BACKWARD so word 0 -- the app-validity marker
+// the bootloader checks -- is the final word written anywhere in the app. Interrupt the sequence at any
+// point and the board reads as app-less: the UF2 bootloader keeps it, drag-and-drop recovers it.
+#define FWUP_RAMFUNC \
+	__attribute__((used, noinline, section(".data.opk_ramfunc")))
+FWUP_RAMFUNC static void ramApply(uint32_t dst, uint32_t src, uint32_t size,
+				  uint32_t metaPage, uint32_t blSettings)
+{
+	volatile uint32_t *d = (volatile uint32_t *)dst;
+	const volatile uint32_t *s = (const volatile uint32_t *)src;
+	const uint32_t words = size / 4;
+	const uint32_t pg0w = words < FWUP_PAGE / 4 ? words : FWUP_PAGE / 4;
+
+	// 1) erase every target page; dst itself (the vector page) goes first
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+	while (!NRF_NVMC->READY) {
+	}
+	for (uint32_t a = dst; a < dst + size; a += FWUP_PAGE) {
+		NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		NRF_NVMC->ERASEPAGE = a;
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// 2) write pages 1..N-1 forward, then the vector page backward (word 0 dead-last)
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
+	while (!NRF_NVMC->READY) {
+	}
+	for (uint32_t w = FWUP_PAGE / 4; w < words; w++) {
+		if ((w & 1023) == 0)
+			NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		d[w] = s[w];
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	for (uint32_t w = pg0w; w-- > 0;) {
+		d[w] = s[w];
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// 3) verify the copy word-for-word against the staged source
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+	uint32_t bad = 0;
+	for (uint32_t w = 0; w < words; w++) {
+		if ((w & 4095) == 0)
+			NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		if (d[w] != s[w]) {
+			bad = 1;
+			break;
+		}
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+	while (!NRF_NVMC->READY) {
+	}
+	if (bad) {
+		// flash cell failure: make the app unambiguously invalid (bootloader keeps the board) instead
+		// of resetting into a half-good image. Meta is erased below so this can't loop.
+		NRF_NVMC->ERASEPAGE = dst;
+		while (!NRF_NVMC->READY) {
+		}
+	} else {
+		// 4) bootloader settings: leave the exact "valid app, CRC check disabled" state drag-and-drop
+		// (MSC) flashing leaves. A previous adafruit-nrfutil serial-DFU flash records a real CRC16 here,
+		// and the bootloader re-checks it EVERY boot -- stale, it would fail our new image straight into
+		// DFU mode. Layout = SDK11 bootloader_settings_t; word 0 ([bank_0=BANK_VALID_APP][bank_0_crc=0])
+		// is written last so an interrupted settings write falls back to the erased-settings path.
+		NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		NRF_NVMC->ERASEPAGE = blSettings;
+		while (!NRF_NVMC->READY) {
+		}
+		NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
+		while (!NRF_NVMC->READY) {
+		}
+		volatile uint32_t *bs = (volatile uint32_t *)blSettings;
+		bs[6] = 0; // sd_image_start
+		while (!NRF_NVMC->READY) {
+		}
+		bs[5] = 0; // app_image_size (bank_0_size is the authoritative one)
+		while (!NRF_NVMC->READY) {
+		}
+		bs[4] = 0; // bl_image_size
+		while (!NRF_NVMC->READY) {
+		}
+		bs[3] = 0; // sd_image_size
+		while (!NRF_NVMC->READY) {
+		}
+		bs[2] = size; // bank_0_size
+		while (!NRF_NVMC->READY) {
+		}
+		bs[1] = 0x000000FEUL; // bank_1 = BANK_ERASED
+		while (!NRF_NVMC->READY) {
+		}
+		bs[0] = 0x00000001UL; // bank_0 = BANK_VALID_APP, bank_0_crc = 0 ("not used")
+		while (!NRF_NVMC->READY) {
+		}
+		NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// 5) disarm: erase the meta page so the (old or new) firmware boots normally from here on
+	NRF_NVMC->ERASEPAGE = metaPage;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+	// 6) reset -- raw AIRCR write; NVIC_SystemReset() is inline but keep even its helpers out of the path
+	__asm volatile("dsb 0xF" ::: "memory");
+	*(volatile uint32_t *)0xE000ED0CUL = (0x5FAUL << 16) | (1UL << 2);
+	for (;;) {
+	}
+}
+
+void fwupApplyIfArmed(void)
+{
+	// materialize a reference to the capability tag so the linker's --gc-sections keeps it in the image
+	__asm volatile("" ::"r"(FWUP_TAG));
+	const volatile FwupMeta *m = (const volatile FwupMeta *)FWUP_META;
+	if (m->magic != FWUP_META_MAGIC)
+		return; // erased page (0xFFFFFFFF) = nothing armed: the common path, zero boot cost
+	// snapshot, then validate EVERYTHING before touching any flash; anything off -> disarm and boot normally
+	FwupMeta v = { m->magic, m->size, m->crc, m->staged, m->metaCrc };
+	bool ok = v.metaCrc ==
+		  (uint32_t)~crc32Step(0xFFFFFFFFUL, (const uint8_t *)&v, 16);
+	ok = ok && v.size && v.size <= FWUP_MAX_IMG && (v.size & 3) == 0;
+	ok = ok && v.staged >= FWUP_APP_BASE + FWUP_MAX_IMG &&
+	     v.staged + v.size <= FWUP_META &&
+	     (v.staged & (FWUP_PAGE - 1)) == 0;
+	ok = ok && crc32Flash(v.staged, v.size) ==
+			   v.crc; // staged bytes still intact?
+	ok = ok && vectorsPlausible(v.staged, v.size);
+	if (!ok) {
+		nvmcErasePage(FWUP_META);
+		return;
+	}
+	// Point of no return. Freeze the system: nothing may run from app flash once the copy starts. The WDT
+	// is forced on first (it survives soft reset if the previous boot armed it, but not power-on), so even
+	// a fault inside the copier resets into the safe app-less/bootloader state instead of hanging forever.
+	__disable_irq();
+	if (!NRF_WDT->RUNSTATUS) {
+		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; // ~8 s, same as the app's own
+		NRF_WDT->RREN = WDT_RREN_RR0_Msk;
+		NRF_WDT->TASKS_START = 1;
+	}
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	// call through a volatile pointer: the compiler must materialize the RAM address and may not inline the
+	// copier back into flash-resident code
+	void (*volatile apply)(uint32_t, uint32_t, uint32_t, uint32_t,
+			       uint32_t) = ramApply;
+	apply(FWUP_APP_BASE, v.staged, v.size, FWUP_META, FWUP_BL_SETTINGS);
+	for (;;) {
+	} // unreachable (ramApply resets)
+}
+
+// ---- full-board wipe (debug-only "erase everything") -------------------------------------------------------
+// Like ramApply, runs entirely from RAM with interrupts off -- it erases the very flash it would otherwise
+// execute from, so no calls/libc/flash constants, only registers + arguments + NVMC. It erases the ENTIRE app
+// region, the LittleFS config/bond region, and the bootloader settings page; the MBR, SoftDevice, and the
+// bootloader itself are left intact so the board still enumerates as the UF2 mass-storage drive. Afterwards no
+// valid app exists, so the Adafruit bootloader stays in UF2 mode on EVERY boot until firmware is flashed again.
+// Power-cut safe by the same ordering as ramApply: the app vector page is erased FIRST, so the board reads
+// app-less from that instant on -- an interrupted wipe still lands in the safe UF2-bootloader state.
+FWUP_RAMFUNC static void ramWipe(uint32_t appBase, uint32_t appEnd,
+				 uint32_t fsBase, uint32_t fsEnd,
+				 uint32_t blSettings)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+	while (!NRF_NVMC->READY) {
+	}
+	// app vector page first (word 0 = the bootloader's app-validity marker)
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	NRF_NVMC->ERASEPAGE = appBase;
+	while (!NRF_NVMC->READY) {
+	}
+	// rest of the app region (code, rodata, .data-init, staged-image scratch, the wipe marker itself)
+	for (uint32_t a = appBase + FWUP_PAGE; a < appEnd; a += FWUP_PAGE) {
+		NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		NRF_NVMC->ERASEPAGE = a;
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// LittleFS: cfg.bin (settings) + bonds.bin (pairings) -- no user data survives
+	for (uint32_t a = fsBase; a < fsEnd; a += FWUP_PAGE) {
+		NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		NRF_NVMC->ERASEPAGE = a;
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// bootloader settings: drop any "valid app" record so the boot check relies only on the (now erased)
+	// app vector -> UF2 mode every boot.
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	NRF_NVMC->ERASEPAGE = blSettings;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+	// reset -- raw AIRCR write (keep NVIC_SystemReset's helpers out of the now-erased flash)
+	__asm volatile("dsb 0xF" ::: "memory");
+	*(volatile uint32_t *)0xE000ED0CUL = (0x5FAUL << 16) | (1UL << 2);
+	for (;;) {
+	}
+}
+
+void fwupWipeIfArmed(void)
+{
+	const volatile uint32_t *m = (const volatile uint32_t *)FWUP_META;
+	if (m[0] != FWUP_WIPE_MAGIC || m[1] != (uint32_t)~FWUP_WIPE_MAGIC)
+		return; // not armed (the common path): erased page or a FwupMeta -> nothing to do
+	// Point of no return -- identical freeze discipline to fwupApplyIfArmed: IRQs off, WDT forced on so a
+	// fault mid-erase still resets into the safe app-less state. The erase (~200 pages) drops USB the instant
+	// it starts; the WDT is reloaded per page so the ~18 s run never trips it.
+	__disable_irq();
+	if (!NRF_WDT->RUNSTATUS) {
+		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; // ~8 s
+		NRF_WDT->RREN = WDT_RREN_RR0_Msk;
+		NRF_WDT->TASKS_START = 1;
+	}
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	void (*volatile wipe)(uint32_t, uint32_t, uint32_t, uint32_t,
+			      uint32_t) = ramWipe;
+	wipe(FWUP_APP_BASE, FWUP_APP_END, FWUP_FS_BASE, FWUP_FS_END,
+	     FWUP_BL_SETTINGS);
+	for (;;) {
+	} // unreachable (ramWipe resets)
+}

+ 66 - 0
ReversePuckFirmware/fw_update.h

@@ -0,0 +1,66 @@
+// Firmware update over WebUSB ("staged UF2"): the panel extracts the raw app image from a .uf2 and streams
+// it over the existing WebUSB vendor channel (ops 0x20..0x24, see webusb_config.h) into UNUSED flash high in
+// the app region. A one-page metadata commit -- written only after the staged bytes CRC32-verify in flash --
+// arms the update; the next reboot applies it before anything else runs, then boots the new firmware.
+//
+// Corruption safety (the invariant every step preserves): at any instant the device has EITHER a valid old
+// app, a valid new app, OR reads as app-less so the resident UF2 bootloader keeps the board (drag-and-drop
+// recovery) -- never a half-written image that crash-loops:
+//   - The transfer stages into flash the running image doesn't use; until fwupEnd() verifies the full CRC32
+//     and commits the meta page, a disconnect/abort/power-cut leaves ZERO trace (no arm, app untouched).
+//   - The boot-time applier re-verifies the staged CRC32 and vector table BEFORE erasing anything.
+//   - The apply itself runs from RAM (flash code can't survive overwriting itself) with a page ordering that
+//     makes power cuts safe: the app's vector-table page is erased FIRST and rewritten LAST (its first word
+//     dead-last), so an interrupted apply leaves 0xFFFFFFFF at the app entry -- the Adafruit bootloader's
+//     validity check then keeps the board in the UF2BOOT mass-storage bootloader instead of jumping to junk.
+//   - The watchdog is force-started before the copy, so even a crash mid-apply resets into that same
+//     safe app-less state rather than hanging forever.
+//   - The applier is idempotent: a reset after the copy but before the meta clear just re-applies the same
+//     verified image.
+// The bootloader, MBR/SoftDevice, and the LittleFS config/bond region are never written (one exception: the
+// bootloader SETTINGS page is rewritten to the same "valid app, no CRC" state drag-and-drop flashing leaves,
+// because a stale CRC recorded by a previous adafruit-nrfutil flash would otherwise fail the new image at
+// the bootloader's boot check).
+#pragma once
+#include <stdbool.h>
+#include <stdint.h>
+
+// ACK status codes, echoed to the panel in the 0xAB frame ([0xAB][5][status][nextOff u32 LE]).
+enum {
+	FWUP_OK = 0,
+	FWUP_ERR_STATE =
+		1, // command out of sequence (no BEGIN, or END before all bytes)
+	FWUP_ERR_BOUNDS =
+		2, // image too big / would collide with the running app or config region
+	FWUP_ERR_OFFSET =
+		3, // chunk offset mismatch (ack carries the expected offset -- resync and resend)
+	FWUP_ERR_CRC = 4, // staged bytes don't match the announced CRC32
+	FWUP_ERR_VECTOR =
+		5, // staged image doesn't start with a plausible Cortex-M vector table
+};
+
+uint8_t fwupBegin(
+	uint32_t size,
+	uint32_t crc32); // 0x20: announce image; disarms any previous staged update
+uint8_t fwupChunk(uint32_t off, const uint8_t *d,
+		  uint8_t len); // 0x21: sequential data (len%4==0, <=128)
+uint8_t
+fwupEnd(void); // 0x22: verify staged CRC32 + vectors, commit the meta page (ARMS the update)
+void fwupAbort(
+	void); // 0x24: disarm + drop any transfer state (staged bytes become inert garbage)
+uint32_t fwupNextOff(
+	void); // next expected chunk offset (returned in every ack for panel resync)
+
+// Apply a committed staged update. Call FIRST in setup(): if a valid meta page is present this never returns
+// (copies staged->app from RAM and resets). Invalid/stale meta is erased and ignored.
+void fwupApplyIfArmed(void);
+
+// Full-board wipe (debug-only "erase everything", 0x25). Unlike a factory reset -- which reformats LittleFS to
+// clean defaults but keeps the firmware -- this ERASES the entire app region, the LittleFS config/bond region,
+// and the bootloader settings page, leaving the board app-less. On every subsequent boot the Adafruit UF2
+// bootloader finds no valid app and stays in UF2 mass-storage (drag-and-drop) mode until firmware is flashed
+// again, so no trace of OpenPuck remains. The MBR/SoftDevice/bootloader are untouched (the board still mounts
+// as the UF2 drive). fwupArmFullWipe() stamps a marker + the caller reboots; fwupWipeIfArmed() does the erase
+// from RAM at the next boot (call BEFORE fwupApplyIfArmed) with the same power-cut-safe ordering as the applier.
+void fwupArmFullWipe(void);
+void fwupWipeIfArmed(void);

+ 7 - 1
ReversePuckFirmware/radio.cpp

@@ -86,8 +86,14 @@ void rfConfig(uint8_t ch)
 	NRF_RADIO->EVENTS_DISABLED = 0;
 	NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);
 	NRF_RADIO->FREQUENCY = ch;
+	// +8dBm = the nRF52840's max TX power (was 0dBm). Mirrors OpenPuck/radio.cpp: the link margin is thin on
+	// the Pro Micro's PCB-trace antenna (RSSI ~20dB below a real Valve unit) and 2.4GHz is crowded, so our F1
+	// replies at 0dBm were being missed by the puck -> the reply gaps that read as intermittent disconnects
+	// (and, past the puck's RF_STALL_MS, trip its radio power-cycle self-heal = churn). The puck already
+	// transmits its polls/beacons at +8dBm; matching it on our reply side closes the asymmetry. USB-powered,
+	// so the higher current draw is free.
 	NRF_RADIO->TXPOWER =
-		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+		(RADIO_TXPOWER_TXPOWER_Pos8dBm << RADIO_TXPOWER_TXPOWER_Pos);
 #if defined(RADIO_MODECNF0_RU_Fast)
 	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
 #endif

+ 206 - 0
ReversePuckFirmware/webusb_ctrl.cpp

@@ -0,0 +1,206 @@
+#include "webusb_ctrl.h"
+#include "ctrl_bonds.h"
+#include "ctrl_link.h" // g_linkSlot, ctrlLinkUp()
+#include "deck_input.h" // deckForwarding()
+#include "fw_update.h"
+#include <Arduino.h>
+#include <string.h>
+
+Adafruit_USBD_WebUSB usb_web;
+
+// The bond-list (0xAC) frame can carry all NBOND=8 pucks: 5-byte header + 8*26 = 213 B. It must fit the
+// vendor TX FIFO whole (we send drop-on-full, never spin), so the build sets CFG_TUD_VENDOR_TX_BUFSIZE=256.
+// Guard it so a build without that flag fails loudly instead of shipping a dongle whose panel never lists a
+// puck. (The core default is 64 -- see arduino/ports/nrf/tusb_config_nrf.h.)
+static_assert(
+	CFG_TUD_VENDOR_TX_BUFSIZE >= 5 + NBOND * 26,
+	"CFG_TUD_VENDOR_TX_BUFSIZE too small for the 0xAC bond list -- build "
+	"the dongle with -DCFG_TUD_VENDOR_TX_BUFSIZE=256 (see Makefile).");
+
+// ---- deferred replies (both sent from webusbCtrlPoll, gated on FIFO room so usb_web.write() never spins) ----
+// usb_web.write() SPINS until the IN FIFO drains or the panel disconnects; a panel that holds the interface
+// open but stops reading would hang loop() -> watchdog reset. So EVERY send here is gated on
+// tud_vendor_write_available(): the bond list is drop-on-full (the panel re-requests), the fw-update ack is
+// kept pending until there is room (the panel's strict ping-pong drains the IN pipe, so that is immediate).
+static volatile bool g_bondListPend = false;
+static volatile bool g_fwupAckPend = false;
+static volatile uint8_t g_fwupAckStatus = 0;
+static volatile uint32_t g_fwupAckOff = 0;
+
+static void fwupAckPost(uint8_t status)
+{
+	g_fwupAckStatus = status;
+	g_fwupAckOff = fwupNextOff();
+	g_fwupAckPend = true;
+}
+
+// Build + send the paired-pucks list. Mirrors deckStatusTask()'s per-bond record so the two management
+// surfaces (the Deck app over CDC, the browser panel over WebUSB) show the same thing.
+static void sendBondList()
+{
+	if (!usb_web.connected())
+		return;
+	static uint8_t p[5 + NBOND * 26];
+	bool linkUp = ctrlLinkUp();
+	p[0] = 0xAC;
+	p[2] = 1; // format version
+	p[3] = (uint8_t)((deckForwarding() ? 1 : 0) | (linkUp ? 2 : 0));
+	uint8_t count = 0;
+	uint8_t *q = p + 5;
+	for (int i = 0; i < NBOND; i++) {
+		if (!g_bond[i].used)
+			continue;
+		q[0] = (uint8_t)
+			i; // firmware slot index (target for 0x30 remove)
+		q[1] = (i == g_linkSlot && linkUp) ? 1 : 0; // alive
+		memcpy(q + 2, g_bond[i].rec + 0, 4); // proteus_uuid
+		memcpy(q + 6, g_bond[i].rec + 4, 4); // ibex_uuid
+		memcpy(q + 10, g_bond[i].rec + 8, 16); // puck serial
+		q += 26;
+		count++;
+	}
+	p[4] = count;
+	uint16_t frame = (uint16_t)(5 + count * 26);
+	p[1] = (uint8_t)(3 + count * 26); // payload length after [marker][len]
+	if (tud_vendor_write_available() >= frame) {
+		usb_web.write(p, frame);
+		usb_web.flush();
+		g_bondListPend = false;
+	} else {
+		g_bondListPend = true; // no room now -> retry next poll
+	}
+}
+
+static void flushDeferred()
+{
+	if (g_fwupAckPend && tud_vendor_write_available() >= 7) {
+		uint8_t f[7] = { 0xAB,
+				 5,
+				 g_fwupAckStatus,
+				 (uint8_t)(g_fwupAckOff),
+				 (uint8_t)(g_fwupAckOff >> 8),
+				 (uint8_t)(g_fwupAckOff >> 16),
+				 (uint8_t)(g_fwupAckOff >> 24) };
+		usb_web.write(f, sizeof f);
+		usb_web.flush();
+		g_fwupAckPend = false;
+	}
+	if (g_bondListPend)
+		sendBondList();
+}
+
+void webusbCtrlBegin()
+{
+	usb_web.begin(); // adds the vendor interface + bumps bcdUSB to 2.1; no landing page (no Chrome popup)
+}
+
+void webusbCtrlPoll()
+{
+	flushDeferred();
+	if (!usb_web.connected())
+		return;
+
+	// 134 B holds the largest command: a firmware-update data chunk 0x21 = [op][off u32][len][<=128 data].
+	// Byte-wise accumulation reassembles a command that spans USB-FS packets; a bad byte costs a 1-byte resync.
+	static uint8_t buf[160];
+	static uint8_t n = 0;
+	while (usb_web.available()) {
+		int c = usb_web.read();
+		if (c < 0)
+			break;
+		if (n < sizeof buf)
+			buf[n++] = (uint8_t)c;
+		for (;;) {
+			if (n == 0)
+				break;
+			uint8_t op = buf[0];
+			// valid opcodes: 0x01, 0x0B, 0x0C, 0x20..0x25, 0x30. Anything else -> drop one byte + resync.
+			bool known = (op == 0x01 || op == 0x0B || op == 0x0C ||
+				      op == 0x30 || (op >= 0x20 && op <= 0x25));
+			if (!known) {
+				memmove(buf, buf + 1, --n);
+				continue;
+			}
+			// 0x21 carries its own data length at [5]; sanity-gate it before trusting it as a length so a
+			// corrupt byte can only cost a one-byte resync, never wedge the parser.
+			if (op == 0x21 && n >= 6) {
+				uint8_t dl = buf[5];
+				if (dl == 0 || dl > 128 || (dl & 3)) {
+					memmove(buf, buf + 1, --n);
+					continue;
+				}
+			}
+			uint8_t need =
+				(op == 0x20) ?
+					9 :
+				(op == 0x21) ?
+					(uint8_t)(6 + (n >= 6 ? buf[5] : 0)) :
+				(op == 0x25) ? 5 :
+				(op == 0x30) ? 2 :
+					       1;
+			if (n < need)
+				break; // wait for more bytes
+
+			if (op == 0x01) {
+				sendBondList();
+			} else if (op == 0x30) {
+				// un-bond one paired puck by firmware slot, persist, then re-report the list.
+				ctrlBondClear(buf[1]);
+				if (g_bondDirty) {
+					g_bondDirty = false;
+					saveCtrlBonds();
+				}
+				sendBondList();
+			} else if (op == 0x0B) {
+				usb_web.flush();
+				delay(40);
+				enterSerialDfu();
+			} else if (op == 0x0C) {
+				usb_web.flush();
+				delay(40);
+				enterUf2Dfu();
+			} else if (op == 0x20) {
+				uint32_t sz = (uint32_t)buf[1] |
+					      ((uint32_t)buf[2] << 8) |
+					      ((uint32_t)buf[3] << 16) |
+					      ((uint32_t)buf[4] << 24);
+				uint32_t crc = (uint32_t)buf[5] |
+					       ((uint32_t)buf[6] << 8) |
+					       ((uint32_t)buf[7] << 16) |
+					       ((uint32_t)buf[8] << 24);
+				fwupAckPost(fwupBegin(sz, crc));
+			} else if (op == 0x21) {
+				uint32_t off = (uint32_t)buf[1] |
+					       ((uint32_t)buf[2] << 8) |
+					       ((uint32_t)buf[3] << 16) |
+					       ((uint32_t)buf[4] << 24);
+				fwupAckPost(fwupChunk(off, buf + 6, buf[5]));
+			} else if (op == 0x22) {
+				fwupAckPost(fwupEnd());
+			} else if (op == 0x23) {
+				// clean reboot. After a committed 0x22 the boot path applies the staged image;
+				// unarmed it is just a normal restart.
+				usb_web.flush();
+				delay(40);
+				NVIC_SystemReset();
+			} else if (op == 0x24) {
+				fwupAbort();
+				fwupAckPost(FWUP_OK);
+			} else if (op == 0x25) {
+				// FULL BOARD WIPE (debug). Guarded by the 4-byte magic "WIPE" so no stray byte can
+				// trigger it. Arms the wipe marker + reboots; fwupWipeIfArmed() erases at next boot.
+				if (buf[1] == 0x57 && buf[2] == 0x49 &&
+				    buf[3] == 0x50 && buf[4] == 0x45) {
+					usb_web.flush();
+					fwupArmFullWipe();
+					delay(40);
+					NVIC_SystemReset();
+				}
+			}
+			// consume the processed command from the front of buf
+			if (n > need)
+				memmove(buf, buf + need, n - need);
+			n -= need;
+		}
+	}
+}

+ 30 - 0
ReversePuckFirmware/webusb_ctrl.h

@@ -0,0 +1,30 @@
+// webusb_ctrl.h -- the ReversePuck dongle's WebUSB configuration channel (28DE:1302).
+//
+// Adds a vendor interface so the OpenPuck browser panel (docs/index.html) can connect to the CONTROLLER
+// dongle -- distinct from the puck's own panel (28DE:1304). It exposes a SUBSET of the puck's WebUSB
+// protocol (webusb_config.h), reusing the SAME firmware-update opcodes + 0xAB ack framing so the panel's
+// existing streaming flasher and DFU buttons work against the dongle unchanged. The panel branches on the
+// USB product id (0x1302 vs 0x1304) to show the reduced controller UI.
+//
+// Binary framed protocol:
+//   host->dev:  0x01                 GET  -> reply the paired-pucks list (0xAC frame)
+//               0x0B                 reboot into serial DFU (adafruit-nrfutil)
+//               0x0C                 reboot into UF2 bootloader (USB mass storage)
+//               0x20 <size u32> <crc32 u32>     firmware update: begin staging (disarms any previous)
+//               0x21 <off u32> <len> <data...>  firmware update: sequential chunk (len<=128, %4==0)
+//               0x22                 firmware update: verify staged image + COMMIT (applies on next reboot)
+//               0x23                 clean reboot (after 0x22: boots the freshly staged firmware)
+//               0x24                 firmware update: abort/disarm
+//               0x25 "WIPE"          FULL BOARD WIPE (debug): erase app+bonds+bl-settings, reboot app-less
+//               0x30 <slot>          un-bond (remove) one paired puck by firmware slot, then reply 0xAC
+//   dev->host:  0xAC <len> <ver=1> <flags> <count> [ per bond: slot, alive, puuid4, iuuid4, serial16 (26B) ]
+//                                    paired-pucks list (flags bit0=forwarding, bit1=link up)
+//               0xAB 5 <status> <nextOff u32>  firmware-update ack, one per 0x20/0x21/0x22/0x24 (fw_update.h)
+// No setLandingPage() on purpose -- it would pop a Chrome "open <url>?" prompt on every plug-in.
+#pragma once
+#include <Adafruit_TinyUSB.h>
+
+extern Adafruit_USBD_WebUSB usb_web;
+
+void webusbCtrlBegin(); // add the vendor interface; call in setup() BEFORE USBDevice.attach()
+void webusbCtrlPoll(); // drain host commands + flush replies; call every loop()

+ 100 - 11
docs/index.html

@@ -20,6 +20,9 @@
   .wrap{max-width:980px;margin:0 auto;padding:20px}
   /* cards stack in one column; width comes from the wider wrap + grids/rows filling it horizontally */
   .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:16px 18px;margin:0 0 16px}
+  /* ReversePuck dongle: hide every puck-only Controller card, keep just the paired-pucks card (this leaves
+     the debugonly/lizard inline visibility rules untouched, so switching back to a puck restores them). */
+  body.dongle #tabMain > .cards > .card:not(#pairedCard){display:none !important}
   .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
   .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
   .row label{flex:0 0 150px;color:var(--fg)}
@@ -124,6 +127,15 @@
 
     <div id="tabMain">
     <div class="cards">
+    <div class="card wide" id="pairedCard" style="display:none">
+      <h2>Paired pucks</h2>
+      <div class="row" style="gap:10px;align-items:center;margin-bottom:4px">
+        <span class="pill" id="pairedLink">—</span>
+        <span class="note" id="pairedFwd" style="flex:1">—</span>
+      </div>
+      <div id="pairedList"><p class="note">Reading paired pucks…</p></div>
+      <p class="note" style="margin-top:8px">This is a <strong>ReversePuck</strong> controller dongle (28DE:1302). It emulates a Steam Controller to whichever puck it's paired with. Pair new pucks with <code>scpair.py</code>; use the <em>Firmware update</em> tab to flash a new dongle build.</p>
+    </div>
     <div class="card">
       <h2>Version</h2>
       <div class="statgrid">
@@ -329,7 +341,7 @@
         </div>
       </div>
 
-      <div class="card fwupcard">
+      <div class="card fwupcard" id="relCard">
         <h2>Update from a release</h2>
         <div class="row" style="margin-top:0">
           <span class="note" style="flex:1;margin:0">Official builds from <a href="https://github.com/safijari/openpuck/releases" target="_blank" rel="noopener" style="color:var(--acc2)">github.com/safijari/openpuck/releases</a> &nbsp;·&nbsp; installed: <b id="updInstalled" style="color:var(--acc)">—</b></span>
@@ -360,6 +372,10 @@
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)"];
 const CHORD_FIELD = [17, 18, 19];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBusy=false;
+// A ReversePuck CONTROLLER dongle (28DE:1302) speaks a REDUCED subset of this protocol: no status blob /
+// lizard / config, just a paired-pucks list (0xAC) plus the identical firmware-update + DFU ops. When true,
+// the panel shows the controller UI (manage paired pucks + flash) and skips every puck-only poll.
+let isDongle=false;
 let lastP=null; // most recent status-blob payload (config source for Export)
 // Stability test: armed = active across reconnects; stabStart = ms of the current run; stabLastRun = last
 // completed run {secs} (ended by a reset). Kept in-page so it survives the device reset (not a page refresh).
@@ -698,13 +714,17 @@ async function openDevice(d){
     ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
     await dev.claimInterface(ifNum);
     await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
+    isDongle = (dev.productId === 0x1302); // ReversePuck controller dongle vs a puck (0x1304 / real)
+    const kind = isDongle ? "ReversePuck" : "puck";
     $("#connState").textContent="connected"; $("#connState").className="pill up";
-    $("#connState").title = dev.serialNumber ? ("puck serial "+dev.serialNumber) : "";
+    $("#connState").title = dev.serialNumber ? (kind+" serial "+dev.serialNumber) : "";
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
-    log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
+    applyDeviceProfile(); // show the controller UI + hide puck-only cards (or vice-versa)
+    log(`connected — ${kind} ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} PID ${dev.productId.toString(16)} iface ${ifNum})`);
     // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
-    if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
+    // (puck-only: the dongle has no stability-test / haptic path)
+    if(stabArmed && !isDongle){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
     updateStabUI();
     // The lizard-map dump (0x11 -> 0xAA) is loaded LAZILY, gated on the firmware advertising v16+ (see
     // applyBlob). Older firmware (every pre-lizard build, including the v15 updater builds) silently drops
@@ -712,7 +732,8 @@ async function openDevice(d){
     // firmware-update gate depends on. So we never send 0x11 until a status blob proves the puck speaks v16.
     lizardLoaded=false;
     startPolling();
-    loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
+    // GitHub releases are PUCK firmware; skip the fetch for a dongle (it flashes via the local-file card).
+    if(!isDongle) loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
     return true;
   }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
 }
@@ -770,10 +791,11 @@ async function send(bytes){
 }
 // Read one framed reply and return its payload (with the 2-byte [marker][len] header stripped). The blob and
 // the bond-export dump share the same framing, distinguished by the marker byte (0xA5 status, 0xA7 bonds).
-async function readFrame(marker, minLen){
+async function readFrame(marker, minLen, readLen){
   try{
     // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
-    const r = await dev.transferIn(epIn, 192);
+    // readLen is overridable: the dongle's 0xAC paired-pucks list reaches 213 B (8 pucks) and needs 256.
+    const r = await dev.transferIn(epIn, readLen||192);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
     // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
@@ -1040,11 +1062,77 @@ async function refresh(){
   if(!dev||inflight||capturing||backupBusy||flightBusy||lizardBusy) return; inflight=true;
   // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
   // that would silently kill all future polls and read as a fake "heartbeat lost".
-  try{ await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
+  try{
+    if(isDongle){ await send([0x01]); const p=await readFrame(0xAC, 3, 256); if(p) applyDongleStatus(p); }
+    else { await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
+  }
   catch(e){ log("refresh err: "+e.message); }
   finally{ inflight=false; }
 }
 
+// ---- ReversePuck controller dongle: paired-pucks list (0xAC frame) ----
+// Payload (readFrame strips the [0xAC][len] header): [ver][flags][count] + per bond 26B:
+//   [slot][alive][puuid4][iuuid4][serial16]. flags bit0=forwarding a Deck, bit1=an RF link is up.
+function applyDongleStatus(p){
+  window._lastBlobTs=Date.now(); // heartbeat + waitReconnect success signal (mirrors applyBlob)
+  if(pendingHang){ // a reconnect after a reboot: close out the hang-log row we opened on disconnect
+    pendingHang=null;
+  }
+  const flags=p[1], count=p[2], bonds=[];
+  for(let i=0;i<count;i++){
+    const q=3+i*26; if(q+26>p.length) break;
+    const dec=new TextDecoder("latin1");
+    let serial=dec.decode(new Uint8Array(p.slice(q+10,q+26)));
+    const z=serial.indexOf(""); if(z>=0) serial=serial.slice(0,z);
+    serial=serial.replace(/[^\x20-\x7e]/g,"").trim(); // strip NUL padding / stray non-printables
+    bonds.push({slot:p[q], alive:!!p[q+1],
+      puuid:p.slice(q+2,q+6), iuuid:p.slice(q+6,q+10), serial});
+  }
+  renderPaired(bonds, flags);
+}
+function renderPaired(bonds, flags){
+  const list=$("#pairedList"); if(!list) return;
+  $("#pairedFwd").textContent = (flags&1) ? "forwarding a Steam Deck" : "idle";
+  $("#pairedLink").textContent = (flags&2) ? "RF link up" : "no RF link";
+  $("#pairedLink").className = "pill "+((flags&2)?"up":"dn");
+  if(!bonds.length){ list.innerHTML='<p class="note">No pucks paired yet. Pair one with <code>scpair.py</code> (see ReversePuck/README.md), then it appears here.</p>'; return; }
+  const hex=a=>[...a].map(x=>x.toString(16).padStart(2,"0")).join("");
+  list.innerHTML="";
+  for(const b of bonds){
+    const row=document.createElement("div"); row.className="row"; row.style.cssText="align-items:center;gap:10px;margin:6px 0";
+    const badge=b.alive?'<span class="pill up" style="flex:0 0 auto">live</span>':'<span class="pill" style="flex:0 0 auto">offline</span>';
+    row.innerHTML=`${badge}<div style="flex:1"><div style="font-weight:600">${b.serial||"(unnamed puck)"}</div>`
+      +`<div class="note" style="font-family:ui-monospace,Menlo,monospace">slot ${b.slot} · puuid ${hex(b.puuid)} · iuuid ${hex(b.iuuid)}</div></div>`;
+    const rm=document.createElement("button"); rm.textContent="Remove"; rm.className="danger"; rm.style.flex="0 0 auto";
+    rm.title="Un-bond this puck from the ReversePuck. You'll need to re-pair to use it again.";
+    rm.onclick=async()=>{
+      if(!confirm(`Remove paired puck "${b.serial||"slot "+b.slot}"?\n\nThe ReversePuck forgets this pairing; re-pair with scpair.py to use it again.`)) return;
+      await send([0x30, b.slot & 0xff]); log("removed paired puck slot "+b.slot);
+      // the firmware replies with a fresh 0xAC after the remove; the next poll (or this read) refreshes the list
+    };
+    row.appendChild(rm); list.appendChild(row);
+  }
+}
+
+// Toggle the UI between the full puck panel and the reduced ReversePuck (controller dongle) panel. Called on
+// every (re)connect so switching a puck<->dongle in one session lands on the right layout.
+function applyDeviceProfile(){
+  const d=isDongle;
+  // body.dongle drives the CSS that hides the puck-only Controller cards; toggling the class back on a puck
+  // reconnect restores them without clobbering the debugonly/lizard inline visibility rules.
+  document.body.classList.toggle("dongle", d);
+  const paired=$("#pairedCard"); if(paired) paired.style.display = d ? "" : "none";
+  // top-bar buttons: factory-erase is a puck-only op (0x0A); the dongle keeps DFU + (debug) wipe-board
+  const fe=$("#factoryErase"); if(fe) fe.style.display = d ? "none" : "";
+  // the GitHub "Update from a release" list is puck firmware; a dongle flashes via the local-file drop card
+  const rc=$("#relCard"); if(rc) rc.style.display = d ? "none" : "";
+  const ua=$("#updAvail"); if(ua && d) ua.classList.add("hide");
+  // flashing the dongle is a primary use, so reveal the Firmware-update tab for it even without ?beta=true
+  // (which otherwise gates the tab for pucks).
+  if(d){ const ut=$('#mainTabs .slot-tab[data-tab="tabUpdate"]'); if(ut) ut.style.display=""; }
+  updateFwGate(); // the dongle always supports panel updates
+}
+
 // ---- Backup / clone ----
 // A backup file is { magic, version, bonds[4], config{} }. bonds come from the 0x09 export (0xA7 frame); the
 // config fields come from the status blob the panel already reads. Restoring replays every setting via the
@@ -1706,7 +1794,7 @@ $("#updAvail").onclick=()=>{ document.querySelector('#mainTabs .slot-tab[data-ta
 // update tab to be usable. Too old => banner explains + both cards go inert until a capable build is flashed
 // the manual way once.
 function updateFwGate(){
-  const ok = !!(lastP && lastP[0]>=15);
+  const ok = isDongle || !!(lastP && lastP[0]>=15);
   $("#updGate").classList.toggle("hide", ok);
   if(!ok) $("#updGateMsg").textContent =
     "This puck is running "+(blobBuildId(lastP)||"an unknown build")+" (status v"+(lastP?lastP[0]:"?")
@@ -1750,8 +1838,9 @@ async function waitReconnect(t0,ms){
 // applied, reconnect observed. getImage runs INSIDE the modal so release downloads show progress there.
 async function runUpdate(title, confirmText, getImage){
   if(dfuBusy) return;
-  if(!dev){ log("connect to the puck first — the update travels over this WebUSB connection"); return; }
-  if(!lastP || lastP[0]<15){
+  if(!dev){ log("connect to the device first — the update travels over this WebUSB connection"); return; }
+  // The dongle always speaks the update ops; only gate a PUCK on its status-blob version.
+  if(!isDongle && (!lastP || lastP[0]<15)){
     log("this puck's firmware ("+(blobBuildId(lastP)||"?")+", status v"+(lastP?lastP[0]:"?")+") predates panel updates (needs v15+) — flash a panel-update-capable build once via UF2 DFU + drag-and-drop, then this works");
     return;
   }