Compare commits

..
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.7 2209f9b8c7 docs(changelog): cut v0.6.3-oled-edition
Small follow-up to v0.6.2. The version-centralization landed on
master after the v0.6.2 tag, so v0.6.2 UF2s still ship the stale
hardcoded "v0.6.0" on the Status header. v0.6.3 rebuilds from the
current master so the new FIRMWARE_VERSION macro path is exercised
and the Status header reflects the actual release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:25:05 -06:00
MarcelineVPQandClaude Opus 4.7 a7835ac156 chore(version): single source of truth — release tag → FIRMWARE_VERSION macro
Audit revealed the Status screen hardcoded "v0.6.0" while v0.6.1 and
v0.6.2 had already shipped — the version string was scattered and
fell behind every release. Centralizing:

- CMakeLists.txt: target_compile_definitions adds
  FIRMWARE_VERSION="${VERSION}" so the existing -DVERSION=$tag flag
  passed by release.yml propagates into C++ as a compile-time macro.
  Local builds without -DVERSION get "dev" — a deliberate "this is
  not a release build" tell on the Status header.
- src/oled.cpp render_screen() now renders
  "DS5 Bridge " FIRMWARE_VERSION via string-literal concatenation. The
  only place a version literal exists in source is now the macro at
  the link layer.
- CLAUDE.md gets a "Versioning — single source of truth" section
  documenting the release ritual (CHANGELOG bump → tag → push → gh
  release create → CI builds + propagates) and naming the
  WEB_REPO_DISPATCH_PAT follow-up that's still pending.

Web preview is updated separately to read firmware-latest.json at
runtime — same single source of truth, different distribution path
(the GitHub API).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:17:46 -06:00
MarcelineVPQandClaude Opus 4.7 ac035fcb25 docs(changelog): cut v0.6.2-oled-edition
OLED button-model + arrow-chrome refactor on top of v0.6.1. Strict
KEY0/KEY1 nav on every screen, KEY0+KEY1 chord reboot, per-screen
cycling moved to controller buttons (Triangle on Trigger Test, R1 on
Lightbar). Visual chrome strip with > / < arrows on the left edge.
Web preview mirrored in lockstep + orange-tint indicator for screens
whose firmware feature report Chrome WebHID can't expose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:11:28 -06:00
MarcelineVPQandClaude Opus 4.7 5b9df0596b docs(readme): OLED section rewrite — 11 screens, new button model, web preview screenshots
- Bump screen count 10 → 11 in the OLED Display Add-on section to
  reflect the CPU/Clock screen added in v0.6.1.
- Update the cycle order to include CPU/Clock between Diagnostics and
  BT Signal.
- Drop the per-screen ASCII mockups (they were noisy and out of date)
  and link each screen section to a consistent web-preview screenshot
  under assets/oled/. Eleven screenshots, named oled_sc01.jpg through
  oled_sc11.jpg, captured from the OLED Preview tab of the web config
  so every screen looks the same and the chrome/layout matches the
  newly-flashed firmware.
- Rewrite the "KEY1 behavior by screen" table as a single "Button
  reference" table. K0 = next, K1 = back, K1 long-press = brightness
  cycle, K0+K1 hold = reboot. The per-screen state-change actions
  (Triangle / R1 / D-pad) live on the controller now and are
  documented per screen.
- Update Soft-reboot Recovery + the top-level Features bullet to
  reflect KEY0+KEY1 chord replacing KEY0 double-click.
- Update per-screen descriptions: Lightbar uses R1 to cycle mode (not
  K1); Trigger Test uses Triangle to cycle preset (not K1); Settings
  text updated to mention the new D-pad cursor behavior on the web
  preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:11:16 -06:00
MarcelineVPQandClaude Opus 4.7 bd2f33bf0d docs(readme): web config tool section + BOOTSEL explainer + overclock clarity
- Add a dedicated "🛠️ Web Config Tool" section near the top of the
  README so the deep link to https://marcelinevpq.github.io/DS5Dongle-
  OLED-Config-Web/#config is discoverable. Lists the three tabs (Flash
  Firmware / Config / OLED Preview) and what each one does end-to-end:
  a brand-new Pico 2 W can go from "out of the box" to flashed and
  configured without touching a terminal.
- BOOTSEL mode callout explaining what the physical button is, how to
  enter the mode, and what the user sees (Pico mounts as a removable
  drive). First-time flashers were getting stuck on this step.
- Performance / Overclocking section reworded: lead with "you don't
  need to do anything — the overclock is baked into the firmware".
  Stock UF2 sets 320 MHz @ 1.20 V automatically via vreg_set_voltage
  and set_sys_clock_khz in src/main.cpp. The "if it fails to boot,
  raise voltage / lower clock" line is scoped to people compiling
  from source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:52:26 -06:00
MarcelineVPQandClaude Opus 4.7 3eb0e76508 feat(oled): strict K0/K1 nav + chord reboot + arrow chrome + controller cycle
Refactor the Pico-OLED-1.3 button model and visual layout:

- KEY0 single press = "next screen" (double-click reboot path removed —
  it was easy to fire by accident while paging quickly).
- KEY1 short press = "previous screen" on every screen. The old
  contextual cycle on Trigger Test (cycle trigger preset) and Lightbar
  (cycle lb_mode) moves to controller buttons:
    * △ rising edge on Trigger Test  → cycle trigger preset
                                       (re-applies via send_trigger_effect)
    * R1 rising edge on Lightbar     → cycle lb_mode
  △ on Lightbar stays as "save current RGB to favorite slot 0" (existing
  UX), so R1 is used there instead.
- KEY1 long press = cycle brightness — unchanged.
- KEY0 + KEY1 held simultaneously for >= 1 s → watchdog_reboot. Pre-empts
  per-key handlers so a chord cancels any armed single press. DS5
  PS+Mute hold-2 s remains the headless backup.

Visual layout:

- New draw_button_chrome() paints '>' at (0, 8) and '<' at (0, 49) on
  every frame from flush_fb(), pairing the on-screen labels with the
  physical KEY0/KEY1 buttons on the left edge of the device.
- Every render_screen_*() shifts main content right by kContentX (6 px)
  to clear the chrome strip. Stick boxes, L2 column, L1 bar, and D-pad
  center on the Status screen also shift right by 6 to avoid the '<'
  glyph painting inside the live left-stick area.
- "K0=next K1=back" footers removed from all screens (the chrome strip
  is now the affordance). Trigger Test footer = "Tri=cycle"; Lightbar
  footer = "R1=mode"; Slots/Settings keep their contextual hints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:51:59 -06:00
MarcelineVPQandClaude Opus 4.7 c5759dfce8 chore(gitignore): ignore build-pr/ + local scratch files
build-pr/ is a parallel CMake build tree that survives between PR
investigations. Added alongside build/ to keep it out of the index.

Also ignore four root-level files that are inherently per-user:
.claude/ (Claude Code config), .directory (KDE Dolphin metadata),
*.code-workspace (VS Code workspace files), pr-review-*.md (local
scratch review notes).

Drops VS Code's untracked-file count for a fresh clone + build run
from ~1760 to 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:14:09 -06:00
17 changed files with 347 additions and 239 deletions
+7
View File
@@ -1,4 +1,11 @@
build
build-pr
.vscode
.idea
example
# Local/per-user scratch
.claude/
.directory
*.code-workspace
pr-review-*.md
+46
View File
@@ -10,6 +10,52 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [0.6.3-oled-edition] — 2026-05-18
Small follow-up to v0.6.2. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.3-oled-edition) (built by `.github/workflows/release.yml`).
### Fixed
- **OLED Status header was stuck on `"DS5 Bridge v0.6.0"`.** The string was hardcoded in `src/oled.cpp` and never got bumped per release, so v0.6.1 and v0.6.2 both shipped with stale text on the Status screen. Now driven by a compile-time `FIRMWARE_VERSION` macro set from `CMakeLists.txt`'s `${VERSION}` (which `release.yml` already passes as `-DVERSION="$FIRMWARE_VERSION"`). Single source of truth: the release tag. Local builds without `-DVERSION` show `"dev"` so an untagged build is obvious at a glance.
- **Web preview's Status header had the same bug.** `src/oled/screens.ts` hardcoded `"v0.5.4"`. Now reads `firmware-latest.json` (already CI-bundled from the GitHub API) at runtime in `OledEmulator.tsx` and writes the short tag (suffix `-oled-edition` stripped) into `state.firmwareVersionLabel`, which `renderStatus()` consumes.
### Documentation
- New `CLAUDE.md` "Versioning — single source of truth" section documents the release ritual (CHANGELOG bump → tag → push → `gh release create`) and the single-source-of-truth flow from tag → CMake → C++ macro → web `firmware-latest.json`. Includes a note about the still-pending `WEB_REPO_DISPATCH_PAT` secret on the firmware repo.
---
## [0.6.2-oled-edition] — 2026-05-18
OLED button-model + visual chrome refactor on top of v0.6.1. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.2-oled-edition) (built by `.github/workflows/release.yml`).
### Changed (button model)
- **KEY0 / KEY1 are now strictly navigation on every screen.** KEY0 short-press = next screen, KEY1 short-press = previous screen. KEY1 long-press still cycles OLED brightness (unchanged). The old contextual K1=cycle behavior on Trigger Test (cycle trigger preset) and Lightbar (cycle lightbar mode) moved to **DualSense controller buttons** — Triangle on Trigger Test, R1 on Lightbar. Source of "the Mode label didn't change when I clicked K1" confusion eliminated.
- **KEY0 double-click reboot → KEY0 + KEY1 simultaneous hold (≥ 1 s).** Rapid forward-navigation kept tripping the double-click timer by accident, soft-rebooting the dongle mid-session. The new two-button chord can't be fat-fingered. `kDoubleClickUs` + `key0_pending_single` state removed; new `chord_held_since_us` + `kChordHoldUs = 1 s`. DS5 PS+Mute hold-2 s remains the headless backup.
- **Per-screen contextual actions on the controller** mirror the existing Slots / Settings conventions (where Triangle has always meant "commit / switch / save"):
- **Trigger Test** — △ rising edge cycles `trigger_preset` and re-applies via `send_trigger_effect()`.
- **Lightbar** — R1 rising edge cycles `lb_mode`. (Triangle stays as "save current RGB to favorite slot 0" — the existing favorite-save UX.)
### Changed (visual chrome)
- **Arrow chrome on the left edge of every screen.** `flush_fb()` now paints `>` at `(0, 8)` and `<` at `(0, 49)` so the on-screen labels physically pair with the KEY0 (top) and KEY1 (bottom) buttons. The horizontal `"K0=next K1=back"` footer at y=56 is removed from all 11 screens. Trigger Test footer = `"Tri=cycle"`; Lightbar footer = `"R1=mode"`; Slots and Settings keep their existing contextual hints (`"Tri=switch Sq hold=wipe"`, `"DP nav/adj Tri=save"`). New `kContentX = 6` shifts every screen's content right by 6 px to clear the chrome strip; rectangles, sticks, and the L1/L2 column on Status all repositioned to avoid the chrome `<` glyph painting inside the live left-stick area.
### Added (web preview parity)
- **`src/protocol/ds5BridgeHid.ts` `sendTriggerPreset(preset)`** — builds the DS5 SetStateData payload byte-for-byte from `src/oled.cpp send_trigger_effect()` and pushes via `device.sendReport(0x02, ...)`. The dongle relays it over BT to the paired controller, so cycling Trigger Test in the web preview actually drives the real adaptive triggers.
- **Web preview mirrors the firmware refactor.** `key1Action()` collapsed to back-nav-only. New rising-edge handlers in `OledEmulator.tsx` detect Triangle / R1 / D-pad from the live controller's input report and dispatch to the appropriate per-screen action. `drawButtonChrome(fb)` paints the `>` / `<` arrows after every render. `flush()` accepts an optional tint color: Slots / Diagnostics / CPU/Clock render in **orange** (`#f59e0b`) when a controller is connected — Chrome WebHID can't expose those reports on a stock DualSense descriptor, so the orange tint + an explanatory paragraph below the canvas flag the values as mock. KEY0/KEY1 buttons in the UI moved to sit visually next to the rendered Pico-OLED-1.3, mirroring the physical board.
- **Settings cursor on the web** — new `settingsSel` state, `>` cursor mark on the selected row; D-pad up/down on the connected controller moves the cursor (web-preview-only — actual edits + save happen via the dedicated Config tab on the website).
- **Mock-data temperature tweak** — web Preview's CPU/Clock screen no longer drifts 4147 °C; jitter is now ±0.4 °C around 33.6 °C (realistic Pico 2 W idle).
### Documentation
- **README "Web Config Tool" section** added near the top, linking https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/#config and explaining the three tabs (Flash / Config / OLED Preview). Includes a BOOTSEL mode primer for first-time flashers.
- **OLED Display Add-on section rewritten.** Screen count 10 → 11 (CPU/Clock added). Cycle order updated. New "Button reference" table covers the strict K0/K1 nav, K1 long-press brightness, and K0+K1 chord reboot. ASCII mockups dropped in favor of consistent web-preview screenshots under `assets/oled/`.
- **Performance / Overclocking section reworded** to lead with "you don't need to do anything — the overclock is baked into the firmware". The "raise voltage / lower clock if it fails to boot" line is scoped to users compiling from source.
---
## [0.6.1-oled-edition] — 2026-05-18
Tagged release of the v0.6.0-oled-edition follow-up. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.1-oled-edition) (built by `.github/workflows/release.yml`).
+27
View File
@@ -95,6 +95,33 @@ These are non-obvious from the code; they cost time when forgotten.
- **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`).
## Versioning — single source of truth
The release tag is the **only** place the version is written. Everything else flows from it:
- **Release tag** (e.g. `v0.6.2-oled-edition`) → created with `git tag` then `gh release create`.
- **`.github/workflows/release.yml`** picks the tag up as `$FIRMWARE_VERSION` and passes it to CMake via `-DVERSION="$FIRMWARE_VERSION"`.
- **`CMakeLists.txt`** exposes it to C++ as a compile-time macro:
```cmake
target_compile_definitions(ds5-bridge PRIVATE FIRMWARE_VERSION="${VERSION}")
```
Local builds without `-DVERSION=...` get the default `"dev"` — that's a deliberate visual signal so an untagged build is obvious on the OLED Status header.
- **`src/oled.cpp` `render_screen()`** renders `"DS5 Bridge " FIRMWARE_VERSION` for the Status screen header. No string literal for the version exists anywhere else in the firmware source.
- **Web preview** (`DS5Dongle-OLED-Config-Web`) reads the same tag at runtime from `public/firmware-latest.json`, which CI bundles in `.github/workflows/deploy.yml`'s "Bundle latest firmware UF2 from GitHub releases" step (it pulls from `MarcelineVPQ/DS5Dongle-OLED-Edition/releases/latest` via the GitHub API). `OledEmulator.tsx` fetches that JSON on mount and writes the short form (suffix `-oled-edition` stripped) into `state.firmwareVersionLabel`, which `screens.ts:renderStatus` consumes.
**The release ritual** is therefore:
1. Update `CHANGELOG.md` — move `[Unreleased]` content into a new `[X.Y.Z-oled-edition]` section dated today.
2. Commit the CHANGELOG bump.
3. `git tag -a vX.Y.Z-oled-edition -m "..."`
4. `git push origin master && git push origin vX.Y.Z-oled-edition`
5. `gh release create vX.Y.Z-oled-edition -R MarcelineVPQ/DS5Dongle-OLED-Edition --title "vX.Y.Z — OLED Edition" --notes "..."`
6. CI builds the UF2s (~57 min), uploads them with SHA256SUMS, edits the release notes to append checksums, and (when `WEB_REPO_DISPATCH_PAT` is configured on the firmware repo — currently unset, see below) fires a `repository_dispatch` event to the web repo to refresh `firmware-latest.json`. Without the secret, the next push to the web repo's `master` does the refresh instead.
There is **no other place** to edit the version. If you find a hardcoded version string in source (`"v0.6.0"`, `"v0.5.4"`, etc.), it's a bug — replace it with the macro / JSON lookup.
**Known follow-up:** `WEB_REPO_DISPATCH_PAT` secret is unset on the firmware repo, so the firmware-release → web-rebuild dispatch is currently silently no-op'd (peter-evans/repository-dispatch with continue-on-error). The web bundle still updates via push events to the web repo's master, but not automatically on every firmware release.
## Git / branch model
- **`master` (origin)** = `MarcelineVPQ/DS5Dongle-OLED-Edition` (this fork's primary branding, what users download).
+8
View File
@@ -146,6 +146,14 @@ if(WAKE_DEBUG)
endif()
set(VERSION "dev" CACHE STRING "Program version string")
# Expose VERSION to firmware C++ as FIRMWARE_VERSION so src/oled.cpp's
# Status header always reflects the release tag (set by release.yml via
# -DVERSION="$FIRMWARE_VERSION"). Local builds without -DVERSION show
# "dev" — visible signal that you're not on a tagged release.
target_compile_definitions(ds5-bridge PRIVATE
FIRMWARE_VERSION="${VERSION}"
)
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}")
+73 -125
View File
@@ -6,6 +6,26 @@
> **OLED Edition** is a fork of **[awalol/DS5Dongle](https://github.com/awalol/DS5Dongle)** (upstream) that adds an optional Pico-OLED-1.3 128×64 display add-on with 11 screens (status, 4-slot multi-controller pairing, lightbar color picker with favorites and effect presets, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, BT signal strength, audio VU meters, and a persistent settings menu), plus a DS5 button-combo soft-reboot. Upstream is the authoritative source for the core bridge firmware; this fork tracks it and layers add-on features on top.
---
## 🛠️ Web Config Tool
**[→ Open the OLED Edition Web Config](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/#config)**
The web tool is a one-stop shop — **no installs, no command line, no `picotool`**. A brand-new Pico 2 W can go from "just out of the box" to fully flashed and configured without ever leaving the browser:
- **Flash Firmware tab** — put the Pico in **BOOTSEL mode**, then click *Connect to Pico* in the browser and *Flash now*. The site bundles the latest release UF2, or you can load a local `.uf2` you've built yourself. Powered by WebUSB.
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use.
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID.
- **OLED Preview tab** — pixel-perfect emulation of all 11 OLED screens. Use the in-page KEY0/KEY1 buttons (or the controller's △ / R1 / D-pad when a DualSense is paired) to navigate. Adaptive triggers actually fire on the controller when you cycle the Trigger Test preset.
Works in any Chromium-based browser (Chrome, Edge, Brave, Opera). Firefox + Safari don't expose WebHID or WebUSB, so flashing and live config aren't available there — the OLED Preview still renders with mock data.
> Source for the web tool: **[MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)** (fork of [awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)).
---
## Overview
This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge for the DualSense controller, allowing wireless connectivity with enhanced haptics support.
@@ -27,7 +47,7 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
- **Lightbar color picker** with 4 user favorite slots + breathing / rainbow / fade effect presets
- **Persistent settings menu** for the 8 firmware config fields (haptics gain, speaker volume, polling rate, etc.) with hold-to-confirm Reset and Wipe-all-slots actions
- **OLED brightness control + auto-dim** after 5 min idle (extends OLED life)
- **Soft-reboot** without unplugging USB via DS5 `PS + Mute` hold (works headless) or OLED KEY0 double-click
- **Soft-reboot** without unplugging USB via DS5 `PS + Mute` hold (works headless) or **KEY0 + KEY1 held together for 1 s** on the OLED add-on (replaces the older KEY0 double-click gesture, which was easy to fire by accident while paging quickly)
- **Audit pass on the core bridge** — critical stack-overflow fix in the audio path (resolves long-standing "audio stuttering"), security hardening, watchdog, length validation across HID/L2CAP boundaries (see [CHANGELOG.md](./CHANGELOG.md))
## Hardware
@@ -127,16 +147,18 @@ To opt out at build time, configure with `-DENABLE_BATT_LED=OFF`. Default is ON.
## Performance / Overclocking
Due to encoding requirements, the Pico2W must be overclocked:
**You don't need to do anything for this — the overclock is baked into the firmware.** When you flash a UF2 from this repo, the Pico 2 W boots at the settings below automatically. There is no separate tool to run, no config file to edit, no fuses to blow.
Current settings:
Baked-in settings:
- Voltage: 1.2V
- Frequency: 320 MHz
- **Voltage: 1.20 V** (`vreg_set_voltage(VREG_VOLTAGE_1_20)`)
- **Clock: 320 MHz** (`set_sys_clock_khz(SYS_CLOCK_KHZ, true)`)
If your device fails to boot:
Why it's required: at stock clock/voltage the CYW43 PIO SPI bus (the path the firmware uses to talk to the on-board Bluetooth chip) is unreliable and pairing fails. 320 MHz @ 1.20 V is the lowest combination we've verified to produce a stable BT link on this board.
- Increase voltage slightly or Reduce CPU frequency
If your device fails to boot a build you compiled yourself (unusual — only relevant when you've changed source), try increasing voltage slightly or lowering the clock in `src/main.cpp`. End users running official UF2 releases should not need to touch this.
A small heatsink on the RP2350 is **recommended for sustained gameplay** but not required for pairing or short sessions.
## Build Instructions
@@ -169,53 +191,25 @@ If you plug a [Waveshare Pico-OLED-1.3](#hardware) onto the Pico2W's headers, th
### Boot splash (1.5 s on power-on)
```
┌──────────────────────────────┐
│ │
│ DS5 Bridge │
│ Pico2W + OLED │
│ │
└──────────────────────────────┘
```
Centered firmware version on a blank screen for 1.5 seconds, then jumps to the Status screen.
### Ten screens, cycled with KEY0 on the add-on
### Eleven screens, cycled with KEY0 on the add-on
Cycle order: **Status → Slots → Lightbar → Trigger Test → Gyro Tilt → Touchpad → Diagnostics → RSSI → VU Meters → Settings →** wrap. KEY0 short-press steps forward. KEY1 short-press steps backward (with two contextual exceptions, see the table at the end).
Cycle order: **Status → Slots → Lightbar → Trigger Test → Gyro Tilt → Touchpad → Diagnostics → CPU/Clock → BT Signal → VU Meters → Settings →** wrap. **KEY0 short-press steps forward; KEY1 short-press steps backward** — on *every* screen. Per-screen interactions (cycling the trigger preset, cycling the lightbar mode, navigating the Settings cursor, switching slots) live on the **DualSense controller buttons**, never on KEY0/KEY1, so the two physical buttons on the OLED add-on always mean the same thing.
Every screen also paints **`>`** at the top-left edge (next to KEY0) and **`<`** at the bottom-left edge (next to KEY1) so the on-screen labels physically pair with the buttons.
#### 1. Status
Connection state, paired DualSense BD address, battery % with bar (`+` charging / `*` complete / `!` error), live analog stick positions, D-pad, face buttons (△ ◯ ✕ □), L1/R1, and L2/R2 analog trigger fill bars. The link indicator and battery use small pixel icons.
```
┌──────────────────────────────┐
│ DS5 Bridge v0.6.0 ● │
│ 14:3A:9A:FF:D9:F9 │
│ 87%+ ╔══════════════╗ │
│ ║██████░░░░░░░░║▌ │
│ ┌────┐ L1 △ R1 ┌────┐│
│ │ ·• │ L2 ○ □ R2 │ ·• ││
│ │ │ ▌ ✕ ▌ │ ││
│ └────┘ └────┘│
└──────────────────────────────┘
```
<img src="./assets/main_screen_01.jpeg" alt="Status screen on the OLED" width="420">
<img src="./assets/oled/oled_sc01.jpg" alt="Status screen on the OLED" width="420">
#### 2. Slots
Persistent 4-slot multi-controller pairing. Browse stored controllers, switch active slot, or wipe a single slot. `>` is the cursor, `*` marks the currently active slot.
```
┌──────────────────────────────┐
│ Slots [s0 ON] │
│ >0* 14:3A:9A:FF:D9:F9 │
│ 1 (empty) │
│ 2 (empty) │
│ 3 (empty) │
│ │
│ Tri=switch Sq hold=wipe │
└──────────────────────────────┘
```
<img src="./assets/oled/oled_sc02.jpg" alt="Slots screen on the OLED" width="420">
- **D-pad ▲▼** — move cursor across slots 03
- **△** — switch to the cursor slot (disconnect current, reconnect to slot's stored controller)
@@ -226,42 +220,17 @@ Persistent 4-slot multi-controller pairing. Browse stored controllers, switch ac
Tilt the controller on each axis to dial in R / G / B; the firmware sends the resulting color to the DualSense's actual lightbar at 10 Hz, so the lightbar IS the visual preview (the OLED is monochrome).
```
┌──────────────────────────────┐
│ Lightbar [LIVE] │
│ R:128 G: 77 B:200 │
│ ████░░░ ██░░░░░ ██████░░ │
│ (R) (G) (B) │
│ Sv: T=0 C=1 X=2 S=3 │
│ Tilt = R/G/B │
│ K0=next K1=cycle │
└──────────────────────────────┘
```
<img src="./assets/lightbar_01.jpeg" alt="Lightbar color picker on the OLED" width="420">
<img src="./assets/oled/oled_sc03.jpg" alt="Lightbar color picker on the OLED" width="420">
- Press **△ ◯ ✕ □** on the controller to **save** the current color into favorite slot 0 / 1 / 2 / 3
- Press **KEY1** to cycle the mode tag: `[LIVE]` → `[FAV0]` → `[FAV1]` → `[FAV2]` → `[FAV3]` → effects (Breathing / Rainbow / Fade) → back to `[LIVE]`
- Press **R1** on the controller to cycle the mode tag: `[LIVE]` → `[FAV0]` → `[FAV1]` → `[FAV2]` → `[FAV3]` → effects (Breathing / Rainbow / Fade) → back to `[LIVE]`
- Default favorites: Red, Green, Blue, White
#### 4. Trigger Test
KEY1 cycles seven adaptive trigger effects applied to both L2 and R2. Pull each trigger to feel the effect.
Press **△** on the controller to cycle seven adaptive trigger effects applied to both L2 and R2. Pull each trigger to feel the effect.
```
┌──────────────────────────────┐
│ Trigger Test │
│ Mode: Weapon │
│ L2: 127 R2: 42 │
│ │
│ ████░░░░░░ ██░░░░░░░░ │
│ (L2 pull) (R2 pull) │
│ │
│ K0=next K1=cycle │
└──────────────────────────────┘
```
<img src="./assets/triggertest_01.jpeg" alt="Trigger Test screen on the OLED" width="420">
<img src="./assets/oled/oled_sc04.jpg" alt="Trigger Test screen on the OLED" width="420">
Cycle order: **Off → Feedback → Weapon → Vibration → Bow → Gallop → Machine Gun → Off …** Effect parameters bitpacked per [dualsensectl](https://github.com/nowrep/dualsensectl)'s reverse-engineering, all at max strength.
@@ -269,69 +238,45 @@ Cycle order: **Off → Feedback → Weapon → Vibration → Bow → Gallop →
Live X/Y/Z accelerometer values with a 40×40 crosshair box. Tilt the controller and the dot tracks in real time.
```
┌──────────────────────────────┐
│ Gyro Tilt │
│ X +123 Y -456 Z +8123 │
│ ┌────────┐ │
│ │ │ │ │
│ │───•────│ │
│ │ │ │ │
│ └────────┘ │
└──────────────────────────────┘
```
<img src="./assets/gyro_tilt_01.jpeg" alt="Gyro Tilt screen on the OLED" width="420">
<img src="./assets/oled/oled_sc05.jpg" alt="Gyro Tilt screen on the OLED" width="420">
#### 6. Touchpad
Live render of the touchpad surface. Dots appear at current finger positions; the count updates as fingers touch / leave.
```
┌──────────────────────────────┐
│ Touchpad │
│ ┌──────────────────────────┐ │
│ │ • • │ │
│ │ │ │
│ └──────────────────────────┘ │
│ Fingers: 2 │
│ │
│ K0=next │
└──────────────────────────────┘
```
<img src="./assets/touchpad_01.jpeg" alt="Touchpad screen on the OLED" width="420">
<img src="./assets/oled/oled_sc06.jpg" alt="Touchpad screen on the OLED" width="420">
#### 7. Diagnostics
Uptime, BT state, and stub counters for HCI errors / audio FIFO drops / opus FIFO drops (always 0 in this build — kept for future wiring).
Uptime, BT state, USB-audio frames/sec, BT 0x32 packets/sec, and HCI error counter — live values for verifying the audio path is moving bytes without needing a UART cable.
```
┌──────────────────────────────┐
│ Diagnostics │
│ Up: 0h 14m 22s │
│ HCI errs: 0 │
│ Aud drops: 0 │
│ Opus drops: 0 │
│ BT: connected │
│ │
│ K0=next │
└──────────────────────────────┘
```
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
<img src="./assets/diagnostics_01.jpeg" alt="Diagnostics screen on the OLED" width="420">
#### 8. CPU / Clock
#### 8. RSSI
Live RP2350 vitals: configured (`Set`) and actually-running (`Real`) system clock measured against the crystal reference, core voltage read back from the regulator, and on-die temperature (256-sample average + slow EMA so the value tracks the real die temp rather than ADC noise).
Live Bluetooth signal strength of the active link, in dBm with a bar. Closer to 0 dBm is stronger; 90 dBm is weak.
<img src="./assets/oled/oled_sc08.jpg" alt="CPU / Clock diagnostics screen on the OLED" width="420">
#### 9. VU Meters
The same telemetry is also exported on HID feature report `0xFC` for tooling.
#### 9. BT Signal
Live Bluetooth signal strength of the active link, in dBm with a bar. Closer to 0 dBm is stronger; 90 dBm is weak. Includes a qualitative label (Poor / Fair / Good / Excellent).
<img src="./assets/oled/oled_sc09.jpg" alt="BT Signal screen on the OLED" width="420">
#### 10. VU Meters
Live peak meters for the speaker and haptic audio paths. Useful for verifying audio routing without the controller being plugged in to a host.
#### 10. Settings
<img src="./assets/oled/oled_sc10.jpg" alt="VU Meters screen on the OLED" width="420">
Persistent config editor. D-pad ▲▼ moves the selection, ▶◀ adjusts values, △ saves to flash. Includes the 8 firmware-config fields (haptics gain, speaker volume, inactive timeout, etc.), the Audio Auto Haptics controls, and two hold-to-confirm actions:
#### 11. Settings
Persistent config editor. D-pad ▲▼ moves the selection, ▶◀ adjusts values, △ saves to flash. Includes the firmware-config fields (haptics gain, speaker volume, inactive timeout, polling rate), the Audio Auto Haptics controls, and two hold-to-confirm actions:
<img src="./assets/oled/oled_sc11.jpg" alt="Settings screen on the OLED" width="420">
- **AutoHap Off / Fallback / Mix / Replace** — selects the Audio Auto Haptics mode. Default `Fallback` fires derived rumble only when the game sends no native haptic data (e.g. Ghost of Tsushima on Linux); games that *do* send native haptics (Spider-Man Remastered) pass through unchanged. `Mix` adds derived on top of native, `Replace` ignores native entirely, `Off` disables.
- **AH Gain N%** — derived-signal gain, 0200 % in 10 % steps. Default 100 %.
@@ -339,15 +284,18 @@ Persistent config editor. D-pad ▲▼ moves the selection, ▶◀ adjusts value
- **Reset to defaults** — hold △ for 2 s to revert all config fields
- **Wipe all slots** — hold △ for 2 s to drop all 4 paired controllers + all BTstack link keys
### KEY1 behavior by screen
### Button reference
| Screen | KEY1 short-press action |
The two physical buttons on the OLED add-on are **strictly navigation**:
| Button | Action |
|---|---|
| Status, Slots, Gyro Tilt, Touchpad, Diagnostics, RSSI, VU Meters, Settings | Step **backward** one screen (mirror of KEY0's forward step) |
| Trigger Test | Cycle the trigger effect preset |
| Lightbar Color Picker | Cycle between LIVE preview, the 4 favorite slots, and the effect presets |
| **KEY0** short-press | Next screen (forward) |
| **KEY1** short-press | Previous screen (backward) |
| **KEY1** long-press (≥ 1.5 s) | Cycle OLED brightness level |
| **KEY0 + KEY1** held together ≥ 1 s | `watchdog_reboot` — soft-reboot without unplugging USB |
KEY1 long-press (≥1.5 s) on any screen cycles the OLED brightness level.
Per-screen state changes (cycling the trigger preset, cycling the lightbar mode, navigating Settings, switching slots, saving colors to favorite slots) all happen on the **DualSense controller buttons** — never on KEY0 / KEY1 — so the two physical buttons always mean the same thing across every screen. See each screen's section above for which controller button does what.
### Pinout (standard Waveshare Pico HAT layout)
@@ -365,8 +313,8 @@ KEY1 long-press (≥1.5 s) on any screen cycles the OLED brightness level.
Two ways to reboot the dongle without unplugging USB — handy if pairing gets stuck or you want a clean state:
- **OLED KEY0 double-click** within ~400 ms → `watchdog_reboot`
- **DualSense `PS + Mute` held for 2 seconds** → `watchdog_reboot` (works whether or not the OLED is attached)
- **OLED KEY0 + KEY1 held simultaneously for ≥ 1 s** → `watchdog_reboot`. Replaces the older "KEY0 double-click" gesture from earlier versions, since rapid forward-navigation kept tripping the double-click timer by accident.
- **DualSense `PS + Mute` held for 2 seconds** → `watchdog_reboot` (works whether or not the OLED is attached — headless backup).
## Acknowledgements
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+186 -114
View File
@@ -42,15 +42,21 @@ 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;
// Single-press latch — armed on rising edge, fired on release. KEY0 was
// previously a double-click reboot trigger; that gesture moved to the
// KEY0+KEY1 chord below because rapid forward-navigation kept tripping it.
bool key0_armed = false;
// KEY1 long-press detection (for brightness cycling)
uint32_t key1_press_us = 0;
bool key1_was_pressed = false;
constexpr uint32_t kLongPressUs = 1500000;
// KEY0 + KEY1 simultaneous hold → watchdog_reboot. 1 s hold is long enough
// to filter accidental two-button taps but short enough to feel responsive.
uint32_t chord_held_since_us = 0;
constexpr uint32_t kChordHoldUs = 1000000;
// 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]);
@@ -119,6 +125,13 @@ constexpr uint32_t kRumbleBurstUs = 250000;
int trigger_preset = 0;
const char* const kTrigPresetNames[] = {"Off", "Feedback", "Weapon", "Vibration", "Bow", "Gallop", "Machine"};
// Rising-edge trackers for the screens whose K1=cycle action moved to a
// controller button. Trigger Test uses △ (byte 7 bit 7); Lightbar uses R1
// (byte 8 bit 1) because △ is already taken on Lightbar for "save current
// RGB to favorite slot 0".
uint8_t triggers_last_face = 0;
uint8_t lb_last_buttons = 0;
constexpr int kNumTrigPresets = 7;
void cmd(uint8_t c) {
@@ -175,7 +188,13 @@ void sh1107_init() {
cmd(0xAF);
}
// Forward-declared so flush_fb can paint the per-button arrows on top of
// the rendered framebuffer just before SPI sends it to the OLED. Body
// lives near the other text-drawing helpers below.
void draw_button_chrome();
void flush_fb() {
draw_button_chrome();
cmd(0xB0);
for (int j = 0; j < kH; j++) {
const uint8_t col = kH - 1 - j;
@@ -225,6 +244,17 @@ void draw_text(int x, int y, const char *s) {
}
}
// Button-chrome strip on the left edge of every screen. KEY0 (top button)
// shows '>' at y=8; KEY1 (bottom button) shows '<' at y=49. Painted by
// flush_fb() on top of the rendered framebuffer so it never gets clobbered.
// Per-screen renderers reserve x ∈ [0..5] (5-wide glyph + 1 padding) and
// start main content at kContentX.
constexpr int kContentX = 6;
void draw_button_chrome() {
draw_char(0, 8, '>');
draw_char(0, 49, '<');
}
// 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.
@@ -387,22 +417,41 @@ void handle_buttons() {
const uint32_t now = time_us_32();
const bool k0 = gpio_get(kPinKey0);
const bool k1 = gpio_get(kPinKey1);
// KEY0 + KEY1 chord — both held >= kChordHoldUs triggers watchdog_reboot.
// Pre-empts the per-key handlers so a chord cancels any armed single
// press (whichever key gets released first won't also navigate).
const bool chord = !k0 && !k1;
if (chord) {
if (chord_held_since_us == 0) chord_held_since_us = now;
key0_armed = false;
key1_was_pressed = false;
if ((now - chord_held_since_us) >= kChordHoldUs) {
watchdog_reboot(0, 0, 0);
}
} else {
chord_held_since_us = 0;
}
// KEY0: arm on debounced rising edge, fire "next screen" on release.
// Releasing without a chord during the hold = pure forward-nav.
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;
}
key0_armed = true;
last_activity_us = now;
}
if (key0_pending_single && (now - key0_first_press_us) > kDoubleClickUs) {
key0_pending_single = false;
if (k0 && !key0_prev && key0_armed) {
key0_armed = false;
current_screen = (current_screen + 1) % kNumScreens;
last_render_us = 0;
last_activity_us = now;
}
// KEY1: track press time, decide on release whether it was short or long
// KEY1: arm on press, fire on release. Short press = back; long press
// = brightness cycle (unchanged). Trigger-preset / lightbar-mode cycle
// moved to the DualSense △ button — see triggers_handle_input() and
// lightbar_handle_input(). The chord above clears key1_was_pressed so
// a released-after-chord K1 doesn't navigate back.
if (!k1 && key1_prev && (now - key1_t_us) > kDebounceUs) {
key1_t_us = now;
key1_press_us = now;
@@ -414,24 +463,13 @@ void handle_buttons() {
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;
}
current_screen = (current_screen - 1 + kNumScreens) % kNumScreens;
last_render_us = 0;
}
}
key0_prev = k0;
key1_prev = k1;
}
@@ -441,7 +479,10 @@ __attribute__((noinline)) void render_screen() {
const bool connected = bt_is_connected();
draw_text(0, 0, "DS5 Bridge v0.6.0");
// FIRMWARE_VERSION is set via CMake from -DVERSION=... on the build
// command line (release.yml passes the tag name). Local builds get
// "dev" so a non-tagged build is visible at a glance.
draw_text(kContentX, 0, "DS5 Bridge " FIRMWARE_VERSION);
draw_icon(120, 0, connected ? kIconLinkOn : kIconLinkOff, 8, 8);
if (connected) {
@@ -450,7 +491,7 @@ __attribute__((noinline)) void render_screen() {
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);
draw_text(kContentX, 9, buf);
const uint8_t pwr = interrupt_in_data[52];
int pct = (pwr & 0x0F) * 10;
@@ -462,11 +503,13 @@ __attribute__((noinline)) void render_screen() {
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);
draw_text(kContentX, 18, bbuf);
draw_battery_icon(36, 18, pct);
rect_outline(0, 30, 32, 32);
int lx = 2 + (interrupt_in_data[0] * 27) / 255;
// Left-half visuals are shifted right by kContentX so the < button
// chrome at (x=0, y=49) doesn't paint over the live stick dot.
rect_outline(kContentX, 30, 32, 32);
int lx = (kContentX + 2) + (interrupt_in_data[0] * 27) / 255;
int ly = 32 + (interrupt_in_data[1] * 27) / 255;
rect_filled(lx - 1, ly - 1, 3, 3);
@@ -475,10 +518,11 @@ __attribute__((noinline)) void render_screen() {
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);
// L2/R2 analog trigger bars (vertical, fill from bottom). L2 sits
// just right of the shifted left stick box.
rect_outline(kContentX + 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);
if (l2_fill > 0) rect_filled(kContentX + 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);
@@ -486,13 +530,14 @@ __attribute__((noinline)) void render_screen() {
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)
// D-pad indicator (4 directions; lit for primary + diagonals).
// Centered between the left stick column and the face-button cluster.
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;
const int dcx = 52, 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);
@@ -514,13 +559,14 @@ __attribute__((noinline)) void render_screen() {
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
// L1 bar shifted to sit between the L2 trigger column and the d-pad.
if (b8 & 0x01) rect_filled(42, 30, 8, 3); else rect_outline(42, 30, 8, 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");
draw_text(kContentX, 14, "Pair your DualSense:");
draw_text(kContentX, 26, "1. Hold Create + PS");
draw_text(kContentX, 36, "2. Wait for light bar");
draw_text(kContentX, 46, " to flash blue");
}
flush_fb();
@@ -528,41 +574,40 @@ __attribute__((noinline)) void render_screen() {
__attribute__((noinline)) void render_screen_rssi() {
fb_clear();
draw_text(0, 0, "BT Signal");
draw_text(kContentX, 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);
draw_text(kContentX, 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);
draw_text(kContentX, 22, buf);
rect_outline(kContentX, 34, 122, 10);
int fill = (pct * 118) / 100;
if (fill > 0) rect_filled(kContentX + 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);
draw_text(kContentX, 48, buf);
} else {
draw_text(0, 30, "(no controller)");
draw_text(kContentX, 30, "(no controller)");
}
draw_text(0, 56, "K0=next");
flush_fb();
}
__attribute__((noinline)) void render_screen_diag() {
fb_clear();
draw_text(0, 0, "Diagnostics");
draw_text(kContentX, 0, "Diagnostics");
const uint32_t uptime_s = time_us_32() / 1000000u;
const uint32_t h = uptime_s / 3600u;
@@ -570,7 +615,7 @@ __attribute__((noinline)) void render_screen_diag() {
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);
draw_text(kContentX, 9, buf);
// Per-second rates for the audio path counters — recompute every render.
static uint32_t prev_us_frames = 0, prev_bt_packets = 0;
@@ -591,23 +636,21 @@ __attribute__((noinline)) void render_screen_diag() {
prev_sample_us = now_us;
snprintf(buf, sizeof(buf), "USB aud %lu/s", (unsigned long)usb_rate);
draw_text(0, 18, buf);
draw_text(kContentX, 18, buf);
snprintf(buf, sizeof(buf), "BT 0x32 %lu/s", (unsigned long)bt_rate);
draw_text(0, 27, buf);
draw_text(kContentX, 27, buf);
snprintf(buf, sizeof(buf), "HCI errs: %lu", (unsigned long)bt_hci_err_count());
draw_text(0, 36, buf);
draw_text(kContentX, 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");
draw_text(kContentX, 45, buf);
flush_fb();
}
__attribute__((noinline)) void render_screen_cpu(bool entered) {
fb_clear();
draw_text(0, 0, "CPU / Clock");
draw_text(kContentX, 0, "CPU / Clock");
char buf[24];
@@ -615,7 +658,7 @@ __attribute__((noinline)) void render_screen_cpu(bool entered) {
// via set_sys_clock_khz(). This is the *target*.
const uint32_t set_khz = (uint32_t)SYS_CLOCK_KHZ;
snprintf(buf, sizeof(buf), "Set : %lu MHz", (unsigned long)(set_khz / 1000u));
draw_text(0, 12, buf);
draw_text(kContentX, 12, buf);
// Actually running clk_sys, measured by the on-chip frequency counter
// against the crystal reference (not just what we asked for). The counter
@@ -631,7 +674,7 @@ __attribute__((noinline)) void render_screen_cpu(bool entered) {
snprintf(buf, sizeof(buf), "Real: %lu.%01lu MHz",
(unsigned long)(real_khz / 1000u),
(unsigned long)((real_khz % 1000u) / 100u));
draw_text(0, 22, buf);
draw_text(kContentX, 22, buf);
// Core voltage actually programmed into the regulator, read back (not the
// compile-time constant). Codes 0..15 are linear 0.05 V steps from 0.55 V.
@@ -642,7 +685,7 @@ __attribute__((noinline)) void render_screen_cpu(bool entered) {
} else {
snprintf(buf, sizeof(buf), "Vcore: code %d", vcode);
}
draw_text(0, 32, buf);
draw_text(kContentX, 32, buf);
// RP2350 on-die temperature sensor. Smoothed + averaged in cmd.cpp
// (single source of truth shared with the 0xfc web telemetry) so the
@@ -653,52 +696,67 @@ __attribute__((noinline)) void render_screen_cpu(bool entered) {
const int t10 = (int)(temp_c * 10.0f + (temp_c >= 0 ? 0.5f : -0.5f));
snprintf(buf, sizeof(buf), "Temp : %d.%d C", t10 / 10,
(t10 < 0 ? -t10 : t10) % 10);
draw_text(0, 42, buf);
draw_text(kContentX, 42, buf);
draw_text(0, 56, "K0=next K1=back");
flush_fb();
}
// △ rising edge on the Trigger Test screen cycles trigger_preset and
// re-applies the new effect to the paired controller. KEY1 used to do
// this; moving it to the controller frees K0/K1 for navigation only.
void triggers_handle_input() {
if (!bt_is_connected()) { triggers_last_face = 0; return; }
const uint8_t face = interrupt_in_data[7] & 0xF0;
const bool tri_now = (face & 0x80) != 0;
const bool tri_prev = (triggers_last_face & 0x80) != 0;
if (tri_now && !tri_prev) {
trigger_preset = (trigger_preset + 1) % kNumTrigPresets;
send_trigger_effect(trigger_preset);
}
triggers_last_face = face;
}
__attribute__((noinline)) void render_screen_triggers() {
triggers_handle_input();
fb_clear();
draw_text(0, 0, "Trigger Test");
draw_text(kContentX, 0, "Trigger Test");
char buf[24];
snprintf(buf, sizeof(buf), "Mode: %s", kTrigPresetNames[trigger_preset]);
draw_text(0, 12, buf);
draw_text(kContentX, 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);
draw_text(kContentX, 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);
rect_outline(kContentX, 35, 56, 9);
int lfill = (l2 * 52) / 255;
if (lfill > 0) rect_filled(kContentX + 2, 37, lfill, 5);
rect_outline(72, 35, 56, 9);
int rfill = (r2 * 52) / 255;
if (rfill > 0) rect_filled(74, 37, rfill, 5);
} else {
draw_text(0, 24, "(no controller)");
draw_text(kContentX, 24, "(no controller)");
}
draw_text(0, 56, "K0=next K1=cycle");
draw_text(kContentX, 56, "Tri=cycle");
flush_fb();
}
__attribute__((noinline)) void render_screen_gyro() {
fb_clear();
draw_text(0, 0, "Gyro Tilt");
draw_text(kContentX, 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);
snprintf(buf, sizeof(buf), "X%+5d", ax); draw_text(kContentX, 10, buf);
snprintf(buf, sizeof(buf), "Y%+5d", ay); draw_text(50, 10, buf);
snprintf(buf, sizeof(buf), "Z%+5d", az); draw_text(94, 10, buf);
const int bx = 44, by = 22, bw = 40, bh = 40;
rect_outline(bx, by, bw, bh);
@@ -714,16 +772,16 @@ __attribute__((noinline)) void render_screen_gyro() {
if (cy > by + bh - 3) cy = by + bh - 3;
rect_filled(cx - 1, cy - 1, 3, 3);
} else {
draw_text(0, 30, "(no controller)");
draw_text(kContentX, 30, "(no controller)");
}
flush_fb();
}
__attribute__((noinline)) void render_screen_touchpad() {
fb_clear();
draw_text(0, 0, "Touchpad");
draw_text(kContentX, 0, "Touchpad");
if (bt_is_connected()) {
rect_outline(4, 12, 120, 30);
rect_outline(kContentX + 2, 12, 116, 30);
int active = 0;
for (int finger = 0; finger < 2; finger++) {
const int off = 32 + finger * 4;
@@ -735,9 +793,9 @@ __attribute__((noinline)) void render_screen_touchpad() {
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 sx = (kContentX + 3) + ((int)fx * 110) / 1919;
int sy = 13 + ((int)fy * 26) / 1079;
if (sx < 5) sx = 5;
if (sx < kContentX + 3) sx = kContentX + 3;
if (sx > 122) sx = 122;
if (sy < 13) sy = 13;
if (sy > 40) sy = 40;
@@ -746,11 +804,10 @@ __attribute__((noinline)) void render_screen_touchpad() {
}
char buf[20];
snprintf(buf, sizeof(buf), "Fingers: %d", active);
draw_text(0, 46, buf);
draw_text(kContentX, 46, buf);
} else {
draw_text(0, 30, "(no controller)");
draw_text(kContentX, 30, "(no controller)");
}
draw_text(0, 56, "K0=next");
flush_fb();
}
@@ -803,9 +860,25 @@ const char* lb_mode_tag(int mode) {
}
}
// R1 rising edge on Lightbar cycles lb_mode. Used to be KEY1; that moved
// to back-nav. Triangle on this screen stays as "save current RGB to
// favorite slot 0" (the existing favorite-save UX), so R1 is the next
// free button that doesn't break a mental model.
void lightbar_handle_input() {
if (!bt_is_connected()) { lb_last_buttons = 0; return; }
const uint8_t btns = interrupt_in_data[8];
const bool r1_now = (btns & 0x02) != 0;
const bool r1_prev = (lb_last_buttons & 0x02) != 0;
if (r1_now && !r1_prev) {
lb_mode = (lb_mode + 1) % kNumLbModes;
}
lb_last_buttons = btns;
}
__attribute__((noinline)) void render_screen_lightbar() {
lightbar_handle_input();
fb_clear();
draw_text(0, 0, "Lightbar");
draw_text(kContentX, 0, "Lightbar");
draw_text(86, 0, lb_mode_tag(lb_mode));
if (bt_is_connected()) {
@@ -853,14 +926,14 @@ __attribute__((noinline)) void render_screen_lightbar() {
}
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);
snprintf(buf, sizeof(buf), "R:%3u", lb_r); draw_text(kContentX, 12, buf);
snprintf(buf, sizeof(buf), "G:%3u", lb_g); draw_text(48, 12, buf);
snprintf(buf, sizeof(buf), "B:%3u", lb_b); draw_text(90, 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);
rect_outline(kContentX, by, 38, bh); int rf = (lb_r * 34) / 255; if (rf > 0) rect_filled(kContentX + 2, by + 2, rf, bh - 4);
rect_outline(48, by, 38, bh); int gf = (lb_g * 34) / 255; if (gf > 0) rect_filled(50, by + 2, gf, bh - 4);
rect_outline(90, by, 38, bh); int bf = (lb_b * 34) / 255; if (bf > 0) rect_filled(92, 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;
@@ -877,47 +950,46 @@ __attribute__((noinline)) void render_screen_lightbar() {
lb_fav_b[save_slot] = lb_b;
}
draw_text(0, 38, "Sv:T=0 C=1 X=2 S=3");
draw_text(kContentX, 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);
draw_text(kContentX, 48, hint);
send_lightbar_color(lb_r, lb_g, lb_b);
} else {
draw_text(0, 30, "(no controller)");
draw_text(kContentX, 30, "(no controller)");
}
draw_text(0, 56, "K0=next K1=cycle");
draw_text(kContentX, 56, "R1=mode");
flush_fb();
}
__attribute__((noinline)) void render_screen_vu() {
fb_clear();
draw_text(0, 0, "Audio Meters");
draw_text(kContentX, 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);
draw_text(kContentX, 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);
draw_text(kContentX, 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");
draw_text(kContentX, 42, "Live USB audio peaks");
} else {
draw_text(0, 30, "(no controller)");
draw_text(kContentX, 30, "(no controller)");
}
draw_text(0, 56, "K0=next");
flush_fb();
}
@@ -1088,7 +1160,7 @@ __attribute__((noinline)) void render_screen_settings() {
fb_clear();
char buf[24];
snprintf(buf, sizeof(buf), "Settings %s", settings_dirty ? "(*)" : " ");
draw_text(0, 0, buf);
draw_text(kContentX, 0, buf);
if (settings_save_status[0]) {
draw_text(86, 0, settings_save_status);
}
@@ -1099,15 +1171,15 @@ __attribute__((noinline)) void render_screen_settings() {
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);
draw_text(kContentX, 9 + i * 9, line);
}
if (settings_sel == kSettingsResetIdx) {
draw_text(0, 56, "Hold Tri 2s = RESET");
draw_text(kContentX, 56, "Hold Tri 2s = RESET");
} else if (settings_sel == kSettingsWipeSlotsIdx) {
draw_text(0, 56, "Hold Tri 2s = WIPE");
draw_text(kContentX, 56, "Hold Tri 2s = WIPE");
} else {
draw_text(0, 56, "DP nav/adj Tri=save");
draw_text(kContentX, 56, "DP nav/adj Tri=save");
}
flush_fb();
}
@@ -1183,7 +1255,7 @@ __attribute__((noinline)) void render_screen_slots() {
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);
draw_text(kContentX, 0, hdr);
if (slots_status[0] && (uint32_t)time_us_32() < slots_status_until_us) {
draw_text(80, 0, slots_status);
@@ -1201,10 +1273,10 @@ __attribute__((noinline)) void render_screen_slots() {
} else {
snprintf(line, sizeof(line), "%s%d%s (empty)", cursor_mark, i, active_mark);
}
draw_text(0, 9 + i * 9, line);
draw_text(kContentX, 9 + i * 9, line);
}
draw_text(0, 56, "Tri=switch Sq hold=wipe");
draw_text(kContentX, 56, "Tri=switch Sq hold=wipe");
flush_fb();
}