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. |
wake_hid.{h,cpp} |
A boot-mouse HID interface added to the clean controller modes so the host honors USB remote-wakeup (see "Wake from sleep"). |
status_led.{h,cpp} |
Wake-sent LED indicator: dark in all steady states; flashes 500 ms at each remoteWakeup(). Drives both the Feather user LED (P1.15) and the SuperMini clone's blue LED (P0.15); pins/polarity overridable. |
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,
CRC16 0x11021/0xFFFF (address included). On-air address is the bit-reversed stored bytes."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")."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).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).Because the discovery address is shared, two things prevent one puck from disturbing another:
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.)Waking the host is an explicit gesture, never a side effect of input. While the bus is suspended, the puck
personality sends nothing (no 0x45 forwards, no lizard mouse/keyboard, no periodic 0x79/0x7B status) —
a suspended-bus sendReport can itself translate into a host wake, which made the PC wake on any trackpad
graze and nearly impossible to keep asleep. The only wake triggers are a Steam-button short press or a
controller connect, detected in rf_link.cpp (guarded by USBDevice.suspended()). Each fires two things:
USBDevice.remoteWakeup() — the device-level USB resume signal; andg_active->wakeEvent() — queues a wake nudge, because a bare resume signal is not enough to wake
some hosts (Windows especially): they only wake when real keyboard/mouse input follows the resume. Once
the bus is back up, the puck personality plays a HARMLESS nudge on its own reports (wakeNudgeTask in
puck_hid.cpp): a mouse jiggle (move a few px and back, no button) + a lone Left-Ctrl tap. It can't
be sent during suspend (reports can't cross a suspended bus), so it's delivered right after resume.
(It originally sent a left click + space, which woke the host but also clicked/activated whatever was
focused — it kept launching the browser. Move + modifier wake just as well with no actionable side effect.)The board LED is a wake debugger (status_led.cpp): dark in all steady states (including while armed), and a
500 ms flash at the moment a remoteWakeup() is actually sent. Flash + host stays asleep = the resume was sent
and the host ignored it (fix host-side, e.g. powercfg /deviceenablewake); no flash = the firmware never fired
(it didn't see the gesture, or didn't consider the bus suspended). USB remote wakeup is a device-level
signal — one wake line per device, armed by a single SET_FEATURE(DEVICE_REMOTE_WAKEUP); there is no
per-interface arming on the wire. Which Windows device node gets credited is host-internal policy.
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 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.
Why a function (not just the device) must be wake-capable: device-level remote-wakeup is necessary but not
sufficient on Windows — a function driver must hold a pending wait-wake request (IRP_MN_WAIT_WAKE) for the
system to actually wake, and only the mouse/keyboard class drivers (mouhid/kbdhid) arm one by default
(that's the "Allow this device to wake the computer" checkbox). The puck's generic-HID / CDC / WebUSB functions
don't, so its armed device-level wake was getting ignored. A boot-mouse function fixes that.
The fix: wake_hid.cpp registers a do-nothing boot mouse interface (the same shape Xbox mode already uses
to wake Windows) so each mode exposes a mouhid-bound, wait-wake-armed function. It's added in setup() for
every clean mode and for puck mode on a normal boot.
Puck mode and the endpoint budget. The nRF52840 has only 7 data IN endpoints. The puck composite used all
7 (CDC = 2, 4 puck HID, WebUSB = 1), leaving no room for a wake interface. So puck mode now drops the CDC
serial console by default (6 IN: 4 HID + WebUSB + wake mouse) — gaining Windows wake at the cost of the
serial debug console. When you genuinely need the console, arm the one-shot debug CDC (WebUSB panel field
20, or the CDC D command): the next boot keeps CDC and skips the wake mouse, and the boot after that reverts
automatically (g_debugCdcThisBoot, stored in the reused Cfg.rsvd0 byte, consumed in loadCfg exactly like
bootMode). The two variants use different bcdDevice (0x0211 normal / 0x0212 debug) so Windows re-reads when
you switch. (Trade-off to verify on hardware: the code historically warned that clearing the puck's CDC
composite hurt simultaneous Steam + Chrome-WebUSB on Windows; the bcdDevice bump + MS OS 2.0 descriptors
should keep WebUSB binding, but confirm Steam pairing + the browser panel still work.)
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.
bcdDevice rule: Windows caches a device's configuration descriptor keyed by VID:PID:bcdDevice. If you
change a mode's interfaces (as adding the wake mouse did) without bumping that mode's setDeviceVersion(...),
Windows keeps serving the stale cached descriptor — the change is invisible and wake silently breaks, with no
fix short of the user manually uninstalling the device. So: whenever you change a mode's USB descriptor, bump
its bcdDevice. The clean modes were bumped for the wake interface; each begin() documents its value.
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.