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.
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:
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).
┌──────────────────────── 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_linkdecodes report0x45intog_in(aPuckInputstruct) → the activeIControllerreadsg_inand 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_linkflushes the relay to the controller inside the poll cadence.
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. |
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:
onReport45(), driven by each
decoded RF frame. They care about fresh (was the report new?) and the raw report bytes.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.
mode_foo.{h,cpp} defining class FooController : public IController and a singleton
extern FooController g_fooCtl;.begin() (set USBDevice VID/PID/strings, register your HID/vendor interface) and either
onReport45() (push) or task() (stream) reading g_in.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.
g_inrf_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).
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:
Ble_2Mbit, big-endian, whitening off, 5-byte address
"ibex", CRC16 0x11021/0xFFFF (address included). On-air address is the bit-reversed stored bytes.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.cpp queues, rf_link flushes).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.RWAIT_DISABLED, the RX windows) has a microsecond
timeout and bails rather than hanging. A wedged radio must never freeze the loop.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.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).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.
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.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_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.