CHANGELOG: headline + Added/Changed/Known-tradeoff for the native-trigger work. README: a Features bullet, a "Linux: native adaptive triggers (Proton)" setup section with every launch option in a reference table, and a caveat that the WebHID Config/Remap tabs are disabled on this firmware. CLAUDE.md: a load-bearing gotcha -- the HID descriptor must stay byte-identical to a real DS5 (289 bytes) and feature reports 0x09/0x20/0x05 must serve the real cached controller data, or native game triggers silently break. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
18 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A Raspberry Pi Pico 2 W (RP2350) firmware that bridges a Sony DualSense (DS5) controller over Bluetooth Classic to a USB host. Presents itself to the host as a USB HID + UAC1 audio composite device. This repo is MarcelineVPQ/DS5Dongle-OLED-Edition, a personal fork of upstream awalol/DS5Dongle that adds an optional Waveshare Pico-OLED-1.3 status display (10 screens) and a 4-slot persistent multi-controller pairing layer.
README.md is authoritative for user-facing capability; CHANGELOG.md tracks what shipped per release. Read both before proposing changes — much of the design is non-obvious from the code alone (audit history, BT pairing-posture rationale, overclock requirement).
Build
This is firmware — there are no tests. Verification is hardware-in-the-loop (see "Hardware-in-the-loop workflow" below).
The build is non-trivial to set up because it requires a specific TinyUSB version:
# Pico SDK 2.2.0 must be installed; export PICO_SDK_PATH.
# CRITICAL: TinyUSB must be pinned to 0.20.0. The 0.18.0 that ships with
# Pico SDK 2.2.0 lacks the 4-arg form of TUD_AUDIO_EP_SIZE used by
# src/tusb_config.h — compile errors with no clear pointer to the cause.
cd "$PICO_SDK_PATH/lib/tinyusb"
git fetch --tags && git checkout 0.20.0
# Build
cd /path/to/DS5Dongle
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPICO_SDK_PATH="$PICO_SDK_PATH"
cmake --build build --target ds5-bridge
# Produces build/ds5-bridge-oled.uf2 (output name set in CMakeLists.txt, not the default)
Common build flags (set via -D on the cmake invocation):
ENABLE_SERIAL=ON— route printf to USB CDC for debugging. Default OFF (production).ENABLE_VERBOSE=ON— chatty BT/HID logging. Default OFF.ENABLE_BATT_LED=OFF— disable the low-battery LED blink. Default ON.PICO_W_BUILD=ON— build for the original Pico W (drops audio, lower clock). Default targets Pico 2 W.
CI runs four build variants in .github/workflows/build.yml (standard / debug / no-batt-led / Pico W). When changing build flags or CMake, update the workflow accordingly.
Hardware-in-the-loop workflow
There are no software tests. To verify a change:
- Build → produces
build/ds5-bridge-oled.uf2. - Put Pico into bootloader: hold BOOTSEL, plug USB. It mounts as
/run/media/$USER/RP2350(Linux) orRPI-RP2(others). - Copy the UF2 to the mount:
cp build/ds5-bridge-oled.uf2 /run/media/$USER/RP2350/— Pico auto-reboots into the new firmware. - Pair a DualSense: hold Create + PS on the controller until the lightbar pulses; the dongle inquiry picks it up.
- Verify enumeration host-side:
lsusb | grep 054c:0ce6should showSony Corp. DualSense wireless controller (PS5).
The development cadence is one feature per UF2 + checkpoint with the user before moving on — the user has hardware in front of them, regressions are caught the same minute, and large multi-feature commits make root-causing hard.
Architecture
Two cores, asymmetric workload:
- Core 0 runs everything:
main()infinite loop insrc/main.cppdrivescyw43_arch_poll()→ BTstack,tud_task()→ TinyUSB,audio_loop()(USB → BT haptic path),interrupt_loop()(BT → USB HID path), andoled_loop(). - Core 1 runs only
core1_entry()insrc/audio.cpp— the Opus speaker encoder. The two cores communicate via Pico SDKqueue_t(audio_fifo) and a critical section (opus_cs).
Two parallel data paths through the firmware:
- HID (DS5 input → host, host output → DS5):
src/bt.cppregisterson_bt_data()→src/main.cpp:on_bt_data()→ updatesinterrupt_in_data[63]→tud_hid_report(). Output reports come the other way throughtud_hid_set_report_cb. - Audio (host USB audio → DS5 speaker + haptics):
src/audio.cpp:audio_loop()reads 4-channel USB UAC1, splits ch1/2 (speaker → Opus encode on core1 → BT 0x32 report) and ch3/4 (haptic → resample 48k→3k → BT 0x32 report).
BT layer: Uses Pico SDK's bundled BTstack (no separate clone). src/bt.cpp is the only file that touches HCI/L2CAP. PSMs are PSM_HID_CONTROL and PSM_HID_INTERRUPT. Link keys live in BTstack's TLV NVM with NVM_NUM_LINK_KEYS=4 (configured in src/btstack_config.h).
OLED add-on is optional and self-contained: All 10 screens, the SH1107 SPI driver, the 5×7 font, the icon table, and the input handling live in src/oled.cpp (~30 KB). The only outward dependencies are read-only accessors (bt_is_connected, bt_get_addr, audio_peak_*, bt_get_signal_strength, the new slot accessors) and the global interrupt_in_data[63] (read-only for input visualization). When no OLED is wired, the SPI writes go nowhere — no init check needed.
Idle power ladder (oled_loop tail): Three-stage state machine (OLED_ACTIVE → OLED_DIM → OLED_OFF) driven by last_activity_us. kAutoDimUs = 2 min enters Dim — the regular per-screen render is replaced with render_dim_pulse(), which clears the framebuffer and walks a 2×2 dot through 8 positions every 30 s, blinking 1 s on / 1 s off. kAutoOffUs = 15 min enters Off — cmd(0xAE) puts the SH1107 to sleep and oled_loop returns early before rendering. Wakes on KEY0/KEY1 (via handle_buttons bumping last_activity_us), bt_is_connected() rising edge, or any change in the interrupt_in_data[0..9] hash. The dim tier uses flush_fb_raw() (the chrome-less variant) since there's no nav target while asleep. Why this shape: the SH1107 contrast register has a heavily non-linear perceptual curve on the Waveshare panel — even 0x02 looks ~90 % as bright as 0xFF. The only reliable "dim" available is rendering fewer pixels.
Multi-slot pairing (Phase G): Storage is two-tier:
- Link keys stay in BTstack's TLV NVM (4 slots, unchanged from upstream).
- bd_addrs + occupancy in a custom flash sector owned by
src/slots.cpp— sector below the config sector (PICO_FLASH_SIZE_BYTES - 2*FLASH_SECTOR_SIZE).
Slot routing is enforced in src/bt.cpp's HCI_EVENT_INQUIRY_RESULT handler: skip devices owned by other slots, accept only the current slot's bd_addr if occupied, auto-assign on first L2CAP_EVENT_CHANNEL_OPENED for HID_CONTROL when the current slot is empty. Switching slots flows: bt_set_slot() → persist new index → bt_disconnect() → HCI_EVENT_DISCONNECTION_COMPLETE → restart inquiry under new filter.
Flash layout (top of flash, growing downward):
| Sector | Owner | Contents |
|---|---|---|
PICO_FLASH_SIZE_BYTES - 1*FLASH_SECTOR_SIZE |
src/config.cpp |
Config{magic, version, crc32, size, body} — 9 user-tunable fields incl. current_slot |
PICO_FLASH_SIZE_BYTES - 2*FLASH_SECTOR_SIZE |
src/slots.cpp |
SlotsData{magic, addrs[4][6], occupied[4]} |
config_save() and save_slots_to_flash() both use save_and_disable_interrupts() + flash_range_erase + flash_range_program. PICO_FLASH_ASSUME_CORE1_SAFE=1 is set in CMakeLists.txt — flash writes happen while core1 may be running, but core1's hot path (Opus encode) does not access XIP during the brief erase/program window.
Critical gotchas
These are non-obvious from the code; they cost time when forgotten.
-
Overclock 320 MHz @ 1.20 V is load-bearing. Dropping voltage to 1.10 V or clock to stock breaks the CYW43 PIO SPI bus (BT chip becomes unreachable). Tested. Don't "optimize"
src/main.cpp:main()'svreg_set_voltage(VREG_VOLTAGE_1_20)+set_sys_clock_khz(SYS_CLOCK_KHZ, true)block. -
Render functions are
__attribute__((noinline))on purpose. With aggressive inlining the compiler folds all 10render_screen_*intooled_loop(), which then exceeds Thumb's 4 KB literal-pool reach and the linker emitsdangerous relocation: unsupported relocation. If you remove the noinline attributes or merge two render functions into a single huge one, the build will fail at link time. The symbol size grows over time as screens get richer — the lambda insiderender_screen_settingswas already hoisted to a noinlineformat_settings_item()for the same reason. -
config_default()is implemented in this fork, not upstream. Upstream declares it inconfig.h:30but never defines it. The implementation insrc/config.cppfills the body with0xFFand re-runsconfig_valid()(matches a freshly-erased flash sector). Don't assume any header declaration is implemented just because it compiles — check the .cpp. -
Screen indices are symbolic.
kScreenStatus,kScreenSlots,kScreenLightbar, … are defined in a single block near the top ofsrc/oled.cpp.oled_loop()'s switch andhandle_buttons()' KEY1 contextual checks (Trigger preset cycle, Lightbar mode cycle) refer to them by name. Never hard-code integers — reorder = one-block edit. -
OLED
kPin*pinout is hardcoded to the Waveshare Pico-OLED-1.3 standard (kPinMOSI=11,kPinCLK=10,kPinCS=9,kPinDC=8,kPinRST=12,kPinKey0=15,kPinKey1=17). SPI1 instance. Conflicts with anything else on those pins. Pico's BT chip uses internal SPI to CYW43 (different bus), and USB / UART pins (GP0/GP1) are also free — no conflicts in default config. -
DS5 PS+Mute hold-2-seconds →
watchdog_reboot(0,0,0)is the headless soft-reboot path (seesrc/main.cpp:interrupt_loop()). It checksinterrupt_in_data[9]bits 0+2. If you change input report layout, this combo breaks silently. -
Inactivity-disconnect uses
packet[3..12]in the L2CAP interrupt data path (src/bt.cpp:l2cap_packet_handler). It's looking at sticks and DPad/buttons to decide "idle." Don't touch those bytes' layout without updating the heuristic. -
The 0x36 BT audio packet layout is load-bearing — speaker + HD haptic actuators silently die without the SetStateData sub-report. Upstream commit
3a31bd7(May 2026, "refactor: add SetStateData and audio send priority") moved the0x10SetStateData block out of every audio frame and into a one-time L2CAP-open setup. The DualSense hardware requires that sub-report (specifically the0x7f 0x7fHeadphones + Speaker volume bytes) atpkt[11..75]of every0x36frame, or the actuators stop producing output even though USB and BT byte counts look fine. Our fork keepsstate_data[63]insrc/audio.cppand re-asserts it on every frame; the pre-3a31bd7 packet layout is: state_data atpkt[11..75], haptic atpkt[76..141], speaker format atpkt[142], opus payload atpkt[144..343]. If you rebase onto an upstream that has the refactor and don't preserve this restoration, speaker + HD haptics silently break. Thescripts/test_speaker.shhelper + theUSB aud / BT 0x32counters on the OLED Diagnostics screen are the regression tripwire — if bytes are flowing but you hear nothing, look at packet contents not flow. Upstream PR #93 is tracking a proper unified fix; ours is the pre-refactor revert applied just toaudio.cpp. Same fix was shipped independently byloteran/DS5Dongle(commitc7a8d3c). -
The DualSense HID descriptor must stay byte-identical to a real DS5 (289 bytes), and
0x09/0x20/0x05feature reports must return the controller's real cached data — or native game triggers silently break. A game's native DualSense detection (Cyberpunk, etc.) validates both the descriptor shape and the feature-report content, then drives adaptive triggers over the native hidraw path. If either differs from genuine hardware the game falls back to a generic/Xbox pad and triggers never fire. Linux'shid_playstationis far more lenient (size + CRC only), so the dongle still binds and the on-dongle OLED trigger-test still works — which masked this break for weeks. Two load-bearing things: (1) don't re-declare config reports0xF6–0xF9indesc_hid_report_ds(or bump itswDescriptorLengthruntime patch intud_descriptor_configuration_cbback to0x41) — that's what made the descriptor 321 bytes and killed triggers; (2) keeptud_hid_get_report_cbserving the realinit_feature()cache for0x09/0x20/0x05, with the CRC-valid synthetic stub only as the pre-BT-link probe fallback.desc_hid_report_dse(DSE mode) still declares F6-F9 and needs the same cut. Host side requires a Proton with the Winewinebus.sys#9034 fix +PROTON_ENABLE_HIDRAW=0x054c/0x0ce6+ Steam Input off; diff a dongle vs real-DS5PROTON_LOG=+hidcapture to debug (a GET retry storm on0x20/0x09= the dongle's feature content is being rejected).
Versioning — single source of truth
The release tag is the only place the version is written. Everything else flows from it:
- Release tag (e.g.
v0.6.2-oled-edition) → created withgit tagthengh release create. .github/workflows/release.ymlpicks the tag up as$FIRMWARE_VERSIONand passes it to CMake via-DVERSION="$FIRMWARE_VERSION".CMakeLists.txtexposes it to C++ as a compile-time macro:Local builds withouttarget_compile_definitions(ds5-bridge PRIVATE FIRMWARE_VERSION="${VERSION}")-DVERSION=...get the default"dev"— that's a deliberate visual signal so an untagged build is obvious on the OLED Status header.src/oled.cpprender_screen()renders"DS5 Bridge " FIRMWARE_VERSIONfor the Status screen header. No string literal for the version exists anywhere else in the firmware source.- Web preview (
DS5Dongle-OLED-Config-Web) reads the same tag at runtime frompublic/firmware-latest.json, which CI bundles in.github/workflows/deploy.yml's "Bundle latest firmware UF2 from GitHub releases" step (it pulls fromMarcelineVPQ/DS5Dongle-OLED-Edition/releases/latestvia the GitHub API).OledEmulator.tsxfetches that JSON on mount and writes the short form (suffix-oled-editionstripped) intostate.firmwareVersionLabel, whichscreens.ts:renderStatusconsumes.
The release ritual is therefore:
- Update
CHANGELOG.md— move[Unreleased]content into a new[X.Y.Z-oled-edition]section dated today. - Commit the CHANGELOG bump.
git tag -a vX.Y.Z-oled-edition -m "..."git push origin master && git push origin vX.Y.Z-oled-editiongh release create vX.Y.Z-oled-edition -R MarcelineVPQ/DS5Dongle-OLED-Edition --title "vX.Y.Z — OLED Edition" --notes "..."- CI builds the UF2s (~5–7 min), uploads them with SHA256SUMS, edits the release notes to append checksums, and (when
WEB_REPO_DISPATCH_PATis configured on the firmware repo — currently unset, see below) fires arepository_dispatchevent to the web repo to refreshfirmware-latest.json. Without the secret, the next push to the web repo'smasterdoes the refresh instead.
There is no other place to edit the version. If you find a hardcoded version string in source ("v0.6.0", "v0.5.4", etc.), it's a bug — replace it with the macro / JSON lookup.
Known follow-up: WEB_REPO_DISPATCH_PAT secret is unset on the firmware repo, so the firmware-release → web-rebuild dispatch is currently silently no-op'd (peter-evans/repository-dispatch with continue-on-error). The web bundle still updates via push events to the web repo's master, but not automatically on every firmware release.
Git / branch model
master(origin) =MarcelineVPQ/DS5Dongle-OLED-Edition(this fork's primary branding, what users download).upstream/upstream-real=awalol/DS5Dongle(read-only mirror forgit fetch upstream && git rebasecycles).upstream-fork=MarcelineVPQ/DS5Dongle-upstream-fork(a separate cleanroom fork retained from earlier PR contributions; not the active development branch).
Commits in this repo include a Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> trailer for AI-assisted work.
Where features live
When asked to modify behavior, the first file to read is usually one of:
- New BT pairing / connection state behavior →
src/bt.cpp(HCI + L2CAP event handlers). - New OLED screen or change to existing one →
src/oled.cpp. - New diagnostic counter on the OLED Diagnostics screen → bump
kNumDiagRowsinsrc/oled.cppand add acasetoformat_diag_row()(single switch, one row per case). The screen scrolls automatically; no D-pad wiring needed. Counters that need rate-per-second arithmetic should be sampled insample_diag_rates()and read fromg_diag_rates. Counter globals themselves typically live insrc/main.cppnext tog_bt_31_packetsetc. withexterndeclarations near the top ofsrc/oled.cpp. - Host-side diagnostics →
scripts/mic_diag.sh(Linux only, reads/dev/hidrawdirectly). Subcommands:status/capture [secs]/watch/bt-trace.bt-tracereads the firmware's0xFDvendor feature report (defined insrc/cmd.cpp'stud_hid_get_report_cb), which currently exposes BT-input counters + the host-output trigger-flow counters. To add a new counter visible tobt-trace: extend the0xFDpayload insrc/cmd.cpp(bumpwant, write at the new offset), bumpIOCTL_SIZEinbt_trace()of the script, and add the field to itsdecode()dict. The0xFDreport ID is not declared in the HID descriptor (Linux hidraw ioctls don't enforce that; WebHID would reject undeclared IDs, which is why config goes through0xF6). - New persistent config field →
src/config.h(struct),src/config.cpp:config_valid()(defaults + clamping),src/oled.cpp:format_settings_item()(UI),src/oled.cpp:settings_adjust()(D-pad ▶◀ behavior). UpdateCHANGELOG.md. - USB descriptor or interface change →
src/usb_descriptors.cpp+src/tusb_config.h. - Audio / haptic path →
src/audio.cpp. Don't add stack arrays sized smaller than the resampler / Opus expects (this is the C1 bug that caused the long-standing "audio stuttering" issue — fix landed in upstream5b04cbd, but the lesson stands).
Don't do these
- Don't disable the watchdog without explicit user authorization.
watchdog_enable(1000, true)insrc/main.cpp:main()is the safety net for hangs. - Don't add fallbacks for missing OLED beyond what
oled.cppalready does (no init checks, SPI writes harmlessly to nothing) — the firmware must boot identically with or without the display. - Don't write a new flash sector backend without updating the
static_assert(SECTOR_OFFSET % FLASH_SECTOR_SIZE == 0)pattern fromslots.cppand confirming no overlap withconfig.cpp's sector. - Don't push to
upstreamorupstream-forkby accident — onlyorigin/masteris the personal fork's deployment target.