Ver Fonte

Finish name change

Jari há 4 meses atrás
pai
commit
95b4f55d9b

+ 1 - 1
Makefile

@@ -12,7 +12,7 @@ CLANG_FORMAT ?= $(shell command -v clang-format-18 \
 
 # All hand-written C/C++ sources. Generated headers (git_version.h) are
 # gitignored and excluded here so formatting never touches them.
-FORMAT_FILES := $(shell find OpenPuck puck_sniffer pairtui \
+FORMAT_FILES := $(shell find OpenPuck ReversePuckFirmware puck_sniffer pairtui \
 	\( -name '*.c' -o -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \
 	-not -name 'git_version.h')
 

+ 4 - 2
OpenPuck/rf_link.cpp

@@ -28,8 +28,10 @@ uint8_t g_e3mode = 1;
 // (a bare E3) was answered by F1 immediately. That contradicts the earlier RE "recipe" (rf_link.h) which
 // assumed E7 + GET-0x45 were required. So these now default to the real-puck behavior; flip them at runtime
 // ('d'/'n' console cmds) to fall back to the legacy GET/E7 path for an A/B comparison on hardware.
-bool g_pollGet = false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
-bool g_e7announce = false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
+bool g_pollGet =
+	false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
+bool g_e7announce =
+	false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
 // Session-channel E1 host-frame keepalive. The real puck sends NO E1 on its session channel (the bonded
 // controller already knows the per-bond address and just resumes). OpenPuck still needs E1 because it runs
 // the SHARED "ibex" address, not the (un-reversed) per-bond address -- E1 is how the controller learns this

+ 6 - 3
OpenPuck/rf_link.h

@@ -33,9 +33,12 @@ extern uint8_t g_getParam; // GET report 0x45 param byte
 extern uint8_t g_e3mode;
 
 // Real-puck alignment toggles (default to the captured real-puck behavior; flip on the CDC console to A/B):
-extern bool g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
-extern bool g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
-extern bool g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
+extern bool
+	g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
+extern bool
+	g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
+extern bool
+	g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
 
 // full multi-line debug dump vs compact "I45 <hex>" stream
 extern bool g_connVerbose;

+ 5 - 3
OpenPuck/serial_console.cpp

@@ -177,7 +177,8 @@ void serialConsolePoll()
 				Serial.printf(
 					"# CONN mode %s: %s%s poll on ch%u (param=%02X). F1 seen=%lu\n",
 					g_connOn ? "ON" : "off",
-					g_e7announce ? "E7-awake[00 00] -> " : "",
+					g_e7announce ? "E7-awake[00 00] -> " :
+						       "",
 					g_pollGet ? "E3+GET-report-0x45" :
 						    "bare E3",
 					g_rfCh, g_getParam,
@@ -316,8 +317,9 @@ void serialConsolePoll()
 				g_pollGet = !g_pollGet;
 				Serial.printf(
 					"# poll = %s (real puck sends BARE E3) - watch F1=/s new=/s\n",
-					g_pollGet ? "E3 + GET-report-0x45 TLV (legacy)" :
-						    "bare E3");
+					g_pollGet ?
+						"E3 + GET-report-0x45 TLV (legacy)" :
+						"bare E3");
 			} else if (line[0] == 'n') {
 				g_e7announce = !g_e7announce;
 				Serial.printf(

+ 62 - 37
ReversePuck/README.md

@@ -1,14 +1,19 @@
-# OpenController — Steam Deck forwarder
+# ReversePuck — 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.
+streamed over USB-CDC to a **ReversePuck** nRF52840 (firmware in
+[`../ReversePuckFirmware/`](../ReversePuckFirmware)), 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 ──libusb detach + raw DeckState──▶ openctrl.py ──USB-CDC──▶ nRF (28DE:1302) ──RF──▶ puck ──USB──▶ host
 ```
 
+The nRF "dongle" (the ReversePuck firmware, USB `28DE:1302`) is **hot-pluggable**: launch the app with or
+without it plugged in, and it appears/disappears live. Unplug it mid-forward and control returns to the
+Deck automatically; plug it back and the tile reappears — no restart needed.
+
 ## Input: USB-level detach (the right way)
 The Deck controller is USB `28DE:1205`; Steam reads its 64-byte "DeckState" report on interface 2.
 You **can't** block Steam by reading `/dev/hidraw3` (it's broadcast), so `openctrl` uses **libusb
@@ -20,7 +25,8 @@ Modeled on `~/Work/joverlay`. (`--evdev` is a lower-fidelity fallback that can't
 
 **Calibrate the report layout for your unit:** `sudo python3 input_source.py` live-dumps the raw
 DeckState and shows which bytes/bits change as you press each control — edit `DECK_BTN_MAP` / the
-analog offsets in `input_source.py` if anything's off.
+analog offsets in `input_source.py` if anything's off. The IMU axis remap (`IMU_GYRO_MAP` /
+`IMU_ACCEL_MAP`) is calibrated in Steam's gyro test — see the comments above those constants.
 
 ## Pieces
 
@@ -29,41 +35,43 @@ analog offsets in `input_source.py` if anything's off.
 | `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 — **libusb DeckSource (detach + raw decode)** + evdev fallback + capture tool. |
-| `frame.py` | The USB-CDC wire protocol (kept in lock-step with `../OpenController/deck_input.*`). |
+| `frame.py` | The USB-CDC wire protocol (kept in lock-step with `../ReversePuckFirmware/deck_input.*`). |
 | `scpair.py` | Linux hidraw pairing tool (the on-Deck equivalent of `pairtui`). |
-| `run.sh` | `uv` launcher (`pyserial`+`pygame`+`pyusb`) — add this as a non-Steam game. |
+| `ReversePuck` | `uv` launcher (`pyserial`+`pygame`+`pyusb`) — add this as a non-Steam game. |
 | `setup.sh` | Per-boot `sudo`: opens `/dev/ttyACM*` + the controller's usbfs node (read-only SteamOS). |
 
 ## Run with `uv` (no system install, read-only SteamOS friendly)
 
-`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.
+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**.
 
 ```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)
+./ReversePuck            # fullscreen touchscreen UI (first run pulls deps)
+./ReversePuck --debug    # headless status print (bring-up)
+./ReversePuck --evdev    # evdev fallback: no USB detach (Steam/QAM won't forward, lower-fidelity pads)
 ```
 
 ### 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.
+Add the `ReversePuck` launcher as a non-Steam game (Target = `ReversePuck`, **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 while we grab it.
 
 ### 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:
+`/dev/input/event*` is already granted to the active-session user (logind ACL), so the `--evdev` fallback
+works without any setup. The two devices the **default** (libusb) path needs are not, so run once per
+boot from Desktop Mode:
 
 ```bash
-sudo ./setup.sh     # chmod a+rw /dev/ttyACM* (+ /dev/hidraw* for --hidraw); not persistent
+sudo ./setup.sh     # chmod a+rw /dev/ttyACM* (nRF CDC) + the Deck controller's usbfs node; not persistent
 ```
 
-(We can't install a udev rule because `/etc` is read-only and you can't disable that.)
+`setup.sh` opens the nRF CDC (`/dev/ttyACM*`) and the Deck controller's `/dev/bus/usb/...` node (so the
+libusb `DeckSource` can detach + read it). We can't install a udev rule because `/etc` is read-only.
+Without it you'll get a permission error and the app falls back to `--evdev`.
 
 ## Pair the controller to a puck
 
@@ -75,32 +83,49 @@ computer. Two ways:
   `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
+  sudo python3 scpair.py list           # show puck slots + controllers
+  sudo python3 scpair.py pair           # pair into the first free slot
+  sudo 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.
 
+### "Steam says pairing failed" (but it actually works)
+Steam's pairing IS the USB bond-write path above (`0xA2`/`0xEE`/`0xEF` then a `0x95` reboot-to-wireless),
+verified end-to-end. If Steam's dialog reports failure even though the puck connects afterwards, the
+suspect is Steam's post-`0x95` confirmation racing the controller's reboot (the reboot drops USB).
+
+The firmware now **defers the `0x95` reset until the host has read the command's ACK back** (or a short
+grace window lapses), so the USB drop no longer lands mid-transaction — see
+`../ReversePuckFirmware/ctrl_usb.cpp` (`g_respRead` / `REBOOT_GRACE_MS`).
+
+To pin down exactly what Steam polls before declaring failure, watch the firmware's serial monitor during
+a pairing attempt: it logs every feature-report `SET` **and** `GET` (`# SET …` / `# GET rid=… -> …`). The
+last command Steam issues before the dialog fails is the one to reproduce. (The Deck app's framed protocol
+resyncs past these `#` lines, so they're harmless when forwarding.)
+
 ## 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.
+anywhere (or unplug the puck/dongle) to hand control back to the Deck. `Esc`/`Q` quits. The app survives
+the dongle or the Deck pad being unplugged and replugged mid-session — it tears down cleanly and rebuilds
+when the device reappears, so a transient USB hiccup never wedges it or leaves the pad detached.
 
-Validate the RF link first with `./run.sh --debug` (headless): you want a line like
+Validate the RF link first with `./ReversePuck --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.
+- **IMU (gyro + accel)** is read straight from the libusb `DeckSource` and remapped to the SC2 axes via
+  `IMU_GYRO_MAP` / `IMU_ACCEL_MAP` in `input_source.py`. Accel and gyro must share handedness or the
+  host's sensor fusion mixes axes — calibrate both together in Steam's gyro test.
+- **Game Mode / gamescope** — the libusb detach and evdev `EVIOCGRAB` both behave 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.DECK_BTN_MAP` (libusb) / `KEYMAP` (evdev); adjust if your
+  Deck maps face buttons or back paddles differently.
+- **Real Valve puck interop** — the RF reconnect (`../ReversePuckFirmware/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.

+ 4 - 4
ReversePuck/ReversePuck

@@ -1,9 +1,9 @@
 #!/usr/bin/env bash
-# OpenController Steam Deck forwarder launcher (uv-based; nothing installed system-wide).
+# ReversePuck 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)
+#   Target:      /home/deck/…/ReversePuck/ReversePuck
+#   Start In:    /home/deck/…/ReversePuck/    (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.
 #
@@ -12,7 +12,7 @@
 #
 # USB-level detach (DeckSource) needs access to the controller's usbfs node, and the CDC link needs
 # /dev/ttyACM*. Run `sudo ./setup.sh` once per boot to open both (see README). Without it, fall back to
-# `./run.sh --evdev` (no USB detach; Steam/QAM won't forward, trackpads are lower fidelity).
+# `./ReversePuck --evdev` (no USB detach; Steam/QAM won't forward, trackpads are lower fidelity).
 set -euo pipefail
 cd "$(dirname "$(readlink -f "$0")")"
 

+ 1 - 1
ReversePuck/frame.py

@@ -1,4 +1,4 @@
-"""frame.py -- the USB-CDC wire protocol shared with OpenController/deck_input.cpp.
+"""frame.py -- the USB-CDC wire protocol shared with ReversePuckFirmware/deck_input.cpp.
 
     frame = [0xA5][0x5A][type][len][payload(len)][sum8(type,len,payload...)]
 

+ 58 - 17
ReversePuck/openctrl.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""openctrl.py -- Steam Deck forwarder for the OpenController emulated Steam Controller 2.
+"""openctrl.py -- Steam Deck forwarder for the ReversePuck 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.
@@ -13,7 +13,7 @@ link, and control returns to the Deck.
     python3 openctrl.py --evdev         # use the evdev fallback input source (lower fidelity)
 
 The dongle (nRF CDC) is hot-pluggable: launch with or without it, it appears/disappears live.
-Deps: pyserial + pygame (UI) + pyusb (input/USB detach). See README.md / run.sh.
+Deps: pyserial + pygame (UI) + pyusb (input/USB detach). See README.md / the ./ReversePuck launcher.
 """
 import argparse
 import os
@@ -24,7 +24,8 @@ import frame
 import input_source as isrc
 
 VID = 0x28DE
-PID = 0x1302  # the OpenController emulated controller
+PID = 0x1302  # the ReversePuck emulated controller (nRF "dongle")
+PUCK_PID = 0x1304  # a puck's own CDC -- never grab this as the dongle
 INPUT_HZ = 250.0
 
 
@@ -36,9 +37,9 @@ def find_port():
     for p in list_ports.comports():
         if (p.vid, p.pid) == (VID, PID):
             return p.device
-    # fall back: any Valve CDC
+    # fall back: any Valve CDC except a puck's (so a puck docked on the Deck isn't mistaken for it)
     for p in list_ports.comports():
-        if p.vid == VID:
+        if p.vid == VID and p.pid != PUCK_PID:
             return p.device
     return None
 
@@ -64,7 +65,7 @@ class App:
         # model surfaced to the UI
         self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
                        "bonds": [], "forwarding": False}
-        self.note = "waiting for OpenController dongle (28DE:1302)…"
+        self.note = "waiting for ReversePuck dongle (28DE:1302)…"
         self.log = []  # firmware '#' diagnostic lines (the in-app serial monitor)
 
         # file log: everything (firmware '#' lines + app events) with timestamps, for sharing.
@@ -111,9 +112,9 @@ class App:
             self.ser = None  # e.g. permission / busy; will retry
 
     def _drop_serial(self, why):
-        """Tear down on disconnect: stop forwarding, close the port, reset the model."""
-        if self.forwarding:
-            self.stop_forwarding()
+        """Tear down on disconnect: close the port FIRST so we never try to write to a gone dongle, then
+        release the grabbed pad locally and reset the model. (Closing first also makes send() a no-op, so
+        there's no re-entrant _drop_serial via a failing write.)"""
         try:
             if self.ser:
                 self.ser.close()
@@ -121,6 +122,7 @@ class App:
             pass
         self.ser = None
         self.port_name = None
+        self._release_local()  # hand the pad back without trying to notify the dongle that's gone
         self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
                        "bonds": [], "forwarding": False}
         self.note = "dongle disconnected (%s) — waiting…" % why
@@ -192,6 +194,9 @@ class App:
         try:
             self._ensure_input().grab()
         except Exception as ex:
+            # roll back any partial interface claim so the pad is never left half-detached, and force a
+            # clean rebuild of the source next time (the cached handle may be wedged).
+            self._drop_input("grab failed: %s" % ex)
             self.note = "grab failed: %s" % ex
             return
         self.forwarding = True
@@ -200,15 +205,32 @@ class App:
         self.note = "forwarding -> slot %s (%s)" % (slot, self.input.name)
         self._flog("APP forwarding START slot=%s input=%s" % (slot, self.input.name))
 
-    def stop_forwarding(self):
+    def _release_local(self):
+        """Clear forwarding state + ungrab the pad locally, WITHOUT notifying the dongle (used when the
+        dongle or input device has gone away, so there's nothing to tell)."""
         self.forwarding = False
         self.fwd_slot = None
-        self.send(frame.build_set_forwarding(False))
         if self.input:
-            self.input.ungrab()
+            try:
+                self.input.ungrab()
+            except Exception:
+                pass
+
+    def stop_forwarding(self):
+        """Clean, user-initiated stop: tell the firmware to stop forwarding, then release the pad."""
+        if self.forwarding:
+            self.send(frame.build_set_forwarding(False))
+        self._release_local()
         self.note = "control returned to Deck"
         self._flog("APP forwarding STOP")
 
+    def _drop_input(self, why):
+        """Tear down the input source and force a rebuild on the next grab, so a transient USB hiccup on
+        the Deck pad (or a failed grab) can't wedge the app behind a dead handle."""
+        self._release_local()
+        self.input = None
+        self._flog("input dropped: %s" % why)
+
     def toggle(self, slot):
         """Tap handler: a tappable tile is one whose puck is live (alive)."""
         if self.forwarding:
@@ -228,7 +250,17 @@ class App:
     def pump_input(self):
         if not self.forwarding or not self.input:
             return
-        st = self.input.pump()
+        try:
+            st = self.input.pump()
+        except Exception as ex:
+            # the pad raised (USB reset / yanked) mid-forward. Notify the firmware to stop (the dongle is
+            # likely still up, so the puck doesn't freeze on the last frame), then drop the dead handle so
+            # the next tap rebuilds it -- never let this bubble up and kill the loop.
+            self._flog("input pump error: %s" % ex)
+            self.stop_forwarding()
+            self.input = None
+            self.note = "input device error — control returned to Deck"
+            return
         now = time.monotonic()
         if now - self.last_input >= 1.0 / INPUT_HZ:
             self.last_input = now
@@ -248,9 +280,12 @@ class App:
     def run_debug(self):
         last = 0
         while True:
-            self.pump_serial()
-            self.pump_input()
-            self.auto_release_on_drop()
+            try:
+                self.pump_serial()
+                self.pump_input()
+                self.auto_release_on_drop()
+            except Exception as ex:
+                self._flog("debug loop error: %r" % ex)
             if time.monotonic() - last > 0.5:
                 last = time.monotonic()
                 s = self.status
@@ -268,7 +303,7 @@ class App:
 
 
 def main():
-    ap = argparse.ArgumentParser(description="Steam Deck forwarder for OpenController")
+    ap = argparse.ArgumentParser(description="Steam Deck forwarder for ReversePuck")
     ap.add_argument("--port", help="CDC port (default: autodetect Valve 28DE:1302)")
     ap.add_argument("--input", help="force an evdev node, e.g. /dev/input/event12 (only with --evdev)")
     ap.add_argument("--evdev", action="store_true",
@@ -289,6 +324,12 @@ def main():
     finally:
         if app.forwarding:
             app.stop_forwarding()
+        # belt-and-suspenders: never exit with the Deck pad still detached from Steam
+        if app.input:
+            try:
+                app.input.ungrab()
+            except Exception:
+                pass
 
 
 if __name__ == "__main__":

+ 3 - 3
ReversePuck/scpair.py

@@ -3,8 +3,8 @@
 
 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.
+Used to pair on the Steam Deck itself when BOTH the puck (PID 1304) and the ReversePuck controller
+(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)
@@ -192,7 +192,7 @@ def pair(slot=None):
     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")
+        raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
     ctrl = ctrls[0]
     puck_serial = pucks[0].read_serial(2) or "FXB000000000"
     ctrl_serial = ctrl.read_serial(1) or "FXA000000000"

+ 1 - 1
ReversePuck/setup.sh

@@ -3,7 +3,7 @@
 # Run ONCE PER BOOT from Desktop Mode:   sudo ./setup.sh
 #
 # Opens, for this boot only (nothing on the read-only FS changes -- re-run after a reboot):
-#   * /dev/ttyACM*                      -- the nRF (OpenController) CDC link
+#   * /dev/ttyACM*                      -- the nRF (ReversePuck) CDC link
 #   * the Deck controller's usbfs node  -- so the app's libusb DeckSource can detach + read it
 #                                          (28DE:1205); this is what lets it take the pad from Steam
 # /dev/input/event* is already granted to the active session (logind ACL) for the --evdev fallback.

+ 12 - 6
ReversePuck/ui.py

@@ -26,7 +26,7 @@ def _font(sz, bold=False):
 
 def run(app):
     pygame.init()
-    pygame.display.set_caption("OpenController · Steam Deck")
+    pygame.display.set_caption("ReversePuck · Steam Deck")
     screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
     W, H = screen.get_size()
     clock = pygame.time.Clock()
@@ -62,7 +62,7 @@ def run(app):
         th = int((H * 0.42 - gap * max(0, len(bonds) - 1)) / max(1, len(bonds)))
         th = min(th, int(H * 0.20))
         if not connected:
-            msg = f_big.render("Plug in the OpenController dongle", True, WARN)
+            msg = f_big.render("Plug in the ReversePuck dongle", True, WARN)
             screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
             sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
                                  True, DIM)
@@ -158,10 +158,16 @@ def run(app):
             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()
+        # One bad frame (a transient serial/USB/draw error) must never take the whole app down — log it
+        # and keep looping so a dongle/pad replug recovers on its own.
+        try:
+            app.pump_serial()
+            app.pump_input()
+            app.auto_release_on_drop()
+            draw()
+        except Exception as ex:
+            app._flog("UI loop error: %r" % ex)
+            app.note = "recovered from error: %s" % ex
         # high tick so forwarding input stays smooth; UI redraw is cheap
         clock.tick(120)
 

+ 5 - 4
ReversePuckFirmware/ReversePuckFirmware.ino

@@ -1,4 +1,4 @@
-// OpenController.ino -- emulated Steam Controller 2 ("Triton"/IBEX) for an nRF52840 Pro Micro.
+// ReversePuckFirmware.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
@@ -12,7 +12,7 @@
 //   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
+//           --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" ReversePuckFirmware
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -56,8 +56,9 @@ void setup()
 	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);
+	Serial.printf(
+		"# ReversePuck controller 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) |

+ 25 - 4
ReversePuckFirmware/ctrl_usb.cpp

@@ -47,6 +47,13 @@ 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;
+// 0x95 reboots into wireless mode, which drops USB. If we reset before the host has read back the
+// command's ACK, Steam sees the disconnect mid-transaction and reports "pairing failed" even though the
+// bond is already on flash. So we wait for the host to GET the response (g_respRead), or REBOOT_GRACE_MS
+// for a host that fires 0x95 without reading back (scpair.py does this), before resetting.
+static volatile bool g_respRead = false;
+static uint32_t g_rebootAt = 0;
+static const uint32_t REBOOT_GRACE_MS = 400;
 
 // Select bond slot from a Zephyr settings key string: "esb/bond" -> 0, "esb/bond_2" -> 1.
 static int slotForKey(const char *key)
@@ -211,9 +218,12 @@ static void handleSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 			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
+	// reboot into wireless mode (magic 0xA427AF52); the reset happens after the flash flush AND once the
+	// host has read the ACK back (see g_respRead / REBOOT_GRACE_MS above)
 	case 0x95:
 		g_rebootPending = true;
+		g_respRead = false;
+		g_rebootAt = millis();
 		if (Serial.availableForWrite() > 40)
 			Serial.println("# 0x95 reboot-to-wireless requested");
 		g_resp[0] = 0x95;
@@ -235,6 +245,13 @@ static uint16_t handleGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 	(void)rid;
 	if (type != HID_REPORT_TYPE_FEATURE)
 		return 0;
+	// The host read the staged response -> the SET/GET transaction completed. Used to know a 0x95 ACK
+	// landed before we drop USB, and as a serial-monitor trace of Steam's pairing read sequence (the SET
+	// side is logged in handleSet; a bare GET with no preceding SET shows up here as a stale g_resp[0]).
+	g_respRead = true;
+	if (Serial.availableForWrite() > 40)
+		Serial.printf("# GET rid=%u -> %02X %02X req=%u\n", rid,
+			      g_resp[0], g_resp[1], (unsigned)reqlen);
 	uint16_t nn = g_resp_len ? g_resp_len : 63;
 	if (nn > reqlen)
 		nn = reqlen;
@@ -260,9 +277,13 @@ void ctrlUsbPoll()
 		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);
+	// reboot only AFTER the freshly-written bond is on flash (else it reloads a stale bond on boot) AND
+	// the host has read the 0x95 ACK back (or the grace window lapsed for a fire-and-forget host) so the
+	// USB drop never lands mid-transaction and reads to Steam as a pairing failure.
+	if (g_rebootPending && !g_bondDirty &&
+	    (g_respRead ||
+	     (uint32_t)(millis() - g_rebootAt) > REBOOT_GRACE_MS)) {
+		delay(20);
 		NVIC_SystemReset();
 	}
 }

+ 40 - 20
puck_sniffer/puck_sniffer.ino

@@ -55,7 +55,8 @@ struct Cap;
 #define BONDFILE "/sniffbonds.bin"
 #define BOND_MAGIC 0x53424E32u // "SBN2"
 #define NBONDS 4
-#define BOND_NCH 6 // learned channels per bond (the map is ~4; a couple of spares)
+#define BOND_NCH \
+	6 // learned channels per bond (the map is ~4; a couple of spares)
 struct Bond {
 	uint8_t uuid[4]; // ibex_uuid key; all-zero = empty slot
 	uint8_t base[4]; // session ESB base
@@ -66,7 +67,8 @@ struct Bond {
 };
 static Bond g_bonds[NBONDS];
 static int g_curBond = -1; // index of the bond we're camped on (-1 = none)
-static bool g_bondDirty = false; // a learn changed the table -> flush to flash in loop()
+static bool g_bondDirty =
+	false; // a learn changed the table -> flush to flash in loop()
 static unsigned long g_lastBondSave = 0;
 
 static bool uuidSet(const uint8_t u[4])
@@ -153,7 +155,8 @@ struct Cap {
 	uint8_t ch, flags, rssi, match, n;
 	uint8_t b[96];
 };
-#define RINGN 1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
+#define RINGN \
+	1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
 static Cap g_ring[RINGN];
 static volatile uint16_t g_head = 0, g_tail = 0;
 static inline uint16_t rnext(uint16_t i)
@@ -168,7 +171,8 @@ static void tuneSession();
 static bool g_streaming = false; // app sent START -> emit packet frames
 // g_state/g_curCh are written by loop() (retune) and READ by the RADIO END ISR (which stamps each captured frame
 // with the current channel/state) -> volatile so the ISR never sees a cached value.
-static volatile uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
+static volatile uint8_t g_state =
+	0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
 static volatile uint8_t g_curCh = 2;
 static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
 static uint8_t g_sPrefix = 0x10;
@@ -201,7 +205,8 @@ static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
 // this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
 static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
 static uint8_t g_sweepIdx = 0;
-static volatile uint8_t g_sweepDry = 0; // reset in the RADIO ISR on any session frame
+static volatile uint8_t g_sweepDry =
+	0; // reset in the RADIO ISR on any session frame
 
 // Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
 // controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
@@ -426,17 +431,28 @@ static void emitStatus()
 	uint8_t curN = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
 	uint8_t bondInfo = (uint8_t)((g_curBond >= 0 ? 0x80 : 0) |
 				     ((nbonds & 0x7) << 4) | (curN & 0xF));
-	uint8_t f[22] = { 0xC1,		  0xDE,
-			  g_state,	  g_curCh,
-			  g_sBase[0],	  g_sBase[1],
-			  g_sBase[2],	  g_sBase[3],
-			  g_sPrefix,	  (uint8_t)(g_streaming ? 1 : 0),
-			  (uint8_t)rx,	  (uint8_t)(rx >> 8),
-			  g_advCh,	  g_advBase[0],
-			  g_advBase[1],	  g_advBase[2],
-			  g_advBase[3],	  g_advPfx,
-			  g_lastMatch,	  (uint8_t)drops,
-			  (uint8_t)(drops >> 8), bondInfo };
+	uint8_t f[22] = { 0xC1,
+			  0xDE,
+			  g_state,
+			  g_curCh,
+			  g_sBase[0],
+			  g_sBase[1],
+			  g_sBase[2],
+			  g_sBase[3],
+			  g_sPrefix,
+			  (uint8_t)(g_streaming ? 1 : 0),
+			  (uint8_t)rx,
+			  (uint8_t)(rx >> 8),
+			  g_advCh,
+			  g_advBase[0],
+			  g_advBase[1],
+			  g_advBase[2],
+			  g_advBase[3],
+			  g_advPfx,
+			  g_lastMatch,
+			  (uint8_t)drops,
+			  (uint8_t)(drops >> 8),
+			  bondInfo };
 	usb_web.write(f, sizeof f);
 	usb_web.flush();
 }
@@ -451,9 +467,11 @@ static void consumeRing()
 	// bound per-call so the loop stays responsive; the 512-deep ring absorbs anything beyond this
 	uint16_t budget = 128;
 	while (budget-- && g_tail != g_head) {
-		Cap c = g_ring[g_tail]; // copy out before releasing the slot to the producer
+		Cap c = g_ring
+			[g_tail]; // copy out before releasing the slot to the producer
 		g_tail = rnext(g_tail);
-		bool retuned = processFrame(c); // may retune the radio (acquire / channel hop)
+		bool retuned = processFrame(
+			c); // may retune the radio (acquire / channel hop)
 		if (emit)
 			emitPacket(c);
 		// On a retune, stop draining this round: the remaining queued frames were captured on the OLD channel, and
@@ -586,7 +604,8 @@ void setup()
 		if (uuidSet(g_bonds[i].uuid))
 			count++;
 	if (count == 1)
-		campBond(b0); // sole known bond -> adopt immediately (unambiguous)
+		campBond(
+			b0); // sole known bond -> adopt immediately (unambiguous)
 	else
 		tuneDiscovery(); // 0 bonds, or several -> discover / wait for a uuid lock
 	g_lastRx = millis();
@@ -612,7 +631,8 @@ void loop()
 	    millis() - g_lastFrx > HUNT_NOFRX_MS &&
 	    millis() - g_huntMs > HUNT_DWELL_MS) {
 		g_huntMs = millis();
-		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
+		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans :
+						     0;
 		uint8_t total = learned + 1 + (uint8_t)sizeof HUNT_CH;
 		uint8_t slot = g_huntIdx;
 		uint8_t ch;