Build-system, config, usb, and CI wiring on top of upstream's
v0.6.0-hotfix tree. Build will still fail until Phase C+D land bt.h
accessors that oled.cpp references; that's expected mid-rebase.
CMakeLists.txt: add src/oled.cpp + src/slots.cpp to add_executable,
link hardware_spi, restore OUTPUT_NAME ds5-bridge-oled.
src/config.h: append the 4 OLED Edition Config_body fields
(current_slot + auto_haptics_enable/gain/lowpass). Preserves
upstream's field order for the first 8 fields.
src/config.cpp:
- speaker_volume validity default -100 dB → 0 dB (footgun fix)
- 4 new validity guards for the appended fields with sensible
defaults (Fallback / 100 % / 160 Hz / slot 0)
- config_default() impl (was declared in upstream's config.h since
v0.5.4 but never defined; the OLED Edition's "Reset to defaults"
Settings item calls it)
src/usb.cpp: drop the UAC1 SET_CUR Volume → flash-config sync that
let PipeWire/Pulse silently override the user's saved Spk Vol on
every device reconnect. Borrowed from loteran/DS5Dongle commit
03fa1e4.
src/usb_descriptors.cpp: restore iSerialNumber = STRID_SERIAL.
Upstream's commit e79c762 zeroed it to work around SpecialK (#32),
but that broke Windows device identity (#100) — users lost
per-device volume / app preferences when moving USB ports. The
OLED Edition prioritizes Windows device-identity stability for
the broader user base over SpecialK compat for a narrower one.
.github/workflows/{build,release}.yml: artifact filenames use
ds5-bridge-oled.uf2 (and -debug / -picow variants) per the
OUTPUT_NAME rebrand.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 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.
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).
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 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.