|
|
@@ -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.
|