Compare commits

...
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.7 1c4e34f8da docs(changelog): cut v0.6.8 — BT microphone + USB 3.0 connection watchdog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:31:06 -06:00
MarcelineVPQandClaude Opus 4.7 20b41d80a1 feat: DualSense BT microphone + USB 3.0 connection watchdog
BT microphone over Bluetooth: the DS5 mic now works over the dongle's BT
pairing — decoded from the controller's Opus stream to the USB capture
endpoint. Hinges on pkt[4] bit 0 (mic-enable) in the outbound 0x36 audio
report; credit to awalol (upstream) for identifying it. Mic-tagged 0x31
frames ((data[2]>>1)&1) are ALWAYS diverted out of the input path (decoded
when on, dropped when off) so Opus payload can never corrupt sticks/buttons.
Always-on via a sticky-latch keep-alive that only runs post-enumeration
(tud_mounted) so it never floods the fresh-pair handshake (which otherwise
delayed controller detection past the watchdog and tore the link down).
Toggle: bt_mic_enable config field (default on) — OLED Settings + web config.
README gains a "DualSense Microphone over Bluetooth" section;
BLUETOOTH_AUDIO_NOTES.md rewritten from "dead end" to the working mechanism.

USB 3.0 connection watchdog: auto-recovers a stalled connection (re-inquiry)
instead of hanging on the amber lightbar, for USB 3.0 ~2.4 GHz RF interference
that desensitizes the CYW43 BT radio. Re-enabled the ACL-fail / auth-fail /
create-connection-reject recovery paths. README "USB 3.0 ports & Bluetooth
interference" section with mitigations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:30:37 -06:00
MarcelineVPQandClaude Opus 4.7 89847ed06e docs(changelog): cut v0.6.7 — provisional charge ETA on plug-in
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:39:35 -06:00
MarcelineVPQandClaude Opus 4.7 0eceb1d5e2 feat(oled): provisional charge ETA on plug-in — no more ~--m wait
Show a default-rate estimate "~Nm?" the moment charging starts instead of
"~--m" for the ~15-20 min until the first 10% step is timed. The "?" drops and
the number switches to the measured rate once a clean step completes. Default
~15 min per 10% step (kDefaultStepUs), taper-weighted like the measured path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:29:46 -06:00
MarcelineVPQandClaude Opus 4.7 d99689bf75 docs(changelog): cut v0.6.6 — configurable screen timeouts (#5) + trig-fold counter (#6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:03:54 -06:00
MarcelineVPQandClaude Opus 4.7 c65e0c9802 feat(oled): configurable idle-ladder thresholds (#5) + trig-fold diag counter (#6)
Issue #5 (requested by @TerryFrench): the OLED dim/off tiers are no longer
hardcoded at 2/15 min. Two new Config_body fields screen_dim_timeout /
screen_off_timeout (minutes, 0 = tier disabled, range [0,250]) are editable
on the Settings screen (ScrDim/ScrOff) and persist to flash; defaults
preserve the 2/15 ladder and upgraders read those via the config_valid clamp.
The idle timer moved from time_us_32() to 64-bit µs so the full 250-min range
is representable without the ~71-min wrap.

Issue #6: new "trig fold" Diagnostics counter — trigger-bearing 0x02 host
reports that arrived while the speaker stream was active and were folded into
the 0x36 audio frames (via state[]) rather than sent as a standalone 0x31.
Makes trig_allow == to_bt(trig share) + fold visible, confirming the apparent
trig/tx gap is audio-path folding, not dropped reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:03:45 -06:00
MarcelineVPQandClaude Opus 4.7 585a385219 docs(changelog): cut v0.6.5 — charging UX, persistent lightbar, idle-ladder fixes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:39:00 -06:00
MarcelineVPQandClaude Opus 4.7 4edcd20181 feat(oled): charge ETA, persistent lightbar, charging-aware idle ladder
Status screen: show an estimated time-to-full ("~43m") while charging,
self-calibrating from each 10% battery notch with Li-ion taper correction
(discard the partial plug-in step; 3-sample moving average; per-step weight
1.0/1.5/2.2 for bulk/80-90/90-100%). Shows "~--m" until the first full step.

Lightbar: the chosen mode + 4 favorites now persist to config flash and
stick across every screen (and through gameplay/audio). A single
lightbar_service() owns the LED via the persistent state[] block (new
state_set_led/state_get_led) with a host-override gate (g_lightbar_override)
so the host's AllowLedColor can't stomp a firmware-chosen mode. New HOST
passthrough mode (default) keeps the game in control of the LED out of the
box. The charging amber pulse (255,100,0) is folded in as top priority.

Idle ladder: keep the panel at the dim/dot tier (never full-off) while
charging so users stop unplugging to wake it (which reset the charge ETA).
Fix idle detection to deadzone stick jitter [120,140] + skip the counter
byte (idata[6]) so the dot tier engages with a controller connected
(mirrors bt.cpp's inactivity heuristic).

New Config_body fields: lightbar_mode + lb_fav_{r,g,b}[4].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:38:51 -06:00
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
16 changed files with 1482 additions and 118 deletions
+37
View File
@@ -0,0 +1,37 @@
# Bluetooth microphone — SOLVED
**TL;DR:** The DualSense's built-in microphone **works over this dongle's Bluetooth pairing** as of v0.6.8. It is decoded and presented to the host as the standard DualSense USB capture device. Earlier versions of this document concluded the opposite — that it was a Sony-side limitation, probably encrypted, a dead end. **That conclusion was wrong.** The whole thing hinged on a single enable bit. Full credit to **[awalol](https://github.com/awalol/DS5Dongle)** (upstream `mic` branch) for identifying it.
## How it works
1. **Enable bit (dongle → DS5).** In the outbound `0x36` BT audio report, the audio-control sub-report's first flag byte (`pkt[4]`) has **bit 0 = mic-enable**. Setting it (`0b11111110``0b11111111`; bits 17 were already the speaker/haptic enables) tells the DS5 to start streaming its mic. See `src/audio.cpp`.
2. **Mic frames (DS5 → dongle).** Once enabled, the DS5 tags certain `0x31` BT input reports as mic frames by setting **bit 1 of byte 2** (`(data[2] >> 1) & 1`); the payload is a **71-byte Opus packet at offset +4**. `src/main.cpp:on_bt_data()` routes those to `mic_add_queue()`.
3. **Decode + present.** `src/audio.cpp` Opus-decodes each frame to mono 48 kHz (480-sample / 10 ms frames), duplicates mono → stereo, and `tud_audio_write`s it to the UAC1 capture endpoint. The host sees a normal DualSense mic.
4. **Sticky.** Once the DS5 starts streaming it **keeps going for the rest of the session** even after the enable bit / audio output stops (verified: mic stays live with no further `0x36` frames).
5. **Always-on.** Because the enable normally only rides the audio-gated `0x36` frames, the dongle sends a **control-only `0x36` keep-alive** (enable bit + `SetStateData` + silent haptic, no speaker payload) at ~4 Hz *only until mic frames start arriving*, then stops (sticky takes over). So the mic works with no game audio playing, at minimal extra BT traffic. See `mic_enable_keepalive()` in `src/audio.cpp`.
6. **Toggle.** Gated by `Config_body.bt_mic_enable` (default on) — OLED **Settings → BT Mic** and the web config tool. Off = no enable sent, no keep-alive, and inbound mic frames are not routed (so it's off host-side even if a previously-enabled DS5 is still streaming). Off by toggle saves DS5 battery (always-on keeps its audio subsystem awake).
## Why the original conclusion was wrong (the lesson)
The earlier investigation ported awalol's *receive* side (the `(data[2]>>1)&1` trigger + Opus decode) and then watched for that bit — but **never sent the enable bit**, because this fork's `audio.cpp` had diverged (the pre-`3a31bd7` SetStateData revert) and sat at `pkt[4] = 0b11111110`. With nothing telling the DS5 to start, it never streamed, so bit 1 of byte 2 never set — which got misread as "the trigger never fires → the channel must be encrypted / it's a dead end."
It was never encrypted. It was one un-set bit on the *transmit* side. Lesson: when porting a two-sided protocol, confirm **both** halves (enable *and* receive) before concluding the device "can't" do something.
## What we use (host-side)
- **`scripts/mic_diag.sh`** — `status` / `capture [secs]` / `watch` / `bt-trace`. `capture` arecords the DualSense mic card and reports peak/RMS/non-zero, the fastest way to confirm real audio.
- The DualSense capture card's **`Headset` capture control defaults low** — raise it (`amixer -c <card> sset 'Headset' 90%`) or captures look silent.
- **OLED Diagnostics** `Mic in:` (~100/s when streaming) + `Mic dec=` (480 = good Opus decode) are the on-device confirmation.
- Vendor HID feature reports `0xFD` / `0xFE` and the BT counters remain useful general audio-debug infra.
## Open follow-ups
- **No documented "stop" command.** Disabling mid-session relies on gating the receive side; the DS5 keeps streaming until reconnect. If a real stop/disable bit is found, wire it into the toggle to stop the DS5-side battery drain immediately.
- **Mono only.** Decoded mono is duplicated to the stereo endpoint; the DS5 mic is mono so this is fine, but the descriptor could be made truly mono (as awalol's branch does) to halve endpoint bandwidth.
- **Name the `pkt[4]` bits precisely.** `daidr/dualsense-tester`'s `outputStruct.ts` documents the *standard* output report flags but not the `0x36` BT-audio sub-report; the bit meanings beyond bit 0 are inferred from the working speaker/haptic path.
## References
- Upstream `awalol/DS5Dongle` branch `mic` (commits `9c197fc`, `3829163`) — the source of the enable bit (`pkt[4]` bit 0) and the receive-side decode. awalol confirmed bit 0 is the key.
- Linux kernel `drivers/hid/hid-playstation.c` (~line 1509, *"Bluetooth audio is currently not supported"*) — still true for the kernel driver; not for this dongle.
- `daidr/dualsense-tester``src/router/DualSense/views/_OutputPanel/outputStruct.ts` for the standard output-report flag layout.
+84
View File
@@ -10,6 +10,90 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
--- ---
## [0.6.8-oled-edition] — 2026-05-24
Two big items: **DualSense microphone over Bluetooth** (long believed impossible — turned out to be a single enable bit; credit [awalol](https://github.com/awalol/DS5Dongle) upstream) and a **USB 3.0 connection-interference watchdog**. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.8-oled-edition) (built by `.github/workflows/release.yml`). The companion `DS5Dongle-OLED-Config-Web` config tool gains a **BT microphone** toggle.
### Added
- **DualSense microphone over Bluetooth.** The controller's built-in mic now works over the dongle's BT pairing — decoded from the DS5's Opus stream and presented to the host as the standard DualSense USB capture device, usable by any app (Discord, OBS, in-game voice). This fork had previously documented BT mic as a hard Sony-firmware limitation (likely encrypted); that conclusion was **wrong** — it hinged on a single enable bit (`pkt[4]` bit 0 in the outbound `0x36` audio report). Credit to **[awalol](https://github.com/awalol/DS5Dongle)** (upstream) for identifying it. The DS5 streams mic as 71-byte Opus packets tagged in `0x31` reports (`(data[2]>>1)&1`); `src/audio.cpp` decodes mono→stereo to the UAC1 endpoint. **Always-on:** the enable is sticky once streaming, so a control-only `0x36` keep-alive asserts it at ~4 Hz only until frames arrive, then backs off — mic works with no game audio, at minimal BT traffic. **Toggle:** new `bt_mic_enable` config field (default on; off saves DS5 battery since always-on keeps its audio subsystem awake) — OLED **Settings → BT Mic** and the web config tool's **BT microphone** switch. `BLUETOOTH_AUDIO_NOTES.md` rewritten from "dead end" to the working mechanism; README gains a user-facing **"DualSense Microphone over Bluetooth"** section.
### Fixed
- **Connection no longer hangs permanently on the amber lightbar (USB 3.0 interference recovery).** Users reported the DualSense getting stuck mid-connect (solid amber/yellow, never enumerates) on USB 3.0 host ports while USB 2.0 worked — caused by USB 3.0's broadband ~2.4 GHz RF noise desensitizing the CYW43 Bluetooth radio. The firmware's connection flow had dead-end states (ACL-fail and auth-fail re-inquiry were commented out; the controller-type feature-packet wait had no timeout), so a single lost packet stalled forever until a replug. Added a **connection-attempt watchdog** (`src/bt.cpp`): a 10 s timeout armed when a connection commits to a device and cleared when it reaches USB enumeration; on expiry it tears down via the existing `HCI_EVENT_DISCONNECTION_COMPLETE` path and restarts inquiry, so a stalled connect auto-retries instead of hanging. Re-enabled the ACL-fail / create-connection-reject / auth-fail recovery paths for faster recovery when the controller *does* report a failure. The watchdog is inert during a healthy established session (no effect on normal play, slot-switching, or idle-disconnect). Helps any marginal-RF setup, not just USB 3.0.
### Documentation
- New README section **"USB 3.0 ports & Bluetooth interference"** + a Known Issues bullet: explains the 2.4 GHz RFI cause (referencing Intel's white paper) and lists mitigations (USB 2.0 port, short USB 2.0 extension cable, powered USB 2.0 hub, ferrite bead, distance/line-of-sight).
---
## [0.6.7-oled-edition] — 2026-05-23
UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.7-oled-edition) (built by `.github/workflows/release.yml`).
### Changed
- **Charge ETA now shows a provisional estimate immediately on plug-in.** Instead of sitting on `~--m` for the ~15-20 min until the first 10% step is timed, the Status screen shows a default-rate estimate `~Nm?` (the trailing `?` marks it provisional) the moment charging starts. The `?` drops and the number switches to the measured rate once a clean 10% step completes. Default is ~15 min per 10% step (`kDefaultStepUs`), taper-weighted exactly like the measured path, so the provisional figure is in the right ballpark and self-corrects.
### Companion web tool
- `DS5Dongle-OLED-Config-Web` gains **lightbar controls** (mode dropdown + four favorite-color pickers) in the config view, the provisional charge-ETA token in the OLED preview to match this firmware, and translations for two preview notes that were English-only. Build housekeeping: `tsconfig.tsbuildinfo` is no longer tracked.
---
## [0.6.6-oled-edition] — 2026-05-23
Community-issue follow-ups: configurable OLED idle-ladder thresholds (#5) and a diagnostic counter clarifying the trigger-flow numbers (#6). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.6-oled-edition) (built by `.github/workflows/release.yml`). The companion `DS5Dongle-OLED-Config-Web` config tool gains matching screen-timeout controls and is synced to the v0.6.5+ `Config_body` layout (fixes a latent issue where saving via the old web tool would zero the lightbar fields).
### Added
- **Configurable OLED idle-ladder thresholds (issue #5, requested by @TerryFrench).** The dim and off tiers are no longer hardcoded at 2 / 15 min — two new `Config_body` fields `screen_dim_timeout` / `screen_off_timeout` (minutes, `0 = that tier disabled`, range `[0,250]`) are editable on the Settings screen (`ScrDim`/`ScrOff`) and persist to flash. Defaults preserve the previous 2 / 15 ladder; on upgrade the unset fields read as those defaults via the `config_valid()` clamp. The idle timer moved from `time_us_32()` to 64-bit µs so the full 250-min range is representable without the ~71-min wrap. Power users with always-on dongles can bias shorter; status-watchers can bias longer or set `0` to keep a tier lit.
- **`trig fold` counter on the Diagnostics screen (issue #6).** Counts trigger-bearing `0x02` host reports that arrived while the speaker stream was active and were therefore folded into the `0x36` audio frames (via `state[]`) instead of sent as a standalone `0x31`. Makes `trig_allow == to_bt(trig) + fold` visible, confirming the apparent `trig`/`tx` gap is audio-path folding, not dropped trigger reports.
---
## [0.6.5-oled-edition] — 2026-05-23
Charging UX (Status-screen battery ETA + amber lightbar pulse), persistent and screen-sticky lightbar control, and a charging-aware idle power ladder. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.5-oled-edition) (built by `.github/workflows/release.yml`).
### Added
- **Charge ETA on the OLED Status screen.** While the DualSense is charging, the battery line shows an estimated time-to-full (`~43m`) to the right of the battery icon. The DS5 only reports battery in 10 % steps over BT (`interrupt_in_data[52]` low nibble, 010; high nibble is power-state, 1 = charging), so a smooth countdown is impossible — instead `sample_charge_eta()` times how long each 10 % step takes and extrapolates the remaining steps. It shows `~--m` while calibrating (the first estimate can't appear until one full step has been timed, ~1520 min after plug-in), then refines on each subsequent notch. The partial step in progress at plug-in is discarded so the first estimate isn't skewed by a half-measured step; a 3-entry moving average smooths the rest. **Li-ion taper correction:** a flat "time-per-step × steps-left" runs optimistic in the constant-voltage tail, so each measured step is normalised to a bulk-equivalent duration (divide out a per-step weight: 1.0× in the bulk region, 1.5× for 80→90 %, 2.2× for 90→100 %) and the remaining steps are re-weighted — keeping the estimate consistent whether the user plugs in near-empty or near-full. Sampled once per frame from `oled_loop` ahead of the idle power-ladder early-returns, so step timing stays correct even while the panel is dimmed/off or the user is on another screen.
- **Lightbar settings persist across reboot and stick across every screen.** The selected lightbar mode and the four favorite colors are now saved to the config flash sector (new `Config_body` fields `lightbar_mode` + `lb_fav_{r,g,b}[4]`), so a chosen mode/color survives a power cycle. A new **HOST** mode (the default) hands the LED back to the host/game so the dongle doesn't hijack player-indicator LEDs out of the box; on upgrade from ≤0.6.4 the unset field reads as HOST, preserving prior behavior. Mode/favorite edits made on the Lightbar screen are batched into a single flash write when you navigate away (tracked by a dirty flag) to spare flash endurance.
- **Lightbar pulses amber-orange while charging.** A slow ~4.6 s breathing pulse (base `(255,100,0)`, sine-enveloped from dim to bright via the existing 32-step LUT) shows charging at a glance from any screen. Implemented in the unified `lightbar_service()` (below) as the top-priority owner of the LED, so it overrides the selected mode while charging and reverts to it when unplugged.
### Changed
- **The OLED no longer fully sleeps while the controller is charging.** The idle power ladder is capped at the Dim tier (the low-power breathing dot) instead of advancing to full Off (`cmd(0xAE)`) when `g_charge_eta.charging` is true. The charge-ETA tracker already runs while the panel is off, but users were unplugging the controller to "wake" the dongle — which reset the ETA calibration and restarted the wait-for-the-next-10%-notch. Capping at the dot tier (which draws ~no current) removes the reason to unplug. Normal Active→Dim→Off behavior resumes once charging stops.
- **A single `lightbar_service()` now owns the controller LED, every frame, on every screen.** Previously the OLED only drove the lightbar via a transient `0x31` packet sent from inside `render_screen_lightbar()` — so the color was only asserted while that screen was open. The service (run from `oled_loop` ahead of the power-ladder early-returns) instead writes the chosen color into the persistent `state[]` block (`SetStateData` `LedRed/Green/Blue`, via new `state_set_led()`), so it rides every outbound host/audio packet, and also actively pushes a `0x31` when audio is idle so animations keep moving. A new `g_lightbar_override` flag gates `state_update()` so the host's `AllowLedColor` writes can't stomp a firmware-chosen mode. During audio the active `0x31` push is suppressed — the `0x36` frames already carry `state[]`'s LED, and slipping a `0x31` between them would intrude on the load-bearing audio path.
### Fixed
- **OLED idle dim/dot tier now actually engages while a controller is connected.** The activity detector hashed `interrupt_in_data[0..9]` with an exact compare, but the analog sticks jitter by ±1 LSB at rest, so the hash changed every few frames and reset the idle timer — meaning the breathing-dot/dim tier only ever kicked in when no controller was paired. Now it mirrors `bt.cpp`'s inactivity heuristic: the stick bytes' rest band `[120,140]` is collapsed to a constant and the volatile counter byte (`idata[6]`) is skipped, so a resting controller reads as idle. Confirmed against a live `/dev/hidraw` capture (only the left-stick X byte was flickering 129↔128).
- **Lightbar no longer reverts the instant you leave the Lightbar screen.** Root cause: the OLED's `send_lightbar_color()` wrote a one-off `0x31` packet and never touched the persistent `state[]` block, while the host's `0x02` output reports, every audio frame, and reconnect all re-stamp `state[]` (incl. the LED) into the controller. Off the Lightbar screen the OLED stopped pushing, so the next `state[]`-based packet overwrote the color — which is why saved favorites and animated modes (Rainbow/Breathing/Fade) never "stuck." Now that the lightbar is owned through `state[]` with a host override gate (see Changed), the selected mode holds across screens and through active gameplay/audio.
---
## [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 ## [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`). 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`).
+4
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. **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: **Multi-slot pairing (Phase G):** Storage is two-tier:
- **Link keys** stay in BTstack's TLV NVM (4 slots, unchanged from upstream). - **Link keys** stay in BTstack's TLV NVM (4 slots, unchanged from upstream).
@@ -136,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 BT pairing / connection state behavior → `src/bt.cpp` (HCI + L2CAP event handlers).
- New OLED screen or change to existing one → `src/oled.cpp`. - 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`. - 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`. - 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). - 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).
+65 -2
View File
@@ -46,7 +46,7 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot - **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 - **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 - **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) - **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) - **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)) - **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))
@@ -140,10 +140,43 @@ When the connected DualSense reports its battery at or below 10% (and it is not
To opt out at build time, configure with `-DENABLE_BATT_LED=OFF`. Default is ON. To opt out at build time, configure with `-DENABLE_BATT_LED=OFF`. Default is ON.
## DualSense Microphone over Bluetooth
**The DualSense's built-in microphone works over the dongle's Bluetooth pairing** (since v0.6.8). The controller streams its mic as Opus audio; the dongle decodes it and presents it to the host as the standard DualSense USB capture device, so any app (Discord, OBS, in-game voice) can use it like a normal microphone.
This was long believed impossible — earlier versions of this fork documented it as a hard Sony-firmware limitation (and the Linux `hid-playstation` kernel driver still doesn't support it). It turned out to hinge on a single enable bit in the dongle's outbound audio report. **Full credit to [awalol](https://github.com/awalol/DS5Dongle) (upstream) for identifying it.** The corrected investigation log lives in [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md).
**Using it:**
- **On by default.** Pair the controller and the mic begins streaming within a second or two — no game audio required (the dongle keeps the stream alive on its own).
- **Raise the capture volume on the host** — it defaults low. On Linux, find the card with `arecord -l`, then e.g. `amixer -c <DualSense card> sset 'Headset' 90%`. Verify capture with `scripts/mic_diag.sh capture`.
- **Toggle it off to save controller battery** — OLED **Settings → BT Mic**, or the **BT microphone** switch in the [web config tool](#web-config-tool). Always-on mic keeps the DS5's audio subsystem awake, which drains its battery noticeably faster, so disable it if you don't use voice.
**Caveats:**
- Mic audio is **mono** (decoded mono, duplicated across the stereo capture endpoint).
- Toggling off mid-session stops the host feed immediately, but the controller keeps streaming until it next reconnects (there's no known "stop" command); connecting fresh with the toggle off never enables it.
- The OLED **Diagnostics** screen's `Mic in:` counter reads ~100/s while the mic is streaming — a quick way to confirm it's live.
## Known Issues ## Known Issues
- 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. - 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. - 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.
- **USB 3.0 ports can disrupt pairing** — the controller may get stuck on a solid amber/yellow lightbar and never connect, while the same dongle works fine on a USB 2.0 port. This is RF interference, not a firmware bug; see [USB 3.0 ports & Bluetooth interference](#usb-30-ports--bluetooth-interference) below. (As of v0.6.8 the firmware auto-retries a stalled connection instead of hanging, which recovers many — but not all — marginal cases.)
## USB 3.0 ports & Bluetooth interference
If the dongle works on one port but not another, **try a USB 2.0 port first.** USB 3.0 ports and (especially) USB 3.0 extension cables emit broadband RF noise centered near 2.4 GHz — the same band the dongle's Bluetooth radio uses to talk to the controller. This is a well-documented industry issue (Intel, *"USB 3.0 Radio Frequency Interference Impact on 2.4 GHz Wireless Devices"*), not specific to this firmware. The noise desensitizes the dongle's BT receiver, so the controller can start connecting (amber lightbar) but the link is too noisy to complete — it hangs on yellow.
Mitigations, roughly in order of effectiveness:
1. **Plug the dongle into a USB 2.0 port** (often the simplest fix — many motherboards/cases have both).
2. **Use a short USB 2.0 extension cable** to get the dongle a few inches away from the USB 3.0 ports / metal chassis, improving line-of-sight to the controller. Avoid USB 3.0 extension cables specifically.
3. **Use a powered USB 2.0 hub** plugged into the USB 3.0 port — the hub downshifts the link and adds distance.
4. **Clip a ferrite bead** onto the cable near the dongle.
5. **Keep the controller closer / in line of sight** of the dongle during pairing.
The firmware will keep retrying a stalled connection on its own, so leaving it plugged in for ~1020 s after the lightbar goes amber may let it recover without a replug.
## Performance / Overclocking ## Performance / Overclocking
@@ -185,6 +218,34 @@ Build flags worth knowing:
- `-DENABLE_SERIAL=ON` — route printf to USB CDC for debugging (default OFF; releases UART for production builds). - `-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. - `-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) ## 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. 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.
@@ -248,7 +309,9 @@ Live render of the touchpad surface. Dots appear at current finger positions; th
#### 7. Diagnostics #### 7. Diagnostics
Uptime, BT state, USB-audio frames/sec, BT 0x32 packets/sec, and HCI error counter — live values for verifying the audio path is moving bytes without needing a UART cable. 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.
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/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420"> <img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
+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
+132 -2
View File
@@ -13,6 +13,7 @@
#include "utils.h" #include "utils.h"
#include "pico/multicore.h" #include "pico/multicore.h"
#include "pico/util/queue.h" #include "pico/util/queue.h"
#include "pico/time.h"
#include "config.h" #include "config.h"
#include "state_mgr.h" #include "state_mgr.h"
#include "usb.h" #include "usb.h"
@@ -25,6 +26,14 @@
// #define VOLUME_GAIN 2 // #define VOLUME_GAIN 2
// #define BUFFER_LENGTH 48 // #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::clamp;
using std::max; using std::max;
@@ -37,6 +46,21 @@ queue_t audio_fifo;
static uint8_t opus_buf[200]; static uint8_t opus_buf[200];
critical_section_t opus_cs; 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 { struct audio_raw_element {
float data[512 * 2]; float data[512 * 2];
}; };
@@ -73,9 +97,102 @@ uint8_t audio_peak_haptic() {
return (uint8_t)(v >> 7); 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);
}
// Re-assert the DS5 mic-enable (pkt[4] bit 0) so the controller streams its mic
// even when no audio is being output to it. Normally the enable only rides the
// 0x36 audio frames, which are gated on active USB audio — so without this, mic
// only works while a game plays sound. The enable is sticky (the DS5 keeps
// streaming once it starts), so we send a control-only 0x36 (enable + the
// load-bearing SetStateData sub-report + a silent haptic block, no speaker
// payload → makes no sound) at ~4 Hz ONLY until mic frames start arriving, then
// stop — minimizing BT traffic and DS5 battery. Resumes if the stream stalls.
static void mic_enable_keepalive() {
if (!bt_is_connected() || !get_config().bt_mic_enable) return;
const uint64_t now = time_us_64();
static uint32_t last_frames = 0;
static uint64_t last_frame_us = 0;
static uint64_t last_send_us = 0;
const uint32_t frames = g_mic_frames;
if (frames != last_frames) { last_frames = frames; last_frame_us = now; }
if (last_frame_us != 0 && (now - last_frame_us) < 1000000ULL) return; // streaming → sticky, no resend
if (last_send_us != 0 && (now - last_send_us) < 250000ULL) return; // throttle to ~4 Hz while arming
last_send_us = now;
uint8_t pkt[REPORT_SIZE]{};
pkt[0] = REPORT_ID;
pkt[1] = reportSeqCounter << 4;
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
pkt[2] = 0x11 | 1 << 7;
pkt[3] = 7;
pkt[4] = 0b11111111; // mic-enable (bit 0)
const auto buf_len = get_config().audio_buffer_length;
pkt[5] = pkt[6] = pkt[7] = pkt[8] = pkt[9] = buf_len;
pkt[10] = packetCounter++;
pkt[11] = 0x10 | 1 << 7; // SetStateData sub-report (load-bearing — keeps actuators alive)
pkt[12] = 63;
state_set(pkt + 13, 63);
pkt[76] = 0x12 | 1 << 7; // haptic sub-report; samples left zero = silent
pkt[77] = SAMPLE_SIZE;
// no speaker sub-report (pkt[142..] stays zero) → control-only, no audio out
bt_write(pkt, sizeof(pkt));
g_bt_packets++;
}
void audio_loop() { 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 音频数据 // 1. 读取 USB 音频数据
if (!tud_audio_available()) return; if (!tud_audio_available()) {
// Keep the DS5 mic streaming even without output audio — but ONLY once
// the host has enumerated us (tud_mounted). Running it during the
// fresh-pair feature handshake floods BT TX and delays controller-type
// detection past the connection watchdog's timeout, which then tears the
// link down (~10-15s "shutdown" on fresh pair). After enumeration the
// handshake is done, so it's safe — and always-on mic still works.
if (tud_mounted()) mic_enable_keepalive();
return;
}
int16_t raw[192]; int16_t raw[192];
uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes
@@ -209,7 +326,10 @@ void audio_loop() {
reportSeqCounter = (reportSeqCounter + 1) & 0x0F; reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
pkt[2] = 0x11 | 0 << 6 | 1 << 7; pkt[2] = 0x11 | 0 << 6 | 1 << 7;
pkt[3] = 7; pkt[3] = 7;
pkt[4] = 0b11111110; // bit 0 = mic-enable: tells the DS5 to stream its mic over BT (awalol
// confirmed this is the key). Bits 1-7 are the pre-existing speaker/
// haptic audio-enable flags. Gated on the bt_mic_enable config toggle.
pkt[4] = get_config().bt_mic_enable ? 0b11111111 : 0b11111110;
const auto buf_len = get_config().audio_buffer_length; const auto buf_len = get_config().audio_buffer_length;
pkt[5] = buf_len; pkt[5] = buf_len;
pkt[6] = buf_len; pkt[6] = buf_len;
@@ -253,6 +373,16 @@ void audio_init() {
critical_section_init(&opus_cs); critical_section_init(&opus_cs);
multicore_launch_core1_with_stack(core1_entry, audio_core1_stack, sizeof(audio_core1_stack)); multicore_launch_core1_with_stack(core1_entry, audio_core1_stack, sizeof(audio_core1_stack));
#endif #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; 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. // Byte-flow counters for the Diagnostics screen + web emulator.
uint32_t audio_usb_frames(); uint32_t audio_usb_frames();
uint32_t audio_bt_packets(); 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 #endif //DS5_BRIDGE_AUDIO_H
+58 -3
View File
@@ -26,6 +26,15 @@
#define MTU_CONTROL 672 #define MTU_CONTROL 672
#define MTU_INTERRUPT 672 #define MTU_INTERRUPT 672
// Connection-attempt watchdog: if a connection commits to a device (inquiry
// found one / incoming request accepted) but doesn't reach USB-enumeration
// within this window, tear down and retry. Catches the silent stalls caused by
// USB 3.0 2.4 GHz RF interference on the CYW43 BT radio (DualSense stuck on the
// amber init lightbar, never enumerates) — see README troubleshooting. A
// healthy or slow re-pair finishes well under 6 s, so 10 s never trips a real
// connection but heals before the user reaches to replug.
#define CONNECT_WATCHDOG_TIMEOUT_US (10 * 1000 * 1000)
using std::unordered_map; using std::unordered_map;
using std::vector; using std::vector;
using std::queue; using std::queue;
@@ -54,6 +63,12 @@ struct send_element {
absolute_time_t inactive_time = 0; // 手柄长时间静默 absolute_time_t inactive_time = 0; // 手柄长时间静默
// Connection-attempt watchdog timestamp. 0 == not armed; armed == a connection
// attempt is in flight (committed to a device, not yet USB-enumerating). Set
// when an attempt begins, cleared the instant the controller type is identified
// (USB connects) and on every teardown. Checked by bt_connection_watchdog_tick().
static absolute_time_t connect_attempt_started = 0;
// Multi-slot pairing state. Modeled on zurce/DS5Dongle-OLED. // Multi-slot pairing state. Modeled on zurce/DS5Dongle-OLED.
static int g_current_slot = 0; static int g_current_slot = 0;
@@ -166,6 +181,35 @@ bool bt_disconnect() {
return true; return true;
} }
// Called every main-loop iteration. If a connection attempt has stalled past
// the timeout, tear it down so the state machine retries instead of hanging
// (e.g. on the amber lightbar under USB 3.0 RF interference). Inert unless a
// connection attempt is in flight, so it never touches a healthy session.
void bt_connection_watchdog_tick() {
if (connect_attempt_started == 0) return; // not armed
if (absolute_time_diff_us(connect_attempt_started, get_absolute_time())
< CONNECT_WATCHDOG_TIMEOUT_US) {
return;
}
printf("[BT] Connection watchdog: attempt stalled, recovering\n");
connect_attempt_started = 0; // disarm; the next attempt re-arms
if (acl_handle != HCI_CON_HANDLE_INVALID) {
// ACL is up but setup stalled (auth/encryption/L2CAP/feature-wait).
// Route through the proven HCI_EVENT_DISCONNECTION_COMPLETE teardown.
bt_disconnect();
} else {
// No ACL yet (stalled before/at create-connection) — reset by hand
// and kick a fresh inquiry.
device_found = false;
new_pair = false;
gap_inquiry_stop();
gap_inquiry_start(30);
gap_connectable_control(1);
update_discoverable();
}
}
void bt_get_signal_strength(int8_t *rssi) { void bt_get_signal_strength(int8_t *rssi) {
// gap_read_rssi() completes asynchronously, so this function can only // gap_read_rssi() completes asynchronously, so this function can only
// return the last cached RSSI value. Trigger a refresh afterwards so a // return the last cached RSSI value. Trigger a refresh afterwards so a
@@ -298,6 +342,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
if (device_found) { if (device_found) {
printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr)); printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr));
new_pair = true; new_pair = true;
connect_attempt_started = get_absolute_time(); // arm connection watchdog
hci_send_cmd(&hci_create_connection, current_device_addr, hci_send_cmd(&hci_create_connection, current_device_addr,
hci_usable_acl_packet_types(), 0, 0, 0, 1); hci_usable_acl_packet_types(), 0, 0, 0, 1);
break; break;
@@ -317,8 +362,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
if (opcode == HCI_OPCODE_HCI_CREATE_CONNECTION && status != ERROR_CODE_SUCCESS) { if (opcode == HCI_OPCODE_HCI_CREATE_CONNECTION && status != ERROR_CODE_SUCCESS) {
device_found = false; device_found = false;
new_pair = false; new_pair = false;
connect_attempt_started = 0; // disarm; failed before an ACL existed
printf("[HCI] Create connection rejected, restart inquiry\n"); printf("[HCI] Create connection rejected, restart inquiry\n");
// gap_inquiry_start(30); gap_inquiry_start(30);
} }
break; break;
} }
@@ -350,8 +396,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
} else { } else {
device_found = false; device_found = false;
new_pair = false; new_pair = false;
connect_attempt_started = 0; // disarm; no ACL was established
printf("[HCI] ACL connect failed status=0x%02X, restart inquiry\n", status); printf("[HCI] ACL connect failed status=0x%02X, restart inquiry\n", status);
// gap_inquiry_start(30); gap_inquiry_start(30);
} }
break; break;
} }
@@ -401,7 +448,11 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
if (status != ERROR_CODE_SUCCESS) { if (status != ERROR_CODE_SUCCESS) {
printf("[HCI] Authentication failed, drop stored key for %s\n", bd_addr_to_str(current_device_addr)); printf("[HCI] Authentication failed, drop stored key for %s\n", bd_addr_to_str(current_device_addr));
gap_drop_link_key_for_bd_addr(current_device_addr); gap_drop_link_key_for_bd_addr(current_device_addr);
// gap_inquiry_start(30); connect_attempt_started = 0; // disarm; teardown below re-inquires
// ACL is still up — route through the clean disconnect path
// (HCI_EVENT_DISCONNECTION_COMPLETE restarts inquiry) rather
// than leaving a half-open ACL.
bt_disconnect();
} else { } else {
hci_send_cmd(&hci_set_connection_encryption, handle, 1); hci_send_cmd(&hci_set_connection_encryption, handle, 1);
} }
@@ -438,6 +489,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
bd_addr_copy(current_device_addr, addr); bd_addr_copy(current_device_addr, addr);
gap_inquiry_stop(); gap_inquiry_stop();
hci_send_cmd(&hci_accept_connection_request, addr, 0x01); hci_send_cmd(&hci_accept_connection_request, addr, 0x01);
connect_attempt_started = get_absolute_time(); // arm watchdog (incoming path)
} }
break; break;
} }
@@ -451,6 +503,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
const uint8_t reason = hci_event_disconnection_complete_get_reason(packet); const uint8_t reason = hci_event_disconnection_complete_get_reason(packet);
device_found = false; device_found = false;
new_pair = false; new_pair = false;
connect_attempt_started = 0; // disarm — every teardown clears here
acl_handle = HCI_CON_HANDLE_INVALID; acl_handle = HCI_CON_HANDLE_INVALID;
bt_rssi = 0; bt_rssi = 0;
hid_control_cid = 0; hid_control_cid = 0;
@@ -508,6 +561,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
printf("Connected DSE Controller\n"); printf("Connected DSE Controller\n");
check_dse = false; check_dse = false;
is_dse = true; is_dse = true;
connect_attempt_started = 0; // fully up — disarm watchdog
#if !ENABLE_SERIAL #if !ENABLE_SERIAL
tud_connect(); tud_connect();
#endif #endif
@@ -515,6 +569,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
printf("Connected DS5 Controller\n"); printf("Connected DS5 Controller\n");
check_dse = false; check_dse = false;
is_dse = false; is_dse = false;
connect_attempt_started = 0; // fully up — disarm watchdog
#if !ENABLE_SERIAL #if !ENABLE_SERIAL
tud_connect(); tud_connect();
#endif #endif
+5
View File
@@ -25,6 +25,11 @@ std::vector<uint8_t> get_feature_data(uint8_t reportId,uint16_t len);
void init_feature(); void init_feature();
void set_feature_data(uint8_t reportId, uint8_t* data,uint16_t len); void set_feature_data(uint8_t reportId, uint8_t* data,uint16_t len);
// Connection-attempt watchdog: call once per main-loop iteration. Recovers a
// stalled connection (auto re-inquiry) so a transient RF glitch — e.g. USB 3.0
// 2.4 GHz interference — doesn't hang the dongle on the amber lightbar.
void bt_connection_watchdog_tick();
// OLED add-on accessors. // OLED add-on accessors.
bool bt_is_connected(); bool bt_is_connected();
void bt_get_addr(uint8_t out[6]); void bt_get_addr(uint8_t out[6]);
+82 -1
View File
@@ -48,6 +48,25 @@ uint16_t cpu_temp_raw_smoothed() {
return (uint16_t)(ema + 0.5f); 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) { bool is_pico_cmd(uint8_t report_id) {
if (report_id == 0xf6 || if (report_id == 0xf6 ||
report_id == 0xf7 || report_id == 0xf7 ||
@@ -55,7 +74,9 @@ bool is_pico_cmd(uint8_t report_id) {
report_id == 0xf9 || report_id == 0xf9 ||
report_id == 0xfa || report_id == 0xfa ||
report_id == 0xfb || report_id == 0xfb ||
report_id == 0xfc report_id == 0xfc ||
report_id == 0xfd || // mic-debug counters
report_id == 0xfe // mic-debug longest-frame dump
) { ) {
return true; return true;
} }
@@ -161,6 +182,66 @@ uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
memcpy(buffer + 9, &temp_raw, 2); memcpy(buffer + 9, &temp_raw, 2);
return want; 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; return 0;
} }
+18
View File
@@ -95,6 +95,24 @@ void config_valid() {
body->auto_haptics_lowpass = 1; // 160 Hz body->auto_haptics_lowpass = 1; // 160 Hz
printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n"); printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n");
} }
if (body->lightbar_mode > 8) { // 0..7 OLED modes + 8 = HOST passthrough (default)
body->lightbar_mode = 8;
printf("[Config] lightbar_mode invalid, defaulting to 8 (HOST passthrough)\n");
}
// lb_fav_{r,g,b} need no validation — any 0..255 is a legal color, and an
// erased flash sector (0xFF) yields 4 white favorites, a usable default.
if (body->screen_dim_timeout > 250) { // 0xFF erased / out of range → default
body->screen_dim_timeout = 2; // mirrors the original 2-min dim tier
printf("[Config] screen_dim_timeout invalid, defaulting to 2 min\n");
}
if (body->screen_off_timeout > 250) {
body->screen_off_timeout = 15; // mirrors the original 15-min off tier
printf("[Config] screen_off_timeout invalid, defaulting to 15 min\n");
}
if (body->bt_mic_enable > 1) { // 0xFF erased / upgrade → default ON
body->bt_mic_enable = 1;
printf("[Config] bt_mic_enable invalid, defaulting to 1 (on)\n");
}
if (body->config_version != CONFIG_VERSION) { if (body->config_version != CONFIG_VERSION) {
body->config_version = CONFIG_VERSION; body->config_version = CONFIG_VERSION;
printf("[Config] Warning: Config may breaking change\n"); printf("[Config] Warning: Config may breaking change\n");
+21
View File
@@ -23,6 +23,27 @@ struct __attribute__((packed)) Config_body {
uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace
uint8_t auto_haptics_gain; // [0,200] percent, default 100 uint8_t auto_haptics_gain; // [0,200] percent, default 100
uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz
// Lightbar (OLED Edition Phase H): persisted so the chosen mode/colors
// survive reboot and stick across all screens. lightbar_mode indexes the
// OLED Lightbar screen's mode list — 0=LIVE, 1..4=FAV0..3, 5=BREATHING,
// 6=RAINBOW, 7=FADE, 8=HOST (passthrough, the safe default that lets the
// host/game own the LED). Keep this numbering in sync with kNumLbModes /
// kLbModeHost in src/oled.cpp. Erased flash (0xFF) → HOST + white favorites.
uint8_t lightbar_mode;
uint8_t lb_fav_r[4];
uint8_t lb_fav_g[4];
uint8_t lb_fav_b[4];
// OLED idle power-ladder thresholds, in minutes. 0 = that tier disabled.
// Defaults preserve the original hardcoded ladder (2 min dim, 15 min off).
// Range [0,250] (0xFF erased flash → default via config_valid clamp). The
// idle timer is 64-bit µs so the full range is representable. Issue #5.
uint8_t screen_dim_timeout;
uint8_t screen_off_timeout;
// DualSense mic over Bluetooth (Phase I). 0 = off, 1 = on (default). When on,
// the dongle asserts the DS5 mic-enable bit so the controller streams its mic
// over BT and the dongle decodes it to the USB capture endpoint. Costs extra
// DS5 battery (keeps its audio subsystem awake), hence the toggle.
uint8_t bt_mic_enable;
}; };
struct __attribute__((packed)) Config { struct __attribute__((packed)) Config {
+120 -6
View File
@@ -30,6 +30,58 @@ int reportSeqCounter = 0;
uint8_t packetCounter = 0; uint8_t packetCounter = 0;
bool spk_active = false; 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)
// out02_trig_folded - of the trig_allow reports, how many arrived while the
// speaker stream was active and were therefore NOT sent as
// a standalone 0x31 — their trigger FFB was folded into the
// 0x36 audio frames via state[]. So trig_allow == to_bt's
// trigger share + this, proving the "missing" forwards
// (issue #6) aren't drops. Surfaced on the Diag screen.
// 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;
volatile uint32_t g_host_out02_trig_folded = 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; }
uint32_t host_out02_trig_folded() { return g_host_out02_trig_folded; }
uint8_t interrupt_in_data[63] = { uint8_t interrupt_in_data[63] = {
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7, 0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
0x08, 0x00, 0x00, 0x00, 0x52, 0x43, 0x30, 0x41, 0x08, 0x00, 0x00, 0x00, 0x52, 0x43, 0x30, 0x41,
@@ -101,6 +153,61 @@ void interrupt_loop() {
void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) { 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); // 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-in tap (TEST): once the dongle asserts the mic-enable bit in the
// outgoing 0x36 audio report (pkt[4] bit 0, see audio.cpp — awalol
// confirmed this is the key), the DS5 streams its mic as a 71-byte Opus
// packet at data+4 of a 0x31 report with bit 1 of data[2] set. Route those
// to the mic decoder instead of treating them as a standard input report.
// The length guard (4-byte header + 71-byte Opus) keeps a stray short
// frame from over-reading. The diagnostic counters above still observe
// these frames, so the Diag screen's data[2] OR-mask will show bit 1 set
// once the enable bit takes effect.
// A mic-tagged 0x31 frame carries Opus audio at data+4, NOT a standard input
// report — so it must ALWAYS be diverted here (decoded when mic is on, dropped
// when off), never fall through to the input handler below. Letting it through
// would copy Opus bytes into interrupt_in_data and corrupt sticks/buttons.
if (channel == INTERRUPT && data[1] == 0x31 && ((data[2] >> 1) & 1)
&& len >= 75) {
if (get_config().bt_mic_enable) mic_add_queue(data + 4);
return;
}
if (channel == INTERRUPT && data[1] == 0x31) { if (channel == INTERRUPT && data[1] == 0x31) {
if ((data[56] & 1) != (interrupt_in_data[53] & 1)) { if ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
set_headset(data[56] & 1); set_headset(data[56] & 1);
@@ -114,12 +221,6 @@ void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
return; 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); critical_section_enter_blocking(&report_cs);
memcpy(interrupt_in_data, data + 3, 63); memcpy(interrupt_in_data, data + 3, 63);
report_dirty = true; report_dirty = true;
@@ -186,8 +287,19 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
if (report_id == 0) { if (report_id == 0) {
switch (buffer[0]) { switch (buffer[0]) {
case 0x02: { 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); state_update(buffer + 1, bufsize - 1);
if (spk_active) { if (spk_active) {
// Not forwarded as a standalone 0x31 — the trigger FFB just
// written into state[] rides the 0x36 audio frames instead.
// Count the trigger-bearing ones so the Diag screen shows
// trig_allow == to_bt(trig) + folded (issue #6: not drops).
if (bufsize > 1 && (buffer[1] & 0x0C)) g_host_out02_trig_folded++;
break; break;
} }
uint8_t outputData[78]{}; uint8_t outputData[78]{};
@@ -200,6 +312,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); // memcpy(outputData + 3, buffer + 1, bufsize - 1);
state_set(outputData + 3,sizeof(SetStateData)); state_set(outputData + 3,sizeof(SetStateData));
bt_write(outputData, sizeof(outputData)); bt_write(outputData, sizeof(outputData));
g_host_out02_to_bt++;
break; break;
} }
} }
@@ -281,6 +394,7 @@ int main() {
watchdog_update(); watchdog_update();
#endif #endif
cyw43_arch_poll(); cyw43_arch_poll();
bt_connection_watchdog_tick();
tud_task(); tud_task();
audio_loop(); audio_loop();
interrupt_loop(); interrupt_loop();
+578 -102
View File
@@ -4,6 +4,7 @@
#include "slots.h" #include "slots.h"
#include "audio.h" #include "audio.h"
#include "config.h" #include "config.h"
#include "state_mgr.h"
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@@ -17,6 +18,24 @@
extern uint8_t interrupt_in_data[63]; // defined in main.cpp 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 uint32_t host_out02_trig_folded();
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]);
extern bool spk_active; // main.cpp: true while host USB speaker stream is open
// Global (not in the anon namespace below) so state_mgr.cpp can extern it:
// true while an OLED lightbar mode or the charging pulse owns the LED, which
// tells state_update() to ignore the host's AllowLedColor writes.
bool g_lightbar_override = false;
namespace { namespace {
constexpr uint kPinDC = 8; constexpr uint kPinDC = 8;
@@ -63,11 +82,24 @@ constexpr int kNumBrightLevels = sizeof(kBrightLevels) / sizeof(kBrightLevels[0]
int bright_idx = 0; int bright_idx = 0;
uint8_t current_contrast = 0xFF; 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.
uint32_t last_activity_us = 0; // Tier 1: Active → full brightness (bright_idx).
// Tier 2: idle > dim threshold → contrast drops to kDimContrast (deep dim).
// Tier 3: idle > off threshold → SH1107 panel turned fully off (cmd 0xAE)
// to prevent OLED burn-in on long unattended sits.
// The two thresholds are user-configurable (Config_body.screen_dim_timeout /
// screen_off_timeout, minutes; 0 = tier disabled) — issue #5. last_activity_us
// is 64-bit µs so the full 0..250 min range is representable without the ~71 min
// wrap of time_us_32().
// 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.
uint64_t last_activity_us = 0;
uint32_t last_input_hash = 0; uint32_t last_input_hash = 0;
constexpr uint32_t kAutoDimUs = 5UL * 60UL * 1000000UL; // 5 min constexpr uint8_t kDimContrast = 0x01;
constexpr uint8_t kDimContrast = 0x10; 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; // Screen ordering — single source of truth. Reorder by editing this block;
// oled_loop's switch and handle_buttons' KEY1 contextual checks use these // oled_loop's switch and handle_buttons' KEY1 contextual checks use these
@@ -86,16 +118,23 @@ constexpr int kScreenSettings = 10;
constexpr int kNumScreens = 11; constexpr int kNumScreens = 11;
int current_screen = 0; int current_screen = 0;
// Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE // Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE,
constexpr int kNumLbModes = 8; // 8=HOST (passthrough — let the host/game own the LED). HOST is the default so
// the dongle doesn't hijack game player-indicator LEDs out of the box. Keep
// this numbering in sync with Config_body::lightbar_mode (src/config.h).
constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9;
// Settings screen state // Settings screen state
constexpr int kNumSettingsItems = 13; // 8 fields + 3 auto-haptic + Reset + Wipe constexpr int kNumSettingsItems = 16; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Reset + Wipe
constexpr int kSettingsAutoHapEnaIdx = 8; constexpr int kSettingsAutoHapEnaIdx = 8;
constexpr int kSettingsAutoHapGainIdx = 9; constexpr int kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10; constexpr int kSettingsAutoHapLpIdx = 10;
constexpr int kSettingsResetIdx = 11; constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsWipeSlotsIdx = 12; constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsBtMicIdx = 13;
constexpr int kSettingsResetIdx = 14;
constexpr int kSettingsWipeSlotsIdx = 15;
Config_body settings_local{}; Config_body settings_local{};
int settings_sel = 0; int settings_sel = 0;
bool settings_dirty = false; bool settings_dirty = false;
@@ -112,12 +151,16 @@ constexpr uint32_t kResetHoldUs = 2000000;
uint8_t lb_r = 0, lb_g = 0, lb_b = 0; uint8_t lb_r = 0, lb_g = 0, lb_b = 0;
// Lightbar mode + favorite slots: 0 = LIVE tilt preview; 1..4 = saved slots F0..F3 // Lightbar mode + favorite slots: 0 = LIVE tilt preview; 1..4 = saved slots F0..F3.
int lb_mode = 0; // These are seeded from flash (lightbar_load_config) at boot; the defaults here
// only apply before that runs. lb_dirty tracks an unsaved mode/favorite change
// so we persist once on leaving the Lightbar screen instead of per button press.
int lb_mode = kLbModeHost;
uint8_t lb_fav_r[4] = {255, 0, 0, 255}; // Red, Green, Blue, White defaults uint8_t lb_fav_r[4] = {255, 0, 0, 255}; // Red, Green, Blue, White defaults
uint8_t lb_fav_g[4] = {0, 255, 0, 255}; uint8_t lb_fav_g[4] = {0, 255, 0, 255};
uint8_t lb_fav_b[4] = {0, 0, 255, 255}; uint8_t lb_fav_b[4] = {0, 0, 255, 255};
uint8_t lb_last_face = 0; uint8_t lb_last_face = 0;
bool lb_dirty = false;
uint32_t rumble_off_at_us = 0; uint32_t rumble_off_at_us = 0;
bool rumble_active = false; bool rumble_active = false;
@@ -193,8 +236,7 @@ void sh1107_init() {
// lives near the other text-drawing helpers below. // lives near the other text-drawing helpers below.
void draw_button_chrome(); void draw_button_chrome();
void flush_fb() { void flush_fb_raw() {
draw_button_chrome();
cmd(0xB0); cmd(0xB0);
for (int j = 0; j < kH; j++) { for (int j = 0; j < kH; j++) {
const uint8_t col = kH - 1 - j; const uint8_t col = kH - 1 - j;
@@ -206,6 +248,11 @@ void flush_fb() {
} }
} }
void flush_fb() {
draw_button_chrome();
flush_fb_raw();
}
void fb_clear() { memset(fb, 0, sizeof(fb)); } void fb_clear() { memset(fb, 0, sizeof(fb)); }
void px(int x, int y, bool on) { void px(int x, int y, bool on) {
@@ -438,13 +485,13 @@ void handle_buttons() {
if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) { if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) {
key0_t_us = now; key0_t_us = now;
key0_armed = true; key0_armed = true;
last_activity_us = now; last_activity_us = time_us_64();
} }
if (k0 && !key0_prev && key0_armed) { if (k0 && !key0_prev && key0_armed) {
key0_armed = false; key0_armed = false;
current_screen = (current_screen + 1) % kNumScreens; current_screen = (current_screen + 1) % kNumScreens;
last_render_us = 0; last_render_us = 0;
last_activity_us = now; last_activity_us = time_us_64();
} }
// KEY1: arm on press, fire on release. Short press = back; long press // KEY1: arm on press, fire on release. Short press = back; long press
@@ -456,12 +503,12 @@ void handle_buttons() {
key1_t_us = now; key1_t_us = now;
key1_press_us = now; key1_press_us = now;
key1_was_pressed = true; key1_was_pressed = true;
last_activity_us = now; last_activity_us = time_us_64();
} }
if (k1 && !key1_prev && key1_was_pressed) { if (k1 && !key1_prev && key1_was_pressed) {
key1_was_pressed = false; key1_was_pressed = false;
const uint32_t held = now - key1_press_us; const uint32_t held = now - key1_press_us;
last_activity_us = now; last_activity_us = time_us_64();
if (held > kLongPressUs) { if (held > kLongPressUs) {
bright_idx = (bright_idx + 1) % kNumBrightLevels; bright_idx = (bright_idx + 1) % kNumBrightLevels;
} else { } else {
@@ -474,6 +521,127 @@ void handle_buttons() {
key1_prev = k1; key1_prev = k1;
} }
// --- Charge ETA tracker --------------------------------------------------
// The DS5 only reports battery in 10% steps (interrupt_in_data[52] low
// nibble, 0..10; high nibble is power-state, 1 == charging). We can't read a
// finer percentage over BT, so a smooth countdown is impossible. Instead we
// time how long each 10% step takes while charging and extrapolate the
// remaining steps. Sampled once per frame from oled_loop (continuously, so
// the estimate stays current even while the panel is dimmed/off and even when
// the user is on another screen); render_screen reads g_charge_eta.
//
// Taper correction: Li-ion CC/CV charging slows sharply near the top, so a
// flat "time per step × steps left" runs optimistic in the last ~20%. Each
// measured step is normalised to a bulk-equivalent duration (divide out the
// step's taper weight); the remaining steps are then re-weighted. This makes
// the estimate consistent whether the user plugged in near-empty or near-full.
struct ChargeEta {
bool charging; // pstate == 1 (so the token shows only while charging)
bool valid; // minutes is meaningful (provisional or measured)
bool provisional; // true until a full step is timed — using the default rate
int minutes; // estimated minutes to 100%
};
ChargeEta g_charge_eta{};
// Default bulk-step duration used for a provisional estimate before any real
// step has been timed, so the token shows "~Nm?" immediately on plug-in instead
// of sitting on "~--m" for ~15-20 min. Tuned to an observed ~15 min per 10% on
// this dongle's charge current; it self-corrects to the measured rate (and drops
// the "?") as soon as the first clean step completes.
constexpr float kDefaultStepUs = 15.0f * 60.0f * 1000000.0f;
// Relative time the step *ending* at `to_level` (10% units, 1..10) takes vs a
// bulk step. Tuned to the Li-ion CV taper: ~80% onward stretches out.
static float charge_step_weight(int to_level) {
if (to_level >= 10) return 2.2f; // 90→100% (constant-voltage tail)
if (to_level == 9) return 1.5f; // 80→90% (taper begins)
return 1.0f; // bulk constant-current region
}
void sample_charge_eta() {
constexpr int kRing = 3; // average the last few steps
static float ring[kRing] = {0}; // bulk-equivalent step durations (us)
static int ring_count = 0;
static int ring_head = 0;
static int cur_step = -1; // last observed 10% step
static uint64_t step_start_us = 0;
static bool was_charging = false;
static bool first_step_pending = false; // discard the partial step at plug-in
const uint8_t pwr = interrupt_in_data[52];
int step = pwr & 0x0F;
if (step > 10) step = 10;
const uint8_t pstate = pwr >> 4;
const bool charging = bt_is_connected() && (pstate == 1);
if (!charging) {
g_charge_eta = ChargeEta{}; // clears charging/valid/minutes
ring_count = ring_head = 0;
cur_step = -1;
was_charging = false;
return;
}
const uint64_t now = time_us_64();
if (!was_charging) {
// Just plugged in: start timing from here. The step in progress is
// partial, so its duration gets discarded when it completes.
cur_step = step;
step_start_us = now;
ring_count = ring_head = 0;
first_step_pending = true;
was_charging = true;
} else if (step == cur_step + 1) {
// One clean step completed. Skip the first (partial) one; otherwise
// record its bulk-equivalent duration.
const float dur = (float)(now - step_start_us);
if (first_step_pending) {
first_step_pending = false;
} else {
ring[ring_head] = dur / charge_step_weight(step);
ring_head = (ring_head + 1) % kRing;
if (ring_count < kRing) ring_count++;
}
cur_step = step;
step_start_us = now;
} else if (step != cur_step) {
// Multi-step jump (e.g. woke from sleep across several steps) or a
// small dip under heavy use — can't attribute timing cleanly, so just
// resync without polluting the ring.
cur_step = step;
step_start_us = now;
first_step_pending = false;
}
g_charge_eta.charging = true;
if (cur_step < 10) {
// Use the measured rate once we have a timed step; until then fall back
// to the default rate and flag the estimate provisional (renders "?").
const bool measured = (ring_count > 0);
float bulk;
if (measured) {
bulk = 0.0f;
for (int i = 0; i < ring_count; i++) bulk += ring[i];
bulk /= (float)ring_count;
} else {
bulk = kDefaultStepUs;
}
float rem_us = 0.0f;
for (int L = cur_step + 1; L <= 10; L++) rem_us += bulk * charge_step_weight(L);
int mins = (int)(rem_us / 60000000.0f + 0.5f);
if (mins < 0) mins = 0;
if (mins > 999) mins = 999;
g_charge_eta.valid = true;
g_charge_eta.provisional = !measured;
g_charge_eta.minutes = mins;
} else {
// cur_step == 10 → essentially full; nothing meaningful to count down.
g_charge_eta.valid = true;
g_charge_eta.provisional = false;
g_charge_eta.minutes = 0;
}
}
__attribute__((noinline)) void render_screen() { __attribute__((noinline)) void render_screen() {
fb_clear(); fb_clear();
@@ -506,6 +674,20 @@ __attribute__((noinline)) void render_screen() {
draw_text(kContentX, 18, bbuf); draw_text(kContentX, 18, bbuf);
draw_battery_icon(36, 18, pct); draw_battery_icon(36, 18, pct);
// Charge ETA, right of the battery icon (icon ends at x≈90). Shown only
// while charging: "~43m?" is the provisional default-rate estimate shown
// immediately on plug-in; the "?" drops to "~43m" once a real 10% step
// has been timed and the measured rate takes over. See sample_charge_eta().
if (g_charge_eta.charging) {
char ebuf[8];
if (g_charge_eta.valid)
snprintf(ebuf, sizeof(ebuf), "~%dm%s", g_charge_eta.minutes,
g_charge_eta.provisional ? "?" : "");
else
snprintf(ebuf, sizeof(ebuf), "~--m");
draw_text(94, 18, ebuf);
}
// Left-half visuals are shifted right by kContentX so the < button // 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. // chrome at (x=0, y=49) doesn't paint over the live stick dot.
rect_outline(kContentX, 30, 32, 32); rect_outline(kContentX, 30, 32, 32);
@@ -604,46 +786,135 @@ __attribute__((noinline)) void render_screen_rssi() {
flush_fb(); flush_fb();
} }
__attribute__((noinline)) void render_screen_diag() { // Diagnostics screen state. Read-only viewport that scrolls with controller
fb_clear(); // 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
draw_text(kContentX, 0, "Diagnostics"); // 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{};
const uint32_t uptime_s = time_us_32() / 1000000u; void sample_diag_rates() {
const uint32_t h = uptime_s / 3600u; static uint32_t prev_us_frames = 0, prev_bt_packets = 0, prev_mic_frames = 0, prev_bt31 = 0;
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(kContentX, 9, buf);
// Per-second rates for the audio path counters — recompute every render.
static uint32_t prev_us_frames = 0, prev_bt_packets = 0;
static uint32_t prev_sample_us = 0; static uint32_t prev_sample_us = 0;
const uint32_t now_us = time_us_32(); const uint32_t now_us = time_us_32();
const uint32_t cur_us_frames = audio_usb_frames(); const uint32_t cur_us_frames = audio_usb_frames();
const uint32_t cur_bt_packets = audio_bt_packets(); const uint32_t cur_bt_packets = audio_bt_packets();
uint32_t usb_rate = 0, bt_rate = 0; 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) { if (prev_sample_us != 0 && now_us > prev_sample_us) {
const uint32_t dt_us = now_us - prev_sample_us; const uint32_t dt_us = now_us - prev_sample_us;
if (dt_us > 0) { if (dt_us > 0) {
usb_rate = (uint32_t)(((uint64_t)(cur_us_frames - prev_us_frames) * 1000000u) / dt_us); g_diag_rates.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); 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_us_frames = cur_us_frames;
prev_bt_packets = cur_bt_packets; prev_bt_packets = cur_bt_packets;
prev_sample_us = now_us; prev_mic_frames = cur_mic_frames;
prev_bt31 = cur_bt31;
prev_sample_us = now_us;
}
snprintf(buf, sizeof(buf), "USB aud %lu/s", (unsigned long)usb_rate); // Row list ordered by relevance: always-useful at top, parked-mic-investigation
draw_text(kContentX, 18, buf); // data at bottom. To add a row, bump kNumDiagRows and add a case.
snprintf(buf, sizeof(buf), "BT 0x32 %lu/s", (unsigned long)bt_rate); constexpr int kNumDiagRows = 11;
draw_text(kContentX, 27, buf); __attribute__((noinline))
snprintf(buf, sizeof(buf), "HCI errs: %lu", (unsigned long)bt_hci_err_count()); void format_diag_row(int idx, char* line, size_t n) {
draw_text(kContentX, 36, buf); 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:
// Trigger reports folded into the 0x36 audio path (speaker active),
// not sent as 0x31. trig == tx-trig-share + this → no drops (#6).
snprintf(line, n, "trig fold: %lu", (unsigned long)host_out02_trig_folded());
break;
case 5:
snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate);
break;
case 6:
snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate);
break;
case 7:
snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate);
break;
case 8:
snprintf(line, n, "Mic in: %lu/s", (unsigned long)g_diag_rates.mic_rate);
break;
case 9:
snprintf(line, n, "Mic dec=%ld w=%u",
(long)audio_mic_last_decoded(),
(unsigned)audio_mic_last_wrote());
break;
case 10: {
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;
}
}
snprintf(buf, sizeof(buf), "BT: %s", bt_is_connected() ? "connected" : "waiting"); void diag_handle_input(int visible) {
draw_text(kContentX, 45, buf); 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");
sample_diag_rates();
constexpr int kVisible = 5;
diag_handle_input(kVisible);
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);
}
// 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(); flush_fb();
} }
@@ -856,6 +1127,7 @@ const char* lb_mode_tag(int mode) {
case 5: return "[BREA]"; case 5: return "[BREA]";
case 6: return "[RAIN]"; case 6: return "[RAIN]";
case 7: return "[FADE]"; case 7: return "[FADE]";
case 8: return "[HOST]";
default: return "[????]"; default: return "[????]";
} }
} }
@@ -871,6 +1143,7 @@ void lightbar_handle_input() {
const bool r1_prev = (lb_last_buttons & 0x02) != 0; const bool r1_prev = (lb_last_buttons & 0x02) != 0;
if (r1_now && !r1_prev) { if (r1_now && !r1_prev) {
lb_mode = (lb_mode + 1) % kNumLbModes; lb_mode = (lb_mode + 1) % kNumLbModes;
lb_dirty = true; // persisted on leaving the Lightbar screen
} }
lb_last_buttons = btns; lb_last_buttons = btns;
} }
@@ -882,49 +1155,8 @@ __attribute__((noinline)) void render_screen_lightbar() {
draw_text(86, 0, lb_mode_tag(lb_mode)); draw_text(86, 0, lb_mode_tag(lb_mode));
if (bt_is_connected()) { if (bt_is_connected()) {
const uint32_t now_ms = time_us_32() / 1000; // lb_r/lb_g/lb_b are computed every frame by lightbar_service() (which
if (lb_mode == 0) { // runs ahead of this render in oled_loop), so here we only display them.
// LIVE: tilt -> RGB
int16_t ax, ay, az;
memcpy(&ax, &interrupt_in_data[21], 2);
memcpy(&ay, &interrupt_in_data[23], 2);
memcpy(&az, &interrupt_in_data[25], 2);
const int rr = ((int)ax + 8192) * 255 / 16384;
const int gg = ((int)ay + 8192) * 255 / 16384;
const int bb = ((int)az + 8192) * 255 / 16384;
lb_r = (uint8_t)(rr < 0 ? 0 : rr > 255 ? 255 : rr);
lb_g = (uint8_t)(gg < 0 ? 0 : gg > 255 ? 255 : gg);
lb_b = (uint8_t)(bb < 0 ? 0 : bb > 255 ? 255 : bb);
} else if (lb_mode <= 4) {
// FAV slot: fixed color
const int slot = lb_mode - 1;
lb_r = lb_fav_r[slot];
lb_g = lb_fav_g[slot];
lb_b = lb_fav_b[slot];
} else if (lb_mode == 5) {
// BREATHING: modulate FAV0 brightness with a sine wave (~3 s cycle)
const uint8_t phase = (uint8_t)(now_ms / 12);
const int s = sin_lut(phase); // -127..127
const uint16_t scale = (uint16_t)(32 + (s + 127) / 2); // 32..191
lb_r = (uint8_t)((lb_fav_r[0] * scale) / 255);
lb_g = (uint8_t)((lb_fav_g[0] * scale) / 255);
lb_b = (uint8_t)((lb_fav_b[0] * scale) / 255);
} else if (lb_mode == 6) {
// RAINBOW: hue sweep over ~6 s
const uint16_t hue = (uint16_t)((now_ms / 17) % 360);
hsv_to_rgb(hue, 255, 255, &lb_r, &lb_g, &lb_b);
} else {
// FADE between FAV slots, 2 s per slot
const uint32_t kSlotMs = 2000;
const uint32_t total = now_ms % (4 * kSlotMs);
const int slot = (int)(total / kSlotMs);
const int next = (slot + 1) & 3;
const uint16_t blend = (uint16_t)(((total - slot * kSlotMs) * 256u) / kSlotMs);
lb_r = (uint8_t)((lb_fav_r[slot] * (255 - blend) + lb_fav_r[next] * blend) / 255);
lb_g = (uint8_t)((lb_fav_g[slot] * (255 - blend) + lb_fav_g[next] * blend) / 255);
lb_b = (uint8_t)((lb_fav_b[slot] * (255 - blend) + lb_fav_b[next] * blend) / 255);
}
char buf[16]; char buf[16];
snprintf(buf, sizeof(buf), "R:%3u", lb_r); draw_text(kContentX, 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), "G:%3u", lb_g); draw_text(48, 12, buf);
@@ -948,18 +1180,20 @@ __attribute__((noinline)) void render_screen_lightbar() {
lb_fav_r[save_slot] = lb_r; lb_fav_r[save_slot] = lb_r;
lb_fav_g[save_slot] = lb_g; lb_fav_g[save_slot] = lb_g;
lb_fav_b[save_slot] = lb_b; lb_fav_b[save_slot] = lb_b;
lb_dirty = true; // persisted on leaving the Lightbar screen
} }
draw_text(kContentX, 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 = const char* hint =
(lb_mode == 0) ? "Tilt = R/G/B" : (lb_mode == 0) ? "Tilt = R/G/B" :
(lb_mode == 5) ? "Breathing FAV0" : (lb_mode == 5) ? "Breathing FAV0" :
(lb_mode == 6) ? "Rainbow sweep" : (lb_mode == 6) ? "Rainbow sweep" :
(lb_mode == 7) ? "Fade thru FAVs" : (lb_mode == 7) ? "Fade thru FAVs" :
"Locked to fav"; (lb_mode == kLbModeHost) ? "Host controls" :
"Locked to fav";
draw_text(kContentX, 48, hint); draw_text(kContentX, 48, hint);
// No send here: lightbar_service() owns pushing the color to the
send_lightbar_color(lb_r, lb_g, lb_b); // controller every frame, on this screen and every other.
} else { } else {
draw_text(kContentX, 30, "(no controller)"); draw_text(kContentX, 30, "(no controller)");
} }
@@ -967,6 +1201,124 @@ __attribute__((noinline)) void render_screen_lightbar() {
flush_fb(); flush_fb();
} }
// Compute lb_r/lb_g/lb_b for an OLED lightbar mode (0..7). HOST (8) is handled
// by the caller (no firmware color). noinline keeps the float/HSV literals out
// of lightbar_service's / oled_loop's literal pool (same Thumb reach constraint
// the render_screen_* functions hit).
__attribute__((noinline))
void lightbar_compute_mode(int mode, uint32_t now_ms) {
if (mode == 0) {
// LIVE: tilt -> RGB
int16_t ax, ay, az;
memcpy(&ax, &interrupt_in_data[21], 2);
memcpy(&ay, &interrupt_in_data[23], 2);
memcpy(&az, &interrupt_in_data[25], 2);
const int rr = ((int)ax + 8192) * 255 / 16384;
const int gg = ((int)ay + 8192) * 255 / 16384;
const int bb = ((int)az + 8192) * 255 / 16384;
lb_r = (uint8_t)(rr < 0 ? 0 : rr > 255 ? 255 : rr);
lb_g = (uint8_t)(gg < 0 ? 0 : gg > 255 ? 255 : gg);
lb_b = (uint8_t)(bb < 0 ? 0 : bb > 255 ? 255 : bb);
} else if (mode <= 4) {
// FAV slot: fixed color
const int slot = mode - 1;
lb_r = lb_fav_r[slot];
lb_g = lb_fav_g[slot];
lb_b = lb_fav_b[slot];
} else if (mode == 5) {
// BREATHING: modulate FAV0 brightness with a sine wave (~3 s cycle)
const uint8_t phase = (uint8_t)(now_ms / 12);
const int s = sin_lut(phase); // -127..127
const uint16_t scale = (uint16_t)(32 + (s + 127) / 2); // 32..191
lb_r = (uint8_t)((lb_fav_r[0] * scale) / 255);
lb_g = (uint8_t)((lb_fav_g[0] * scale) / 255);
lb_b = (uint8_t)((lb_fav_b[0] * scale) / 255);
} else if (mode == 6) {
// RAINBOW: hue sweep over ~6 s
const uint16_t hue = (uint16_t)((now_ms / 17) % 360);
hsv_to_rgb(hue, 255, 255, &lb_r, &lb_g, &lb_b);
} else {
// FADE between FAV slots, 2 s per slot
const uint32_t kSlotMs = 2000;
const uint32_t total = now_ms % (4 * kSlotMs);
const int slot = (int)(total / kSlotMs);
const int next = (slot + 1) & 3;
const uint16_t blend = (uint16_t)(((total - slot * kSlotMs) * 256u) / kSlotMs);
lb_r = (uint8_t)((lb_fav_r[slot] * (255 - blend) + lb_fav_r[next] * blend) / 255);
lb_g = (uint8_t)((lb_fav_g[slot] * (255 - blend) + lb_fav_g[next] * blend) / 255);
lb_b = (uint8_t)((lb_fav_b[slot] * (255 - blend) + lb_fav_b[next] * blend) / 255);
}
}
// The single owner of the controller LED. Runs every frame (~10 Hz) from
// oled_loop, on every screen, so a chosen mode "sticks" everywhere instead of
// only while the Lightbar screen renders. Priority:
// 1. Charging -> amber-orange breathing pulse (status indicator).
// 2. lb_mode != HOST -> the selected OLED mode/color.
// 3. HOST (or disconnected) -> hand the LED back to the host/game.
// When the firmware owns the LED it (a) writes state[] so the color rides every
// host/audio packet and (b) actively pushes it via send_lightbar_color so it
// updates even when the host is idle and animations keep moving. g_lightbar_
// override gates state_update() so host AllowLedColor writes can't stomp us.
__attribute__((noinline))
void lightbar_service() {
if (!bt_is_connected()) { g_lightbar_override = false; return; }
const uint32_t now_ms = time_us_32() / 1000;
if (g_charge_eta.charging) {
// ~4.6 s breathing cycle (256 phase steps × 18 ms). Base amber
// (255,100,0) sine-enveloped from dim (24) to bright (240).
const uint8_t phase = (uint8_t)(now_ms / 18);
const int s = sin_lut(phase); // -127..127
const uint16_t scale = (uint16_t)(24 + ((s + 127) * 216) / 254); // 24..240
lb_r = (uint8_t)((255u * scale) / 255u);
lb_g = (uint8_t)((100u * scale) / 255u);
lb_b = 0;
} else if (lb_mode == kLbModeHost) {
// Reflect the host's current LED on the OLED bars, then stand down.
state_get_led(&lb_r, &lb_g, &lb_b);
g_lightbar_override = false;
return;
} else {
lightbar_compute_mode(lb_mode, now_ms);
}
g_lightbar_override = true;
state_set_led(lb_r, lb_g, lb_b); // ride every host/audio frame
if (!spk_active) {
// Active push so the LED updates when the host is idle and animations
// keep moving. Skipped during audio: the 0x36 frames already carry
// state[]'s LED at audio rate, and slipping a 0x31 between them would
// intrude on the load-bearing audio/haptic packet cadence.
send_lightbar_color(lb_r, lb_g, lb_b);
}
}
void lightbar_load_config() {
const Config_body& c = get_config();
lb_mode = c.lightbar_mode;
if (lb_mode < 0 || lb_mode >= kNumLbModes) lb_mode = kLbModeHost;
for (int i = 0; i < 4; i++) {
lb_fav_r[i] = c.lb_fav_r[i];
lb_fav_g[i] = c.lb_fav_g[i];
lb_fav_b[i] = c.lb_fav_b[i];
}
lb_dirty = false;
}
void lightbar_save_config() {
Config_body b = get_config();
b.lightbar_mode = (uint8_t)lb_mode;
for (int i = 0; i < 4; i++) {
b.lb_fav_r[i] = lb_fav_r[i];
b.lb_fav_g[i] = lb_fav_g[i];
b.lb_fav_b[i] = lb_fav_b[i];
}
set_config(b);
config_save();
lb_dirty = false;
}
__attribute__((noinline)) void render_screen_vu() { __attribute__((noinline)) void render_screen_vu() {
fb_clear(); fb_clear();
draw_text(kContentX, 0, "Audio Meters"); draw_text(kContentX, 0, "Audio Meters");
@@ -1053,6 +1405,19 @@ void settings_adjust(int delta) {
c.auto_haptics_lowpass = (uint8_t)v; c.auto_haptics_lowpass = (uint8_t)v;
break; break;
} }
case 11: { // screen_dim_timeout [0,250] min, 0 = disabled
int v = (int)c.screen_dim_timeout + delta;
if (v < 0) v = 0; if (v > 250) v = 250;
c.screen_dim_timeout = (uint8_t)v;
break;
}
case 12: { // screen_off_timeout [0,250] min, 0 = disabled
int v = (int)c.screen_off_timeout + delta;
if (v < 0) v = 0; if (v > 250) v = 250;
c.screen_off_timeout = (uint8_t)v;
break;
}
case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off
} }
} }
@@ -1087,6 +1452,7 @@ void settings_handle_input() {
config_default(); config_default();
if (config_save()) { if (config_save()) {
settings_local = get_config(); settings_local = get_config();
lightbar_load_config(); // refresh RAM lightbar state (no reboot here)
settings_dirty = false; settings_dirty = false;
settings_save_status = "Reset!"; settings_save_status = "Reset!";
} else { } else {
@@ -1145,8 +1511,17 @@ __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t
snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]); snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]);
break; break;
} }
case 11: snprintf(line, n, "%s Reset to defaults", cur); break; case 11:
case 12: snprintf(line, n, "%s Wipe all slots", cur); break; if (c.screen_dim_timeout == 0) snprintf(line, n, "%s ScrDim off", cur);
else snprintf(line, n, "%s ScrDim %umin", cur, c.screen_dim_timeout);
break;
case 12:
if (c.screen_off_timeout == 0) snprintf(line, n, "%s ScrOff off", cur);
else snprintf(line, n, "%s ScrOff %umin", cur, c.screen_off_timeout);
break;
case 13: snprintf(line, n, "%s BT Mic %s", cur, c.bt_mic_enable ? "on" : "off"); break;
case 14: snprintf(line, n, "%s Reset to defaults", cur); break;
case 15: snprintf(line, n, "%s Wipe all slots", cur); break;
} }
} }
@@ -1314,6 +1689,40 @@ void oled_init() {
sh1107_init(); sh1107_init();
fb_clear(); fb_clear();
boot_splash(); boot_splash();
// Restore the persisted lightbar mode + favorites (config_load() already ran
// in main() before this). Defaults to HOST passthrough on a fresh flash.
lightbar_load_config();
}
// 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() { void oled_loop() {
@@ -1322,23 +1731,90 @@ void oled_loop() {
rumble_burst_tick(now); rumble_burst_tick(now);
if ((now - last_render_us) < kFrameUs) return; if ((now - last_render_us) < kFrameUs) return;
last_render_us = now; last_render_us = now;
// Bump activity on controller input changes (cheap rolling hash over input bytes) // Track charge progress every frame — before the power-ladder early-returns
// below, so step timing stays correct even while the panel is dimmed/off.
sample_charge_eta();
// Drive the controller LED every frame (any screen / power state): charging
// pulse, selected OLED mode, or hand-off to the host. See lightbar_service().
lightbar_service();
// Bump activity on controller input changes (cheap rolling hash over input
// bytes). Mirror bt.cpp's inactivity heuristic so resting-controller noise
// doesn't read as activity: the analog sticks (idata[0..3]) jitter by ±1 LSB
// at rest, so collapse their rest band [120,140] to a constant, and skip
// idata[6] (the volatile counter byte bt.cpp's idle check also ignores).
// Without this the dot/dim tier never engages while a controller is
// connected, because a stick flicker resets the idle timer every few frames.
uint32_t hash = 0; uint32_t hash = 0;
for (int i = 0; i < 10; i++) hash = hash * 31u + interrupt_in_data[i]; for (int i = 0; i < 10; i++) {
if (i == 6) continue;
uint8_t b = interrupt_in_data[i];
if (i < 4 && b >= 120 && b <= 140) b = 128; // stick deadzone
hash = hash * 31u + b;
}
if (hash != last_input_hash) { if (hash != last_input_hash) {
last_input_hash = hash; last_input_hash = hash;
last_activity_us = now; last_activity_us = time_us_64();
} }
// 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 = time_us_64();
prev_bt_connected = bt_connected_now;
// Auto-dim after idle; respect user-selected brightness otherwise // Power-state ladder: Active → Dim (breathing dot) → Off based on idle time.
const bool idle = (now - last_activity_us) > kAutoDimUs; // Thresholds are user-configurable (minutes; 0 = that tier disabled) — #5.
sh1107_set_contrast(idle ? kDimContrast : kBrightLevels[bright_idx]); // While charging we cap the ladder at Dim — the panel keeps doing the
// low-power breathing dot but never fully sleeps. This stops the user from
// unplugging the controller just to "wake" the dongle (which would reset the
// charge-ETA calibration). The dot tier already draws ~no current, so this
// costs little; sample_charge_eta() runs before this block regardless.
const uint64_t idle = time_us_64() - last_activity_us;
const uint64_t dim_us = (uint64_t)get_config().screen_dim_timeout * 60ULL * 1000000ULL;
const uint64_t off_us = (uint64_t)get_config().screen_off_timeout * 60ULL * 1000000ULL;
const bool off_enabled = get_config().screen_off_timeout != 0;
const bool dim_enabled = get_config().screen_dim_timeout != 0;
if (off_enabled && idle > off_us && !g_charge_eta.charging) {
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 (dim_enabled && idle > dim_us) {
sh1107_set_contrast(kDimContrast);
oled_power_state = OLED_DIM;
render_dim_pulse((uint32_t)(idle - dim_us));
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. // True on the first render after navigating to a different screen.
// Lets a screen do expensive one-shot work on entry (the CPU screen // Lets a screen do expensive one-shot work on entry (the CPU screen
// caches its frequency-counter measurement here). // caches its frequency-counter measurement here).
static int last_rendered_screen = -1; static int last_rendered_screen = -1;
const bool screen_entered = (current_screen != last_rendered_screen); 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);
}
// Leaving the Lightbar screen → persist mode/favorite changes made there,
// batched into a single flash write instead of one per button press.
if (last_rendered_screen == kScreenLightbar
&& current_screen != kScreenLightbar
&& lb_dirty) {
lightbar_save_config();
}
last_rendered_screen = current_screen; last_rendered_screen = current_screen;
switch (current_screen) { switch (current_screen) {
+20 -2
View File
@@ -6,6 +6,12 @@
#include <cstring> #include <cstring>
#include "utils.h" #include "utils.h"
#include "state_mgr.h"
// Set by the OLED lightbar service (src/oled.cpp). While true, the firmware
// owns the lightbar (an OLED mode or the charging pulse) and the host's
// AllowLedColor writes are suppressed below so they can't stomp it.
extern bool g_lightbar_override;
namespace { namespace {
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t); constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
@@ -19,7 +25,7 @@ namespace {
static constexpr uint8_t state_init_data[63] = { static constexpr uint8_t state_init_data[63] = {
0xfd, 0xf7, 0x0, 0x0, 0xfd, 0xf7, 0x0, 0x0,
0x7f, 0x64, // Headphones, Speaker 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, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa,
@@ -41,6 +47,18 @@ void state_set(uint8_t *data, const uint8_t size) {
memcpy(data, state, size); memcpy(data, state, size);
} }
void state_set_led(uint8_t r, uint8_t g, uint8_t b) {
state[offsetof(SetStateData, LedRed) + 0] = r;
state[offsetof(SetStateData, LedRed) + 1] = g;
state[offsetof(SetStateData, LedRed) + 2] = b;
}
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b) {
*r = state[offsetof(SetStateData, LedRed) + 0];
*g = state[offsetof(SetStateData, LedRed) + 1];
*b = state[offsetof(SetStateData, LedRed) + 2];
}
void state_update(const uint8_t *data, const uint8_t size) { void state_update(const uint8_t *data, const uint8_t size) {
if (size < sizeof(SetStateData)) { if (size < sizeof(SetStateData)) {
printf( printf(
@@ -147,7 +165,7 @@ void state_update(const uint8_t *data, const uint8_t size) {
sizeof(uint8_t) sizeof(uint8_t)
); );
copy_if_allowed( copy_if_allowed(
update.AllowLedColor, update.AllowLedColor && !g_lightbar_override,
offsetof(SetStateData, LedRed), offsetof(SetStateData, LedRed),
sizeof(update.LedRed) * 3 sizeof(update.LedRed) * 3
); );
+9
View File
@@ -5,8 +5,17 @@
#ifndef DS5_BRIDGE_STATE_MGR_H #ifndef DS5_BRIDGE_STATE_MGR_H
#define DS5_BRIDGE_STATE_MGR_H #define DS5_BRIDGE_STATE_MGR_H
#include <cstdint>
void state_init(); void state_init();
void state_set(uint8_t *data, const uint8_t size); void state_set(uint8_t *data, const uint8_t size);
void state_update(const uint8_t *data, const uint8_t size); void state_update(const uint8_t *data, const uint8_t size);
// Lightbar RGB lives in the persistent state[] block (SetStateData LedRed/
// Green/Blue) that gets stamped into every outbound BT packet. The OLED
// lightbar service writes it directly so a firmware-chosen color rides every
// host/audio frame instead of only the transient send_lightbar_color() packet.
void state_set_led(uint8_t r, uint8_t g, uint8_t b);
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b);
#endif //DS5_BRIDGE_STATE_MGR_H #endif //DS5_BRIDGE_STATE_MGR_H