nRF52840 / Adafruit Arduino core / TinyUSB. The device impersonates a Valve Steam
Controller 2 "puck" over USB and talks to SC2 controllers over a bare-metal nRF52
RADIO (ESB-style, no SoftDevice) protocol. Built with -DCFG_TUD_HID=6.
This map describes what the code actually does. Source comments in this repo are known to be misleading and were ignored.
Three contexts matter for every piece of state below:
| Context | Priority / stack | What runs here |
|---|---|---|
| loop task | Arduino FreeRTOS "loop" task, low priority, ~4 KB stack | Everything called from loop(): webusbPoll, g_active->task(), serialConsolePoll, rfDiagTask, rfLinkTask, hapticTask, ledTask, usbMountTask, pwrSwitchTask (only under OPK_PWR_SWITCH). Also all of the RF radio code (it is driven synchronously from rfLinkTask/rfDiagTask). Also setup(). |
| usbd task | TinyUSB "usbd" task, high priority, ~800-byte stack | All TinyUSB HID set_report/get_report callbacks, the custom XInput class-driver xfer callbacks, and (in the Adafruit core) HID sendReport queuing context. This is where host→device control transfers are decoded. Stack is tiny — a Serial.printf here can overflow it (the suspected LOCKUP cause), which is why all per-report logging is #if OPK_LOG gated. |
| ISR | Hardware exception | HardFault_Handler (fault_diag.cpp). The RADIO is polled, not interrupt-driven — there is no radio ISR. |
Key cross-task rule the code relies on: producers that touch the relay rings and the
capture ring run under __disable_irq()/PRIMASK; the per-slot HID reply rings are
single-producer (usbd) / single-consumer (loop) with volatile head/tail and no lock.
OpenPuck.inoEntry point + main loop. No input/output logic of its own.
setup() (loop task, boot)genSerial(), ledInit(), seed per-slot RF session addresses, InternalFS.begin(),
optional one-time factoryResetOnce(), loadCfg(), loadBonds(), regenerate session
addresses for used slots.g_xbox = !modeIsPuck, g_active = controllerFor(g_usbMode).wakeHidBegin() (HID instance 0,
unless clean-PS), g_active->beginPool(), optional usb_web.begin(),
usbMountEnable(), usbMountRebuildMap(), usbReenumerate(g_usbMountCount).keepCdc), per-mode serial suffix, ordered interface registration
(usb_web IF0 → wake mouse IF1 → 4 puck slots IF2..5 for puck), then attach().for (i<300 && !mounted()) delay(10).hapticInit(), faultDiagBoot(), prints diagnostics.NRF_WDT CRV = 8*32768-1 (~8 s), RUN-in-sleep.
If loop() ever stops feeding it, the chip resets.usbReenumerate(uint8_t k) (loop task)Tears down and rebuilds the USB config descriptor for k connected slots with no MCU
reboot: detach(), delay(20), clearConfiguration(), restore identity/serial,
re-add wake mouse + g_active->mountSlots(k) + WebUSB in locked instance order,
attach(). Called at boot and by usbMountTask.
loop() (loop task)NRF_WDT->RR[0]).g_dirty, saveBonds() (flash write in loop context).webusbPoll, g_active->task, serialConsolePoll, rfDiagTask,
rfLinkTask, hapticTask, ledTask, pwrSwitchTask (only under OPK_PWR_SWITCH),
usbMountTask.
#if OPK_LOG wraps each in micros() timing for the WebUSB panel (pwrSwitchTask
is untimed, like usbMountTask/usbTxPump).g_usbCfgDesc[512] — config-descriptor build buffer (puck composite + WebUSB exceeds 256 B).MODE_SUFFIX[8], s_dynWantWebusb, s_dynWantWakeMouse.g_loopPeriodUs, g_loopWorst, g_loopWorstUs — loop-timing diagnostics (read by webusb).config.cpp / config.h — persisted settings (loop task)g_usbMode, g_xbox, g_chordBtn[3], g_chordDpad[4], g_persistMode, g_bootMode,
g_debugCdcThisBoot, g_mDiv, g_mFric, g_type[ET_COUNT] (per-emulated-type
button config), g_etype, live mirrors g_abSwap/g_back[4]/g_qamMap/
g_padHaptics/g_ledBright, and the constant g_pollUs = 4000 (250 Hz, fixed).
(g_swGyroLegacy is declared here but lives in mode_switch_pro.cpp/swprocfg.bin.)struct Cfg is serialized to /cfg.bin (LittleFS), magic 0xCF. rxWin10,
lizKeep, landAll87 and the per-type table travel with it; rsvd0 is the
one-shot debug-CDC arm and rsvd1 the ex-rumble-strength slot (both kept so the
on-flash layout is unchanged). New fields are appended to the tail (chordDpad[4]) —
loadCfg() prefills 0xFF and accepts a file short by only the tail (CFG_LEN_MIN),
so an upgrade keeps every existing setting and unwritten tail bytes fall back to defaults.loadCfg() resolves the boot mode policy: one-shot bootMode wins once then clears;
else persistMode ? last mode : STEAM(0). Calls applyActiveType().saveCfg, saveMode, armDebugCdcNextBoot, factoryErase (LittleFS format()),
factoryResetOnce (git-hash-tagged one-time wipe). All flash I/O — loop context only.applyActiveType() copies g_type[g_etype] into the hot-path live mirrors.identity.cpp / identity.h (loop task, boot)genSerial() derives g_unit[16] ("FXB...") and g_board[16] ("MXB...") from
NRF_FICR->DEVICEID. g_usbSerial[18] holds the per-mode suffixed serial.ATTR83[25] — the constant 0x83-attribute blob (advertises product 0x1304).build_info.h / git_version.h — compile-time tags only.bonds.cpp / bonds.hNSLOT = 4. struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; }.
rec = [8 uuid][16 serial]. resp/resp_len is the staging buffer for that
interface's pending feature-report reply.g_slot[NSLOT] — bond records + per-interface reply staging. Written by the puck
feature command channel (usbd task, handleSet 0xA2) and by loadBonds (loop). Read
by RF code, webusb, serial console.g_connReplyMs[NSLOT] — millis() of the last F-type reply per slot (the live
"controller is alive" timestamp). Written by the RF decode (loop task,
rfConnTx). Read everywhere: puck_hid connection presentation, haptic link-up gate,
usb_mount connectivity watcher, webusb, serial console.g_dirty (volatile) — set by handleSet (usbd) when a bond is written; consumed
by loop() → saveBonds() (defers flash off the usbd task).g_pairing — set by handleSet 0xAD.loadBonds/saveBonds — /bonds.bin LittleFS, loop context.radio.cpp / radio.h — hardware layer (loop task)NRF_RADIO directly (PHY=Ble_2Mbit, ENDIAN=Big, no whitening, CRC16
poly 0x11021/init 0xFFFF, ESB-DPL framing). No SoftDevice.rfrx[100], rftx[100] (declared >= MAXLEN+2 = 66+). Shared by
rf_link.cpp AND rf_diag.cpp with no guarding (see §13).g_rfBase[4]="ibex", g_rfPrefix=0x10, g_rfCh=2 — shared discovery rendezvous.g_sessCh=18 — connected-session channel (global across slots).g_sessBase[NSLOT][4], g_sessPrefix[NSLOT] — per-bond unique session address,
derived in rfGenSessionAddr(slot) from bond UUID + FICR DEVICEID (deterministic,
stable across reboots; degenerate bytes scrubbed; never equals "ibex").g_crcinit, g_whiteiv, g_mode, g_pcnf0/1, g_crcpoly, g_crccnf,
g_pid, g_balen, g_prefixRaw, g_statlen) — mutated by the CDC console and by
rf_diag's applyCfg/sweeps. rfConfig() reads them on every (re)program.RWAIT_DISABLED() macro: bounded spin-wait for EVENTS_DISABLED, 3000 µs
timeout, then bails (never hangs the loop forever).rfSetAddr bit-reverses the base/prefix into BASE0/PREFIX0.rf_link.cpp / rf_link.h — operational puck protocol (loop task)The "dongle" role. All driven synchronously from rfLinkTask() in loop().
rfLinkTask(): host-frame beacons (session keepalive E1 on g_sessCh, discovery
E1 on ch2), rfConnStep() poll loop, USB remote-wakeup on a fresh RF connection, QoS
channel hop, per-second stat aggregation. Gated off during a 2.5 s post-disconnect
cooldown (g_connCooldown).rfConnStep(): cycle gate fires once per g_pollUs (250 Hz); on each fire it
polls every warm slot back-to-back (so N controllers all run at full rate). Cold
slots (silent > 5 s) retry only every 2 s. Per slot it flushes one relay entry and
sends a bare E3 poll (real-puck behavior; legacy E7/GET path is toggle-able).rfConnTx(ch, s1, payload, plen, rxWinUs): the core TX-then-RX primitive. TXes
one frame on the current slot's session address, then busy-waits for a reply
(while(!EVENTS_END && micros()-t0 < win), win = g_rxWin default 2000 µs, or
a tiny override like 80 µs for no-ACK relays). On a CRC-good F-type reply it:
samples RSSI, updates g_connReplyMs/g_linkRssi, calls hapticOnReconnect on a
gap, and decodes the 0x45 input report into g_in[g_curSlot] (buttons, sticks,
triggers, trackpads, IMU). Also handles: Steam-button short-press remote wakeup,
Steam+Y 2 s power-off chord, back-4 mode-switch chord (face + D-pad, saveMode + NVIC_SystemReset),
and status reports 0x43 (battery → g_battery/g_batteryState) / 0x44 → dispatched
via g_active->onAuxReport.g_curSlot — the slot the poll loop is currently driving. Set by rfConnStep
before each TX; read by the decode, the relay flush, haptics, stats, webusb.volatile, written here, read by puck_hid/webusb):
g_linkRssi[NSLOT], g_battery[NSLOT], g_batteryState[NSLOT].g_f1ps, g_newps, g_pollsps, g_pollPeriodUs.g_pollGet, g_e7announce, g_e1keepalive, g_rxWin,
g_e3mode, g_qos, …) set from the console/webusb.rftx/rfrx (radio.h). The 0x45 TLV walk bounds every
read against the received length and sizeof(rfrx) (explicit guards against runaway
idx and reads past the buffer — the documented stack-smash/LOCKUP class).triton.cpp / triton.h — decoded controller inputg_in[NSLOT] (struct PuckInput): the decoded SC2 input per slot — buttons,
lx/ly/rx/ry, lt/rt, lpx/lpy/rpx/rpy, accel ax/ay/az, gyro gx/gy/gz.
Written by rf_link (loop task) once per fresh 0x45; read by every USB
personality's task() (loop) and by webusb. No lock (single writer, loop-context
readers; the usbd-task readers in mode_switch_pro read it too — benign).s16off/u16off/trigU8/btnsOf, imuFrom45.rf_diag.cpp / rf_diag.h — RE / calibration tooling (loop task)Channel sniffers, raw capture/replay, beacon/respond, config sweeps — all driven from
rfDiagTask() and CDC-console start hooks. Reprograms the same NRF_RADIO and shares
rfrx/rftx with rf_link with zero mutual exclusion (relies on the design assumption
that diag modes are off during normal operation). Buffers: g_replay[48],
g_hostTx[48] (len 0x22=34), pl[48] in rfBeaconOnce (copy clamped to 48 into
rftx). Spin-waits: RWAIT_DISABLED() (3000 µs) ×1–3 per call; a 500 µs RX window in
rfBeaconOnce. rfReplayOnce runs every loop with no rate gate while armed.
Notable: rfCapPoll copies 48 bytes into g_replay but sets g_replayLen=32.
controllers.cpp / controllers.hIController interface: begin, onReport45, onAuxReport, task, wakeEvent,
isPuck, and the dynamic-mount hooks dynamicMount/maxSlots/usbIdentity/
beginPool/mountSlots.g_active — the one controller for this boot.controllerFor(mode) maps g_usbMode → singleton (Steam & Lizard share g_steamPuck;
PS5/PS5_GAME share g_ps5Ctl; HIDGYRO/DS4_GAME share g_hidGyroCtl).onReport45 (loop task, called from rf_link). STREAM modes (Switch Hori/Pro, PS5,
HIDGYRO) ignore onReport45 and emit from task() reading g_in[].puck_hid.cpp / puck_hid.h (MODE_STEAM + MODE_LIZARD)The 28DE:1304 puck identity with four HID slot interfaces (interface N = bond slot N).
PUCK_HID_DESC[] / PUCK_LIZARD_HID_DESC[] — the cloned puck report
descriptors (mouse 0x40 + keyboard 0x41 + vendor FF00 with 63-byte feature reports).hid[NSLOT] — the four Adafruit_USBD_HID slot objects.handleSet(slot, rid, type, b, n) (via setcb0..3): host→device reports.
g_steamAliveMs (Steam-alive heartbeat),
hapLogAdd, and for 0x80–0x86 relay to the controller via relayEnqueue
(gated by hapticRelaySlotOk, lizardActive, post-resume mute, haptic82Blocked).relayEnqueue(cmd,…); 0x9F → hapticSendShutdown.g_slot[slot] → sets g_dirty), 0xA3 (read slot).
Each writes the reply into S.resp[63] / S.resp_len.handleGet(slot, rid, type, buf, reqlen) (via getcb0..3): returns the staged
S.resp (≤63 bytes), stamps g_steamAliveMs in Steam mode, hapLogAdd(0xFC,…).onReport45(slot, rep, fresh, bodyTlen) (from rf_link): suspended/post-resume
gating, then either rfLizard (lizard, slot 0 only) or hid[slot].sendReport(0x45,
rep+1, blen) (blen clamped ≤ 45).onAuxReport(slot, rid, data, n): forwards controller 0x43/0x44 verbatim to
hid[slot] (how Steam reads battery).task(): post-resume edge stamp; per-slot connection presentation —
edge-triggered 0x79 (1-byte conn state), periodic 0x7B (12-byte status with RSSI
patched from g_linkRssi), and a synthesized 14-byte 0x43 battery report from
g_battery/g_batteryState every 2 s (for SDL's full-length requirement). Also
wakeNudgeTask().wakeEvent() / wakeNudgeTask(): queue + deliver a net-zero mouse jiggle to wake
the host (prefers the boot wake-mouse; falls back to per-slot gamepad jiggle).
g_nudgeStep[NSLOT], g_nudgeMs[NSLOT].g_steamAliveMs (last Steam OUTPUT/heartbeat; written in usbd & loop) and the derived
lizardActive() — the single source of truth gating both the USB input path and the
haptic relay. g_resumeMs (post-resume mute). g_fwdNewOnly. g_autoLizard.S.resp/S.resp_len written in handleSet (usbd) and read in
handleGet (usbd) — same task. g_slot[slot].rec/used + g_dirty written in usbd,
flushed in loop. g_steamAliveMs/g_resumeMs written in both contexts, no lock.mode_lizard.cpp / mode_lizard.h (loop task)rfLizard(r, mdev, kdev, mrid, krid) — not an IController; called from
puck_hid::onReport45 (loop task). Turns a raw 0x45 into keyboard+mouse:
mdev->sendReport(mrid, &m, 5) (hid_mouse_report_t = 5 bytes).krep[8] (boot-keyboard layout),
kdev->sendReport(krid, krep, 8).mdev->sendReport(0x03, &cc, 1).mode_xinput.cpp / mode_xinput.h (g_xboxCtl)Dynamic-mount, PUSH-style. Custom TinyUSB vendor class driver (0xFF/0x5D/0x01) plus a boot mouse for the right pad.
g_xiDriver, returned via
usbd_app_driver_get_cb): xi_init, xi_reset, xi_open (claims interface →
g_usbToBond[u], arms OUT endpoint with a 32-byte xfer), xi_ctrl, xi_xfer.
xi_xfer decodes the host rumble OUT report (outBuf[0]==0x00 && [1]==0x08) and
calls hapticSteamRumble(lo, hi, slot) directly from the usbd task, then re-arms
the 32-byte OUT xfer. No HID feature/get/set callbacks in this mode.onReport45 → rfXboxGamepad(slot, rep) (+ rfXboxMouse for slot
0); xinputSend fills S.inBuf (20 bytes filled of the 32-byte buffer) and
usbd_edpt_xfer(0, epIn, r, 20); task() is the stuck-rumble watchdog (2.5 s →
hapticSteamRumble(0,0,s)).XiSlot.inBuf[32], XiSlot.outBuf[32]; XInput report = 20 bytes;
descriptor = XINPUT_DESC_LEN 40 bytes; mouse report = 5 bytes.g_xiSlot[NSLOT] — inUse/rumbleLow/rumbleHigh/rumbleMs
volatile, but itf/epIn/epOut not volatile. usbd writes endpoint/rumble
fields, loop reads them and touches the IN endpoint. No lock. hapticSteamRumble
reached from both usbd (xi_xfer) and loop (task) — safety relies on the relay
ring's PRIMASK guard.mode_switch_hori.cpp / mode_switch_hori.h (g_switchHori)HORIPAD (0F0D:0092). Dynamic-mount, STREAM-style, plain 8-byte report, no report callbacks registered → no usbd-task user code at all.
task() (loop): rate-gated, switchBuildHoripad(slot, p[8]) from g_in[],
sendReport(0, p, 8) (8 bytes, no report id). All build state loop-local.mode_switch_pro.cpp / mode_switch_pro.h (g_switchPro)Switch Pro (057E:2009). Dynamic-mount, STREAM-style + a full handshake/subcommand state
machine. This mode registers an OUT/set-report callback (setReportCallback(NULL,
JC_SETCB[s])).
jcSet0..3 → jcSetCommon → jcRumble (decodes amplitudes, dedupes, calls
hapticSteamRumble(lo,hi,bond) from usbd) and jcSubcmd (builds a 63-byte 0x21
reply: BT pairing, device info, SPI read via spiRead, SPI write via jcSpiWrite,
report-mode set). jcSubcmd's local p[63] plus jcInputPrefix/spiRead is the
heaviest stack user on the 800-byte stack. jcEnq (usbd) is the ring producer.
jcSpiWrite is RAM-only (writes g_userCal[slot][], sets a dirty flag) —
deliberately no flash I/O in the callback.beginPool (LittleFS loads, descriptor setup), task() —
deferred flash save (saveUserCal when dirty + 250 ms), drains one queued reply
per slot via sendReport(rid, data, 63), else rate-gated switchProBuild(slot, p[63])
sendReport(0x30, p, 63).JC_REPLEN = 63 (all input/reply reports are 63 payload bytes);
reply ring g_jcQ[NSLOT][8] of {rid, data[63]}; g_userCal[NSLOT][0x100];
g_spiStickCal[18]; g_jcMac[NSLOT][6]; config file b[2] (swprocfg.bin v2 =
[ver][g_swGyroLegacy]; the report rate is fixed at USB_STREAM_MS).g_jcQ / g_jcQh / g_jcQt — SPSC (usbd produces, loop consumes);
head/tail volatile, ring body not volatile.g_swProReportMode[] — written usbd (jcSubcmd) + loop (mountSlots), read loop;
not volatile.g_userCal[][] + g_userCalDirty/g_userCalDirtyMs — g_userCal written/read in
usbd, read in loop (saveUserCal); only the dirty flag/ts are volatile → a
loop-side save can race a usbd-side write (no lock).g_jcTimer[] — incremented in both contexts, not atomic (benign).g_in[bond] — read from both jcInputPrefix (usbd) and switchProBuild (loop).mode_ps5.cpp / mode_ps5.h (g_ps5Ctl, MODE_PS5 + MODE_PS5_GAME)DualSense (054C:0CE6). Dynamic-mount, STREAM-style. Registers get+set report
callbacks (setReportCallback(PS5_GETCB[s], PS5_SETCB[s])).
ps5GetCommon (GET_FEATURE: 0x03→47 B, 0x05 motion-calib via
psNeutralCalib writing through buf[33]→returns 40, 0x09 MAC 6 B→19, 0x20 fw→63 B;
each memset(buf,0,reqlen) first); ps5SetCommon (OUTPUT 0x02 rumble →
hapticSteamRumble(p[3]*257, p[2]*257, bond) from usbd, reads g_usbToBond[slot]).task() rate-gated → ps5Build(u, bond, p[63]) from g_in[bond] (per-slot
seq[]), sendReport(0x01, p, 63). Touch packed at out+32 (8 bytes).p[63]; g_ps5Mac[NSLOT][6].mode_hidgyro.cpp / mode_hidgyro.h (g_hidGyroCtl, MODE_HIDGYRO + MODE_DS4_GAME)DS4-layout (054C:05C4) + gyro. Same structure as PS5.
hidGyroGetCommon (GET_FEATURE: 0x02 calib via psNeutralCalib→36,
0x12 MAC 6→15, 0x81 MAC 6→6, 0xA3 fw→48); hidGyroSetCommon (OUTPUT 0x05 rumble →
hapticSteamRumble(p[4]*257, p[3]*257, bond) from usbd).task() → hidGyroBuild(u, bond, p[63]) from g_in[bond],
sendReport(0x01, p, 63); touch via touchPackPads(out+34,…) (8 bytes).p[63]; g_ds4Mac[NSLOT][6].Both PS modes: g_usbToBond[] read in usbd without a lock (bounds-checked); the
calib helper psNeutralCalib writes 34 bytes (buf[6..33]) while callers return 36/40
(remainder zeroed by the prior memset).
mode_dinput.cpp / mode_dinput.h (g_dinputCtl, MODE_DINPUT)Generic DirectInput joystick (1209:4F50). Dynamic-mount, STREAM-style, no report callbacks → no usbd-task user code (input-only: DirectInput force feedback is the PID class, not the vendor rumble reports the other modes decode).
DI_HID_DESC with two top-level Application collections →
Windows makes one HIDClass PDO (= one DirectInput device) per collection, which is how
13 analog inputs fit a format capped at 8 axes/device. Report 1 = sticks/triggers/hat +
26 buttons; report 2 = both trackpads + gyro XYZ + 4 pad buttons. Both 15 payload bytes.task() rate-gated (USB_STREAM_MS), diBuildStick + diBuildMotion
from g_in[bond], two usbTxHid sends per slot per tick.g_padAxis[NSLOT] — LATCHED trackpad axes (a pad only reports while touched,
so the axis holds the last position and a pad CLICK re-centres it). Loop-context only.TB_* bits (no psButtonsFromSteam remap; etypeForMode = ET_NONE).mode_sinput.cpp / mode_sinput.h (g_sinputCtl, MODE_SINPUT)SInput, the open SDL-native gamepad protocol (2E8A:10C6 = the generic ID SDL's SInput driver matches). Dynamic-mount, STREAM-style. Registers a set-report callback.
SDL_hidapi_sinput.c + the MIT-0 reference lib): 64-byte input
0x01 = plug/charge, 4 button bytes, 6×s16 sticks+triggers, u32 IMU timestamp, 6×s16
accel+gyro, 2×(s16 x, y, pressure) touchpads; 64-byte input 0x02 = command replies;
48-byte output 0x03 = [cmd] HAPTIC(1) / FEATURES(2) / PLAYERLED(3) / RGB(4).sinSetCommon → FEATURES sets g_sinFeatReq[slot]; HAPTIC type 1
(freq/amp pairs, louder band per side) or type 2 (ERM amplitudes) →
hapticSteamRumble(lo, hi, bond).task() answers a pending FEATURES ahead of the stream gate (SDL's
init gives up after ~100 ms) via sinBuildFeatures → usbTxHid(0x02,…), then the
rate-gated sinBuild → usbTxHid(0x01,…).g_sinFeatReq[NSLOT] (volatile, set usbd / cleared loop);
g_usbToBond[] read in usbd (bounds-checked), as in the PS modes.gamepad_util.cpp / gamepad_util.h — shared report-builders (called from loop task)swStick, psNeutralCalib (writes through buf[33]), trackpad→touch mappers
(touchPackPads writes 8 bytes = two 4-byte points), psButtonsFromSteam (back-paddle/
QAM/chord remap), psShouldersByte, psHatNibble, psFaceNibble, tritonFromCode.
Reads g_qamMap/g_abSwap/g_back[]. Pure transforms, no buffers beyond callers'.
haptics.cpp / haptics.hThe central cross-task data structure: the relay rings.
g_rq[NSLOT][RELAY_QLEN] (RELAY_QLEN = 32), struct RelayMsg { uint8_t rid, len;
uint8_t data[RELAY_MAXP=60]; }, with volatile g_rqHead[NSLOT] / g_rqTail[NSLOT].
One ring per bond slot. Multi-producer (usbd-task HID callbacks: puck_hid
handleSet, the mode rumble callbacks; plus loop-context console/test injectors),
single-consumer per slot (rfConnFlushRelay on that slot's poll turn, loop task).relayEnqueue(rid, payload, plen, slot): producers serialize under
__get_PRIMASK() / __disable_irq() / __set_PRIMASK. slot==0xFF broadcasts to all
rings. Full ring → evict the oldest (keeps the most recent meaningful frame).rfConnFlushRelay(ch, s1) (loop, from rfConnStep): drains one entry from
g_curSlot's ring, frames it as an E3 sub-TLV (type-01 form for 0x9F/LED-0x2D,
legacy type-05 otherwise), hapLogAdd(0xFE,…), TXes via rfConnTx(…, 80) (no-ACK).rfConnQueueHapticRelay() (loop): injects test/stop haptics only on an idle cycle.volatile flags)g_testHaptic, g_hapticStop (volatile), g_hapticBlockOn, g_hapticBlockMs,
g_hapticBlockUntil[NSLOT], g_relayOp, g_relaySub.hapticSendShutdown() — bursts 0x9F "off!" ({6f 66 66 21}) ×3 broadcast.hapticSteamRumble(low, high, slot) — builds a 9-byte 0x80 report (×RUMBLE_SCALE_PCT),
relayEnqueue(0x80, p, 9, slot); called from usbd (mode rumble callbacks) and loop.
Per-slot stuck-rumble tracking g_rumble80On/Ms[NSLOT].haptic82Blocked/hapticLinkUp/hapticRelaySlotOk — link-up + block gates (read
g_connReplyMs, g_slot).hapticOnReconnect(slot) — arms the per-slot block + schedules g_reinitLeft re-init
shots; hapticCancelPendingOn voids queued ON entries under PRIMASK.hapticReinit(slot) — enqueues the captured 0x81/0x87 settings re-init sequence.hapticTask() (loop): per-slot link-edge detect → hapticOnReconnect; fires
scheduled re-init; autonomous controller power-off on a persisted USB suspend
(SUSPEND_OFF_MS = 4000 ms, VBUS-gated) → hapticSendShutdown; stuck-rumble watchdog
(2.5 s); idle-clear re-init.hapInit() resets all rings/flags at boot.#if OPK_LOG only)g_hapLog[HAPLOG_N=4096] of {ms, slot, rid, n, b[16]} (~80 KB). hapLogAdd written
from both usbd and loop, guarded by PRIMASK; g_hapHead is uint16_t. Drained to
WebUSB via hapLogResetDrain/hapLogPull (loop) and 'H' console dump. Compiled out
entirely in production (OPK_LOG=0), reclaiming the RAM.usb_mount.cpp / usb_mount.h — dynamic re-enumeration watcher (loop task)g_usbMountCount, g_usbToBond[NSLOT] (usbSlot→bond, -1 unused),
g_bondToUsb[NSLOT] (inverse). g_usbToBond is the routing map the usbd-task
rumble callbacks read to map a USB output back to a bond slot.usbMountTask() (loop): connectedMask() (slots with a reply < CONN_UP_MS=1200 ms),
debounce MOUNT_DEBOUNCE_MS=2000 ms, and on a steady change commitOrder() (rewrites
both maps + count) then usbReenumerate(g_usbMountCount) (detach/rebuild/attach,
no reboot). Order-preserving so existing instances keep their index.delay() here (the detach delay lives in usbReenumerate). Maps rewritten
non-atomically just before re-attach; consumers tolerate the brief window.wake_hid.cpp / wake_hid.h — boot-mouse wake sourceWAKE_HID_DESC = boot mouse, g_wakeHid, g_wakeHidPresent.wakeHidBegin (setup), wakeHidAddInterface (re-enumerate build), wakeHidPresent,
wakeHidReady, wakeHidMove(dx,dy) → g_wakeHid.mouseReport(0,0,dx,dy,0,0)
(buttons always 0). Poll interval 10 ms. No buffers/delays/loops; only shared state is
the one flag set once at begin.webusb_config.cpp / webusb_config.h — browser config channel (loop task)Adafruit_USBD_WebUSB usb_web. No vendor callbacks — fully polled from
webusbPoll() in loop() (the library buffers the vendor RX). So nothing here runs
on the usbd 800-byte stack.webusbSendBlob() builds a 115-byte status blob (p[2+WB_PAYLEN(113)], header
A5 113 11) reading a large set of diagnostic globals (g_curSlot, g_connReplyMs,
g_battery, g_linkRssi, g_in[0] IMU, g_slot, g_type, loop/poll stats, …) —
unsynchronized reads of RF-owned state, but cosmetic (torn reads at most).webusbPoll() command parser (static buf[16]): 0x01 send blob; 0x02 field value
mutates config tunables (writes g_type, g_mDiv/Fric, g_rxWin (clamped 600..3000),
g_hapticBlockMs (≤60 s), etc., then applyActiveType/saveCfg/swProSaveCfg);
0x03 mode→saveMode+reset; 0x07→hapticReinit; 0x08→hapticSendShutdown;
0x0A ("ERS")→factoryErase+reset; 0x0B/0x0C→DFU; capture drain (OPK_LOG).
Each reset/DFU path does delay(40) then NVIC_SystemReset() (harmless — reboots).fw_update.cpp / fw_update.h — staged firmware update (loop task + boot)0x20 begin / 0x21 chunk / 0x22 verify+commit / 0x24 abort stage a new app
image into unused flash below the meta page at 0xEC000 (top of the app region);
strictly sequential offsets, duplicate chunks re-acked without rewriting (flash words
are written once per erase). One page erase max per loop() pass (~85 ms CPU stall).fwupEnd() CRC32s the staged flash (heartbeat-beaten so the wedge reporter stays
quiet), sanity-checks the vector table, then commits the one-page meta record — the
single arming action. Idempotent on retry (lost-ack safe).fwupApplyIfArmed() (first line of setup()): re-validates everything, then
ramApply() — a .data-section (RAM-resident) copier, IRQs off, WDT force-started
and fed — erases the app (vector page FIRST), writes it (vector page LAST, first word
dead-last), verifies word-for-word, rewrites the bootloader settings page to the
drag-and-drop state (BANK_VALID_APP, crc-check off), erases meta, resets. Any
interruption leaves the board app-less → UF2 bootloader, never a half image.serial_console.cpp / serial_console.h — CDC debug CLI (loop task)serialConsolePoll() drains available CDC bytes into static char line[24] (bounded),
dispatches single-letter commands on newline (first match wins). Commands mutate the
radio tunables, rf_link toggles, rf_diag flags, config (E/F/W/K/Q → saveCfg), and
inject relays (J/t). No blocking reads; delay() only before resets.C and H each appear twice in the chain — the second occurrence of each is
dead code (unreachable).status_led.cpp / status_led.h (loop task)Drives two GPIO pins (LED_BUILTIN + pin 24) together. ledWakePulse() lights them and
stamps g_pulseMs; ledTask() clears after PULSE_MS=500. No buffers/delays/radio.
pwr_switch.cpp / pwr_switch.h (loop task; compiled in only under OPK_PWR_SWITCH)Drives one GPIO pin, wired externally to the HOST motherboard's power-switch header, to
power the PC on. pwrSwitchTask(): (1) debounces USBDevice.mounted()==false for
HOST_OFF_DEBOUNCE_MS into a hostOff flag; (2) per slot, independently re-derives the
Steam-button short-press edge already detected in rf_link.cpp by reading g_in[s].buttons
g_connReplyMs[s] directly (same pattern haptics.cpp's hapticOnReconnect uses against
rf_link.cpp's own link-up edge) — gated on that slot being link-up so a mid-press link drop
(which zeroes g_in[s]) can't read as a release; (3) on a completed short press with
hostOff true, outside a RETRIGGER_COOLDOWN_MS window, and no pulse already in flight,
pulses the pin for PULSE_MS (non-blocking release, same idiom as status_led.cpp). No new
cross-task shared state — every static here is loop-task-only.fault_diag.cpp / fault_diag.hHardFault_Handler() (ISR / exception context) — a strong override of the
weak default: sets GPREGRET2 = 0xFA then NVIC_SystemReset(). The only ISR/handler
in the codebase.faultDiagArmIntentionalReset() sets GPREGRET2 = 0xB1 before a deliberate reset.faultDiagBoot() (loop) reads RESETREAS + GPREGRET2, classifies the last boot
(PIN / WATCHDOG / LOCKUP / HARDFAULT / REBOOT / SOFT / WAKE / POWERON / UNKNOWN),
clears GPREGRET2. Accessors faultDiagReason/…ResetReas/…ReasonStr feed WebUSB.State touched by both the loop task and the usbd task (the synchronization-critical set):
| State | Writer(s) | Reader(s) | Synchronization |
|---|---|---|---|
g_rq[NSLOT][32] relay rings + g_rqHead/Tail (haptics) |
usbd HID callbacks (handleSet, mode rumble cbs) + loop (console/test/hapticTask) |
loop rfConnFlushRelay |
Producers under PRIMASK; volatile head/tail; ring body not volatile; SPSC-drain per slot |
g_slot[NSLOT].rec/used/resp (bonds) |
usbd handleSet (0xA2 / reply staging) + loop (loadBonds) |
loop RF/webusb/console, usbd handleGet |
resp is same-task (usbd); g_dirty (volatile) defers the flash write to loop |
g_dirty |
usbd handleSet |
loop loop() → saveBonds |
volatile flag |
g_padStick[2] (config, trackpad→stick live mirror) |
loop applyActiveType() (boot + WebUSB setter) |
loop mode builders via slotSticks; usbd jcInputPrefix (switch pro) |
none; plain uint8_t byte writes, worst case one stale frame (same shape as g_abSwap/g_back[]) |
g_in[NSLOT] (decoded input) |
loop rf_link decode |
loop mode task()/onReport45; usbd jcInputPrefix (switch pro); webusb |
none (single writer; readers tolerate staleness) |
g_connReplyMs[NSLOT] |
loop rf_link |
loop puck_hid/usb_mount/haptics/webusb; usbd haptic gates | none (plain unsigned long) |
g_battery/g_batteryState/g_linkRssi[NSLOT] |
loop rf_link |
loop puck_hid task/webusb |
declared volatile |
g_usbToBond[NSLOT] (usb_mount) |
loop usbMountTask/commitOrder |
usbd rumble callbacks (ps5/hidgyro/xinput) + loop modes | none; bounds-checked; non-atomic rewrite just before re-attach |
g_jcQ/g_jcQh/g_jcQt (switch pro reply ring) |
usbd jcEnq |
loop task() |
volatile head/tail, SPSC; body not volatile |
g_userCal[NSLOT][0x100] + dirty flags (switch pro) |
usbd jcSpiWrite |
usbd spiRead, loop saveUserCal |
only dirty flag/ts volatile; g_userCal itself unguarded (loop save can race usbd write) |
g_xiSlot[NSLOT] (xinput) |
usbd class driver (eps, rumble) | loop xinputSend/task |
inUse/rumble* volatile; itf/epIn/epOut not volatile; no lock |
g_swProReportMode[NSLOT] |
usbd jcSubcmd + loop mountSlots |
loop task |
not volatile |
g_steamAliveMs / g_resumeMs (puck_hid) |
usbd handleSet/handleGet + loop task |
both | none |
g_hapLog[4096] (OPK_LOG) |
usbd + loop hapLogAdd |
loop drain | PRIMASK in hapLogAdd |
rfrx[100]/rftx[100] + NRF_RADIO |
loop rf_link AND loop rf_diag |
same | no guard — relies on diag-modes-off assumption (both are loop task, so no preemption between them, but no arbitration of radio state) |
hapticSteamRumble() is the one app function legitimately entered from the usbd task
(via the ps5/hidgyro/xinput/switch-pro rumble callbacks) as well as the loop task; its
safety rests entirely on relayEnqueue's PRIMASK guard.