Compare commits

...
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.7 219a9dd58a docs(changelog): cut v0.6.4 — idle power ladder + trigger-flow diagnostics
Issue #3: screen-stays-on fixed by the idle power ladder; trigger-flow diagnostics (host02/trig/tx counters + mic_diag.sh bt-trace) added to triage the missing adaptive-trigger tension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:08:32 -06:00
MarcelineVPQandClaude Opus 4.7 69a0cd4ddb feat(diag): host-side trigger-flow triage via 0xFD + mic_diag.sh bt-trace
Extends the firmware's 0xFD vendor feature report from 32 to 44 bytes
to carry the three host -> dongle -> BT trigger counters added in
5da1be4 (host_out02_total / host_out02_trig_allow / host_out02_to_bt).
mic_diag.sh's bt-trace decoder reads them and prints a one-line
verdict so the user can triage issue #3 in one terminal command
instead of switching to the OLED Diagnostics screen mid-game.

Verdicts encoded:
  host02 > 0, trig == 0       -> host driver never sets the trigger
                                 Allow bits; not a firmware problem.
  trig > 0, tx < trig         -> trigger Allow bits arrive but the
                                 speaker-active gate in main.cpp
                                 swallowed them.
  full chain populated        -> dongle delivered, controller didn't
                                 actuate; Sony BT-side limit.

The 0xFD report ID stays undeclared in the HID descriptor — Linux
hidraw ioctls don't enforce that and WebHID never sees these reports
(config goes through 0xF6). bt-trace's IOCTL_SIZE bumped to 45
(1 byte for the kernel-prepended report ID + 44 bytes payload).

Docs:
  README.md   - new "Diagnostics & debug tooling" section walks
                through the four mic_diag.sh subcommands; previously
                the script was only mentioned inside
                BLUETOOTH_AUDIO_NOTES.md.
  README.md   - Diagnostics-screen description updated for the
                scrolling row-list + cross-link to bt-trace.
  CLAUDE.md   - "Where features live" gets a host-side-diagnostics
                entry explaining how to extend the 0xFD payload and
                wire it into bt-trace's decoder.
  CHANGELOG   - records both the firmware extension and the README
                discoverability fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:21:00 -06:00
MarcelineVPQandClaude Opus 4.7 c6f490ad8e docs: record idle power ladder, trigger counters, and scrolling Diag screen
CHANGELOG [Unreleased]:
- Added: OLED idle power ladder (breathing dot @ 2 min, full off @ 15 min).
- Added: host02 / trig / tx trigger-flow counters for issue #3 triage.
- Added: D-pad scrolling Diagnostics screen.
- Changed: flush_fb split into flush_fb_raw + chrome wrapper.
- Changed: Diagnostics row order re-prioritized for trigger triage.

README — replace stale "auto-dim after 5 min idle" line with the
new three-stage power ladder description.

CLAUDE.md — new "Idle power ladder" paragraph in the Architecture
section (state machine + wake events + why contrast-register dim
doesn't work on this panel), and a new entry in "Where features
live" for adding diagnostic rows.

There's a stray older `[Unreleased]` heading at CHANGELOG.md:112
that looks like leftover release content (speaker/haptic regression
fix + Audio Auto Haptics) which actually shipped in an earlier tag.
Not touched here; flagging for a future CHANGELOG hygiene pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:14:16 -06:00
MarcelineVPQandClaude Opus 4.7 5da1be498e feat(diag): trigger-flow counters + scroll-only Diagnostics screen
Issue #3 (Few things — Triggers and Screen) reports that adaptive
trigger tension is missing through the dongle. The send_trigger_effect
preset cycle on the OLED Trigger Test screen works fine, so the BT
forwarding path is sound — meaning the loss is either at the host
(driver / Steam Input / game not emitting trigger output) or in our
forwarding of the host's 0x02 output report. Adds counters so we can
tell which.

Counters in main.cpp's tud_hid_set_report_cb 0x02 handler:
  - host_out02_total       — every 0x02 OUT report seen
  - host_out02_trig_allow  — those where the host set
                             AllowRight/LeftTriggerFFB
                             (valid_flag0 bits 2 & 3)
  - host_out02_to_bt       — 0x02 reports forwarded as BT 0x31

Diagnosis matrix:
  total grows, trig_allow flat → host driver never sets the trigger
                                 Allow bits = host-side issue
  trig_allow grows, to_bt lags → we're dropping (audio path stealing
                                 the BT pipe while speaker is active)
  all three grow proportionally → forwarding works; controller is
                                  receiving but not actuating
                                  = Sony / DS5 firmware limit

Same flash gets a scroll-only Diagnostics screen — the old layout
was already at the 6-row ceiling and three new counters push it over.
Rows are now a single switch-formatted list (10 rows), 5 visible at
a time, D-pad up/down scrolls. Caret glyphs at the right edge mark
"more above/below". Read-only screen so no cursor (unlike Settings).

Order is by triage relevance: Uptime, BT state, host02, trig/tx,
BT31 in/s fit on the default view; audio + parked-mic counters are
below the fold.

flush_fb split earlier (flush_fb_raw + flush_fb) is unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:08:49 -06:00
MarcelineVPQandClaude Opus 4.7 0a2841bbfe feat(oled): idle power ladder — breathing dot at 2 min, full off at 15 min
Replaces the single-tier auto-dim (5 min, contrast 0x10) with a three-tier
ladder that actually protects the OLED:

- 0–2 min idle: full brightness per bright_idx (unchanged).
- 2 min: clear the panel and render a 2×2 "breathing" dot (1 s on / 1 s
  off) that walks through 8 evenly-spaced screen positions every ~30 s.
  Drops contrast to 0x01. The dot is the indicator that the dongle is
  alive; rotating its position prevents any single pixel from
  accumulating wear.
- 15 min: SH1107 display-off (cmd 0xAE). Panel fully dark.

Wake on KEY0/KEY1, controller pair (BT-connect rising edge), or any
controller input report change.

Why this shape: bench-tested 0x10 and 0x02 contrast values — both
looked nearly identical to full brightness because the SH1107 contrast
register has a heavily non-linear perceptual curve on this Waveshare
panel. The only reliable per-pixel "dim" available is to render fewer
pixels. The breathing dot lights ~4 of 8192 pixels half the time,
which is roughly a 1000× drop in cumulative current vs the bright
Status screen.

flush_fb split into flush_fb_raw + flush_fb so the dim-tier renderer
can push the framebuffer without the chrome arrows (no navigation
target while sleeping).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:34:35 -06:00
MarcelineVPQandClaude Opus 4.7 4a984c8347 docs(mic): add encrypted-channel hypothesis to BT-audio notes
Most plausible explanation for the negative-evidence pattern (kernel
gave up, public RE blocked, our matching every documented "enable" bit
got nothing back): the DS5 ↔ PS5 mic channel is encrypted with a
session key derived during pairing, on a Sony-proprietary L2CAP PSM.

Sony's incentives all align with this — voice chat data from a $40
third-party dongle routed to an attacker is a worst-case PR / GDPR
scenario, and anti-spoofing is its own threat model.

Mechanism that fits: BT-Classic SSP link key → Sony-proprietary KDF →
audio-channel session key (likely AES-CCM). PS5 firmware on both ends
knows the KDF; third parties don't. Even a BT sniffer would see only
encrypted payload blobs without the KDF.

Re-frames "we can't get mic over BT" from "we haven't tried hard
enough" to "the architecture is intentionally hardened" — a clear
answer to give users + a clear bar for anyone wanting to actually
pursue this.

Credit: hypothesis suggested by MarcelineVPQ ("its probably encrypted,
I think the microphone over bluetooth is a security concern for sony").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:57:20 -06:00
MarcelineVPQandClaude Opus 4.7 e0a86a8983 fix(diag): mic_diag.sh — strip kernel-prepended report ID byte
bt-trace was reading kernel-prepended 0xFD as the low byte of bt_31
counter (and shifting all subsequent fields by 1), producing
nonsensical rates like 200000/s and frame lengths of 20224 bytes.
Slicing buf[1:] before decoding gives the firmware-supplied payload
bytes aligned to the documented 0xFD layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:56:24 -06:00
MarcelineVPQandClaude Opus 4.7 0429bd6703 docs(mic): park BT-mic investigation; document findings + USB workaround
Park the DualSense-microphone-over-Bluetooth feature. Investigation
log + diagnostic infrastructure ship as-is for a future contributor
who wants to take another crack at it.

BLUETOOTH_AUDIO_NOTES.md (new): full hand-off doc covering what we
tried, what we found, where Sony / the kernel / the public RE
community currently stand on DS5 BT mic. Highlights:

- Real DS5 mic works fine over USB; our dongle's UAC1 descriptor
  matches the DS5's exactly. The data path is correct.
- DS5 firmware on the test controller (build date Jul 4 2025) does
  not stream microphone audio over the standard BT-HID L2CAP
  channels. Confirmed by: every BT input report is a standard 79-
  byte 0x31 report with no audio appended; no alternative report
  IDs ever arrive; no bit of byte 2 correlates with mic-tagged
  frames; bidirectional-audio hypothesis (aplay + arecord) did not
  trigger streaming.
- Upstream awalol/DS5Dongle 'mic' branch's RE — 71-byte Opus at
  data + 4 of frames where (data[2] >> 1) & 1 — appears to be
  firmware-revision-specific; bit 1 of byte 2 is never set on the
  current firmware.
- Linux kernel hid-playstation.c line ~1509 explicitly says
  "Bluetooth audio is currently not supported" — the kernel
  maintainers reached the same conclusion.
- Our state config (AllowAudioControl=1, MicSelect=Internal,
  VolumeMic=0x40, MicMute=0, AudioPowerSave=0) matches exactly
  what the kernel driver sends to enable the mic over USB. The
  bytes are right; the BT-side trigger we're missing is not in
  the standard DS5 protocol.

Diagnostic infrastructure stays in firmware (mic_diag.sh,
vendor feature reports 0xFD/0xFE, OLED Diagnostics rate counters,
Opus-decoder + mic_fifo wired but gated behind `if (false)` in
on_bt_data). Zero cost when mic-add is disabled; ready to flip back
on the moment someone identifies the right trigger.

README.md: short "known issue" entry pointing users at USB-direct
when they need the mic, and at BLUETOOTH_AUDIO_NOTES.md for the
research log.

Possible next steps for the curious:
- SDP browse the DS5 to discover non-HID L2CAP services
- BT sniffer (~$50-200) between a real PS5 and a DS5 during voice
  chat — would settle the protocol question definitively
- Test with an older DS5 firmware revision (pre-2024) to see if
  the BT mic path used to work and got removed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:56:00 -06:00
MarcelineVPQandClaude Opus 4.7 72f163ca50 wip(mic): BT-side mic capture infrastructure + host-side diag
In-progress work on DualSense microphone capture over BT. Mic-add tap
itself is disabled (was decoding standard input bytes as Opus and
producing INT16_MIN garbage on the USB IN endpoint) but everything
around it is wired and ready to re-enable once we identify the actual
mic transport.

Firmware:
- src/audio.cpp: Opus decoder on core0, mic_fifo queue, audio_loop
  mic-in path with decode + mono->stereo + tud_audio_write. Decoder
  init in audio_init() (creates 48kHz mono OpusDecoder).
- src/audio.h: exports mic_add_queue() + per-frame diagnostic
  accessors (audio_mic_frames, last_decoded, last_want, last_wrote,
  last_toc).
- src/main.cpp on_bt_data(): BT-side instrumentation — counts every
  INTERRUPT input report, tracks min/max length, OR mask of byte[2],
  most recent non-0x31 report ID, hex prefix of last 0x31/other/any
  frame, full content of the longest 0x31 frame seen. Mic-tap call
  itself stubbed behind `if (false)` pending the real detector.
- src/state_mgr.cpp: state_init_data byte 6 (VolumeMic) 0xFF→0x40
  (was out of range), byte 9 (MuteControl) 0x0F→0x00 (clear all
  PowerSave bits — AudioPowerSave was muting DSP).
- src/cmd.cpp: two new vendor feature reports — 0xFD returns 32-byte
  diagnostic state (counters + prefixes), 0xFE returns the longest
  0x31 frame in full (up to 80 bytes). Both queryable via
  /dev/hidraw on Linux from the host script.
- src/oled.cpp: Diagnostics screen shows TOC + decode result + USB
  wrote/want bytes for live BT-side visibility.

Host-side:
- scripts/mic_diag.sh: subcommands `status`, `capture [secs]`,
  `watch`, `bt-trace`. The bt-trace subcommand reads the 0xFD
  feature report via hidraw ioctl, decodes counters + recent
  prefixes, computes per-second rates. Drastically cuts iteration
  time — no OLED relay or per-test flash cycle needed.

Findings to date:
- Upstream/mic's mic-flag bit ((data[2] >> 1) & 1) does NOT match
  this DS5 firmware; bit 1 of byte[2] is NEVER set. Bit 0 is the
  standard input report type indicator, not a mic tag — confirmed
  by stick-bytes appearing as our supposed "Opus prefix".
- DS5 sends both report ID 0x01 and 0x31 over BT; the longest frame
  is a standard 79-byte 0x31 input report with sticks/IMU/touchpad
  but no audio bytes appended.
- Conclusion in progress: the DS5 firmware on this controller is
  not currently streaming mic over BT at all, even with
  AllowAudioControl=1, VolumeMic=0x40, AudioPowerSave=0,
  MicMute=0. Next investigation step: compare against a USB-mode
  DS5 to see what a real mic stream looks like at the UAC1 layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:35:08 -06:00
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
MarcelineVPQandClaude Opus 4.7 05ea46cbd3 docs(changelog): cut v0.6.1-oled-edition
CPU/Clock OLED diagnostics screen (PR #2) + battery_led disconnect
fix, on top of v0.6.0-oled-edition. Promotes the [Unreleased] section
to [0.6.1-oled-edition] with today's date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:02:55 -06:00
63fcfb91ea feat(oled): add CPU / Clock diagnostics screen
New CPU/Clock OLED screen (kScreenCpu), between Diagnostics and BT
Signal in the K0 cycle. Shows:

- Set  - configured system clock (SYS_CLOCK_KHZ overclock target)
- Real - clk_sys from the RP2350 frequency counter, measured once on
  screen entry and cached (it is fixed at boot; avoids a per-frame
  multi-ms stall)
- Vcore - core voltage read back via vreg_get_voltage()
- Temp - on-die sensor via cpu_temp_raw_smoothed()

render_screen_cpu() is noinline like the other render functions (Thumb
literal-pool reach). Hardware-in-the-loop verified on Pico 2 W +
Waveshare Pico-OLED-1.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:31:13 -06:00
5425b21f00 feat(cpu): smoothed RP2350 CPU/Clock telemetry + 0xfc HID report
Add cpu_temp_raw_smoothed() (cmd.cpp/cmd.h): 256-sample average over a
slow EMA (alpha=0.15) so the RP2350 on-die temperature converges to the
true value instead of chasing per-sample 12-bit ADC noise (~1 LSB =
0.47 C). Single source of truth, ADC initialised in one place.

Also export CPU/Clock telemetry on HID feature report 0xfc (set_khz,
cached real_khz, vreg code, ADC ch4 raw). The handler is dormant: live
read over WebHID is not feasible (Chrome requires the report declared
in the HID descriptor, and declaring the OLED Edition vendor reports
breaks DualSense enumeration on Windows). It is harmless and left for a
possible future non-WebHID consumer. Link hardware_adc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:31:13 -06:00
MarcelineVPQandClaude Opus 4.7 4552fd2b41 docs(readme): bump OLED screen count 10 → 11 for CPU/Clock
Follow-up to #2 — the PR added a CPU/Clock OLED screen and bumped
kNumScreens from 10 to 11, but README still advertised 10 screens
in two places (header blurb + Features bullet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:29:18 -06:00
MarcelineVPQandClaude Opus 4.7 f8d79b3007 ci(release): append SHA256SUMS to release notes + upload as an asset
Future releases get:
- A 'Checksums' section appended to the release body with sha256sum -c
  -compatible 'HASH  filename' lines for each .uf2 attached
- A SHA256SUMS.txt file uploaded alongside the .uf2 artifacts so users
  can verify with 'sha256sum -c SHA256SUMS.txt' after downloading

Web-config flasher already compares this against its own SHA-256 on the
'Use latest release' path, but the hashes on the release page let people
manually verify the bundled site UF2 too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:26:11 -06:00
MarcelineVPQandClaude Opus 4.7 b03415fdb8 ci(release): trigger web config rebuild on new firmware release
Adds a repository_dispatch step to the release workflow that pings the
DS5Dongle-OLED-Config-Web repo with event-type 'firmware-released' after
the UF2s are uploaded. The web repo's deploy.yml listens for that event
and re-runs its build, which re-fetches the latest release UF2 and
bundles it into the GH Pages site.

Requires a fine-grained PAT stored as WEB_REPO_DISPATCH_PAT secret on
this repo, scoped to:
  - Repository: MarcelineVPQ/DS5Dongle-OLED-Config-Web
  - Permissions: Actions: Write, Metadata: Read
If the secret isn't set, the step fails 401 and continue-on-error
swallows it — release upload still succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:30:50 -06:00
MarcelineVPQandClaude Opus 4.7 79d8841040 docs: CHANGELOG entries for v0.6.0-oled-edition tag + battery_led disconnect fix
Adds an [Unreleased] block for the fb68ea5 disconnect fix (cross-references
the upstream PR awalol/DS5Dongle#101), and a [0.6.0-oled-edition] entry
covering the GitHub release / tag that wraps the existing 0.6.0-rebase code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:54:57 -06:00
MarcelineVPQandClaude Opus 4.7 fb68ea5608 fix(batt-led): force LED off + disarm on disconnect
The low-battery blink would stay frozen mid-cycle (or briefly resume
during reconnect retries) after a controller died and lost connection,
because the stale-report check stopped toggling but never turned the
LED off, and the cached interrupt_in_data[52] still read low.

New `battery_led_on_disconnect()` clears blink state, forces the LED
off, and zeros last_report_us so the stale-check early-return blocks
any new blink until a fresh 0x31 report arrives on the next connection.
Called from bt.cpp's HCI_EVENT_DISCONNECTION_COMPLETE handler.

Stale-check in the tick also now forces LED off when it fires while a
blink was in progress (defense in depth for unclean disconnects).

Same bug exists in upstream awalol/DS5Dongle (their battery_led.cpp is
byte-identical to ours pre-fix) — Sura Academy reported it in their
Discord. Plan to send this back as a PR after we validate it locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:19:55 -06:00
29 changed files with 1428 additions and 288 deletions
+34 -2
View File
@@ -104,8 +104,40 @@ jobs:
cmake --build build/debug --target ds5-bridge
cp build/debug/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-debug-${{ github.event.release.tag_name }}.uf2"
- name: Upload UF2 files to release
- name: Compute UF2 checksums + append to release notes
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name }}
run: |
# Generate SHA256SUMS.txt next to the UF2s
( cd artifacts && sha256sum *.uf2 > SHA256SUMS.txt )
cat artifacts/SHA256SUMS.txt
# Append the same hashes (markdown-formatted) to the existing release body
BODY=$(gh release view "$TAG" --json body --jq .body)
{
printf '%s\n' "$BODY"
printf '\n---\n\n## Checksums\n\n```\n'
cat artifacts/SHA256SUMS.txt
printf '```\n'
} > /tmp/release-notes.md
gh release edit "$TAG" --notes-file /tmp/release-notes.md
- name: Upload UF2 files + SHA256SUMS to release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${{ github.event.release.tag_name }}" artifacts/*.uf2 --clobber
gh release upload "${{ github.event.release.tag_name }}" artifacts/*.uf2 artifacts/SHA256SUMS.txt --clobber
# Notify the web config repo so its GH Pages deploy rebuilds and
# bundles this fresh UF2 into the site. If the WEB_REPO_DISPATCH_PAT
# secret isn't configured on this repo, the dispatch call fails 401
# and continue-on-error swallows it — the web app keeps serving the
# previously-bundled UF2 until its own deploy is manually triggered.
- name: Trigger web config rebuild
continue-on-error: true
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.WEB_REPO_DISPATCH_PAT }}
repository: MarcelineVPQ/DS5Dongle-OLED-Config-Web
event-type: firmware-released
client-payload: '{"tag": "${{ github.event.release.tag_name }}", "title": "${{ github.event.release.name }}"}'
+7
View File
@@ -1,4 +1,11 @@
build
build-pr
.vscode
.idea
example
# Local/per-user scratch
.claude/
.directory
*.code-workspace
pr-review-*.md
+77
View File
@@ -0,0 +1,77 @@
# Bluetooth microphone investigation — current status
**TL;DR:** The DualSense's built-in microphone does **not** work when the controller is paired to this dongle over Bluetooth. It works fine when the controller is connected directly to a host over USB. This is a Sony / DS5-firmware-side limitation we currently can't work around without reverse engineering or BT-sniffer access to PS5 ↔ DS5 traffic. The same limitation is documented in the upstream Linux kernel driver (`drivers/hid/hid-playstation.c` line ~1509: *"Bluetooth audio is currently not supported"*).
This file is a hand-off / research log for the next person who tries.
## What does work
- **Direct USB-C from DS5 → host:** mic enumerates as a UAC1 IN endpoint at 48 kHz / 16-bit / 2 channels on `EP 0x82`, max packet 196 bytes. ALSA recognizes it as `card N: Controller [DualSense Wireless Controller]`. `arecord` captures real audio after raising the `Headset Capture Volume` mixer control (it defaults to 0 dB).
- **Our dongle's USB descriptor** correctly mirrors the DS5's UAC1 layout — same interfaces, same alt settings, same endpoint addresses, same packet sizes. Verified with `lsusb -v` against a real DS5.
- **All the firmware-side decode infrastructure for BT mic is in place** (Opus decoder, `mic_fifo` queue, `tud_audio_write` to the IN endpoint, mono → stereo duplication) — see `src/audio.cpp`. It's currently gated behind `if (false)` in `src/main.cpp`'s `on_bt_data()` because we have nothing to feed it.
## What doesn't, and why
The DS5 firmware on the test controller (build date `Jul 4 2025`, queried via feature report 0x20) **does not stream microphone audio over the standard BT-HID L2CAP channels** (PSM 0x11 control + 0x13 interrupt).
What we tried:
1. **Upstream `awalol/DS5Dongle` `mic` branch as reference.** That branch claims to extract a 71-byte Opus packet at `data + 4` of any BT input report where `(data[2] >> 1) & 1` is set. On our DS5 firmware, **bit 1 of byte 2 is never set** (verified across thousands of frames via the `g_31_b2_or` OR mask). The upstream RE was likely done on a different (older) DS5 firmware revision.
2. **Bit 0 of byte 2** matches roughly all standard input reports — confirmed by reading the supposed "mic prefix" via `0xFD` feature report and seeing live stick X/Y values (not Opus data). Not a mic flag.
3. **Frame length sweep.** Longest BT 0x31 frame we ever see is 79 bytes — a fully-decoded standard DS5 input report (sticks + IMU + touchpad + battery + sensor timestamp + trailing zeros). No audio bytes appended anywhere.
4. **Other report IDs.** Counted ALL incoming BT input reports by report ID. Only `0x01` (rare) and `0x31` (common). No 0x33 / 0x35 / 0x36 / 0x39 / etc. The DS5 isn't sending anything mic-shaped on a different ID.
5. **State configuration matching the kernel.** Set `AllowAudioControl=1`, `AllowMicVolume=1`, `AllowAudioMute=1`, `MicSelect=Internal`, `VolumeMic=0x40`, `MicMute=0`, `AudioPowerSave=0` — exactly what `hid-playstation.c` sets when calling its "Enable microphone" path. DS5 still doesn't stream.
6. **Bidirectional audio session hypothesis.** Maybe the DS5 only streams mic when there's also active speaker audio (`0x36` packets) flowing. Tested: ran `aplay /dev/zero` simultaneously with `arecord`. No change in BT-side counters, no new report IDs, no longer frames. Disproved.
7. **State refresh on host UAC1 alt-setting change.** Considered hooking `tud_audio_set_itf_cb(itf=2, alt=1)` to send the DS5 a fresh "enable mic" state update. Not implemented — given the kernel comment and our state matching the kernel's own "enable" sequence, this wouldn't have helped.
What we **did not** try (real next steps if anyone picks this up):
- **SDP browse the DS5** over BT after pairing. Discover what L2CAP PSMs / services it advertises beyond HID. If there's a Sony proprietary audio PSM we haven't subscribed to, that's where mic traffic might live.
- **Open additional L2CAP channels** (proprietary audio PSM if found, or standard ones like A2DP=0x19 / RFCOMM=0x03) and watch for unsolicited inbound data.
- **Compare DS5 firmware revisions.** Test with an older DS5 (pre-2024 manufacture) and see if it streams mic over BT — that would tell us whether Sony removed the feature or just nobody documented the protocol. (We only have one DS5; can't test.)
- **BT sniffer** between a PS5 console and a DS5 during voice chat. Tells us exactly what L2CAP channels and bytes Sony uses for mic. Equipment-intensive (~$50200 for an Ubertooth or commercial sniffer).
- **DS5 firmware disassembly.** Legally fraught, almost certainly EULA-violating.
## Strongest hypothesis: the channel is encrypted
The shape of all the negative evidence — kernel maintainers giving up, no public RE project succeeding, our matching every documented "enable" bit and getting nothing — strongly suggests the channel is **encrypted with a session key derived during pairing**, not just transported on an undocumented PSM. Sony's incentives line up perfectly:
- **PR / privacy:** a $40 third-party dongle routing a user's PS5 voice chat to a malicious host is a worst-case PR scenario. Encrypting the mic channel is the obvious defense.
- **GDPR-class regulation:** voice biometrics from a console controller over plaintext BT is the kind of thing EU regulators ask hard questions about.
- **Anti-spoofing:** prevents injecting fake mic data into a PS5 session, which is its own threat model.
Mechanism that fits the evidence:
- During pairing, BT Classic SSP produces a link key. The PS5 + DS5 firmware likely run a Sony-proprietary KDF on top of that link key to produce an audio-channel session key.
- Mic audio is transported on a Sony-allocated proprietary L2CAP PSM (not in the standard BT-SIG ranges) and encrypted with that session key (AES-CCM or similar).
- A third-party dongle could connect to the PSM if it knew the number, but without the KDF / session key the payload would be opaque encrypted blobs.
**Implication:** a BT sniffer might tell us the PSM and packet timing/sizes, but not the payload contents. Building a PS5-impersonating dongle that derives valid session keys would require either Sony system-software disassembly or DS5 firmware disassembly — legally fraught, and a much bigger undertaking than what this project is set up for.
This re-frames "we can't get mic over BT" from "we haven't tried hard enough" to "the architecture is intentionally hardened against exactly this." That's not nothing — it's a clear answer to give users who ask, and a clear bar to clear if anyone wants to actually pursue it.
## What we built that's useful regardless
These all stay shipped — they're general-purpose audio-debug infrastructure now:
- **`scripts/mic_diag.sh`** with subcommands `status`, `capture [secs]`, `watch`, `bt-trace`. Drives the entire diagnostic loop from the host without needing OLED-relay-through-the-user; reads vendor feature reports via `/dev/hidraw`.
- **Vendor HID feature report `0xFD`** (32 bytes): BT input-report counter, non-0x31 counter, last seen non-0x31 report ID, OR mask of byte 2 across 0x31 frames, length range, hex prefix of last frame.
- **Vendor HID feature report `0xFE`** (82 bytes): full content of the longest 0x31 frame seen, for byte-level inspection.
- **OLED Diagnostics screen** carries BT31/Mic rate + recent frame prefix + opus dec/wrote bytes — useful for any future audio-path debugging at the bench.
- **`src/audio.cpp`** mic-decode infrastructure (Opus decoder on core0, FIFO, mono → stereo duplication, `tud_audio_write` to IN endpoint). Disabled at the `mic_add_queue` call site, ready to re-enable the moment a real mic trigger is identified.
- **`src/state_mgr.cpp`** initial state corrected — `VolumeMic` was `0xff` (out of valid range per spec; max is `0x40`), `MuteControl` had all `*PowerSave` bits set which would have power-gated the audio DSP. These corrections don't enable BT mic but they're the right defaults regardless.
## References
- Linux kernel `drivers/hid/hid-playstation.c`. Quoted lines: ~14071420 (mic enable/disable), ~1509 (*"Bluetooth audio is currently not supported"*). [Raw source on GitHub mirror](https://raw.githubusercontent.com/torvalds/linux/master/drivers/hid/hid-playstation.c).
- PSDevWiki [DualSense HID Commands](https://www.psdevwiki.com/ps5/DualSense_HID_Commands) — has factory/manufacturer commands (report IDs 128, 129, 160, 164, 165) for BT patches and audio codec selection, but explicitly notes most "do not work with retail controllers". Not a path forward.
- Upstream `awalol/DS5Dongle` branch `mic` (commits `9c197fc feat: mic work`, `3829163 mic mono channel`). RE'd a working mic path for an older DS5 firmware revision; we ported the data plumbing but the BT-side trigger differs on current firmware.
- dualsensectl: `command_microphone on/off` sets `valid_flag0 |= DS_OUTPUT_VALID_FLAG0_AUDIO_CONTROL_ENABLE` and clears `DS_OUTPUT_POWER_SAVE_CONTROL_MIC_MUTE`. Same as what we already do on connect.
## For users asking about the mic
When users report "the mic doesn't work":
- **Plug the DS5 into the host via USB.** Mic works out of the box. You may need to raise the `Headset Capture Volume` mixer control if it defaults to 0 dB.
- **Over the dongle's Bluetooth pairing, the mic is currently a known limitation** — not something a firmware update on our side can fix without further reverse engineering of the DS5's proprietary BT audio path.
- The diagnostic tools in `scripts/mic_diag.sh` are available if you want to help reverse engineer this; PRs welcome.
+94
View File
@@ -6,6 +6,100 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [Unreleased]
---
## [0.6.4-oled-edition] — 2026-05-19
Trigger-flow diagnostics (in response to issue #3) + the OLED idle power ladder. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.4-oled-edition) (built by `.github/workflows/release.yml`).
### Added
- **OLED idle power ladder.** Replaces the single-tier 5-min auto-dim with a three-stage state machine: at 2 min idle the panel wipes black and a 2×2 "breathing dot" (1 s on / 1 s off) walks through 8 evenly-spaced positions every 30 s; at 15 min idle the SH1107 is sent `cmd(0xAE)` (display off) entirely. Wakes instantly on KEY0/KEY1, controller pair (BT-connect rising edge), or any input-report change. Why this shape: on the Waveshare panel, bench-testing `kDimContrast = 0x10` and `0x02` both produced only ~10 % perceptual reduction (SH1107's contrast register vs apparent brightness is heavily non-linear on this hardware), so the only reliable per-pixel dim available is *rendering fewer pixels*. The breathing dot lights ~4 of 8 192 pixels half the time — roughly a 1 000× drop in cumulative current — while still indicating "the dongle is alive," and the rotating position spreads OLED wear across the panel.
- **Trigger-flow diagnostic counters on the Diagnostics screen.** `host02` (total `0x02` HID OUT reports from host) / `trig` (those where the host set `AllowRight|LeftTriggerFFB` in `valid_flag0`) / `tx` (forwarded as BT `0x31` sub-`0x10`). Added in response to issue #3 ("trigger tension missing in Death Stranding 2"). Lets the user triage in one game session whether the dongle, the host driver, or the controller is the source of the missing adaptive-trigger effect — without a UART or BT sniffer.
- **Diagnostics screen now scrolls with the controller D-pad.** Refactored to a row-list (10 rows currently: Uptime / BT state / host02 / trig+tx / BT31 in/s / USB aud/s / BT32 out/s / Mic in/s / Mic dec=&w= / Mic prefix). 5 rows visible at a time; ▲/▼ glyphs at the right edge mark "more above/below." Read-only — no cursor, unlike Settings, since there's nothing to select.
- **Host-side trigger-flow triage via `scripts/mic_diag.sh bt-trace`.** The firmware's `0xFD` vendor feature report grew a second section (bytes 3243) with the trigger counters; `bt-trace`'s Python decoder now reads them and prints a one-line verdict — "host driver isn't setting Allow*TriggerFFB" / "trigger Allow bits set but speaker path stole the BT pipe" / "full chain reached the controller". Lets the user diagnose issue #3 without a UART cable or OLED-relay-per-flash.
- **`README.md` "Diagnostics & debug tooling" section** documents `scripts/mic_diag.sh` and its subcommands. The script existed but was only mentioned inside `BLUETOOTH_AUDIO_NOTES.md` — invisible to anyone who hadn't already read the parked-mic notes.
### Changed
- **`flush_fb()` split.** Internal refactor: `flush_fb_raw()` writes just the framebuffer; `flush_fb()` is now `draw_button_chrome() + flush_fb_raw()`. Lets the dim-tier renderer push the breathing dot without the K0/K1 chrome arrows (no navigation target while the panel is asleep).
- **Diagnostics row order re-prioritized.** The first 5 rows (always visible without scrolling) cover the most common triage path: Uptime / BT state / `host02` / `trig`+`tx` / `BT31 in/s`. Audio + parked-mic-investigation counters live below the fold.
---
## [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`).
### Added
- **CPU / Clock diagnostics screen** (`kScreenCpu`, inserted between Diagnostics and BT Signal in the K0 cycle). Shows the configured system clock (`SYS_CLOCK_KHZ` — the overclock target), the *actually running* `clk_sys` measured live by the RP2350 on-chip frequency counter against the crystal reference, the core voltage read back from the regulator (`vreg_get_voltage()`, not the compile-time constant), and the RP2350 on-die temperature (ADC input 4). Pure read-only instrumentation; one-time ADC bring-up and no other code path uses the ADC, so it is conflict-free. `render_screen_cpu()` is `noinline` like the other render functions (Thumb literal-pool reach). Adds `hardware_adc` to `target_link_libraries`. The frequency-counter measurement (a multi-ms busy-wait) runs **once on screen entry** and is cached — `clk_sys` is fixed at boot, so only the temperature refreshes per frame, avoiding a per-frame BT/audio hitch while the screen is visible. `oled_loop()` gained a generic `screen_entered` flag for this. Hardware-verified on Pico 2 W + OLED. Also exported over a new HID feature report **`0xfc`** (`src/cmd.cpp`) — 11 bytes: set_khz, cached real_khz, vreg code, ADC ch4 raw — so the web config emulator can show live CPU telemetry (volts/temp math done web-side to keep the firmware HID path float-free).
### Fixed (web telemetry — latent since the slots/diag reports landed)
- **CPU/Clock temperature was a single noisy ADC sample.** The RP2350 temp sensor has a shallow slope (1.721 mV/°C, ~1 LSB ≈ 0.47 °C) so a lone 12-bit reading swings several tenths of a degree per frame — the displayed value just mirrored the latest noisy sample instead of the true die temperature. New `cpu_temp_raw_smoothed()` in `src/cmd.cpp` averages a 256-sample block then runs a slow EMA (α=0.15, seeded on first call). It is the **single source of truth**: both `render_screen_cpu()` and the `0xfc` web telemetry call it, and the duplicated per-site ADC bring-up was removed (ADC now initialised in exactly one place). `oled.cpp` no longer touches the ADC directly (drops `hardware/adc.h`, adds `cmd.h`).
- **Live web telemetry over WebHID: not feasible on the target setup; abandoned.** A browser-side read-only diagnostic proved Chrome WebHID returns `NotAllowedError` for any report ID **not declared** in the parsed HID report descriptor (declared `0xF7`/`0xF8`/`0xF9` read fine; undeclared `0xFA`/`0xFB`/`0xFC` fail). Declaring them is therefore mandatory for the web read — but doing so (even applied atomically with a matching `wDescriptorLength`, correct bytes identical in shape to the working `0xF6``0xF9`, and a `bcdDevice` cache-bust bump) made the device fail to enumerate as a usable HID device on the user's real Windows machine in **two** independent attempts (Device Manager showed it; WebHID and the PlayStation Accessories app did not). The cloned DualSense HID report descriptor cannot be safely extended on this environment. Reverted to the original descriptor (`0x0141`/`0x01B5`, no vendor feature reports, `bcdDevice 0x0100`). Retained with **no USB impact**: the `0xfc` firmware handler and the temperature smoothing/`cpu_temp_raw_smoothed()`. The on-device CPU/Clock OLED screen is fully working and hardware-verified; the web preview's CPU screen stays on representative mock values (the slots/diagnostics web screens were never readable for the same root cause and are likewise mock-only when connected).
### Fixed
- **Low-battery LED keeps blinking after controller disconnect** (`fb68ea5`). When the DualSense's battery dropped low enough to trigger `battery_led_tick`'s blink and the controller subsequently disconnected (typically: battery fully depletes and the BT link drops), the Pico's onboard LED stayed frozen in whichever half-cycle it was in at the moment of disconnect; reconnect-retry windows could even briefly resume blinking. New `battery_led_on_disconnect()` clears blink state, forces LED off, and zeros `last_report_us` so the stale-check early-return blocks any new blink until a fresh 0x31 report arrives on the next connection. Stale-check in the tick also now forces LED off when it fires mid-blink (defense in depth for ungraceful disconnects). Reported by Sura Academy on Discord. Same bug present in upstream — sent back as [awalol/DS5Dongle#101](https://github.com/awalol/DS5Dongle/pull/101).
---
## [0.6.0-oled-edition] — 2026-05-17
Tagged release of the rebase below. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.0-oled-edition) (built by `.github/workflows/release.yml`). No code changes vs the rebase; tag exists so users can install from a stable artifact.
---
## [0.6.0-rebase] — 2026-05-17
Rebased onto upstream `awalol/DS5Dongle` `v0.6.0-hotfix`. All OLED Edition features preserved with no user-visible regression.
+31
View File
@@ -66,6 +66,8 @@ The development cadence is **one feature per UF2 + checkpoint with the user befo
**OLED add-on is optional and self-contained:** All 10 screens, the SH1107 SPI driver, the 5×7 font, the icon table, and the input handling live in `src/oled.cpp` (~30 KB). The only outward dependencies are read-only accessors (`bt_is_connected`, `bt_get_addr`, `audio_peak_*`, `bt_get_signal_strength`, the new slot accessors) and the global `interrupt_in_data[63]` (read-only for input visualization). When no OLED is wired, the SPI writes go nowhere — no init check needed.
**Idle power ladder (`oled_loop` tail):** Three-stage state machine (`OLED_ACTIVE``OLED_DIM``OLED_OFF`) driven by `last_activity_us`. `kAutoDimUs = 2 min` enters Dim — the regular per-screen render is replaced with `render_dim_pulse()`, which clears the framebuffer and walks a 2×2 dot through 8 positions every 30 s, blinking 1 s on / 1 s off. `kAutoOffUs = 15 min` enters Off — `cmd(0xAE)` puts the SH1107 to sleep and `oled_loop` returns early before rendering. Wakes on KEY0/KEY1 (via `handle_buttons` bumping `last_activity_us`), `bt_is_connected()` rising edge, or any change in the `interrupt_in_data[0..9]` hash. The dim tier uses `flush_fb_raw()` (the chrome-less variant) since there's no nav target while asleep. **Why this shape:** the SH1107 contrast register has a heavily non-linear perceptual curve on the Waveshare panel — even `0x02` looks ~90 % as bright as `0xFF`. The only reliable "dim" available is rendering fewer pixels.
**Multi-slot pairing (Phase G):** Storage is two-tier:
- **Link keys** stay in BTstack's TLV NVM (4 slots, unchanged from upstream).
@@ -95,6 +97,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).
@@ -109,6 +138,8 @@ When asked to modify behavior, the *first* file to read is usually one of:
- New BT pairing / connection state behavior → `src/bt.cpp` (HCI + L2CAP event handlers).
- New OLED screen or change to existing one → `src/oled.cpp`.
- New diagnostic counter on the OLED Diagnostics screen → bump `kNumDiagRows` in `src/oled.cpp` and add a `case` to `format_diag_row()` (single switch, one row per case). The screen scrolls automatically; no D-pad wiring needed. Counters that need rate-per-second arithmetic should be sampled in `sample_diag_rates()` and read from `g_diag_rates`. Counter globals themselves typically live in `src/main.cpp` next to `g_bt_31_packets` etc. with `extern` declarations near the top of `src/oled.cpp`.
- Host-side diagnostics → `scripts/mic_diag.sh` (Linux only, reads `/dev/hidraw` directly). Subcommands: `status` / `capture [secs]` / `watch` / `bt-trace`. `bt-trace` reads the firmware's `0xFD` vendor feature report (defined in `src/cmd.cpp`'s `tud_hid_get_report_cb`), which currently exposes BT-input counters + the host-output trigger-flow counters. To add a new counter visible to `bt-trace`: extend the `0xFD` payload in `src/cmd.cpp` (bump `want`, write at the new offset), bump `IOCTL_SIZE` in `bt_trace()` of the script, and add the field to its `decode()` dict. The `0xFD` report ID is not declared in the HID descriptor (Linux hidraw ioctls don't enforce that; WebHID would reject undeclared IDs, which is why config goes through `0xF6`).
- New persistent config field → `src/config.h` (struct), `src/config.cpp:config_valid()` (defaults + clamping), `src/oled.cpp:format_settings_item()` (UI), `src/oled.cpp:settings_adjust()` (D-pad ▶◀ behavior). Update `CHANGELOG.md`.
- USB descriptor or interface change → `src/usb_descriptors.cpp` + `src/tusb_config.h`.
- Audio / haptic path → `src/audio.cpp`. **Don't** add stack arrays sized smaller than the resampler / Opus expects (this is the C1 bug that caused the long-standing "audio stuttering" issue — fix landed in upstream `5b04cbd`, but the lesson stands).
+9
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}")
@@ -171,6 +179,7 @@ target_link_libraries(ds5-bridge
hardware_timer
hardware_flash
hardware_spi
hardware_adc
pico_btstack_classic
# pico_cyw43_arch_threadsafe_background
pico_cyw43_arch_poll
+107 -128
View File
@@ -4,7 +4,27 @@
> Turn a Raspberry Pi Pico2W into a wireless adapter for the DualSense (DS5) controller — with an optional on-board status display.
> **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 10 screens (status, 4-slot multi-controller pairing, lightbar color picker with favorites and effect presets, trigger test, gyro tilt, touchpad, diagnostics, 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.
> **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
@@ -22,12 +42,12 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
**OLED Edition additions:**
- Optional Pico-OLED-1.3 status display with **10 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, RSSI, VU meters, settings)
- Optional Pico-OLED-1.3 status display with **11 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, RSSI, VU meters, settings)
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
- **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
- **OLED idle power ladder** — manual brightness cycle (KEY1 long-press), automatic deep-dim with a small breathing dot at 2 min idle, full display-off at 15 min idle. Wakes instantly on button, controller pair, or input. Real burn-in protection, not just a contrast tweak.
- **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
@@ -124,19 +144,22 @@ To opt out at build time, configure with `-DENABLE_BATT_LED=OFF`. Default is ON.
- Overclocking to 320 MHz @ 1.20 V is **required** for stable BT pairing. Dropping voltage to 1.10 V or clock to stock breaks the CYW43 PIO SPI bus and BT stops working. A small heatsink on the RP2350 is recommended for sustained gameplay.
- HD haptics may not fire in every game on Linux + Steam; this is game-side (some titles only send HD-haptic audio under Windows-specific APIs). Tested working in Spider-Man Remastered; not delivered in Ghost of Tsushima — same firmware, same controller.
- **DualSense microphone does not work over the Bluetooth pairing.** This is a Sony / DS5-firmware-side limitation also documented in the upstream Linux kernel driver (`drivers/hid/hid-playstation.c` line ~1509: *"Bluetooth audio is currently not supported"*). The mic works fine when the controller is connected directly to the host via USB-C. See [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md) for the full investigation log + what's already wired firmware-side if a future contributor cracks the BT-side trigger.
## 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
@@ -163,59 +186,59 @@ Build flags worth knowing:
- `-DENABLE_SERIAL=ON` — route printf to USB CDC for debugging (default OFF; releases UART for production builds).
- `-DPICO_W_BUILD=ON` — build for the original Pico W (drops audio, lowers clock). Default targets Pico 2 W.
## Diagnostics & debug tooling
Two ways to triage bridge issues — on-device via the OLED Diagnostics screen, and host-side via `scripts/mic_diag.sh` (Linux). The host-side path is faster: no screen-switching, no flash cycle, runs while the controller is in active use.
```
# One-shot snapshot — is the dongle on USB? Did ALSA enumerate it? Is the
# capture stream live? Is a controller currently paired?
scripts/mic_diag.sh status
# 3-second arecord on the mic IN endpoint — reports peak / RMS / non-zero
# count so we can tell "stream is silent" from "stream is producing audio".
scripts/mic_diag.sh capture 3
# Same as `status` but in a loop, prints only on state change. Useful for
# catching the exact second pairing completes or audio streams open / close.
scripts/mic_diag.sh watch
# Live read of the firmware's 0xFD vendor feature report (via /dev/hidraw):
# BT input counts + rates, last seen non-0x31 IDs, byte prefixes, AND the
# trigger-flow counters (host 0x02 received / with AllowTriggerFFB set /
# forwarded to BT). bt-trace prints a verdict — "host driver isn't sending
# trigger Allow bits" vs "forwarded but controller didn't actuate" — which
# is what would otherwise need a USB protocol analyzer.
scripts/mic_diag.sh bt-trace
```
Originally written to triage the parked DS5 BT-microphone investigation (see [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md)). The `0xFD` feature report and `bt-trace` decoder now also carry the trigger-flow counters added for [issue #3](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/issues/3) (missing adaptive trigger tension in some games).
## OLED Display Add-on (optional)
If you plug a [Waveshare Pico-OLED-1.3](#hardware) onto the Pico2W's headers, the firmware drives it automatically as a live status display. No configuration needed — the firmware no-ops gracefully when no OLED is present.
### 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 +249,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 +267,47 @@ 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).
Scrollable list of live counters — uptime, BT state, host → BT trigger flow (`host02` / `trig` / `tx`), BT 0x31 input rate, USB audio frames/sec, BT 0x32 packets/sec, and parked mic-investigation counters at the bottom. Controller D-pad ▲/▼ scrolls; tiny `^` / `v` glyphs at the right edge mark "more above/below." Read-only, so no cursor. Useful for verifying the bridge 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 │
└──────────────────────────────┘
```
The same counters are also exported on HID feature report `0xFD` for host-side tooling — see `scripts/mic_diag.sh bt-trace` below.
<img src="./assets/diagnostics_01.jpeg" alt="Diagnostics screen on the OLED" width="420">
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
#### 8. RSSI
#### 8. CPU / Clock
Live Bluetooth signal strength of the active link, in dBm with a bar. Closer to 0 dBm is stronger; 90 dBm is weak.
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).
#### 9. VU Meters
<img src="./assets/oled/oled_sc08.jpg" alt="CPU / Clock diagnostics screen on the OLED" width="420">
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 +315,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 +344,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

+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
# Mic-path host-side diagnostic for the DS5Dongle (OLED Edition).
#
# Subcommands:
# status — one-shot snapshot of dongle USB / ALSA / capture stream state.
# Prints whether the dongle enumerated, what ALSA card # it
# took, the capture stream's current alt setting + sync mode,
# and whether a paired DualSense is reachable.
# capture — runs a 3-second arecord on the mic IN endpoint, reports
# ALSA result code, captured byte count, and a non-silence
# indicator (peak abs sample value via Python's wave module).
# Tells us in one shot whether the firmware is producing
# actual isoc-IN data and whether anything audio-like is
# showing up.
# watch — loops `status` every 2 seconds, prints only on changes —
# useful for catching the moment pairing completes or the
# arecord stream opens/closes.
#
# Why a script: lets the assistant query mic-path state directly from
# the host rather than waiting for the user to relay OLED counters
# through chat, which dominated the early Phase-3 debugging time.
#
# Requirements (all already installed on the user's machine):
# - arecord (alsa-utils)
# - lsusb (usbutils)
# - python3 (for wave-file stats)
set -u
VID=054c
PID=0ce6
DEV_NAME_RE='DualSense Wireless Controller'
find_card() {
arecord -l 2>/dev/null | awk -v re="$DEV_NAME_RE" '
$0 ~ re {
for (i = 1; i <= NF; i++) {
if ($i == "card") { gsub(":", "", $(i+1)); print $(i+1); exit }
}
}'
}
show_status() {
local card
card="$(find_card)"
# USB layer — is the device visible?
if lsusb -d "${VID}:${PID}" >/dev/null 2>&1; then
printf 'usb: present (%s:%s)\n' "$VID" "$PID"
else
printf 'usb: NOT FOUND — is the dongle plugged in?\n'
return 1
fi
if [[ -z "$card" ]]; then
printf 'alsa: dongle is on USB but not exposed as an audio card\n'
return 1
fi
printf 'alsa: card %s\n' "$card"
# Capture stream details (interface 2 alt 1 mic-IN endpoint)
if [[ -r "/proc/asound/card${card}/stream0" ]]; then
# Grep just the Capture block so we see status + altset + endpoint
awk '/^Capture:/,0' "/proc/asound/card${card}/stream0" | head -10 | sed 's/^/ /'
else
printf ' (no /proc/asound/card%s/stream0 — older kernel?)\n' "$card"
fi
}
run_capture() {
local card secs="${1:-3}"
card="$(find_card)"
if [[ -z "$card" ]]; then
printf 'no dongle capture device found\n'
return 1
fi
local tmp
tmp="$(mktemp -t mic_diag.XXXXXX.wav)"
printf 'capturing %ss from card %s into %s ...\n' "$secs" "$card" "$tmp"
local err
err="$(arecord -q -D "plughw:${card},0" -f S16_LE -c 2 -r 48000 -d "$secs" "$tmp" 2>&1)"
local rc=$?
if (( rc != 0 )); then
printf 'arecord exit=%d: %s\n' "$rc" "$err"
rm -f "$tmp"
return "$rc"
fi
# Stats via Python — peak abs sample is enough to distinguish "stream
# produced silence" from "stream produced actual audio".
python3 - "$tmp" <<'PY'
import sys, wave, struct
path = sys.argv[1]
with wave.open(path, 'rb') as w:
nframes = w.getnframes()
sw = w.getsampwidth()
ch = w.getnchannels()
fr = w.getframerate()
raw = w.readframes(nframes)
nsamples = nframes * ch
fmt = '<' + ('h' * nsamples)
data = struct.unpack(fmt, raw)
peak = max(abs(s) for s in data) if data else 0
nonzero = sum(1 for s in data if s != 0)
rms = (sum(s*s for s in data) / max(len(data), 1)) ** 0.5
print(f'wav: {nframes} frames, {ch} ch, {sw*8}-bit, {fr} Hz')
print(f'samples: nonzero={nonzero}/{nsamples} peak={peak} rms={rms:.1f}')
if peak == 0:
print('verdict: STREAM IS SILENT — firmware not producing isoc-IN data')
elif peak < 100:
print('verdict: extremely quiet — possibly DC offset only')
else:
print('verdict: AUDIO PRESENT')
PY
rm -f "$tmp"
}
watch_status() {
local prev=""
while :; do
local now
now="$(show_status 2>&1)"
if [[ "$now" != "$prev" ]]; then
printf '\n=== %s ===\n%s\n' "$(date '+%H:%M:%S')" "$now"
prev="$now"
fi
sleep 2
done
}
bt_trace() {
# Query the firmware's 0xFD vendor feature report via /dev/hidraw.
# 0xFD carries two sections:
# Section 1 (bytes 0..31) — mic-investigation: BT 0x31 / non-0x31
# counts, byte[2] OR mask, frame prefixes. Originally used to
# locate the mic stream; kept for any future BT-input triage.
# Section 2 (bytes 32..43) — host -> dongle -> BT trigger flow
# counters (issue #3): host 0x02 OUT received total, of those
# where AllowRight/LeftTriggerFFB was set, and of those forwarded
# as BT 0x31 sub-0x10. Lets the user triage adaptive-trigger
# issues without needing an OLED in the loop.
# The ioctl buffer is 45 bytes (44 payload + 1 byte that the kernel
# fills with the report ID).
python3 - <<'PY'
import fcntl, glob, struct, sys, time
VID, PID = 0x054c, 0x0ce6
IOCTL_SIZE = 45 # 1 byte report ID + 44 bytes firmware payload
def find_dongle():
for path in sorted(glob.glob('/dev/hidraw*')):
try:
f = open(path, 'rb+')
buf = bytearray(IOCTL_SIZE); buf[0] = 0xFD
ioctl_num = (3 << 30) | (IOCTL_SIZE << 16) | (ord('H') << 8) | 0x07
try:
fcntl.ioctl(f, ioctl_num, buf)
return f
except OSError:
f.close()
except (OSError, PermissionError):
pass
return None
f = find_dongle()
if f is None:
print('no dongle found (or no /dev/hidraw permission)')
sys.exit(1)
def query():
buf = bytearray(IOCTL_SIZE); buf[0] = 0xFD
ioctl_num = (3 << 30) | (IOCTL_SIZE << 16) | (ord('H') << 8) | 0x07
fcntl.ioctl(f, ioctl_num, buf)
# Kernel prepends the report ID at byte 0; firmware payload starts at byte 1.
return bytes(buf[1:])
def decode(b):
return {
'bt31': struct.unpack('<I', b[0:4])[0],
'btoth': struct.unpack('<I', b[4:8])[0],
'other_id': b[8],
'other_or': b[9],
'b2_or': b[10],
'b2_last': b[11],
'lmin': struct.unpack('<H', b[12:14])[0],
'lmax': struct.unpack('<H', b[14:16])[0],
'othpfx': b[16:24].hex(),
'anypfx': b[24:32].hex(),
'host02': struct.unpack('<I', b[32:36])[0] if len(b) >= 36 else 0,
'host02_trig':struct.unpack('<I', b[36:40])[0] if len(b) >= 40 else 0,
'host02_tx': struct.unpack('<I', b[40:44])[0] if len(b) >= 44 else 0,
}
s1 = query(); time.sleep(1.0); s2 = query()
d1 = decode(s1); d2 = decode(s2)
# Mic-investigation section
bt31_rate = d2['bt31'] - d1['bt31']
btoth_rate = d2['btoth'] - d1['btoth']
print('-- BT input (mic investigation legacy) --')
print(f'rates: 0x31={bt31_rate}/s, non-0x31={btoth_rate}/s')
print(f'len range: {d2["lmin"]}-{d2["lmax"]} bytes')
print(f'byte[2] OR mask across 0x31 frames: 0x{d2["b2_or"]:02X} last=0x{d2["b2_last"]:02X}')
print(f'non-0x31 report IDs: OR mask=0x{d2["other_or"]:02X} most recent=0x{d2["other_id"]:02X}')
print(f'last non-0x31 prefix (data[0..7]): {d2["othpfx"]}')
print(f'last ANY frame (data[0..7]): {d2["anypfx"]}')
# Trigger-flow section
o02_rate = d2['host02'] - d1['host02']
trig_rate = d2['host02_trig'] - d1['host02_trig']
tx_rate = d2['host02_tx'] - d1['host02_tx']
print()
print('-- Host -> dongle -> BT trigger flow (issue #3) --')
print(f'host 0x02 OUT: total={d2["host02"]} ({o02_rate}/s)')
print(f' w/ AllowTrigFFB: total={d2["host02_trig"]} ({trig_rate}/s)')
print(f' forwarded to BT: total={d2["host02_tx"]} ({tx_rate}/s)')
if d2['host02'] > 0 and d2['host02_trig'] == 0:
print('verdict: host is sending 0x02 reports but never sets Allow*TriggerFFB.')
print(' The host driver is not requesting adaptive trigger effects.')
elif d2['host02_trig'] > 0 and d2['host02_tx'] < d2['host02_trig']:
print('verdict: trigger Allow bits are set but some reports are not reaching BT.')
print(' Likely the speaker-active gate in main.cpp swallowed them.')
elif d2['host02_trig'] > 0:
print('verdict: full chain reached the controller. Tension still missing -> Sony BT limit.')
PY
}
case "${1:-status}" in
status) show_status ;;
capture) shift; run_capture "${1:-3}" ;;
watch) watch_status ;;
bt-trace) bt_trace ;;
*)
printf 'usage: %s {status|capture [secs]|watch|bt-trace}\n' "$0" >&2
exit 2
;;
esac
+77
View File
@@ -25,6 +25,14 @@
// #define VOLUME_GAIN 2
// #define BUFFER_LENGTH 48
// DualSense microphone, ported from awalol/DS5Dongle's `mic` branch.
// The DS5 sends mic audio as Opus packets embedded in BT input report
// 0x31 when bit 1 of byte 2 is set; payload is 71 bytes of Opus at
// offset 4, decoded to mono 48 kHz 10 ms frames (480 samples).
#define MIC_CHANNELS 1
#define MIC_FRAMES 480
#define MIC_OPUS_SIZE 71
using std::clamp;
using std::max;
@@ -37,6 +45,21 @@ queue_t audio_fifo;
static uint8_t opus_buf[200];
critical_section_t opus_cs;
// Mic ingress queue — filled from on_bt_data() (BT poll, core0), drained
// at the top of audio_loop() on core0. The decoder is single-threaded
// (core0 only), so no critical section is needed around it.
queue_t mic_fifo;
struct mic_element { uint8_t data[MIC_OPUS_SIZE]; };
static OpusDecoder *mic_decoder = nullptr;
static volatile uint32_t g_mic_frames = 0;
static volatile int32_t g_mic_last_decoded = 0; // opus_decode return value
static volatile uint16_t g_mic_last_want = 0; // bytes we asked TinyUSB to send
static volatile uint16_t g_mic_last_wrote = 0; // bytes TinyUSB accepted
uint32_t audio_mic_frames() { return g_mic_frames; }
int32_t audio_mic_last_decoded() { return g_mic_last_decoded; }
uint16_t audio_mic_last_want() { return g_mic_last_want; }
uint16_t audio_mic_last_wrote() { return g_mic_last_wrote; }
struct audio_raw_element {
float data[512 * 2];
};
@@ -73,7 +96,51 @@ uint8_t audio_peak_haptic() {
return (uint8_t)(v >> 7);
}
// Most-recent Opus TOC byte (first byte of the packet). Used by the OLED
// Diagnostics screen to decode the frame's bandwidth + duration config
// without serial.
static volatile uint8_t g_mic_toc = 0;
uint8_t audio_mic_last_toc() { return g_mic_toc; }
// Push a 71-byte Opus mic packet from the BT handler into the mic_fifo.
// Called from src/main.cpp's on_bt_data() when the DS5 sends a mic-tagged
// 0x31 input report. Drops the oldest queued packet if the FIFO is full —
// preferring fresh audio over backlog on overload.
void mic_add_queue(const uint8_t *data) {
static mic_element packet{};
memcpy(packet.data, data, MIC_OPUS_SIZE);
g_mic_toc = data[0]; // first byte of the Opus packet
if (queue_is_full(&mic_fifo)) queue_try_remove(&mic_fifo, NULL);
queue_try_add(&mic_fifo, &packet);
}
void audio_loop() {
// Mic-in path: pull one Opus packet from the BT-side FIFO, decode to
// mono PCM, duplicate to stereo (our UAC1 endpoint declares 2 channels),
// push to the host via tud_audio_write. Runs once per loop iteration so
// it keeps up with the ~100 Hz arrival rate of mic-tagged BT frames.
if (mic_decoder != nullptr) {
static mic_element packet{};
if (queue_try_remove(&mic_fifo, &packet)) {
static int16_t mono[MIC_FRAMES];
const int decoded = opus_decode(mic_decoder, packet.data,
MIC_OPUS_SIZE, mono, MIC_FRAMES, 0);
g_mic_last_decoded = decoded; // observed in OLED Diag
if (decoded > 0) {
static int16_t stereo[MIC_FRAMES * 2];
for (int i = 0; i < decoded; i++) {
stereo[i * 2] = mono[i];
stereo[i * 2 + 1] = mono[i];
}
const uint16_t want = (uint16_t)(decoded * 2 * sizeof(int16_t));
const uint16_t wrote = tud_audio_write(stereo, want);
g_mic_last_want = want;
g_mic_last_wrote = wrote;
g_mic_frames++;
}
}
}
// 1. 读取 USB 音频数据
if (!tud_audio_available()) return;
@@ -253,6 +320,16 @@ void audio_init() {
critical_section_init(&opus_cs);
multicore_launch_core1_with_stack(core1_entry, audio_core1_stack, sizeof(audio_core1_stack));
#endif
// Mic path: queue + decoder live on core0 (audio_loop), separate from
// the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames.
queue_init(&mic_fifo, sizeof(mic_element), 2);
int dec_error = 0;
mic_decoder = opus_decoder_create(48000, MIC_CHANNELS, &dec_error);
if (dec_error != 0 || mic_decoder == nullptr) {
printf("[Audio] OpusDecoder create failed (err=%d)\n", dec_error);
mic_decoder = nullptr; // ensure audio_loop's null-guard short-circuits
}
}
static OpusEncoder *encoder;
+10
View File
@@ -21,5 +21,15 @@ uint8_t audio_peak_haptic(); // 0..255, decays on read
// Byte-flow counters for the Diagnostics screen + web emulator.
uint32_t audio_usb_frames();
uint32_t audio_bt_packets();
uint32_t audio_mic_frames(); // count of mic Opus frames decoded + written
int32_t audio_mic_last_decoded(); // last opus_decode return — neg = error, 480 = OK
uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write
uint16_t audio_mic_last_wrote(); // bytes TinyUSB FIFO actually accepted
uint8_t audio_mic_last_toc(); // first byte of last Opus packet (frame config)
// Called from on_bt_data() in main.cpp when the DS5 sends a mic-tagged
// 0x31 input report. Buffer must point at MIC_OPUS_SIZE (71) bytes of
// Opus payload.
void mic_add_queue(const uint8_t *data);
#endif //DS5_BRIDGE_AUDIO_H
+20 -2
View File
@@ -37,11 +37,29 @@ void battery_led_note_report(void) {
last_report_us = time_us_64();
}
void battery_led_on_disconnect(void) {
// Stop any in-progress blink and force the LED off immediately. Zero
// last_report_us so the tick's stale-check early-returns until a fresh
// 0x31 report arrives on the next connection — prevents the cached
// low-battery byte from re-arming a blink during reconnect retries.
blinking = false;
led_state = false;
last_report_us = 0;
last_toggle_us = 0;
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, false);
}
void battery_led_tick(void) {
const uint64_t now = time_us_64();
if (last_report_us == 0 || (now - last_report_us) >= REPORT_STALE_US) {
// No fresh data — bt.cpp owns the LED while disconnected.
blinking = false;
// No fresh data — bt.cpp owns the LED while disconnected. If we
// were mid-blink when the report went stale, force the LED off
// so it doesn't freeze in whichever half-cycle it was in.
if (blinking) {
blinking = false;
led_state = false;
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, false);
}
return;
}
+8
View File
@@ -16,3 +16,11 @@ void battery_led_tick(void);
// has been copied into interrupt_in_data. Used to detect disconnection
// via stale-report timeout.
void battery_led_note_report(void);
// Call from the BT disconnect handler. Cancels any in-progress blink,
// forces the LED off, and arms the module so it ignores the cached
// (now-stale) battery byte until a fresh report arrives on the next
// connection. Without this, the LED can stay frozen in whichever state
// it was at the moment of disconnect, or briefly resume blinking during
// reconnect retries while interrupt_in_data[52] still reads low.
void battery_led_on_disconnect(void);
+6
View File
@@ -19,6 +19,9 @@
#include "state_mgr.h"
#include "pico/util/queue.h"
#include "slots.h"
#if ENABLE_BATT_LED
#include "battery_led.h"
#endif
#define MTU_CONTROL 672
#define MTU_INTERRUPT 672
@@ -454,6 +457,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
hid_interrupt_cid = 0;
feature_data.clear();
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, false);
#if ENABLE_BATT_LED
battery_led_on_disconnect();
#endif
printf("[HCI] Disconnected reason=0x%02X, start inquiry\n", reason);
gap_inquiry_start(30);
break;
+150 -1
View File
@@ -14,6 +14,58 @@
#include "device/usbd.h"
#include "pico/time.h"
#include "slots.h"
#include "hardware/clocks.h"
#include "hardware/adc.h"
#include "hardware/vreg.h"
uint16_t cpu_temp_raw_smoothed() {
// One-time ADC bring-up. This is the only place the ADC is initialised
// now (oled.cpp's CPU screen calls through here too). Runs on core0
// under the cooperative main loop; adc_select_input(4) is set before
// every read, so the shared ADC needs no locking.
static bool adc_ready = false;
if (!adc_ready) {
adc_init();
adc_set_temp_sensor_enabled(true);
adc_ready = true;
}
adc_select_input(4);
// The temp sensor has a shallow slope (-1.721 mV/C) and ~1 LSB ≈ 0.47 C,
// so a lone 12-bit sample swings several tenths of a degree frame to
// frame. Average a big block to kill that...
constexpr int kSamples = 256;
uint32_t acc = 0;
for (int i = 0; i < kSamples; i++) acc += adc_read();
const float mean = (float)acc / (float)kSamples;
// ...then a slow EMA so the displayed value glides to the true die
// temperature rather than mirroring the latest block. Seeded on the
// first call so it doesn't ramp up from zero.
static float ema = -1.0f;
if (ema < 0.0f) ema = mean;
else ema += (mean - ema) * 0.15f;
return (uint16_t)(ema + 0.5f);
}
// Mic-debug globals (defined in main.cpp). File-scope extern so the
// linker resolves them once and cmd.cpp's 0xFD handler reads the same
// memory main.cpp writes to.
extern volatile uint32_t g_bt_31_packets;
extern volatile uint32_t g_bt_other_packets;
extern volatile uint8_t g_last_other_id;
extern volatile uint8_t g_other_id_or;
extern volatile uint8_t g_31_b2_or;
extern volatile uint8_t g_last_31_b2;
extern volatile uint16_t g_31_len_min;
extern volatile uint16_t g_31_len_max;
extern volatile uint8_t g_last_other_prefix[8];
extern volatile uint8_t g_last_any_prefix[16];
extern volatile uint16_t g_longest_len;
extern volatile uint8_t g_longest_frame[80];
extern volatile uint32_t g_host_out02_total;
extern volatile uint32_t g_host_out02_trig_allow;
extern volatile uint32_t g_host_out02_to_bt;
bool is_pico_cmd(uint8_t report_id) {
if (report_id == 0xf6 ||
@@ -21,7 +73,10 @@ bool is_pico_cmd(uint8_t report_id) {
report_id == 0xf8 ||
report_id == 0xf9 ||
report_id == 0xfa ||
report_id == 0xfb
report_id == 0xfb ||
report_id == 0xfc ||
report_id == 0xfd || // mic-debug counters
report_id == 0xfe // mic-debug longest-frame dump
) {
return true;
}
@@ -93,6 +148,100 @@ uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
memcpy(buffer + 14, &hci_errs, 4);
return want;
}
if (report_id == 0xfc) {
// OLED Edition: CPU / Clock telemetry for the web emulator. 11 bytes:
// [0..3] set_khz uint32 configured clk_sys (SYS_CLOCK_KHZ)
// [4..7] real_khz uint32 measured clk_sys (cached, see below)
// [8] vcode uint8 vreg_get_voltage() raw enum code
// [9..10] temp_raw uint16 ADC ch4 12-bit reading
// The web side does the volts/temperature math (same formulas as
// render_screen_cpu) so the firmware HID path stays float-free.
constexpr uint16_t want = 11;
if (reqlen < want) {
printf("[HID] 0xfc reqlen=%u too small for cpu payload (%u)\n", reqlen, want);
return 0;
}
const uint32_t set_khz = (uint32_t)SYS_CLOCK_KHZ;
// clk_sys is fixed at boot and frequency_count_khz() busy-waits a few
// ms — measure exactly once (lazily) and cache. Doing it here on the
// first poll keeps it off the boot path; one ~ms stall in a single
// GET_REPORT is acceptable.
static uint32_t cached_real_khz = 0;
if (cached_real_khz == 0) {
cached_real_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_SYS);
}
const uint16_t temp_raw = cpu_temp_raw_smoothed();
const uint8_t vcode = (uint8_t)vreg_get_voltage();
memcpy(buffer + 0, &set_khz, 4);
memcpy(buffer + 4, &cached_real_khz, 4);
buffer[8] = vcode;
memcpy(buffer + 9, &temp_raw, 2);
return want;
}
if (report_id == 0xfd) {
// Bridge-diagnostics feature report. 44-byte payload.
// Section 1: mic-investigation counters (original 0..31).
// [0..3] uint32 BT 0x31 input report count
// [4..7] uint32 BT non-0x31 input report count
// [8] uint8 last non-0x31 report ID seen
// [9] uint8 OR mask of all non-0x31 report IDs seen
// [10] uint8 OR mask of byte[2] across all 0x31 frames
// [11] uint8 last value of byte[2] in a 0x31 frame
// [12..13] uint16 min frame length seen
// [14..15] uint16 max frame length seen
// [16..23] uint8[8] first 8 bytes of last non-0x31 frame
// [24..31] uint8[8] first 8 bytes of most recent ANY frame
// Section 2: trigger-flow counters (issue #3 triage).
// [32..35] uint32 host 0x02 OUT reports received total
// [36..39] uint32 ...of those, with Allow*TriggerFFB set
// [40..43] uint32 ...forwarded as BT 0x31 sub-0x10
constexpr uint16_t want = 44;
for (uint16_t i = 0; i < want && i < reqlen; i++) buffer[i] = 0;
const uint32_t bt31 = g_bt_31_packets;
const uint32_t btother = g_bt_other_packets;
const uint16_t lmin = g_31_len_min == 0xFFFF ? 0 : g_31_len_min;
const uint16_t lmax = g_31_len_max;
memcpy(buffer + 0, &bt31, 4);
memcpy(buffer + 4, &btother, 4);
buffer[8] = g_last_other_id;
buffer[9] = g_other_id_or;
buffer[10] = g_31_b2_or;
buffer[11] = g_last_31_b2;
memcpy(buffer + 12, &lmin, 2);
memcpy(buffer + 14, &lmax, 2);
for (int i = 0; i < 8 && (16 + i) < reqlen; i++) buffer[16 + i] = g_last_other_prefix[i];
for (int i = 0; i < 8 && (24 + i) < reqlen; i++) buffer[24 + i] = g_last_any_prefix[i];
const uint32_t out02 = g_host_out02_total;
const uint32_t out02_t = g_host_out02_trig_allow;
const uint32_t out02_b = g_host_out02_to_bt;
if ((32 + 4) <= reqlen) memcpy(buffer + 32, &out02, 4);
if ((36 + 4) <= reqlen) memcpy(buffer + 36, &out02_t, 4);
if ((40 + 4) <= reqlen) memcpy(buffer + 40, &out02_b, 4);
return (reqlen < want) ? reqlen : want;
}
if (report_id == 0xfe) {
// 0xFE: full content of the LONGEST 0x31 frame seen. Bytes 0-1
// = length (uint16 LE), bytes 2+ = the captured frame bytes.
constexpr uint16_t want = 82; // 2 length + 80 frame bytes
const uint16_t lim = (reqlen < want) ? reqlen : want;
for (uint16_t i = 0; i < lim; i++) buffer[i] = 0;
const uint16_t llen = g_longest_len;
if (lim >= 2) {
buffer[0] = (uint8_t)(llen & 0xFF);
buffer[1] = (uint8_t)((llen >> 8) & 0xFF);
}
for (uint16_t i = 0; i < 80 && (i + 2) < lim; i++) {
buffer[2 + i] = g_longest_frame[i];
}
return lim;
}
return 0;
}
+7
View File
@@ -11,4 +11,11 @@ bool is_pico_cmd(uint8_t report_id);
uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer,uint16_t reqlen);
void pico_cmd_set(uint8_t report_id, uint8_t const *buffer,uint16_t bufsize);
// Smoothed RP2350 on-die temperature sensor reading (ADC input 4, 12-bit
// raw). A single sample is very noisy; this averages a large block and runs
// a slow EMA so the value converges to the true die temperature instead of
// chasing per-sample noise. Single source of truth — the OLED CPU screen and
// the 0xfc web telemetry both call this so device and web always agree.
uint16_t cpu_temp_raw_smoothed();
#endif //DS5_BRIDGE_CMD_H
+93 -6
View File
@@ -30,6 +30,50 @@ int reportSeqCounter = 0;
uint8_t packetCounter = 0;
bool spk_active = false;
// Mic-debug instrumentation: count every 0x31 BT input report regardless
// of mic-tag bit, accumulate OR-mask of every byte-2 value seen (tells us
// which bits ever fire) and remember the last byte-2 value. Also track
// observed frame-length range. Surfaced on the OLED Diagnostics screen.
volatile uint32_t g_bt_31_packets = 0;
volatile uint32_t g_bt_other_packets = 0;
volatile uint8_t g_last_other_id = 0;
volatile uint8_t g_other_id_or = 0;
volatile uint8_t g_last_31_b2 = 0;
volatile uint8_t g_31_b2_or = 0;
volatile uint16_t g_31_len_min = 0xFFFF;
volatile uint16_t g_31_len_max = 0;
volatile uint8_t g_mic_prefix[6] = {0};
volatile uint8_t g_last_other_prefix[8] = {0};
volatile uint8_t g_last_any_prefix[16] = {0};
volatile uint16_t g_longest_len = 0;
volatile uint8_t g_longest_frame[80] = {0};
uint32_t bt_31_packet_count() { return g_bt_31_packets; }
uint8_t bt_31_last_byte2() { return g_last_31_b2; }
uint8_t bt_31_b2_or_mask() { return g_31_b2_or; }
uint16_t bt_31_len_min() { return g_31_len_min == 0xFFFF ? 0 : g_31_len_min; }
uint16_t bt_31_len_max() { return g_31_len_max; }
void bt_31_mic_prefix(uint8_t out[6]) {
for (int i = 0; i < 6; i++) out[i] = g_mic_prefix[i];
}
// Trigger-flow diagnostics. Counts host → dongle → BT path for adaptive
// trigger effects. Lets us tell which link in the chain breaks when games
// like Death Stranding 2 don't produce trigger tension via the dongle:
// out02_total - every 0x02 HID OUT report received from host
// out02_trig_allow - of those, how many set AllowRight/LeftTriggerFFB
// (valid_flag0 bits 2 & 3) — i.e. the host actually
// told us "apply trigger FFB"
// out02_to_bt - 0x02 reports that we forwarded to the controller as
// a BT 0x31 sub-0x10 packet (gated off when speaker is
// active; audio.cpp's 0x36 path carries state then)
// Surfaced on the OLED Diagnostics screen.
volatile uint32_t g_host_out02_total = 0;
volatile uint32_t g_host_out02_trig_allow = 0;
volatile uint32_t g_host_out02_to_bt = 0;
uint32_t host_out02_total() { return g_host_out02_total; }
uint32_t host_out02_trig_allow() { return g_host_out02_trig_allow; }
uint32_t host_out02_to_bt() { return g_host_out02_to_bt; }
uint8_t interrupt_in_data[63] = {
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
0x08, 0x00, 0x00, 0x00, 0x52, 0x43, 0x30, 0x41,
@@ -101,6 +145,48 @@ void interrupt_loop() {
void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
// printf("[Main] BT data callback: channel=%u len=%u\n", channel, len);
// Track ALL INTERRUPT input reports, not just 0x31. The mic stream
// may live on a different report ID — confirmed 2026-05-19 that data[2]
// bit 0 (and bit 1) is NOT a mic flag, just the report-type indicator;
// every "mic-tagged" frame turned out to be standard input.
if (channel == INTERRUPT && len > 1) {
if (data[1] == 0x31) g_bt_31_packets++;
else {
g_bt_other_packets++;
g_last_other_id = data[1];
g_other_id_or = (uint8_t)(g_other_id_or | data[1]);
for (uint16_t i = 0; i < 8 && i < len; i++) {
g_last_other_prefix[i] = data[i];
}
}
if (len > 2) {
g_last_31_b2 = data[2];
g_31_b2_or = (uint8_t)(g_31_b2_or | data[2]);
}
if (len < g_31_len_min) g_31_len_min = len;
if (len > g_31_len_max) g_31_len_max = len;
for (uint16_t i = 0; i < 16 && i < len; i++) {
g_last_any_prefix[i] = data[i];
}
// Capture the entire content of the longest 0x31 frame we've
// seen. Long frames almost certainly carry the mic audio appended
// after the standard 63-byte input report — this lets us look
// at the trailing bytes directly via 0xFD diagnostic.
if (data[1] == 0x31 && len > g_longest_len) {
g_longest_len = len;
for (uint16_t i = 0; i < 80 && i < len; i++) {
g_longest_frame[i] = data[i];
}
}
}
// Mic-add tap DISABLED — was decoding standard input (button/stick
// bytes) as Opus and producing INT16_MIN garbage on the USB IN
// endpoint. Re-enable once we identify the actual mic transport.
// (Standard input handling below resumes — Status screen + HID
// reports to host need this.)
if (channel == INTERRUPT && data[1] == 0x31) {
if ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
set_headset(data[56] & 1);
@@ -114,12 +200,6 @@ void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
return;
}
// We add the critical section here to avoid any race conditions when writing to the interrupt_in_data buffer,
// which is shared between the main loop and this callback.
// The critical section ensures that only one thread can access the buffer at a time,
// preventing data corruption and ensuring thread safety.
// We also set the report_dirty flag to true to indicate that new data is available
// and needs to be sent in the next interrupt report.
critical_section_enter_blocking(&report_cs);
memcpy(interrupt_in_data, data + 3, 63);
report_dirty = true;
@@ -186,6 +266,12 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
if (report_id == 0) {
switch (buffer[0]) {
case 0x02: {
g_host_out02_total++;
// valid_flag0 lives at buffer[1] (right after the 0x02 report id).
// Bits 2 & 3 are AllowRight/LeftTriggerFFB.
if (bufsize > 1 && (buffer[1] & 0x0C)) {
g_host_out02_trig_allow++;
}
state_update(buffer + 1, bufsize - 1);
if (spk_active) {
break;
@@ -200,6 +286,7 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
// memcpy(outputData + 3, buffer + 1, bufsize - 1);
state_set(outputData + 3,sizeof(SetStateData));
bt_write(outputData, sizeof(outputData));
g_host_out02_to_bt++;
break;
}
}
+458 -148
View File
@@ -10,10 +10,24 @@
#include "hardware/spi.h"
#include "hardware/gpio.h"
#include "hardware/watchdog.h"
#include "hardware/clocks.h"
#include "hardware/vreg.h"
#include "cmd.h"
#include "pico/time.h"
extern uint8_t interrupt_in_data[63]; // defined in main.cpp
// Mic diagnostic counters (defined in main.cpp).
extern uint32_t bt_31_packet_count();
extern uint32_t host_out02_total();
extern uint32_t host_out02_trig_allow();
extern uint32_t host_out02_to_bt();
extern uint8_t bt_31_last_byte2();
extern uint8_t bt_31_b2_or_mask();
extern uint16_t bt_31_len_min();
extern uint16_t bt_31_len_max();
extern void bt_31_mic_prefix(uint8_t out[6]);
namespace {
constexpr uint kPinDC = 8;
@@ -39,26 +53,43 @@ 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]);
int bright_idx = 0;
uint8_t current_contrast = 0xFF;
// Auto-dim after idle. Tracks last button/input activity.
// Auto-dim / auto-off after idle. Tracks last button/input activity.
// Tier 1: Active → full brightness (bright_idx).
// Tier 2: idle > kAutoDimUs → contrast drops to kDimContrast (deep dim).
// Tier 3: idle > kAutoOffUs → SH1107 panel turned fully off (cmd 0xAE)
// to prevent OLED burn-in on long unattended sits.
// kDimContrast tuned by eye: 0x10 looked like only ~10% reduction on this
// panel (contrast-vs-brightness is heavily non-linear near the bottom of
// the register range). 0x02 is visibly dim while still legible up close.
uint32_t last_activity_us = 0;
uint32_t last_input_hash = 0;
constexpr uint32_t kAutoDimUs = 5UL * 60UL * 1000000UL; // 5 min
constexpr uint8_t kDimContrast = 0x10;
constexpr uint32_t kAutoDimUs = 2UL * 60UL * 1000000UL; // 2 min — generous for pairing
constexpr uint32_t kAutoOffUs = 15UL * 60UL * 1000000UL; // 15 min
constexpr uint8_t kDimContrast = 0x01;
enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF };
OledPowerState oled_power_state = OLED_ACTIVE;
bool prev_bt_connected = false;
// Screen ordering — single source of truth. Reorder by editing this block;
// oled_loop's switch and handle_buttons' KEY1 contextual checks use these
@@ -70,10 +101,11 @@ constexpr int kScreenTriggers = 3;
constexpr int kScreenGyro = 4;
constexpr int kScreenTouchpad = 5;
constexpr int kScreenDiag = 6;
constexpr int kScreenRssi = 7;
constexpr int kScreenVU = 8;
constexpr int kScreenSettings = 9;
constexpr int kNumScreens = 10;
constexpr int kScreenCpu = 7;
constexpr int kScreenRssi = 8;
constexpr int kScreenVU = 9;
constexpr int kScreenSettings = 10;
constexpr int kNumScreens = 11;
int current_screen = 0;
// Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE
@@ -115,6 +147,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) {
@@ -171,7 +210,12 @@ void sh1107_init() {
cmd(0xAF);
}
void flush_fb() {
// 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_raw() {
cmd(0xB0);
for (int j = 0; j < kH; j++) {
const uint8_t col = kH - 1 - j;
@@ -183,6 +227,11 @@ void flush_fb() {
}
}
void flush_fb() {
draw_button_chrome();
flush_fb_raw();
}
void fb_clear() { memset(fb, 0, sizeof(fb)); }
void px(int x, int y, bool on) {
@@ -221,6 +270,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.
@@ -383,22 +443,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;
@@ -410,24 +489,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;
}
@@ -437,7 +505,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) {
@@ -446,7 +517,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;
@@ -458,11 +529,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);
@@ -471,10 +544,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);
@@ -482,13 +556,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);
@@ -510,13 +585,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();
@@ -524,123 +600,273 @@ __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();
}
// Diagnostics screen state. Read-only viewport that scrolls with controller
// D-pad up/down. No cursor — there's nothing to select.
int diag_scroll = 0;
uint8_t diag_last_dpad = 8; // edge-trigger N/E/S/W like settings_handle_input
// Per-second rates sampled once per render, shared across format_diag_row's
// rate-based rows so they stay in sync.
struct DiagRates {
uint32_t usb_rate;
uint32_t bt_rate;
uint32_t mic_rate;
uint32_t bt31_rate;
};
DiagRates g_diag_rates{};
void sample_diag_rates() {
static uint32_t prev_us_frames = 0, prev_bt_packets = 0, prev_mic_frames = 0, prev_bt31 = 0;
static uint32_t prev_sample_us = 0;
const uint32_t now_us = time_us_32();
const uint32_t cur_us_frames = audio_usb_frames();
const uint32_t cur_bt_packets = audio_bt_packets();
const uint32_t cur_mic_frames = audio_mic_frames();
const uint32_t cur_bt31 = bt_31_packet_count();
if (prev_sample_us != 0 && now_us > prev_sample_us) {
const uint32_t dt_us = now_us - prev_sample_us;
if (dt_us > 0) {
g_diag_rates.usb_rate = (uint32_t)(((uint64_t)(cur_us_frames - prev_us_frames) * 1000000u) / dt_us);
g_diag_rates.bt_rate = (uint32_t)(((uint64_t)(cur_bt_packets - prev_bt_packets) * 1000000u) / dt_us);
g_diag_rates.mic_rate = (uint32_t)(((uint64_t)(cur_mic_frames - prev_mic_frames) * 1000000u) / dt_us);
g_diag_rates.bt31_rate = (uint32_t)(((uint64_t)(cur_bt31 - prev_bt31) * 1000000u) / dt_us);
}
}
prev_us_frames = cur_us_frames;
prev_bt_packets = cur_bt_packets;
prev_mic_frames = cur_mic_frames;
prev_bt31 = cur_bt31;
prev_sample_us = now_us;
}
// Row list ordered by relevance: always-useful at top, parked-mic-investigation
// data at bottom. To add a row, bump kNumDiagRows and add a case.
constexpr int kNumDiagRows = 10;
__attribute__((noinline))
void format_diag_row(int idx, char* line, size_t n) {
switch (idx) {
case 0: {
const uint32_t s = time_us_32() / 1000000u;
snprintf(line, n, "Up:%luh %02lum %02lus",
(unsigned long)(s / 3600u),
(unsigned long)((s / 60u) % 60u),
(unsigned long)(s % 60u));
break;
}
case 1:
snprintf(line, n, "BT: %s", bt_is_connected() ? "connected" : "waiting");
break;
case 2:
snprintf(line, n, "host02: %lu", (unsigned long)host_out02_total());
break;
case 3:
snprintf(line, n, "trig %lu / tx %lu",
(unsigned long)host_out02_trig_allow(),
(unsigned long)host_out02_to_bt());
break;
case 4:
snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate);
break;
case 5:
snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate);
break;
case 6:
snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate);
break;
case 7:
snprintf(line, n, "Mic in: %lu/s", (unsigned long)g_diag_rates.mic_rate);
break;
case 8:
snprintf(line, n, "Mic dec=%ld w=%u",
(long)audio_mic_last_decoded(),
(unsigned)audio_mic_last_wrote());
break;
case 9: {
uint8_t pfx[6]; bt_31_mic_prefix(pfx);
snprintf(line, n, "%02X %02X %02X %02X %02X %02X",
pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]);
break;
}
default:
line[0] = '\0';
break;
}
}
void diag_handle_input(int visible) {
if (!bt_is_connected()) return;
const uint8_t dpad = (uint8_t)(interrupt_in_data[7] & 0x0F);
if (dpad != diag_last_dpad && dpad != 8) {
if (dpad == 0) diag_scroll--; // up
else if (dpad == 4) diag_scroll++; // down
}
diag_last_dpad = dpad;
const int max_top = (kNumDiagRows > visible) ? (kNumDiagRows - visible) : 0;
if (diag_scroll < 0) diag_scroll = 0;
if (diag_scroll > max_top) diag_scroll = max_top;
}
__attribute__((noinline)) void render_screen_diag() {
fb_clear();
draw_text(kContentX, 0, "Diagnostics");
draw_text(0, 0, "Diagnostics");
sample_diag_rates();
constexpr int kVisible = 5;
diag_handle_input(kVisible);
const uint32_t uptime_s = time_us_32() / 1000000u;
const uint32_t h = uptime_s / 3600u;
const uint32_t m = (uptime_s / 60u) % 60u;
const uint32_t s = uptime_s % 60u;
char buf[24];
snprintf(buf, sizeof(buf), "Up:%luh %02lum %02lus", (unsigned long)h, (unsigned long)m, (unsigned long)s);
draw_text(0, 9, buf);
// Per-second rates for the audio path counters — recompute every render.
static uint32_t prev_us_frames = 0, prev_bt_packets = 0;
static uint32_t prev_sample_us = 0;
const uint32_t now_us = time_us_32();
const uint32_t cur_us_frames = audio_usb_frames();
const uint32_t cur_bt_packets = audio_bt_packets();
uint32_t usb_rate = 0, bt_rate = 0;
if (prev_sample_us != 0 && now_us > prev_sample_us) {
const uint32_t dt_us = now_us - prev_sample_us;
if (dt_us > 0) {
usb_rate = (uint32_t)(((uint64_t)(cur_us_frames - prev_us_frames) * 1000000u) / dt_us);
bt_rate = (uint32_t)(((uint64_t)(cur_bt_packets - prev_bt_packets) * 1000000u) / dt_us);
}
char line[28];
for (int i = 0; i < kVisible && diag_scroll + i < kNumDiagRows; i++) {
format_diag_row(diag_scroll + i, line, sizeof(line));
draw_text(kContentX, 9 + i * 9, line);
}
prev_us_frames = cur_us_frames;
prev_bt_packets = cur_bt_packets;
prev_sample_us = now_us;
snprintf(buf, sizeof(buf), "USB aud %lu/s", (unsigned long)usb_rate);
draw_text(0, 18, buf);
snprintf(buf, sizeof(buf), "BT 0x32 %lu/s", (unsigned long)bt_rate);
draw_text(0, 27, buf);
snprintf(buf, sizeof(buf), "HCI errs: %lu", (unsigned long)bt_hci_err_count());
draw_text(0, 36, buf);
snprintf(buf, sizeof(buf), "BT: %s", bt_is_connected() ? "connected" : "waiting");
draw_text(0, 45, buf);
draw_text(0, 56, "K0=next K1=back");
// Scroll indicators along the right edge, adjacent to the visible content
// rather than in a footer — keeps the bottom row available for content.
if (diag_scroll > 0) draw_text(120, 9, "^");
if (diag_scroll + kVisible < kNumDiagRows) draw_text(120, 45, "v");
flush_fb();
}
__attribute__((noinline)) void render_screen_triggers() {
__attribute__((noinline)) void render_screen_cpu(bool entered) {
fb_clear();
draw_text(0, 0, "Trigger Test");
draw_text(kContentX, 0, "CPU / Clock");
char buf[24];
// Configured system clock — compile-time SYS_CLOCK_KHZ, set in main()
// 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(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
// busy-waits a few ms per call, so measure ONCE on screen entry and cache
// it — clk_sys is fixed at boot and never changes, so the temperature
// (which legitimately drifts) is the only thing worth refreshing per
// frame. cached_real_khz==0 also forces a (re)measure as a safety net.
static uint32_t cached_real_khz = 0;
if (entered || cached_real_khz == 0) {
cached_real_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_SYS);
}
const uint32_t real_khz = cached_real_khz;
snprintf(buf, sizeof(buf), "Real: %lu.%01lu MHz",
(unsigned long)(real_khz / 1000u),
(unsigned long)((real_khz % 1000u) / 100u));
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.
const int vcode = (int)vreg_get_voltage();
if (vcode >= 0 && vcode <= 0b01111) {
const unsigned mv = 550u + 50u * (unsigned)vcode;
snprintf(buf, sizeof(buf), "Vcore: %u.%02u V", mv / 1000u, (mv % 1000u) / 10u);
} else {
snprintf(buf, sizeof(buf), "Vcore: code %d", vcode);
}
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
// reading converges to the true die temp instead of chasing ADC noise.
const uint16_t raw = cpu_temp_raw_smoothed();
const float volts = (float)raw * 3.3f / 4096.0f;
const float temp_c = 27.0f - (volts - 0.706f) / 0.001721f;
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(kContentX, 42, buf);
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(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);
@@ -656,16 +882,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;
@@ -677,9 +903,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;
@@ -688,11 +914,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();
}
@@ -745,9 +970,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()) {
@@ -795,14 +1036,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;
@@ -819,47 +1060,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();
}
@@ -1030,7 +1270,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);
}
@@ -1041,15 +1281,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();
}
@@ -1125,7 +1365,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);
@@ -1143,10 +1383,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();
}
@@ -1186,6 +1426,36 @@ void oled_init() {
boot_splash();
}
// Dim-tier renderer: blank the panel and draw a tiny "I'm alive" dot that
// breathes (1s on / 1s off) and walks through 8 evenly-spaced positions every
// ~30 s. Two goals: (1) reduce total pixel-on-time to a tiny fraction so the
// panel barely glows even with the contrast register pinned, (2) prevent any
// single pixel from accumulating wear. noinline keeps oled_loop's literal pool
// in Thumb's 4 KB reach (same constraint the other render_screen_* hit).
__attribute__((noinline))
void render_dim_pulse(uint32_t dim_elapsed_us) {
fb_clear();
constexpr uint32_t kPulsePeriodUs = 2UL * 1000000UL; // 2 s blink cycle
constexpr uint32_t kPulseOnUs = 1UL * 1000000UL; // 1 s on, 1 s off
constexpr uint32_t kPosStepUs = 30UL * 1000000UL; // 30 s per position
constexpr int kPositions[][2] = {
{ 16, 8}, { 64, 8}, {112, 8},
{112, 32},
{112, 56}, { 64, 56}, { 16, 56},
{ 16, 32},
};
constexpr int kNumPositions = sizeof(kPositions) / sizeof(kPositions[0]);
const bool dot_on = (dim_elapsed_us % kPulsePeriodUs) < kPulseOnUs;
if (dot_on) {
const int idx = (int)((dim_elapsed_us / kPosStepUs) % (uint32_t)kNumPositions);
const int cx = kPositions[idx][0];
const int cy = kPositions[idx][1];
// 2x2 dot — small enough to barely register, big enough to see across a desk.
rect_filled(cx, cy, 2, 2);
}
flush_fb_raw(); // skip chrome arrows; nothing to navigate to from sleep
}
void oled_loop() {
handle_buttons();
const uint32_t now = time_us_32();
@@ -1199,10 +1469,49 @@ void oled_loop() {
last_input_hash = hash;
last_activity_us = now;
}
// Rising-edge: BT-connect itself counts as activity, so the screen wakes
// the moment a controller pairs rather than waiting for the first input.
const bool bt_connected_now = bt_is_connected();
if (bt_connected_now && !prev_bt_connected) last_activity_us = now;
prev_bt_connected = bt_connected_now;
// Auto-dim after idle; respect user-selected brightness otherwise
const bool idle = (now - last_activity_us) > kAutoDimUs;
sh1107_set_contrast(idle ? kDimContrast : kBrightLevels[bright_idx]);
// Power-state ladder: Active → Dim (breathing dot) → Off based on idle time.
const uint32_t idle = now - last_activity_us;
if (idle > kAutoOffUs) {
if (oled_power_state != OLED_OFF) {
cmd(0xAE);
oled_power_state = OLED_OFF;
}
return; // panel is off, nothing to draw
}
if (oled_power_state == OLED_OFF) cmd(0xAF); // wake panel before drawing
if (idle > kAutoDimUs) {
sh1107_set_contrast(kDimContrast);
oled_power_state = OLED_DIM;
render_dim_pulse(idle - kAutoDimUs);
return; // skip the regular per-screen render path
}
sh1107_set_contrast(kBrightLevels[bright_idx]);
oled_power_state = OLED_ACTIVE;
// True on the first render after navigating to a different screen.
// Lets a screen do expensive one-shot work on entry (the CPU screen
// caches its frequency-counter measurement here).
static int last_rendered_screen = -1;
const bool screen_entered = (current_screen != last_rendered_screen);
// Leaving Trigger Test in either direction → reset the adaptive
// trigger preset to OFF and push it to the controller. Otherwise
// the last-cycled effect (Weapon snap, Galloping pulse, etc.)
// stays active on the DS5 indefinitely, which surprised users
// who'd just navigated away expecting a clean slate.
if (last_rendered_screen == kScreenTriggers
&& current_screen != kScreenTriggers) {
trigger_preset = 0;
send_trigger_effect(0);
}
last_rendered_screen = current_screen;
switch (current_screen) {
case kScreenStatus: render_screen(); break;
@@ -1212,6 +1521,7 @@ void oled_loop() {
case kScreenGyro: render_screen_gyro(); break;
case kScreenTouchpad: render_screen_touchpad(); break;
case kScreenDiag: render_screen_diag(); break;
case kScreenCpu: render_screen_cpu(screen_entered); break;
case kScreenRssi: render_screen_rssi(); break;
case kScreenVU: render_screen_vu(); break;
case kScreenSettings: render_screen_settings(); break;
+1 -1
View File
@@ -19,7 +19,7 @@ namespace {
static constexpr uint8_t state_init_data[63] = {
0xfd, 0xf7, 0x0, 0x0,
0x7f, 0x64, // Headphones, Speaker
0xff, 0x9, 0x0, 0x0F, 0x0, 0x0, 0x0, 0x0,
0x40, 0x9, 0x0, 0x00, 0x0, 0x0, 0x0, 0x0, // VolumeMic=64, MuteControl all clear (no PowerSave)
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa,