Compare commits

..
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.7 a3881a4bda docs(changelog): cut v0.6.9 — button remapping + visual web remap editor + mic PLC
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:02:42 -06:00
MarcelineVPQandClaude Opus 4.7 3d90ad4aa2 docs: highlight button remapping (README + CHANGELOG)
README: add a "Button remapping" feature bullet and a "Remap tab" entry
under the Web Config Tool section. CHANGELOG [Unreleased]: add a Companion
web tool note documenting the visual Remap editor + Zacksly CC BY 3.0
asset attribution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:58:32 -06:00
MarcelineVPQandClaude Opus 4.7 1b1944c006 feat(remap): on-dongle button remapping over 0xF6/0xF7
16-entry source→target table in its own flash sector (-3, magic DS5\x03),
identity default, 0xFF = disabled. Applied to the OUTGOING host report copy
only — raw interrupt_in_data (OLED screens + PS+Mute reboot combo) stays
physical. Edited via the existing 0xF6/0xF7 vendor reports with a hardened
RM+version frame (no HID-descriptor change, so Windows enumeration is
unaffected) plus a revision counter the host polls to confirm a write.
Apply logic + button set ported from SundayMoments/DS5_Bridge (credit).

scripts/remap_test.py exercises the path over /dev/hidraw without the web
tool. Verified on hardware: swap applied, read back, survived reboot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:42:29 -06:00
MarcelineVPQandClaude Opus 4.7 da63e2bb72 feat(audio): mic packet-loss concealment (jitter buffer + Opus PLC)
The BT mic decode path gained a decoded-frame jitter buffer (8 frames) drained
at a steady 10 ms playout cadence. Bursty BT delivery is smoothed; a dropped
mic frame during an active session is concealed with an Opus PLC frame
(opus_decode(decoder, NULL, 0, ...)) instead of leaving a hole the host hears
as a click/dropout. Playout pre-buffers 3 frames and stops after 300 ms of no
real frames so it never emits comfort noise when the mic is idle. New "Mic PLC:"
Diagnostics counter climbs only when concealment fires (a live link-quality
gauge). Verified on hardware: forced BT loss kept captured audio gap-free
(longest zero-run ~0 ms) while the counter climbed; clean link leaves it idle.
Adds ~30 ms mic latency (the pre-buffer). Design ported from
SundayMoments/DS5_Bridge (credit). Unreleased — batching with the next feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:27:11 -06:00
MarcelineVPQandClaude Opus 4.7 9920516f52 ci(release): stop publishing the debug UF2 as a release download
The debug build (ENABLE_SERIAL=ON) compiles out tud_connect/disconnect and
enumerates as a serial console, not a working HID/audio bridge — an end user
who downloads it gets a non-functional dongle. Keep it buildable on demand
(-DENABLE_SERIAL=ON) and in build.yml PR CI for compile coverage, but don't
ship it on the public release page. Releases now upload the standard UF2 only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:44:32 -06:00
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
17 changed files with 913 additions and 145 deletions
+5 -10
View File
@@ -93,16 +93,11 @@ jobs:
mkdir -p artifacts
cp build/standard/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-${{ github.event.release.tag_name }}.uf2"
- name: Build Debug firmware
run: |
cmake -S . -B build/debug -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DPICO_SDK_PATH="$PICO_SDK_PATH" \
-DENABLE_SERIAL=ON \
-DENABLE_VERBOSE=ON \
-DVERSION="$FIRMWARE_VERSION"
cmake --build build/debug --target ds5-bridge
cp build/debug/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-debug-${{ github.event.release.tag_name }}.uf2"
# The debug build (ENABLE_SERIAL=ON) compiles out tud_connect/disconnect and
# comes up as a serial console rather than a working HID/audio bridge — a
# developer diagnostic, not an end-user UF2. It is NOT published as a release
# download (footgun) — build it on demand with -DENABLE_SERIAL=ON, or use the
# PR-CI compile in build.yml. See CHANGELOG/CLAUDE.md.
- name: Compute UF2 checksums + append to release notes
env:
+24 -64
View File
@@ -1,77 +1,37 @@
# Bluetooth microphone investigation — current status
# Bluetooth microphone — SOLVED
**TL;DR:** The DualSense's built-in microphone does **not** work when the controller is paired to this dongle over Bluetooth. It works fine when the controller is connected directly to a host over USB. This is a Sony / DS5-firmware-side limitation we currently can't work around without reverse engineering or BT-sniffer access to PS5 ↔ DS5 traffic. The same limitation is documented in the upstream Linux kernel driver (`drivers/hid/hid-playstation.c` line ~1509: *"Bluetooth audio is currently not supported"*).
**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.
This file is a hand-off / research log for the next person who tries.
## How it works
## What does work
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).
- **Direct USB-C from DS5 → host:** mic enumerates as a UAC1 IN endpoint at 48 kHz / 16-bit / 2 channels on `EP 0x82`, max packet 196 bytes. ALSA recognizes it as `card N: Controller [DualSense Wireless Controller]`. `arecord` captures real audio after raising the `Headset Capture Volume` mixer control (it defaults to 0 dB).
- **Our dongle's USB descriptor** correctly mirrors the DS5's UAC1 layout — same interfaces, same alt settings, same endpoint addresses, same packet sizes. Verified with `lsusb -v` against a real DS5.
- **All the firmware-side decode infrastructure for BT mic is in place** (Opus decoder, `mic_fifo` queue, `tud_audio_write` to the IN endpoint, mono → stereo duplication) — see `src/audio.cpp`. It's currently gated behind `if (false)` in `src/main.cpp`'s `on_bt_data()` because we have nothing to feed it.
## Why the original conclusion was wrong (the lesson)
## What doesn't, and why
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."
The DS5 firmware on the test controller (build date `Jul 4 2025`, queried via feature report 0x20) **does not stream microphone audio over the standard BT-HID L2CAP channels** (PSM 0x11 control + 0x13 interrupt).
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 tried:
## What we use (host-side)
1. **Upstream `awalol/DS5Dongle` `mic` branch as reference.** That branch claims to extract a 71-byte Opus packet at `data + 4` of any BT input report where `(data[2] >> 1) & 1` is set. On our DS5 firmware, **bit 1 of byte 2 is never set** (verified across thousands of frames via the `g_31_b2_or` OR mask). The upstream RE was likely done on a different (older) DS5 firmware revision.
2. **Bit 0 of byte 2** matches roughly all standard input reports — confirmed by reading the supposed "mic prefix" via `0xFD` feature report and seeing live stick X/Y values (not Opus data). Not a mic flag.
3. **Frame length sweep.** Longest BT 0x31 frame we ever see is 79 bytes — a fully-decoded standard DS5 input report (sticks + IMU + touchpad + battery + sensor timestamp + trailing zeros). No audio bytes appended anywhere.
4. **Other report IDs.** Counted ALL incoming BT input reports by report ID. Only `0x01` (rare) and `0x31` (common). No 0x33 / 0x35 / 0x36 / 0x39 / etc. The DS5 isn't sending anything mic-shaped on a different ID.
5. **State configuration matching the kernel.** Set `AllowAudioControl=1`, `AllowMicVolume=1`, `AllowAudioMute=1`, `MicSelect=Internal`, `VolumeMic=0x40`, `MicMute=0`, `AudioPowerSave=0` — exactly what `hid-playstation.c` sets when calling its "Enable microphone" path. DS5 still doesn't stream.
6. **Bidirectional audio session hypothesis.** Maybe the DS5 only streams mic when there's also active speaker audio (`0x36` packets) flowing. Tested: ran `aplay /dev/zero` simultaneously with `arecord`. No change in BT-side counters, no new report IDs, no longer frames. Disproved.
7. **State refresh on host UAC1 alt-setting change.** Considered hooking `tud_audio_set_itf_cb(itf=2, alt=1)` to send the DS5 a fresh "enable mic" state update. Not implemented — given the kernel comment and our state matching the kernel's own "enable" sequence, this wouldn't have helped.
- **`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.
What we **did not** try (real next steps if anyone picks this up):
## Open follow-ups
- **SDP browse the DS5** over BT after pairing. Discover what L2CAP PSMs / services it advertises beyond HID. If there's a Sony proprietary audio PSM we haven't subscribed to, that's where mic traffic might live.
- **Open additional L2CAP channels** (proprietary audio PSM if found, or standard ones like A2DP=0x19 / RFCOMM=0x03) and watch for unsolicited inbound data.
- **Compare DS5 firmware revisions.** Test with an older DS5 (pre-2024 manufacture) and see if it streams mic over BT — that would tell us whether Sony removed the feature or just nobody documented the protocol. (We only have one DS5; can't test.)
- **BT sniffer** between a PS5 console and a DS5 during voice chat. Tells us exactly what L2CAP channels and bytes Sony uses for mic. Equipment-intensive (~$50200 for an Ubertooth or commercial sniffer).
- **DS5 firmware disassembly.** Legally fraught, almost certainly EULA-violating.
## Strongest hypothesis: the channel is encrypted
The shape of all the negative evidence — kernel maintainers giving up, no public RE project succeeding, our matching every documented "enable" bit and getting nothing — strongly suggests the channel is **encrypted with a session key derived during pairing**, not just transported on an undocumented PSM. Sony's incentives line up perfectly:
- **PR / privacy:** a $40 third-party dongle routing a user's PS5 voice chat to a malicious host is a worst-case PR scenario. Encrypting the mic channel is the obvious defense.
- **GDPR-class regulation:** voice biometrics from a console controller over plaintext BT is the kind of thing EU regulators ask hard questions about.
- **Anti-spoofing:** prevents injecting fake mic data into a PS5 session, which is its own threat model.
Mechanism that fits the evidence:
- During pairing, BT Classic SSP produces a link key. The PS5 + DS5 firmware likely run a Sony-proprietary KDF on top of that link key to produce an audio-channel session key.
- Mic audio is transported on a Sony-allocated proprietary L2CAP PSM (not in the standard BT-SIG ranges) and encrypted with that session key (AES-CCM or similar).
- A third-party dongle could connect to the PSM if it knew the number, but without the KDF / session key the payload would be opaque encrypted blobs.
**Implication:** a BT sniffer might tell us the PSM and packet timing/sizes, but not the payload contents. Building a PS5-impersonating dongle that derives valid session keys would require either Sony system-software disassembly or DS5 firmware disassembly — legally fraught, and a much bigger undertaking than what this project is set up for.
This re-frames "we can't get mic over BT" from "we haven't tried hard enough" to "the architecture is intentionally hardened against exactly this." That's not nothing — it's a clear answer to give users who ask, and a clear bar to clear if anyone wants to actually pursue it.
## What we built that's useful regardless
These all stay shipped — they're general-purpose audio-debug infrastructure now:
- **`scripts/mic_diag.sh`** with subcommands `status`, `capture [secs]`, `watch`, `bt-trace`. Drives the entire diagnostic loop from the host without needing OLED-relay-through-the-user; reads vendor feature reports via `/dev/hidraw`.
- **Vendor HID feature report `0xFD`** (32 bytes): BT input-report counter, non-0x31 counter, last seen non-0x31 report ID, OR mask of byte 2 across 0x31 frames, length range, hex prefix of last frame.
- **Vendor HID feature report `0xFE`** (82 bytes): full content of the longest 0x31 frame seen, for byte-level inspection.
- **OLED Diagnostics screen** carries BT31/Mic rate + recent frame prefix + opus dec/wrote bytes — useful for any future audio-path debugging at the bench.
- **`src/audio.cpp`** mic-decode infrastructure (Opus decoder on core0, FIFO, mono → stereo duplication, `tud_audio_write` to IN endpoint). Disabled at the `mic_add_queue` call site, ready to re-enable the moment a real mic trigger is identified.
- **`src/state_mgr.cpp`** initial state corrected — `VolumeMic` was `0xff` (out of valid range per spec; max is `0x40`), `MuteControl` had all `*PowerSave` bits set which would have power-gated the audio DSP. These corrections don't enable BT mic but they're the right defaults regardless.
- **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
- Linux kernel `drivers/hid/hid-playstation.c`. Quoted lines: ~14071420 (mic enable/disable), ~1509 (*"Bluetooth audio is currently not supported"*). [Raw source on GitHub mirror](https://raw.githubusercontent.com/torvalds/linux/master/drivers/hid/hid-playstation.c).
- PSDevWiki [DualSense HID Commands](https://www.psdevwiki.com/ps5/DualSense_HID_Commands) — has factory/manufacturer commands (report IDs 128, 129, 160, 164, 165) for BT patches and audio codec selection, but explicitly notes most "do not work with retail controllers". Not a path forward.
- Upstream `awalol/DS5Dongle` branch `mic` (commits `9c197fc feat: mic work`, `3829163 mic mono channel`). RE'd a working mic path for an older DS5 firmware revision; we ported the data plumbing but the BT-side trigger differs on current firmware.
- dualsensectl: `command_microphone on/off` sets `valid_flag0 |= DS_OUTPUT_VALID_FLAG0_AUDIO_CONTROL_ENABLE` and clears `DS_OUTPUT_POWER_SAVE_CONTROL_MIC_MUTE`. Same as what we already do on connect.
## For users asking about the mic
When users report "the mic doesn't work":
- **Plug the DS5 into the host via USB.** Mic works out of the box. You may need to raise the `Headset Capture Volume` mixer control if it defaults to 0 dB.
- **Over the dongle's Bluetooth pairing, the mic is currently a known limitation** — not something a firmware update on our side can fix without further reverse engineering of the DS5's proprietary BT audio path.
- The diagnostic tools in `scripts/mic_diag.sh` are available if you want to help reverse engineer this; PRs welcome.
- 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.
+61
View File
@@ -10,6 +10,67 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [0.6.9-oled-edition] — 2026-05-24
Headline feature: **on-dongle button remapping** — reassign any of the 16 digital controls, stored on the dongle so it works in every game and on every OS with no host-side software, edited visually in the web config tool's new **Remap** tab (a click-the-controller diagram built on Zacksly's CC BY 3.0 DualSense art). Also ships **packet-loss concealment** for the BT microphone. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.9-oled-edition) (built by `.github/workflows/release.yml`); this is the first release without the debug UF2 as a download.
### Added
- **Button remapping.** Any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) can be remapped to any other — stored on the dongle, applied transparently before the host sees the report, so it works on every OS and every game with no host-side software. The remap table lives in its own dedicated flash sector (`PICO_FLASH_SIZE_BYTES - 3·FLASH_SECTOR_SIZE`, magic `DS5\x03`, below the slots sector) and survives reboot; identity (no remap) is the default. Multiple sources mapping to one target OR together (analog L2/R2 take the max); a source can be set to *disabled* (`0xFF`). The remap acts on the **outgoing host report copy only** — the raw input the OLED screens and the PS+Mute reboot combo read is untouched. Edited over the existing `0xF6`/`0xF7` vendor reports with a hardened `RM`+version frame (**no HID-descriptor change**, so Windows enumeration is unaffected) and a revision counter the host polls to confirm a write landed. New `src/remap.{h,cpp}`; apply logic + button set ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Dev helper `scripts/remap_test.py` exercises the path over `/dev/hidraw` without the web tool.
### Changed
- **BT microphone now has packet-loss concealment (PLC).** The mic decode path gained a small decoded-frame jitter buffer (8 frames) drained at a steady 10 ms playout cadence: bursty BT delivery is smoothed, and a dropped mic frame during an active session is concealed with an Opus PLC frame (`opus_decode(decoder, NULL, 0, …)`) instead of leaving a hole the host hears as a click/dropout. Playout pre-buffers 3 frames and stops after 300 ms of no real frames (so it never emits comfort noise when the mic is idle). A new **`Mic PLC:`** counter on the Diagnostics screen climbs only when concealment fires — effectively a live BT link-quality gauge. Verified: forced BT loss kept the captured audio gap-free (longest zero-run ~0 ms) while the counter climbed. Design ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Adds ~30 ms mic latency (the pre-buffer).
### Companion web tool
- **Visual button-remapping editor (new Remap tab).** `DS5Dongle-OLED-Config-Web` gains a dedicated **Remap** tab built on the new firmware remap protocol: click a button on a live, theme-aware DualSense diagram to reassign it, with the shoulders/triggers (L1/L2/R1/R2) floated to the corners as labeled glyphs + leader lines (they have no target in a front view). Remapped/selected buttons glow; a collapsible full dropdown list is kept as a fallback. Reads the remap block appended to the `0xF7` config response and writes over `0xF6` (func `0x10`), independent of `Config_body`. Controller outline + button glyphs are [Zacksly's "PS5 Button Icons and Controls"](https://zacksly.itch.io/ps5-button-icons-and-controls) (CC BY 3.0, recolored to `currentColor` and cropped), credited in the footer, each asset file, and a bundled license. Strings translated across all 7 locales.
---
## [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`).
+1
View File
@@ -90,6 +90,7 @@ add_executable(ds5-bridge
src/state_mgr.cpp
src/oled.cpp
src/slots.cpp
src/remap.cpp
)
if (ENABLE_BATT_LED)
+36 -1
View File
@@ -18,6 +18,7 @@ The web tool is a one-stop shop — **no installs, no command line, no `picotool
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use.
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID.
- **Remap tab** — visual button remapper: click a button on a live DualSense diagram to reassign it to any other (the shoulders/triggers float to the corners as labeled glyphs). The map is stored on the dongle and applied before the host sees the report, so it works in every game and on every OS. Powered by WebHID.
- **OLED Preview tab** — pixel-perfect emulation of all 11 OLED screens. Use the in-page KEY0/KEY1 buttons (or the controller's △ / R1 / D-pad when a DualSense is paired) to navigate. Adaptive triggers actually fire on the controller when you cycle the Trigger Test preset.
Works in any Chromium-based browser (Chrome, Edge, Brave, Opera). Firefox + Safari don't expose WebHID or WebUSB, so flashing and live config aren't available there — the OLED Preview still renders with mock data.
@@ -43,6 +44,7 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
**OLED Edition additions:**
- Optional Pico-OLED-1.3 status display with **11 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, RSSI, VU meters, settings)
- **Button remapping** — reassign any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) to any other. Stored on the dongle and applied before the host sees the report, so it works in **every game and OS with no host-side software**; identity (no remap) is the default. Edit it visually in the [web config tool](#-web-config-tool)'s **Remap tab**, or headlessly via `scripts/remap_test.py`. Persisted in its own flash sector, survives reboot.
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
- **Lightbar color picker** with 4 user favorite slots + breathing / rainbow / fade effect presets
- **Persistent settings menu** for the 8 firmware config fields (haptics gain, speaker volume, polling rate, etc.) with hold-to-confirm Reset and Wipe-all-slots actions
@@ -140,11 +142,44 @@ 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.
## 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.
- **Packet-loss concealment:** dropped mic frames (weak BT link, distance, interference) are concealed with Opus PLC so voice stays continuous instead of clicking/cutting out, at a small jitter-buffer latency (~30 ms). The Diag screen's `Mic PLC:` counter climbs only when frames are being concealed — effectively a live link-quality gauge.
## 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.
- HD haptics may not fire in every game on Linux + Steam; this is game-side (some titles only send HD-haptic audio under Windows-specific APIs). Tested working in Spider-Man Remastered; not delivered in Ghost of Tsushima — same firmware, same controller.
- **DualSense microphone does not work over the Bluetooth pairing.** This is a Sony / DS5-firmware-side limitation also documented in the upstream Linux kernel driver (`drivers/hid/hid-playstation.c` line ~1509: *"Bluetooth audio is currently not supported"*). The mic works fine when the controller is connected directly to the host via USB-C. See [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md) for the full investigation log + what's already wired firmware-side if a future contributor cracks the BT-side trigger.
- **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
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Host-side dev helper to exercise the firmware button-remap path over the
already-declared 0xF6 (SET) / 0xF7 (GET) vendor feature reports — no web tool
needed. Linux only (reads /dev/hidraw directly), same role as mic_diag.sh.
The remap rides 0xF6/0xF7 with a magic+version frame (see src/cmd.cpp):
SET 0xF6: [func=0x10]['R']['M'][ver][table[16]]
GET 0xF7: <Config_body(35)> ['R']['M'][ver][rev_lo][rev_hi][table[16]]
Usage:
remap_test.py get # show revision + current table
remap_test.py swap CIRCLE CROSS # swap two buttons (and persist)
remap_test.py set SRC TGT # route SRC -> TGT (one-way)
remap_test.py off SRC # disable SRC (0xFF)
remap_test.py reset # identity (no remap)
Run with sudo if /dev/hidraw needs root.
"""
import fcntl, glob, struct, sys
VID, PID = 0x054c, 0x0ce6
CONFIG_LEN = 35 # sizeof(Config_body), see src/config.h
PROTO_VER = 1 # kRemapProtoVer
COUNT = 16 # kRemapCount
# Must match RemapButton order in src/remap.cpp.
NAMES = ["L2", "L1", "CREATE", "DPAD_UP", "DPAD_LEFT", "DPAD_DOWN",
"DPAD_RIGHT", "L3", "R2", "R1", "OPTIONS", "TRIANGLE",
"CIRCLE", "CROSS", "SQUARE", "R3"]
IDX = {n: i for i, n in enumerate(NAMES)}
def hidiocg(size): # HIDIOCGFEATURE(size)
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x07
def hidiocs(size): # HIDIOCSFEATURE(size)
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x06
def read_f7(f):
"""Return (rev, table[16]) or None if the response lacks the remap block."""
size = 64 # 1 report-id byte + up to 63 payload
buf = bytearray(size)
buf[0] = 0xF7
fcntl.ioctl(f, hidiocg(size), buf)
payload = bytes(buf[1:]) # kernel prepends report id at byte 0
blk = payload[CONFIG_LEN:CONFIG_LEN + 5 + COUNT]
if len(blk) < 5 + COUNT or blk[0] != ord('R') or blk[1] != ord('M'):
return None
ver = blk[2]
rev = blk[3] | (blk[4] << 8)
table = list(blk[5:5 + COUNT])
return ver, rev, table
def find_dongle():
for path in sorted(glob.glob('/dev/hidraw*')):
try:
f = open(path, 'rb+', buffering=0)
except (OSError, PermissionError):
continue
try:
if read_f7(f) is not None:
return f, path
except OSError:
pass
f.close()
return None, None
def write_table(f, table):
assert len(table) == COUNT
payload = bytes([0xF6, 0x10, ord('R'), ord('M'), PROTO_VER]) + bytes(table)
buf = bytearray(payload)
fcntl.ioctl(f, hidiocs(len(buf)), buf)
def show(label, ver, rev, table):
print(f"{label}: proto v{ver}, revision {rev}")
active = [(s, t) for s, t in enumerate(table) if t != s]
if not active:
print(" identity (no remap active)")
return
for s, t in active:
tn = "DISABLED" if t == 0xFF else NAMES[t]
print(f" {NAMES[s]:<10} -> {tn}")
def parse_button(arg):
key = arg.upper()
if key not in IDX:
sys.exit(f"unknown button '{arg}'. choices: {', '.join(NAMES)}")
return IDX[key]
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(2)
cmd = sys.argv[1].lower()
f, path = find_dongle()
if f is None:
sys.exit("no DS5 dongle with remap support found "
"(check /dev/hidraw permissions and firmware version)")
print(f"dongle: {path}")
ver, rev, table = read_f7(f)
if cmd == "get":
show("current", ver, rev, table)
return
if cmd == "reset":
table = list(range(COUNT))
elif cmd == "off" and len(sys.argv) == 3:
table[parse_button(sys.argv[2])] = 0xFF
elif cmd == "set" and len(sys.argv) == 4:
table[parse_button(sys.argv[2])] = parse_button(sys.argv[3])
elif cmd == "swap" and len(sys.argv) == 4:
a, b = parse_button(sys.argv[2]), parse_button(sys.argv[3])
table[a], table[b] = b, a
else:
print(__doc__)
sys.exit(2)
show("writing", ver, rev, table)
write_table(f, table)
nver, nrev, ntable = read_f7(f)
show("read-back", nver, nrev, ntable)
if ntable == table and nrev != rev:
print("OK: table applied and revision bumped")
else:
print("WARNING: read-back mismatch or revision did not change")
if __name__ == "__main__":
main()
+127 -17
View File
@@ -13,6 +13,7 @@
#include "utils.h"
#include "pico/multicore.h"
#include "pico/util/queue.h"
#include "pico/time.h"
#include "config.h"
#include "state_mgr.h"
#include "usb.h"
@@ -60,6 +61,24 @@ 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; }
// Mic jitter buffer + packet-loss concealment. Decoded mono frames land here
// (filled as Opus arrives, drained at a steady 10 ms playout cadence) so bursty
// BT delivery is smoothed and a dropped frame is concealed via Opus PLC instead
// of underrunning the host with a click/hole. Design ported from
// SundayMoments/DS5_Bridge (credit there). PLC keeps voice continuous on a
// lossy BT link (e.g. controller moved away, USB 3.0 RF interference).
struct mic_decoded_element { int16_t mono[MIC_FRAMES]; };
static queue_t mic_decode_fifo;
static constexpr int MIC_DECODE_DEPTH = 8; // jitter-buffer capacity (frames)
static constexpr int MIC_PLAYOUT_START = 3; // pre-buffer before playout begins
static constexpr uint64_t MIC_FRAME_US = 10000; // 10 ms per Opus frame @ 48 kHz
static constexpr uint64_t MIC_SESSION_US = 300000; // no real frame this long → stop playout
static bool mic_playout_started = false;
static uint64_t mic_next_playout_us = 0;
static uint64_t mic_last_real_us = 0;
static volatile uint32_t g_mic_plc_frames = 0; // concealed frames generated (Diag)
uint32_t audio_mic_plc_frames() { return g_mic_plc_frames; }
struct audio_raw_element {
float data[512 * 2];
};
@@ -114,35 +133,122 @@ void mic_add_queue(const uint8_t *data) {
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() {
// 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 uint64_t now = time_us_64();
// Decode stage: drain incoming Opus into the jitter buffer as fast as it
// arrives (absorbs bursty BT delivery), up to the buffer's capacity.
static mic_element pkt{};
while (queue_get_level(&mic_decode_fifo) < MIC_DECODE_DEPTH
&& queue_try_remove(&mic_fifo, &pkt)) {
static mic_decoded_element dec{};
const int n = opus_decode(mic_decoder, pkt.data, MIC_OPUS_SIZE,
dec.mono, MIC_FRAMES, 0);
g_mic_last_decoded = n; // observed in OLED Diag
if (n > 0) {
queue_try_add(&mic_decode_fifo, &dec);
mic_last_real_us = now;
}
}
// Playout stage: emit one frame every 10 ms. Pre-buffer a few frames to
// absorb jitter, then play a real frame if buffered, else conceal with an
// Opus PLC frame during an active session (transient loss) so the host
// hears continuity instead of a hole. If real frames have been gone for a
// while (mic off/idle), stop so we don't emit comfort noise forever.
if (!mic_playout_started
&& queue_get_level(&mic_decode_fifo) >= MIC_PLAYOUT_START) {
mic_playout_started = true;
mic_next_playout_us = now;
}
if (mic_playout_started && (int64_t)(now - mic_next_playout_us) >= 0) {
static mic_decoded_element out{};
bool have = queue_try_remove(&mic_decode_fifo, &out);
if (!have) {
if (now - mic_last_real_us < MIC_SESSION_US) {
const int n = opus_decode(mic_decoder, nullptr, 0,
out.mono, MIC_FRAMES, 0); // PLC
if (n > 0) { have = true; g_mic_plc_frames++; }
} else {
mic_playout_started = false; // session ended — re-buffer next time
}
const uint16_t want = (uint16_t)(decoded * 2 * sizeof(int16_t));
const uint16_t wrote = tud_audio_write(stereo, want);
}
if (have) {
static int16_t stereo[MIC_FRAMES * 2];
for (int i = 0; i < MIC_FRAMES; i++) {
stereo[i * 2] = out.mono[i];
stereo[i * 2 + 1] = out.mono[i];
}
const uint16_t want = (uint16_t)(MIC_FRAMES * 2 * sizeof(int16_t));
g_mic_last_wrote = tud_audio_write(stereo, want);
g_mic_last_want = want;
g_mic_last_wrote = wrote;
g_mic_frames++;
mic_next_playout_us += MIC_FRAME_US;
// Drift guard: if we've fallen many frames behind (loop stall),
// resync the cadence instead of bursting to catch up.
if ((int64_t)(now - mic_next_playout_us) > (int64_t)(4 * MIC_FRAME_US)) {
mic_next_playout_us = now + MIC_FRAME_US;
}
}
}
}
// 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];
uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes
@@ -276,7 +382,10 @@ void audio_loop() {
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
pkt[2] = 0x11 | 0 << 6 | 1 << 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;
pkt[5] = buf_len;
pkt[6] = buf_len;
@@ -323,7 +432,8 @@ void audio_init() {
// 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);
queue_init(&mic_fifo, sizeof(mic_element), MIC_DECODE_DEPTH); // deeper: tolerate BT bursts
queue_init(&mic_decode_fifo, sizeof(mic_decoded_element), MIC_DECODE_DEPTH); // decoded-PCM jitter buffer
int dec_error = 0;
mic_decoder = opus_decoder_create(48000, MIC_CHANNELS, &dec_error);
if (dec_error != 0 || mic_decoder == nullptr) {
+1
View File
@@ -26,6 +26,7 @@ int32_t audio_mic_last_decoded(); // last opus_decode return — neg = error, 4
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)
uint32_t audio_mic_plc_frames(); // count of packet-loss-concealment frames generated
// 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
+58 -3
View File
@@ -26,6 +26,15 @@
#define MTU_CONTROL 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::vector;
using std::queue;
@@ -54,6 +63,12 @@ struct send_element {
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.
static int g_current_slot = 0;
@@ -166,6 +181,35 @@ bool bt_disconnect() {
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) {
// gap_read_rssi() completes asynchronously, so this function can only
// 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) {
printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr));
new_pair = true;
connect_attempt_started = get_absolute_time(); // arm connection watchdog
hci_send_cmd(&hci_create_connection, current_device_addr,
hci_usable_acl_packet_types(), 0, 0, 0, 1);
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) {
device_found = false;
new_pair = false;
connect_attempt_started = 0; // disarm; failed before an ACL existed
printf("[HCI] Create connection rejected, restart inquiry\n");
// gap_inquiry_start(30);
gap_inquiry_start(30);
}
break;
}
@@ -350,8 +396,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
} else {
device_found = 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);
// gap_inquiry_start(30);
gap_inquiry_start(30);
}
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) {
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_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 {
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);
gap_inquiry_stop();
hci_send_cmd(&hci_accept_connection_request, addr, 0x01);
connect_attempt_started = get_absolute_time(); // arm watchdog (incoming path)
}
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);
device_found = false;
new_pair = false;
connect_attempt_started = 0; // disarm — every teardown clears here
acl_handle = HCI_CON_HANDLE_INVALID;
bt_rssi = 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");
check_dse = false;
is_dse = true;
connect_attempt_started = 0; // fully up — disarm watchdog
#if !ENABLE_SERIAL
tud_connect();
#endif
@@ -515,6 +569,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
printf("Connected DS5 Controller\n");
check_dse = false;
is_dse = false;
connect_attempt_started = 0; // fully up — disarm watchdog
#if !ENABLE_SERIAL
tud_connect();
#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 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.
bool bt_is_connected();
void bt_get_addr(uint8_t out[6]);
+48 -3
View File
@@ -14,6 +14,7 @@
#include "device/usbd.h"
#include "pico/time.h"
#include "slots.h"
#include "remap.h"
#include "hardware/clocks.h"
#include "hardware/adc.h"
#include "hardware/vreg.h"
@@ -86,11 +87,34 @@ bool is_pico_cmd(uint8_t report_id) {
uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
if (report_id == 0xf7) {
printf("[HID] Receive 0xf7 getting config\n");
if (sizeof(Config_body) > reqlen) {
const size_t cfg_len = sizeof(Config_body);
if (cfg_len > reqlen) {
printf("[Config] Warning: Config_body overflow\n");
}
const auto len = std::min(sizeof(Config_body),static_cast<size_t>(reqlen));
memcpy(buffer,&get_config(),len);
const auto len = std::min(cfg_len, static_cast<size_t>(reqlen));
memcpy(buffer, &get_config(), len);
// OLED Edition: append the button-remap block right after Config_body
// when the host asked for enough room. Old clients request exactly
// sizeof(Config_body) and never see it; new web tools read config +
// remap in one GET (the 0xF6/0xF7 reports are 63 bytes, plenty).
// [+0] 'R'
// [+1] 'M'
// [+2] protocol version (kRemapProtoVer)
// [+3..+4] revision uint16 LE (bumps on each successful set)
// [+5..+20] 16-byte remap table (source idx -> target idx, 0xFF=off)
constexpr size_t kRemapBlock = 5 + kRemapCount;
if (reqlen >= cfg_len + kRemapBlock) {
uint8_t *p = buffer + cfg_len;
p[0] = 'R';
p[1] = 'M';
p[2] = kRemapProtoVer;
const uint16_t rev = remap_revision();
p[3] = (uint8_t)(rev & 0xFF);
p[4] = (uint8_t)((rev >> 8) & 0xFF);
remap_get(p + 5);
return cfg_len + kRemapBlock;
}
return len;
}
if (report_id == 0xf8) {
@@ -268,4 +292,25 @@ void pico_cmd_set(uint8_t report_id, uint8_t const *buffer, uint16_t bufsize) {
sleep_ms(150);
tud_connect();
}
// 0x10 set button-remap table (OLED Edition). Hardened framing so a stray
// write to 0xF6 can't corrupt the map: magic 'R''M' + protocol version gate
// before remap_set() (which itself validates each entry <16 or 0xFF=off).
// [0] 0x10 func-id
// [1] 'R'
// [2] 'M'
// [3] protocol version (must == kRemapProtoVer)
// [4..19] 16-byte remap table
if (buffer[0] == 0x10) {
constexpr uint16_t kNeed = 4 + kRemapCount;
if (bufsize < kNeed) {
printf("[CMD] 0x10 remap-set too short (%u<%u)\n", bufsize, kNeed);
return;
}
if (buffer[1] != 'R' || buffer[2] != 'M' || buffer[3] != kRemapProtoVer) {
printf("[CMD] 0x10 remap-set bad magic/version\n");
return;
}
if (remap_set(buffer + 4)) printf("[CMD] remap set ok (rev=%u)\n", remap_revision());
else printf("[CMD] remap set rejected (invalid table)\n");
}
}
+12
View File
@@ -101,6 +101,18 @@ void config_valid() {
}
// 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) {
body->config_version = CONFIG_VERSION;
printf("[Config] Warning: Config may breaking change\n");
+11
View File
@@ -33,6 +33,17 @@ struct __attribute__((packed)) Config_body {
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 {
+46 -9
View File
@@ -22,6 +22,7 @@
#include "battery_led.h"
#endif
#include "oled.h"
#include "remap.h"
// Pico SDK speciifically for waiting on conditions
#include "pico/critical_section.h"
@@ -66,13 +67,21 @@ void bt_31_mic_prefix(uint8_t out[6]) {
// 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;
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; }
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] = {
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
@@ -110,7 +119,12 @@ void interrupt_loop() {
// TODO: Refactor for better code reuse
if (get_config().polling_rate_mode != 2) {
if (!tud_hid_report(0x01, interrupt_in_data, 63)) {
// Remap acts on the OUTGOING copy only — interrupt_in_data stays raw so
// the reboot combo above and every OLED screen keep seeing physical input.
uint8_t out[63];
memcpy(out, interrupt_in_data, 63);
remap_apply(out);
if (!tud_hid_report(0x01, out, 63)) {
printf("[USBHID] tud_hid_report error\n");
}
return;
@@ -129,6 +143,9 @@ void interrupt_loop() {
}
critical_section_exit(&report_cs);
// Remap the snapshot, not interrupt_in_data (outgoing copy only — see above).
if (should_send) remap_apply(safe_report);
// Only send to TinyUSB if we actually grabbed fresh data
if (should_send) {
if (!tud_hid_report(0x01, safe_report, 63)) {
@@ -181,11 +198,24 @@ void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
}
}
// Mic-add tap DISABLED — was decoding standard input (button/stick
// bytes) as Opus and producing INT16_MIN garbage on the USB IN
// endpoint. Re-enable once we identify the actual mic transport.
// (Standard input handling below resumes — Status screen + HID
// reports to host need this.)
// 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 ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
@@ -274,6 +304,11 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
}
state_update(buffer + 1, bufsize - 1);
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;
}
uint8_t outputData[78]{};
@@ -351,6 +386,7 @@ int main() {
critical_section_init(&report_cs);
config_load();
remap_load();
bt_init();
bt_register_data_callback(on_bt_data);
@@ -368,6 +404,7 @@ int main() {
watchdog_update();
#endif
cyw43_arch_poll();
bt_connection_watchdog_tick();
tud_task();
audio_loop();
interrupt_loop();
+99 -38
View File
@@ -23,6 +23,7 @@ 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();
@@ -83,16 +84,18 @@ uint8_t current_contrast = 0xFF;
// Auto-dim / auto-off after idle. Tracks last button/input activity.
// Tier 1: Active → full brightness (bright_idx).
// Tier 2: idle > kAutoDimUs → contrast drops to kDimContrast (deep dim).
// Tier 3: idle > kAutoOffUs → SH1107 panel turned fully off (cmd 0xAE)
// 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.
uint32_t last_activity_us = 0;
uint64_t last_activity_us = 0;
uint32_t last_input_hash = 0;
constexpr uint32_t kAutoDimUs = 2UL * 60UL * 1000000UL; // 2 min — generous for pairing
constexpr uint32_t kAutoOffUs = 15UL * 60UL * 1000000UL; // 15 min
constexpr uint8_t kDimContrast = 0x01;
enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF };
OledPowerState oled_power_state = OLED_ACTIVE;
@@ -123,12 +126,15 @@ constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9;
// 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 kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10;
constexpr int kSettingsResetIdx = 11;
constexpr int kSettingsWipeSlotsIdx = 12;
constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsBtMicIdx = 13;
constexpr int kSettingsResetIdx = 14;
constexpr int kSettingsWipeSlotsIdx = 15;
Config_body settings_local{};
int settings_sel = 0;
bool settings_dirty = false;
@@ -479,13 +485,13 @@ void handle_buttons() {
if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) {
key0_t_us = now;
key0_armed = true;
last_activity_us = now;
last_activity_us = time_us_64();
}
if (k0 && !key0_prev && key0_armed) {
key0_armed = false;
current_screen = (current_screen + 1) % kNumScreens;
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
@@ -497,12 +503,12 @@ void handle_buttons() {
key1_t_us = now;
key1_press_us = now;
key1_was_pressed = true;
last_activity_us = now;
last_activity_us = time_us_64();
}
if (k1 && !key1_prev && key1_was_pressed) {
key1_was_pressed = false;
const uint32_t held = now - key1_press_us;
last_activity_us = now;
last_activity_us = time_us_64();
if (held > kLongPressUs) {
bright_idx = (bright_idx + 1) % kNumBrightLevels;
} else {
@@ -530,12 +536,20 @@ void handle_buttons() {
// 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; // at least one full step timed → minutes is meaningful
int minutes; // estimated minutes to 100%
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) {
@@ -600,20 +614,30 @@ void sample_charge_eta() {
}
g_charge_eta.charging = true;
if (ring_count > 0 && cur_step < 10) {
float bulk = 0.0f;
for (int i = 0; i < ring_count; i++) bulk += ring[i];
bulk /= (float)ring_count;
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 = (cur_step >= 10);
g_charge_eta.valid = true;
g_charge_eta.provisional = false;
g_charge_eta.minutes = 0;
}
}
@@ -650,13 +674,15 @@ __attribute__((noinline)) void render_screen() {
draw_text(kContentX, 18, bbuf);
draw_battery_icon(36, 18, pct);
// Charge ETA, right of the battery icon (icon ends at x≈90). Shown
// only while charging: "~43m" once a step has been timed, "~--m" while
// still calibrating the first step. See sample_charge_eta().
// 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", g_charge_eta.minutes);
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);
@@ -801,7 +827,7 @@ void sample_diag_rates() {
// Row list ordered by relevance: always-useful at top, parked-mic-investigation
// data at bottom. To add a row, bump kNumDiagRows and add a case.
constexpr int kNumDiagRows = 10;
constexpr int kNumDiagRows = 12;
__attribute__((noinline))
void format_diag_row(int idx, char* line, size_t n) {
switch (idx) {
@@ -825,23 +851,31 @@ void format_diag_row(int idx, char* line, size_t n) {
(unsigned long)host_out02_to_bt());
break;
case 4:
snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate);
// 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, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate);
snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate);
break;
case 6:
snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate);
snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate);
break;
case 7:
snprintf(line, n, "Mic in: %lu/s", (unsigned long)g_diag_rates.mic_rate);
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 9: {
case 10:
snprintf(line, n, "Mic PLC: %lu", (unsigned long)audio_mic_plc_frames());
break;
case 11: {
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]);
@@ -1374,6 +1408,19 @@ void settings_adjust(int delta) {
c.auto_haptics_lowpass = (uint8_t)v;
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
}
}
@@ -1467,8 +1514,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]);
break;
}
case 11: snprintf(line, n, "%s Reset to defaults", cur); break;
case 12: snprintf(line, n, "%s Wipe all slots", cur); break;
case 11:
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;
}
}
@@ -1700,22 +1756,27 @@ void oled_loop() {
}
if (hash != last_input_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 = now;
if (bt_connected_now && !prev_bt_connected) last_activity_us = time_us_64();
prev_bt_connected = bt_connected_now;
// Power-state ladder: Active → Dim (breathing dot) → Off based on idle time.
// Thresholds are user-configurable (minutes; 0 = that tier disabled) — #5.
// 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 uint32_t idle = now - last_activity_us;
if (idle > kAutoOffUs && !g_charge_eta.charging) {
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;
@@ -1723,10 +1784,10 @@ void oled_loop() {
return; // panel is off, nothing to draw
}
if (oled_power_state == OLED_OFF) cmd(0xAF); // wake panel before drawing
if (idle > kAutoDimUs) {
if (dim_enabled && idle > dim_us) {
sh1107_set_contrast(kDimContrast);
oled_power_state = OLED_DIM;
render_dim_pulse(idle - kAutoDimUs);
render_dim_pulse((uint32_t)(idle - dim_us));
return; // skip the regular per-screen render path
}
sh1107_set_contrast(kBrightLevels[bright_idx]);
+197
View File
@@ -0,0 +1,197 @@
// Button remapping. See remap.h. Flash-sector pattern mirrors slots.cpp;
// the apply logic + button set are ported from SundayMoments/DS5_Bridge.
#include "remap.h"
#include <cstring>
#include <cstdio>
#include <algorithm>
#include "hardware/flash.h"
#include "hardware/sync.h"
namespace {
// Source/target button indices. Order is arbitrary but MUST match the web
// editor's expectation (DS5Dongle-OLED-Config-Web src/protocol/remap.ts).
enum RemapButton : uint8_t {
RemapL2, RemapL1, RemapCreate, RemapDpadUp, RemapDpadLeft, RemapDpadDown,
RemapDpadRight, RemapL3, RemapR2, RemapR1, RemapOptions, RemapTriangle,
RemapCircle, RemapCross, RemapSquare, RemapR3, RemapButtonCount,
};
static_assert(RemapButtonCount == kRemapCount, "kRemapCount must match RemapButton");
// interrupt_in_data[8]: shoulders / sticks / system bits.
constexpr uint8_t kL1Bit = 0x01, kR1Bit = 0x02, kL2Bit = 0x04, kR2Bit = 0x08,
kCreateBit = 0x10, kOptionsBit = 0x20, kL3Bit = 0x40, kR3Bit = 0x80;
// interrupt_in_data[7]: D-pad hat (low nibble) + face buttons (high nibble).
constexpr uint8_t kSquareBit = 0x10, kCrossBit = 0x20, kCircleBit = 0x40,
kTriangleBit = 0x80, kDpadMask = 0x0F;
// D-pad hat values.
constexpr uint8_t kUp = 0, kUpRight = 1, kRight = 2, kDownRight = 3, kDown = 4,
kDownLeft = 5, kLeft = 6, kUpLeft = 7, kNeutral = 8;
constexpr uint32_t REMAP_MAGIC = 0x44533503u; // "DS5\x03"
constexpr uint32_t REMAP_FLASH_OFFSET = PICO_FLASH_SIZE_BYTES - 3u * FLASH_SECTOR_SIZE;
struct __attribute__((packed)) RemapData {
uint32_t magic;
uint8_t table[kRemapCount];
};
static_assert(sizeof(RemapData) <= FLASH_PAGE_SIZE);
static_assert(REMAP_FLASH_OFFSET % FLASH_SECTOR_SIZE == 0);
RemapData g_remap{};
bool g_active = false; // false = identity → remap_apply() fast-returns
uint16_t g_revision = 0;
const RemapData *flash_remap() {
return reinterpret_cast<const RemapData *>(XIP_BASE + REMAP_FLASH_OFFSET);
}
void set_identity() {
for (int i = 0; i < kRemapCount; i++) g_remap.table[i] = (uint8_t) i;
}
void recompute_active() {
g_active = false;
for (int i = 0; i < kRemapCount; i++) {
if (g_remap.table[i] != i) { g_active = true; return; }
}
}
bool valid_table(const uint8_t *t) {
for (int i = 0; i < kRemapCount; i++) {
if (t[i] >= kRemapCount && t[i] != 0xFF) return false; // <16 or disabled
}
return true;
}
bool save_to_flash() {
alignas(4) uint8_t page[FLASH_PAGE_SIZE];
memset(page, 0xff, sizeof(page));
memcpy(page, &g_remap, sizeof(g_remap));
const uint32_t interrupts = save_and_disable_interrupts();
flash_range_erase(REMAP_FLASH_OFFSET, FLASH_SECTOR_SIZE);
flash_range_program(REMAP_FLASH_OFFSET, page, sizeof(page));
restore_interrupts(interrupts);
RemapData verify{};
memcpy(&verify, flash_remap(), sizeof(verify));
if (memcmp(&verify, &g_remap, sizeof(g_remap)) == 0) {
printf("[Remap] flash write verified\n");
return true;
}
printf("[Remap] flash write VERIFY FAILED\n");
return false;
}
bool dpad_has(uint8_t dir, RemapButton b) {
switch (b) {
case RemapDpadUp: return dir == kUp || dir == kUpRight || dir == kUpLeft;
case RemapDpadRight: return dir == kRight || dir == kUpRight || dir == kDownRight;
case RemapDpadDown: return dir == kDown || dir == kDownRight || dir == kDownLeft;
case RemapDpadLeft: return dir == kLeft || dir == kUpLeft || dir == kDownLeft;
default: return false;
}
}
uint8_t dpad_from(bool up, bool right, bool down, bool left) {
if (up && right && !down && !left) return kUpRight;
if (right && down && !up && !left) return kDownRight;
if (down && left && !up && !right) return kDownLeft;
if (left && up && !right && !down) return kUpLeft;
if (up && !down) return kUp;
if (right && !left) return kRight;
if (down && !up) return kDown;
if (left && !right) return kLeft;
return kNeutral;
}
} // namespace
void remap_load() {
memcpy(&g_remap, flash_remap(), sizeof(g_remap));
if (g_remap.magic != REMAP_MAGIC || !valid_table(g_remap.table)) {
printf("[Remap] flash sector empty/invalid, defaulting to identity\n");
g_remap.magic = REMAP_MAGIC;
set_identity();
save_to_flash();
}
recompute_active();
printf("[Remap] loaded (active=%d)\n", g_active);
}
void remap_get(uint8_t out[kRemapCount]) { memcpy(out, g_remap.table, kRemapCount); }
uint16_t remap_revision() { return g_revision; }
bool remap_set(const uint8_t *table) {
if (!valid_table(table)) return false;
memcpy(g_remap.table, table, kRemapCount);
g_remap.magic = REMAP_MAGIC;
recompute_active();
g_revision++;
return save_to_flash();
}
void remap_apply(uint8_t *report) {
if (!g_active) return; // identity → no-op (hot-path fast return)
bool src[kRemapCount]{};
uint8_t src_analog[kRemapCount]{};
const uint8_t dir = report[7] & kDpadMask;
src[RemapL2] = (report[8] & kL2Bit) != 0;
src[RemapL1] = (report[8] & kL1Bit) != 0;
src[RemapCreate] = (report[8] & kCreateBit) != 0;
src[RemapDpadUp] = dpad_has(dir, RemapDpadUp);
src[RemapDpadLeft] = dpad_has(dir, RemapDpadLeft);
src[RemapDpadDown] = dpad_has(dir, RemapDpadDown);
src[RemapDpadRight] = dpad_has(dir, RemapDpadRight);
src[RemapL3] = (report[8] & kL3Bit) != 0;
src[RemapR2] = (report[8] & kR2Bit) != 0;
src[RemapR1] = (report[8] & kR1Bit) != 0;
src[RemapOptions] = (report[8] & kOptionsBit) != 0;
src[RemapTriangle] = (report[7] & kTriangleBit) != 0;
src[RemapCircle] = (report[7] & kCircleBit) != 0;
src[RemapCross] = (report[7] & kCrossBit) != 0;
src[RemapSquare] = (report[7] & kSquareBit) != 0;
src[RemapR3] = (report[8] & kR3Bit) != 0;
for (int i = 0; i < kRemapCount; i++) src_analog[i] = src[i] ? 0xFF : 0;
src_analog[RemapL2] = report[4]; // L2 analog
src_analog[RemapR2] = report[5]; // R2 analog
bool tgt[kRemapCount]{};
uint8_t tgt_analog[kRemapCount]{};
for (int s = 0; s < kRemapCount; s++) {
const uint8_t t = g_remap.table[s];
if (t >= kRemapCount) continue; // 0xFF = disabled (source produces nothing)
if (src[s]) tgt[t] = true;
tgt_analog[t] = std::max(tgt_analog[t], src_analog[s]); // OR digital / max analog
}
report[4] = tgt_analog[RemapL2];
report[5] = tgt_analog[RemapR2];
// Byte 7 is entirely D-pad + face (all 8 bits) — rebuild it.
report[7] = dpad_from(tgt[RemapDpadUp], tgt[RemapDpadRight],
tgt[RemapDpadDown], tgt[RemapDpadLeft]);
if (tgt[RemapSquare]) report[7] |= kSquareBit;
if (tgt[RemapCross]) report[7] |= kCrossBit;
if (tgt[RemapCircle]) report[7] |= kCircleBit;
if (tgt[RemapTriangle]) report[7] |= kTriangleBit;
// Byte 8 is entirely shoulders/sticks/Create/Options (all 8 bits) — rebuild it.
report[8] = 0;
if (tgt[RemapL1]) report[8] |= kL1Bit;
if (tgt[RemapR1]) report[8] |= kR1Bit;
if (tgt[RemapL2]) report[8] |= kL2Bit;
if (tgt[RemapR2]) report[8] |= kR2Bit;
if (tgt[RemapCreate]) report[8] |= kCreateBit;
if (tgt[RemapOptions]) report[8] |= kOptionsBit;
if (tgt[RemapL3]) report[8] |= kL3Bit;
if (tgt[RemapR3]) report[8] |= kR3Bit;
}
+43
View File
@@ -0,0 +1,43 @@
//
// Button remapping — a 16-entry table persisted in its own flash sector,
// applied to the OUTGOING host HID report only (never the raw interrupt_in_data
// the OLED / reboot-combo logic reads). Edited from the web config tool over the
// already-declared 0xF6/0xF7 vendor reports. Apply logic + button set ported
// from SundayMoments/DS5_Bridge (credit).
//
#ifndef DS5_BRIDGE_REMAP_H
#define DS5_BRIDGE_REMAP_H
#include <cstdint>
// Number of remappable buttons (see RemapButton in remap.cpp). The table maps
// source index -> target index; 0xFF means "disabled" (source does nothing).
constexpr int kRemapCount = 16;
// Wire-protocol version for the remap get/set framing carried over the existing
// 0xF6/0xF7 vendor reports (see cmd.cpp). Bump only on incompatible layout
// changes so the web tool can refuse a mismatched firmware.
constexpr uint8_t kRemapProtoVer = 1;
// Load the table from its dedicated flash sector. Call once at boot, after
// config_load(). A fresh/invalid sector defaults to identity (no remap).
void remap_load();
// Remap a 63-byte DS5 input-report COPY in place (buttons live in report[4,5,7,8]).
// No-op fast path when the table is identity. Must only ever touch the outgoing
// host report, not the raw interrupt_in_data.
void remap_apply(uint8_t *report);
// Validate + store + persist a new 16-entry table (each entry < 16, or 0xFF =
// disabled). Bumps the revision on success. Returns false if the table is invalid.
bool remap_set(const uint8_t *table);
// Copy the current 16-entry table out.
void remap_get(uint8_t out[kRemapCount]);
// Monotonic counter bumped on each successful remap_set — the web polls it to
// confirm a write landed. Runtime only (not persisted).
uint16_t remap_revision();
#endif // DS5_BRIDGE_REMAP_H