Compare commits
@@ -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:
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
build
|
||||
build-pr
|
||||
.vscode
|
||||
.idea
|
||||
example
|
||||
|
||||
# Local/per-user scratch
|
||||
.claude/
|
||||
.directory
|
||||
*.code-workspace
|
||||
pr-review-*.md
|
||||
|
||||
@@ -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 1–7 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.
|
||||
@@ -10,6 +10,154 @@ 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`).
|
||||
|
||||
### 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, 0–10; 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, ~15–20 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 32–43) with the trigger counters; `bt-trace`'s Python decoder now reads them and prints a one-line verdict — "host driver isn't setting Allow*TriggerFFB" / "trigger Allow bits set but speaker path stole the BT pipe" / "full chain reached the controller". Lets the user diagnose issue #3 without a UART cable or OLED-relay-per-flash.
|
||||
- **`README.md` "Diagnostics & debug tooling" section** documents `scripts/mic_diag.sh` and its subcommands. The script existed but was only mentioned inside `BLUETOOTH_AUDIO_NOTES.md` — invisible to anyone who hadn't already read the parked-mic notes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`flush_fb()` split.** Internal refactor: `flush_fb_raw()` writes just the framebuffer; `flush_fb()` is now `draw_button_chrome() + flush_fb_raw()`. Lets the dim-tier renderer push the breathing dot without the K0/K1 chrome arrows (no navigation target while the panel is asleep).
|
||||
- **Diagnostics row order re-prioritized.** The first 5 rows (always visible without scrolling) cover the most common triage path: Uptime / BT state / `host02` / `trig`+`tx` / `BT31 in/s`. Audio + parked-mic-investigation counters live below the fold.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.3-oled-edition] — 2026-05-18
|
||||
|
||||
Small follow-up to v0.6.2. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.3-oled-edition) (built by `.github/workflows/release.yml`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OLED Status header was stuck on `"DS5 Bridge v0.6.0"`.** The string was hardcoded in `src/oled.cpp` and never got bumped per release, so v0.6.1 and v0.6.2 both shipped with stale text on the Status screen. Now driven by a compile-time `FIRMWARE_VERSION` macro set from `CMakeLists.txt`'s `${VERSION}` (which `release.yml` already passes as `-DVERSION="$FIRMWARE_VERSION"`). Single source of truth: the release tag. Local builds without `-DVERSION` show `"dev"` so an untagged build is obvious at a glance.
|
||||
- **Web preview's Status header had the same bug.** `src/oled/screens.ts` hardcoded `"v0.5.4"`. Now reads `firmware-latest.json` (already CI-bundled from the GitHub API) at runtime in `OledEmulator.tsx` and writes the short tag (suffix `-oled-edition` stripped) into `state.firmwareVersionLabel`, which `renderStatus()` consumes.
|
||||
|
||||
### Documentation
|
||||
|
||||
- New `CLAUDE.md` "Versioning — single source of truth" section documents the release ritual (CHANGELOG bump → tag → push → `gh release create`) and the single-source-of-truth flow from tag → CMake → C++ macro → web `firmware-latest.json`. Includes a note about the still-pending `WEB_REPO_DISPATCH_PAT` secret on the firmware repo.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.2-oled-edition] — 2026-05-18
|
||||
|
||||
OLED button-model + visual chrome refactor on top of v0.6.1. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.2-oled-edition) (built by `.github/workflows/release.yml`).
|
||||
|
||||
### Changed (button model)
|
||||
|
||||
- **KEY0 / KEY1 are now strictly navigation on every screen.** KEY0 short-press = next screen, KEY1 short-press = previous screen. KEY1 long-press still cycles OLED brightness (unchanged). The old contextual K1=cycle behavior on Trigger Test (cycle trigger preset) and Lightbar (cycle lightbar mode) moved to **DualSense controller buttons** — Triangle on Trigger Test, R1 on Lightbar. Source of "the Mode label didn't change when I clicked K1" confusion eliminated.
|
||||
- **KEY0 double-click reboot → KEY0 + KEY1 simultaneous hold (≥ 1 s).** Rapid forward-navigation kept tripping the double-click timer by accident, soft-rebooting the dongle mid-session. The new two-button chord can't be fat-fingered. `kDoubleClickUs` + `key0_pending_single` state removed; new `chord_held_since_us` + `kChordHoldUs = 1 s`. DS5 PS+Mute hold-2 s remains the headless backup.
|
||||
- **Per-screen contextual actions on the controller** mirror the existing Slots / Settings conventions (where Triangle has always meant "commit / switch / save"):
|
||||
- **Trigger Test** — △ rising edge cycles `trigger_preset` and re-applies via `send_trigger_effect()`.
|
||||
- **Lightbar** — R1 rising edge cycles `lb_mode`. (Triangle stays as "save current RGB to favorite slot 0" — the existing favorite-save UX.)
|
||||
|
||||
### Changed (visual chrome)
|
||||
|
||||
- **Arrow chrome on the left edge of every screen.** `flush_fb()` now paints `>` at `(0, 8)` and `<` at `(0, 49)` so the on-screen labels physically pair with the KEY0 (top) and KEY1 (bottom) buttons. The horizontal `"K0=next K1=back"` footer at y=56 is removed from all 11 screens. Trigger Test footer = `"Tri=cycle"`; Lightbar footer = `"R1=mode"`; Slots and Settings keep their existing contextual hints (`"Tri=switch Sq hold=wipe"`, `"DP nav/adj Tri=save"`). New `kContentX = 6` shifts every screen's content right by 6 px to clear the chrome strip; rectangles, sticks, and the L1/L2 column on Status all repositioned to avoid the chrome `<` glyph painting inside the live left-stick area.
|
||||
|
||||
### Added (web preview parity)
|
||||
|
||||
- **`src/protocol/ds5BridgeHid.ts` `sendTriggerPreset(preset)`** — builds the DS5 SetStateData payload byte-for-byte from `src/oled.cpp send_trigger_effect()` and pushes via `device.sendReport(0x02, ...)`. The dongle relays it over BT to the paired controller, so cycling Trigger Test in the web preview actually drives the real adaptive triggers.
|
||||
- **Web preview mirrors the firmware refactor.** `key1Action()` collapsed to back-nav-only. New rising-edge handlers in `OledEmulator.tsx` detect Triangle / R1 / D-pad from the live controller's input report and dispatch to the appropriate per-screen action. `drawButtonChrome(fb)` paints the `>` / `<` arrows after every render. `flush()` accepts an optional tint color: Slots / Diagnostics / CPU/Clock render in **orange** (`#f59e0b`) when a controller is connected — Chrome WebHID can't expose those reports on a stock DualSense descriptor, so the orange tint + an explanatory paragraph below the canvas flag the values as mock. KEY0/KEY1 buttons in the UI moved to sit visually next to the rendered Pico-OLED-1.3, mirroring the physical board.
|
||||
- **Settings cursor on the web** — new `settingsSel` state, `>` cursor mark on the selected row; D-pad up/down on the connected controller moves the cursor (web-preview-only — actual edits + save happen via the dedicated Config tab on the website).
|
||||
- **Mock-data temperature tweak** — web Preview's CPU/Clock screen no longer drifts 41–47 °C; jitter is now ±0.4 °C around 33.6 °C (realistic Pico 2 W idle).
|
||||
|
||||
### Documentation
|
||||
|
||||
- **README "Web Config Tool" section** added near the top, linking https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/#config and explaining the three tabs (Flash / Config / OLED Preview). Includes a BOOTSEL mode primer for first-time flashers.
|
||||
- **OLED Display Add-on section rewritten.** Screen count 10 → 11 (CPU/Clock added). Cycle order updated. New "Button reference" table covers the strict K0/K1 nav, K1 long-press brightness, and K0+K1 chord reboot. ASCII mockups dropped in favor of consistent web-preview screenshots under `assets/oled/`.
|
||||
- **Performance / Overclocking section reworded** to lead with "you don't need to do anything — the overclock is baked into the firmware". The "raise voltage / lower clock if it fails to boot" line is scoped to users compiling from source.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.1-oled-edition] — 2026-05-18
|
||||
|
||||
Tagged release of the v0.6.0-oled-edition follow-up. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.1-oled-edition) (built by `.github/workflows/release.yml`).
|
||||
|
||||
@@ -66,6 +66,8 @@ The development cadence is **one feature per UF2 + checkpoint with the user befo
|
||||
|
||||
**OLED add-on is optional and self-contained:** All 10 screens, the SH1107 SPI driver, the 5×7 font, the icon table, and the input handling live in `src/oled.cpp` (~30 KB). The only outward dependencies are read-only accessors (`bt_is_connected`, `bt_get_addr`, `audio_peak_*`, `bt_get_signal_strength`, the new slot accessors) and the global `interrupt_in_data[63]` (read-only for input visualization). When no OLED is wired, the SPI writes go nowhere — no init check needed.
|
||||
|
||||
**Idle power ladder (`oled_loop` tail):** Three-stage state machine (`OLED_ACTIVE` → `OLED_DIM` → `OLED_OFF`) driven by `last_activity_us`. `kAutoDimUs = 2 min` enters Dim — the regular per-screen render is replaced with `render_dim_pulse()`, which clears the framebuffer and walks a 2×2 dot through 8 positions every 30 s, blinking 1 s on / 1 s off. `kAutoOffUs = 15 min` enters Off — `cmd(0xAE)` puts the SH1107 to sleep and `oled_loop` returns early before rendering. Wakes on KEY0/KEY1 (via `handle_buttons` bumping `last_activity_us`), `bt_is_connected()` rising edge, or any change in the `interrupt_in_data[0..9]` hash. The dim tier uses `flush_fb_raw()` (the chrome-less variant) since there's no nav target while asleep. **Why this shape:** the SH1107 contrast register has a heavily non-linear perceptual curve on the Waveshare panel — even `0x02` looks ~90 % as bright as `0xFF`. The only reliable "dim" available is rendering fewer pixels.
|
||||
|
||||
**Multi-slot pairing (Phase G):** Storage is two-tier:
|
||||
|
||||
- **Link keys** stay in BTstack's TLV NVM (4 slots, unchanged from upstream).
|
||||
@@ -95,6 +97,33 @@ These are non-obvious from the code; they cost time when forgotten.
|
||||
- **Inactivity-disconnect uses `packet[3..12]`** in the L2CAP interrupt data path (`src/bt.cpp:l2cap_packet_handler`). It's looking at sticks and DPad/buttons to decide "idle." Don't touch those bytes' layout without updating the heuristic.
|
||||
- **The 0x36 BT audio packet layout is load-bearing — speaker + HD haptic actuators silently die without the SetStateData sub-report.** Upstream commit `3a31bd7` (May 2026, "refactor: add SetStateData and audio send priority") moved the `0x10` SetStateData block out of every audio frame and into a one-time L2CAP-open setup. The DualSense hardware requires that sub-report (specifically the `0x7f 0x7f` Headphones + Speaker volume bytes) at `pkt[11..75]` of every `0x36` frame, or the actuators stop producing output even though USB and BT byte counts look fine. Our fork keeps `state_data[63]` in `src/audio.cpp` and re-asserts it on every frame; the pre-3a31bd7 packet layout is: state_data at `pkt[11..75]`, haptic at `pkt[76..141]`, speaker format at `pkt[142]`, opus payload at `pkt[144..343]`. If you rebase onto an upstream that has the refactor and don't preserve this restoration, speaker + HD haptics silently break. The `scripts/test_speaker.sh` helper + the `USB aud / BT 0x32` counters on the OLED Diagnostics screen are the regression tripwire — if bytes are flowing but you hear nothing, look at packet contents not flow. Upstream PR #93 is tracking a proper unified fix; ours is the pre-refactor revert applied just to `audio.cpp`. Same fix was shipped independently by `loteran/DS5Dongle` (commit `c7a8d3c`).
|
||||
|
||||
## Versioning — single source of truth
|
||||
|
||||
The release tag is the **only** place the version is written. Everything else flows from it:
|
||||
|
||||
- **Release tag** (e.g. `v0.6.2-oled-edition`) → created with `git tag` then `gh release create`.
|
||||
- **`.github/workflows/release.yml`** picks the tag up as `$FIRMWARE_VERSION` and passes it to CMake via `-DVERSION="$FIRMWARE_VERSION"`.
|
||||
- **`CMakeLists.txt`** exposes it to C++ as a compile-time macro:
|
||||
```cmake
|
||||
target_compile_definitions(ds5-bridge PRIVATE FIRMWARE_VERSION="${VERSION}")
|
||||
```
|
||||
Local builds without `-DVERSION=...` get the default `"dev"` — that's a deliberate visual signal so an untagged build is obvious on the OLED Status header.
|
||||
- **`src/oled.cpp` `render_screen()`** renders `"DS5 Bridge " FIRMWARE_VERSION` for the Status screen header. No string literal for the version exists anywhere else in the firmware source.
|
||||
- **Web preview** (`DS5Dongle-OLED-Config-Web`) reads the same tag at runtime from `public/firmware-latest.json`, which CI bundles in `.github/workflows/deploy.yml`'s "Bundle latest firmware UF2 from GitHub releases" step (it pulls from `MarcelineVPQ/DS5Dongle-OLED-Edition/releases/latest` via the GitHub API). `OledEmulator.tsx` fetches that JSON on mount and writes the short form (suffix `-oled-edition` stripped) into `state.firmwareVersionLabel`, which `screens.ts:renderStatus` consumes.
|
||||
|
||||
**The release ritual** is therefore:
|
||||
|
||||
1. Update `CHANGELOG.md` — move `[Unreleased]` content into a new `[X.Y.Z-oled-edition]` section dated today.
|
||||
2. Commit the CHANGELOG bump.
|
||||
3. `git tag -a vX.Y.Z-oled-edition -m "..."`
|
||||
4. `git push origin master && git push origin vX.Y.Z-oled-edition`
|
||||
5. `gh release create vX.Y.Z-oled-edition -R MarcelineVPQ/DS5Dongle-OLED-Edition --title "vX.Y.Z — OLED Edition" --notes "..."`
|
||||
6. CI builds the UF2s (~5–7 min), uploads them with SHA256SUMS, edits the release notes to append checksums, and (when `WEB_REPO_DISPATCH_PAT` is configured on the firmware repo — currently unset, see below) fires a `repository_dispatch` event to the web repo to refresh `firmware-latest.json`. Without the secret, the next push to the web repo's `master` does the refresh instead.
|
||||
|
||||
There is **no other place** to edit the version. If you find a hardcoded version string in source (`"v0.6.0"`, `"v0.5.4"`, etc.), it's a bug — replace it with the macro / JSON lookup.
|
||||
|
||||
**Known follow-up:** `WEB_REPO_DISPATCH_PAT` secret is unset on the firmware repo, so the firmware-release → web-rebuild dispatch is currently silently no-op'd (peter-evans/repository-dispatch with continue-on-error). The web bundle still updates via push events to the web repo's master, but not automatically on every firmware release.
|
||||
|
||||
## Git / branch model
|
||||
|
||||
- **`master` (origin)** = `MarcelineVPQ/DS5Dongle-OLED-Edition` (this fork's primary branding, what users download).
|
||||
@@ -109,6 +138,8 @@ When asked to modify behavior, the *first* file to read is usually one of:
|
||||
|
||||
- New BT pairing / connection state behavior → `src/bt.cpp` (HCI + L2CAP event handlers).
|
||||
- New OLED screen or change to existing one → `src/oled.cpp`.
|
||||
- New diagnostic counter on the OLED Diagnostics screen → bump `kNumDiagRows` in `src/oled.cpp` and add a `case` to `format_diag_row()` (single switch, one row per case). The screen scrolls automatically; no D-pad wiring needed. Counters that need rate-per-second arithmetic should be sampled in `sample_diag_rates()` and read from `g_diag_rates`. Counter globals themselves typically live in `src/main.cpp` next to `g_bt_31_packets` etc. with `extern` declarations near the top of `src/oled.cpp`.
|
||||
- Host-side diagnostics → `scripts/mic_diag.sh` (Linux only, reads `/dev/hidraw` directly). Subcommands: `status` / `capture [secs]` / `watch` / `bt-trace`. `bt-trace` reads the firmware's `0xFD` vendor feature report (defined in `src/cmd.cpp`'s `tud_hid_get_report_cb`), which currently exposes BT-input counters + the host-output trigger-flow counters. To add a new counter visible to `bt-trace`: extend the `0xFD` payload in `src/cmd.cpp` (bump `want`, write at the new offset), bump `IOCTL_SIZE` in `bt_trace()` of the script, and add the field to its `decode()` dict. The `0xFD` report ID is not declared in the HID descriptor (Linux hidraw ioctls don't enforce that; WebHID would reject undeclared IDs, which is why config goes through `0xF6`).
|
||||
- New persistent config field → `src/config.h` (struct), `src/config.cpp:config_valid()` (defaults + clamping), `src/oled.cpp:format_settings_item()` (UI), `src/oled.cpp:settings_adjust()` (D-pad ▶◀ behavior). Update `CHANGELOG.md`.
|
||||
- USB descriptor or interface change → `src/usb_descriptors.cpp` + `src/tusb_config.h`.
|
||||
- Audio / haptic path → `src/audio.cpp`. **Don't** add stack arrays sized smaller than the resampler / Opus expects (this is the C1 bug that caused the long-standing "audio stuttering" issue — fix landed in upstream `5b04cbd`, but the lesson stands).
|
||||
|
||||
@@ -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)
|
||||
@@ -146,6 +147,14 @@ if(WAKE_DEBUG)
|
||||
endif()
|
||||
set(VERSION "dev" CACHE STRING "Program version string")
|
||||
|
||||
# Expose VERSION to firmware C++ as FIRMWARE_VERSION so src/oled.cpp's
|
||||
# Status header always reflects the release tag (set by release.yml via
|
||||
# -DVERSION="$FIRMWARE_VERSION"). Local builds without -DVERSION show
|
||||
# "dev" — visible signal that you're not on a tagged release.
|
||||
target_compile_definitions(ds5-bridge PRIVATE
|
||||
FIRMWARE_VERSION="${VERSION}"
|
||||
)
|
||||
|
||||
set_target_properties(ds5-bridge PROPERTIES OUTPUT_NAME "ds5-bridge-oled")
|
||||
pico_set_program_name(ds5-bridge "ds5-bridge-oled")
|
||||
pico_set_program_version(ds5-bridge "${VERSION}")
|
||||
|
||||
@@ -6,6 +6,27 @@
|
||||
|
||||
> **OLED Edition** is a fork of **[awalol/DS5Dongle](https://github.com/awalol/DS5Dongle)** (upstream) that adds an optional Pico-OLED-1.3 128×64 display add-on with 11 screens (status, 4-slot multi-controller pairing, lightbar color picker with favorites and effect presets, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, BT signal strength, audio VU meters, and a persistent settings menu), plus a DS5 button-combo soft-reboot. Upstream is the authoritative source for the core bridge firmware; this fork tracks it and layers add-on features on top.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Web Config Tool
|
||||
|
||||
**[→ Open the OLED Edition Web Config](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/#config)**
|
||||
|
||||
The web tool is a one-stop shop — **no installs, no command line, no `picotool`**. A brand-new Pico 2 W can go from "just out of the box" to fully flashed and configured without ever leaving the browser:
|
||||
|
||||
- **Flash Firmware tab** — put the Pico in **BOOTSEL mode**, then click *Connect to Pico* in the browser and *Flash now*. The site bundles the latest release UF2, or you can load a local `.uf2` you've built yourself. Powered by WebUSB.
|
||||
|
||||
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use.
|
||||
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID.
|
||||
- **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.
|
||||
|
||||
> Source for the web tool: **[MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)** (fork of [awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge for the DualSense controller, allowing wireless connectivity with enhanced haptics support.
|
||||
@@ -23,11 +44,12 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
|
||||
**OLED Edition additions:**
|
||||
|
||||
- Optional Pico-OLED-1.3 status display with **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
|
||||
- **OLED brightness control + auto-dim** after 5 min idle (extends OLED life)
|
||||
- **Soft-reboot** without unplugging USB via DS5 `PS + Mute` hold (works headless) or OLED KEY0 double-click
|
||||
- **OLED idle power ladder** — manual brightness cycle (KEY1 long-press), automatic deep-dim with a small breathing dot at 2 min idle, full display-off at 15 min idle. Wakes instantly on button, controller pair, or input. Real burn-in protection, not just a contrast tweak.
|
||||
- **Soft-reboot** without unplugging USB via DS5 `PS + Mute` hold (works headless) or **KEY0 + KEY1 held together for 1 s** on the OLED add-on (replaces the older KEY0 double-click gesture, which was easy to fire by accident while paging quickly)
|
||||
- **Audit pass on the core bridge** — critical stack-overflow fix in the audio path (resolves long-standing "audio stuttering"), security hardening, watchdog, length validation across HID/L2CAP boundaries (see [CHANGELOG.md](./CHANGELOG.md))
|
||||
|
||||
## Hardware
|
||||
@@ -120,23 +142,59 @@ 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.
|
||||
- **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 ~10–20 s after the lightbar goes amber may let it recover without a replug.
|
||||
|
||||
## Performance / Overclocking
|
||||
|
||||
Due to encoding requirements, the Pico2W must be overclocked:
|
||||
**You don't need to do anything for this — the overclock is baked into the firmware.** When you flash a UF2 from this repo, the Pico 2 W boots at the settings below automatically. There is no separate tool to run, no config file to edit, no fuses to blow.
|
||||
|
||||
Current settings:
|
||||
Baked-in settings:
|
||||
|
||||
- Voltage: 1.2V
|
||||
- Frequency: 320 MHz
|
||||
- **Voltage: 1.20 V** (`vreg_set_voltage(VREG_VOLTAGE_1_20)`)
|
||||
- **Clock: 320 MHz** (`set_sys_clock_khz(SYS_CLOCK_KHZ, true)`)
|
||||
|
||||
If your device fails to boot:
|
||||
Why it's required: at stock clock/voltage the CYW43 PIO SPI bus (the path the firmware uses to talk to the on-board Bluetooth chip) is unreliable and pairing fails. 320 MHz @ 1.20 V is the lowest combination we've verified to produce a stable BT link on this board.
|
||||
|
||||
- Increase voltage slightly or Reduce CPU frequency
|
||||
If your device fails to boot a build you compiled yourself (unusual — only relevant when you've changed source), try increasing voltage slightly or lowering the clock in `src/main.cpp`. End users running official UF2 releases should not need to touch this.
|
||||
|
||||
A small heatsink on the RP2350 is **recommended for sustained gameplay** but not required for pairing or short sessions.
|
||||
|
||||
## Build Instructions
|
||||
|
||||
@@ -163,59 +221,59 @@ Build flags worth knowing:
|
||||
- `-DENABLE_SERIAL=ON` — route printf to USB CDC for debugging (default OFF; releases UART for production builds).
|
||||
- `-DPICO_W_BUILD=ON` — build for the original Pico W (drops audio, lowers clock). Default targets Pico 2 W.
|
||||
|
||||
## Diagnostics & debug tooling
|
||||
|
||||
Two ways to triage bridge issues — on-device via the OLED Diagnostics screen, and host-side via `scripts/mic_diag.sh` (Linux). The host-side path is faster: no screen-switching, no flash cycle, runs while the controller is in active use.
|
||||
|
||||
```
|
||||
# One-shot snapshot — is the dongle on USB? Did ALSA enumerate it? Is the
|
||||
# capture stream live? Is a controller currently paired?
|
||||
scripts/mic_diag.sh status
|
||||
|
||||
# 3-second arecord on the mic IN endpoint — reports peak / RMS / non-zero
|
||||
# count so we can tell "stream is silent" from "stream is producing audio".
|
||||
scripts/mic_diag.sh capture 3
|
||||
|
||||
# Same as `status` but in a loop, prints only on state change. Useful for
|
||||
# catching the exact second pairing completes or audio streams open / close.
|
||||
scripts/mic_diag.sh watch
|
||||
|
||||
# Live read of the firmware's 0xFD vendor feature report (via /dev/hidraw):
|
||||
# BT input counts + rates, last seen non-0x31 IDs, byte prefixes, AND the
|
||||
# trigger-flow counters (host 0x02 received / with AllowTriggerFFB set /
|
||||
# forwarded to BT). bt-trace prints a verdict — "host driver isn't sending
|
||||
# trigger Allow bits" vs "forwarded but controller didn't actuate" — which
|
||||
# is what would otherwise need a USB protocol analyzer.
|
||||
scripts/mic_diag.sh bt-trace
|
||||
```
|
||||
|
||||
Originally written to triage the parked DS5 BT-microphone investigation (see [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md)). The `0xFD` feature report and `bt-trace` decoder now also carry the trigger-flow counters added for [issue #3](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/issues/3) (missing adaptive trigger tension in some games).
|
||||
|
||||
## OLED Display Add-on (optional)
|
||||
|
||||
If you plug a [Waveshare Pico-OLED-1.3](#hardware) onto the Pico2W's headers, the firmware drives it automatically as a live status display. No configuration needed — the firmware no-ops gracefully when no OLED is present.
|
||||
|
||||
### Boot splash (1.5 s on power-on)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ │
|
||||
│ DS5 Bridge │
|
||||
│ Pico2W + OLED │
|
||||
│ │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
Centered firmware version on a blank screen for 1.5 seconds, then jumps to the Status screen.
|
||||
|
||||
### Ten screens, cycled with KEY0 on the add-on
|
||||
### Eleven screens, cycled with KEY0 on the add-on
|
||||
|
||||
Cycle order: **Status → Slots → Lightbar → Trigger Test → Gyro Tilt → Touchpad → Diagnostics → RSSI → VU Meters → Settings →** wrap. KEY0 short-press steps forward. KEY1 short-press steps backward (with two contextual exceptions, see the table at the end).
|
||||
Cycle order: **Status → Slots → Lightbar → Trigger Test → Gyro Tilt → Touchpad → Diagnostics → CPU/Clock → BT Signal → VU Meters → Settings →** wrap. **KEY0 short-press steps forward; KEY1 short-press steps backward** — on *every* screen. Per-screen interactions (cycling the trigger preset, cycling the lightbar mode, navigating the Settings cursor, switching slots) live on the **DualSense controller buttons**, never on KEY0/KEY1, so the two physical buttons on the OLED add-on always mean the same thing.
|
||||
|
||||
Every screen also paints **`>`** at the top-left edge (next to KEY0) and **`<`** at the bottom-left edge (next to KEY1) so the on-screen labels physically pair with the buttons.
|
||||
|
||||
#### 1. Status
|
||||
|
||||
Connection state, paired DualSense BD address, battery % with bar (`+` charging / `*` complete / `!` error), live analog stick positions, D-pad, face buttons (△ ◯ ✕ □), L1/R1, and L2/R2 analog trigger fill bars. The link indicator and battery use small pixel icons.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ DS5 Bridge v0.6.0 ● │
|
||||
│ 14:3A:9A:FF:D9:F9 │
|
||||
│ 87%+ ╔══════════════╗ │
|
||||
│ ║██████░░░░░░░░║▌ │
|
||||
│ ┌────┐ L1 △ R1 ┌────┐│
|
||||
│ │ ·• │ L2 ○ □ R2 │ ·• ││
|
||||
│ │ │ ▌ ✕ ▌ │ ││
|
||||
│ └────┘ └────┘│
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
<img src="./assets/main_screen_01.jpeg" alt="Status screen on the OLED" width="420">
|
||||
<img src="./assets/oled/oled_sc01.jpg" alt="Status screen on the OLED" width="420">
|
||||
|
||||
#### 2. Slots
|
||||
|
||||
Persistent 4-slot multi-controller pairing. Browse stored controllers, switch active slot, or wipe a single slot. `>` is the cursor, `*` marks the currently active slot.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Slots [s0 ON] │
|
||||
│ >0* 14:3A:9A:FF:D9:F9 │
|
||||
│ 1 (empty) │
|
||||
│ 2 (empty) │
|
||||
│ 3 (empty) │
|
||||
│ │
|
||||
│ Tri=switch Sq hold=wipe │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
<img src="./assets/oled/oled_sc02.jpg" alt="Slots screen on the OLED" width="420">
|
||||
|
||||
- **D-pad ▲▼** — move cursor across slots 0–3
|
||||
- **△** — switch to the cursor slot (disconnect current, reconnect to slot's stored controller)
|
||||
@@ -226,42 +284,17 @@ Persistent 4-slot multi-controller pairing. Browse stored controllers, switch ac
|
||||
|
||||
Tilt the controller on each axis to dial in R / G / B; the firmware sends the resulting color to the DualSense's actual lightbar at 10 Hz, so the lightbar IS the visual preview (the OLED is monochrome).
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Lightbar [LIVE] │
|
||||
│ R:128 G: 77 B:200 │
|
||||
│ ████░░░ ██░░░░░ ██████░░ │
|
||||
│ (R) (G) (B) │
|
||||
│ Sv: T=0 C=1 X=2 S=3 │
|
||||
│ Tilt = R/G/B │
|
||||
│ K0=next K1=cycle │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
<img src="./assets/lightbar_01.jpeg" alt="Lightbar color picker on the OLED" width="420">
|
||||
<img src="./assets/oled/oled_sc03.jpg" alt="Lightbar color picker on the OLED" width="420">
|
||||
|
||||
- Press **△ ◯ ✕ □** on the controller to **save** the current color into favorite slot 0 / 1 / 2 / 3
|
||||
- Press **KEY1** to cycle the mode tag: `[LIVE]` → `[FAV0]` → `[FAV1]` → `[FAV2]` → `[FAV3]` → effects (Breathing / Rainbow / Fade) → back to `[LIVE]`
|
||||
- Press **R1** on the controller to cycle the mode tag: `[LIVE]` → `[FAV0]` → `[FAV1]` → `[FAV2]` → `[FAV3]` → effects (Breathing / Rainbow / Fade) → back to `[LIVE]`
|
||||
- Default favorites: Red, Green, Blue, White
|
||||
|
||||
#### 4. Trigger Test
|
||||
|
||||
KEY1 cycles seven adaptive trigger effects applied to both L2 and R2. Pull each trigger to feel the effect.
|
||||
Press **△** on the controller to cycle seven adaptive trigger effects applied to both L2 and R2. Pull each trigger to feel the effect.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Trigger Test │
|
||||
│ Mode: Weapon │
|
||||
│ L2: 127 R2: 42 │
|
||||
│ │
|
||||
│ ████░░░░░░ ██░░░░░░░░ │
|
||||
│ (L2 pull) (R2 pull) │
|
||||
│ │
|
||||
│ K0=next K1=cycle │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
<img src="./assets/triggertest_01.jpeg" alt="Trigger Test screen on the OLED" width="420">
|
||||
<img src="./assets/oled/oled_sc04.jpg" alt="Trigger Test screen on the OLED" width="420">
|
||||
|
||||
Cycle order: **Off → Feedback → Weapon → Vibration → Bow → Gallop → Machine Gun → Off …** Effect parameters bitpacked per [dualsensectl](https://github.com/nowrep/dualsensectl)'s reverse-engineering, all at max strength.
|
||||
|
||||
@@ -269,69 +302,47 @@ Cycle order: **Off → Feedback → Weapon → Vibration → Bow → Gallop →
|
||||
|
||||
Live X/Y/Z accelerometer values with a 40×40 crosshair box. Tilt the controller and the dot tracks in real time.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Gyro Tilt │
|
||||
│ X +123 Y -456 Z +8123 │
|
||||
│ ┌────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │───•────│ │
|
||||
│ │ │ │ │
|
||||
│ └────────┘ │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
<img src="./assets/gyro_tilt_01.jpeg" alt="Gyro Tilt screen on the OLED" width="420">
|
||||
<img src="./assets/oled/oled_sc05.jpg" alt="Gyro Tilt screen on the OLED" width="420">
|
||||
|
||||
#### 6. Touchpad
|
||||
|
||||
Live render of the touchpad surface. Dots appear at current finger positions; the count updates as fingers touch / leave.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Touchpad │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ • • │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ Fingers: 2 │
|
||||
│ │
|
||||
│ K0=next │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
<img src="./assets/touchpad_01.jpeg" alt="Touchpad screen on the OLED" width="420">
|
||||
<img src="./assets/oled/oled_sc06.jpg" alt="Touchpad screen on the OLED" width="420">
|
||||
|
||||
#### 7. Diagnostics
|
||||
|
||||
Uptime, BT state, and stub counters for HCI errors / audio FIFO drops / opus FIFO drops (always 0 in this build — kept for future wiring).
|
||||
Scrollable list of live counters — uptime, BT state, host → BT trigger flow (`host02` / `trig` / `tx`), BT 0x31 input rate, USB audio frames/sec, BT 0x32 packets/sec, and parked mic-investigation counters at the bottom. Controller D-pad ▲/▼ scrolls; tiny `^` / `v` glyphs at the right edge mark "more above/below." Read-only, so no cursor. Useful for verifying the bridge is moving bytes without needing a UART cable.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Diagnostics │
|
||||
│ Up: 0h 14m 22s │
|
||||
│ HCI errs: 0 │
|
||||
│ Aud drops: 0 │
|
||||
│ Opus drops: 0 │
|
||||
│ BT: connected │
|
||||
│ │
|
||||
│ K0=next │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
The same counters are also exported on HID feature report `0xFD` for host-side tooling — see `scripts/mic_diag.sh bt-trace` below.
|
||||
|
||||
<img src="./assets/diagnostics_01.jpeg" alt="Diagnostics screen on the OLED" width="420">
|
||||
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
|
||||
|
||||
#### 8. RSSI
|
||||
#### 8. CPU / Clock
|
||||
|
||||
Live Bluetooth signal strength of the active link, in dBm with a bar. Closer to 0 dBm is stronger; −90 dBm is weak.
|
||||
Live RP2350 vitals: configured (`Set`) and actually-running (`Real`) system clock measured against the crystal reference, core voltage read back from the regulator, and on-die temperature (256-sample average + slow EMA so the value tracks the real die temp rather than ADC noise).
|
||||
|
||||
#### 9. VU Meters
|
||||
<img src="./assets/oled/oled_sc08.jpg" alt="CPU / Clock diagnostics screen on the OLED" width="420">
|
||||
|
||||
The same telemetry is also exported on HID feature report `0xFC` for tooling.
|
||||
|
||||
#### 9. BT Signal
|
||||
|
||||
Live Bluetooth signal strength of the active link, in dBm with a bar. Closer to 0 dBm is stronger; −90 dBm is weak. Includes a qualitative label (Poor / Fair / Good / Excellent).
|
||||
|
||||
<img src="./assets/oled/oled_sc09.jpg" alt="BT Signal screen on the OLED" width="420">
|
||||
|
||||
#### 10. VU Meters
|
||||
|
||||
Live peak meters for the speaker and haptic audio paths. Useful for verifying audio routing without the controller being plugged in to a host.
|
||||
|
||||
#### 10. Settings
|
||||
<img src="./assets/oled/oled_sc10.jpg" alt="VU Meters screen on the OLED" width="420">
|
||||
|
||||
Persistent config editor. D-pad ▲▼ moves the selection, ▶◀ adjusts values, △ saves to flash. Includes the 8 firmware-config fields (haptics gain, speaker volume, inactive timeout, etc.), the Audio Auto Haptics controls, and two hold-to-confirm actions:
|
||||
#### 11. Settings
|
||||
|
||||
Persistent config editor. D-pad ▲▼ moves the selection, ▶◀ adjusts values, △ saves to flash. Includes the firmware-config fields (haptics gain, speaker volume, inactive timeout, polling rate), the Audio Auto Haptics controls, and two hold-to-confirm actions:
|
||||
|
||||
<img src="./assets/oled/oled_sc11.jpg" alt="Settings screen on the OLED" width="420">
|
||||
|
||||
- **AutoHap Off / Fallback / Mix / Replace** — selects the Audio Auto Haptics mode. Default `Fallback` fires derived rumble only when the game sends no native haptic data (e.g. Ghost of Tsushima on Linux); games that *do* send native haptics (Spider-Man Remastered) pass through unchanged. `Mix` adds derived on top of native, `Replace` ignores native entirely, `Off` disables.
|
||||
- **AH Gain N%** — derived-signal gain, 0–200 % in 10 % steps. Default 100 %.
|
||||
@@ -339,15 +350,18 @@ Persistent config editor. D-pad ▲▼ moves the selection, ▶◀ adjusts value
|
||||
- **Reset to defaults** — hold △ for 2 s to revert all config fields
|
||||
- **Wipe all slots** — hold △ for 2 s to drop all 4 paired controllers + all BTstack link keys
|
||||
|
||||
### KEY1 behavior by screen
|
||||
### Button reference
|
||||
|
||||
| Screen | KEY1 short-press action |
|
||||
The two physical buttons on the OLED add-on are **strictly navigation**:
|
||||
|
||||
| Button | Action |
|
||||
|---|---|
|
||||
| Status, Slots, Gyro Tilt, Touchpad, Diagnostics, RSSI, VU Meters, Settings | Step **backward** one screen (mirror of KEY0's forward step) |
|
||||
| Trigger Test | Cycle the trigger effect preset |
|
||||
| Lightbar Color Picker | Cycle between LIVE preview, the 4 favorite slots, and the effect presets |
|
||||
| **KEY0** short-press | Next screen (forward) |
|
||||
| **KEY1** short-press | Previous screen (backward) |
|
||||
| **KEY1** long-press (≥ 1.5 s) | Cycle OLED brightness level |
|
||||
| **KEY0 + KEY1** held together ≥ 1 s | `watchdog_reboot` — soft-reboot without unplugging USB |
|
||||
|
||||
KEY1 long-press (≥1.5 s) on any screen cycles the OLED brightness level.
|
||||
Per-screen state changes (cycling the trigger preset, cycling the lightbar mode, navigating Settings, switching slots, saving colors to favorite slots) all happen on the **DualSense controller buttons** — never on KEY0 / KEY1 — so the two physical buttons always mean the same thing across every screen. See each screen's section above for which controller button does what.
|
||||
|
||||
### Pinout (standard Waveshare Pico HAT layout)
|
||||
|
||||
@@ -365,8 +379,8 @@ KEY1 long-press (≥1.5 s) on any screen cycles the OLED brightness level.
|
||||
|
||||
Two ways to reboot the dongle without unplugging USB — handy if pairing gets stuck or you want a clean state:
|
||||
|
||||
- **OLED KEY0 double-click** within ~400 ms → `watchdog_reboot`
|
||||
- **DualSense `PS + Mute` held for 2 seconds** → `watchdog_reboot` (works whether or not the OLED is attached)
|
||||
- **OLED KEY0 + KEY1 held simultaneously for ≥ 1 s** → `watchdog_reboot`. Replaces the older "KEY0 double-click" gesture from earlier versions, since rapid forward-navigation kept tripping the double-click timer by accident.
|
||||
- **DualSense `PS + Mute` held for 2 seconds** → `watchdog_reboot` (works whether or not the OLED is attached — headless backup).
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -25,6 +26,14 @@
|
||||
// #define VOLUME_GAIN 2
|
||||
// #define BUFFER_LENGTH 48
|
||||
|
||||
// DualSense microphone, ported from awalol/DS5Dongle's `mic` branch.
|
||||
// The DS5 sends mic audio as Opus packets embedded in BT input report
|
||||
// 0x31 when bit 1 of byte 2 is set; payload is 71 bytes of Opus at
|
||||
// offset 4, decoded to mono 48 kHz 10 ms frames (480 samples).
|
||||
#define MIC_CHANNELS 1
|
||||
#define MIC_FRAMES 480
|
||||
#define MIC_OPUS_SIZE 71
|
||||
|
||||
using std::clamp;
|
||||
using std::max;
|
||||
|
||||
@@ -37,6 +46,39 @@ queue_t audio_fifo;
|
||||
static uint8_t opus_buf[200];
|
||||
critical_section_t opus_cs;
|
||||
|
||||
// Mic ingress queue — filled from on_bt_data() (BT poll, core0), drained
|
||||
// at the top of audio_loop() on core0. The decoder is single-threaded
|
||||
// (core0 only), so no critical section is needed around it.
|
||||
queue_t mic_fifo;
|
||||
struct mic_element { uint8_t data[MIC_OPUS_SIZE]; };
|
||||
static OpusDecoder *mic_decoder = nullptr;
|
||||
static volatile uint32_t g_mic_frames = 0;
|
||||
static volatile int32_t g_mic_last_decoded = 0; // opus_decode return value
|
||||
static volatile uint16_t g_mic_last_want = 0; // bytes we asked TinyUSB to send
|
||||
static volatile uint16_t g_mic_last_wrote = 0; // bytes TinyUSB accepted
|
||||
uint32_t audio_mic_frames() { return g_mic_frames; }
|
||||
int32_t audio_mic_last_decoded() { return g_mic_last_decoded; }
|
||||
uint16_t audio_mic_last_want() { return g_mic_last_want; }
|
||||
uint16_t audio_mic_last_wrote() { return g_mic_last_wrote; }
|
||||
|
||||
// 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];
|
||||
};
|
||||
@@ -73,9 +115,140 @@ uint8_t audio_peak_haptic() {
|
||||
return (uint8_t)(v >> 7);
|
||||
}
|
||||
|
||||
// Most-recent Opus TOC byte (first byte of the packet). Used by the OLED
|
||||
// Diagnostics screen to decode the frame's bandwidth + duration config
|
||||
// without serial.
|
||||
static volatile uint8_t g_mic_toc = 0;
|
||||
uint8_t audio_mic_last_toc() { return g_mic_toc; }
|
||||
|
||||
// Push a 71-byte Opus mic packet from the BT handler into the mic_fifo.
|
||||
// Called from src/main.cpp's on_bt_data() when the DS5 sends a mic-tagged
|
||||
// 0x31 input report. Drops the oldest queued packet if the FIFO is full —
|
||||
// preferring fresh audio over backlog on overload.
|
||||
void mic_add_queue(const uint8_t *data) {
|
||||
static mic_element packet{};
|
||||
memcpy(packet.data, data, MIC_OPUS_SIZE);
|
||||
g_mic_toc = data[0]; // first byte of the Opus packet
|
||||
if (queue_is_full(&mic_fifo)) queue_try_remove(&mic_fifo, NULL);
|
||||
queue_try_add(&mic_fifo, &packet);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
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_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
|
||||
@@ -209,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;
|
||||
@@ -253,6 +429,17 @@ void audio_init() {
|
||||
critical_section_init(&opus_cs);
|
||||
multicore_launch_core1_with_stack(core1_entry, audio_core1_stack, sizeof(audio_core1_stack));
|
||||
#endif
|
||||
|
||||
// Mic path: queue + decoder live on core0 (audio_loop), separate from
|
||||
// the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames.
|
||||
queue_init(&mic_fifo, sizeof(mic_element), 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) {
|
||||
printf("[Audio] OpusDecoder create failed (err=%d)\n", dec_error);
|
||||
mic_decoder = nullptr; // ensure audio_loop's null-guard short-circuits
|
||||
}
|
||||
}
|
||||
|
||||
static OpusEncoder *encoder;
|
||||
|
||||
@@ -21,5 +21,16 @@ uint8_t audio_peak_haptic(); // 0..255, decays on read
|
||||
// Byte-flow counters for the Diagnostics screen + web emulator.
|
||||
uint32_t audio_usb_frames();
|
||||
uint32_t audio_bt_packets();
|
||||
uint32_t audio_mic_frames(); // count of mic Opus frames decoded + written
|
||||
int32_t audio_mic_last_decoded(); // last opus_decode return — neg = error, 480 = OK
|
||||
uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write
|
||||
uint16_t audio_mic_last_wrote(); // bytes TinyUSB FIFO actually accepted
|
||||
uint8_t audio_mic_last_toc(); // first byte of last Opus packet (frame config)
|
||||
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
|
||||
// Opus payload.
|
||||
void mic_add_queue(const uint8_t *data);
|
||||
|
||||
#endif //DS5_BRIDGE_AUDIO_H
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"
|
||||
@@ -48,6 +49,25 @@ uint16_t cpu_temp_raw_smoothed() {
|
||||
return (uint16_t)(ema + 0.5f);
|
||||
}
|
||||
|
||||
// Mic-debug globals (defined in main.cpp). File-scope extern so the
|
||||
// linker resolves them once and cmd.cpp's 0xFD handler reads the same
|
||||
// memory main.cpp writes to.
|
||||
extern volatile uint32_t g_bt_31_packets;
|
||||
extern volatile uint32_t g_bt_other_packets;
|
||||
extern volatile uint8_t g_last_other_id;
|
||||
extern volatile uint8_t g_other_id_or;
|
||||
extern volatile uint8_t g_31_b2_or;
|
||||
extern volatile uint8_t g_last_31_b2;
|
||||
extern volatile uint16_t g_31_len_min;
|
||||
extern volatile uint16_t g_31_len_max;
|
||||
extern volatile uint8_t g_last_other_prefix[8];
|
||||
extern volatile uint8_t g_last_any_prefix[16];
|
||||
extern volatile uint16_t g_longest_len;
|
||||
extern volatile uint8_t g_longest_frame[80];
|
||||
extern volatile uint32_t g_host_out02_total;
|
||||
extern volatile uint32_t g_host_out02_trig_allow;
|
||||
extern volatile uint32_t g_host_out02_to_bt;
|
||||
|
||||
bool is_pico_cmd(uint8_t report_id) {
|
||||
if (report_id == 0xf6 ||
|
||||
report_id == 0xf7 ||
|
||||
@@ -55,7 +75,9 @@ bool is_pico_cmd(uint8_t report_id) {
|
||||
report_id == 0xf9 ||
|
||||
report_id == 0xfa ||
|
||||
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;
|
||||
}
|
||||
@@ -65,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));
|
||||
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) {
|
||||
@@ -161,6 +206,66 @@ uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
|
||||
memcpy(buffer + 9, &temp_raw, 2);
|
||||
return want;
|
||||
}
|
||||
if (report_id == 0xfd) {
|
||||
// Bridge-diagnostics feature report. 44-byte payload.
|
||||
// Section 1: mic-investigation counters (original 0..31).
|
||||
// [0..3] uint32 BT 0x31 input report count
|
||||
// [4..7] uint32 BT non-0x31 input report count
|
||||
// [8] uint8 last non-0x31 report ID seen
|
||||
// [9] uint8 OR mask of all non-0x31 report IDs seen
|
||||
// [10] uint8 OR mask of byte[2] across all 0x31 frames
|
||||
// [11] uint8 last value of byte[2] in a 0x31 frame
|
||||
// [12..13] uint16 min frame length seen
|
||||
// [14..15] uint16 max frame length seen
|
||||
// [16..23] uint8[8] first 8 bytes of last non-0x31 frame
|
||||
// [24..31] uint8[8] first 8 bytes of most recent ANY frame
|
||||
// Section 2: trigger-flow counters (issue #3 triage).
|
||||
// [32..35] uint32 host 0x02 OUT reports received total
|
||||
// [36..39] uint32 ...of those, with Allow*TriggerFFB set
|
||||
// [40..43] uint32 ...forwarded as BT 0x31 sub-0x10
|
||||
constexpr uint16_t want = 44;
|
||||
for (uint16_t i = 0; i < want && i < reqlen; i++) buffer[i] = 0;
|
||||
|
||||
const uint32_t bt31 = g_bt_31_packets;
|
||||
const uint32_t btother = g_bt_other_packets;
|
||||
const uint16_t lmin = g_31_len_min == 0xFFFF ? 0 : g_31_len_min;
|
||||
const uint16_t lmax = g_31_len_max;
|
||||
|
||||
memcpy(buffer + 0, &bt31, 4);
|
||||
memcpy(buffer + 4, &btother, 4);
|
||||
buffer[8] = g_last_other_id;
|
||||
buffer[9] = g_other_id_or;
|
||||
buffer[10] = g_31_b2_or;
|
||||
buffer[11] = g_last_31_b2;
|
||||
memcpy(buffer + 12, &lmin, 2);
|
||||
memcpy(buffer + 14, &lmax, 2);
|
||||
for (int i = 0; i < 8 && (16 + i) < reqlen; i++) buffer[16 + i] = g_last_other_prefix[i];
|
||||
for (int i = 0; i < 8 && (24 + i) < reqlen; i++) buffer[24 + i] = g_last_any_prefix[i];
|
||||
|
||||
const uint32_t out02 = g_host_out02_total;
|
||||
const uint32_t out02_t = g_host_out02_trig_allow;
|
||||
const uint32_t out02_b = g_host_out02_to_bt;
|
||||
if ((32 + 4) <= reqlen) memcpy(buffer + 32, &out02, 4);
|
||||
if ((36 + 4) <= reqlen) memcpy(buffer + 36, &out02_t, 4);
|
||||
if ((40 + 4) <= reqlen) memcpy(buffer + 40, &out02_b, 4);
|
||||
return (reqlen < want) ? reqlen : want;
|
||||
}
|
||||
if (report_id == 0xfe) {
|
||||
// 0xFE: full content of the LONGEST 0x31 frame seen. Bytes 0-1
|
||||
// = length (uint16 LE), bytes 2+ = the captured frame bytes.
|
||||
constexpr uint16_t want = 82; // 2 length + 80 frame bytes
|
||||
const uint16_t lim = (reqlen < want) ? reqlen : want;
|
||||
for (uint16_t i = 0; i < lim; i++) buffer[i] = 0;
|
||||
const uint16_t llen = g_longest_len;
|
||||
if (lim >= 2) {
|
||||
buffer[0] = (uint8_t)(llen & 0xFF);
|
||||
buffer[1] = (uint8_t)((llen >> 8) & 0xFF);
|
||||
}
|
||||
for (uint16_t i = 0; i < 80 && (i + 2) < lim; i++) {
|
||||
buffer[2 + i] = g_longest_frame[i];
|
||||
}
|
||||
return lim;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -187,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,24 @@ void config_valid() {
|
||||
body->auto_haptics_lowpass = 1; // 160 Hz
|
||||
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) {
|
||||
body->config_version = CONFIG_VERSION;
|
||||
printf("[Config] Warning: Config may breaking change\n");
|
||||
|
||||
@@ -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_gain; // [0,200] percent, default 100
|
||||
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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -30,6 +31,58 @@ int reportSeqCounter = 0;
|
||||
uint8_t packetCounter = 0;
|
||||
bool spk_active = false;
|
||||
|
||||
// Mic-debug instrumentation: count every 0x31 BT input report regardless
|
||||
// of mic-tag bit, accumulate OR-mask of every byte-2 value seen (tells us
|
||||
// which bits ever fire) and remember the last byte-2 value. Also track
|
||||
// observed frame-length range. Surfaced on the OLED Diagnostics screen.
|
||||
volatile uint32_t g_bt_31_packets = 0;
|
||||
volatile uint32_t g_bt_other_packets = 0;
|
||||
volatile uint8_t g_last_other_id = 0;
|
||||
volatile uint8_t g_other_id_or = 0;
|
||||
volatile uint8_t g_last_31_b2 = 0;
|
||||
volatile uint8_t g_31_b2_or = 0;
|
||||
volatile uint16_t g_31_len_min = 0xFFFF;
|
||||
volatile uint16_t g_31_len_max = 0;
|
||||
volatile uint8_t g_mic_prefix[6] = {0};
|
||||
volatile uint8_t g_last_other_prefix[8] = {0};
|
||||
volatile uint8_t g_last_any_prefix[16] = {0};
|
||||
volatile uint16_t g_longest_len = 0;
|
||||
volatile uint8_t g_longest_frame[80] = {0};
|
||||
uint32_t bt_31_packet_count() { return g_bt_31_packets; }
|
||||
uint8_t bt_31_last_byte2() { return g_last_31_b2; }
|
||||
uint8_t bt_31_b2_or_mask() { return g_31_b2_or; }
|
||||
uint16_t bt_31_len_min() { return g_31_len_min == 0xFFFF ? 0 : g_31_len_min; }
|
||||
uint16_t bt_31_len_max() { return g_31_len_max; }
|
||||
void bt_31_mic_prefix(uint8_t out[6]) {
|
||||
for (int i = 0; i < 6; i++) out[i] = g_mic_prefix[i];
|
||||
}
|
||||
|
||||
// Trigger-flow diagnostics. Counts host → dongle → BT path for adaptive
|
||||
// trigger effects. Lets us tell which link in the chain breaks when games
|
||||
// like Death Stranding 2 don't produce trigger tension via the dongle:
|
||||
// out02_total - every 0x02 HID OUT report received from host
|
||||
// out02_trig_allow - of those, how many set AllowRight/LeftTriggerFFB
|
||||
// (valid_flag0 bits 2 & 3) — i.e. the host actually
|
||||
// told us "apply trigger FFB"
|
||||
// out02_to_bt - 0x02 reports that we forwarded to the controller as
|
||||
// a BT 0x31 sub-0x10 packet (gated off when speaker is
|
||||
// active; audio.cpp's 0x36 path carries state then)
|
||||
// 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] = {
|
||||
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
|
||||
0x08, 0x00, 0x00, 0x00, 0x52, 0x43, 0x30, 0x41,
|
||||
@@ -66,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;
|
||||
@@ -85,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)) {
|
||||
@@ -101,6 +162,61 @@ void interrupt_loop() {
|
||||
|
||||
void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
|
||||
// printf("[Main] BT data callback: channel=%u len=%u\n", channel, len);
|
||||
// Track ALL INTERRUPT input reports, not just 0x31. The mic stream
|
||||
// may live on a different report ID — confirmed 2026-05-19 that data[2]
|
||||
// bit 0 (and bit 1) is NOT a mic flag, just the report-type indicator;
|
||||
// every "mic-tagged" frame turned out to be standard input.
|
||||
if (channel == INTERRUPT && len > 1) {
|
||||
if (data[1] == 0x31) g_bt_31_packets++;
|
||||
else {
|
||||
g_bt_other_packets++;
|
||||
g_last_other_id = data[1];
|
||||
g_other_id_or = (uint8_t)(g_other_id_or | data[1]);
|
||||
for (uint16_t i = 0; i < 8 && i < len; i++) {
|
||||
g_last_other_prefix[i] = data[i];
|
||||
}
|
||||
}
|
||||
if (len > 2) {
|
||||
g_last_31_b2 = data[2];
|
||||
g_31_b2_or = (uint8_t)(g_31_b2_or | data[2]);
|
||||
}
|
||||
if (len < g_31_len_min) g_31_len_min = len;
|
||||
if (len > g_31_len_max) g_31_len_max = len;
|
||||
for (uint16_t i = 0; i < 16 && i < len; i++) {
|
||||
g_last_any_prefix[i] = data[i];
|
||||
}
|
||||
|
||||
// Capture the entire content of the longest 0x31 frame we've
|
||||
// seen. Long frames almost certainly carry the mic audio appended
|
||||
// after the standard 63-byte input report — this lets us look
|
||||
// at the trailing bytes directly via 0xFD diagnostic.
|
||||
if (data[1] == 0x31 && len > g_longest_len) {
|
||||
g_longest_len = len;
|
||||
for (uint16_t i = 0; i < 80 && i < len; i++) {
|
||||
g_longest_frame[i] = data[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mic-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)) {
|
||||
set_headset(data[56] & 1);
|
||||
@@ -114,12 +230,6 @@ void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We add the critical section here to avoid any race conditions when writing to the interrupt_in_data buffer,
|
||||
// which is shared between the main loop and this callback.
|
||||
// The critical section ensures that only one thread can access the buffer at a time,
|
||||
// preventing data corruption and ensuring thread safety.
|
||||
// We also set the report_dirty flag to true to indicate that new data is available
|
||||
// and needs to be sent in the next interrupt report.
|
||||
critical_section_enter_blocking(&report_cs);
|
||||
memcpy(interrupt_in_data, data + 3, 63);
|
||||
report_dirty = true;
|
||||
@@ -186,8 +296,19 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
|
||||
if (report_id == 0) {
|
||||
switch (buffer[0]) {
|
||||
case 0x02: {
|
||||
g_host_out02_total++;
|
||||
// valid_flag0 lives at buffer[1] (right after the 0x02 report id).
|
||||
// Bits 2 & 3 are AllowRight/LeftTriggerFFB.
|
||||
if (bufsize > 1 && (buffer[1] & 0x0C)) {
|
||||
g_host_out02_trig_allow++;
|
||||
}
|
||||
state_update(buffer + 1, bufsize - 1);
|
||||
if (spk_active) {
|
||||
// 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]{};
|
||||
@@ -200,6 +321,7 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
|
||||
// memcpy(outputData + 3, buffer + 1, bufsize - 1);
|
||||
state_set(outputData + 3,sizeof(SetStateData));
|
||||
bt_write(outputData, sizeof(outputData));
|
||||
g_host_out02_to_bt++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -264,6 +386,7 @@ int main() {
|
||||
critical_section_init(&report_cs);
|
||||
|
||||
config_load();
|
||||
remap_load();
|
||||
|
||||
bt_init();
|
||||
bt_register_data_callback(on_bt_data);
|
||||
@@ -281,6 +404,7 @@ int main() {
|
||||
watchdog_update();
|
||||
#endif
|
||||
cyw43_arch_poll();
|
||||
bt_connection_watchdog_tick();
|
||||
tud_task();
|
||||
audio_loop();
|
||||
interrupt_loop();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -6,6 +6,12 @@
|
||||
#include <cstring>
|
||||
|
||||
#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 {
|
||||
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
|
||||
@@ -19,7 +25,7 @@ namespace {
|
||||
static constexpr uint8_t state_init_data[63] = {
|
||||
0xfd, 0xf7, 0x0, 0x0,
|
||||
0x7f, 0x64, // Headphones, Speaker
|
||||
0xff, 0x9, 0x0, 0x0F, 0x0, 0x0, 0x0, 0x0,
|
||||
0x40, 0x9, 0x0, 0x00, 0x0, 0x0, 0x0, 0x0, // VolumeMic=64, MuteControl all clear (no PowerSave)
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa,
|
||||
@@ -41,6 +47,18 @@ void state_set(uint8_t *data, const uint8_t 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) {
|
||||
if (size < sizeof(SetStateData)) {
|
||||
printf(
|
||||
@@ -147,7 +165,7 @@ void state_update(const uint8_t *data, const uint8_t size) {
|
||||
sizeof(uint8_t)
|
||||
);
|
||||
copy_if_allowed(
|
||||
update.AllowLedColor,
|
||||
update.AllowLedColor && !g_lightbar_override,
|
||||
offsetof(SetStateData, LedRed),
|
||||
sizeof(update.LedRed) * 3
|
||||
);
|
||||
|
||||
@@ -5,8 +5,17 @@
|
||||
#ifndef DS5_BRIDGE_STATE_MGR_H
|
||||
#define DS5_BRIDGE_STATE_MGR_H
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
void state_init();
|
||||
void state_set(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
|
||||
|
||||