Properties2
TypePractice
Note createdJun 19, 2026

OpenPuck officially targets a Pro Micro nRF52840. I ran it on a Seeed Studio XIAO nRF52840 (the plain one, mfr ref 102010448; its bootloader self-identifies as “Sense”). The same nRF52840 chip, but enough is different that a straight build won’t even enumerate over USB. This note records the working procedure and the four fixes it took.

Build with arduino-cli + the Adafruit nRF52 core 1.7.0not the Seeed core (Seeed ships an ancient TinyUSB that can’t even compile OpenPuck’s 4-HID requirement).

The four fixes

  1. App offset + SoftDevice version. The XIAO bootloader ships SoftDevice S140 7.3.0, so the app must link at 0x27000. The feather52840 target defaults to 0x26000 (S140 6.1.1) → the app overlaps the SoftDevice and hard-faults. Fix: create nrf52840_s140_v7.ld (a copy of nrf52840_s140_v6.ld with FLASH ORIGIN moved 0x26000 → 0x27000) in the core’s cores/nRF5/linker/, and build against the 7.3.0 headers.
  2. HID interface count. OpenPuck needs 4 HID interfaces: pass -DCFG_TUD_HID=4.
  3. The killer bug — InternalFS.begin() hangs. This is what cost the most time. The Adafruit flash backend (flash_nrf5x.c) always programs flash through the SoftDevice SVCs (sd_flash_page_erase / sd_flash_write). OpenPuck runs with the SoftDevice disabled (it drives the radio bare-metal), so on a fresh board the LittleFS format hangs/faults on those SVCs and boot freezes before USB ever comes up. Fix: patch the backend to use direct NVMC erase/write when the SoftDevice is not enabled (safe because the app owns the NVMC then). This is the change that finally made USB enumerate.
  4. Not the clock. A red herring: I suspected the 32.768 kHz crystal. The XIAO nRF52840 does have one, so USE_LFXO is correct and no USE_LFRC change is needed. (A bare Bluefruit.begin() does hang on LFXO per a Nordic thread, but OpenPuck never enables the SoftDevice, so it’s irrelevant here.)

Build commands

From the repo root (~/Projects/Personal/openpuck):

arduino-cli compile -b adafruit:nrf52:feather52840 \
  --build-property "build.ldscript=nrf52840_s140_v7.ld" \
  --build-property "build.sd_version=7.3.0" \
  --build-property "build.sd_fwid=0x0123" \
  --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
  --export-binaries OpenPuck
 
# convert the .hex to a .uf2 (nRF52840 UF2 family id)
python3 <core>/tools/uf2conv/uf2conv.py \
  OpenPuck/build/adafruit.nrf52.feather52840/OpenPuck.ino.hex \
  -c -f 0xADA52840 -o OpenPuck.uf2

Flashing

  1. Double-click the RST button on the XIAO. The XIAO-SENSE USB mass-storage drive mounts (this is the UF2 bootloader). The 1200-baud serial “touch” auto-reset does not work reliably from macOS here, so the physical double-tap is the reliable way in.
  2. Drag the .uf2 onto the drive. It flashes and auto-reboots (a “device not configured” copy error at the end is normal — that’s the auto-eject).
  3. Done: it enumerates as a Valve “Steam Controller Puck” (28DE:1304).

Debugging aids (in the fork)

Two compile flags were added to OpenPuck.ino for bring-up:

  • -DOPK_LED_BEACON — blinks the red LED (P0.26) at each setup() stage (count 1→7; 7 = USB mounted). Invaluable since there’s no serial when puck mode drops CDC.
  • -DOPK_FORCE_DEBUG_CDC — keeps the CDC console in puck mode (boot log over serial).

Leave both off for the clean production build.

Recovery

The UF2 bootloader lives in protected flash and survives everything, so the board is always recoverable: double-click RST → XIAO-SENSE mounts → drop a known-good .uf2 (even CircuitPython) to restore it.