ARCHITECTURE.md 17 KB

OpenPuck Architecture

This document is the map of the firmware. It explains what the device is, how the code is laid out, how data flows through it, and how to extend it. If you only read one thing before touching the code, read this.

What the device is

OpenPuck turns an nRF52840 Pro Micro into a clone of the Steam Controller 2 "puck" — the little USB radio dongle the controller pairs with. It does three jobs at once:

  1. Speaks the puck's RF protocol to a real Steam Controller 2 ("Triton") over a bare-metal nRF radio (no SoftDevice, no BLE stack). It bonds controllers, transmits the host frame they reconnect on, and polls them for input at ~250 Hz.
  2. Presents a USB personality to the host PC/console. The same controller input can be re-dressed as the Valve puck (for Steam), an Xbox 360 pad, a Switch controller, a PS5 DualSense, or a DS4 — selected at boot.
  3. Exposes config surfaces — a WebUSB channel for the browser panel and a CDC serial console for debugging and the protocol reverse-engineering tooling.

It runs as a single-threaded loop() plus USB interrupt callbacks. There is no RTOS task switching in our code; concurrency is cooperative. The only preemption is the USB stack's ISR (which calls our HID report callbacks) and the radio's hardware events (which we poll, never interrupt on).

The big picture

                 ┌──────────────────────── host PC / console ────────────────────────┐
                 │  USB: one personality at a time (puck / xbox / switch / ps5 / ds4) │
                 └───────────────▲─────────────────────────────────▲──────────────────┘
                                 │ HID reports                      │ WebUSB / CDC
                                 │                                  │
        ┌────────────────────────┴─────────────┐        ┌──────────┴───────────┐
        │  controllers.h  (IController vtable)  │        │ webusb_config        │
        │  mode_*.cpp  — one per personality    │        │ serial_console       │
        └───────▲───────────────────▲───────────┘        └──────────┬───────────┘
   onReport45() │                   │ task()                        │ tunables / commands
        (push)  │                   │ (stream)                      ▼
        ┌───────┴───────────────────┴───────────────────────────────────────────┐
        │  rf_link.cpp  — operational puck protocol                              │
        │   • host-frame beacon  • connected poll (E7/E3)  • decode 0xF1 → g_in  │
        │   • mode-switch chord  • remote wakeup  • QoS channel hop  • stats     │
        └───────▲──────────────────────────────────────────▲────────────────────┘
                │ rfConfig / rfConnTx                       │ relay (haptics.cpp)
        ┌───────┴────────────┐                     ┌────────┴──────────┐
        │  radio.cpp         │   rf_diag.cpp ─────▶│  (RE/calibration  │
        │  nRF52 RADIO HW    │   (capture/sniff/   │   tooling, isolated)
        └────────────────────┘    replay/sweep)    └───────────────────┘

   shared state:  config (modes+tunables) · identity · bonds (slots+link) · triton (g_in) · gamepad_util

The central data flow is one direction for input:

radio RX → rf_link decodes report 0x45 into g_in (a PuckInput struct) → the active IController reads g_in and emits a host HID report.

and the reverse for haptics/config:

host OUTPUT report / WebUSB / CDC → queued into the haptic relay or a tunable → rf_link flushes the relay to the controller inside the poll cadence.

Module layout (OpenPuck/)

The Arduino build compiles every .cpp/.h in the sketch folder; the build command is unchanged (it still just points at the OpenPuck directory). Modules are layered low → high:

File Responsibility
OpenPuck.ino Entry point only: setup() builds the USB presentation for the persisted mode + arms the watchdog; loop() pumps each subsystem.
config.{h,cpp} USB-mode definitions (MODE_*), persisted settings (cfg.bin), all runtime tunables, mode-switch persistence policy.
identity.{h,cpp} Unique device serial from FICR DEVICEID; the puck 0x83 attribute blob.
bonds.{h,cpp} The four bond slots (g_slot), bond persistence (bonds.bin), and the live link state (g_connSlot, g_connReplyMs).
radio.{h,cpp} Bare-metal nRF52 RADIO register layer: PHY/CRC/whitening/address config (all live-tunable), rfConfig()/rfSetAddr(), the rfrx/rftx DMA buffers.
triton.{h,cpp} The controller's native input: report 0x45 button masks (TB_*) + decoders, and the shared decoded-input struct g_in.
gamepad_util.{h,cpp} Cross-mode helpers for building host reports: stick rescaling, Steam-trackpad → touch mapping, PlayStation-layout button packers.
haptics.{h,cpp} Host→controller haptic relay queue + the watchdogs that stop a latched buzz; the OUTPUT-report capture ring.
controllers.{h,cpp} The IController abstraction + the mode → singleton registry.
puck_hid.{h,cpp} Steam/Lizard personality: the puck HID slot interfaces, the feature command channel (bond read/write, attributes), the auto-lizard decision, and the USB connection presentation.
mode_lizard.{h,cpp} The lizard keyboard+mouse mapping (rides on the puck interface; not a standalone controller).
mode_xinput.{h,cpp} Xbox personality: a custom TinyUSB XInput class driver + right-pad mouse + rumble relay.
mode_switch_hori.{h,cpp} Switch HORIPAD personality (console-friendly, no handshake).
mode_switch_pro.{h,cpp} Switch Pro personality: the full Nintendo USB handshake/subcommand state machine + SPI calibration + gyro.
mode_ps5.{h,cpp} PS5 DualSense personality: gyro + split trackpad.
mode_hidgyro.{h,cpp} DS4-layout generic HID gyro personality (motion-aware PC games).
rf_link.{h,cpp} The operational puck protocol: host-frame beacons, connected-mode poll loop, 0xF1 decode + dispatch, chord detection, remote wakeup, QoS hopping, stats.
rf_diag.{h,cpp} RF reverse-engineering / calibration tooling: raw capture, CRC-validating config sweeps, frame replay, address listen, scan-then-respond, live-session sniffer. Not used in normal operation.
webusb_config.{h,cpp} The WebUSB binary config channel for the browser panel.
serial_console.{h,cpp} The CDC single-letter debug command line.
wake_hid.{h,cpp} A boot-keyboard HID interface added to the clean controller modes so the host honors USB remote-wakeup (see "Wake from sleep").

The controller abstraction

IController (in controllers.h) is the seam that makes each USB personality a self-contained file. Exactly one controller is active per boot, chosen by g_usbMode via controllerFor() and stored in g_active.

class IController {
  virtual void begin();                                              // setup(): register USB interface(s) + IDs
  virtual void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen);  // RF input arrived (PUSH modes)
  virtual void task();                                               // loop(): streaming emit / handshake drains
  virtual bool isPuck() const;                                       // Steam/Lizard keep the boot CDC composite
};

Two emission styles:

  • Push modes (puck/lizard, xbox) build and send their host report inside onReport45(), driven by each decoded RF frame. They care about fresh (was the report new?) and the raw report bytes.
  • Stream modes (switch hori/pro, ps5, ds4) ignore onReport45() and instead emit at a fixed ~250 Hz cadence from task(), reading the latest g_in. This decouples the USB report rate from the RF rate, which is what those hosts expect.

setup() owns the shared USB lifecycle (detach, keep-or-clear the CDC composite based on isPuck(), set the per-mode serial suffix, attach); begin() only adds the interfaces specific to that controller.

Adding a new personality

  1. Create mode_foo.{h,cpp} defining class FooController : public IController and a singleton extern FooController g_fooCtl;.
  2. Implement begin() (set USBDevice VID/PID/strings, register your HID/vendor interface) and either onReport45() (push) or task() (stream) reading g_in.
  3. Add a MODE_FOO constant in config.h (bump MODE_MAX), a MODE_SUFFIX letter in OpenPuck.ino, and a case MODE_FOO: return &g_fooCtl; in controllerFor() (controllers.cpp).

Nothing in rf_link, radio, or the other modes needs to change — g_in is your input contract.

Shared input state: g_in

rf_link decodes the controller's report 0x45 into a single PuckInput g_in (triton.h): buttons, sticks, triggers, both trackpad coordinates, and IMU (accel + gyro). Every controller reads from this one struct. (The pre-refactor firmware copied the same IMU/stick/button values into three separate per-mode global sets; that duplication is gone.)

The raw report bytes are still passed to onReport45() for the modes that forward them verbatim (the Steam path forwards the puck's raw 0x45 body untouched so Steam does its own trackpad smoothing).

The RF protocol, briefly

Full details are in docs/PROTOCOL.md; the register-level constants and their provenance are commented in radio.cpp and rf_link.cpp. The essentials:

  • PHY/framing (decoded from real-puck capture): Ble_2Mbit, big-endian, whitening off, 5-byte address, CRC16 0x11021/0xFFFF (address included). On-air address is the bit-reversed stored bytes.
  • Two addresses — discovery vs session: the discovery/rendezvous address is the shared, controller-known "ibex" on channel 2; every puck uses it so any controller can find any puck. The session address is unique per device (g_sessBase/g_sessPrefix, derived from the FICR DEVICEID in rfGenSessionAddr()). This mirrors the real puck and is what keeps two OpenPucks from colliding on-air (see "Multi-puck isolation").
  • Bonding/reconnect: we transmit a host frame — on the shared "ibex" rendezvous (ch2) for a searching controller, and on our unique session address as a keepalive once connected. Both carry the bonded controller's UUIDs plus the session base/prefix/channel we want it to adopt. The controller filters by UUID, adopts the advertised session address, and the connected poll then runs there (default channel 18).
  • Connected poll: we are the PTX/master. We announce host-awake (E7 00 00), then each poll cycle send E3 + a GET-report-0x45 sub-TLV; the controller replies with a 0xF1 input report containing TLVs (type 6 = the HID report 0x45). Cycling the ESB PID on the poll is what drains the controller's report queue at full rate (~400 new reports/s instead of ~60).
  • Haptics ride back as a SET sub-TLV inside the poll (haptics.cpp queues, rf_link flushes).

Timing & concurrency model

  • One cooperative loop(). Each subsystem's per-loop hook (g_active->task(), rfLinkTask(), hapticTask(), rfDiagTask(), webusbPoll(), serialConsolePoll()) must be non-blocking — anything that spins risks starving USB servicing.
  • The RF poll is busy-wait bounded: every radio wait (RWAIT_DISABLED, the RX windows) has a microsecond timeout and bails rather than hanging. A wedged radio must never freeze the loop.
  • A hardware watchdog (~8 s, armed in setup()) resets the chip if loop() ever stops feeding it — so a hang re-enumerates USB and re-inits RF on its own without a physical replug.
  • USB HID report callbacks (handleSet/handleGet in puck_hid, jcSet in mode_switch_pro) run in ISR context. They only stage data (into bond slots, the relay queue, or an SPSC reply ring); the actual sendReport happens later from task()/loop() where it's safe.
  • volatile marks state shared across the ISR/loop boundary (the relay queue, the Switch-Pro reply ring indices, the rumble level).

Persistence

Two files in the nRF52 internal LittleFS:

  • cfg.bin (config.cpp) — USB mode, tunables, chord assignments, persistence policy. A magic byte versions the layout; a mismatch falls back to clean defaults.
  • bonds.bin (bonds.cpp) — the four bond records.

Mode switches (chord, WebUSB, or CDC) call saveMode() then reboot so the next boot enumerates the right interface set. By default every cold boot returns to Steam mode unless "persist last mode" is enabled.

Config & debug surfaces

  • WebUSB (webusb_config.cpp): present in every mode; the browser panel reads a status blob and sets one-byte tunable fields or requests a mode switch.
  • CDC console (serial_console.cpp): present only in puck modes. Single-letter commands toggle RF diag modes, poke radio registers live, switch USB mode, edit tunables, inject test haptics, and dump capture history. It is an else-if chain, so the first matching letter wins — a couple of letters (C, H) appear twice and the second occurrence is currently unreachable (preserved verbatim from the original; worth cleaning up if those commands are ever needed).

Multi-puck isolation

Because the discovery address is shared, two things prevent one puck from disturbing another:

  1. Unique session address. After bonding, each puck moves the controller onto its own per-device session base/prefix (advertised in the host frame). The connected poll and its RX windows run there, so a puck only ever hears its own controller — not a neighbor's session traffic.
  2. Reply-type gate. The link-alive timestamp (g_connReplyMs, which drives the "controller reconnected → wake the host" remote-wakeup) is stamped only on genuine controller replies (F-type: 0xF1/0xF2/0xF3), never on a puck→controller frame (E-type: 0xE1 host frame, 0xE2/E3/E7). This is belt-and-suspenders: it means even a neighbor's 0xE1 discovery beacon picked up on the shared rendezvous can't be mistaken for a reconnect. (Before these two fixes, plugging a second puck into another computer would wake a sleeping host running the first puck.)

Wake from sleep

The puck can wake a sleeping host via USBDevice.remoteWakeup() — driven from rf_link.cpp on a Steam-button short-press or a fresh RF reconnect (guarded by USBDevice.suspended()). Every mode advertises the remote-wakeup capability in its config descriptor, so the device is always armed. The catch is that a host only honors a resume signal from an allow-listed input device class (HID keyboard/mouse) — a bare gamepad/vendor/composite presentation gets armed but ignored (notably Windows under Modern Standby). That's why Xbox mode (which exposes a boot mouse) woke Windows while the gamepad/puck modes didn't.

The fix: wake_hid.cpp registers a do-nothing boot keyboard interface so each mode is classified as a wake-capable input device. It's added in setup() for every clean mode. It is not added in puck mode, which is already at the nRF52840's 7-data-IN-endpoint limit (CDC + 4 puck HID + WebUSB) — adding it there would require freeing an endpoint (dropping CDC or WebUSB, or a bond slot). Puck mode already wakes on Linux/Steam Deck; making it wake Windows is the one case this doesn't cover without that tradeoff.

Note hosts differ in what they honor: Windows keys on the input device class (this fix targets it); Linux/Steam Deck gates wake per-device in /sys/.../power/wakeup regardless of class, so a mode that still won't wake the Deck may just need that sysfs flag enabled host-side.

RF reverse-engineering tooling

rf_diag.cpp holds everything used to discover the protocol — promiscuous capture, config sweeps, bit-exact replay, the session sniffer, scan-then-respond. The protocol is solved, so none of it runs in normal use, but it's kept compiled and reachable (via the CDC console) because it's the only way to re-derive parameters if a controller firmware update ever changes them. It depends only on radio and bonds, so it stays out of the operational path in rf_link.