Compare commits
45
Commits
@@ -93,16 +93,11 @@ jobs:
|
|||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
cp build/standard/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-${{ github.event.release.tag_name }}.uf2"
|
cp build/standard/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-${{ github.event.release.tag_name }}.uf2"
|
||||||
|
|
||||||
- name: Build Debug firmware
|
# The debug build (ENABLE_SERIAL=ON) compiles out tud_connect/disconnect and
|
||||||
run: |
|
# comes up as a serial console rather than a working HID/audio bridge — a
|
||||||
cmake -S . -B build/debug -G Ninja \
|
# developer diagnostic, not an end-user UF2. It is NOT published as a release
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
# download (footgun) — build it on demand with -DENABLE_SERIAL=ON, or use the
|
||||||
-DPICO_SDK_PATH="$PICO_SDK_PATH" \
|
# PR-CI compile in build.yml. See CHANGELOG/CLAUDE.md.
|
||||||
-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"
|
|
||||||
|
|
||||||
- name: Compute UF2 checksums + append to release notes
|
- name: Compute UF2 checksums + append to release notes
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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.
|
||||||
+161
-1
@@ -6,7 +6,167 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.6.12-oled-edition] — 2026-06-07
|
||||||
|
|
||||||
|
> **Supersedes the withdrawn v0.6.11.** v0.6.11 bundled the two OLED quality-of-life features below with two regressions (constant haptics, issue #11; an audio retiming change, issue #12) and is not recommended. v0.6.12 is built on the verified-working native-trigger firmware and folds in **only** the two separable good features — `CtrlWake` and brightness persistence — leaving the trigger and audio code paths byte-identical to the tested build. The v0.6.11 regression changes (the `state_mgr.cpp` trigger-FFB allow-bit mirror and the `audio.cpp` resampler retiming) are intentionally **not** carried over.
|
||||||
|
|
||||||
|
Headline: **native DualSense adaptive triggers now fire in real PC games on Linux/Proton — through the dongle, 1:1 with a wired controller.** For weeks the triggers only ever worked in the on-dongle OLED self-test, never in a game: the dongle was *recognised*, but games fell back to a generic/Xbox pad and never sent trigger effects. The root cause was three ways the dongle's USB presentation differed from genuine Sony hardware — each one accepted by Linux's `hid_playstation` (which only checks size + CRC) but **rejected by a game's native DualSense detection, which validates the actual content**. Closing all three makes the dongle byte-for-byte indistinguishable from a real DS5 to the game.
|
||||||
|
|
||||||
|
**Host-side recipe (required alongside this firmware):** run the game on a Proton that carries the Wine `winebus.sys` **#9034** fix (Wine 11 / current Proton-GE — the bug suppresses the SDL gamepad device when a hidraw device exists for the same VID/PID, so the pad shows up everywhere *except* in-game) and **disable Steam Input** (native path). **No launch option is needed** — Wine 11 enables the hidraw native path by default. Works on **both Steam and Heroic** (on Heroic, fully quit Steam so it can't grab the pad, and keep any global `PROTON_PREFER_SDL` off). The game must natively support DualSense — XInput-only titles give rumble but no adaptive triggers. With a real DS5 this "just works"; the firmware changes below make the dongle match.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Native adaptive triggers & haptics in games on Linux/Proton, through the dongle.** Games with native DualSense support (Cyberpunk 2077, etc.) now drive the controller's adaptive triggers via the dongle, identical to a directly-wired DualSense — verified 1:1 by A/B against the same controller plugged in over USB-C. No Steam Input, no per-game hacks beyond the host recipe above. The firmware already proxies the host's trigger output reports to the controller (unchanged); the missing piece was getting games to *recognise* the dongle as a genuine DS5 in the first place (below).
|
||||||
|
- **`CtrlWake` setting — let the OLED sleep while you play.** New Settings toggle (default **on**, preserving the old behaviour). With it **off**, controller input no longer keeps the panel awake — only the OLED's own KEY0/KEY1 do — so the auto-dim / auto-off timers actually count down during gameplay and the screen can sleep while the controller is in active use. (folded from v0.6.11; issues #8/#9)
|
||||||
|
- **OLED brightness now persists across a power cycle.** The KEY1-long-press brightness choice is saved to config and restored on boot, instead of resetting to full every power-on. (folded from v0.6.11; issue #9)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The DualSense HID report descriptor is now byte-identical to a real DS5 (289 bytes, was 321).** The OLED Edition declared four extra feature reports — `0xF6`–`0xF9`, the WebHID config/remap IDs — inside the gamepad collection, making the descriptor 32 bytes longer than genuine hardware. Games' native DualSense parser rejects that mismatch and falls back to a generic pad (no triggers); `hid_playstation` didn't care, which is why the OLED self-test always worked but games never did. The four declarations are removed (plus the runtime `wDescriptorLength` patch that overrode the static array, `0x41`→`0x21`). The firmware **still handles** `0xF6`–`0xF9`; they're simply undeclared now and work over Linux `hidraw` exactly like the existing `0xFD` diagnostic report.
|
||||||
|
- **Feature reports `0x09` (pairing), `0x20` (firmware) and `0x05` (calibration) now return the controller's real cached data, not zeros.** The earlier native-recognition fix answered these with zeros + a valid CRC — enough for `hid_playstation` to bind, but games validate the *content*: Cyberpunk GET-read `0x20`/`0x09` ~156× in a retry storm rejecting the zeros, never completed the DualSense handshake, and showed no controller. The dongle already fetches and caches the genuine reports from the connected controller over BT (`init_feature()`); `tud_hid_get_report_cb` now serves that cache (real firmware string / pairing / calibration), keeping the CRC-valid synthetic stub only as a fallback for the USB-enumeration probe before the BT link is up (so `hid_playstation` still binds then). Builds on the `hid_playstation` binding fix and the serial-=-MAC identity fix.
|
||||||
|
|
||||||
|
### Known tradeoff
|
||||||
|
|
||||||
|
- **The browser (WebHID) Config and Remap tabs no longer reach the dongle.** They drove config over reports `0xF6`/`0xF7`, which are no longer *declared* in the HID descriptor — and WebHID refuses undeclared report IDs (declaring them is exactly what broke game triggers). Mitigations, no firmware change needed: **on-dongle OLED config still works**, and because undeclared IDs are fine over Linux `hidraw`, a small `hidraw` CLI/script can read & write all of `0xF6`–`0xF9` (the same mechanism `scripts/mic_diag.sh` uses for `0xFD`). Restoring the *browser* tool would require moving the custom reports into a separate vendor HID collection — which risks the descriptor diverging from a real DS5 again, re-breaking triggers, so it's deliberately not done here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.10-oled-edition] — 2026-05-25
|
||||||
|
|
||||||
|
Headline: the **Gyro Tilt screen** is now actually usable — it applies the controller's per-unit factory **IMU calibration**, the dot **centres when the controller lies flat**, and tilt **tracks the direction** you move it. Also adds an **L3 / R3 stick-click indicator** on the Status screen and makes the **charge-ETA** robust so it no longer over-reports off a single slow charge step. Everything here is on-dongle display only — **what games receive is unchanged** (the full gyro/accel stream is still forwarded byte-for-byte). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.10-oled-edition) (built by `.github/workflows/release.yml`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **L3 / R3 click indicator on the OLED Status screen.** Clicking a stick in now flashes its analog-stick box inverse (white box, black dot) for as long as it's held — previously the stick clicks had no on-screen feedback. Mirrored in the web config tool's OLED Preview.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Gyro Tilt screen reworked: per-unit IMU calibration, a tilt dot that centres when flat, and intuitive direction.** Three things, all display-only — the host input report (all gyro + accel axes) is still forwarded byte-for-byte, so in-game motion is unaffected:
|
||||||
|
- **Calibration.** The DualSense ships factory gyro/accel bias + sensitivity in feature report `0x05` (which the dongle already fetches and caches at connect). Previously the tilt visuals used raw accel counts; now the cached `0x05` is parsed once per connection (re-read per controller, so it stays correct across the 4 pairing slots) and applied as `(raw − bias) × sensitivity`, keeping the same ±8192 ≈ 1 g scale. A bad/short read is rejected (same sanity gate SDL uses) and it falls back to raw — no regression when calibration is unavailable. The tilt→RGB lightbar mode uses the corrected accel too. Parse/apply mirror SDL's `SDL_hidapi_ps5.c` (zlib-licensed; credit).
|
||||||
|
- **Centred when flat.** The dot is now driven by the X (roll) and **Z** (pitch) axes — the two that read ~0 when the controller lies flat — instead of X/Y. Gravity rests on Y when flat, so the old Y mapping pegged the dot to the bottom edge at rest; it now sits centred.
|
||||||
|
- **Direction follows the controller.** Both axes are negated so tilting left moves the dot left and tilting forward moves it up, instead of mirrored.
|
||||||
|
- The dot centring + direction are mirrored in the web config tool's OLED Preview (its mock IMU now also rests gravity on Y to match real hardware).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Charge-ETA no longer balloons off a single slow 10% step.** The Status-screen `~Nm` charge estimate timed each 10% battery step and projected the rest, but one anomalously slow step (e.g. ~47 min — observed reading `~222m` at 70% on the dock) used to drag the whole projection up because the rate was a mean over only 3 steps. Each timed step's bulk-equivalent is now clamped to a 30-min ceiling and the rate is taken as the **median** over the last 5 steps, so a single under-load/anomalous reading can't dominate. Mirrored in the web OLED Preview emulator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ The development cadence is **one feature per UF2 + checkpoint with the user befo
|
|||||||
|
|
||||||
**OLED add-on is optional and self-contained:** All 10 screens, the SH1107 SPI driver, the 5×7 font, the icon table, and the input handling live in `src/oled.cpp` (~30 KB). The only outward dependencies are read-only accessors (`bt_is_connected`, `bt_get_addr`, `audio_peak_*`, `bt_get_signal_strength`, the new slot accessors) and the global `interrupt_in_data[63]` (read-only for input visualization). When no OLED is wired, the SPI writes go nowhere — no init check needed.
|
**OLED add-on is optional and self-contained:** All 10 screens, the SH1107 SPI driver, the 5×7 font, the icon table, and the input handling live in `src/oled.cpp` (~30 KB). The only outward dependencies are read-only accessors (`bt_is_connected`, `bt_get_addr`, `audio_peak_*`, `bt_get_signal_strength`, the new slot accessors) and the global `interrupt_in_data[63]` (read-only for input visualization). When no OLED is wired, the SPI writes go nowhere — no init check needed.
|
||||||
|
|
||||||
|
**Idle power ladder (`oled_loop` tail):** Three-stage state machine (`OLED_ACTIVE` → `OLED_DIM` → `OLED_OFF`) driven by `last_activity_us`. `kAutoDimUs = 2 min` enters Dim — the regular per-screen render is replaced with `render_dim_pulse()`, which clears the framebuffer and walks a 2×2 dot through 8 positions every 30 s, blinking 1 s on / 1 s off. `kAutoOffUs = 15 min` enters Off — `cmd(0xAE)` puts the SH1107 to sleep and `oled_loop` returns early before rendering. Wakes on KEY0/KEY1 (via `handle_buttons` bumping `last_activity_us`), `bt_is_connected()` rising edge, or any change in the `interrupt_in_data[0..9]` hash. The dim tier uses `flush_fb_raw()` (the chrome-less variant) since there's no nav target while asleep. **Why this shape:** the SH1107 contrast register has a heavily non-linear perceptual curve on the Waveshare panel — even `0x02` looks ~90 % as bright as `0xFF`. The only reliable "dim" available is rendering fewer pixels.
|
||||||
|
|
||||||
**Multi-slot pairing (Phase G):** Storage is two-tier:
|
**Multi-slot pairing (Phase G):** Storage is two-tier:
|
||||||
|
|
||||||
- **Link keys** stay in BTstack's TLV NVM (4 slots, unchanged from upstream).
|
- **Link keys** stay in BTstack's TLV NVM (4 slots, unchanged from upstream).
|
||||||
@@ -95,6 +97,35 @@ 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.
|
- **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`).
|
- **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`).
|
||||||
|
|
||||||
|
- **The DualSense HID descriptor must stay byte-identical to a real DS5 (289 bytes), and `0x09`/`0x20`/`0x05` feature reports must return the controller's *real* cached data — or native game triggers silently break.** A game's native DualSense detection (Cyberpunk, etc.) validates *both* the descriptor shape and the feature-report *content*, then drives adaptive triggers over the native hidraw path. If either differs from genuine hardware the game falls back to a generic/Xbox pad and triggers never fire. Linux's `hid_playstation` is far more lenient (size + CRC only), so the dongle still *binds* and the on-dongle OLED trigger-test still works — which masked this break for weeks. Two load-bearing things: (1) **don't re-declare config reports `0xF6`–`0xF9`** in `desc_hid_report_ds` (or bump its `wDescriptorLength` runtime patch in `tud_descriptor_configuration_cb` back to `0x41`) — that's what made the descriptor 321 bytes and killed triggers; (2) keep `tud_hid_get_report_cb` serving the **real `init_feature()` cache** for `0x09`/`0x20`/`0x05`, with the CRC-valid synthetic stub only as the pre-BT-link probe fallback. `desc_hid_report_dse` (DSE mode) still declares F6-F9 and needs the same cut. Host side requires a Proton with the Wine `winebus.sys` #9034 fix + `PROTON_ENABLE_HIDRAW=0x054c/0x0ce6` + Steam Input off; diff a dongle vs real-DS5 `PROTON_LOG=+hid` capture to debug (a GET retry storm on `0x20`/`0x09` = the dongle's feature content is being rejected).
|
||||||
|
|
||||||
|
## 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
|
## Git / branch model
|
||||||
|
|
||||||
- **`master` (origin)** = `MarcelineVPQ/DS5Dongle-OLED-Edition` (this fork's primary branding, what users download).
|
- **`master` (origin)** = `MarcelineVPQ/DS5Dongle-OLED-Edition` (this fork's primary branding, what users download).
|
||||||
@@ -109,6 +140,8 @@ When asked to modify behavior, the *first* file to read is usually one of:
|
|||||||
|
|
||||||
- New BT pairing / connection state behavior → `src/bt.cpp` (HCI + L2CAP event handlers).
|
- New BT pairing / connection state behavior → `src/bt.cpp` (HCI + L2CAP event handlers).
|
||||||
- New OLED screen or change to existing one → `src/oled.cpp`.
|
- New OLED screen or change to existing one → `src/oled.cpp`.
|
||||||
|
- New diagnostic counter on the OLED Diagnostics screen → bump `kNumDiagRows` in `src/oled.cpp` and add a `case` to `format_diag_row()` (single switch, one row per case). The screen scrolls automatically; no D-pad wiring needed. Counters that need rate-per-second arithmetic should be sampled in `sample_diag_rates()` and read from `g_diag_rates`. Counter globals themselves typically live in `src/main.cpp` next to `g_bt_31_packets` etc. with `extern` declarations near the top of `src/oled.cpp`.
|
||||||
|
- Host-side diagnostics → `scripts/mic_diag.sh` (Linux only, reads `/dev/hidraw` directly). Subcommands: `status` / `capture [secs]` / `watch` / `bt-trace`. `bt-trace` reads the firmware's `0xFD` vendor feature report (defined in `src/cmd.cpp`'s `tud_hid_get_report_cb`), which currently exposes BT-input counters + the host-output trigger-flow counters. To add a new counter visible to `bt-trace`: extend the `0xFD` payload in `src/cmd.cpp` (bump `want`, write at the new offset), bump `IOCTL_SIZE` in `bt_trace()` of the script, and add the field to its `decode()` dict. The `0xFD` report ID is not declared in the HID descriptor (Linux hidraw ioctls don't enforce that; WebHID would reject undeclared IDs, which is why config goes through `0xF6`).
|
||||||
- New persistent config field → `src/config.h` (struct), `src/config.cpp:config_valid()` (defaults + clamping), `src/oled.cpp:format_settings_item()` (UI), `src/oled.cpp:settings_adjust()` (D-pad ▶◀ behavior). Update `CHANGELOG.md`.
|
- New persistent config field → `src/config.h` (struct), `src/config.cpp:config_valid()` (defaults + clamping), `src/oled.cpp:format_settings_item()` (UI), `src/oled.cpp:settings_adjust()` (D-pad ▶◀ behavior). Update `CHANGELOG.md`.
|
||||||
- USB descriptor or interface change → `src/usb_descriptors.cpp` + `src/tusb_config.h`.
|
- USB descriptor or interface change → `src/usb_descriptors.cpp` + `src/tusb_config.h`.
|
||||||
- Audio / haptic path → `src/audio.cpp`. **Don't** add stack arrays sized smaller than the resampler / Opus expects (this is the C1 bug that caused the long-standing "audio stuttering" issue — fix landed in upstream `5b04cbd`, but the lesson stands).
|
- Audio / haptic path → `src/audio.cpp`. **Don't** add stack arrays sized smaller than the resampler / Opus expects (this is the C1 bug that caused the long-standing "audio stuttering" issue — fix landed in upstream `5b04cbd`, but the lesson stands).
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ add_executable(ds5-bridge
|
|||||||
src/state_mgr.cpp
|
src/state_mgr.cpp
|
||||||
src/oled.cpp
|
src/oled.cpp
|
||||||
src/slots.cpp
|
src/slots.cpp
|
||||||
|
src/remap.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
if (ENABLE_BATT_LED)
|
if (ENABLE_BATT_LED)
|
||||||
@@ -146,6 +147,14 @@ if(WAKE_DEBUG)
|
|||||||
endif()
|
endif()
|
||||||
set(VERSION "dev" CACHE STRING "Program version string")
|
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")
|
set_target_properties(ds5-bridge PROPERTIES OUTPUT_NAME "ds5-bridge-oled")
|
||||||
pico_set_program_name(ds5-bridge "ds5-bridge-oled")
|
pico_set_program_name(ds5-bridge "ds5-bridge-oled")
|
||||||
pico_set_program_version(ds5-bridge "${VERSION}")
|
pico_set_program_version(ds5-bridge "${VERSION}")
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# DualSense adaptive-trigger games — owned + tested through the dongle
|
||||||
|
|
||||||
|
Cross-referenced across Steam + Epic + GOG (~1,271 owned games). Native adaptive triggers work
|
||||||
|
on **both Steam and Heroic**, flag-free, with **runner = GE-Proton-DualSense (Wine 11)**. Verified
|
||||||
|
2026-06-07 by running Cyberpunk on *both* launchers — identical trigger behaviour.
|
||||||
|
|
||||||
|
- **Steam:** GE-Proton-DualSense + **Steam Input off**. No launch option needed (Wine 11 enables
|
||||||
|
the hidraw native path by default).
|
||||||
|
- **Heroic (Epic / GOG):** GE-Proton-DualSense + **Steam fully quit** + **no `PROTON_PREFER_SDL`**.
|
||||||
|
No launch option needed. (Earlier belief that Heroic needs `PROTON_ENABLE_HIDRAW` was wrong —
|
||||||
|
it was masked by the two gotchas below.)
|
||||||
|
|
||||||
|
### ⚠️ Linux gotchas that masquerade as "the dongle is broken" (all host-side)
|
||||||
|
|
||||||
|
- **Steam running in the background** grabs the pad from non-Steam (Heroic) games. Fully quit Steam,
|
||||||
|
or disable Settings → Controller → PlayStation controller support.
|
||||||
|
- **A global `PROTON_PREFER_SDL=1`** forces the SDL/Xbox path and *suppresses* native triggers. Keep
|
||||||
|
it off (or per-game only). It's how you get a generic Xbox pad when a game lacks native DualSense.
|
||||||
|
- **XInput-only games can't do adaptive triggers — period.** XInput has no trigger-resistance API, so
|
||||||
|
titles that read the pad via XInput (e.g. Ghostrunner, Control on PC) give rumble only, on any OS,
|
||||||
|
through any tool. Not a dongle limit.
|
||||||
|
|
||||||
|
Legend: ✅ = confirmed working **flag-free** through the dongle (this session, 2026-06-07).
|
||||||
|
|
||||||
|
## 🎯 Full adaptive triggers
|
||||||
|
|
||||||
|
| Game | Where you own it | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Cyberpunk 2077 | Steam · GOG | ✅ tested flag-free — **confirmed on BOTH Steam and Heroic/GOG** |
|
||||||
|
| The Last of Us Part I | Steam | ✅ tested flag-free |
|
||||||
|
| Marvel's Spider-Man Remastered | Steam | ✅ tested flag-free |
|
||||||
|
| Uncharted: Legacy of Thieves Collection | Steam | ✅ tested flag-free |
|
||||||
|
| Ghost of Tsushima Director's Cut | Steam | recognized — bow draw 🏹 (earlier) |
|
||||||
|
| Hogwarts Legacy | Steam · Epic | ✅ tested flag-free (native recog + rumble; triggers moderate, in spellcasting) |
|
||||||
|
| Ghostrunner | Epic · GOG | ⚠️ XInput-only on PC — rumble, **no adaptive triggers** (not the dongle) |
|
||||||
|
| Avatar: Frontiers of Pandora | Steam | ✅ tested flag-free |
|
||||||
|
| Assassin's Creed Shadows | Steam | |
|
||||||
|
| Indiana Jones and the Great Circle | Steam | ✅ tested flag-free |
|
||||||
|
| Star Wars Jedi: Fallen Order | Steam | |
|
||||||
|
| LEGO Star Wars: The Skywalker Saga | Steam | |
|
||||||
|
| Metro Exodus (Enhanced) | Steam | |
|
||||||
|
| Marvel's Guardians of the Galaxy | Epic | |
|
||||||
|
| F.I.S.T.: Forged In Shadow Torch | Epic | |
|
||||||
|
| Dakar Desert Rally | Epic | racing-trigger feel |
|
||||||
|
|
||||||
|
## 〰️ Lighter / haptics-leaning (subtle trigger use)
|
||||||
|
|
||||||
|
- Baldur's Gate 3 — Steam — ✅ great experience; haptics-forward, light trigger use (dice rolls / ranged)
|
||||||
|
- Sifu — Epic — haptics only; combat is O/Triangle face-buttons, **no adaptive triggers** (not a trigger test)
|
||||||
|
- The Witcher 3: Wild Hunt (next-gen) — Steam · GOG
|
||||||
|
- Control Ultimate Edition — GOG — XInput-only on PC (rumble, no adaptive triggers)
|
||||||
|
- Forza Horizon 5 — Steam
|
||||||
|
- Hellblade: Senua's Sacrifice — Steam
|
||||||
|
- Star Wars: Squadrons — Epic
|
||||||
|
- Maneater — Epic
|
||||||
|
|
||||||
|
**Caveats:** matched the *well-known* trigger titles across a 600+ game library — there may be a
|
||||||
|
few more not flagged. ~18 games with real triggers, all owned, all working through the dongle.
|
||||||
+388
-37
@@ -1,53 +1,404 @@
|
|||||||
# Pico2W DualSense 5 Bridge
|
# Pico2W DualSense 5 Bridge — OLED Edition
|
||||||
|
|
||||||
[English](./README.md)
|
[English](./README.md)
|
||||||
> 将 Pico2W 变成 DS5 手柄的无线适配器
|
|
||||||
|
|
||||||
# 功能特点
|
> 将 Raspberry Pi Pico2W 变成 DualSense (DS5) 手柄的无线适配器 —— 并可选配板载状态显示屏。
|
||||||
- 支持HD震动
|
|
||||||
|
|
||||||
# 使用方法
|
> **OLED 版**是 **[awalol/DS5Dongle](https://github.com/awalol/DS5Dongle)**(上游)的一个分支,增加了可选的 Pico-OLED-1.3 128×64 显示屏插件,提供 11 个屏幕(状态、4 槽多手柄配对、带收藏与特效预设的灯条调色器、扳机测试、陀螺仪倾斜、触摸板、诊断、CPU/时钟、蓝牙信号强度、音频 VU 表,以及一个持久化设置菜单),外加 DS5 按键组合软重启。核心桥接固件以上游为权威来源;本分支跟踪上游并在其之上叠加插件功能。
|
||||||
1. 按住 Pico 上的BOOTSEL进入刷机
|
|
||||||
2. 将 .uf2 文件拖入进去
|
|
||||||
3. 将 DS5 手柄进入蓝牙配对模式
|
|
||||||
4. Enjoy it
|
|
||||||
|
|
||||||
***你可能需要在控制器处于匹配模式时重新插拔 pico***
|
---
|
||||||
|
|
||||||
- 手柄连接到pico以后,系统才会显示设备
|
## 🛠️ 网页配置工具
|
||||||
|
|
||||||
# Pico 配置调整
|
**[→ 打开 OLED 版网页配置](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/#config)**
|
||||||
你可以通过网页调整Pico的内部设置
|
|
||||||
|
|
||||||
- 用于正式固件: https://ds5.awalol.eu.org
|
网页工具是一站式方案 —— **无需安装、无需命令行、无需 `picotool`**。一块全新的 Pico 2 W 可以全程在浏览器里从"刚开箱"做到完整刷写并配置完成:
|
||||||
- 用于测试固件: https://ds5-dev.awalol.eu.org
|
|
||||||
|
|
||||||
### Pico W 版本
|
- **刷写固件标签页** —— 让 Pico 进入 **BOOTSEL 模式**,然后在浏览器中点击 *Connect to Pico*,再点 *Flash now*。站点会捆绑最新发布的 UF2,你也可以载入自己编译的本地 `.uf2`。基于 WebUSB。
|
||||||
|
|
||||||
Pico W 由于性能问题,只能支持震动,不支持扬声器。
|
> **什么是 BOOTSEL 模式?** 这是 Pico 内置的刷写模式。进入方法:按住 Pico 上标有 **BOOTSEL** 的白色小按钮,*然后*插入 USB 线(若已插好,则在按住 BOOTSEL 的同时短暂拔插一次)。Pico 会作为可移动磁盘出现在电脑上 —— 看到它就说明已进入 BOOTSEL 模式。网页工具刷完固件后,Pico 会自动重启进入正常模式即可使用。
|
||||||
你可以通过开启 `-DPICO_W_BUILD=ON` 编译项去开启 Pico W 固件编译,或者在 Github Action 下载预编译的固件
|
- **配置标签页** —— 设备刷好并重新连接后,可编辑振动增益、扬声器音量、轮询率、音频自动触感模式以及其余持久化设置;一键保存到设备闪存。基于 WebHID。
|
||||||
|
- **重映射标签页** —— 可视化按键重映射器:在实时 DualSense 示意图上点击某个按键即可把它重新指派为任意其他按键(肩键/扳机会以带标签的图标浮动到角落)。映射保存在设备上并在主机看到报告之前应用,因此在任何游戏、任何操作系统中都生效。基于 WebHID。
|
||||||
|
- **OLED 预览标签页** —— 像素级精确模拟全部 11 个 OLED 屏幕。用页面内的 KEY0/KEY1 按钮(或在 DualSense 配对时用手柄的 △ / R1 / 方向键)来导航。循环切换扳机测试预设时,自适应扳机会在手柄上真实触发。
|
||||||
|
|
||||||
### USB 唤醒支持
|
可在任何基于 Chromium 的浏览器中使用(Chrome、Edge、Brave、Opera)。Firefox 与 Safari 不提供 WebHID 或 WebUSB,因此那里无法刷写和实时配置 —— 但 OLED 预览仍会用模拟数据渲染。
|
||||||
这是一项实验性的功能。如果你需要该功能,请前往 feat/usb-wake 分支进行编译,或者使用该分支对应的 Github Action 预编译固件。`ds5-bridge-wake.uf2` 为该功能的固件
|
|
||||||
|
|
||||||
极为建议在使用该功能前阅读 #60 和 #61
|
> 网页工具源码:**[MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)**([awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web) 的分支)。
|
||||||
|
|
||||||
### 社区分支
|
---
|
||||||
https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition
|
|
||||||
https://github.com/zurce/DS5Dongle-OLED
|
|
||||||
|
|
||||||
# 当前问题:
|
## 概述
|
||||||
- 声音可能有点小卡顿
|
|
||||||
- 由于编码需要,需要对pico进行超频,当前的参数是1.2V 320MHz。
|
|
||||||
- 若您的pico使用该超频参数无法启动,请自行增加电压或者降低频率
|
|
||||||
|
|
||||||
# 未来计划
|
本项目让 Raspberry Pi Pico2W 作为 DualSense 手柄的蓝牙桥接器,实现无线连接并增强触感支持。
|
||||||
请查看[DS5Dongle plan](https://github.com/users/awalol/projects/5)
|
|
||||||
|
|
||||||
# 编译
|
## 功能特点
|
||||||
需要将pico sdk里面的tinyusb版本升级到最新
|
|
||||||
|
|
||||||
# 致谢
|
**核心桥接(来自上游):**
|
||||||
- [rafaelvaloto/Pico_W-Dualsense](https://github.com/rafaelvaloto/Pico_W-Dualsense) - 灵感来源
|
|
||||||
- [egormanga/SAxense](https://github.com/egormanga/SAxense) - 震动报文
|
- 通过 Pico2W 完整连接 DualSense
|
||||||
- [https://controllers.fandom.com/wiki/Sony_DualSense](https://controllers.fandom.com/wiki/Sony_DualSense) - 数据报文结构
|
- HD 触感(高级振动反馈)
|
||||||
- [Paliverse/DualSenseX](https://github.com/Paliverse/DualSenseX) - 扬声器数据包报文
|
- 无线蓝牙桥接
|
||||||
|
- 通过麦克风音量调节触感增益
|
||||||
|
- 可配置的 LED 与断连行为
|
||||||
|
|
||||||
|
**OLED 版新增:**
|
||||||
|
|
||||||
|
- 可选的 Pico-OLED-1.3 状态显示屏,含 **11 个屏幕**(状态、配对槽、灯条、扳机测试、陀螺仪倾斜、触摸板、诊断、CPU/时钟、RSSI、VU 表、设置)
|
||||||
|
- **按键重映射** —— 把 16 个数字控件(面板按键、方向键、肩键/扳机、摇杆按下、Create/Options)中的任意一个重新指派为其他按键。映射保存在设备上并在主机看到报告之前应用,因此在**任何游戏和操作系统中都无需任何主机端软件**即可生效;默认为恒等映射(无重映射)。可在[网页配置工具](#️-网页配置工具)的**重映射标签页**中可视化编辑,或通过 `scripts/remap_test.py` 无界面编辑。保存在专属闪存扇区,重启后保留。
|
||||||
|
- **4 槽持久化多手柄配对** —— 可绑定至多四个 DualSense,在 OLED 上切换,开机时自动重连槽 0
|
||||||
|
- **灯条调色器**,含 4 个用户收藏槽 + 呼吸 / 彩虹 / 渐变特效预设
|
||||||
|
- **持久化设置菜单**,涵盖 8 个固件配置字段(振动增益、扬声器音量、轮询率等),并提供按住确认的"重置"与"清除全部槽"操作
|
||||||
|
- **OLED 空闲省电阶梯** —— 手动亮度循环(长按 KEY1)、空闲 2 分钟自动深度变暗并显示一个呼吸小点、空闲 15 分钟完全熄屏。按键、手柄配对或输入时立即唤醒。是真正的防烧屏,而不只是调对比度。
|
||||||
|
- **软重启**,无需拔 USB:长按 DS5 的 `PS + Mute`(可无界面工作),或在 OLED 插件上**同时按住 KEY0 + KEY1 持续 1 秒**(取代了旧的 KEY0 双击手势 —— 快速翻页时容易误触发)
|
||||||
|
- **核心桥接的审计修复** —— 修复音频路径中关键的栈溢出(解决长期存在的"音频卡顿")、安全加固、看门狗、HID/L2CAP 边界处的长度校验(见 [CHANGELOG.md](./CHANGELOG.md))
|
||||||
|
|
||||||
|
## 硬件
|
||||||
|
|
||||||
|
### 必需
|
||||||
|
|
||||||
|
| 物品 | 说明 | 大致价格 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Raspberry Pi Pico 2 W** | 搭载 RP2350 MCU,板载 CYW43 蓝牙/WiFi。[官方产品页](https://www.raspberrypi.com/products/raspberry-pi-pico-2/) | 约 $7 USD |
|
||||||
|
| **索尼 DualSense 手柄** | 任何标准 PS5 DualSense(VID `054C:0CE6`)。 | — |
|
||||||
|
| **USB-C 线缆** | 将 Pico 2 W 连接到主机 PC。 | — |
|
||||||
|
|
||||||
|
### 可选(强烈推荐)
|
||||||
|
|
||||||
|
| 物品 | 说明 | 大致价格 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Waveshare Pico-OLED-1.3** | 128×64 SH1107 OLED 插件板(SKU HIPI1798)。直接插到 Pico 2 W 排针上。固件检测到时自动驱动,缺失时优雅地不做任何动作。[产品页](https://www.waveshare.com/pico-oled-1.3.htm) · [Wiki](https://www.waveshare.com/wiki/Pico-OLED-1.3) | 约 $6 USD |
|
||||||
|
| **小散热片**(用于 RP2350) | 固件将 MCU 超频到 320 MHz @ 1.20 V(见[性能 / 超频](#性能--超频))。持续游玩时小散热片或导热垫有帮助。 | $1–3 USD |
|
||||||
|
|
||||||
|
### 哪里购买
|
||||||
|
|
||||||
|
Pico 2 W 与 Waveshare Pico-OLED-1.3 在全球都很容易买到:
|
||||||
|
|
||||||
|
- **Adafruit**、**Pimoroni**、**The Pi Hut**、**DigiKey**、**Mouser** —— 主要电子元件分销商(美/欧)
|
||||||
|
- **Waveshare 自营商店** 购买 OLED 插件
|
||||||
|
- 各地区 **Amazon** 店面 —— 搜索 `Raspberry Pi Pico 2 W` 与 `Waveshare Pico-OLED-1.3`(或 SKU `HIPI1798`)
|
||||||
|
- **AliExpress(速卖通)** —— 原装 Waveshare 与 Pico 货源以及克隆品;注意查看卖家评分
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 刷写固件
|
||||||
|
|
||||||
|
1. 按住 Pico2W 上的 BOOTSEL 按钮
|
||||||
|
2. 通过 USB 将 Pico2W 连接到电脑
|
||||||
|
3. 设备会挂载为 USB 存储设备
|
||||||
|
4. 将 .uf2 固件文件拖放到该设备上
|
||||||
|
|
||||||
|
### 配对手柄
|
||||||
|
|
||||||
|
1. 让 DualSense 手柄进入蓝牙配对模式
|
||||||
|
2. 等待 Pico2W 检测并连接
|
||||||
|
3. 连接后,设备会出现在主机系统中
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
有四种方式配置固件:
|
||||||
|
|
||||||
|
**网页配置(推荐,任何基于 Chromium 的浏览器):** 在 Chrome、Edge、Vivaldi、Brave 或 Opera 中打开 **[DS5 Bridge Config — OLED Edition](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/)**(不支持 Firefox —— Mozilla 拒绝实现 WebHID)。点击 **Connect**,在浏览器对话框中选择 DualSense,然后用熟悉的表单界面编辑任意字段。页面通过 WebHID 直接与 Pico 通信 —— 无驱动、无安装,数据不离开你的机器。源码见 [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)。
|
||||||
|
|
||||||
|
**设备端(已装 OLED 插件):** 使用屏上的**设置**菜单(第 11 个屏幕)。方向键 ▲▼ 移动选择,▶◀ 调整数值,△ 保存到闪存。
|
||||||
|
|
||||||
|
**终端 CLI(任何系统、任何浏览器):** 安装 hidapi,然后使用 `scripts/set_ds5.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install hidapi
|
||||||
|
scripts/set_ds5.py # 显示当前配置
|
||||||
|
scripts/set_ds5.py --auto-haptics fallback # 修改某字段并持久化到闪存
|
||||||
|
scripts/set_ds5.py --speaker-volume -10 --haptics-gain 1.5
|
||||||
|
scripts/set_ds5.py --slot 2 # 切换活动多槽配对
|
||||||
|
scripts/set_ds5.py --version # 固件版本
|
||||||
|
scripts/set_ds5.py --rssi # 实时蓝牙 RSSI(dBm)
|
||||||
|
scripts/set_ds5.py --help # 完整参数列表
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本通过 USB HID 特性报告 `0xF6`/`0xF7`/`0xF8`/`0xF9` 与固件通信 —— 在 Linux、macOS、Windows 的任何终端下都可用,与你用哪个浏览器无关。移植自 [loteran/DS5Dongle](https://github.com/loteran/DS5Dongle) 并为本分支的 `current_slot` 字段做了扩展。
|
||||||
|
|
||||||
|
**DualSense 手柄按键(旧式后备方案,无 OLED、无 CLI):**
|
||||||
|
|
||||||
|
### 麦克风音量
|
||||||
|
|
||||||
|
控制触感增益倍率。范围:`[1.0 – 2.0]`。
|
||||||
|
|
||||||
|
### 扬声器静音
|
||||||
|
|
||||||
|
禁用 LED 连接指示灯。手柄重连后生效。
|
||||||
|
|
||||||
|
### 麦克风静音
|
||||||
|
|
||||||
|
禁用静默断连行为。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
只有在手柄连接之后,Pico 设备才会对系统可见
|
||||||
|
|
||||||
|
某些行为需要经过重连周期才会生效
|
||||||
|
|
||||||
|
### 低电量 LED 指示
|
||||||
|
|
||||||
|
当所连 DualSense 报告其电量等于或低于 10%(且未在充电)时,Pico 板载 LED 会从常亮切换为 1 Hz 闪烁,让你一眼看到警告。一旦手柄插上充电或其报告电量回升,LED 恢复常亮。即使设置了 `disable_pico_led`,该闪烁也会触发 —— 该警告被视为关键,会覆盖 LED 关闭偏好;电量恢复或手柄开始充电后,LED 回到其禁用(熄灭)状态。
|
||||||
|
|
||||||
|
如需在编译期退出此功能,用 `-DENABLE_BATT_LED=OFF` 配置。默认为 ON。
|
||||||
|
|
||||||
|
## 蓝牙 DualSense 麦克风
|
||||||
|
|
||||||
|
**DualSense 的内置麦克风可通过适配器的蓝牙配对工作**(自 v0.6.8 起)。手柄将麦克风以 Opus 音频流式传输;适配器解码后作为标准 DualSense USB 采集设备呈现给主机,因此任何应用(Discord、OBS、游戏内语音)都能像普通麦克风一样使用它。
|
||||||
|
|
||||||
|
这一度被认为不可能实现 —— 本分支早期版本曾将其记录为索尼固件的硬性限制(Linux 的 `hid-playstation` 内核驱动至今仍不支持)。结果发现它取决于适配器外发音频报告中的一个使能位。**完全归功于 [awalol](https://github.com/awalol/DS5Dongle)(上游)发现了它。** 更正后的调查记录见 [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md)。
|
||||||
|
|
||||||
|
**使用方法:**
|
||||||
|
|
||||||
|
- **默认开启。** 配对手柄后一两秒内麦克风便开始流式传输 —— 无需游戏音频(适配器会自行保活该流)。
|
||||||
|
- **在主机端调高采集音量** —— 它默认很低。在 Linux 上,用 `arecord -l` 找到声卡,然后例如 `amixer -c <DualSense 声卡> sset 'Headset' 90%`。用 `scripts/mic_diag.sh capture` 验证采集。
|
||||||
|
- **关闭它以节省手柄电量** —— OLED **设置 → BT Mic**,或[网页配置工具](#️-网页配置工具)中的 **BT microphone** 开关。常开麦克风会让 DS5 的音频子系统保持唤醒,明显加快耗电,所以不用语音时请关闭。
|
||||||
|
|
||||||
|
**注意点:**
|
||||||
|
|
||||||
|
- 麦克风音频为**单声道**(解码为单声道,在立体声采集端点上复制)。
|
||||||
|
- 会话中途关闭会立即停止主机端的馈送,但手柄会持续流式传输直到下次重连(目前没有已知的"停止"命令);以关闭状态全新连接则永远不会启用它。
|
||||||
|
- OLED **诊断**屏幕的 `Mic in:` 计数器在麦克风流式传输时约为 ~100/s —— 是确认它在工作的快捷方式。
|
||||||
|
- **丢包隐藏:** 丢失的麦克风帧(蓝牙链路弱、距离远、干扰)会用 Opus PLC 隐藏,使语音保持连续而不卡顿/中断,代价是少量抖动缓冲延迟(~30 ms)。诊断屏的 `Mic PLC:` 计数器仅在隐藏帧时增长 —— 实质上是一个实时链路质量计。
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
- 超频到 320 MHz @ 1.20 V 是稳定蓝牙配对所**必需**的。把电压降到 1.10 V 或时钟降回默认会破坏 CYW43 PIO SPI 总线,蓝牙将停止工作。持续游玩时建议在 RP2350 上加小散热片。
|
||||||
|
- 在 Linux + Steam 上,HD 触感未必在每个游戏中都触发;这是游戏侧问题(部分作品仅在 Windows 专用 API 下发送 HD 触感音频)。在《漫威蜘蛛侠:重制版》中测试可用;在《对马岛之魂》中未送达 —— 同一固件、同一手柄。
|
||||||
|
- **USB 3.0 端口可能干扰配对** —— 手柄可能卡在常亮的琥珀/黄色灯条上始终连不上,而同一适配器在 USB 2.0 端口上工作正常。这是射频干扰,并非固件 bug;见下文 [USB 3.0 端口与蓝牙干扰](#usb-30-端口与蓝牙干扰)。(自 v0.6.8 起,固件会自动重试卡住的连接而不是一直挂着,能恢复许多 —— 但非全部 —— 边缘情况。)
|
||||||
|
|
||||||
|
## USB 3.0 端口与蓝牙干扰
|
||||||
|
|
||||||
|
如果适配器在某个端口能用而在另一个不能用,**请先试试 USB 2.0 端口。** USB 3.0 端口(尤其是 USB 3.0 延长线)会发出以 2.4 GHz 附近为中心的宽带射频噪声 —— 正是适配器蓝牙电台与手柄通信所用的频段。这是业界有充分记录的问题(Intel,*"USB 3.0 Radio Frequency Interference Impact on 2.4 GHz Wireless Devices"*),并非本固件特有。该噪声会降低适配器蓝牙接收机的灵敏度,于是手柄可以开始连接(琥珀灯条)但链路太吵无法完成 —— 卡在黄色上。
|
||||||
|
|
||||||
|
缓解措施,大致按效果排序:
|
||||||
|
|
||||||
|
1. **把适配器插到 USB 2.0 端口**(往往是最简单的修复 —— 许多主板/机箱两者都有)。
|
||||||
|
2. **用一根短的 USB 2.0 延长线** 让适配器离 USB 3.0 端口/金属机箱几英寸远,改善对手柄的视线。要特别避免 USB 3.0 延长线。
|
||||||
|
3. **使用插在 USB 3.0 端口上的有源 USB 2.0 集线器** —— 集线器会把链路降速并增加距离。
|
||||||
|
4. **在靠近适配器处的线缆上夹一个磁环。**
|
||||||
|
5. **配对时让手柄更靠近 / 与适配器保持视线。**
|
||||||
|
|
||||||
|
固件会自行持续重试卡住的连接,因此灯条变琥珀后让它插着等约 10–20 秒,也许无需拔插即可恢复。
|
||||||
|
|
||||||
|
## 性能 / 超频
|
||||||
|
|
||||||
|
**你无需为此做任何事 —— 超频已内置在固件中。** 当你刷入本仓库的 UF2 时,Pico 2 W 会自动以下列设置启动。没有单独要运行的工具、没有要编辑的配置文件、没有要烧的熔丝。
|
||||||
|
|
||||||
|
内置设置:
|
||||||
|
|
||||||
|
- **电压:1.20 V**(`vreg_set_voltage(VREG_VOLTAGE_1_20)`)
|
||||||
|
- **时钟:320 MHz**(`set_sys_clock_khz(SYS_CLOCK_KHZ, true)`)
|
||||||
|
|
||||||
|
为何必需:在默认时钟/电压下,CYW43 PIO SPI 总线(固件用来与板载蓝牙芯片通信的通路)不可靠,配对会失败。320 MHz @ 1.20 V 是我们验证过能在本板上产生稳定蓝牙链路的最低组合。
|
||||||
|
|
||||||
|
如果你自己编译的构建无法启动(不常见 —— 仅当你改过源码时才相关),可尝试在 `src/main.cpp` 中略微提高电压或降低时钟。运行官方 UF2 发布版的最终用户无需改动此处。
|
||||||
|
|
||||||
|
持续游玩**建议**在 RP2350 上加小散热片,但配对或短时使用并不需要。
|
||||||
|
|
||||||
|
## 编译说明
|
||||||
|
|
||||||
|
从源码编译本项目:
|
||||||
|
|
||||||
|
1. 安装 Pico SDK 2.2.0(或更新版本)。编译使用 `pico_sdk_import.cmake`。
|
||||||
|
2. **在 Pico SDK 内将 TinyUSB 固定到 0.20.0**(`$PICO_SDK_PATH/lib/tinyusb`)。本项目的 `tusb_config.h` 使用了 `TUD_AUDIO_EP_SIZE` 的四参数形式,而 Pico SDK 2.2.0 捆绑的 0.18.0 版本没有它:
|
||||||
|
```bash
|
||||||
|
cd "$PICO_SDK_PATH/lib/tinyusb"
|
||||||
|
git fetch --tags
|
||||||
|
git checkout 0.20.0
|
||||||
|
```
|
||||||
|
3. 用 CMake + Ninja(或 Make)配置并编译:
|
||||||
|
```bash
|
||||||
|
cd /path/to/DS5Dongle
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPICO_SDK_PATH="$PICO_SDK_PATH"
|
||||||
|
cmake --build build --target ds5-bridge
|
||||||
|
```
|
||||||
|
4. UF2 会生成在 `build/ds5-bridge-oled.uf2`。照常用 BOOTSEL 刷写。
|
||||||
|
|
||||||
|
值得了解的编译标志:
|
||||||
|
|
||||||
|
- `-DENABLE_BATT_LED=ON`(默认)—— DS5 低电量时闪烁 Pico LED。
|
||||||
|
- `-DENABLE_SERIAL=ON` —— 将 printf 路由到 USB CDC 用于调试(默认 OFF;生产构建释放 UART)。
|
||||||
|
- `-DPICO_W_BUILD=ON` —— 为原版 Pico W 编译(去掉音频、降低时钟)。默认面向 Pico 2 W。
|
||||||
|
|
||||||
|
## 诊断与调试工具
|
||||||
|
|
||||||
|
排查桥接问题有两种方式 —— 设备端通过 OLED 诊断屏幕,主机端通过 `scripts/mic_diag.sh`(Linux)。主机端更快:无需切屏、无需刷写周期,可在手柄正在使用时运行。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 一次性快照 —— 适配器在 USB 上吗?ALSA 枚举到了吗?采集流在跑吗?当前有手柄配对吗?
|
||||||
|
scripts/mic_diag.sh status
|
||||||
|
|
||||||
|
# 对麦克风 IN 端点做 3 秒 arecord —— 报告峰值 / RMS / 非零计数,
|
||||||
|
# 以便区分"流是静音的"和"流在产生音频"。
|
||||||
|
scripts/mic_diag.sh capture 3
|
||||||
|
|
||||||
|
# 同 `status` 但循环运行,仅在状态变化时打印。可用于
|
||||||
|
# 捕捉配对完成或音频流开/关的确切时刻。
|
||||||
|
scripts/mic_diag.sh watch
|
||||||
|
|
||||||
|
# 实时读取固件的 0xFD 厂商特性报告(经由 /dev/hidraw):
|
||||||
|
# 蓝牙输入计数与速率、最近见到的非 0x31 ID、字节前缀,以及
|
||||||
|
# 扳机流计数器(收到的主机 0x02 / 置了 AllowTriggerFFB 的 /
|
||||||
|
# 转发到蓝牙的)。bt-trace 会给出结论 —— "主机驱动没有发送
|
||||||
|
# 扳机 Allow 位" 还是 "已转发但手柄未触动" —— 这原本需要
|
||||||
|
# USB 协议分析仪才能判断。
|
||||||
|
scripts/mic_diag.sh bt-trace
|
||||||
|
```
|
||||||
|
|
||||||
|
最初是为排查被搁置的 DS5 蓝牙麦克风调查而写的(见 [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md))。`0xFD` 特性报告与 `bt-trace` 解码器现在还携带为 [issue #3](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/issues/3)(部分游戏缺少自适应扳机张力)新增的扳机流计数器。
|
||||||
|
|
||||||
|
## OLED 显示插件(可选)
|
||||||
|
|
||||||
|
如果你把 [Waveshare Pico-OLED-1.3](#硬件) 插到 Pico2W 的排针上,固件会自动将其作为实时状态显示屏驱动。无需任何配置 —— 没有 OLED 时固件会优雅地不做动作。
|
||||||
|
|
||||||
|
### 开机启动画面(上电后 1.5 秒)
|
||||||
|
|
||||||
|
空屏居中显示固件版本 1.5 秒,然后跳转到状态屏幕。
|
||||||
|
|
||||||
|
### 11 个屏幕,用插件上的 KEY0 循环
|
||||||
|
|
||||||
|
循环顺序:**状态 → 配对槽 → 灯条 → 扳机测试 → 陀螺仪倾斜 → 触摸板 → 诊断 → CPU/时钟 → 蓝牙信号 → VU 表 → 设置 →** 回绕。**短按 KEY0 前进;短按 KEY1 后退** —— 在*每个*屏幕上都如此。各屏幕内的交互(循环扳机预设、循环灯条模式、移动设置光标、切换槽)都放在 **DualSense 手柄按键**上,绝不放在 KEY0/KEY1 上,因此 OLED 插件上这两个物理按键始终表示同一含义。
|
||||||
|
|
||||||
|
每个屏幕还会在左上边缘(KEY0 旁)画 **`>`**、在左下边缘(KEY1 旁)画 **`<`**,让屏上标签与按键在物理上配对。
|
||||||
|
|
||||||
|
#### 1. 状态
|
||||||
|
|
||||||
|
连接状态、已配对 DualSense 的蓝牙地址、带条形的电量百分比(`+` 充电中 / `*` 已充满 / `!` 错误)、实时摇杆位置(按下 **L3 / R3** 时,对应的摇杆框会反色闪烁 —— 白底黑点 —— 直到松开)、方向键、面板按键(△ ◯ ✕ □)、L1/R1,以及 L2/R2 模拟扳机填充条。链路指示与电量使用小像素图标。充电时,电池旁会显示到 100% 的预计时间 `~Nm`。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc01.jpg" alt="Status screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 2. 配对槽
|
||||||
|
|
||||||
|
持久化 4 槽多手柄配对。浏览已存手柄、切换活动槽,或清除单个槽。`>` 是光标,`*` 标记当前活动槽。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc02.jpg" alt="Slots screen on the OLED" width="420">
|
||||||
|
|
||||||
|
- **方向键 ▲▼** —— 在槽 0–3 间移动光标
|
||||||
|
- **△** —— 切换到光标所在槽(断开当前,重连到该槽存储的手柄)
|
||||||
|
- **□ 按住 1.5 秒** —— 清除光标所在槽(删除 bd_addr + BTstack 链路密钥)
|
||||||
|
- 活动槽会被持久化;适配器下次开机时重连到它
|
||||||
|
|
||||||
|
#### 3. 灯条调色器
|
||||||
|
|
||||||
|
在各轴上倾斜手柄来调出 R / G / B;固件以 10 Hz 把得到的颜色发送到 DualSense 实际的灯条,因此灯条本身就是可视预览(OLED 是单色的)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc03.jpg" alt="Lightbar color picker on the OLED" width="420">
|
||||||
|
|
||||||
|
- 在手柄上按 **△ ◯ ✕ □** 把当前颜色**保存**到收藏槽 0 / 1 / 2 / 3
|
||||||
|
- 在手柄上按 **R1** 循环模式标签:`[LIVE]` → `[FAV0]` → `[FAV1]` → `[FAV2]` → `[FAV3]` → 特效(呼吸 / 彩虹 / 渐变)→ 回到 `[LIVE]`
|
||||||
|
- 默认收藏:红、绿、蓝、白
|
||||||
|
|
||||||
|
#### 4. 扳机测试
|
||||||
|
|
||||||
|
在手柄上按 **△** 循环七种应用到 L2 和 R2 的自适应扳机效果。扣动各扳机来感受效果。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc04.jpg" alt="Trigger Test screen on the OLED" width="420">
|
||||||
|
|
||||||
|
循环顺序:**关 → 反馈 → 武器 → 振动 → 弓 → 疾驰 → 机枪 → 关 …** 效果参数依据 [dualsensectl](https://github.com/nowrep/dualsensectl) 的逆向工程按位打包,全部为最大强度。
|
||||||
|
|
||||||
|
#### 5. 陀螺仪倾斜
|
||||||
|
|
||||||
|
实时 X/Y/Z 加速度计数值,配 40×40 十字准线框。点会实时跟随手柄的倾斜,并在手柄**平放时居中** —— 它使用手柄自带的逐台出厂 IMU 校准(从特性报告 `0x05` 解析),因此每个手柄的静止位置与增益都准确。向左/右、向前/后倾斜时,点会朝相应方向移动。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc05.jpg" alt="Gyro Tilt screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 6. 触摸板
|
||||||
|
|
||||||
|
触摸板表面的实时渲染。当前手指位置处出现圆点;计数随手指触碰/离开而更新。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc06.jpg" alt="Touchpad screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 7. 诊断
|
||||||
|
|
||||||
|
可滚动的实时计数器列表 —— 运行时间、蓝牙状态、主机 → 蓝牙扳机流(`host02` / `trig` / `tx`)、蓝牙 0x31 输入速率、USB 音频帧/秒、蓝牙 0x32 包/秒,底部还有被搁置的麦克风调查计数器。手柄方向键 ▲/▼ 滚动;右边缘的小 `^` / `v` 标记"上方/下方还有更多"。只读,所以没有光标。无需 UART 线即可验证桥接器在搬运字节。
|
||||||
|
|
||||||
|
同一批计数器也通过 HID 特性报告 `0xFD` 导出给主机端工具 —— 见下文 `scripts/mic_diag.sh bt-trace`。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 8. CPU / 时钟
|
||||||
|
|
||||||
|
实时 RP2350 指标:配置的(`Set`)与实际运行的(`Real`)系统时钟(以晶振参考测量)、从稳压器回读的核心电压,以及片上温度(256 次采样平均 + 慢速 EMA,使数值跟踪真实芯片温度而非 ADC 噪声)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc08.jpg" alt="CPU / Clock diagnostics screen on the OLED" width="420">
|
||||||
|
|
||||||
|
同样的遥测也通过 HID 特性报告 `0xFC` 导出给工具。
|
||||||
|
|
||||||
|
#### 9. 蓝牙信号
|
||||||
|
|
||||||
|
活动链路的实时蓝牙信号强度,以 dBm 显示并带条形。越接近 0 dBm 越强;−90 dBm 为弱。含定性标签(差 / 一般 / 好 / 极佳)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc09.jpg" alt="BT Signal screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 10. VU 表
|
||||||
|
|
||||||
|
扬声器与触感音频路径的实时峰值表。可在手柄未插入主机时验证音频路由。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc10.jpg" alt="VU Meters screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 11. 设置
|
||||||
|
|
||||||
|
持久化配置编辑器。方向键 ▲▼ 移动选择,▶◀ 调整数值,△ 保存到闪存。包含固件配置字段(振动增益、扬声器音量、空闲超时、轮询率)、音频自动触感控件,以及两个按住确认的操作:
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc11.jpg" alt="Settings screen on the OLED" width="420">
|
||||||
|
|
||||||
|
- **AutoHap Off / Fallback / Mix / Replace** —— 选择音频自动触感模式。默认 `Fallback` 仅在游戏不发送原生触感数据时(如 Linux 上的《对马岛之魂》)才触发派生震动;确实发送原生触感的游戏(《漫威蜘蛛侠:重制版》)则原样通过。`Mix` 在原生之上叠加派生,`Replace` 完全忽略原生,`Off` 禁用。
|
||||||
|
- **AH Gain N%** —— 派生信号增益,0–200%,10% 步进。默认 100%。
|
||||||
|
- **AH LP 80/160/250/400 Hz** —— 在包络跟随之前应用到扬声器音频的低通截止。越低越偏重低音,越高越偏临场感。默认 160 Hz。
|
||||||
|
- **Reset to defaults** —— 按住 △ 2 秒恢复所有配置字段
|
||||||
|
- **Wipe all slots** —— 按住 △ 2 秒删除全部 4 个已配对手柄 + 全部 BTstack 链路密钥
|
||||||
|
|
||||||
|
### 按键参考
|
||||||
|
|
||||||
|
OLED 插件上的两个物理按键**严格用于导航**:
|
||||||
|
|
||||||
|
| 按键 | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| **KEY0** 短按 | 下一屏(前进) |
|
||||||
|
| **KEY1** 短按 | 上一屏(后退) |
|
||||||
|
| **KEY1** 长按(≥ 1.5 秒) | 循环 OLED 亮度等级 |
|
||||||
|
| **KEY0 + KEY1** 同时按住 ≥ 1 秒 | `watchdog_reboot` —— 无需拔 USB 的软重启 |
|
||||||
|
|
||||||
|
各屏幕内的状态变更(循环扳机预设、循环灯条模式、移动设置光标、切换槽、把颜色保存到收藏槽)全部发生在 **DualSense 手柄按键**上 —— 绝不在 KEY0 / KEY1 上 —— 因此这两个物理按键在每个屏幕上始终表示同一含义。各按键对应哪个手柄操作,见上文每个屏幕的小节。
|
||||||
|
|
||||||
|
### 引脚定义(标准 Waveshare Pico HAT 布局)
|
||||||
|
|
||||||
|
| 功能 | GPIO |
|
||||||
|
|---|---|
|
||||||
|
| MOSI | 11 |
|
||||||
|
| SCK | 10 |
|
||||||
|
| CS | 9 |
|
||||||
|
| DC | 8 |
|
||||||
|
| RST | 12 |
|
||||||
|
| KEY0 | 15 |
|
||||||
|
| KEY1 | 17 |
|
||||||
|
|
||||||
|
### 软重启恢复
|
||||||
|
|
||||||
|
两种无需拔 USB 重启适配器的方式 —— 配对卡住或想要干净状态时很方便:
|
||||||
|
|
||||||
|
- **OLED 上同时按住 KEY0 + KEY1 ≥ 1 秒** → `watchdog_reboot`。取代了早期版本的"KEY0 双击"手势,因为快速前进导航总会误触双击计时器。
|
||||||
|
- **按住 DualSense 的 `PS + Mute` 2 秒** → `watchdog_reboot`(无论是否装有 OLED 都可用 —— 无界面后备)。
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
本分支中的部分功能与设计思路借鉴自上游的其他分支,并予以致谢:
|
||||||
|
|
||||||
|
- **[zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED)** —— OLED 状态头部的像素图标(视觉方案)、设置屏 "Reset to defaults" 项使用的"按住以恢复出厂"交互模式(按住 △ 2 秒确认),以及新增配对槽屏上的多槽持久化蓝牙配对系统(4 个已绑定手柄、方向键导航、△ 切换槽、□ 按住清除某槽,外加设置菜单中的 "Wipe all slots")。
|
||||||
|
- **[loteran/DS5Dongle](https://github.com/loteran/DS5Dongle)** —— 独立地重新发现了上游 `3a31bd7` 破坏扬声器/HD 触感输出的回归(提交 `c7a8d3c`);我们 `src/audio.cpp` 中的修复恢复了同样的 SetStateData 子报告。也是音频自动触感 DSP(1 极点 LP + 包络跟随器,见 设置 → Auto Haptics)以及"不要把 USB 侧 UAC1 音量同步到持久化配置"修复的来源。
|
||||||
|
- **[awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)** —— 我们分支版网页配置应用 [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web) 的基础。该分支把上游的 Config_body 布局适配为我们的版本,并为我们的新增功能(多槽配对、Auto Haptics)添加了 UI。
|
||||||
|
- **[PS5 Button Icons and Controls](https://zacksly.itch.io/ps5-button-icons-and-controls)**(作者 **Zacksly**)—— 网页配置工具重映射标签页所用的 DualSense 控制器轮廓与按键图标,采用 [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) 授权(已重新着色为 `currentColor` 并裁剪以适配主题)。
|
||||||
|
|
||||||
|
## 路线图
|
||||||
|
- 请查看 [DS5Dongle plan](https://github.com/users/awalol/projects/5)
|
||||||
|
|
||||||
|
## 社区
|
||||||
|
- 加入 Discord 服务器:[Discord Server](https://discord.gg/hM4ntchGCa)
|
||||||
|
- 如果你遇到 bug,请改为开 issue。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
- [rafaelvaloto/Pico_W-Dualsense](https://github.com/rafaelvaloto/Pico_W-Dualsense) —— 项目灵感
|
||||||
|
- [egormanga/SAxense](https://github.com/egormanga/SAxense) —— 蓝牙触感 POC
|
||||||
|
- [https://controllers.fandom.com/wiki/Sony_DualSense](https://controllers.fandom.com/wiki/Sony_DualSense) —— DualSense 数据报告结构文档
|
||||||
|
- [Paliverse/DualSenseX](https://github.com/Paliverse/DualSenseX) —— 扬声器报告数据包
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ The web tool is a one-stop shop — **no installs, no command line, no `picotool
|
|||||||
- **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.
|
- **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.
|
> **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.
|
- **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. **⚠️ Does not work on the native-trigger firmware** — see the note below.
|
||||||
|
- **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. **⚠️ Does not work on the native-trigger firmware** — see the note below.
|
||||||
- **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.
|
- **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.
|
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.
|
||||||
|
|
||||||
|
> **⚠️ Native-trigger firmware (Unreleased) and the WebHID tabs.** To make games on Linux/Proton recognise the dongle as a genuine DualSense and fire **native adaptive triggers** (see [CHANGELOG.md](./CHANGELOG.md)), the firmware's HID descriptor is now byte-identical to a real DS5 — which meant *un-declaring* the `0xF6`/`0xF7` config reports the browser **Config** and **Remap** tabs depend on. On that firmware those two tabs can't reach the dongle (WebHID refuses undeclared report IDs). **Flash Firmware and OLED Preview still work**, on-dongle OLED config still works, and the same settings can be driven over Linux `hidraw`. Earlier releases are unaffected.
|
||||||
|
|
||||||
> 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)).
|
> 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)).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,14 +45,54 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
|
|||||||
|
|
||||||
**OLED Edition additions:**
|
**OLED Edition additions:**
|
||||||
|
|
||||||
|
- **Native DualSense adaptive triggers in PC games on Linux/Proton — through the dongle.** Games with native DualSense support drive the controller's adaptive triggers wirelessly via the dongle, **1:1 with a directly-wired DualSense**. Requires a Proton carrying the Wine `winebus.sys` #9034 fix (Wine 11 / current Proton-GE) with Steam Input disabled — **no launch option needed** (Wine 11 enables the hidraw native path by default). Works on **both Steam and Heroic** (Epic/GOG). The firmware makes the dongle byte-for-byte indistinguishable from a real DS5 so the game accepts it (full write-up in [CHANGELOG.md](./CHANGELOG.md)). Trade-off: the browser Config/Remap tabs are disabled on this firmware (see the note above).
|
||||||
- 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)
|
- 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
|
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
|
||||||
- **Lightbar color picker** with 4 user favorite slots + breathing / rainbow / fade effect presets
|
- **Lightbar color picker** with 4 user favorite slots + breathing / rainbow / fade effect presets
|
||||||
- **Persistent settings menu** for the 8 firmware config fields (haptics gain, speaker volume, polling rate, etc.) with hold-to-confirm Reset and Wipe-all-slots actions
|
- **Persistent settings menu** for the 8 firmware config fields (haptics gain, speaker volume, polling rate, etc.) with hold-to-confirm Reset and Wipe-all-slots actions
|
||||||
- **OLED brightness control + auto-dim** after 5 min idle (extends OLED life)
|
- **OLED idle power ladder** — manual brightness cycle (KEY1 long-press), automatic deep-dim with a small breathing dot at 2 min idle, full display-off at 15 min idle. Wakes instantly on button, controller pair, or input. Real burn-in protection, not just a contrast tweak.
|
||||||
- **Soft-reboot** without unplugging USB via DS5 `PS + Mute` hold (works headless) or **KEY0 + KEY1 held together for 1 s** on the OLED add-on (replaces the older KEY0 double-click gesture, which was easy to fire by accident while paging quickly)
|
- **Soft-reboot** without unplugging USB via DS5 `PS + Mute` hold (works headless) or **KEY0 + KEY1 held together for 1 s** on the OLED add-on (replaces the older KEY0 double-click gesture, which was easy to fire by accident while paging quickly)
|
||||||
- **Audit pass on the core bridge** — critical stack-overflow fix in the audio path (resolves long-standing "audio stuttering"), security hardening, watchdog, length validation across HID/L2CAP boundaries (see [CHANGELOG.md](./CHANGELOG.md))
|
- **Audit pass on the core bridge** — critical stack-overflow fix in the audio path (resolves long-standing "audio stuttering"), security hardening, watchdog, length validation across HID/L2CAP boundaries (see [CHANGELOG.md](./CHANGELOG.md))
|
||||||
|
|
||||||
|
## 🐧 Linux: native adaptive triggers (Proton)
|
||||||
|
|
||||||
|
Getting a game to drive the controller's **adaptive triggers through the dongle** on Linux needs a Proton that carries the Wine `winebus.sys` **#9034** fix. **As of this writing no official Proton-GE release includes it yet** — it's merged upstream but unreleased, and `GE-Proton10-34` (the current release) and earlier do **not** have it. So a build that does is provided: **`GE-Proton-DualSense`** (see [Releases](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases)). Once an official GE-Proton ships the fix, that will work too.
|
||||||
|
|
||||||
|
> Why: the #9034 bug suppresses the SDL gamepad device when a hidraw device exists for the same VID/PID, so the pad is seen *everywhere except in-game*. The fix lets the native (trigger-capable) path and a working gamepad coexist.
|
||||||
|
|
||||||
|
**One-time setup**
|
||||||
|
|
||||||
|
1. **Install the Proton build.** Download `GE-Proton-DualSense.tar.gz` and extract it to:
|
||||||
|
- **Steam:** `~/.steam/root/compatibilitytools.d/`
|
||||||
|
- **Heroic:** `~/.config/heroic/tools/proton/`
|
||||||
|
|
||||||
|
Then **fully restart Steam** so it detects the new compatibility tool.
|
||||||
|
2. **Disable Steam Input** so the game talks to the controller natively — Steam → game **Properties → Controller → Disable Steam Input** (or globally: Settings → Controller → turn off PlayStation Controller support).
|
||||||
|
|
||||||
|
**Per game**
|
||||||
|
|
||||||
|
3. **Force the Proton:** Steam → right-click the game → **Properties → Compatibility → Force the use of a specific Steam Play compatibility tool → `GE-Proton-DualSense`**. (Heroic: set the game's *Wine version* to it.)
|
||||||
|
|
||||||
|
**No launch option is needed** — Wine 11 enables the hidraw native path by default, so the dongle is handed to the game automatically. Just launch — native adaptive triggers fire through the dongle, 1:1 with a wired DualSense (verified flag-free on Cyberpunk 2077, Uncharted, Spider-Man Remastered, The Last of Us Part I, Avatar, Indiana Jones — and on **both Steam and Heroic**). The game must have **native DualSense support** (XInput-only games give rumble but no adaptive triggers — that's an engine limit, not the dongle). First launch under a new Proton does locale/prefix setup and can sit "Not Responding" for a minute — that's setup, not a crash; let it finish.
|
||||||
|
|
||||||
|
### Heroic (Epic / GOG) — same recipe, two extra requirements
|
||||||
|
|
||||||
|
Native triggers work identically on Heroic, but non-Steam launchers are sensitive to two host-side things that silently steal the controller:
|
||||||
|
|
||||||
|
1. **Fully quit Steam** — a background Steam grabs the DualSense from non-Steam games (or disable Settings → Controller → PlayStation controller support).
|
||||||
|
2. **No global `PROTON_PREFER_SDL`** — that env var forces the SDL/Xbox path and *suppresses* native triggers. Use it per-game only, as a deliberate generic-pad fallback.
|
||||||
|
|
||||||
|
**Launch-option reference**
|
||||||
|
|
||||||
|
| Variable | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PROTON_ENABLE_HIDRAW` | `0x054c/0x0ce6` | *Legacy / optional — **not needed** on Wine 11*, which enables the hidraw native path by default. Harmless if set. Only relevant on older Proton where hidraw was opt-in (but those also lack the #9034 fix, so triggers won't work there anyway). |
|
||||||
|
| `PROTON_PREFER_SDL` | `1` | *Alternative, input-only.* Forces the SDL gamepad path — gives working input + rumble but as a generic/Xbox pad (**no triggers**). Use only when a game lacks native DualSense support and `ENABLE_HIDRAW` doesn't help. Do **not** combine with `ENABLE_HIDRAW` for trigger games. |
|
||||||
|
| `PROTON_LOG` | `+hid` | *Diagnostic only* (not for normal play). Writes a HID enumeration trace — Steam lands it at `~/steam-<appid>.log`; Heroic sets `PROTON_LOG_DIR` to your home, so it lands at `~/steam-0.log`. Useful for confirming the game opened the native HID path. |
|
||||||
|
|
||||||
|
> **Heroic vs Steam:** Native triggers work on **both** — and neither needs a launch option on Wine 11. Steam: set the runner + disable Steam Input. Heroic: set the runner, **fully quit Steam** (a background Steam steals the pad from non-Steam games), and keep `PROTON_PREFER_SDL` off. Either way the game must natively support DualSense — XInput-only games (e.g. Ghostrunner, Control) give rumble but no adaptive triggers, on any OS.
|
||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|
||||||
### Required
|
### Required
|
||||||
@@ -140,10 +183,44 @@ When the connected DualSense reports its battery at or below 10% (and it is not
|
|||||||
|
|
||||||
To opt out at build time, configure with `-DENABLE_BATT_LED=OFF`. Default is ON.
|
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
|
## Known Issues
|
||||||
|
|
||||||
- Overclocking to 320 MHz @ 1.20 V is **required** for stable BT pairing. Dropping voltage to 1.10 V or clock to stock breaks the CYW43 PIO SPI bus and BT stops working. A small heatsink on the RP2350 is recommended for sustained gameplay.
|
- Overclocking to 320 MHz @ 1.20 V is **required** for stable BT pairing. Dropping voltage to 1.10 V or clock to stock breaks the CYW43 PIO SPI bus and BT stops working. A small heatsink on the RP2350 is recommended for sustained gameplay.
|
||||||
- HD haptics may not fire in every game on Linux + Steam; this is game-side (some titles only send HD-haptic audio under Windows-specific APIs). Tested working in Spider-Man Remastered; not delivered in Ghost of Tsushima — same firmware, same controller.
|
- HD haptics may not fire in every game on Linux + Steam; this is game-side (some titles only send HD-haptic audio under Windows-specific APIs). Tested working in Spider-Man Remastered; not delivered in Ghost of Tsushima — same firmware, same controller.
|
||||||
|
- **USB 3.0 ports can disrupt pairing** — the controller may get stuck on a solid amber/yellow lightbar and never connect, while the same dongle works fine on a USB 2.0 port. This is RF interference, not a firmware bug; see [USB 3.0 ports & Bluetooth interference](#usb-30-ports--bluetooth-interference) below. (As of v0.6.8 the firmware auto-retries a stalled connection instead of hanging, which recovers many — but not all — marginal cases.)
|
||||||
|
|
||||||
|
## USB 3.0 ports & Bluetooth interference
|
||||||
|
|
||||||
|
If the dongle works on one port but not another, **try a USB 2.0 port first.** USB 3.0 ports and (especially) USB 3.0 extension cables emit broadband RF noise centered near 2.4 GHz — the same band the dongle's Bluetooth radio uses to talk to the controller. This is a well-documented industry issue (Intel, *"USB 3.0 Radio Frequency Interference Impact on 2.4 GHz Wireless Devices"*), not specific to this firmware. The noise desensitizes the dongle's BT receiver, so the controller can start connecting (amber lightbar) but the link is too noisy to complete — it hangs on yellow.
|
||||||
|
|
||||||
|
Mitigations, roughly in order of effectiveness:
|
||||||
|
|
||||||
|
1. **Plug the dongle into a USB 2.0 port** (often the simplest fix — many motherboards/cases have both).
|
||||||
|
2. **Use a short USB 2.0 extension cable** to get the dongle a few inches away from the USB 3.0 ports / metal chassis, improving line-of-sight to the controller. Avoid USB 3.0 extension cables specifically.
|
||||||
|
3. **Use a powered USB 2.0 hub** plugged into the USB 3.0 port — the hub downshifts the link and adds distance.
|
||||||
|
4. **Clip a ferrite bead** onto the cable near the dongle.
|
||||||
|
5. **Keep the controller closer / in line of sight** of the dongle during pairing.
|
||||||
|
|
||||||
|
The firmware will keep retrying a stalled connection on its own, so leaving it plugged in for ~10–20 s after the lightbar goes amber may let it recover without a replug.
|
||||||
|
|
||||||
## Performance / Overclocking
|
## Performance / Overclocking
|
||||||
|
|
||||||
@@ -185,6 +262,34 @@ Build flags worth knowing:
|
|||||||
- `-DENABLE_SERIAL=ON` — route printf to USB CDC for debugging (default OFF; releases UART for production builds).
|
- `-DENABLE_SERIAL=ON` — route printf to USB CDC for debugging (default OFF; releases UART for production builds).
|
||||||
- `-DPICO_W_BUILD=ON` — build for the original Pico W (drops audio, lowers clock). Default targets Pico 2 W.
|
- `-DPICO_W_BUILD=ON` — build for the original Pico W (drops audio, lowers clock). Default targets Pico 2 W.
|
||||||
|
|
||||||
|
## Diagnostics & debug tooling
|
||||||
|
|
||||||
|
Two ways to triage bridge issues — on-device via the OLED Diagnostics screen, and host-side via `scripts/mic_diag.sh` (Linux). The host-side path is faster: no screen-switching, no flash cycle, runs while the controller is in active use.
|
||||||
|
|
||||||
|
```
|
||||||
|
# One-shot snapshot — is the dongle on USB? Did ALSA enumerate it? Is the
|
||||||
|
# capture stream live? Is a controller currently paired?
|
||||||
|
scripts/mic_diag.sh status
|
||||||
|
|
||||||
|
# 3-second arecord on the mic IN endpoint — reports peak / RMS / non-zero
|
||||||
|
# count so we can tell "stream is silent" from "stream is producing audio".
|
||||||
|
scripts/mic_diag.sh capture 3
|
||||||
|
|
||||||
|
# Same as `status` but in a loop, prints only on state change. Useful for
|
||||||
|
# catching the exact second pairing completes or audio streams open / close.
|
||||||
|
scripts/mic_diag.sh watch
|
||||||
|
|
||||||
|
# Live read of the firmware's 0xFD vendor feature report (via /dev/hidraw):
|
||||||
|
# BT input counts + rates, last seen non-0x31 IDs, byte prefixes, AND the
|
||||||
|
# trigger-flow counters (host 0x02 received / with AllowTriggerFFB set /
|
||||||
|
# forwarded to BT). bt-trace prints a verdict — "host driver isn't sending
|
||||||
|
# trigger Allow bits" vs "forwarded but controller didn't actuate" — which
|
||||||
|
# is what would otherwise need a USB protocol analyzer.
|
||||||
|
scripts/mic_diag.sh bt-trace
|
||||||
|
```
|
||||||
|
|
||||||
|
Originally written to triage the parked DS5 BT-microphone investigation (see [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md)). The `0xFD` feature report and `bt-trace` decoder now also carry the trigger-flow counters added for [issue #3](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/issues/3) (missing adaptive trigger tension in some games).
|
||||||
|
|
||||||
## OLED Display Add-on (optional)
|
## OLED Display Add-on (optional)
|
||||||
|
|
||||||
If you plug a [Waveshare Pico-OLED-1.3](#hardware) onto the Pico2W's headers, the firmware drives it automatically as a live status display. No configuration needed — the firmware no-ops gracefully when no OLED is present.
|
If you plug a [Waveshare Pico-OLED-1.3](#hardware) onto the Pico2W's headers, the firmware drives it automatically as a live status display. No configuration needed — the firmware no-ops gracefully when no OLED is present.
|
||||||
@@ -201,7 +306,7 @@ Every screen also paints **`>`** at the top-left edge (next to KEY0) and **`<`**
|
|||||||
|
|
||||||
#### 1. Status
|
#### 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.
|
Connection state, paired DualSense BD address, battery % with bar (`+` charging / `*` complete / `!` error), live analog stick positions (each stick box flashes inverse — a black dot on a white box — while its **L3 / R3** is clicked in), D-pad, face buttons (△ ◯ ✕ □), L1/R1, and L2/R2 analog trigger fill bars. The link indicator and battery use small pixel icons. While charging, a `~Nm` estimate of the time to 100% appears next to the battery.
|
||||||
|
|
||||||
<img src="./assets/oled/oled_sc01.jpg" alt="Status screen on the OLED" width="420">
|
<img src="./assets/oled/oled_sc01.jpg" alt="Status screen on the OLED" width="420">
|
||||||
|
|
||||||
@@ -236,7 +341,7 @@ Cycle order: **Off → Feedback → Weapon → Vibration → Bow → Gallop →
|
|||||||
|
|
||||||
#### 5. Gyro Tilt
|
#### 5. Gyro Tilt
|
||||||
|
|
||||||
Live X/Y/Z accelerometer values with a 40×40 crosshair box. Tilt the controller and the dot tracks in real time.
|
Live X/Y/Z accelerometer values with a 40×40 crosshair box. The dot tracks the controller's tilt in real time and **sits centered when the controller lies flat** — it's driven by the controller's own per-unit factory IMU calibration (parsed from feature report `0x05`), so the rest position and gain are correct on every controller. Tilting left/right and forward/back moves the dot in the matching direction.
|
||||||
|
|
||||||
<img src="./assets/oled/oled_sc05.jpg" 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">
|
||||||
|
|
||||||
@@ -248,7 +353,9 @@ Live render of the touchpad surface. Dots appear at current finger positions; th
|
|||||||
|
|
||||||
#### 7. Diagnostics
|
#### 7. Diagnostics
|
||||||
|
|
||||||
Uptime, BT state, USB-audio frames/sec, BT 0x32 packets/sec, and HCI error counter — live values for verifying the audio path is moving bytes without needing a UART cable.
|
Scrollable list of live counters — uptime, BT state, host → BT trigger flow (`host02` / `trig` / `tx`), BT 0x31 input rate, USB audio frames/sec, BT 0x32 packets/sec, and parked mic-investigation counters at the bottom. Controller D-pad ▲/▼ scrolls; tiny `^` / `v` glyphs at the right edge mark "more above/below." Read-only, so no cursor. Useful for verifying the bridge is moving bytes without needing a UART cable.
|
||||||
|
|
||||||
|
The same counters are also exported on HID feature report `0xFD` for host-side tooling — see `scripts/mic_diag.sh bt-trace` below.
|
||||||
|
|
||||||
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
|
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
|
||||||
|
|
||||||
@@ -322,6 +429,7 @@ Some features and design ideas in this fork are borrowed from other forks of ups
|
|||||||
|
|
||||||
- **[zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED)** — pixel-art icons in the OLED status header (visual approach), the "hold for factory reset" UX pattern used by the Settings screen's "Reset to defaults" item (hold △ for 2 s to confirm), and the multi-slot persistent BT pairing system on the new Slots screen (4 bonded controllers, D-pad to navigate, △ to switch slots, □ hold to wipe a slot, plus "Wipe all slots" in the Settings menu).
|
- **[zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED)** — pixel-art icons in the OLED status header (visual approach), the "hold for factory reset" UX pattern used by the Settings screen's "Reset to defaults" item (hold △ for 2 s to confirm), and the multi-slot persistent BT pairing system on the new Slots screen (4 bonded controllers, D-pad to navigate, △ to switch slots, □ hold to wipe a slot, plus "Wipe all slots" in the Settings menu).
|
||||||
- **[loteran/DS5Dongle](https://github.com/loteran/DS5Dongle)** — independent rediscovery of the upstream `3a31bd7` regression that broke speaker / HD haptic output (commit `c7a8d3c`); the fix in our `src/audio.cpp` restores the same SetStateData sub-report. Also the source of the Audio Auto Haptics DSP (1-pole LP + envelope follower, see Settings → Auto Haptics) and the "don't sync USB-side UAC1 volume to the persistent config" fix.
|
- **[loteran/DS5Dongle](https://github.com/loteran/DS5Dongle)** — independent rediscovery of the upstream `3a31bd7` regression that broke speaker / HD haptic output (commit `c7a8d3c`); the fix in our `src/audio.cpp` restores the same SetStateData sub-report. Also the source of the Audio Auto Haptics DSP (1-pole LP + envelope follower, see Settings → Auto Haptics) and the "don't sync USB-side UAC1 volume to the persistent config" fix.
|
||||||
|
- **[SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge)** — a sibling Pico DualSense bridge. The button-remapping apply logic and digital-control set in `src/remap.{h,cpp}` are ported from it. Its audio architecture (on-device Opus encoding vs an optional host-encoding companion app) was also the reference for diagnosing our speaker-cadence ("warble") work. Thanks for the groundwork.
|
||||||
- **[awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)** — base for our forked web config app at [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web). The fork adapts upstream's 13-byte Config_body layout to our 19-byte one and adds UI for our additions (multi-slot pairing, Auto Haptics).
|
- **[awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)** — base for our forked web config app at [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web). The fork adapts upstream's 13-byte Config_body layout to our 19-byte one and adds UI for our additions (multi-slot pairing, Auto Haptics).
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
Executable
+239
@@ -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
|
||||||
Executable
+139
@@ -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()
|
||||||
+189
-2
@@ -13,6 +13,7 @@
|
|||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include "pico/multicore.h"
|
#include "pico/multicore.h"
|
||||||
#include "pico/util/queue.h"
|
#include "pico/util/queue.h"
|
||||||
|
#include "pico/time.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "state_mgr.h"
|
#include "state_mgr.h"
|
||||||
#include "usb.h"
|
#include "usb.h"
|
||||||
@@ -25,6 +26,14 @@
|
|||||||
// #define VOLUME_GAIN 2
|
// #define VOLUME_GAIN 2
|
||||||
// #define BUFFER_LENGTH 48
|
// #define BUFFER_LENGTH 48
|
||||||
|
|
||||||
|
// DualSense microphone, ported from awalol/DS5Dongle's `mic` branch.
|
||||||
|
// The DS5 sends mic audio as Opus packets embedded in BT input report
|
||||||
|
// 0x31 when bit 1 of byte 2 is set; payload is 71 bytes of Opus at
|
||||||
|
// offset 4, decoded to mono 48 kHz 10 ms frames (480 samples).
|
||||||
|
#define MIC_CHANNELS 1
|
||||||
|
#define MIC_FRAMES 480
|
||||||
|
#define MIC_OPUS_SIZE 71
|
||||||
|
|
||||||
using std::clamp;
|
using std::clamp;
|
||||||
using std::max;
|
using std::max;
|
||||||
|
|
||||||
@@ -37,6 +46,39 @@ queue_t audio_fifo;
|
|||||||
static uint8_t opus_buf[200];
|
static uint8_t opus_buf[200];
|
||||||
critical_section_t opus_cs;
|
critical_section_t opus_cs;
|
||||||
|
|
||||||
|
// Mic ingress queue — filled from on_bt_data() (BT poll, core0), drained
|
||||||
|
// at the top of audio_loop() on core0. The decoder is single-threaded
|
||||||
|
// (core0 only), so no critical section is needed around it.
|
||||||
|
queue_t mic_fifo;
|
||||||
|
struct mic_element { uint8_t data[MIC_OPUS_SIZE]; };
|
||||||
|
static OpusDecoder *mic_decoder = nullptr;
|
||||||
|
static volatile uint32_t g_mic_frames = 0;
|
||||||
|
static volatile int32_t g_mic_last_decoded = 0; // opus_decode return value
|
||||||
|
static volatile uint16_t g_mic_last_want = 0; // bytes we asked TinyUSB to send
|
||||||
|
static volatile uint16_t g_mic_last_wrote = 0; // bytes TinyUSB accepted
|
||||||
|
uint32_t audio_mic_frames() { return g_mic_frames; }
|
||||||
|
int32_t audio_mic_last_decoded() { return g_mic_last_decoded; }
|
||||||
|
uint16_t audio_mic_last_want() { return g_mic_last_want; }
|
||||||
|
uint16_t audio_mic_last_wrote() { return g_mic_last_wrote; }
|
||||||
|
|
||||||
|
// 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 {
|
struct audio_raw_element {
|
||||||
float data[512 * 2];
|
float data[512 * 2];
|
||||||
};
|
};
|
||||||
@@ -73,9 +115,140 @@ uint8_t audio_peak_haptic() {
|
|||||||
return (uint8_t)(v >> 7);
|
return (uint8_t)(v >> 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Most-recent Opus TOC byte (first byte of the packet). Used by the OLED
|
||||||
|
// Diagnostics screen to decode the frame's bandwidth + duration config
|
||||||
|
// without serial.
|
||||||
|
static volatile uint8_t g_mic_toc = 0;
|
||||||
|
uint8_t audio_mic_last_toc() { return g_mic_toc; }
|
||||||
|
|
||||||
|
// Push a 71-byte Opus mic packet from the BT handler into the mic_fifo.
|
||||||
|
// Called from src/main.cpp's on_bt_data() when the DS5 sends a mic-tagged
|
||||||
|
// 0x31 input report. Drops the oldest queued packet if the FIFO is full —
|
||||||
|
// preferring fresh audio over backlog on overload.
|
||||||
|
void mic_add_queue(const uint8_t *data) {
|
||||||
|
static mic_element packet{};
|
||||||
|
memcpy(packet.data, data, MIC_OPUS_SIZE);
|
||||||
|
g_mic_toc = data[0]; // first byte of the Opus packet
|
||||||
|
if (queue_is_full(&mic_fifo)) queue_try_remove(&mic_fifo, NULL);
|
||||||
|
queue_try_add(&mic_fifo, &packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-assert the DS5 mic-enable (pkt[4] bit 0) so the controller streams its mic
|
||||||
|
// even when no audio is being output to it. Normally the enable only rides the
|
||||||
|
// 0x36 audio frames, which are gated on active USB audio — so without this, mic
|
||||||
|
// only works while a game plays sound. The enable is sticky (the DS5 keeps
|
||||||
|
// streaming once it starts), so we send a control-only 0x36 (enable + the
|
||||||
|
// load-bearing SetStateData sub-report + a silent haptic block, no speaker
|
||||||
|
// payload → makes no sound) at ~4 Hz ONLY until mic frames start arriving, then
|
||||||
|
// stop — minimizing BT traffic and DS5 battery. Resumes if the stream stalls.
|
||||||
|
static void mic_enable_keepalive() {
|
||||||
|
if (!bt_is_connected() || !get_config().bt_mic_enable) return;
|
||||||
|
const uint64_t now = time_us_64();
|
||||||
|
static uint32_t last_frames = 0;
|
||||||
|
static uint64_t last_frame_us = 0;
|
||||||
|
static uint64_t last_send_us = 0;
|
||||||
|
const uint32_t frames = g_mic_frames;
|
||||||
|
if (frames != last_frames) { last_frames = frames; last_frame_us = now; }
|
||||||
|
if (last_frame_us != 0 && (now - last_frame_us) < 1000000ULL) return; // streaming → sticky, no resend
|
||||||
|
if (last_send_us != 0 && (now - last_send_us) < 250000ULL) return; // throttle to ~4 Hz while arming
|
||||||
|
last_send_us = now;
|
||||||
|
|
||||||
|
uint8_t pkt[REPORT_SIZE]{};
|
||||||
|
pkt[0] = REPORT_ID;
|
||||||
|
pkt[1] = reportSeqCounter << 4;
|
||||||
|
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
|
||||||
|
pkt[2] = 0x11 | 1 << 7;
|
||||||
|
pkt[3] = 7;
|
||||||
|
pkt[4] = 0b11111111; // mic-enable (bit 0)
|
||||||
|
const auto buf_len = get_config().audio_buffer_length;
|
||||||
|
pkt[5] = pkt[6] = pkt[7] = pkt[8] = pkt[9] = buf_len;
|
||||||
|
pkt[10] = packetCounter++;
|
||||||
|
pkt[11] = 0x10 | 1 << 7; // SetStateData sub-report (load-bearing — keeps actuators alive)
|
||||||
|
pkt[12] = 63;
|
||||||
|
state_set(pkt + 13, 63);
|
||||||
|
pkt[76] = 0x12 | 1 << 7; // haptic sub-report; samples left zero = silent
|
||||||
|
pkt[77] = SAMPLE_SIZE;
|
||||||
|
// no speaker sub-report (pkt[142..] stays zero) → control-only, no audio out
|
||||||
|
bt_write(pkt, sizeof(pkt));
|
||||||
|
g_bt_packets++;
|
||||||
|
}
|
||||||
|
|
||||||
void audio_loop() {
|
void audio_loop() {
|
||||||
|
// Mic-in path: pull one Opus packet from the BT-side FIFO, decode to
|
||||||
|
// mono PCM, duplicate to stereo (our UAC1 endpoint declares 2 channels),
|
||||||
|
// push to the host via tud_audio_write. Runs once per loop iteration so
|
||||||
|
// it keeps up with the ~100 Hz arrival rate of mic-tagged BT frames.
|
||||||
|
if (mic_decoder != nullptr) {
|
||||||
|
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 音频数据
|
// 1. 读取 USB 音频数据
|
||||||
if (!tud_audio_available()) return;
|
if (!tud_audio_available()) {
|
||||||
|
// Keep the DS5 mic streaming even without output audio — but ONLY once
|
||||||
|
// the host has enumerated us (tud_mounted). Running it during the
|
||||||
|
// fresh-pair feature handshake floods BT TX and delays controller-type
|
||||||
|
// detection past the connection watchdog's timeout, which then tears the
|
||||||
|
// link down (~10-15s "shutdown" on fresh pair). After enumeration the
|
||||||
|
// handshake is done, so it's safe — and always-on mic still works.
|
||||||
|
if (tud_mounted()) mic_enable_keepalive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int16_t raw[192];
|
int16_t raw[192];
|
||||||
uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes
|
uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes
|
||||||
@@ -209,7 +382,10 @@ void audio_loop() {
|
|||||||
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
|
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
|
||||||
pkt[2] = 0x11 | 0 << 6 | 1 << 7;
|
pkt[2] = 0x11 | 0 << 6 | 1 << 7;
|
||||||
pkt[3] = 7;
|
pkt[3] = 7;
|
||||||
pkt[4] = 0b11111110;
|
// bit 0 = mic-enable: tells the DS5 to stream its mic over BT (awalol
|
||||||
|
// confirmed this is the key). Bits 1-7 are the pre-existing speaker/
|
||||||
|
// haptic audio-enable flags. Gated on the bt_mic_enable config toggle.
|
||||||
|
pkt[4] = get_config().bt_mic_enable ? 0b11111111 : 0b11111110;
|
||||||
const auto buf_len = get_config().audio_buffer_length;
|
const auto buf_len = get_config().audio_buffer_length;
|
||||||
pkt[5] = buf_len;
|
pkt[5] = buf_len;
|
||||||
pkt[6] = buf_len;
|
pkt[6] = buf_len;
|
||||||
@@ -253,6 +429,17 @@ void audio_init() {
|
|||||||
critical_section_init(&opus_cs);
|
critical_section_init(&opus_cs);
|
||||||
multicore_launch_core1_with_stack(core1_entry, audio_core1_stack, sizeof(audio_core1_stack));
|
multicore_launch_core1_with_stack(core1_entry, audio_core1_stack, sizeof(audio_core1_stack));
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Mic path: queue + decoder live on core0 (audio_loop), separate from
|
||||||
|
// the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames.
|
||||||
|
queue_init(&mic_fifo, sizeof(mic_element), 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;
|
static OpusEncoder *encoder;
|
||||||
|
|||||||
+11
@@ -21,5 +21,16 @@ uint8_t audio_peak_haptic(); // 0..255, decays on read
|
|||||||
// Byte-flow counters for the Diagnostics screen + web emulator.
|
// Byte-flow counters for the Diagnostics screen + web emulator.
|
||||||
uint32_t audio_usb_frames();
|
uint32_t audio_usb_frames();
|
||||||
uint32_t audio_bt_packets();
|
uint32_t audio_bt_packets();
|
||||||
|
uint32_t audio_mic_frames(); // count of mic Opus frames decoded + written
|
||||||
|
int32_t audio_mic_last_decoded(); // last opus_decode return — neg = error, 480 = OK
|
||||||
|
uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write
|
||||||
|
uint16_t audio_mic_last_wrote(); // bytes TinyUSB FIFO actually accepted
|
||||||
|
uint8_t audio_mic_last_toc(); // first byte of last Opus packet (frame config)
|
||||||
|
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
|
#endif //DS5_BRIDGE_AUDIO_H
|
||||||
+63
-3
@@ -26,6 +26,15 @@
|
|||||||
#define MTU_CONTROL 672
|
#define MTU_CONTROL 672
|
||||||
#define MTU_INTERRUPT 672
|
#define MTU_INTERRUPT 672
|
||||||
|
|
||||||
|
// Connection-attempt watchdog: if a connection commits to a device (inquiry
|
||||||
|
// found one / incoming request accepted) but doesn't reach USB-enumeration
|
||||||
|
// within this window, tear down and retry. Catches the silent stalls caused by
|
||||||
|
// USB 3.0 2.4 GHz RF interference on the CYW43 BT radio (DualSense stuck on the
|
||||||
|
// amber init lightbar, never enumerates) — see README troubleshooting. A
|
||||||
|
// healthy or slow re-pair finishes well under 6 s, so 10 s never trips a real
|
||||||
|
// connection but heals before the user reaches to replug.
|
||||||
|
#define CONNECT_WATCHDOG_TIMEOUT_US (10 * 1000 * 1000)
|
||||||
|
|
||||||
using std::unordered_map;
|
using std::unordered_map;
|
||||||
using std::vector;
|
using std::vector;
|
||||||
using std::queue;
|
using std::queue;
|
||||||
@@ -54,6 +63,12 @@ struct send_element {
|
|||||||
|
|
||||||
absolute_time_t inactive_time = 0; // 手柄长时间静默
|
absolute_time_t inactive_time = 0; // 手柄长时间静默
|
||||||
|
|
||||||
|
// Connection-attempt watchdog timestamp. 0 == not armed; armed == a connection
|
||||||
|
// attempt is in flight (committed to a device, not yet USB-enumerating). Set
|
||||||
|
// when an attempt begins, cleared the instant the controller type is identified
|
||||||
|
// (USB connects) and on every teardown. Checked by bt_connection_watchdog_tick().
|
||||||
|
static absolute_time_t connect_attempt_started = 0;
|
||||||
|
|
||||||
// Multi-slot pairing state. Modeled on zurce/DS5Dongle-OLED.
|
// Multi-slot pairing state. Modeled on zurce/DS5Dongle-OLED.
|
||||||
static int g_current_slot = 0;
|
static int g_current_slot = 0;
|
||||||
|
|
||||||
@@ -166,6 +181,35 @@ bool bt_disconnect() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called every main-loop iteration. If a connection attempt has stalled past
|
||||||
|
// the timeout, tear it down so the state machine retries instead of hanging
|
||||||
|
// (e.g. on the amber lightbar under USB 3.0 RF interference). Inert unless a
|
||||||
|
// connection attempt is in flight, so it never touches a healthy session.
|
||||||
|
void bt_connection_watchdog_tick() {
|
||||||
|
if (connect_attempt_started == 0) return; // not armed
|
||||||
|
if (absolute_time_diff_us(connect_attempt_started, get_absolute_time())
|
||||||
|
< CONNECT_WATCHDOG_TIMEOUT_US) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf("[BT] Connection watchdog: attempt stalled, recovering\n");
|
||||||
|
connect_attempt_started = 0; // disarm; the next attempt re-arms
|
||||||
|
|
||||||
|
if (acl_handle != HCI_CON_HANDLE_INVALID) {
|
||||||
|
// ACL is up but setup stalled (auth/encryption/L2CAP/feature-wait).
|
||||||
|
// Route through the proven HCI_EVENT_DISCONNECTION_COMPLETE teardown.
|
||||||
|
bt_disconnect();
|
||||||
|
} else {
|
||||||
|
// No ACL yet (stalled before/at create-connection) — reset by hand
|
||||||
|
// and kick a fresh inquiry.
|
||||||
|
device_found = false;
|
||||||
|
new_pair = false;
|
||||||
|
gap_inquiry_stop();
|
||||||
|
gap_inquiry_start(30);
|
||||||
|
gap_connectable_control(1);
|
||||||
|
update_discoverable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void bt_get_signal_strength(int8_t *rssi) {
|
void bt_get_signal_strength(int8_t *rssi) {
|
||||||
// gap_read_rssi() completes asynchronously, so this function can only
|
// gap_read_rssi() completes asynchronously, so this function can only
|
||||||
// return the last cached RSSI value. Trigger a refresh afterwards so a
|
// return the last cached RSSI value. Trigger a refresh afterwards so a
|
||||||
@@ -298,6 +342,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
if (device_found) {
|
if (device_found) {
|
||||||
printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr));
|
printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr));
|
||||||
new_pair = true;
|
new_pair = true;
|
||||||
|
connect_attempt_started = get_absolute_time(); // arm connection watchdog
|
||||||
hci_send_cmd(&hci_create_connection, current_device_addr,
|
hci_send_cmd(&hci_create_connection, current_device_addr,
|
||||||
hci_usable_acl_packet_types(), 0, 0, 0, 1);
|
hci_usable_acl_packet_types(), 0, 0, 0, 1);
|
||||||
break;
|
break;
|
||||||
@@ -317,8 +362,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
if (opcode == HCI_OPCODE_HCI_CREATE_CONNECTION && status != ERROR_CODE_SUCCESS) {
|
if (opcode == HCI_OPCODE_HCI_CREATE_CONNECTION && status != ERROR_CODE_SUCCESS) {
|
||||||
device_found = false;
|
device_found = false;
|
||||||
new_pair = false;
|
new_pair = false;
|
||||||
|
connect_attempt_started = 0; // disarm; failed before an ACL existed
|
||||||
printf("[HCI] Create connection rejected, restart inquiry\n");
|
printf("[HCI] Create connection rejected, restart inquiry\n");
|
||||||
// gap_inquiry_start(30);
|
gap_inquiry_start(30);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -350,8 +396,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
} else {
|
} else {
|
||||||
device_found = false;
|
device_found = false;
|
||||||
new_pair = false;
|
new_pair = false;
|
||||||
|
connect_attempt_started = 0; // disarm; no ACL was established
|
||||||
printf("[HCI] ACL connect failed status=0x%02X, restart inquiry\n", status);
|
printf("[HCI] ACL connect failed status=0x%02X, restart inquiry\n", status);
|
||||||
// gap_inquiry_start(30);
|
gap_inquiry_start(30);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -401,7 +448,11 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
if (status != ERROR_CODE_SUCCESS) {
|
if (status != ERROR_CODE_SUCCESS) {
|
||||||
printf("[HCI] Authentication failed, drop stored key for %s\n", bd_addr_to_str(current_device_addr));
|
printf("[HCI] Authentication failed, drop stored key for %s\n", bd_addr_to_str(current_device_addr));
|
||||||
gap_drop_link_key_for_bd_addr(current_device_addr);
|
gap_drop_link_key_for_bd_addr(current_device_addr);
|
||||||
// gap_inquiry_start(30);
|
connect_attempt_started = 0; // disarm; teardown below re-inquires
|
||||||
|
// ACL is still up — route through the clean disconnect path
|
||||||
|
// (HCI_EVENT_DISCONNECTION_COMPLETE restarts inquiry) rather
|
||||||
|
// than leaving a half-open ACL.
|
||||||
|
bt_disconnect();
|
||||||
} else {
|
} else {
|
||||||
hci_send_cmd(&hci_set_connection_encryption, handle, 1);
|
hci_send_cmd(&hci_set_connection_encryption, handle, 1);
|
||||||
}
|
}
|
||||||
@@ -438,6 +489,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
bd_addr_copy(current_device_addr, addr);
|
bd_addr_copy(current_device_addr, addr);
|
||||||
gap_inquiry_stop();
|
gap_inquiry_stop();
|
||||||
hci_send_cmd(&hci_accept_connection_request, addr, 0x01);
|
hci_send_cmd(&hci_accept_connection_request, addr, 0x01);
|
||||||
|
connect_attempt_started = get_absolute_time(); // arm watchdog (incoming path)
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -451,6 +503,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
const uint8_t reason = hci_event_disconnection_complete_get_reason(packet);
|
const uint8_t reason = hci_event_disconnection_complete_get_reason(packet);
|
||||||
device_found = false;
|
device_found = false;
|
||||||
new_pair = false;
|
new_pair = false;
|
||||||
|
connect_attempt_started = 0; // disarm — every teardown clears here
|
||||||
acl_handle = HCI_CON_HANDLE_INVALID;
|
acl_handle = HCI_CON_HANDLE_INVALID;
|
||||||
bt_rssi = 0;
|
bt_rssi = 0;
|
||||||
hid_control_cid = 0;
|
hid_control_cid = 0;
|
||||||
@@ -508,6 +561,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
|
|||||||
printf("Connected DSE Controller\n");
|
printf("Connected DSE Controller\n");
|
||||||
check_dse = false;
|
check_dse = false;
|
||||||
is_dse = true;
|
is_dse = true;
|
||||||
|
connect_attempt_started = 0; // fully up — disarm watchdog
|
||||||
#if !ENABLE_SERIAL
|
#if !ENABLE_SERIAL
|
||||||
tud_connect();
|
tud_connect();
|
||||||
#endif
|
#endif
|
||||||
@@ -515,6 +569,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
|
|||||||
printf("Connected DS5 Controller\n");
|
printf("Connected DS5 Controller\n");
|
||||||
check_dse = false;
|
check_dse = false;
|
||||||
is_dse = false;
|
is_dse = false;
|
||||||
|
connect_attempt_started = 0; // fully up — disarm watchdog
|
||||||
#if !ENABLE_SERIAL
|
#if !ENABLE_SERIAL
|
||||||
tud_connect();
|
tud_connect();
|
||||||
#endif
|
#endif
|
||||||
@@ -693,6 +748,11 @@ vector<uint8_t> get_feature_data(uint8_t reportId, uint16_t len) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> bt_peek_feature(uint8_t reportId) {
|
||||||
|
auto it = feature_data.find(reportId);
|
||||||
|
return (it != feature_data.end()) ? it->second : std::vector<uint8_t>{};
|
||||||
|
}
|
||||||
|
|
||||||
void set_feature_data(uint8_t reportId, uint8_t *data, uint16_t len) {
|
void set_feature_data(uint8_t reportId, uint8_t *data, uint16_t len) {
|
||||||
if (hid_control_cid != 0) {
|
if (hid_control_cid != 0) {
|
||||||
uint8_t get_feature[len + 2];
|
uint8_t get_feature[len + 2];
|
||||||
|
|||||||
@@ -22,9 +22,18 @@ void bt_send_control(uint8_t *data, uint16_t len);
|
|||||||
void bt_write(const uint8_t *data, uint16_t len);
|
void bt_write(const uint8_t *data, uint16_t len);
|
||||||
void bt_get_signal_strength(int8_t *rssi);
|
void bt_get_signal_strength(int8_t *rssi);
|
||||||
std::vector<uint8_t> get_feature_data(uint8_t reportId,uint16_t len);
|
std::vector<uint8_t> get_feature_data(uint8_t reportId,uint16_t len);
|
||||||
|
// Side-effect-free read of an already-cached feature report (empty vector if it
|
||||||
|
// hasn't arrived yet). Unlike get_feature_data(), never issues an L2CAP request,
|
||||||
|
// so it is safe to poll every frame — used by the OLED IMU-calibration parse.
|
||||||
|
std::vector<uint8_t> bt_peek_feature(uint8_t reportId);
|
||||||
void init_feature();
|
void init_feature();
|
||||||
void set_feature_data(uint8_t reportId, uint8_t* data,uint16_t len);
|
void set_feature_data(uint8_t reportId, uint8_t* data,uint16_t len);
|
||||||
|
|
||||||
|
// Connection-attempt watchdog: call once per main-loop iteration. Recovers a
|
||||||
|
// stalled connection (auto re-inquiry) so a transient RF glitch — e.g. USB 3.0
|
||||||
|
// 2.4 GHz interference — doesn't hang the dongle on the amber lightbar.
|
||||||
|
void bt_connection_watchdog_tick();
|
||||||
|
|
||||||
// OLED add-on accessors.
|
// OLED add-on accessors.
|
||||||
bool bt_is_connected();
|
bool bt_is_connected();
|
||||||
void bt_get_addr(uint8_t out[6]);
|
void bt_get_addr(uint8_t out[6]);
|
||||||
|
|||||||
+129
-3
@@ -14,6 +14,7 @@
|
|||||||
#include "device/usbd.h"
|
#include "device/usbd.h"
|
||||||
#include "pico/time.h"
|
#include "pico/time.h"
|
||||||
#include "slots.h"
|
#include "slots.h"
|
||||||
|
#include "remap.h"
|
||||||
#include "hardware/clocks.h"
|
#include "hardware/clocks.h"
|
||||||
#include "hardware/adc.h"
|
#include "hardware/adc.h"
|
||||||
#include "hardware/vreg.h"
|
#include "hardware/vreg.h"
|
||||||
@@ -48,6 +49,25 @@ uint16_t cpu_temp_raw_smoothed() {
|
|||||||
return (uint16_t)(ema + 0.5f);
|
return (uint16_t)(ema + 0.5f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mic-debug globals (defined in main.cpp). File-scope extern so the
|
||||||
|
// linker resolves them once and cmd.cpp's 0xFD handler reads the same
|
||||||
|
// memory main.cpp writes to.
|
||||||
|
extern volatile uint32_t g_bt_31_packets;
|
||||||
|
extern volatile uint32_t g_bt_other_packets;
|
||||||
|
extern volatile uint8_t g_last_other_id;
|
||||||
|
extern volatile uint8_t g_other_id_or;
|
||||||
|
extern volatile uint8_t g_31_b2_or;
|
||||||
|
extern volatile uint8_t g_last_31_b2;
|
||||||
|
extern volatile uint16_t g_31_len_min;
|
||||||
|
extern volatile uint16_t g_31_len_max;
|
||||||
|
extern volatile uint8_t g_last_other_prefix[8];
|
||||||
|
extern volatile uint8_t g_last_any_prefix[16];
|
||||||
|
extern volatile uint16_t g_longest_len;
|
||||||
|
extern volatile uint8_t g_longest_frame[80];
|
||||||
|
extern volatile uint32_t g_host_out02_total;
|
||||||
|
extern volatile uint32_t g_host_out02_trig_allow;
|
||||||
|
extern volatile uint32_t g_host_out02_to_bt;
|
||||||
|
|
||||||
bool is_pico_cmd(uint8_t report_id) {
|
bool is_pico_cmd(uint8_t report_id) {
|
||||||
if (report_id == 0xf6 ||
|
if (report_id == 0xf6 ||
|
||||||
report_id == 0xf7 ||
|
report_id == 0xf7 ||
|
||||||
@@ -55,7 +75,9 @@ bool is_pico_cmd(uint8_t report_id) {
|
|||||||
report_id == 0xf9 ||
|
report_id == 0xf9 ||
|
||||||
report_id == 0xfa ||
|
report_id == 0xfa ||
|
||||||
report_id == 0xfb ||
|
report_id == 0xfb ||
|
||||||
report_id == 0xfc
|
report_id == 0xfc ||
|
||||||
|
report_id == 0xfd || // mic-debug counters
|
||||||
|
report_id == 0xfe // mic-debug longest-frame dump
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -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) {
|
uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
|
||||||
if (report_id == 0xf7) {
|
if (report_id == 0xf7) {
|
||||||
printf("[HID] Receive 0xf7 getting config\n");
|
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");
|
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);
|
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;
|
return len;
|
||||||
}
|
}
|
||||||
if (report_id == 0xf8) {
|
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);
|
memcpy(buffer + 9, &temp_raw, 2);
|
||||||
return want;
|
return want;
|
||||||
}
|
}
|
||||||
|
if (report_id == 0xfd) {
|
||||||
|
// Bridge-diagnostics feature report. 44-byte payload.
|
||||||
|
// Section 1: mic-investigation counters (original 0..31).
|
||||||
|
// [0..3] uint32 BT 0x31 input report count
|
||||||
|
// [4..7] uint32 BT non-0x31 input report count
|
||||||
|
// [8] uint8 last non-0x31 report ID seen
|
||||||
|
// [9] uint8 OR mask of all non-0x31 report IDs seen
|
||||||
|
// [10] uint8 OR mask of byte[2] across all 0x31 frames
|
||||||
|
// [11] uint8 last value of byte[2] in a 0x31 frame
|
||||||
|
// [12..13] uint16 min frame length seen
|
||||||
|
// [14..15] uint16 max frame length seen
|
||||||
|
// [16..23] uint8[8] first 8 bytes of last non-0x31 frame
|
||||||
|
// [24..31] uint8[8] first 8 bytes of most recent ANY frame
|
||||||
|
// Section 2: trigger-flow counters (issue #3 triage).
|
||||||
|
// [32..35] uint32 host 0x02 OUT reports received total
|
||||||
|
// [36..39] uint32 ...of those, with Allow*TriggerFFB set
|
||||||
|
// [40..43] uint32 ...forwarded as BT 0x31 sub-0x10
|
||||||
|
constexpr uint16_t want = 44;
|
||||||
|
for (uint16_t i = 0; i < want && i < reqlen; i++) buffer[i] = 0;
|
||||||
|
|
||||||
|
const uint32_t bt31 = g_bt_31_packets;
|
||||||
|
const uint32_t btother = g_bt_other_packets;
|
||||||
|
const uint16_t lmin = g_31_len_min == 0xFFFF ? 0 : g_31_len_min;
|
||||||
|
const uint16_t lmax = g_31_len_max;
|
||||||
|
|
||||||
|
memcpy(buffer + 0, &bt31, 4);
|
||||||
|
memcpy(buffer + 4, &btother, 4);
|
||||||
|
buffer[8] = g_last_other_id;
|
||||||
|
buffer[9] = g_other_id_or;
|
||||||
|
buffer[10] = g_31_b2_or;
|
||||||
|
buffer[11] = g_last_31_b2;
|
||||||
|
memcpy(buffer + 12, &lmin, 2);
|
||||||
|
memcpy(buffer + 14, &lmax, 2);
|
||||||
|
for (int i = 0; i < 8 && (16 + i) < reqlen; i++) buffer[16 + i] = g_last_other_prefix[i];
|
||||||
|
for (int i = 0; i < 8 && (24 + i) < reqlen; i++) buffer[24 + i] = g_last_any_prefix[i];
|
||||||
|
|
||||||
|
const uint32_t out02 = g_host_out02_total;
|
||||||
|
const uint32_t out02_t = g_host_out02_trig_allow;
|
||||||
|
const uint32_t out02_b = g_host_out02_to_bt;
|
||||||
|
if ((32 + 4) <= reqlen) memcpy(buffer + 32, &out02, 4);
|
||||||
|
if ((36 + 4) <= reqlen) memcpy(buffer + 36, &out02_t, 4);
|
||||||
|
if ((40 + 4) <= reqlen) memcpy(buffer + 40, &out02_b, 4);
|
||||||
|
return (reqlen < want) ? reqlen : want;
|
||||||
|
}
|
||||||
|
if (report_id == 0xfe) {
|
||||||
|
// 0xFE: full content of the LONGEST 0x31 frame seen. Bytes 0-1
|
||||||
|
// = length (uint16 LE), bytes 2+ = the captured frame bytes.
|
||||||
|
constexpr uint16_t want = 82; // 2 length + 80 frame bytes
|
||||||
|
const uint16_t lim = (reqlen < want) ? reqlen : want;
|
||||||
|
for (uint16_t i = 0; i < lim; i++) buffer[i] = 0;
|
||||||
|
const uint16_t llen = g_longest_len;
|
||||||
|
if (lim >= 2) {
|
||||||
|
buffer[0] = (uint8_t)(llen & 0xFF);
|
||||||
|
buffer[1] = (uint8_t)((llen >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
for (uint16_t i = 0; i < 80 && (i + 2) < lim; i++) {
|
||||||
|
buffer[2 + i] = g_longest_frame[i];
|
||||||
|
}
|
||||||
|
return lim;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,4 +292,25 @@ void pico_cmd_set(uint8_t report_id, uint8_t const *buffer, uint16_t bufsize) {
|
|||||||
sleep_ms(150);
|
sleep_ms(150);
|
||||||
tud_connect();
|
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,32 @@ void config_valid() {
|
|||||||
body->auto_haptics_lowpass = 1; // 160 Hz
|
body->auto_haptics_lowpass = 1; // 160 Hz
|
||||||
printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n");
|
printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n");
|
||||||
}
|
}
|
||||||
|
if (body->lightbar_mode > 8) { // 0..7 OLED modes + 8 = HOST passthrough (default)
|
||||||
|
body->lightbar_mode = 8;
|
||||||
|
printf("[Config] lightbar_mode invalid, defaulting to 8 (HOST passthrough)\n");
|
||||||
|
}
|
||||||
|
// lb_fav_{r,g,b} need no validation — any 0..255 is a legal color, and an
|
||||||
|
// erased flash sector (0xFF) yields 4 white favorites, a usable default.
|
||||||
|
if (body->screen_dim_timeout > 250) { // 0xFF erased / out of range → default
|
||||||
|
body->screen_dim_timeout = 2; // mirrors the original 2-min dim tier
|
||||||
|
printf("[Config] screen_dim_timeout invalid, defaulting to 2 min\n");
|
||||||
|
}
|
||||||
|
if (body->screen_off_timeout > 250) {
|
||||||
|
body->screen_off_timeout = 15; // mirrors the original 15-min off tier
|
||||||
|
printf("[Config] screen_off_timeout invalid, defaulting to 15 min\n");
|
||||||
|
}
|
||||||
|
if (body->bt_mic_enable > 1) { // 0xFF erased / upgrade → default ON
|
||||||
|
body->bt_mic_enable = 1;
|
||||||
|
printf("[Config] bt_mic_enable invalid, defaulting to 1 (on)\n");
|
||||||
|
}
|
||||||
|
if (body->screen_brightness > 3) { // kBrightLevels has 4 entries (0..3)
|
||||||
|
body->screen_brightness = 0; // full brightness
|
||||||
|
printf("[Config] screen_brightness invalid, defaulting to 0 (full)\n");
|
||||||
|
}
|
||||||
|
if (body->controller_wakes_display > 1) { // 0xFF erased / upgrade → default ON
|
||||||
|
body->controller_wakes_display = 1;
|
||||||
|
printf("[Config] controller_wakes_display invalid, defaulting to 1 (on)\n");
|
||||||
|
}
|
||||||
if (body->config_version != CONFIG_VERSION) {
|
if (body->config_version != CONFIG_VERSION) {
|
||||||
body->config_version = CONFIG_VERSION;
|
body->config_version = CONFIG_VERSION;
|
||||||
printf("[Config] Warning: Config may breaking change\n");
|
printf("[Config] Warning: Config may breaking change\n");
|
||||||
|
|||||||
@@ -23,6 +23,37 @@ struct __attribute__((packed)) Config_body {
|
|||||||
uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace
|
uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace
|
||||||
uint8_t auto_haptics_gain; // [0,200] percent, default 100
|
uint8_t auto_haptics_gain; // [0,200] percent, default 100
|
||||||
uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz
|
uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz
|
||||||
|
// Lightbar (OLED Edition Phase H): persisted so the chosen mode/colors
|
||||||
|
// survive reboot and stick across all screens. lightbar_mode indexes the
|
||||||
|
// OLED Lightbar screen's mode list — 0=LIVE, 1..4=FAV0..3, 5=BREATHING,
|
||||||
|
// 6=RAINBOW, 7=FADE, 8=HOST (passthrough, the safe default that lets the
|
||||||
|
// host/game own the LED). Keep this numbering in sync with kNumLbModes /
|
||||||
|
// kLbModeHost in src/oled.cpp. Erased flash (0xFF) → HOST + white favorites.
|
||||||
|
uint8_t lightbar_mode;
|
||||||
|
uint8_t lb_fav_r[4];
|
||||||
|
uint8_t lb_fav_g[4];
|
||||||
|
uint8_t lb_fav_b[4];
|
||||||
|
// OLED idle power-ladder thresholds, in minutes. 0 = that tier disabled.
|
||||||
|
// Defaults preserve the original hardcoded ladder (2 min dim, 15 min off).
|
||||||
|
// Range [0,250] (0xFF erased flash → default via config_valid clamp). The
|
||||||
|
// idle timer is 64-bit µs so the full range is representable. Issue #5.
|
||||||
|
uint8_t screen_dim_timeout;
|
||||||
|
uint8_t screen_off_timeout;
|
||||||
|
// DualSense mic over Bluetooth (Phase I). 0 = off, 1 = on (default). When on,
|
||||||
|
// the dongle asserts the DS5 mic-enable bit so the controller streams its mic
|
||||||
|
// over BT and the dongle decodes it to the USB capture endpoint. Costs extra
|
||||||
|
// DS5 battery (keeps its audio subsystem awake), hence the toggle.
|
||||||
|
uint8_t bt_mic_enable;
|
||||||
|
// OLED brightness, as an index into kBrightLevels[] (src/oled.cpp). Persisted
|
||||||
|
// so the KEY1-long-press brightness choice survives a power cycle. Erased
|
||||||
|
// flash (0xFF) → clamped to 0 (full brightness) by config_valid. Issue #9.
|
||||||
|
uint8_t screen_brightness;
|
||||||
|
// When 0, controller input no longer keeps the OLED awake — only the OLED's
|
||||||
|
// own KEY0/KEY1 do — so the dim/off timers actually count down during
|
||||||
|
// gameplay and the panel can sleep while the controller is in use. Default 1
|
||||||
|
// preserves the original "any controller activity wakes the screen"
|
||||||
|
// behavior. Issues #8 (dim timeout never fired during play) and #9.
|
||||||
|
uint8_t controller_wakes_display;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct __attribute__((packed)) Config {
|
struct __attribute__((packed)) Config {
|
||||||
|
|||||||
+172
-7
@@ -22,6 +22,7 @@
|
|||||||
#include "battery_led.h"
|
#include "battery_led.h"
|
||||||
#endif
|
#endif
|
||||||
#include "oled.h"
|
#include "oled.h"
|
||||||
|
#include "remap.h"
|
||||||
|
|
||||||
// Pico SDK speciifically for waiting on conditions
|
// Pico SDK speciifically for waiting on conditions
|
||||||
#include "pico/critical_section.h"
|
#include "pico/critical_section.h"
|
||||||
@@ -30,6 +31,58 @@ int reportSeqCounter = 0;
|
|||||||
uint8_t packetCounter = 0;
|
uint8_t packetCounter = 0;
|
||||||
bool spk_active = false;
|
bool spk_active = false;
|
||||||
|
|
||||||
|
// Mic-debug instrumentation: count every 0x31 BT input report regardless
|
||||||
|
// of mic-tag bit, accumulate OR-mask of every byte-2 value seen (tells us
|
||||||
|
// which bits ever fire) and remember the last byte-2 value. Also track
|
||||||
|
// observed frame-length range. Surfaced on the OLED Diagnostics screen.
|
||||||
|
volatile uint32_t g_bt_31_packets = 0;
|
||||||
|
volatile uint32_t g_bt_other_packets = 0;
|
||||||
|
volatile uint8_t g_last_other_id = 0;
|
||||||
|
volatile uint8_t g_other_id_or = 0;
|
||||||
|
volatile uint8_t g_last_31_b2 = 0;
|
||||||
|
volatile uint8_t g_31_b2_or = 0;
|
||||||
|
volatile uint16_t g_31_len_min = 0xFFFF;
|
||||||
|
volatile uint16_t g_31_len_max = 0;
|
||||||
|
volatile uint8_t g_mic_prefix[6] = {0};
|
||||||
|
volatile uint8_t g_last_other_prefix[8] = {0};
|
||||||
|
volatile uint8_t g_last_any_prefix[16] = {0};
|
||||||
|
volatile uint16_t g_longest_len = 0;
|
||||||
|
volatile uint8_t g_longest_frame[80] = {0};
|
||||||
|
uint32_t bt_31_packet_count() { return g_bt_31_packets; }
|
||||||
|
uint8_t bt_31_last_byte2() { return g_last_31_b2; }
|
||||||
|
uint8_t bt_31_b2_or_mask() { return g_31_b2_or; }
|
||||||
|
uint16_t bt_31_len_min() { return g_31_len_min == 0xFFFF ? 0 : g_31_len_min; }
|
||||||
|
uint16_t bt_31_len_max() { return g_31_len_max; }
|
||||||
|
void bt_31_mic_prefix(uint8_t out[6]) {
|
||||||
|
for (int i = 0; i < 6; i++) out[i] = g_mic_prefix[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger-flow diagnostics. Counts host → dongle → BT path for adaptive
|
||||||
|
// trigger effects. Lets us tell which link in the chain breaks when games
|
||||||
|
// like Death Stranding 2 don't produce trigger tension via the dongle:
|
||||||
|
// out02_total - every 0x02 HID OUT report received from host
|
||||||
|
// out02_trig_allow - of those, how many set AllowRight/LeftTriggerFFB
|
||||||
|
// (valid_flag0 bits 2 & 3) — i.e. the host actually
|
||||||
|
// told us "apply trigger FFB"
|
||||||
|
// out02_to_bt - 0x02 reports that we forwarded to the controller as
|
||||||
|
// a BT 0x31 sub-0x10 packet (gated off when speaker is
|
||||||
|
// active; audio.cpp's 0x36 path carries state then)
|
||||||
|
// out02_trig_folded - of the trig_allow reports, how many arrived while the
|
||||||
|
// speaker stream was active and were therefore NOT sent as
|
||||||
|
// a standalone 0x31 — their trigger FFB was folded into the
|
||||||
|
// 0x36 audio frames via state[]. So trig_allow == to_bt's
|
||||||
|
// trigger share + this, proving the "missing" forwards
|
||||||
|
// (issue #6) aren't drops. Surfaced on the Diag screen.
|
||||||
|
// Surfaced on the OLED Diagnostics screen.
|
||||||
|
volatile uint32_t g_host_out02_total = 0;
|
||||||
|
volatile uint32_t g_host_out02_trig_allow = 0;
|
||||||
|
volatile uint32_t g_host_out02_to_bt = 0;
|
||||||
|
volatile uint32_t g_host_out02_trig_folded = 0;
|
||||||
|
uint32_t host_out02_total() { return g_host_out02_total; }
|
||||||
|
uint32_t host_out02_trig_allow() { return g_host_out02_trig_allow; }
|
||||||
|
uint32_t host_out02_to_bt() { return g_host_out02_to_bt; }
|
||||||
|
uint32_t host_out02_trig_folded() { return g_host_out02_trig_folded; }
|
||||||
|
|
||||||
uint8_t interrupt_in_data[63] = {
|
uint8_t interrupt_in_data[63] = {
|
||||||
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
|
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
|
||||||
0x08, 0x00, 0x00, 0x00, 0x52, 0x43, 0x30, 0x41,
|
0x08, 0x00, 0x00, 0x00, 0x52, 0x43, 0x30, 0x41,
|
||||||
@@ -66,7 +119,12 @@ void interrupt_loop() {
|
|||||||
|
|
||||||
// TODO: Refactor for better code reuse
|
// TODO: Refactor for better code reuse
|
||||||
if (get_config().polling_rate_mode != 2) {
|
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");
|
printf("[USBHID] tud_hid_report error\n");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -85,6 +143,9 @@ void interrupt_loop() {
|
|||||||
}
|
}
|
||||||
critical_section_exit(&report_cs);
|
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
|
// Only send to TinyUSB if we actually grabbed fresh data
|
||||||
if (should_send) {
|
if (should_send) {
|
||||||
if (!tud_hid_report(0x01, safe_report, 63)) {
|
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) {
|
void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
|
||||||
// printf("[Main] BT data callback: channel=%u len=%u\n", channel, len);
|
// printf("[Main] BT data callback: channel=%u len=%u\n", channel, len);
|
||||||
|
// Track ALL INTERRUPT input reports, not just 0x31. The mic stream
|
||||||
|
// may live on a different report ID — confirmed 2026-05-19 that data[2]
|
||||||
|
// bit 0 (and bit 1) is NOT a mic flag, just the report-type indicator;
|
||||||
|
// every "mic-tagged" frame turned out to be standard input.
|
||||||
|
if (channel == INTERRUPT && len > 1) {
|
||||||
|
if (data[1] == 0x31) g_bt_31_packets++;
|
||||||
|
else {
|
||||||
|
g_bt_other_packets++;
|
||||||
|
g_last_other_id = data[1];
|
||||||
|
g_other_id_or = (uint8_t)(g_other_id_or | data[1]);
|
||||||
|
for (uint16_t i = 0; i < 8 && i < len; i++) {
|
||||||
|
g_last_other_prefix[i] = data[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (len > 2) {
|
||||||
|
g_last_31_b2 = data[2];
|
||||||
|
g_31_b2_or = (uint8_t)(g_31_b2_or | data[2]);
|
||||||
|
}
|
||||||
|
if (len < g_31_len_min) g_31_len_min = len;
|
||||||
|
if (len > g_31_len_max) g_31_len_max = len;
|
||||||
|
for (uint16_t i = 0; i < 16 && i < len; i++) {
|
||||||
|
g_last_any_prefix[i] = data[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the entire content of the longest 0x31 frame we've
|
||||||
|
// seen. Long frames almost certainly carry the mic audio appended
|
||||||
|
// after the standard 63-byte input report — this lets us look
|
||||||
|
// at the trailing bytes directly via 0xFD diagnostic.
|
||||||
|
if (data[1] == 0x31 && len > g_longest_len) {
|
||||||
|
g_longest_len = len;
|
||||||
|
for (uint16_t i = 0; i < 80 && i < len; i++) {
|
||||||
|
g_longest_frame[i] = data[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mic-in tap (TEST): once the dongle asserts the mic-enable bit in the
|
||||||
|
// outgoing 0x36 audio report (pkt[4] bit 0, see audio.cpp — awalol
|
||||||
|
// confirmed this is the key), the DS5 streams its mic as a 71-byte Opus
|
||||||
|
// packet at data+4 of a 0x31 report with bit 1 of data[2] set. Route those
|
||||||
|
// to the mic decoder instead of treating them as a standard input report.
|
||||||
|
// The length guard (4-byte header + 71-byte Opus) keeps a stray short
|
||||||
|
// frame from over-reading. The diagnostic counters above still observe
|
||||||
|
// these frames, so the Diag screen's data[2] OR-mask will show bit 1 set
|
||||||
|
// once the enable bit takes effect.
|
||||||
|
// A mic-tagged 0x31 frame carries Opus audio at data+4, NOT a standard input
|
||||||
|
// report — so it must ALWAYS be diverted here (decoded when mic is on, dropped
|
||||||
|
// when off), never fall through to the input handler below. Letting it through
|
||||||
|
// would copy Opus bytes into interrupt_in_data and corrupt sticks/buttons.
|
||||||
|
if (channel == INTERRUPT && data[1] == 0x31 && ((data[2] >> 1) & 1)
|
||||||
|
&& len >= 75) {
|
||||||
|
if (get_config().bt_mic_enable) mic_add_queue(data + 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (channel == INTERRUPT && data[1] == 0x31) {
|
if (channel == INTERRUPT && data[1] == 0x31) {
|
||||||
if ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
|
if ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
|
||||||
set_headset(data[56] & 1);
|
set_headset(data[56] & 1);
|
||||||
@@ -114,12 +230,6 @@ void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We add the critical section here to avoid any race conditions when writing to the interrupt_in_data buffer,
|
|
||||||
// which is shared between the main loop and this callback.
|
|
||||||
// The critical section ensures that only one thread can access the buffer at a time,
|
|
||||||
// preventing data corruption and ensuring thread safety.
|
|
||||||
// We also set the report_dirty flag to true to indicate that new data is available
|
|
||||||
// and needs to be sent in the next interrupt report.
|
|
||||||
critical_section_enter_blocking(&report_cs);
|
critical_section_enter_blocking(&report_cs);
|
||||||
memcpy(interrupt_in_data, data + 3, 63);
|
memcpy(interrupt_in_data, data + 3, 63);
|
||||||
report_dirty = true;
|
report_dirty = true;
|
||||||
@@ -141,6 +251,47 @@ uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t
|
|||||||
(void) buffer;
|
(void) buffer;
|
||||||
(void) reqlen;
|
(void) reqlen;
|
||||||
|
|
||||||
|
// --- DualSense feature reports that Linux's hid_playstation reads at probe ---
|
||||||
|
// Without valid answers the kernel never creates a gamepad device, so games
|
||||||
|
// outside Steam Input (Heroic/Proton/native) see no controller. The host asks
|
||||||
|
// only for the report DATA (reqlen = report_size - 1); usbhid prepends the
|
||||||
|
// report-id byte itself. The two CRC'd reports validate crc32 over
|
||||||
|
// [0xA3 feature-seed, report_id, data...] in the last 4 bytes.
|
||||||
|
// hid_playstation (kernel) AND the game's native DualSense detection both read
|
||||||
|
// 0x09 (pairing), 0x20 (firmware) and 0x05 (calibration). The KERNEL only checks
|
||||||
|
// size + crc, but the GAME validates the actual CONTENT — so synthesized zeros
|
||||||
|
// pass the kernel yet get rejected by the game (a ~156x GET retry storm, and no
|
||||||
|
// native adaptive triggers). Serve the REAL controller data, which init_feature()
|
||||||
|
// caches from the controller over BT (get_feature_data returns it incl. the
|
||||||
|
// report-id at [0]). Fall back to a crc-valid synthetic answer ONLY when the
|
||||||
|
// controller isn't linked yet (USB-enumeration probe before the BT link), so the
|
||||||
|
// kernel still binds at that moment.
|
||||||
|
if (report_id == 0x09 || report_id == 0x20 || report_id == 0x05) {
|
||||||
|
if (reqlen == 0) return 0;
|
||||||
|
std::vector<uint8_t> real = get_feature_data(report_id, reqlen);
|
||||||
|
if (real.size() > 1) { // real cached response present
|
||||||
|
uint16_t n = (uint16_t)(real.size() - 1);
|
||||||
|
if (n > reqlen) n = reqlen;
|
||||||
|
memcpy(buffer, real.data() + 1, n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
memset(buffer, 0, reqlen);
|
||||||
|
if (report_id == 0x09) { // not linked yet: MAC-only stub
|
||||||
|
if (reqlen >= 6) bt_get_addr(buffer);
|
||||||
|
return reqlen;
|
||||||
|
}
|
||||||
|
if (reqlen < 5) return 0; // 0x20 / 0x05 stub: zeros + valid crc32
|
||||||
|
uint8_t tmp[2 + 64];
|
||||||
|
tmp[0] = 0xA3; tmp[1] = report_id;
|
||||||
|
memcpy(tmp + 2, buffer, reqlen - 4);
|
||||||
|
uint32_t crc = crc32_seeded(tmp, (size_t)(2 + (reqlen - 4)), 0);
|
||||||
|
buffer[reqlen - 4] = (uint8_t)(crc);
|
||||||
|
buffer[reqlen - 3] = (uint8_t)(crc >> 8);
|
||||||
|
buffer[reqlen - 2] = (uint8_t)(crc >> 16);
|
||||||
|
buffer[reqlen - 1] = (uint8_t)(crc >> 24);
|
||||||
|
return reqlen;
|
||||||
|
}
|
||||||
|
|
||||||
if (is_pico_cmd(report_id)) {
|
if (is_pico_cmd(report_id)) {
|
||||||
return pico_cmd_get(report_id, buffer, reqlen);
|
return pico_cmd_get(report_id, buffer, reqlen);
|
||||||
}
|
}
|
||||||
@@ -186,8 +337,19 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
|
|||||||
if (report_id == 0) {
|
if (report_id == 0) {
|
||||||
switch (buffer[0]) {
|
switch (buffer[0]) {
|
||||||
case 0x02: {
|
case 0x02: {
|
||||||
|
g_host_out02_total++;
|
||||||
|
// valid_flag0 lives at buffer[1] (right after the 0x02 report id).
|
||||||
|
// Bits 2 & 3 are AllowRight/LeftTriggerFFB.
|
||||||
|
if (bufsize > 1 && (buffer[1] & 0x0C)) {
|
||||||
|
g_host_out02_trig_allow++;
|
||||||
|
}
|
||||||
state_update(buffer + 1, bufsize - 1);
|
state_update(buffer + 1, bufsize - 1);
|
||||||
if (spk_active) {
|
if (spk_active) {
|
||||||
|
// Not forwarded as a standalone 0x31 — the trigger FFB just
|
||||||
|
// written into state[] rides the 0x36 audio frames instead.
|
||||||
|
// Count the trigger-bearing ones so the Diag screen shows
|
||||||
|
// trig_allow == to_bt(trig) + folded (issue #6: not drops).
|
||||||
|
if (bufsize > 1 && (buffer[1] & 0x0C)) g_host_out02_trig_folded++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
uint8_t outputData[78]{};
|
uint8_t outputData[78]{};
|
||||||
@@ -200,6 +362,7 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
|
|||||||
// memcpy(outputData + 3, buffer + 1, bufsize - 1);
|
// memcpy(outputData + 3, buffer + 1, bufsize - 1);
|
||||||
state_set(outputData + 3,sizeof(SetStateData));
|
state_set(outputData + 3,sizeof(SetStateData));
|
||||||
bt_write(outputData, sizeof(outputData));
|
bt_write(outputData, sizeof(outputData));
|
||||||
|
g_host_out02_to_bt++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,6 +427,7 @@ int main() {
|
|||||||
critical_section_init(&report_cs);
|
critical_section_init(&report_cs);
|
||||||
|
|
||||||
config_load();
|
config_load();
|
||||||
|
remap_load();
|
||||||
|
|
||||||
bt_init();
|
bt_init();
|
||||||
bt_register_data_callback(on_bt_data);
|
bt_register_data_callback(on_bt_data);
|
||||||
@@ -281,6 +445,7 @@ int main() {
|
|||||||
watchdog_update();
|
watchdog_update();
|
||||||
#endif
|
#endif
|
||||||
cyw43_arch_poll();
|
cyw43_arch_poll();
|
||||||
|
bt_connection_watchdog_tick();
|
||||||
tud_task();
|
tud_task();
|
||||||
audio_loop();
|
audio_loop();
|
||||||
interrupt_loop();
|
interrupt_loop();
|
||||||
|
|||||||
+729
-97
File diff suppressed because it is too large
Load Diff
+197
@@ -0,0 +1,197 @@
|
|||||||
|
// Button remapping. See remap.h. Flash-sector pattern mirrors slots.cpp;
|
||||||
|
// the apply logic + button set are ported from SundayMoments/DS5_Bridge.
|
||||||
|
|
||||||
|
#include "remap.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "hardware/flash.h"
|
||||||
|
#include "hardware/sync.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Source/target button indices. Order is arbitrary but MUST match the web
|
||||||
|
// editor's expectation (DS5Dongle-OLED-Config-Web src/protocol/remap.ts).
|
||||||
|
enum RemapButton : uint8_t {
|
||||||
|
RemapL2, RemapL1, RemapCreate, RemapDpadUp, RemapDpadLeft, RemapDpadDown,
|
||||||
|
RemapDpadRight, RemapL3, RemapR2, RemapR1, RemapOptions, RemapTriangle,
|
||||||
|
RemapCircle, RemapCross, RemapSquare, RemapR3, RemapButtonCount,
|
||||||
|
};
|
||||||
|
static_assert(RemapButtonCount == kRemapCount, "kRemapCount must match RemapButton");
|
||||||
|
|
||||||
|
// interrupt_in_data[8]: shoulders / sticks / system bits.
|
||||||
|
constexpr uint8_t kL1Bit = 0x01, kR1Bit = 0x02, kL2Bit = 0x04, kR2Bit = 0x08,
|
||||||
|
kCreateBit = 0x10, kOptionsBit = 0x20, kL3Bit = 0x40, kR3Bit = 0x80;
|
||||||
|
// interrupt_in_data[7]: D-pad hat (low nibble) + face buttons (high nibble).
|
||||||
|
constexpr uint8_t kSquareBit = 0x10, kCrossBit = 0x20, kCircleBit = 0x40,
|
||||||
|
kTriangleBit = 0x80, kDpadMask = 0x0F;
|
||||||
|
// D-pad hat values.
|
||||||
|
constexpr uint8_t kUp = 0, kUpRight = 1, kRight = 2, kDownRight = 3, kDown = 4,
|
||||||
|
kDownLeft = 5, kLeft = 6, kUpLeft = 7, kNeutral = 8;
|
||||||
|
|
||||||
|
constexpr uint32_t REMAP_MAGIC = 0x44533503u; // "DS5\x03"
|
||||||
|
constexpr uint32_t REMAP_FLASH_OFFSET = PICO_FLASH_SIZE_BYTES - 3u * FLASH_SECTOR_SIZE;
|
||||||
|
|
||||||
|
struct __attribute__((packed)) RemapData {
|
||||||
|
uint32_t magic;
|
||||||
|
uint8_t table[kRemapCount];
|
||||||
|
};
|
||||||
|
static_assert(sizeof(RemapData) <= FLASH_PAGE_SIZE);
|
||||||
|
static_assert(REMAP_FLASH_OFFSET % FLASH_SECTOR_SIZE == 0);
|
||||||
|
|
||||||
|
RemapData g_remap{};
|
||||||
|
bool g_active = false; // false = identity → remap_apply() fast-returns
|
||||||
|
uint16_t g_revision = 0;
|
||||||
|
|
||||||
|
const RemapData *flash_remap() {
|
||||||
|
return reinterpret_cast<const RemapData *>(XIP_BASE + REMAP_FLASH_OFFSET);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_identity() {
|
||||||
|
for (int i = 0; i < kRemapCount; i++) g_remap.table[i] = (uint8_t) i;
|
||||||
|
}
|
||||||
|
|
||||||
|
void recompute_active() {
|
||||||
|
g_active = false;
|
||||||
|
for (int i = 0; i < kRemapCount; i++) {
|
||||||
|
if (g_remap.table[i] != i) { g_active = true; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid_table(const uint8_t *t) {
|
||||||
|
for (int i = 0; i < kRemapCount; i++) {
|
||||||
|
if (t[i] >= kRemapCount && t[i] != 0xFF) return false; // <16 or disabled
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool save_to_flash() {
|
||||||
|
alignas(4) uint8_t page[FLASH_PAGE_SIZE];
|
||||||
|
memset(page, 0xff, sizeof(page));
|
||||||
|
memcpy(page, &g_remap, sizeof(g_remap));
|
||||||
|
|
||||||
|
const uint32_t interrupts = save_and_disable_interrupts();
|
||||||
|
flash_range_erase(REMAP_FLASH_OFFSET, FLASH_SECTOR_SIZE);
|
||||||
|
flash_range_program(REMAP_FLASH_OFFSET, page, sizeof(page));
|
||||||
|
restore_interrupts(interrupts);
|
||||||
|
|
||||||
|
RemapData verify{};
|
||||||
|
memcpy(&verify, flash_remap(), sizeof(verify));
|
||||||
|
if (memcmp(&verify, &g_remap, sizeof(g_remap)) == 0) {
|
||||||
|
printf("[Remap] flash write verified\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
printf("[Remap] flash write VERIFY FAILED\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dpad_has(uint8_t dir, RemapButton b) {
|
||||||
|
switch (b) {
|
||||||
|
case RemapDpadUp: return dir == kUp || dir == kUpRight || dir == kUpLeft;
|
||||||
|
case RemapDpadRight: return dir == kRight || dir == kUpRight || dir == kDownRight;
|
||||||
|
case RemapDpadDown: return dir == kDown || dir == kDownRight || dir == kDownLeft;
|
||||||
|
case RemapDpadLeft: return dir == kLeft || dir == kUpLeft || dir == kDownLeft;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t dpad_from(bool up, bool right, bool down, bool left) {
|
||||||
|
if (up && right && !down && !left) return kUpRight;
|
||||||
|
if (right && down && !up && !left) return kDownRight;
|
||||||
|
if (down && left && !up && !right) return kDownLeft;
|
||||||
|
if (left && up && !right && !down) return kUpLeft;
|
||||||
|
if (up && !down) return kUp;
|
||||||
|
if (right && !left) return kRight;
|
||||||
|
if (down && !up) return kDown;
|
||||||
|
if (left && !right) return kLeft;
|
||||||
|
return kNeutral;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void remap_load() {
|
||||||
|
memcpy(&g_remap, flash_remap(), sizeof(g_remap));
|
||||||
|
if (g_remap.magic != REMAP_MAGIC || !valid_table(g_remap.table)) {
|
||||||
|
printf("[Remap] flash sector empty/invalid, defaulting to identity\n");
|
||||||
|
g_remap.magic = REMAP_MAGIC;
|
||||||
|
set_identity();
|
||||||
|
save_to_flash();
|
||||||
|
}
|
||||||
|
recompute_active();
|
||||||
|
printf("[Remap] loaded (active=%d)\n", g_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
void remap_get(uint8_t out[kRemapCount]) { memcpy(out, g_remap.table, kRemapCount); }
|
||||||
|
|
||||||
|
uint16_t remap_revision() { return g_revision; }
|
||||||
|
|
||||||
|
bool remap_set(const uint8_t *table) {
|
||||||
|
if (!valid_table(table)) return false;
|
||||||
|
memcpy(g_remap.table, table, kRemapCount);
|
||||||
|
g_remap.magic = REMAP_MAGIC;
|
||||||
|
recompute_active();
|
||||||
|
g_revision++;
|
||||||
|
return save_to_flash();
|
||||||
|
}
|
||||||
|
|
||||||
|
void remap_apply(uint8_t *report) {
|
||||||
|
if (!g_active) return; // identity → no-op (hot-path fast return)
|
||||||
|
|
||||||
|
bool src[kRemapCount]{};
|
||||||
|
uint8_t src_analog[kRemapCount]{};
|
||||||
|
const uint8_t dir = report[7] & kDpadMask;
|
||||||
|
|
||||||
|
src[RemapL2] = (report[8] & kL2Bit) != 0;
|
||||||
|
src[RemapL1] = (report[8] & kL1Bit) != 0;
|
||||||
|
src[RemapCreate] = (report[8] & kCreateBit) != 0;
|
||||||
|
src[RemapDpadUp] = dpad_has(dir, RemapDpadUp);
|
||||||
|
src[RemapDpadLeft] = dpad_has(dir, RemapDpadLeft);
|
||||||
|
src[RemapDpadDown] = dpad_has(dir, RemapDpadDown);
|
||||||
|
src[RemapDpadRight] = dpad_has(dir, RemapDpadRight);
|
||||||
|
src[RemapL3] = (report[8] & kL3Bit) != 0;
|
||||||
|
src[RemapR2] = (report[8] & kR2Bit) != 0;
|
||||||
|
src[RemapR1] = (report[8] & kR1Bit) != 0;
|
||||||
|
src[RemapOptions] = (report[8] & kOptionsBit) != 0;
|
||||||
|
src[RemapTriangle] = (report[7] & kTriangleBit) != 0;
|
||||||
|
src[RemapCircle] = (report[7] & kCircleBit) != 0;
|
||||||
|
src[RemapCross] = (report[7] & kCrossBit) != 0;
|
||||||
|
src[RemapSquare] = (report[7] & kSquareBit) != 0;
|
||||||
|
src[RemapR3] = (report[8] & kR3Bit) != 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < kRemapCount; i++) src_analog[i] = src[i] ? 0xFF : 0;
|
||||||
|
src_analog[RemapL2] = report[4]; // L2 analog
|
||||||
|
src_analog[RemapR2] = report[5]; // R2 analog
|
||||||
|
|
||||||
|
bool tgt[kRemapCount]{};
|
||||||
|
uint8_t tgt_analog[kRemapCount]{};
|
||||||
|
for (int s = 0; s < kRemapCount; s++) {
|
||||||
|
const uint8_t t = g_remap.table[s];
|
||||||
|
if (t >= kRemapCount) continue; // 0xFF = disabled (source produces nothing)
|
||||||
|
if (src[s]) tgt[t] = true;
|
||||||
|
tgt_analog[t] = std::max(tgt_analog[t], src_analog[s]); // OR digital / max analog
|
||||||
|
}
|
||||||
|
|
||||||
|
report[4] = tgt_analog[RemapL2];
|
||||||
|
report[5] = tgt_analog[RemapR2];
|
||||||
|
|
||||||
|
// Byte 7 is entirely D-pad + face (all 8 bits) — rebuild it.
|
||||||
|
report[7] = dpad_from(tgt[RemapDpadUp], tgt[RemapDpadRight],
|
||||||
|
tgt[RemapDpadDown], tgt[RemapDpadLeft]);
|
||||||
|
if (tgt[RemapSquare]) report[7] |= kSquareBit;
|
||||||
|
if (tgt[RemapCross]) report[7] |= kCrossBit;
|
||||||
|
if (tgt[RemapCircle]) report[7] |= kCircleBit;
|
||||||
|
if (tgt[RemapTriangle]) report[7] |= kTriangleBit;
|
||||||
|
|
||||||
|
// Byte 8 is entirely shoulders/sticks/Create/Options (all 8 bits) — rebuild it.
|
||||||
|
report[8] = 0;
|
||||||
|
if (tgt[RemapL1]) report[8] |= kL1Bit;
|
||||||
|
if (tgt[RemapR1]) report[8] |= kR1Bit;
|
||||||
|
if (tgt[RemapL2]) report[8] |= kL2Bit;
|
||||||
|
if (tgt[RemapR2]) report[8] |= kR2Bit;
|
||||||
|
if (tgt[RemapCreate]) report[8] |= kCreateBit;
|
||||||
|
if (tgt[RemapOptions]) report[8] |= kOptionsBit;
|
||||||
|
if (tgt[RemapL3]) report[8] |= kL3Bit;
|
||||||
|
if (tgt[RemapR3]) report[8] |= kR3Bit;
|
||||||
|
}
|
||||||
+43
@@ -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
|
||||||
+20
-2
@@ -6,6 +6,12 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
|
#include "state_mgr.h"
|
||||||
|
|
||||||
|
// Set by the OLED lightbar service (src/oled.cpp). While true, the firmware
|
||||||
|
// owns the lightbar (an OLED mode or the charging pulse) and the host's
|
||||||
|
// AllowLedColor writes are suppressed below so they can't stomp it.
|
||||||
|
extern bool g_lightbar_override;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
|
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
|
||||||
@@ -19,7 +25,7 @@ namespace {
|
|||||||
static constexpr uint8_t state_init_data[63] = {
|
static constexpr uint8_t state_init_data[63] = {
|
||||||
0xfd, 0xf7, 0x0, 0x0,
|
0xfd, 0xf7, 0x0, 0x0,
|
||||||
0x7f, 0x64, // Headphones, Speaker
|
0x7f, 0x64, // Headphones, Speaker
|
||||||
0xff, 0x9, 0x0, 0x0F, 0x0, 0x0, 0x0, 0x0,
|
0x40, 0x9, 0x0, 0x00, 0x0, 0x0, 0x0, 0x0, // VolumeMic=64, MuteControl all clear (no PowerSave)
|
||||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa,
|
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa,
|
||||||
@@ -41,6 +47,18 @@ void state_set(uint8_t *data, const uint8_t size) {
|
|||||||
memcpy(data, state, size);
|
memcpy(data, state, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void state_set_led(uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
state[offsetof(SetStateData, LedRed) + 0] = r;
|
||||||
|
state[offsetof(SetStateData, LedRed) + 1] = g;
|
||||||
|
state[offsetof(SetStateData, LedRed) + 2] = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b) {
|
||||||
|
*r = state[offsetof(SetStateData, LedRed) + 0];
|
||||||
|
*g = state[offsetof(SetStateData, LedRed) + 1];
|
||||||
|
*b = state[offsetof(SetStateData, LedRed) + 2];
|
||||||
|
}
|
||||||
|
|
||||||
void state_update(const uint8_t *data, const uint8_t size) {
|
void state_update(const uint8_t *data, const uint8_t size) {
|
||||||
if (size < sizeof(SetStateData)) {
|
if (size < sizeof(SetStateData)) {
|
||||||
printf(
|
printf(
|
||||||
@@ -147,7 +165,7 @@ void state_update(const uint8_t *data, const uint8_t size) {
|
|||||||
sizeof(uint8_t)
|
sizeof(uint8_t)
|
||||||
);
|
);
|
||||||
copy_if_allowed(
|
copy_if_allowed(
|
||||||
update.AllowLedColor,
|
update.AllowLedColor && !g_lightbar_override,
|
||||||
offsetof(SetStateData, LedRed),
|
offsetof(SetStateData, LedRed),
|
||||||
sizeof(update.LedRed) * 3
|
sizeof(update.LedRed) * 3
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,17 @@
|
|||||||
#ifndef DS5_BRIDGE_STATE_MGR_H
|
#ifndef DS5_BRIDGE_STATE_MGR_H
|
||||||
#define DS5_BRIDGE_STATE_MGR_H
|
#define DS5_BRIDGE_STATE_MGR_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
void state_init();
|
void state_init();
|
||||||
void state_set(uint8_t *data, const uint8_t size);
|
void state_set(uint8_t *data, const uint8_t size);
|
||||||
void state_update(const uint8_t *data, const uint8_t size);
|
void state_update(const uint8_t *data, const uint8_t size);
|
||||||
|
|
||||||
|
// Lightbar RGB lives in the persistent state[] block (SetStateData LedRed/
|
||||||
|
// Green/Blue) that gets stamped into every outbound BT packet. The OLED
|
||||||
|
// lightbar service writes it directly so a firmware-chosen color rides every
|
||||||
|
// host/audio frame instead of only the transient send_lightbar_color() packet.
|
||||||
|
void state_set_led(uint8_t r, uint8_t g, uint8_t b);
|
||||||
|
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b);
|
||||||
|
|
||||||
#endif //DS5_BRIDGE_STATE_MGR_H
|
#endif //DS5_BRIDGE_STATE_MGR_H
|
||||||
|
|||||||
+1
-1
@@ -127,7 +127,7 @@
|
|||||||
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX CFG_TUD_AUDIO_FUNC_1_FORMAT_1_EP_SZ_OUT
|
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX CFG_TUD_AUDIO_FUNC_1_FORMAT_1_EP_SZ_OUT
|
||||||
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX CFG_TUD_AUDIO_FUNC_1_FORMAT_1_EP_SZ_IN
|
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX CFG_TUD_AUDIO_FUNC_1_FORMAT_1_EP_SZ_IN
|
||||||
|
|
||||||
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ (3 * CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX)
|
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ (16 * CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX)
|
||||||
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SW_BUF_SZ (4 * CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX)
|
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SW_BUF_SZ (4 * CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX)
|
||||||
|
|
||||||
// Enable OUT EP (speaker) and IN EP (mic)
|
// Enable OUT EP (speaker) and IN EP (mic)
|
||||||
|
|||||||
+40
-21
@@ -26,6 +26,8 @@
|
|||||||
#include "bsp/board_api.h"
|
#include "bsp/board_api.h"
|
||||||
#include "tusb.h"
|
#include "tusb.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "bt.h"
|
||||||
|
#include "slots.h"
|
||||||
|
|
||||||
#ifndef ENABLE_SERIAL
|
#ifndef ENABLE_SERIAL
|
||||||
#define ENABLE_SERIAL 0
|
#define ENABLE_SERIAL 0
|
||||||
@@ -370,7 +372,7 @@ uint8_t descriptor_configuration[] = {
|
|||||||
0x00, // bCountryCode: Not localized
|
0x00, // bCountryCode: Not localized
|
||||||
0x01, // bNumDescriptors: 1 report descriptor
|
0x01, // bNumDescriptors: 1 report descriptor
|
||||||
0x22, // bDescriptorType: Report
|
0x22, // bDescriptorType: Report
|
||||||
0x41, 0x01, // wDescriptorLength: 321 (0x0141) DS
|
0x21, 0x01, // wDescriptorLength: 289 (0x0121) DS (F6-F9 removed to byte-match a real DS5)
|
||||||
// 0xB5, 0x01, // wDescriptorLength: 437 (0x01B5) DSE
|
// 0xB5, 0x01, // wDescriptorLength: 437 (0x01B5) DSE
|
||||||
|
|
||||||
// Endpoint Descriptor (HID IN: EP4)
|
// Endpoint Descriptor (HID IN: EP4)
|
||||||
@@ -416,7 +418,7 @@ uint8_t const *tud_descriptor_configuration_cb(uint8_t index) {
|
|||||||
descriptor_configuration[offset - 1] = bInterval;
|
descriptor_configuration[offset - 1] = bInterval;
|
||||||
descriptor_configuration[offset - 8] = bInterval;
|
descriptor_configuration[offset - 8] = bInterval;
|
||||||
if (ds_mode()) {
|
if (ds_mode()) {
|
||||||
descriptor_configuration[offset - 16] = 0x41;
|
descriptor_configuration[offset - 16] = 0x21; // wDescriptorLength lo = 289 (0x0121); F6-F9 removed to byte-match a real DS5
|
||||||
}else {
|
}else {
|
||||||
descriptor_configuration[offset - 16] = 0xB5;
|
descriptor_configuration[offset - 16] = 0xB5;
|
||||||
}
|
}
|
||||||
@@ -569,26 +571,17 @@ uint8_t const desc_hid_report_ds[] = {
|
|||||||
0x09, 0x36, // Usage (0x36)
|
0x09, 0x36, // Usage (0x36)
|
||||||
0x95, 0x03, // Report Count (3)
|
0x95, 0x03, // Report Count (3)
|
||||||
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
|
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
|
||||||
0x85, 0xF6, // Report ID (-10)
|
// NOTE: web-config feature reports 0xF6-0xF9 are intentionally NOT declared
|
||||||
0x09, 0x37, // Usage (Vendor 0x37)
|
// here. Declaring them made this descriptor 321 bytes vs a genuine DS5's 289,
|
||||||
0x95, 0x3F, // Report Count (63)
|
// and games' native DualSense parser rejected the mismatch -> no adaptive
|
||||||
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
|
// triggers in-game (the long-standing "triggers only work in the OLED test"
|
||||||
0x85, 0xF7, // Report ID (-9)
|
// bug). The firmware still HANDLES 0xF6-0xF9 (src/cmd.cpp); on Linux they
|
||||||
0x09, 0x38, // Usage (Vendor 0x38)
|
// work fine undeclared over hidraw, exactly like 0xFD. Trade-off: the browser
|
||||||
0x95, 0x3F, // Report Count (63)
|
// WebHID config tool can't reach them (OLED + hidraw config still work).
|
||||||
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
|
|
||||||
0x85, 0xF8, // Report ID (-8)
|
|
||||||
0x09, 0x39, // Usage (Vendor 0x39)
|
|
||||||
0x95, 0x3F, // Report Count (63)
|
|
||||||
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
|
|
||||||
0x85, 0xF9, // Report ID (-7)
|
|
||||||
0x09, 0x3A, // Usage (Vendor 0x3A)
|
|
||||||
0x95, 0x3F, // Report Count (63)
|
|
||||||
0xB1, 0x02, // Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
|
|
||||||
0xC0, // End Collection
|
0xC0, // End Collection
|
||||||
// 321 bytes
|
// 289 bytes — byte-identical to a real DualSense
|
||||||
};
|
};
|
||||||
static_assert(sizeof(desc_hid_report_ds) == 0x0141);
|
static_assert(sizeof(desc_hid_report_ds) == 0x0121);
|
||||||
|
|
||||||
uint8_t const desc_hid_report_dse[] = {
|
uint8_t const desc_hid_report_dse[] = {
|
||||||
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
|
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
|
||||||
@@ -858,9 +851,35 @@ uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
|
|||||||
chr_count = 1;
|
chr_count = 1;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case STRID_SERIAL:
|
case STRID_SERIAL: {
|
||||||
|
// Present like a REAL DualSense: USB serial == controller MAC, the
|
||||||
|
// same value the 0x09 pairing-info feature report returns. Before
|
||||||
|
// this the serial was the Pico flash unique id, so the host saw two
|
||||||
|
// identities for one controller (flash-id over USB, MAC over 0x09)
|
||||||
|
// and Wine/Steam device-matching choked. Prefer the live connected
|
||||||
|
// MAC; fall back to the current slot's stored MAC (known at boot,
|
||||||
|
// before BT connects); finally fall back to the flash id if never
|
||||||
|
// paired so the descriptor is always valid.
|
||||||
|
uint8_t mac[6] = {0};
|
||||||
|
bool have = false;
|
||||||
|
bt_get_addr(mac);
|
||||||
|
for (int i = 0; i < 6; ++i) if (mac[i]) { have = true; break; }
|
||||||
|
if (!have) {
|
||||||
|
slot_get_addr(get_config().current_slot, mac);
|
||||||
|
for (int i = 0; i < 6; ++i) if (mac[i]) { have = true; break; }
|
||||||
|
}
|
||||||
|
if (have) {
|
||||||
|
static const char hexd[] = "0123456789ABCDEF";
|
||||||
|
for (int i = 0; i < 6; ++i) {
|
||||||
|
_desc_str[1 + i * 2] = hexd[(mac[i] >> 4) & 0x0F];
|
||||||
|
_desc_str[1 + i * 2 + 1] = hexd[mac[i] & 0x0F];
|
||||||
|
}
|
||||||
|
chr_count = 12;
|
||||||
|
} else {
|
||||||
chr_count = board_usb_get_serial(_desc_str + 1, 32);
|
chr_count = board_usb_get_serial(_desc_str + 1, 32);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Note: the 0xEE index string is a Microsoft OS 1.0 Descriptors.
|
// Note: the 0xEE index string is a Microsoft OS 1.0 Descriptors.
|
||||||
|
|||||||
Reference in New Issue
Block a user