Compare commits

..
Author SHA1 Message Date
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
15 changed files with 294 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
+31
View File
@@ -10,6 +10,37 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [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`).
+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

+183 -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,7 @@ __attribute__((noinline)) void render_screen() {
const bool connected = bt_is_connected();
draw_text(0, 0, "DS5 Bridge v0.6.0");
draw_text(kContentX, 0, "DS5 Bridge v0.6.0");
draw_icon(120, 0, connected ? kIconLinkOn : kIconLinkOff, 8, 8);
if (connected) {
@@ -450,7 +488,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 +500,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 +515,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 +527,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 +556,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 +571,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 +612,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 +633,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 +655,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 +671,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 +682,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 +693,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 +769,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 +790,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 +801,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 +857,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 +923,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 +947,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 +1157,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 +1168,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 +1252,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 +1270,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();
}