diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e8eac0..9b38418 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,7 +86,7 @@ jobs: -DVERSION="$FIRMWARE_VERSION" cmake --build build/standard --target ds5-bridge mkdir -p artifacts - cp build/standard/ds5-bridge.uf2 artifacts/ds5-bridge.uf2 + cp build/standard/ds5-bridge-oled.uf2 artifacts/ds5-bridge-oled.uf2 - name: Build Debug firmware run: | @@ -97,7 +97,7 @@ jobs: -DENABLE_VERBOSE=ON \ -DVERSION="$FIRMWARE_VERSION" cmake --build build/debug --target ds5-bridge - cp build/debug/ds5-bridge.uf2 artifacts/ds5-bridge-debug.uf2 + cp build/debug/ds5-bridge-oled.uf2 artifacts/ds5-bridge-oled-debug.uf2 - name: Build no-batt-led firmware (compile check only) run: | @@ -116,25 +116,25 @@ jobs: -DPICO_W_BUILD=ON \ -DVERSION="$FIRMWARE_VERSION" cmake --build build/picow --target ds5-bridge - cp build/picow/ds5-bridge.uf2 artifacts/ds5-bridge-picow.uf2 + cp build/picow/ds5-bridge-oled.uf2 artifacts/ds5-bridge-oled-picow.uf2 - name: Upload standard UF2 artifact uses: actions/upload-artifact@v7 with: - path: artifacts/ds5-bridge.uf2 + path: artifacts/ds5-bridge-oled.uf2 archive: false if-no-files-found: error - name: Upload Debug UF2 artifact uses: actions/upload-artifact@v7 with: - path: artifacts/ds5-bridge-debug.uf2 + path: artifacts/ds5-bridge-oled-debug.uf2 archive: false if-no-files-found: error - name: Upload PicoW UF2 artifact uses: actions/upload-artifact@v7 with: - path: artifacts/ds5-bridge-picow.uf2 + path: artifacts/ds5-bridge-oled-picow.uf2 archive: false if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8eb8133..41a9148 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: -DVERSION="$FIRMWARE_VERSION" cmake --build build/standard --target ds5-bridge mkdir -p artifacts - cp build/standard/ds5-bridge.uf2 "artifacts/ds5-bridge-${{ github.event.release.tag_name }}.uf2" + cp build/standard/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-${{ github.event.release.tag_name }}.uf2" - name: Build Debug firmware run: | @@ -102,7 +102,7 @@ jobs: -DENABLE_VERBOSE=ON \ -DVERSION="$FIRMWARE_VERSION" cmake --build build/debug --target ds5-bridge - cp build/debug/ds5-bridge.uf2 "artifacts/ds5-bridge-debug-${{ github.event.release.tag_name }}.uf2" + cp build/debug/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-debug-${{ github.event.release.tag_name }}.uf2" - name: Upload UF2 files to release env: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5ab6b61 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,110 @@ +# Changelog + +All notable changes to **Pico2W DualSense 5 Bridge — OLED Edition** are documented here. This fork tracks [awalol/DS5Dongle](https://github.com/awalol/DS5Dongle) (upstream) and adds an optional OLED status display plus a security/correctness audit pass on the core bridge. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning approximates [SemVer](https://semver.org/) with the upstream version stream — the fork shares a major.minor with whatever upstream tag it is rebased on. + +--- + +## [Unreleased] + +### Fixed + +- **DualSense speaker + HD haptic actuator regression** — upstream commit `3a31bd7` (2026-05-12, "refactor: add SetStateData and audio send priority") moved the `0x10` SetStateData sub-report out of every `0x36` audio packet and into a one-time L2CAP-open setup. The DS5 hardware requires that sub-report to be re-asserted on every audio frame (the `0x7f 0x7f` Headphones+Speaker volume bytes specifically) or the speaker and HD haptic actuators silently stop producing output. Restored in `src/audio.cpp` with the pre-3a31bd7 packet layout (state_data at `pkt[11..75]`, haptic at `pkt[76..141]`, speaker at `pkt[142..343]`). Same fix shipped independently by loteran/DS5Dongle as commit `c7a8d3c` ~6 h before ours; credit to loteran for the clearer hardware-side explanation in their commit message. +- **USB UAC1 SET_CUR Volume request no longer overrides flash-persisted speaker_volume.** PipeWire / PulseAudio re-apply their last-known UAC1 volume on every device reconnect, which had silently overridden whatever the user had saved in the OLED Settings menu. The in-memory `volume[]` array still tracks live host volume; only the flash sync was removed. Fix borrowed from loteran/DS5Dongle commit `03fa1e4`. + +### Added + +- **Audio Auto Haptics** — derive haptic feedback from the speaker audio (UAC channels 0/1) for games that send sound but no per-frame haptic data, e.g. Ghost of Tsushima on Linux + Steam. DSP is a 1-pole low-pass + envelope follower + modulation + soft-clip (`x / (1 + |x|)`, avoids `tanhf` on Cortex-M33), borrowed from loteran/DS5Dongle commit `5d6bc2f`. Four modes selectable from the OLED Settings menu: **Off** / **Fallback** / **Mix** / **Replace**. Default is **Fallback** — derived rumble fires only after the game has been silent on the native haptic path (channels 2/3) for ~1 s, so games that send native HD haptics (Spider-Man Remastered) are not overridden, while games that don't (Ghost of Tsushima) get derived haptics out of the box. Gain (0–200 %) and LP cutoff (80 / 160 / 250 / 400 Hz) are also tunable from the Settings menu. +- **Audio Diagnostics counters** on the OLED Diagnostics screen: `USB aud N/s` (UAC1 frames per second arriving from the host) and `BT 0x32 N/s` (audio packets emitted to the DS5 per second). Lets the user verify the speaker/haptic path is actually moving bytes without needing a UART cable. Used to triage the speaker regression above. + +--- + +## [0.5.4] — 2026-05-16 + +First full OLED Edition release. Includes upstream's v0.5.4 base plus the audit pass and the entire OLED add-on feature set. + +### Added — OLED display add-on + +Requires a Waveshare [Pico-OLED-1.3](https://www.waveshare.com/wiki/Pico-OLED-1.3) (128×64 SH1107). Firmware drives it automatically when present and no-ops gracefully when absent. + +- **Boot splash** (1.5 s) showing firmware version on power-on. +- **10 screens**, cycled with KEY0 (forward) / KEY1 (back, except where contextual): + 1. **Status** — connection state, paired BD address, battery % with pixel-icon battery, live stick / D-pad / face-button / L1-R1 / L2-R2 trigger visualization. + 2. **Slots** — persistent 4-slot multi-controller pairing (see below). + 3. **Lightbar Color Picker** — tilt-to-RGB live preview on the controller's lightbar, with 4 user-savable favorite slots (△ ○ ✕ □) and three effect presets (Breathing, Rainbow, Fade). + 4. **Trigger Test** — cycles 7 DS5 adaptive trigger effects (Off / Feedback / Weapon / Vibration / Bow / Galloping / Machine Gun) on both L2 and R2, bitpacked per [dualsensectl](https://github.com/nowrep/dualsensectl)'s reverse-engineering. + 5. **Gyro Tilt** — live X/Y/Z accelerometer values + 40×40 crosshair box that tracks tilt in real time. + 6. **Touchpad** — live render of finger positions on the touchpad surface, with a finger count. + 7. **Diagnostics** — uptime, BT state, HCI / audio-FIFO / opus-FIFO counter stubs (kept for future wiring). + 8. **RSSI** — live BT signal strength of the active link, dBm + bar. + 9. **VU Meters** — live peak meters for the speaker + haptic audio paths. + 10. **Settings** — persistent on-device editor for the 8 firmware config fields, plus "Reset to defaults" (hold △ 2 s) and "Wipe all slots" (hold △ 2 s). +- **OLED brightness control** — KEY1 long-press cycles brightness levels. +- **Auto-dim** after 5 minutes of input idle (lifespan / burn-in protection). +- **Soft-reboot recovery** without unplugging USB: + - OLED KEY0 **double-click** (~400 ms window) → `watchdog_reboot`. + - DS5 `PS + Mute` held for 2 seconds → `watchdog_reboot` (works without the OLED). +- **Pixel-art icons** in the Status screen header (link indicator + battery icon). + +### Added — 4-slot persistent multi-controller pairing + +- Bond up to 4 DualSenses; switch between them from the **Slots** OLED screen. +- Active slot persisted in the existing flash-backed config; dongle reconnects to the last-used controller on boot. +- D-pad ▲▼ to move cursor, △ to switch to the cursor slot (disconnect current ACL, restart inquiry filtered to the new slot's bd_addr), □ hold 1.5 s to wipe a single slot. +- "Wipe all slots" item in the Settings menu drops all 4 stored bd_addrs + all BTstack link keys in one shot. +- Inquiry filter on `HCI_EVENT_INQUIRY_RESULT` enforces slot ownership: devices stored in other slots are skipped; empty slots auto-assign on the first L2CAP HID_CONTROL channel open. +- Dongle stops being BT-discoverable once all 4 slots are full (security tightening; was permanently discoverable in upstream). +- Storage: BTstack's TLV link-key DB holds the keys (`NVM_NUM_LINK_KEYS=4`, unchanged from upstream); a new dedicated flash sector holds the 4 bd_addrs + occupancy bits + magic word. +- Multi-slot UX modeled on [zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED). Credit to zurce. + +### Added — security / correctness audit pass + +Critical and high-severity fixes on the core bridge code. Many of these have since landed upstream independently; this changelog captures what this fork shipped. + +- **C1**: 4.8× stack-overflow in `core1_entry`'s `out_buf` (200 floats vs. 960 floats the resampler/encoder actually writes/reads). Root cause of the long-standing "audio may experience slight stuttering" known issue. Buffer resized and Opus return value now checked. +- **C2 + H5**: Variable-length stack array in `set_feature_data` sized by host-controlled length (potential stack blowup). Replaced with a bounded fixed buffer + length validation; CRC bounds tightened so `len < 4` no longer wraps to a huge `size_t`. +- **C3**: Unbounded `memcpy` into a 78-byte stack buffer in `tud_hid_set_report_cb` for HID report 0x02 (overflow if `bufsize > 76`). Bounded. +- **H1**: `tud_hid_get_report_cb` wrote `feature_data.size() - 1` bytes into the host-supplied buffer without clamping to `reqlen` (host-buffer overflow). Clamped. +- **H2**: OOB read in `on_bt_data` (`data[56]` / `memcpy(.., data+3, 63)` with no length check on the incoming BT frame). Bounded. +- **H3**: Same pattern in `l2cap_packet_handler` (`packet[3]` read with no size check). Bounded. +- **H4**: UAC1 volume parsed as `uint16` instead of signed `int16` — non-conformant hosts could crank haptic gain to 256× and saturate everything. Fixed sign extension. +- **H7**: BT report sequence counter in `main.cpp` cycled with period 16 (incremented as `int`, written as `(c<<4)`). Replaced with the correct `(c+1) & 0x0F` wrap used in `audio.cpp`. +- **N1**: Pairing-failure recovery: three `gap_inquiry_start(30)` calls were commented out in `bt.cpp`'s error paths (HCI command status, connection complete, authentication complete). Dongle would soft-brick after any pairing failure that didn't end in a disconnect. Uncommented. +- **N2**: Removed printf of the bonding link-key to UART on every reconnect. +- **N3**: CRC-helper bounds: `fill_output_report_checksum` / `fill_feature_report_checksum` underflowed `size_t` and wrote at negative offsets on `len < 4`. Guarded. +- **N4**: HCI command-send return values logged so a future stuck-dongle report has observability. +- **N5**: Watchdog enabled (8 s). Hangs now recover automatically instead of needing a power-cycle. + +### Added — pairing posture hardening + +- Tightened `gap_discoverable_control(1)` — the dongle now only advertises as pairable when at least one slot is empty. Once all 4 slots are full, it goes non-discoverable (still connectable to bonded controllers). +- (Carried over from upstream's own hardening; documented here for completeness.) + +### Changed + +- Project rebranded as **OLED Edition** to differentiate from upstream. UF2 artifact renamed: `ds5-bridge.uf2` → `ds5-bridge-oled.uf2`. +- README rewritten with full hardware section (Pico 2 W + Pico-OLED-1.3 SKUs, vendor links, prices), all 10 OLED screen mockups in the new cycle order, and explicit `TINYUSB 0.20.0` pin requirement (the 0.18.0 bundled with Pico SDK 2.2.0 lacks the 4-arg form of `TUD_AUDIO_EP_SIZE` used by this project's `tusb_config.h`). +- KEY1 short-press: was a 250 ms test-rumble burst on most screens; now cycles **backward** through screens (mirror of KEY0). Still cycles trigger preset on Trigger Test and lightbar mode on Lightbar (their primary in-screen interactions). +- Screen cycle order reorganized: **Status → Slots → Lightbar → Trigger Test → Gyro Tilt → Touchpad → Diagnostics → RSSI → VU Meters → Settings**. Settings last; the three "test" stimulus screens grouped together; diagnostic screens grouped together. Screen indices are symbolic constants (`kScreenStatus`, `kScreenSlots`, …) so future reorders are a one-block edit. + +### Fixed + +- `config_default()` was declared in `config.h` but never defined upstream (latent "undefined symbol" → linker "dangerous relocation" if anything called it). Implemented (fills body with `0xFF`, runs `config_valid()` — same path as a freshly-erased flash sector). +- GitHub Actions workflow `cp` paths corrected to match the `ds5-bridge-oled.uf2` produced after the CMake `OUTPUT_NAME` rename. (Workflows had failed on every push since the rebrand commit.) + +### Build + +- TinyUSB pinned to `0.20.0` (required; see Build Instructions in README). +- Pico SDK 2.2.0 + toolchain `14_2_Rel1` validated. Pico 2 W (RP2350) is the primary target. +- `CMakeLists.txt` adds `src/oled.cpp` + `src/slots.cpp` to the executable target, links `hardware_spi`. +- `OUTPUT_NAME` set to `ds5-bridge-oled` so the UF2 reflects the project name. + +### Acknowledgements + +- **[awalol/DS5Dongle](https://github.com/awalol/DS5Dongle)** — upstream base. This fork is a strict superset that tracks upstream and layers add-on features. +- **[zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED)** — pixel-art icon approach, the "hold for factory reset" UX pattern, and the multi-slot persistent pairing model. + +--- + +[0.5.4]: https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.5.4 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eb54d70 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# 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: + +```bash +# 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: + +1. Build → produces `build/ds5-bridge-oled.uf2`. +2. Put Pico into bootloader: hold BOOTSEL, plug USB. It mounts as `/run/media/$USER/RP2350` (Linux) or `RPI-RP2` (others). +3. Copy the UF2 to the mount: `cp build/ds5-bridge-oled.uf2 /run/media/$USER/RP2350/` — Pico auto-reboots into the new firmware. +4. Pair a DualSense: hold Create + PS on the controller until the lightbar pulses; the dongle inquiry picks it up. +5. Verify enumeration host-side: `lsusb | grep 054c:0ce6` should show `Sony 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 in `src/main.cpp` drives `cyw43_arch_poll()` → BTstack, `tud_task()` → TinyUSB, `audio_loop()` (USB → BT haptic path), `interrupt_loop()` (BT → USB HID path), and `oled_loop()`. +- **Core 1** runs only `core1_entry()` in `src/audio.cpp` — the Opus speaker encoder. The two cores communicate via Pico SDK `queue_t` (`audio_fifo`) and a critical section (`opus_cs`). + +**Two parallel data paths through the firmware:** + +1. **HID** (DS5 input → host, host output → DS5): `src/bt.cpp` registers `on_bt_data()` → `src/main.cpp:on_bt_data()` → updates `interrupt_in_data[63]` → `tud_hid_report()`. Output reports come the other way through `tud_hid_set_report_cb`. +2. **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()`'s `vreg_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 10 `render_screen_*` into `oled_loop()`, which then exceeds Thumb's 4 KB literal-pool reach and the linker emits `dangerous 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 inside `render_screen_settings` was already hoisted to a noinline `format_settings_item()` for the same reason. +- **`config_default()` is implemented in this fork**, not upstream. Upstream declares it in `config.h:30` but never defines it. The implementation in `src/config.cpp` fills the body with `0xFF` and re-runs `config_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 of `src/oled.cpp`. `oled_loop()`'s switch and `handle_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 (see `src/main.cpp:interrupt_loop()`). It checks `interrupt_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 the `0x10` SetStateData block out of every audio frame and into a one-time L2CAP-open setup. The DualSense hardware requires that sub-report (specifically the `0x7f 0x7f` Headphones + Speaker volume bytes) at `pkt[11..75]` of every `0x36` frame, or the actuators stop producing output even though USB and BT byte counts look fine. Our fork keeps `state_data[63]` in `src/audio.cpp` and re-asserts it on every frame; the pre-3a31bd7 packet layout is: state_data at `pkt[11..75]`, haptic at `pkt[76..141]`, speaker format at `pkt[142]`, opus payload at `pkt[144..343]`. If you rebase onto an upstream that has the refactor and don't preserve this restoration, speaker + HD haptics silently break. The `scripts/test_speaker.sh` helper + the `USB aud / BT 0x32` counters 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 to `audio.cpp`. Same fix was shipped independently by `loteran/DS5Dongle` (commit `c7a8d3c`). + +## 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 for `git fetch upstream && git rebase` cycles). +- **`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) ` 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). Update `CHANGELOG.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 upstream `5b04cbd`, but the lesson stands). + +## Don't do these + +- **Don't disable the watchdog** without explicit user authorization. `watchdog_enable(1000, true)` in `src/main.cpp:main()` is the safety net for hangs. +- **Don't add fallbacks for missing OLED** beyond what `oled.cpp` already 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 from `slots.cpp` and confirming no overlap with `config.cpp`'s sector. +- **Don't push to `upstream` or `upstream-fork`** by accident — only `origin/master` is the personal fork's deployment target. diff --git a/CMakeLists.txt b/CMakeLists.txt index c9f4b99..7bcd22b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,8 @@ add_executable(ds5-bridge src/config.cpp src/cmd.cpp src/state_mgr.cpp + src/oled.cpp + src/slots.cpp ) if (ENABLE_BATT_LED) @@ -144,7 +146,8 @@ if(WAKE_DEBUG) endif() set(VERSION "dev" CACHE STRING "Program version string") -pico_set_program_name(ds5-bridge "ds5-bridge") +set_target_properties(ds5-bridge PROPERTIES OUTPUT_NAME "ds5-bridge-oled") +pico_set_program_name(ds5-bridge "ds5-bridge-oled") pico_set_program_version(ds5-bridge "${VERSION}") # Modify the below lines to enable/disable output over UART/USB @@ -167,6 +170,7 @@ target_link_libraries(ds5-bridge hardware_watchdog hardware_timer hardware_flash + hardware_spi pico_btstack_classic # pico_cyw43_arch_threadsafe_background pico_cyw43_arch_poll diff --git a/assets/diagnostics_01.jpeg b/assets/diagnostics_01.jpeg new file mode 100644 index 0000000..b95ca95 Binary files /dev/null and b/assets/diagnostics_01.jpeg differ diff --git a/assets/gyro_tilt_01.jpeg b/assets/gyro_tilt_01.jpeg new file mode 100644 index 0000000..a1bc954 Binary files /dev/null and b/assets/gyro_tilt_01.jpeg differ diff --git a/assets/lightbar_01.jpeg b/assets/lightbar_01.jpeg new file mode 100644 index 0000000..e86bbf6 Binary files /dev/null and b/assets/lightbar_01.jpeg differ diff --git a/assets/main_screen_01.jpeg b/assets/main_screen_01.jpeg new file mode 100644 index 0000000..7835317 Binary files /dev/null and b/assets/main_screen_01.jpeg differ diff --git a/assets/touchpad_01.jpeg b/assets/touchpad_01.jpeg new file mode 100644 index 0000000..c901153 Binary files /dev/null and b/assets/touchpad_01.jpeg differ diff --git a/assets/triggertest_01.jpeg b/assets/triggertest_01.jpeg new file mode 100644 index 0000000..5ef0170 Binary files /dev/null and b/assets/triggertest_01.jpeg differ diff --git a/scripts/set_ds5.py b/scripts/set_ds5.py new file mode 100755 index 0000000..b0664ce --- /dev/null +++ b/scripts/set_ds5.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +DS5Dongle (OLED Edition) configuration tool. + +Reads and writes the firmware's persistent config over USB HID feature +reports — no browser, no WebHID, works in any terminal. Ported from +loteran/DS5Dongle's scripts/set_ds5.py and extended for this fork's +Config_body layout (adds `config_version` at the start and `current_slot` +between `controller_mode` and `auto_haptics_*`). + +Requires EITHER `cython-hidapi` (preferred — `pip install hidapi`) OR +the `hid` package (`pip install hid`). The script auto-detects which is +installed; both expose `hid.device` somewhere with slightly different +constructor APIs, and we handle both. + +Quick usage: + scripts/set_ds5.py # print current config + scripts/set_ds5.py --auto-haptics fallback --auto-haptics-gain 120 + scripts/set_ds5.py --haptics-gain 1.5 --speaker-volume -10 + scripts/set_ds5.py --slot 2 # switch active pairing slot + scripts/set_ds5.py --version # print firmware version + scripts/set_ds5.py --rssi # print live BT RSSI in dBm + +Credit: protocol + script structure from loteran/DS5Dongle commit 5d6bc2f. +""" + +import argparse +import struct +import sys + +try: + import hid +except ImportError: + print("[ERROR] Missing dependency: install with pip install hidapi", file=sys.stderr) + sys.exit(1) + + +def _open_hid(vid, pid): + """Open a HID device, abstracting over cython-hidapi vs apmorton/pyhidapi.""" + if hasattr(hid, 'Device'): # apmorton/pyhidapi: hid.Device(vid, pid) + return hid.Device(vid, pid) + if hasattr(hid, 'device'): # cython-hidapi: hid.device() + .open(vid, pid) + d = hid.device() + d.open(vid, pid) + return d + raise RuntimeError("installed `hid` module has neither Device nor device — unknown variant") + +SONY_VID = 0x054C +DS5_PID = 0x0CE6 +DSE_PID = 0x0DF2 + +# Our Config_body wire layout — 19 bytes, little-endian, packed struct. +# uint8 config_version [0] (firmware-set, ignored on write) +# float haptics_gain [1:5] +# float speaker_volume [5:9] +# uint8 inactive_time [9] +# uint8 disable_inactive_disc [10] +# uint8 disable_pico_led [11] +# uint8 polling_rate_mode [12] +# uint8 audio_buffer_length [13] +# uint8 controller_mode [14] +# uint8 current_slot [15] (fork-specific: multi-slot pairing) +# uint8 auto_haptics_enable [16] 0=off 1=fallback 2=mix 3=replace +# uint8 auto_haptics_gain [17] [0..200] percent +# uint8 auto_haptics_lowpass [18] 0=80Hz 1=160Hz 2=250Hz 3=400Hz +CONFIG_FMT = '= 128 else val # int8 + + +def fmt_cfg(c): + return ( + f" config_version {c['config_version']}\n" + f" haptics_gain {c['haptics_gain']:.3f}\n" + f" speaker_volume {c['speaker_volume']:.1f} dB\n" + f" inactive_time {c['inactive_time']} min\n" + f" inactive_disc {'disabled' if c['disable_inactive_disconnect'] else 'enabled'}\n" + f" pico_led {'off' if c['disable_pico_led'] else 'on'}\n" + f" polling_rate {c['polling_rate_mode']} ({POLLING_MODES.get(c['polling_rate_mode'], '?')})\n" + f" audio_buffer {c['audio_buffer_length']}\n" + f" controller_mode {c['controller_mode']} ({CONTROLLER_MODES.get(c['controller_mode'], '?')})\n" + f" current_slot {c['current_slot']}\n" + f" auto_haptics {c['auto_haptics_enable']} ({AUTO_HAP_MODES.get(c['auto_haptics_enable'], '?')})\n" + f" auto_haptics_gain {c['auto_haptics_gain']}%\n" + f" auto_haptics_lp {c['auto_haptics_lowpass']} ({LOWPASS_MODES.get(c['auto_haptics_lowpass'], '?')})" + ) + + +AUTO_HAP_ARG = {'off': 0, 'fallback': 1, 'mix': 2, 'replace': 3} +LOWPASS_ARG = {'80': 0, '160': 1, '250': 2, '400': 3} +CTRL_MODE_ARG = {'ds5': 0, 'dse': 1, 'auto': 2} +POLL_ARG = {'250': 0, '500': 1, 'realtime': 2, 'rt': 2} + + +def build_parser(): + p = argparse.ArgumentParser(description="DS5Dongle (OLED Edition) config tool.") + p.add_argument('--version', action='store_true', help="print firmware version and exit") + p.add_argument('--rssi', action='store_true', help="print live BT RSSI (dBm) and exit") + p.add_argument('--haptics-gain', type=float, help="float [1.0, 2.0]") + p.add_argument('--speaker-volume', type=float, help="dB [-100, 0]") + p.add_argument('--inactive-time', type=int, help="minutes [10, 60]") + p.add_argument('--inactive-disc', choices=['on', 'off'], help="silent disconnect on idle") + p.add_argument('--pico-led', choices=['on', 'off']) + p.add_argument('--polling', choices=POLL_ARG.keys(), help="USB HID polling rate") + p.add_argument('--audio-buffer', type=int, help="haptic buffer length [16, 128]") + p.add_argument('--controller-mode', choices=CTRL_MODE_ARG.keys()) + p.add_argument('--slot', type=int, choices=[0, 1, 2, 3], help="active multi-pairing slot") + p.add_argument('--auto-haptics', choices=AUTO_HAP_ARG.keys(), help="Auto Haptics mode") + p.add_argument('--auto-haptics-gain', type=int, help="percent [0, 200]") + p.add_argument('--auto-haptics-lp', choices=LOWPASS_ARG.keys(), help="LP cutoff Hz") + return p + + +def main(): + args = build_parser().parse_args() + device = open_device() + + if args.version: + print(f"Firmware: {get_version(device)}") + return + if args.rssi: + rssi = get_rssi(device) + print(f"BT RSSI: {rssi} dBm" if rssi is not None else "RSSI unavailable") + return + + cfg = get_config(device) + changes = [] + + def set_kv(key, val, label=None): + if cfg[key] != val: + changes.append(f" {label or key}: {cfg[key]} → {val}") + cfg[key] = val + + if args.haptics_gain is not None: set_kv('haptics_gain', args.haptics_gain) + if args.speaker_volume is not None: set_kv('speaker_volume', args.speaker_volume) + if args.inactive_time is not None: set_kv('inactive_time', args.inactive_time) + if args.inactive_disc is not None: + set_kv('disable_inactive_disconnect', 1 if args.inactive_disc == 'off' else 0, + label='inactive_disc') + if args.pico_led is not None: + set_kv('disable_pico_led', 1 if args.pico_led == 'off' else 0, label='pico_led') + if args.polling is not None: set_kv('polling_rate_mode', POLL_ARG[args.polling]) + if args.audio_buffer is not None: set_kv('audio_buffer_length', args.audio_buffer) + if args.controller_mode is not None: set_kv('controller_mode', CTRL_MODE_ARG[args.controller_mode]) + if args.slot is not None: set_kv('current_slot', args.slot) + if args.auto_haptics is not None: set_kv('auto_haptics_enable', AUTO_HAP_ARG[args.auto_haptics]) + if args.auto_haptics_gain is not None: set_kv('auto_haptics_gain', args.auto_haptics_gain) + if args.auto_haptics_lp is not None: set_kv('auto_haptics_lowpass', LOWPASS_ARG[args.auto_haptics_lp]) + + if changes: + print("Updating config:") + print("\n".join(changes)) + write_config(device, cfg) + print("Saved to flash.") + # Re-read to show what stuck after firmware validation + cfg = get_config(device) + print("\nNew config:") + else: + print("Current config:") + print(fmt_cfg(cfg)) + + +if __name__ == '__main__': + main() diff --git a/scripts/test_speaker.sh b/scripts/test_speaker.sh new file mode 100755 index 0000000..be462d5 --- /dev/null +++ b/scripts/test_speaker.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# test_speaker.sh — play audio through the DualSense speaker via the Pico bridge. +# +# The Pico exposes itself as a 4-channel UAC1 audio device (VID:PID 054c:0ce6). +# Channels 0+1 route to the DS5 speaker; channels 2+3 drive the HD haptic motors. +# Stereo audio fills 0+1 and leaves 2+3 silent (no haptic noise). +# +# Usage: +# scripts/test_speaker.sh # 3-second 440 Hz sine +# scripts/test_speaker.sh --tone 880 5 # 5-second 880 Hz sine +# scripts/test_speaker.sh path/to/audio # play a file (anything paplay accepts) + +set -euo pipefail + +find_sink() { + pactl list short sinks 2>/dev/null \ + | grep -iE 'dualsense|sony|054c' \ + | awk '{print $2}' \ + | head -n1 +} + +play_tone() { + local freq="$1" dur="$2" + echo "Playing ${dur}s sine at ${freq} Hz on $SINK ..." + ffmpeg -hide_banner -loglevel error -nostdin \ + -f lavfi -i "sine=frequency=${freq}:duration=${dur}" \ + -ac 2 -ar 48000 -f wav - \ + | paplay --device="$SINK" +} + +play_file() { + local path="$1" + [[ -f "$path" ]] || { echo "Error: file not found: $path" >&2; exit 1; } + echo "Playing $path on $SINK ..." + paplay --device="$SINK" "$path" +} + +if [[ "${1-}" == -h || "${1-}" == --help ]]; then + sed -n '2,11p' "$0" + exit 0 +fi + +SINK="$(find_sink)" +if [[ -z "$SINK" ]]; then + echo "Error: no DualSense audio sink found." >&2 + echo " Check the DS5 is paired: lsusb | grep 054c:0ce6" >&2 + echo " Check the sink shows up: pactl list short sinks" >&2 + exit 1 +fi + +case "${1-}" in + --tone) + play_tone "${2:-440}" "${3:-3}" + ;; + "") + play_tone 440 3 + ;; + *) + play_file "$1" + ;; +esac + +echo +echo "If you heard nothing on the DS5 speaker, sanity checks:" +echo " - DS5 is paired and connected? (lsusb | grep 054c:0ce6)" +echo " - Sink not muted? pactl get-sink-mute $SINK" +echo " - DS5 hardware speaker switch / mute LED not on?" +echo " - OLED Settings -> Spk Vol not set to -100 dB (silent floor)?" diff --git a/src/config.cpp b/src/config.cpp index 3bcc72b..dc53606 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -52,8 +52,8 @@ void config_valid() { printf("[Config] Haptics Gain value is invalid\n"); } if (std::isnan(body->speaker_volume) || body->speaker_volume < -100 || body->speaker_volume > 0) { - body->speaker_volume = -100; - printf("[Config] Speaker Volume is invalid\n"); + body->speaker_volume = 0; // OLED Edition: 0 dB default (unity); -100 would be silent + printf("[Config] Speaker Volume is invalid, defaulting to 0 dB\n"); } if (body->inactive_time < 5 || body->inactive_time > 60) { body->inactive_time = 30; @@ -79,6 +79,22 @@ void config_valid() { body->controller_mode = 2; printf("[Config] controller_mode is invalid\n"); } + if (body->current_slot >= 4) { + body->current_slot = 0; + printf("[Config] current_slot is invalid\n"); + } + if (body->auto_haptics_enable > 3) { + body->auto_haptics_enable = 1; // Fallback default + printf("[Config] auto_haptics_enable invalid, defaulting to 1 (Fallback)\n"); + } + if (body->auto_haptics_gain > 200) { + body->auto_haptics_gain = 100; + printf("[Config] auto_haptics_gain invalid, defaulting to 100\n"); + } + if (body->auto_haptics_lowpass > 3) { + body->auto_haptics_lowpass = 1; // 160 Hz + printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n"); + } if (body->config_version != CONFIG_VERSION) { body->config_version = CONFIG_VERSION; printf("[Config] Warning: Config may breaking change\n"); @@ -91,6 +107,15 @@ void config_load() { config_valid(); } +// Reset RAM-resident config body to firmware defaults. Caller must +// config_save() to persist. Filling with 0xFF mirrors the byte pattern +// of a freshly-erased flash sector, so every field fails validity and +// gets re-initialized to its documented default by config_valid(). +void config_default() { + memset(&config.body, 0xFF, sizeof(config.body)); + config_valid(); +} + bool config_save() { config.crc32 = calc_config_crc(config); alignas(4) uint8_t page[FLASH_PAGE_SIZE]; diff --git a/src/config.h b/src/config.h index 6945fb4..4cf4582 100644 --- a/src/config.h +++ b/src/config.h @@ -17,6 +17,12 @@ struct __attribute__((packed)) Config_body { uint8_t polling_rate_mode; // 0: 250Hz, 1: 500Hz, 2: real-time uint8_t audio_buffer_length; // [16,128] uint8_t controller_mode; // 0: DS5, 1: DSE, 2: Auto + uint8_t current_slot; // [0..3] active multi-pairing slot (OLED Edition Phase G) + // Audio Auto Haptics — derive haptic feedback from speaker audio for games that + // send no per-frame native haptic data. DSP borrowed from loteran/DS5Dongle 5d6bc2f. + uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace + uint8_t auto_haptics_gain; // [0,200] percent, default 100 + uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz }; struct __attribute__((packed)) Config { diff --git a/src/oled.cpp b/src/oled.cpp new file mode 100644 index 0000000..47cd226 --- /dev/null +++ b/src/oled.cpp @@ -0,0 +1,1219 @@ +#include "oled.h" +#include "oled_font.h" +#include "bt.h" +#include "slots.h" +#include "audio.h" +#include "config.h" + +#include +#include +#include "hardware/spi.h" +#include "hardware/gpio.h" +#include "hardware/watchdog.h" +#include "pico/time.h" + +extern uint8_t interrupt_in_data[63]; // defined in main.cpp + +namespace { + +constexpr uint kPinDC = 8; +constexpr uint kPinCS = 9; +constexpr uint kPinCLK = 10; +constexpr uint kPinMOSI = 11; +constexpr uint kPinRST = 12; +constexpr uint kPinKey0 = 15; +constexpr uint kPinKey1 = 17; + +constexpr int kW = 128; +constexpr int kH = 64; +constexpr int kRowBytes = kW / 8; +constexpr int kFbBytes = kRowBytes * kH; + +uint8_t fb[kFbBytes]; + +uint32_t last_render_us = 0; +constexpr uint32_t kFrameUs = 100000; +bool key0_prev = true; +bool key1_prev = true; +uint32_t key0_t_us = 0; +uint32_t key1_t_us = 0; +constexpr uint32_t kDebounceUs = 20000; + +uint32_t key0_first_press_us = 0; +bool key0_pending_single = false; +constexpr uint32_t kDoubleClickUs = 400000; + +// KEY1 long-press detection (for brightness cycling) +uint32_t key1_press_us = 0; +bool key1_was_pressed = false; +constexpr uint32_t kLongPressUs = 1500000; + +// Brightness levels (SH1107 contrast register 0x81). User cycles via KEY1 long-press. +constexpr uint8_t kBrightLevels[] = {0xFF, 0x7F, 0x3F, 0x10}; +constexpr int kNumBrightLevels = sizeof(kBrightLevels) / sizeof(kBrightLevels[0]); +int bright_idx = 0; +uint8_t current_contrast = 0xFF; + +// Auto-dim after idle. Tracks last button/input activity. +uint32_t last_activity_us = 0; +uint32_t last_input_hash = 0; +constexpr uint32_t kAutoDimUs = 5UL * 60UL * 1000000UL; // 5 min +constexpr uint8_t kDimContrast = 0x10; + +// Screen ordering — single source of truth. Reorder by editing this block; +// oled_loop's switch and handle_buttons' KEY1 contextual checks use these +// names, so the indices can move without touching that code. +constexpr int kScreenStatus = 0; +constexpr int kScreenSlots = 1; +constexpr int kScreenLightbar = 2; +constexpr int kScreenTriggers = 3; +constexpr int kScreenGyro = 4; +constexpr int kScreenTouchpad = 5; +constexpr int kScreenDiag = 6; +constexpr int kScreenRssi = 7; +constexpr int kScreenVU = 8; +constexpr int kScreenSettings = 9; +constexpr int kNumScreens = 10; +int current_screen = 0; + +// Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE +constexpr int kNumLbModes = 8; + +// Settings screen state +constexpr int kNumSettingsItems = 13; // 8 fields + 3 auto-haptic + Reset + Wipe +constexpr int kSettingsAutoHapEnaIdx = 8; +constexpr int kSettingsAutoHapGainIdx = 9; +constexpr int kSettingsAutoHapLpIdx = 10; +constexpr int kSettingsResetIdx = 11; +constexpr int kSettingsWipeSlotsIdx = 12; +Config_body settings_local{}; +int settings_sel = 0; +bool settings_dirty = false; +bool settings_init_done = false; +uint8_t settings_last_dpad = 8; // 8 = released +uint8_t settings_last_face = 0; +const char* settings_save_status = ""; // shown briefly after Triangle press + +// Factory-reset hold-Triangle-2s state. Borrowed from zurce/DS5Dongle-OLED's +// "hold to wipe" UX pattern (https://github.com/zurce/DS5Dongle-OLED). +uint32_t settings_tri_press_us = 0; +bool settings_reset_triggered = false; +constexpr uint32_t kResetHoldUs = 2000000; + +uint8_t lb_r = 0, lb_g = 0, lb_b = 0; + +// Lightbar mode + favorite slots: 0 = LIVE tilt preview; 1..4 = saved slots F0..F3 +int lb_mode = 0; +uint8_t lb_fav_r[4] = {255, 0, 0, 255}; // Red, Green, Blue, White defaults +uint8_t lb_fav_g[4] = {0, 255, 0, 255}; +uint8_t lb_fav_b[4] = {0, 0, 255, 255}; +uint8_t lb_last_face = 0; + +uint32_t rumble_off_at_us = 0; +bool rumble_active = false; +constexpr uint32_t kRumbleBurstUs = 250000; + +int trigger_preset = 0; +const char* const kTrigPresetNames[] = {"Off", "Feedback", "Weapon", "Vibration", "Bow", "Gallop", "Machine"}; +constexpr int kNumTrigPresets = 7; + +void cmd(uint8_t c) { + gpio_put(kPinDC, 0); + gpio_put(kPinCS, 0); + spi_write_blocking(spi1, &c, 1); + gpio_put(kPinCS, 1); +} + +void data_byte(uint8_t d) { + gpio_put(kPinDC, 1); + gpio_put(kPinCS, 0); + spi_write_blocking(spi1, &d, 1); + gpio_put(kPinCS, 1); +} + +uint8_t reverse_byte(uint8_t b) { + b = ((b & 0x55) << 1) | ((b & 0xAA) >> 1); + b = ((b & 0x33) << 2) | ((b & 0xCC) >> 2); + b = ((b & 0x0F) << 4) | ((b & 0xF0) >> 4); + return b; +} + +void hw_reset() { + gpio_put(kPinRST, 1); sleep_ms(100); + gpio_put(kPinRST, 0); sleep_ms(100); + gpio_put(kPinRST, 1); sleep_ms(100); +} + +void sh1107_set_contrast(uint8_t value) { + if (value == current_contrast) return; + current_contrast = value; + cmd(0x81); cmd(value); +} + +void sh1107_init() { + cmd(0xAE); + cmd(0x00); cmd(0x10); + cmd(0xB0); + cmd(0xDC); cmd(0x00); + cmd(0x81); cmd(0x6F); + cmd(0x21); + cmd(0xA0); + cmd(0xC0); + cmd(0xA4); + cmd(0xA6); + cmd(0xA8); cmd(0x3F); + cmd(0xD3); cmd(0x60); + cmd(0xD5); cmd(0x41); + cmd(0xD9); cmd(0x22); + cmd(0xDB); cmd(0x35); + cmd(0xAD); cmd(0x8A); + sleep_ms(50); + cmd(0xAF); +} + +void flush_fb() { + cmd(0xB0); + for (int j = 0; j < kH; j++) { + const uint8_t col = kH - 1 - j; + cmd(0x00 + (col & 0x0F)); + cmd(0x10 + (col >> 4)); + for (int i = 0; i < kRowBytes; i++) { + data_byte(reverse_byte(fb[j * kRowBytes + i])); + } + } +} + +void fb_clear() { memset(fb, 0, sizeof(fb)); } + +void px(int x, int y, bool on) { + if (x < 0 || x >= kW || y < 0 || y >= kH) return; + uint8_t *p = &fb[y * kRowBytes + (x / 8)]; + uint8_t m = 1 << (7 - (x % 8)); + if (on) *p |= m; else *p &= ~m; +} + +void rect_outline(int x, int y, int w, int h) { + for (int i = 0; i < w; i++) { px(x + i, y, true); px(x + i, y + h - 1, true); } + for (int i = 0; i < h; i++) { px(x, y + i, true); px(x + w - 1, y + i, true); } +} + +void rect_filled(int x, int y, int w, int h) { + for (int j = 0; j < h; j++) + for (int i = 0; i < w; i++) + px(x + i, y + j, true); +} + +void draw_char(int x, int y, char c) { + if (c < 0x20 || c > 0x7E) return; + const uint8_t *g = kFont5x7[c - 0x20]; + for (int col = 0; col < kFontW; col++) { + uint8_t bits = g[col]; + for (int row = 0; row < kFontH; row++) { + if (bits & (1 << row)) px(x + col, y + row, true); + } + } +} + +void draw_text(int x, int y, const char *s) { + while (*s) { + draw_char(x, y, *s++); + x += 6; + } +} + +// Pixel-art icon support. Visual approach inspired by zurce/DS5Dongle-OLED +// (https://github.com/zurce/DS5Dongle-OLED) — credit to zurce for the idea +// of decorating the OLED with small bitmaps instead of bare text/shapes. +// Bitmap layout: row-major, MSB = leftmost pixel, ceil(w/8) bytes per row. +void draw_icon(int x, int y, const uint8_t *bitmap, int w, int h) { + const int row_bytes = (w + 7) / 8; + for (int row = 0; row < h; row++) { + for (int col = 0; col < w; col++) { + const uint8_t byte = bitmap[row * row_bytes + (col / 8)]; + const uint8_t mask = (uint8_t)(1u << (7 - (col % 8))); + if (byte & mask) px(x + col, y + row, true); + } + } +} + +// 8x8 "link active" filled circle (drawn when DS5 is paired) +static const uint8_t kIconLinkOn[8] = { + 0b00111100, + 0b01111110, + 0b11111111, + 0b11111111, + 0b11111111, + 0b11111111, + 0b01111110, + 0b00111100, +}; +// 8x8 "link inactive" hollow circle (drawn when waiting for DS5) +static const uint8_t kIconLinkOff[8] = { + 0b00111100, + 0b01000010, + 0b10000001, + 0b10000001, + 0b10000001, + 0b10000001, + 0b01000010, + 0b00111100, +}; + +// Battery icon — body 52x8 + small nub on the right. Inside fill scales with pct. +void draw_battery_icon(int x, int y, int pct) { + rect_outline(x, y, 52, 8); + rect_filled(x + 52, y + 2, 3, 4); + int fill = (pct * 48) / 100; + if (fill < 0) fill = 0; + if (fill > 48) fill = 48; + if (fill > 0) rect_filled(x + 2, y + 2, fill, 4); +} + +void send_rumble(uint8_t amplitude) { + uint8_t pkt[78] = {}; + pkt[0] = 0x31; + pkt[1] = 0x00; + pkt[2] = 0x10; + pkt[3] = 0x03; + pkt[5] = amplitude; + pkt[6] = amplitude; + bt_write(pkt, sizeof(pkt)); +} + +void rumble_burst_tick(uint32_t now) { + if (rumble_active && (int32_t)(now - rumble_off_at_us) >= 0) { + send_rumble(0); + rumble_active = false; + } +} + +// Trigger effect param format follows dualsensectl's reverse-engineering. +// Modes 0x21/0x25/0x26 use bitpacked 10-zone arrays, not raw position bytes. +void send_trigger_effect(int preset) { + uint8_t pkt[78] = {}; + pkt[0] = 0x31; + pkt[2] = 0x10; + pkt[3] = 0x0C; // valid_flag0: RIGHT_TRIGGER_MOTOR_ENABLE | LEFT_TRIGGER_MOTOR_ENABLE + + uint8_t mode = 0x05; // OFF + uint8_t p[9] = {0}; + + switch (preset) { + case 0: // Off + mode = 0x05; + break; + case 1: { // Feedback — all 10 zones at max strength 8 + mode = 0x21; + const uint16_t active = 0x03FF; + uint32_t strength = 0; + for (int i = 0; i < 10; i++) strength |= (uint32_t)(7u << (3 * i)); + p[0] = active & 0xFF; + p[1] = (active >> 8) & 0xFF; + p[2] = strength & 0xFF; + p[3] = (strength >> 8) & 0xFF; + p[4] = (strength >> 16) & 0xFF; + p[5] = (strength >> 24) & 0xFF; + break; + } + case 2: { // Weapon — snap between positions 3 and 5, force 8 + mode = 0x25; + const uint16_t start_stop = (1u << 3) | (1u << 5); + p[0] = start_stop & 0xFF; + p[1] = (start_stop >> 8) & 0xFF; + p[2] = 7; // force = strength - 1 + break; + } + case 3: { // Vibration — all 10 zones at amplitude 8, frequency 30 Hz + mode = 0x26; + const uint16_t active = 0x03FF; + uint32_t strength = 0; + for (int i = 0; i < 10; i++) strength |= (uint32_t)(7u << (3 * i)); + p[0] = active & 0xFF; + p[1] = (active >> 8) & 0xFF; + p[2] = strength & 0xFF; + p[3] = (strength >> 8) & 0xFF; + p[4] = (strength >> 16) & 0xFF; + p[5] = (strength >> 24) & 0xFF; + p[8] = 30; + break; + } + case 4: { // Bow — drawing resistance + snap at position 6 + mode = 0x22; + const uint16_t start_stop = (1u << 2) | (1u << 6); + const uint8_t force_pair = 7u | (7u << 3); // strength=8, snap=8 + p[0] = start_stop & 0xFF; + p[1] = (start_stop >> 8) & 0xFF; + p[2] = force_pair; + break; + } + case 5: { // Galloping + mode = 0x23; + const uint16_t start_stop = (1u << 0) | (1u << 9); + const uint8_t ratio = (5u & 0x07) | ((1u & 0x07) << 3); + p[0] = start_stop & 0xFF; + p[1] = (start_stop >> 8) & 0xFF; + p[2] = ratio; + p[3] = 5; // frequency + break; + } + case 6: { // Machine gun + mode = 0x27; + const uint16_t start_stop = (1u << 1) | (1u << 8); + const uint8_t force_pair = 7u | (7u << 3); + p[0] = start_stop & 0xFF; + p[1] = (start_stop >> 8) & 0xFF; + p[2] = force_pair; + p[3] = 20; // frequency + p[4] = 0; // period + break; + } + } + + pkt[13] = mode; + for (int i = 0; i < 9; i++) pkt[14 + i] = p[i]; + pkt[24] = mode; + for (int i = 0; i < 9; i++) pkt[25 + i] = p[i]; + + bt_write(pkt, sizeof(pkt)); +} + +void send_lightbar_color(uint8_t r, uint8_t g, uint8_t b); + +void handle_buttons() { + const uint32_t now = time_us_32(); + const bool k0 = gpio_get(kPinKey0); + const bool k1 = gpio_get(kPinKey1); + if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) { + key0_t_us = now; + if (key0_pending_single && (now - key0_first_press_us) < kDoubleClickUs) { + key0_pending_single = false; + watchdog_reboot(0, 0, 0); + } else { + key0_pending_single = true; + key0_first_press_us = now; + } + } + if (key0_pending_single && (now - key0_first_press_us) > kDoubleClickUs) { + key0_pending_single = false; + current_screen = (current_screen + 1) % kNumScreens; + last_render_us = 0; + } + // KEY1: track press time, decide on release whether it was short or long + if (!k1 && key1_prev && (now - key1_t_us) > kDebounceUs) { + key1_t_us = now; + key1_press_us = now; + key1_was_pressed = true; + last_activity_us = now; + } + if (k1 && !key1_prev && key1_was_pressed) { + key1_was_pressed = false; + const uint32_t held = now - key1_press_us; + last_activity_us = now; + if (held > kLongPressUs) { + // Long press: cycle brightness level + bright_idx = (bright_idx + 1) % kNumBrightLevels; + } else { + // Short press: contextual per screen. On Trigger Test / Lightbar + // KEY1 stays as the primary in-screen cycle. Everywhere else it + // acts as a "back" button — cycles to the previous screen so + // KEY0/KEY1 form a natural forward/back pair. + if (current_screen == kScreenTriggers) { + trigger_preset = (trigger_preset + 1) % kNumTrigPresets; + send_trigger_effect(trigger_preset); + } else if (current_screen == kScreenLightbar) { + lb_mode = (lb_mode + 1) % kNumLbModes; + } else { + current_screen = (current_screen - 1 + kNumScreens) % kNumScreens; + last_render_us = 0; + } + } + } + key0_prev = k0; + key1_prev = k1; +} + +__attribute__((noinline)) void render_screen() { + fb_clear(); + + const bool connected = bt_is_connected(); + + draw_text(0, 0, "DS5 Bridge v0.5.4"); + draw_icon(120, 0, connected ? kIconLinkOn : kIconLinkOff, 8, 8); + + if (connected) { + uint8_t a[6]; + bt_get_addr(a); + char buf[24]; + snprintf(buf, sizeof(buf), "%02X:%02X:%02X:%02X:%02X:%02X", + a[0], a[1], a[2], a[3], a[4], a[5]); + draw_text(0, 9, buf); + + const uint8_t pwr = interrupt_in_data[52]; + int pct = (pwr & 0x0F) * 10; + if (pct > 100) pct = 100; + const uint8_t pstate = pwr >> 4; + char marker = ' '; + if (pstate == 1) marker = '+'; // Charging + else if (pstate == 2) marker = '*'; // Complete + else if (pstate >= 0xA) marker = '!'; // Error + char bbuf[16]; + snprintf(bbuf, sizeof(bbuf), "%3d%%%c", pct, marker); + draw_text(0, 18, bbuf); + draw_battery_icon(30, 18, pct); + + rect_outline(0, 30, 32, 32); + int lx = 2 + (interrupt_in_data[0] * 27) / 255; + int ly = 32 + (interrupt_in_data[1] * 27) / 255; + rect_filled(lx - 1, ly - 1, 3, 3); + + rect_outline(96, 30, 32, 32); + int rx = 98 + (interrupt_in_data[2] * 27) / 255; + int ry = 32 + (interrupt_in_data[3] * 27) / 255; + rect_filled(rx - 1, ry - 1, 3, 3); + + // L2/R2 analog trigger bars (vertical, fill from bottom) + rect_outline(32, 33, 4, 29); + const int l2_fill = (interrupt_in_data[4] * 27) / 255; + if (l2_fill > 0) rect_filled(33, 61 - l2_fill, 2, l2_fill); + rect_outline(92, 33, 4, 29); + const int r2_fill = (interrupt_in_data[5] * 27) / 255; + if (r2_fill > 0) rect_filled(93, 61 - r2_fill, 2, r2_fill); + + const uint8_t b7 = interrupt_in_data[7]; + const uint8_t b8 = interrupt_in_data[8]; + + // D-pad indicator (4 directions; lit for primary + diagonals) + const int dp = b7 & 0x0F; + const bool dp_n = (dp == 7 || dp == 0 || dp == 1); + const bool dp_e = (dp == 1 || dp == 2 || dp == 3); + const bool dp_s = (dp == 3 || dp == 4 || dp == 5); + const bool dp_w = (dp == 5 || dp == 6 || dp == 7); + const int dcx = 46, dcy = 46; + auto dot = [&](int dx, int dy, bool on) { + if (on) rect_filled(dcx + dx - 2, dcy + dy - 2, 5, 5); + else rect_outline(dcx + dx - 2, dcy + dy - 2, 5, 5); + }; + dot(0, -7, dp_n); + dot(7, 0, dp_e); + dot(0, 7, dp_s); + dot(-7, 0, dp_w); + + const int fcx = 64, fcy = 46; + auto sq = [&](int dx, int dy, bool on) { + if (on) rect_filled(fcx + dx - 2, fcy + dy - 2, 5, 5); + else rect_outline(fcx + dx - 2, fcy + dy - 2, 5, 5); + }; + // shift face buttons right so they don't collide with d-pad + const int fcx_off = 18; + sq(fcx_off + 0, -8, b7 & 0x80); // Triangle + sq(fcx_off + 8, 0, b7 & 0x40); // Circle + sq(fcx_off + 0, 8, b7 & 0x20); // Cross + sq(fcx_off - 8, 0, b7 & 0x10); // Square + + if (b8 & 0x01) rect_filled(36, 30, 12, 3); else rect_outline(36, 30, 12, 3); // L1 + if (b8 & 0x02) rect_filled(80, 30, 12, 3); else rect_outline(80, 30, 12, 3); // R1 + } else { + draw_text(0, 14, "Pair your DualSense:"); + draw_text(0, 26, "1. Hold Create + PS"); + draw_text(0, 36, "2. Wait for light bar"); + draw_text(0, 46, " to flash blue"); + } + + flush_fb(); +} + +__attribute__((noinline)) void render_screen_rssi() { + fb_clear(); + draw_text(0, 0, "BT Signal"); + if (bt_is_connected()) { + int8_t rssi = 0; + bt_get_signal_strength(&rssi); + char buf[24]; + snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)rssi); + draw_text(0, 12, buf); + + // Map RSSI range -90..-40 dBm to 0..100% bar + int pct = ((int)rssi + 90) * 100 / 50; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + snprintf(buf, sizeof(buf), "Quality: %d%%", pct); + draw_text(0, 22, buf); + rect_outline(0, 34, 128, 10); + int fill = (pct * 124) / 100; + if (fill > 0) rect_filled(2, 36, fill, 6); + + const char *label = "Poor"; + if (rssi > -55) label = "Excellent"; + else if (rssi > -65) label = "Good"; + else if (rssi > -75) label = "Fair"; + snprintf(buf, sizeof(buf), "Link: %s", label); + draw_text(0, 48, buf); + } else { + draw_text(0, 30, "(no controller)"); + } + draw_text(0, 56, "K0=next"); + flush_fb(); +} + +__attribute__((noinline)) void render_screen_diag() { + fb_clear(); + + draw_text(0, 0, "Diagnostics"); + + const uint32_t uptime_s = time_us_32() / 1000000u; + const uint32_t h = uptime_s / 3600u; + const uint32_t m = (uptime_s / 60u) % 60u; + const uint32_t s = uptime_s % 60u; + char buf[24]; + snprintf(buf, sizeof(buf), "Up:%luh %02lum %02lus", (unsigned long)h, (unsigned long)m, (unsigned long)s); + draw_text(0, 9, buf); + + // Per-second rates for the audio path counters — recompute every render. + static uint32_t prev_us_frames = 0, prev_bt_packets = 0; + static uint32_t prev_sample_us = 0; + const uint32_t now_us = time_us_32(); + const uint32_t cur_us_frames = audio_usb_frames(); + const uint32_t cur_bt_packets = audio_bt_packets(); + uint32_t usb_rate = 0, bt_rate = 0; + if (prev_sample_us != 0 && now_us > prev_sample_us) { + const uint32_t dt_us = now_us - prev_sample_us; + if (dt_us > 0) { + usb_rate = (uint32_t)(((uint64_t)(cur_us_frames - prev_us_frames) * 1000000u) / dt_us); + bt_rate = (uint32_t)(((uint64_t)(cur_bt_packets - prev_bt_packets) * 1000000u) / dt_us); + } + } + prev_us_frames = cur_us_frames; + prev_bt_packets = cur_bt_packets; + prev_sample_us = now_us; + + snprintf(buf, sizeof(buf), "USB aud %lu/s", (unsigned long)usb_rate); + draw_text(0, 18, buf); + snprintf(buf, sizeof(buf), "BT 0x32 %lu/s", (unsigned long)bt_rate); + draw_text(0, 27, buf); + snprintf(buf, sizeof(buf), "HCI errs: %lu", (unsigned long)bt_hci_err_count()); + draw_text(0, 36, buf); + + snprintf(buf, sizeof(buf), "BT: %s", bt_is_connected() ? "connected" : "waiting"); + draw_text(0, 45, buf); + + draw_text(0, 56, "K0=next K1=back"); + + flush_fb(); +} + +__attribute__((noinline)) void render_screen_triggers() { + fb_clear(); + draw_text(0, 0, "Trigger Test"); + + char buf[24]; + snprintf(buf, sizeof(buf), "Mode: %s", kTrigPresetNames[trigger_preset]); + draw_text(0, 12, buf); + + if (bt_is_connected()) { + const uint8_t l2 = interrupt_in_data[4]; + const uint8_t r2 = interrupt_in_data[5]; + snprintf(buf, sizeof(buf), "L2:%3d R2:%3d", l2, r2); + draw_text(0, 24, buf); + + rect_outline(0, 35, 60, 9); + int lfill = (l2 * 56) / 255; + if (lfill > 0) rect_filled(2, 37, lfill, 5); + rect_outline(68, 35, 60, 9); + int rfill = (r2 * 56) / 255; + if (rfill > 0) rect_filled(70, 37, rfill, 5); + } else { + draw_text(0, 24, "(no controller)"); + } + + draw_text(0, 56, "K0=next K1=cycle"); + flush_fb(); +} + +__attribute__((noinline)) void render_screen_gyro() { + fb_clear(); + draw_text(0, 0, "Gyro Tilt"); + if (bt_is_connected()) { + int16_t ax, ay, az; + memcpy(&ax, &interrupt_in_data[21], 2); + memcpy(&ay, &interrupt_in_data[23], 2); + memcpy(&az, &interrupt_in_data[25], 2); + char buf[16]; + snprintf(buf, sizeof(buf), "X%+5d", ax); draw_text(0, 10, buf); + snprintf(buf, sizeof(buf), "Y%+5d", ay); draw_text(44, 10, buf); + snprintf(buf, sizeof(buf), "Z%+5d", az); draw_text(88, 10, buf); + + const int bx = 44, by = 22, bw = 40, bh = 40; + rect_outline(bx, by, bw, bh); + for (int x = bx + 1; x < bx + bw - 1; x++) px(x, by + bh / 2, true); + for (int y = by + 1; y < by + bh - 1; y++) px(bx + bw / 2, y, true); + int dx = ((int)ax * (bw / 2 - 3)) / 8192; + int dy = ((int)ay * (bh / 2 - 3)) / 8192; + int cx = bx + bw / 2 + dx; + int cy = by + bh / 2 + dy; + if (cx < bx + 2) cx = bx + 2; + if (cx > bx + bw - 3) cx = bx + bw - 3; + if (cy < by + 2) cy = by + 2; + if (cy > by + bh - 3) cy = by + bh - 3; + rect_filled(cx - 1, cy - 1, 3, 3); + } else { + draw_text(0, 30, "(no controller)"); + } + flush_fb(); +} + +__attribute__((noinline)) void render_screen_touchpad() { + fb_clear(); + draw_text(0, 0, "Touchpad"); + if (bt_is_connected()) { + rect_outline(4, 12, 120, 30); + int active = 0; + for (int finger = 0; finger < 2; finger++) { + const int off = 32 + finger * 4; + const uint32_t f = (uint32_t)interrupt_in_data[off] | + ((uint32_t)interrupt_in_data[off + 1] << 8) | + ((uint32_t)interrupt_in_data[off + 2] << 16) | + ((uint32_t)interrupt_in_data[off + 3] << 24); + const bool not_touching = (f >> 7) & 1u; + if (not_touching) continue; + const uint16_t fx = (f >> 8) & 0xFFFu; + const uint16_t fy = (f >> 20) & 0xFFFu; + int sx = 5 + ((int)fx * 114) / 1919; + int sy = 13 + ((int)fy * 26) / 1079; + if (sx < 5) sx = 5; + if (sx > 122) sx = 122; + if (sy < 13) sy = 13; + if (sy > 40) sy = 40; + rect_filled(sx - 1, sy - 1, 3, 3); + active++; + } + char buf[20]; + snprintf(buf, sizeof(buf), "Fingers: %d", active); + draw_text(0, 46, buf); + } else { + draw_text(0, 30, "(no controller)"); + } + draw_text(0, 56, "K0=next"); + flush_fb(); +} + +void send_lightbar_color(uint8_t r, uint8_t g, uint8_t b) { + uint8_t pkt[78] = {}; + pkt[0] = 0x31; + pkt[2] = 0x10; + pkt[4] = 0x04; // valid_flag1: LIGHTBAR_CONTROL_ENABLE (bit 2) + pkt[47] = r; // lightbar_red + pkt[48] = g; // lightbar_green + pkt[49] = b; // lightbar_blue + bt_write(pkt, sizeof(pkt)); +} + +// Tiny 32-step sine LUT (no ). angle 0..255 → amplitude -127..127. +static const int8_t kSine32[32] = { + 0, 24, 49, 70, 90, 106, 117, 125, 127, 125, 117, 106, 90, 70, 49, 24, + 0, -24, -49, -70, -90, -106, -117, -125, -127, -125, -117, -106, -90, -70, -49, -24, +}; +int sin_lut(uint8_t a) { return kSine32[(a >> 3) & 0x1F]; } + +void hsv_to_rgb(uint16_t h, uint8_t s, uint8_t v, uint8_t *r, uint8_t *g, uint8_t *b) { + if (h >= 360) h %= 360; + const uint8_t region = (uint8_t)(h / 60); + const uint16_t remainder = (uint16_t)((h - region * 60u) * 256u / 60u); + const uint8_t p = (uint8_t)(((uint16_t)v * (255u - s)) >> 8); + const uint8_t q = (uint8_t)(((uint16_t)v * (255u - (((uint16_t)s * remainder) >> 8))) >> 8); + const uint8_t t = (uint8_t)(((uint16_t)v * (255u - (((uint16_t)s * (255u - remainder)) >> 8))) >> 8); + switch (region) { + case 0: *r = v; *g = t; *b = p; break; + case 1: *r = q; *g = v; *b = p; break; + case 2: *r = p; *g = v; *b = t; break; + case 3: *r = p; *g = q; *b = v; break; + case 4: *r = t; *g = p; *b = v; break; + default: *r = v; *g = p; *b = q; break; + } +} + +const char* lb_mode_tag(int mode) { + switch (mode) { + case 0: return "[LIVE]"; + case 1: return "[FAV0]"; + case 2: return "[FAV1]"; + case 3: return "[FAV2]"; + case 4: return "[FAV3]"; + case 5: return "[BREA]"; + case 6: return "[RAIN]"; + case 7: return "[FADE]"; + default: return "[????]"; + } +} + +__attribute__((noinline)) void render_screen_lightbar() { + fb_clear(); + draw_text(0, 0, "Lightbar"); + draw_text(86, 0, lb_mode_tag(lb_mode)); + + if (bt_is_connected()) { + const uint32_t now_ms = time_us_32() / 1000; + if (lb_mode == 0) { + // LIVE: tilt -> RGB + int16_t ax, ay, az; + memcpy(&ax, &interrupt_in_data[21], 2); + memcpy(&ay, &interrupt_in_data[23], 2); + memcpy(&az, &interrupt_in_data[25], 2); + const int rr = ((int)ax + 8192) * 255 / 16384; + const int gg = ((int)ay + 8192) * 255 / 16384; + const int bb = ((int)az + 8192) * 255 / 16384; + lb_r = (uint8_t)(rr < 0 ? 0 : rr > 255 ? 255 : rr); + lb_g = (uint8_t)(gg < 0 ? 0 : gg > 255 ? 255 : gg); + lb_b = (uint8_t)(bb < 0 ? 0 : bb > 255 ? 255 : bb); + } else if (lb_mode <= 4) { + // FAV slot: fixed color + const int slot = lb_mode - 1; + lb_r = lb_fav_r[slot]; + lb_g = lb_fav_g[slot]; + lb_b = lb_fav_b[slot]; + } else if (lb_mode == 5) { + // BREATHING: modulate FAV0 brightness with a sine wave (~3 s cycle) + const uint8_t phase = (uint8_t)(now_ms / 12); + const int s = sin_lut(phase); // -127..127 + const uint16_t scale = (uint16_t)(32 + (s + 127) / 2); // 32..191 + lb_r = (uint8_t)((lb_fav_r[0] * scale) / 255); + lb_g = (uint8_t)((lb_fav_g[0] * scale) / 255); + lb_b = (uint8_t)((lb_fav_b[0] * scale) / 255); + } else if (lb_mode == 6) { + // RAINBOW: hue sweep over ~6 s + const uint16_t hue = (uint16_t)((now_ms / 17) % 360); + hsv_to_rgb(hue, 255, 255, &lb_r, &lb_g, &lb_b); + } else { + // FADE between FAV slots, 2 s per slot + const uint32_t kSlotMs = 2000; + const uint32_t total = now_ms % (4 * kSlotMs); + const int slot = (int)(total / kSlotMs); + const int next = (slot + 1) & 3; + const uint16_t blend = (uint16_t)(((total - slot * kSlotMs) * 256u) / kSlotMs); + lb_r = (uint8_t)((lb_fav_r[slot] * (255 - blend) + lb_fav_r[next] * blend) / 255); + lb_g = (uint8_t)((lb_fav_g[slot] * (255 - blend) + lb_fav_g[next] * blend) / 255); + lb_b = (uint8_t)((lb_fav_b[slot] * (255 - blend) + lb_fav_b[next] * blend) / 255); + } + + char buf[16]; + snprintf(buf, sizeof(buf), "R:%3u", lb_r); draw_text(0, 12, buf); + snprintf(buf, sizeof(buf), "G:%3u", lb_g); draw_text(44, 12, buf); + snprintf(buf, sizeof(buf), "B:%3u", lb_b); draw_text(88, 12, buf); + + const int by = 22, bh = 8; + rect_outline(0, by, 40, bh); int rf = (lb_r * 36) / 255; if (rf > 0) rect_filled(2, by + 2, rf, bh - 4); + rect_outline(44, by, 40, bh); int gf = (lb_g * 36) / 255; if (gf > 0) rect_filled(46, by + 2, gf, bh - 4); + rect_outline(88, by, 40, bh); int bf = (lb_b * 36) / 255; if (bf > 0) rect_filled(90, by + 2, bf, bh - 4); + + // Face button rising-edge -> save current color to slot 0..3 + const uint8_t face = interrupt_in_data[7] & 0xF0; + const uint8_t pressed = face & ~lb_last_face; + lb_last_face = face; + int save_slot = -1; + if (pressed & 0x80) save_slot = 0; // Triangle + else if (pressed & 0x40) save_slot = 1; // Circle + else if (pressed & 0x20) save_slot = 2; // Cross + else if (pressed & 0x10) save_slot = 3; // Square + if (save_slot >= 0) { + lb_fav_r[save_slot] = lb_r; + lb_fav_g[save_slot] = lb_g; + lb_fav_b[save_slot] = lb_b; + } + + draw_text(0, 38, "Sv:T=0 C=1 X=2 S=3"); + const char* hint = + (lb_mode == 0) ? "Tilt = R/G/B" : + (lb_mode == 5) ? "Breathing FAV0" : + (lb_mode == 6) ? "Rainbow sweep" : + (lb_mode == 7) ? "Fade thru FAVs" : + "Locked to fav"; + draw_text(0, 48, hint); + + send_lightbar_color(lb_r, lb_g, lb_b); + } else { + draw_text(0, 30, "(no controller)"); + } + draw_text(0, 56, "K0=next K1=cycle"); + flush_fb(); +} + +__attribute__((noinline)) void render_screen_vu() { + fb_clear(); + draw_text(0, 0, "Audio Meters"); + if (bt_is_connected()) { + const uint8_t spk = audio_peak_speaker(); + const uint8_t hap = audio_peak_haptic(); + char buf[16]; + snprintf(buf, sizeof(buf), "SPK %3u", spk); + draw_text(0, 14, buf); + rect_outline(48, 14, 80, 8); + int sfill = (spk * 76) / 255; + if (sfill > 0) rect_filled(50, 16, sfill, 4); + + snprintf(buf, sizeof(buf), "HAP %3u", hap); + draw_text(0, 28, buf); + rect_outline(48, 28, 80, 8); + int hfill = (hap * 76) / 255; + if (hfill > 0) rect_filled(50, 30, hfill, 4); + + draw_text(0, 42, "Live USB audio peaks"); + } else { + draw_text(0, 30, "(no controller)"); + } + draw_text(0, 56, "K0=next"); + flush_fb(); +} + +void settings_adjust(int delta) { + Config_body &c = settings_local; + settings_dirty = true; + switch (settings_sel) { + case 0: { // haptics_gain [1.0, 2.0] step 0.1 + int v = (int)(c.haptics_gain * 10.0f + 0.5f) + delta; + if (v < 10) v = 10; if (v > 20) v = 20; + c.haptics_gain = v / 10.0f; + break; + } + case 1: { // speaker_volume [-100, 0] step 5 + int v = (int)c.speaker_volume + delta * 5; + if (v < -100) v = -100; if (v > 0) v = 0; + c.speaker_volume = (float)v; + break; + } + case 2: { // inactive_time [10, 60] step 5 + int v = (int)c.inactive_time + delta * 5; + if (v < 10) v = 10; if (v > 60) v = 60; + c.inactive_time = (uint8_t)v; + break; + } + case 3: c.disable_inactive_disconnect ^= 1; break; + case 4: c.disable_pico_led ^= 1; break; + case 5: { // polling_rate_mode 0..2 + int v = (int)c.polling_rate_mode + delta; + if (v < 0) v = 2; if (v > 2) v = 0; + c.polling_rate_mode = (uint8_t)v; + break; + } + case 6: { // audio_buffer_length [16, 128] step 4 + int v = (int)c.audio_buffer_length + delta * 4; + if (v < 16) v = 16; if (v > 128) v = 128; + c.audio_buffer_length = (uint8_t)v; + break; + } + case 7: { // controller_mode 0..2 + int v = (int)c.controller_mode + delta; + if (v < 0) v = 2; if (v > 2) v = 0; + c.controller_mode = (uint8_t)v; + break; + } + case 8: { // auto_haptics_enable 0..3 + int v = (int)c.auto_haptics_enable + delta; + if (v < 0) v = 3; if (v > 3) v = 0; + c.auto_haptics_enable = (uint8_t)v; + break; + } + case 9: { // auto_haptics_gain [0, 200] step 10 + int v = (int)c.auto_haptics_gain + delta * 10; + if (v < 0) v = 0; if (v > 200) v = 200; + c.auto_haptics_gain = (uint8_t)v; + break; + } + case 10: { // auto_haptics_lowpass 0..3 + int v = (int)c.auto_haptics_lowpass + delta; + if (v < 0) v = 3; if (v > 3) v = 0; + c.auto_haptics_lowpass = (uint8_t)v; + break; + } + } +} + +void settings_handle_input() { + if (!bt_is_connected()) return; + const uint8_t dpad = (uint8_t)(interrupt_in_data[7] & 0x0F); + const uint8_t face = (uint8_t)(interrupt_in_data[7] & 0xF0); + + // Edge-trigger on D-pad direction CHANGE; only pure N/E/S/W to avoid diagonals + if (dpad != settings_last_dpad && dpad != 8) { + if (dpad == 0) settings_sel = (settings_sel - 1 + kNumSettingsItems) % kNumSettingsItems; + else if (dpad == 4) settings_sel = (settings_sel + 1) % kNumSettingsItems; + else if (dpad == 6) settings_adjust(-1); + else if (dpad == 2) settings_adjust(+1); + } + settings_last_dpad = dpad; + + // Triangle handling — Reset and Wipe-slots items both require a 2 s hold; + // every other item saves edits on a normal short press. + const bool tri_now = (face & 0x80) != 0; + const bool tri_prev = (settings_last_face & 0x80) != 0; + const bool is_hold_item = (settings_sel == kSettingsResetIdx + || settings_sel == kSettingsWipeSlotsIdx); + if (tri_now && !tri_prev) { + settings_tri_press_us = (uint32_t)time_us_32(); + settings_reset_triggered = false; + } + if (is_hold_item && tri_now && !settings_reset_triggered + && ((uint32_t)time_us_32() - settings_tri_press_us) >= kResetHoldUs) { + settings_reset_triggered = true; + if (settings_sel == kSettingsResetIdx) { + config_default(); + if (config_save()) { + settings_local = get_config(); + settings_dirty = false; + settings_save_status = "Reset!"; + } else { + settings_save_status = "Reset FAIL"; + } + } else { + bt_wipe_all_slots(); + settings_save_status = "Slots wiped!"; + } + } + if (!tri_now && tri_prev) { + if (!is_hold_item && !settings_reset_triggered) { + set_config(settings_local); + settings_save_status = config_save() ? "Saved!" : "Save FAIL"; + if (settings_save_status[0] == 'S' && settings_save_status[1] == 'a') { + settings_dirty = false; + } + } + settings_reset_triggered = false; + } + settings_last_face = face; +} + +__attribute__((noinline)) void format_settings_item(int idx, char* line, size_t n) { + const Config_body &c = settings_local; + const char *cur = (idx == settings_sel) ? ">" : " "; + switch (idx) { + case 0: { + int g = (int)(c.haptics_gain * 10.0f + 0.5f); + snprintf(line, n, "%s Hap Gain %d.%dx", cur, g / 10, g % 10); + break; + } + case 1: snprintf(line, n, "%s Spk Vol %ddB", cur, (int)c.speaker_volume); break; + case 2: snprintf(line, n, "%s Inact %umin", cur, c.inactive_time); break; + case 3: snprintf(line, n, "%s InactDC %s", cur, c.disable_inactive_disconnect ? "off" : "on"); break; + case 4: snprintf(line, n, "%s Pico LED %s", cur, c.disable_pico_led ? "off" : "on"); break; + case 5: { + const char* names[3] = {"250Hz", "500Hz", "RT"}; + snprintf(line, n, "%s Poll %s", cur, names[c.polling_rate_mode % 3]); + break; + } + case 6: snprintf(line, n, "%s AudBuf %u", cur, c.audio_buffer_length); break; + case 7: { + const char* names[3] = {"DS5", "DSE", "Auto"}; + snprintf(line, n, "%s Ctrl %s", cur, names[c.controller_mode % 3]); + break; + } + case 8: { + const char* names[4] = {"Off", "Fallback", "Mix", "Replace"}; + snprintf(line, n, "%s AutoHap %s", cur, names[c.auto_haptics_enable & 3]); + break; + } + case 9: snprintf(line, n, "%s AH Gain %u%%", cur, c.auto_haptics_gain); break; + case 10: { + const char* names[4] = {"80Hz", "160Hz", "250Hz", "400Hz"}; + snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]); + break; + } + case 11: snprintf(line, n, "%s Reset to defaults", cur); break; + case 12: snprintf(line, n, "%s Wipe all slots", cur); break; + } +} + +__attribute__((noinline)) void render_screen_settings() { + if (!settings_init_done) { + settings_local = get_config(); + settings_init_done = true; + } + settings_handle_input(); + + fb_clear(); + char buf[24]; + snprintf(buf, sizeof(buf), "Settings %s", settings_dirty ? "(*)" : " "); + draw_text(0, 0, buf); + if (settings_save_status[0]) { + draw_text(86, 0, settings_save_status); + } + + constexpr int kVisible = 5; + int top = 0; + if (settings_sel >= kVisible) top = settings_sel - kVisible + 1; + char line[28]; + for (int i = 0; i < kVisible && top + i < kNumSettingsItems; i++) { + format_settings_item(top + i, line, sizeof(line)); + draw_text(0, 9 + i * 9, line); + } + + if (settings_sel == kSettingsResetIdx) { + draw_text(0, 56, "Hold Tri 2s = RESET"); + } else if (settings_sel == kSettingsWipeSlotsIdx) { + draw_text(0, 56, "Hold Tri 2s = WIPE"); + } else { + draw_text(0, 56, "DP nav/adj Tri=save"); + } + flush_fb(); +} + +// ---- Slots screen (Phase G) ---------------------------------------------- +// Multi-slot persistent pairing UI. Modeled on zurce/DS5Dongle-OLED. +// Credit to zurce. + +int slots_cursor = -1; // initialized to active slot on first entry +uint8_t slots_last_dpad = 8; +uint8_t slots_last_face = 0; +uint32_t slots_sq_press_us = 0; +bool slots_wipe_triggered = false; +const char* slots_status = ""; +uint32_t slots_status_until_us = 0; +constexpr uint32_t kSlotsWipeHoldUs = 1500000; // 1.5 s + +void slots_handle_input() { + if (slots_cursor < 0) slots_cursor = bt_get_slot(); + if (!bt_is_connected()) { + // Even without a DS5 connected we still want to navigate / wipe; + // we just can't read the controller's D-pad / face inputs. Return + // here and require KEY0/KEY1 for screen switching. + slots_last_dpad = 8; + slots_last_face = 0; + return; + } + const uint8_t dpad = (uint8_t)(interrupt_in_data[7] & 0x0F); + const uint8_t face = (uint8_t)(interrupt_in_data[7] & 0xF0); + + if (dpad != slots_last_dpad && dpad != 8) { + if (dpad == 0) slots_cursor = (slots_cursor - 1 + kNumSlots) % kNumSlots; + else if (dpad == 4) slots_cursor = (slots_cursor + 1) % kNumSlots; + } + slots_last_dpad = dpad; + + // Triangle rising edge: switch to cursor slot if different from active + const bool tri_now = (face & 0x80) != 0; + const bool tri_prev = (slots_last_face & 0x80) != 0; + if (tri_now && !tri_prev) { + if (slots_cursor != bt_get_slot()) { + bt_set_slot(slots_cursor); + slots_status = "Switched!"; + slots_status_until_us = (uint32_t)time_us_32() + 1500000; + } + } + + // Square hold 1.5 s: wipe cursor slot + const bool sq_now = (face & 0x10) != 0; + const bool sq_prev = (slots_last_face & 0x10) != 0; + if (sq_now && !sq_prev) { + slots_sq_press_us = (uint32_t)time_us_32(); + slots_wipe_triggered = false; + } + if (sq_now && !slots_wipe_triggered + && ((uint32_t)time_us_32() - slots_sq_press_us) >= kSlotsWipeHoldUs) { + slots_wipe_triggered = true; + bt_forget_slot(slots_cursor); + slots_status = "Wiped!"; + slots_status_until_us = (uint32_t)time_us_32() + 1500000; + } + if (!sq_now && sq_prev) slots_wipe_triggered = false; + + slots_last_face = face; +} + +__attribute__((noinline)) void render_screen_slots() { + slots_handle_input(); + if (slots_cursor < 0) slots_cursor = bt_get_slot(); + + fb_clear(); + char hdr[24]; + const int active = bt_get_slot(); + const bool conn = bt_is_connected(); + snprintf(hdr, sizeof(hdr), "Slots [s%d %s]", active, conn ? "ON" : "--"); + draw_text(0, 0, hdr); + + if (slots_status[0] && (uint32_t)time_us_32() < slots_status_until_us) { + draw_text(80, 0, slots_status); + } + + for (int i = 0; i < kNumSlots; i++) { + char line[28]; + const char *cursor_mark = (i == slots_cursor) ? ">" : " "; + const char *active_mark = (i == active) ? "*" : " "; + if (slot_occupied(i)) { + uint8_t a[6]; + slot_get_addr(i, a); + snprintf(line, sizeof(line), "%s%d%s %02X:%02X:%02X:%02X:%02X:%02X", + cursor_mark, i, active_mark, a[0], a[1], a[2], a[3], a[4], a[5]); + } else { + snprintf(line, sizeof(line), "%s%d%s (empty)", cursor_mark, i, active_mark); + } + draw_text(0, 9 + i * 9, line); + } + + draw_text(0, 56, "Tri=switch Sq hold=wipe"); + flush_fb(); +} + +void boot_splash() { + fb_clear(); + auto cx_for = [](const char* s) { + int n = 0; while (s[n]) n++; + return (128 - (n * 6 - 1)) / 2; + }; + const char* l1 = "DS5 Bridge"; + const char* l2 = "v0.5.4"; + const char* l3 = "Pico2W + OLED"; + draw_text(cx_for(l1), 16, l1); + draw_text(cx_for(l2), 30, l2); + draw_text(cx_for(l3), 44, l3); + flush_fb(); + sleep_ms(1500); +} + +} // namespace + +void oled_init() { + spi_init(spi1, 10 * 1000 * 1000); + gpio_set_function(kPinCLK, GPIO_FUNC_SPI); + gpio_set_function(kPinMOSI, GPIO_FUNC_SPI); + + gpio_init(kPinCS); gpio_set_dir(kPinCS, GPIO_OUT); gpio_put(kPinCS, 1); + gpio_init(kPinDC); gpio_set_dir(kPinDC, GPIO_OUT); gpio_put(kPinDC, 0); + gpio_init(kPinRST); gpio_set_dir(kPinRST, GPIO_OUT); gpio_put(kPinRST, 1); + + gpio_init(kPinKey0); gpio_set_dir(kPinKey0, GPIO_IN); gpio_pull_up(kPinKey0); + gpio_init(kPinKey1); gpio_set_dir(kPinKey1, GPIO_IN); gpio_pull_up(kPinKey1); + + hw_reset(); + sh1107_init(); + fb_clear(); + boot_splash(); +} + +void oled_loop() { + handle_buttons(); + const uint32_t now = time_us_32(); + rumble_burst_tick(now); + if ((now - last_render_us) < kFrameUs) return; + last_render_us = now; + // Bump activity on controller input changes (cheap rolling hash over input bytes) + uint32_t hash = 0; + for (int i = 0; i < 10; i++) hash = hash * 31u + interrupt_in_data[i]; + if (hash != last_input_hash) { + last_input_hash = hash; + last_activity_us = now; + } + + // Auto-dim after idle; respect user-selected brightness otherwise + const bool idle = (now - last_activity_us) > kAutoDimUs; + sh1107_set_contrast(idle ? kDimContrast : kBrightLevels[bright_idx]); + + switch (current_screen) { + case kScreenStatus: render_screen(); break; + case kScreenSlots: render_screen_slots(); break; + case kScreenLightbar: render_screen_lightbar(); break; + case kScreenTriggers: render_screen_triggers(); break; + case kScreenGyro: render_screen_gyro(); break; + case kScreenTouchpad: render_screen_touchpad(); break; + case kScreenDiag: render_screen_diag(); break; + case kScreenRssi: render_screen_rssi(); break; + case kScreenVU: render_screen_vu(); break; + case kScreenSettings: render_screen_settings(); break; + } +} diff --git a/src/oled.h b/src/oled.h new file mode 100644 index 0000000..1c4d882 --- /dev/null +++ b/src/oled.h @@ -0,0 +1,7 @@ +#ifndef DS5_BRIDGE_OLED_H +#define DS5_BRIDGE_OLED_H + +void oled_init(); +void oled_loop(); + +#endif // DS5_BRIDGE_OLED_H diff --git a/src/oled_font.h b/src/oled_font.h new file mode 100644 index 0000000..f12fe83 --- /dev/null +++ b/src/oled_font.h @@ -0,0 +1,109 @@ +// 5x7 ASCII font, ASCII 0x20-0x7E. 5 column bytes per glyph, LSB = top row. +// Source: public-domain font from the Adafruit / SSD1306 ecosystem. +#ifndef DS5_BRIDGE_OLED_FONT_H +#define DS5_BRIDGE_OLED_FONT_H + +#include + +static constexpr int kFontW = 5; +static constexpr int kFontH = 7; + +static const uint8_t kFont5x7[95][5] = { + {0x00, 0x00, 0x00, 0x00, 0x00}, // ' ' + {0x00, 0x00, 0x5F, 0x00, 0x00}, // '!' + {0x00, 0x03, 0x00, 0x03, 0x00}, // '"' + {0x14, 0x3E, 0x14, 0x3E, 0x14}, // '#' + {0x24, 0x2A, 0x7F, 0x2A, 0x12}, // '$' + {0x43, 0x33, 0x08, 0x66, 0x61}, // '%' + {0x36, 0x49, 0x55, 0x22, 0x50}, // '&' + {0x00, 0x05, 0x03, 0x00, 0x00}, // ''' + {0x00, 0x1C, 0x22, 0x41, 0x00}, // '(' + {0x00, 0x41, 0x22, 0x1C, 0x00}, // ')' + {0x14, 0x08, 0x3E, 0x08, 0x14}, // '*' + {0x08, 0x08, 0x3E, 0x08, 0x08}, // '+' + {0x00, 0x50, 0x30, 0x00, 0x00}, // ',' + {0x08, 0x08, 0x08, 0x08, 0x08}, // '-' + {0x00, 0x60, 0x60, 0x00, 0x00}, // '.' + {0x20, 0x10, 0x08, 0x04, 0x02}, // '/' + {0x3E, 0x51, 0x49, 0x45, 0x3E}, // '0' + {0x00, 0x04, 0x02, 0x7F, 0x00}, // '1' + {0x42, 0x61, 0x51, 0x49, 0x46}, // '2' + {0x22, 0x41, 0x49, 0x49, 0x36}, // '3' + {0x18, 0x14, 0x12, 0x7F, 0x10}, // '4' + {0x27, 0x45, 0x45, 0x45, 0x39}, // '5' + {0x3E, 0x49, 0x49, 0x49, 0x32}, // '6' + {0x01, 0x01, 0x71, 0x09, 0x07}, // '7' + {0x36, 0x49, 0x49, 0x49, 0x36}, // '8' + {0x26, 0x49, 0x49, 0x49, 0x3E}, // '9' + {0x00, 0x36, 0x36, 0x00, 0x00}, // ':' + {0x00, 0x56, 0x36, 0x00, 0x00}, // ';' + {0x08, 0x14, 0x22, 0x41, 0x00}, // '<' + {0x14, 0x14, 0x14, 0x14, 0x14}, // '=' + {0x00, 0x41, 0x22, 0x14, 0x08}, // '>' + {0x02, 0x01, 0x51, 0x09, 0x06}, // '?' + {0x3E, 0x41, 0x59, 0x55, 0x5E}, // '@' + {0x7E, 0x09, 0x09, 0x09, 0x7E}, // 'A' + {0x7F, 0x49, 0x49, 0x49, 0x36}, // 'B' + {0x3E, 0x41, 0x41, 0x41, 0x22}, // 'C' + {0x7F, 0x41, 0x41, 0x41, 0x3E}, // 'D' + {0x7F, 0x49, 0x49, 0x49, 0x41}, // 'E' + {0x7F, 0x09, 0x09, 0x09, 0x01}, // 'F' + {0x3E, 0x41, 0x41, 0x49, 0x3A}, // 'G' + {0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'H' + {0x00, 0x41, 0x7F, 0x41, 0x00}, // 'I' + {0x30, 0x40, 0x40, 0x40, 0x3F}, // 'J' + {0x7F, 0x08, 0x14, 0x22, 0x41}, // 'K' + {0x7F, 0x40, 0x40, 0x40, 0x40}, // 'L' + {0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'M' + {0x7F, 0x02, 0x04, 0x08, 0x7F}, // 'N' + {0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'O' + {0x7F, 0x09, 0x09, 0x09, 0x06}, // 'P' + {0x1E, 0x21, 0x21, 0x21, 0x5E}, // 'Q' + {0x7F, 0x09, 0x09, 0x09, 0x76}, // 'R' + {0x26, 0x49, 0x49, 0x49, 0x32}, // 'S' + {0x01, 0x01, 0x7F, 0x01, 0x01}, // 'T' + {0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'U' + {0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'V' + {0x7F, 0x20, 0x10, 0x20, 0x7F}, // 'W' + {0x41, 0x22, 0x1C, 0x22, 0x41}, // 'X' + {0x07, 0x08, 0x70, 0x08, 0x07}, // 'Y' + {0x61, 0x51, 0x49, 0x45, 0x43}, // 'Z' + {0x00, 0x7F, 0x41, 0x00, 0x00}, // '[' + {0x02, 0x04, 0x08, 0x10, 0x20}, // '\\' + {0x00, 0x00, 0x41, 0x7F, 0x00}, // ']' + {0x04, 0x02, 0x01, 0x02, 0x04}, // '^' + {0x40, 0x40, 0x40, 0x40, 0x40}, // '_' + {0x00, 0x01, 0x02, 0x04, 0x00}, // '`' + {0x20, 0x54, 0x54, 0x54, 0x78}, // 'a' + {0x7F, 0x44, 0x44, 0x44, 0x38}, // 'b' + {0x38, 0x44, 0x44, 0x44, 0x44}, // 'c' + {0x38, 0x44, 0x44, 0x44, 0x7F}, // 'd' + {0x38, 0x54, 0x54, 0x54, 0x18}, // 'e' + {0x04, 0x04, 0x7E, 0x05, 0x05}, // 'f' + {0x08, 0x54, 0x54, 0x54, 0x3C}, // 'g' + {0x7F, 0x08, 0x04, 0x04, 0x78}, // 'h' + {0x00, 0x44, 0x7D, 0x40, 0x00}, // 'i' + {0x20, 0x40, 0x44, 0x3D, 0x00}, // 'j' + {0x7F, 0x10, 0x28, 0x44, 0x00}, // 'k' + {0x00, 0x41, 0x7F, 0x40, 0x00}, // 'l' + {0x7C, 0x04, 0x78, 0x04, 0x78}, // 'm' + {0x7C, 0x08, 0x04, 0x04, 0x78}, // 'n' + {0x38, 0x44, 0x44, 0x44, 0x38}, // 'o' + {0x7C, 0x14, 0x14, 0x14, 0x08}, // 'p' + {0x08, 0x14, 0x14, 0x14, 0x7C}, // 'q' + {0x00, 0x7C, 0x08, 0x04, 0x04}, // 'r' + {0x48, 0x54, 0x54, 0x54, 0x20}, // 's' + {0x04, 0x04, 0x3F, 0x44, 0x44}, // 't' + {0x3C, 0x40, 0x40, 0x20, 0x7C}, // 'u' + {0x1C, 0x20, 0x40, 0x20, 0x1C}, // 'v' + {0x3C, 0x40, 0x30, 0x40, 0x3C}, // 'w' + {0x44, 0x28, 0x10, 0x28, 0x44}, // 'x' + {0x0C, 0x50, 0x50, 0x50, 0x3C}, // 'y' + {0x44, 0x64, 0x54, 0x4C, 0x44}, // 'z' + {0x00, 0x08, 0x36, 0x41, 0x41}, // '{' + {0x00, 0x00, 0x7F, 0x00, 0x00}, // '|' + {0x41, 0x41, 0x36, 0x08, 0x00}, // '}' + {0x02, 0x01, 0x02, 0x04, 0x02}, // '~' +}; + +#endif // DS5_BRIDGE_OLED_FONT_H diff --git a/src/slots.cpp b/src/slots.cpp new file mode 100644 index 0000000..a3f9803 --- /dev/null +++ b/src/slots.cpp @@ -0,0 +1,116 @@ +// Flash-backed slot table for 4-slot persistent BT pairing. +// Modeled on zurce/DS5Dongle-OLED (bt.cpp:29-115). Credit to zurce. + +#include "slots.h" + +#include +#include + +#include "hardware/flash.h" +#include "hardware/sync.h" + +constexpr uint32_t SLOTS_MAGIC = 0x44533502u; // "DS5\x02" +constexpr uint32_t SLOTS_FLASH_OFFSET = PICO_FLASH_SIZE_BYTES - 2u * FLASH_SECTOR_SIZE; + +struct __attribute__((packed)) SlotsData { + uint32_t magic; + uint8_t addrs[kNumSlots][6]; + uint8_t occupied[kNumSlots]; +}; + +static_assert(sizeof(SlotsData) <= FLASH_PAGE_SIZE); +static_assert(SLOTS_FLASH_OFFSET % FLASH_SECTOR_SIZE == 0); + +static SlotsData g_slots{}; + +static const SlotsData *flash_slots() { + return reinterpret_cast(XIP_BASE + SLOTS_FLASH_OFFSET); +} + +static bool save_slots_to_flash() { + alignas(4) uint8_t page[FLASH_PAGE_SIZE]; + memset(page, 0xff, sizeof(page)); + memcpy(page, &g_slots, sizeof(g_slots)); + + const uint32_t interrupts = save_and_disable_interrupts(); + flash_range_erase(SLOTS_FLASH_OFFSET, FLASH_SECTOR_SIZE); + flash_range_program(SLOTS_FLASH_OFFSET, page, sizeof(page)); + restore_interrupts(interrupts); + + SlotsData verify{}; + memcpy(&verify, flash_slots(), sizeof(verify)); + if (memcmp(&verify, &g_slots, sizeof(g_slots)) == 0) { + printf("[Slots] flash write verified\n"); + return true; + } + printf("[Slots] flash write VERIFY FAILED\n"); + return false; +} + +void slots_load() { + memcpy(&g_slots, flash_slots(), sizeof(g_slots)); + if (g_slots.magic != SLOTS_MAGIC) { + printf("[Slots] flash sector empty/invalid, initializing\n"); + memset(&g_slots, 0, sizeof(g_slots)); + g_slots.magic = SLOTS_MAGIC; + save_slots_to_flash(); + } + for (int i = 0; i < kNumSlots; i++) { + if (g_slots.occupied[i]) { + printf("[Slots] %d: %02X:%02X:%02X:%02X:%02X:%02X\n", i, + g_slots.addrs[i][0], g_slots.addrs[i][1], g_slots.addrs[i][2], + g_slots.addrs[i][3], g_slots.addrs[i][4], g_slots.addrs[i][5]); + } else { + printf("[Slots] %d: (empty)\n", i); + } + } +} + +bool slot_occupied(int slot) { + if (slot < 0 || slot >= kNumSlots) return false; + return g_slots.occupied[slot] != 0; +} + +void slot_get_addr(int slot, uint8_t out[6]) { + if (slot < 0 || slot >= kNumSlots) { + memset(out, 0, 6); + return; + } + memcpy(out, g_slots.addrs[slot], 6); +} + +int slot_owner_of(const uint8_t addr[6]) { + for (int i = 0; i < kNumSlots; i++) { + if (g_slots.occupied[i] && memcmp(g_slots.addrs[i], addr, 6) == 0) return i; + } + return -1; +} + +void slot_assign(int slot, const uint8_t addr[6]) { + if (slot < 0 || slot >= kNumSlots) return; + memcpy(g_slots.addrs[slot], addr, 6); + g_slots.occupied[slot] = 1; + save_slots_to_flash(); +} + +void slot_forget(int slot) { + if (slot < 0 || slot >= kNumSlots) return; + memset(g_slots.addrs[slot], 0, 6); + g_slots.occupied[slot] = 0; + save_slots_to_flash(); +} + +void slots_wipe_all() { + for (int i = 0; i < kNumSlots; i++) { + memset(g_slots.addrs[i], 0, 6); + g_slots.occupied[i] = 0; + } + save_slots_to_flash(); +} + +bool slots_any_empty() { + for (int i = 0; i < kNumSlots; i++) { + if (!g_slots.occupied[i]) return true; + } + return false; +} diff --git a/src/slots.h b/src/slots.h new file mode 100644 index 0000000..55edc5b --- /dev/null +++ b/src/slots.h @@ -0,0 +1,22 @@ +// Persistent 4-slot multi-controller pairing storage. Stores the 4 bonded +// DS5 bd_addrs in a custom flash sector (BTstack's NVM keeps the link_keys). +// +// Multi-slot pairing modeled on zurce/DS5Dongle-OLED. Credit to zurce. + +#ifndef DS5_BRIDGE_SLOTS_H +#define DS5_BRIDGE_SLOTS_H + +#include + +constexpr int kNumSlots = 4; + +void slots_load(); +bool slot_occupied(int slot); +void slot_get_addr(int slot, uint8_t out[6]); +int slot_owner_of(const uint8_t addr[6]); +void slot_assign(int slot, const uint8_t addr[6]); +void slot_forget(int slot); +void slots_wipe_all(); +bool slots_any_empty(); + +#endif // DS5_BRIDGE_SLOTS_H diff --git a/src/usb.cpp b/src/usb.cpp index 0096954..c09c0c3 100644 --- a/src/usb.cpp +++ b/src/usb.cpp @@ -67,11 +67,12 @@ static bool audio10_set_req_entity(tusb_control_request_t const *p_request, uint TU_VERIFY(p_request->wLength == 2); volume[index] = static_cast(*reinterpret_cast(pBuff)) / 256; - if (entityID == UAC1_ENTITY_SPK_FEATURE_UNIT) { - auto config = get_config(); - config.speaker_volume = volume[index]; - set_config(config); - } + // Do NOT sync the USB-side UAC1 volume control into our + // flash-persisted config. The host (PipeWire/Pulse) re-applies + // its last-known UAC1 volume on every device reconnect, which + // would silently override the user's saved speaker_volume. + // Fix borrowed from loteran/DS5Dongle commit 03fa1e4. + (void)entityID; TU_LOG2(" Set Volume: %d dB of entity: %u\r\n", volume[index], entityID); return true; diff --git a/src/usb_descriptors.cpp b/src/usb_descriptors.cpp index 0194ef2..4c5b701 100644 --- a/src/usb_descriptors.cpp +++ b/src/usb_descriptors.cpp @@ -102,7 +102,15 @@ tusb_desc_device_t desc_device = .iManufacturer = 0x01, .iProduct = 0x02, - .iSerialNumber = 0x00, + // OLED Edition restores the per-board serial number that upstream stripped + // in commit e79c762 ("remove usb serialnumber #32"). Without it, Windows + // treats the dongle as a NEW device for each USB port (issue awalol#100): + // users lose their per-device volume / app preferences when they move + // ports. The trade-off (re-introducing SpecialK incompatibility from #32) + // affects only Windows users with that specific tool; the broader Windows + // population gets stable device identity back. The serial string itself + // is produced by board_usb_get_serial() from the flash chip's unique ID. + .iSerialNumber = STRID_SERIAL, .bNumConfigurations = 0x01 };