ARCHITECTURE.md 14 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.

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 "ibex", CRC16 0x11021/0xFFFF (address included). On-air address is the bit-reversed stored bytes.
  • Bonding/reconnect: we transmit a host frame on the rendezvous address (channel 2) carrying the bonded controller's UUIDs plus the session base/prefix/channel we want it to use. The controller filters by UUID and connects on the clean session channel (default 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).

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.