These instructions cover firmware builds for macOS, Linux, and Windows, plus static hosting of the WebUSB app through GitHub Pages.
Hardware:
Software:
arduino-cliadafruit-nrfutil (Python package, for DFU packaging - see "Build the firmware")brew install arduino-cli
Use your package manager if it ships a recent arduino-cli, or download the release archive from Arduino and place arduino-cli on PATH.
Use choco and do a choco install arduino-cli. If you don't have that then install the official Arduino CLI zip or MSI, then ensure arduino-cli.exe is on PATH.
Run once on any platform:
pip install adafruit-nrfutil # DFU packaging helper (required by the Adafruit nRF52 build recipe)
arduino-cli config init
arduino-cli core update-index
arduino-cli core install adafruit:nrf52 --additional-urls https://adafruit.github.io/arduino-board-index/package_adafruit_index.json
From the repository root:
make build
That's the whole command — the USB flags the firmware needs are baked in, so you don't pass them yourself:
CFG_TUD_HID=6 — Steam mode exposes four HID interfaces (the Adafruit nRF core defaults to 2); one extra for mouse and one for WebUSB brings the total to 6.CFG_TUD_TASK_QUEUE_SZ=512 — a deeper TinyUSB device event queue; the default of 16 can deadlock the firmware's loop under heavy USB traffic and trip the watchdog.CFG_TUD_VENDOR_TX_BUFSIZE=256 — the WebUSB status blob (~118 B) must fit the vendor TX FIFO in one write; the default 64 is too small and the panel (which drops frames rather than block the loop) would send nothing — a blank dashboard.Overriding the defaults (only if you need to) — pass them as make variables:
make build CFG_TUD_HID=6 CFG_TUD_TASK_QUEUE_SZ=128 # different interface count / queue depth
make build EXTRA_FLAGS="-DOPK_LOG=1" # add your own defines
make build FQBN=adafruit:nrf52:somethingelse # a different nRF52840 board
Calling arduino-cli directly instead of make? Supply both the USB defines and
all three linker wrappers. Compile-time checks guard the USB defines; they do not
replace the required linker flags:
arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=6 -DCFG_TUD_TASK_QUEUE_SZ=512 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" --build-property "compiler.c.elf.extra_flags=-Wl,--wrap=tud_vendor_control_xfer_cb -Wl,--wrap=tud_descriptor_string_cb -Wl,--wrap=usbd_control_xfer_cb" OpenPuck
The wrappers route vendor control requests, preserve the full 178-byte Xbox
security string, and validate actual EP0 OUT transfer lengths respectively.
make build includes all three through OPENPUCK_LINK_FLAGS; do not drop any
when overriding that variable.
The default adafruit:nrf52:feather52840 build passed with Adafruit nRF52 core
1.7.0. The Xbox 360 firmware has not been flashed or tested on a console;
stock-console support remains experimental and awaiting validation. It runs
retail authentication in software on the existing nRF52840 OpenPuck, with no
donor controller or added hardware.
From the repository root, the non-hardware checks are:
python3 tools/test-xinput.py --check-fixtures
make check
make build
Native tests require Python 3, C11/C++17 compilers, and OpenSSL for independent fixture regeneration. These are synthetic fixtures, not console captures; passing tests and a firmware build do not prove physical USB operation or stock console compatibility. See ../tests/xinput/README.md for harness limits and XBOX360.md for the physical test checklist.
Before distributing firmware, retain the libxsm3 LGPL-2.1-or-later, ExCrypt BSD-3-Clause, and descriptor MIT notices and license texts. Static firmware redistribution must also satisfy applicable LGPL source/relinking requirements, not just include a license notice. See ../OpenPuck/src/libxsm3/NOTICE.md, ../OpenPuck/src/libxsm3/LICENSE.txt, and XBOX360.md.
Retail listings may shorten MDBT50Q-CX-40 to MDBT50Q-CX. This target is only
for the nRF52840 model with 1 MB flash and 256 KB RAM, not the CX-33.
The build borrows Adafruit's adafruit:nrf52:mdbt50qrx profile for its
nRF52840, S140, and linker settings; it does not modify that installed profile.
CX-40 GPIO differences remain in OpenPuck.
Download nRF Util and put it in your path.
mkdir -p ~/.local/bin
install -m 755 ~/Downloads/nrfutil ~/.local/bin/nrfutil
export PATH="$HOME/.local/bin:$PATH"
# Install the DFU packaging and flashing commands.
nrfutil --version
nrfutil install nrf5sdk-tools
nrfutil install device
OpenPuck requires S140 6.1.1 (0xB6). Download and extract Nordic's
nRF5 SDK v15.3.0,
then provision its SoftDevice before installing OpenPuck for the first time:
# Hold the dongle button while inserting it; release when the LED fades.
SDK=/path/to/nRF5_SDK
make provision-raytac-softdevice \
SOFTDEVICE_HEX="$SDK/components/softdevice/s140/hex/\
s140_nrf52_6.1.1_softdevice.hex"
# Unplug the dongle and re-enter Open DFU before installing the application.
Provisioning is separate from routine builds and updates.
# Build and create a Nordic Secure DFU application package.
./gen_version.sh # recommended: embeds version (when tagged) + git hash provenance
make build-raytac
make package-raytac
# Enter physical Open DFU mode, then flash the package.
make flash-raytac
# For later updates, this runs all three commands above.
make deploy-raytac
The Adafruit compile recipe also emits build/raytac/OpenPuck.ino.zip in its
older DFU v0.5 format; ignore that automatic output. make package-raytac
creates OpenPuck-mdbt50q-cx-40.zip, the Secure DFU package used by the
factory Open DFU bootloader. These targets do not replace that bootloader or
use arduino-cli upload.
The relevant flash layout is:
0x26000 and must end before 0xED000.0xED000 through 0xF3FFF.0xF4000 through 0xFFFFF.The populated active-low blue LED is P0.08. OpenPuck reserves it for the
existing remote-wake diagnostic rather than connection or pairing state.
The WebUSB firmware updater (§5b), its serial/UF2 DFU buttons, and the debug
full-board wipe all target the Adafruit bootloader and are rejected by this
build; use the physical Open DFU workflow above instead. The panel's factory
erase (§6, filesystem-only) and the serial ERASE-ALL console command work
normally.
The quickest path is make. The serial port is a required argument (find it with arduino-cli board list):
make flash /dev/cu.usbmodem1101 # upload the most recent build to that port
make deploy /dev/cu.usbmodem1101 # build + flash in one step (same build overrides as `make build`)
Use the port for your OS: macOS /dev/cu.usbmodem*, Linux /dev/ttyACM0, Windows COM5.
DFU note: in puck (Steam/Lizard) mode the firmware drops the CDC serial port to free a USB endpoint, so
arduino-clican't auto-reset the board into its bootloader. If the upload can't connect, put the board in DFU mode first by double-tapping RST, thenmake flash <bootloader-port>(re-checkarduino-cli board list— the port can change in DFU mode). The drag-and-drop UF2 path in §5b also works.
Xbox 360 mode also has no CDC serial port, and deliberately omits WebUSB and wake HID. For configuration or WebUSB updates, connect to a PC and hold all four back buttons (L4 + R4 + L5 + R5) plus A to return to Steam mode. Afterward, back-4 + X returns to Xbox by default (X can be reassigned; A is fixed). Physical DFU remains the recovery path if no paired controller is available.
make)Find the board port with arduino-cli board list, then:
arduino-cli upload \
-b adafruit:nrf52:feather52840 \
-p /dev/ttyACM0 \
OpenPuck
Replace /dev/ttyACM0 with the actual port. On macOS it is usually /dev/cu.usbmodem*.
Find the COM port:
arduino-cli board list
Upload:
arduino-cli upload `
-b adafruit:nrf52:feather52840 `
-p COM5 `
OpenPuck
Replace COM5 with the actual board port.
If the board has the Adafruit nRF52 UF2 bootloader (common on Pro Micro nRF52840 boards), you can upload by dragging the compiled .uf2 file onto the board's mass-storage volume:
Locate the compiled .uf2 file:
# After a successful `arduino-cli compile`, find the .uf2 in the build directory:
ls /tmp/arduino/cores/adafruit_nrf52_adafruit52840/*.uf2
# or, on Windows, look in %TEMP%\arduino\...
Copy the .uf2 file onto the UF2BOOT drive. The board auto-ejects and reboots with the new firmware.
Alternatively, use the adafruit-nrfutil DFU Python tool with the board in DFU mode (bootloader LED pulsing):
# Enter DFU mode (double-tap RST). On Linux/macOS:
adafruit-nrfutil --verbose dfu serial --package OpenPuck/OpenPuck.ino.adafruit_nrf52_feather52840.zip -p /dev/ttyACM0 -b 115200
# On Windows (PowerShell):
adafruit-nrfutil --verbose dfu serial --package OpenPuck/OpenPuck.ino.adafruit_nrf52_feather52840.zip -p COM5 -b 115200
Replace the port (/dev/ttyACM0 / COM5) with the actual board port.
Note: The
.zippackage is generated automatically byarduino-cli compilewhen the Adafruit nRF52 core is used. If it is missing, ensureadafruit-nrfutilis installed and recompile.
In Xbox 360 mode, first return to Steam mode with back-4 + A on a PC; the Xbox USB layout has no configuration/update interface.
A board already running OpenPuck (status protocol v15+) can be updated entirely from the
WebUSB configurator's Firmware update tab: drag-and-drop a
.uf2 (or click to browse), or pick a version from the built-in releases list — each release offers the
standard build or, via its checkbox, the -factory-reset build (wipes settings + pairing once on first
boot). Each entry shows the release's GitHub notes under a collapsible release notes toggle and, when
the release is marked as a pre-release on GitHub, a pre-release badge (un-marking it on GitHub removes
the badge on the next list refresh; pre-releases are never suggested by the "update available" notice). A blocking modal shows download/transfer/verify/apply progress and reports the old → new build once
the puck reconnects. Under the hood the panel extracts the app image from the .uf2 and streams it over the
normal WebUSB connection into spare flash high in the app region (~15 s; the running firmware keeps working);
the firmware CRC32-verifies what landed and commits a one-page "apply on reboot" record; a final automatic
reboot copies staged→app from RAM (~5 s dark) and comes back up on the new firmware. See
OpenPuck/fw_update.h for the design.
Release downloads / the
firmwarebranch: GitHub's release-asset CDN sends no CORS headers, so the browser cannot fetch release assets directly. The release workflow therefore mirrors every OpenPuck.uf2onto the orphanfirmwarebranch, and the panel downloads fromraw.githubusercontent.com/safijari/openpuck/firmware/<asset>(which is CORS-clean). If a release is missing from the mirror, the panel falls back to opening the asset in a new tab for manual drag-and-drop.Release notes and the pre-release flag are owned by GitHub, not the workflow: the release job uploads its artifacts onto an existing release (
gh release upload --clobber) and only authors a title/body when it creates the release itself from a bare tag push. Notes written in the GitHub UI, a custom release title, and the pre-release checkbox therefore survive the artifact build that follows publishing.
Failure safety: nothing is armed until the staged image verifies in flash, so a disconnect, error, or power cut during the transfer leaves the current firmware untouched. The apply step erases the app's vector page first and rewrites it last (first word dead-last), so even a power cut mid-apply leaves the board "app-less" — the resident UF2 bootloader then keeps it as the UF2BOOT drive for drag-and-drop recovery. A half-flashed, crash-looping state is not reachable.
Re-flashing firmware does not erase the board's internal LittleFS. The paired-controller bond (bonds.bin) and every saved setting (cfg.bin: USB mode, chord assignments, back-paddle map, mouse sensitivity) survive a fresh build and upload. To bring a board up in a truly clean state — a new unit, a hand-me-down with a stale bond, or a corrupted config — wipe the filesystem with one of:
Recovery build (-DOPK_FACTORY_RESET=1): a firmware that wipes all persistent storage once, on the first boot after flashing, then behaves like a normal build that persists settings. Use it to recover a board from a bad config/bond without a console or panel:
./gen_version.sh # recommended: embeds version (when tagged) + git hash provenance
make build-recovery
(make build-recovery is just make build plus -DOPK_FACTORY_RESET=1; the usual USB flags are still baked in.)
It is not a wipe-every-boot image: after the one-time reset it stamps a tag file with the build's git hash, so subsequent boots skip the wipe and persist normally. Flashing this same image again won't re-wipe (the tag matches); flashing a different build (different git hash) re-triggers the one-time reset. For an on-demand wipe at any time, use the WebUSB button or serial ERASE-ALL below. Re-pair the controller after a reset.
ERASE when prompted. The board reformats its filesystem and reboots to factory defaults. From Xbox 360 mode, first return to Steam with back-4 + A.ERASE-ALL (exact, all caps). Same effect: reformat + reboot.Both reformat the entire internal filesystem (cfg.bin + bonds.bin and anything else), so the action is irreversible and the controller must be re-paired afterwards (see §7).
Note on the serial method: puck (Steam/Lizard) mode drops the CDC console by default to free a USB endpoint for the wake-mouse interface, so the serial port may not be present in that mode. Either arm the one-shot debug CDC first (panel debug-CDC toggle / D console command, which keeps the console for the next boot), or use the WebUSB Factory erase button in Steam mode. Xbox 360 mode exposes neither CDC nor WebUSB.
0xB4 = 0x02 when connected.WebUSB requires a secure context. http://localhost qualifies.
cd docs
python3 -m http.server 8008
Open:
http://localhost:8008
cd docs
py -m http.server 8008
Open:
http://localhost:8008