Compare commits

...
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.8 878a742bfc docs(changelog): cut v0.6.11 — triggers fix + CtrlWake + audio retiming
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:19:16 -06:00
MarcelineVPQandClaude Opus 4.8 84393c00e8 wip: park audio-crackle investigation + haptic/OLED defaults + debug tooling
Preserves in-progress work that had been sitting uncommitted in the working
tree (predates this session) so it is protected in git history. This is NOT a
verified/shipped fix — it is parked. Audio path is unverified on hardware and
still carries debug instrumentation.

- src/audio.cpp: drop the 512->480 resampler and retime the 0x36 frame to a
  true 10ms/100Hz grid (SAMPLE_SIZE 64->60, 480-sample buffer) to chase the
  ~45kHz-vs-48kHz speaker underrun theory behind the crackle; dynamic speaker
  sub-report offset; debug printf + Opus-frame-dump instrumentation.
- src/config.cpp: default audio_buffer_length 64->16; auto_haptics_enable
  default 1 (Fallback) -> 0 (Off).
- src/oled.cpp: chunked non-blocking SPI flush (issue #7 OLED-stall angle) and
  a battery-% midpoint display tweak.
- scripts/: audio debug helpers (opus-dump decode, pi audio test, sine ch1/2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:11:39 -06:00
MarcelineVPQandClaude Opus 4.8 71cead401d fix: adaptive triggers + opt-in OLED sleep + brightness persistence
Closes the three non-audio user issues (audio path untouched):

- #6: adaptive trigger FFB was silently dropped. state_update() copied the
  RightTriggerFFB/LeftTriggerFFB params into the outgoing state but never set
  the Allow{Right,Left}TriggerFFB apply-bits in byte 0, so the DS5 discarded
  them on BOTH the standalone 0x31 path and the 0x36 audio-frame fold, while
  direct USB worked. Mirror the host's two allow-flags in, like the rumble
  flags already were (matches what the on-device Trigger Test screen does).

- #8/#9: new CtrlWake setting (default on = unchanged behavior). Set off and
  controller input no longer keeps the OLED awake, so the dim/off timeouts run
  during gameplay and the panel can sleep while the controller is in use; only
  KEY0/KEY1 wake it.

- #9: OLED brightness (screen_brightness) now persists across a power cycle
  instead of resetting to full on every boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:58:33 -06:00
MarcelineVPQandClaude Opus 4.7 8fc369b1af docs(readme): document gyro-tilt rework + L3/R3 indicator (EN + CN)
Bring both READMEs up to v0.6.10's user-facing capability:
- Status screen: note the L3/R3 stick-click inverse-flash indicator and
  the ~Nm charge-time token next to the battery.
- Gyro Tilt screen: per-unit factory IMU calibration, dot centres when the
  controller lies flat, and tilt moves the dot in the matching direction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:45:49 -06:00
MarcelineVPQandClaude Opus 4.7 c879b24364 docs(changelog): cut v0.6.10 — gyro tilt rework + L3/R3 indicator + charge-ETA
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:20:30 -06:00
MarcelineVPQandClaude Opus 4.7 c7e6c5b914 feat(oled): IMU gyro calibration + centred, correct-direction tilt dot
Rework the Gyro Tilt screen (and the tilt->RGB lightbar) using the DS5's
own factory IMU calibration, and fix two long-standing tilt-dot quirks.
All three are display-only — the host input report (every gyro + accel
axis) is still forwarded byte-for-byte, so in-game motion is unaffected.

- Calibration: parse feature report 0x05 (already cached by bt.cpp) into
  per-axis bias + sensitivity and apply (raw-bias)*sens to the accel the
  tilt visuals use, keeping the +-8192 == 1g scale. Re-read per connection
  so it tracks across the 4 pairing slots; sanity-gated with raw fallback.
  New side-effect-free bt_peek_feature() accessor (never issues an L2CAP
  request, safe to poll). Parse/apply mirror SDL's SDL_hidapi_ps5.c (zlib).
- Centred when flat: drive the dot from X (roll) + Z (pitch) 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: negate both axes so the dot follows the tilt (tilt left -> dot
  left, tilt forward -> dot up) instead of mirroring it.

Also fold in this session's charge-ETA robustness fix: cap each timed 10%
step at 30 min and take the median over the last 5 steps, so one slow step
can't balloon the estimate (was reading ~222m at 70% off a single ~47-min
step). Shares src/oled.cpp with the gyro work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:16:51 -06:00
MarcelineVPQandClaude Opus 4.7 7e21a1fbea docs(changelog): note L3/R3 Status-screen click indicator under [Unreleased]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:39:40 -06:00
MarcelineVPQandClaude Opus 4.7 3556cdedb3 feat(oled): invert the stick box on L3/R3 click (Status screen)
The two analog-stick boxes on the Status screen had no L3/R3 (stick-click)
indicator. Add a rect_invert() helper and XOR-invert the whole box on click
so it flashes inverse — the position dot inverts with it (black dot on a
white box). Bits 0x40 (L3) / 0x80 (R3) on interrupt_in_data[8].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:36:40 -06:00
MarcelineVPQandClaude Opus 4.7 a7f20cfd5a docs(cn): rewrite README.CN.md to match the OLED Edition README
The Chinese README was still the upstream stub (awalol.eu.org links, Pico W
notes, USB-wake branch) — never adapted for this fork. Replaced with a full
translation of the current README: web config tool (incl. Remap tab), button
remapping, BT mic + PLC, all 11 OLED screens, overclock, build, diagnostics,
USB 3.0 interference, and Zacksly CC BY 3.0 credit.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:44:32 -06:00
21 changed files with 1553 additions and 120 deletions
+5 -10
View File
@@ -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:
+63
View File
@@ -10,6 +10,69 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
--- ---
## [0.6.11-oled-edition] — 2026-06-02
Headline: **adaptive triggers now actually work through the dongle** (#6) — host trigger force-feedback was being forwarded with its enable bits cleared, so the DualSense silently discarded it; this is the fix. Also adds an opt-in **`CtrlWake`** setting so the OLED can sleep while you play (#8/#9), persists the **OLED brightness** choice across power cycles (#9), and ships an in-progress **speaker-audio retiming** pass aimed at the periodic crackle (#7). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.11-oled-edition) (built by `.github/workflows/release.yml`).
### Added
- **`CtrlWake` setting — let the OLED sleep while the controller is in use (issues #8, #9).** The idle power-ladder (dim → off) previously never fired during gameplay, because every controller input bumped the activity timer and kept the panel awake — so a configured dim/off timeout effectively did nothing while playing. New Settings item `CtrlWake` (default **on**, preserving the old behavior); set it **off** and controller input no longer wakes the screen — only the OLED's KEY0/KEY1 do — so the dim/off timers count down during play and the panel can sleep, requiring a button press to wake.
### Changed
- **Speaker audio path retimed onto a true 10 ms / 100 Hz grid (issue #7, experimental).** The old path resampled 512→480 samples and shipped a 480-sample Opus frame on the haptic-gated cadence (~every 10.667 ms ≈ 45 kHz into the DS5's free-running 48 kHz DAC), a ~6.25 % underrun that produced a periodic gap/crackle. The resampler is removed and the `0x36` frame now carries exactly one native 10 ms / 48 kHz Opus frame (`SAMPLE_SIZE` 64→60, 480-sample buffer), with the speaker sub-report offset computed from the haptic block length rather than hard-coded. This is an in-progress change aimed at the long-standing crackle; the audio path also carries debug counters (inert in the production UF2, visible only with `ENABLE_SERIAL`).
- **`audio_buffer_length` now defaults to 16** (was 64) — a lower buffer avoids the DS5's periodic re-buffer gap. Existing saved configs keep their value; this only affects fresh flashes / Reset-to-defaults.
- **`auto_haptics_enable` now defaults to Off** (was Fallback) — the speaker-derived rumble fallback could produce erratic haptics, so it is opt-in.
- **OLED SPI flush is now chunked / non-blocking.** The framebuffer is sent in row-chunks across main-loop iterations instead of one ~1.1 ms blocking transfer, so the OLED no longer stalls `tud_task` / `audio_loop` / BTstack polling while it refreshes (the OLED-on side of the #7 audio distortion).
- **Battery percentage shows the mid-point of each 10 % band** (5, 15, …, 95, 100 %), matching the kernel `hid-playstation` driver and Steam, instead of the band floor.
### Fixed
- **Adaptive trigger effects now work through the dongle (issue #6).** Host-sent trigger force-feedback was silently dropped: `state_update()` copied the 11-byte `RightTriggerFFB`/`LeftTriggerFFB` parameter blocks into the outgoing controller-state but never set the `AllowRightTriggerFFB`/`AllowLeftTriggerFFB` "apply" bits in byte 0, so the DualSense received the data with the enable flags cleared and discarded it. Affected **both** output paths (standalone `0x31` and the `0x36` audio-frame fold), which is why triggers felt absent through the dongle but worked on direct USB. The on-dongle Trigger Test screen always set those bits directly (`0x0C`), which is why it worked. Now the host's two allow-flags are mirrored into the state alongside the FFB data, like the rumble flags already were.
- **OLED brightness now persists across a power cycle (issue #9).** The KEY1-long-press brightness level was a runtime-only value that reset to full on every boot; it is now stored in config and restored at init.
---
## [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 ## [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. 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.
+1
View File
@@ -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)
+388 -37
View File
@@ -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 DualSenseVID `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 # 实时蓝牙 RSSIdBm
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%** —— 派生信号增益,0200%,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) —— 扬声器报告数据包
+5 -2
View File
@@ -18,6 +18,7 @@ The web tool is a one-stop shop — **no installs, no command line, no `picotool
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use. > **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.
- **Remap tab** — visual button remapper: click a button on a live DualSense diagram to reassign it to any other (the shoulders/triggers float to the corners as labeled glyphs). The map is stored on the dongle and applied before the host sees the report, so it works in every game and on every OS. Powered by WebHID.
- **OLED Preview tab** — pixel-perfect emulation of all 11 OLED screens. Use the in-page KEY0/KEY1 buttons (or the controller's △ / R1 / D-pad when a DualSense is paired) to navigate. Adaptive triggers actually fire on the controller when you cycle the Trigger Test preset. - **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.
@@ -43,6 +44,7 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
**OLED Edition additions:** **OLED Edition additions:**
- Optional Pico-OLED-1.3 status display with **11 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, RSSI, VU meters, settings) - 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
@@ -157,6 +159,7 @@ This was long believed impossible — earlier versions of this fork documented i
- Mic audio is **mono** (decoded mono, duplicated across the stereo capture endpoint). - 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. - 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. - 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
@@ -262,7 +265,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">
@@ -297,7 +300,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">
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Decode raw Opus frames captured from firmware serial output.
Reads [OPUS_FRAME_N] hex lines from stdin or a file, decodes each frame
with libopus, and writes the result as a WAV file + prints a summary
(peak amplitude, zero-crossing rate) to diagnose encoder output quality.
"""
import ctypes
import struct
import sys
import wave
import re
lib = ctypes.cdll.LoadLibrary("libopus.so.0")
SAMPLE_RATE = 48000
CHANNELS = 2
FRAME_SIZE = 480 # 10ms at 48kHz
# Create decoder
err = ctypes.c_int(0)
decoder = lib.opus_decoder_create(SAMPLE_RATE, CHANNELS, ctypes.byref(err))
if err.value != 0:
print(f"opus_decoder_create failed: {err.value}", file=sys.stderr)
sys.exit(1)
infile = sys.argv[1] if len(sys.argv) > 1 else "/tmp/ds5_opus.log"
with open(infile) as f:
lines = f.readlines()
frames = []
for line in lines:
m = re.match(r'\[OPUS_FRAME_\d+\]\s+([0-9a-fA-F]+)', line.strip())
if m:
frames.append(bytes.fromhex(m.group(1)))
if not frames:
print("No [OPUS_FRAME_N] lines found in input.", file=sys.stderr)
sys.exit(1)
print(f"Found {len(frames)} Opus frames, decoding...")
all_pcm = b""
for i, frame_data in enumerate(frames):
pcm = (ctypes.c_int16 * (FRAME_SIZE * CHANNELS))()
ret = lib.opus_decode(
decoder,
frame_data, len(frame_data),
pcm, FRAME_SIZE,
0 # no FEC
)
if ret < 0:
errstr = lib.opus_strerror(ret)
print(f" Frame {i+1}: DECODE ERROR {ret} ({ctypes.string_at(errstr).decode()})")
continue
samples = list(pcm)
peak = max(abs(s) for s in samples)
nonzero = sum(1 for s in samples if s != 0)
print(f" Frame {i+1}: {ret} samples decoded, peak={peak}, nonzero={nonzero}/{len(samples)}")
print(f" TOC=0x{frame_data[0]:02x} first 8 bytes: {frame_data[:8].hex()}")
all_pcm += struct.pack(f"<{ret * CHANNELS}h", *samples[:ret * CHANNELS])
outpath = "/tmp/ds5_opus_decoded.wav"
with wave.open(outpath, "w") as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(all_pcm)
print(f"\nDecoded audio written to {outpath}")
print(f"Play with: aplay {outpath}")
print(f"View spectrogram: sox {outpath} -n spectrogram -o /tmp/ds5_opus_spectrogram.png")
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Send a 440 Hz sine to the DualSense speaker over Bluetooth from a Linux host
(Raspberry Pi), via raw /dev/hidraw writes — replicating the DS5Dongle firmware's
0x36 audio report BYTE-FOR-BYTE (same Opus settings, same SetStateData, same CRC).
Purpose: test whether a NON-tunneled BT path (Pi's BlueZ over a real UART) delivers
clean audio to the DS5 speaker, vs the Pico 2 W's BT-over-gSPI tunnel. If clean here,
the Pico's tunnel is the crackle culprit.
Pair the DS5 first (bluetoothctl), then run as root (hidraw needs RW):
sudo python3 pi_ds5_audio_test.py [seconds] [--headset] [--audbuf N]
"""
import ctypes, struct, math, sys, os, glob, time
# ---- libopus via ctypes (same loader style as decode_opus_dump.py) ----
opus = ctypes.cdll.LoadLibrary("libopus.so.0")
opus.opus_encoder_create.restype = ctypes.c_void_p
opus.opus_encoder_create.argtypes = [ctypes.c_int32, ctypes.c_int, ctypes.c_int,
ctypes.POINTER(ctypes.c_int)]
opus.opus_encode_float.restype = ctypes.c_int32
opus.opus_encode_float.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_float),
ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte),
ctypes.c_int32]
# opus_encoder_ctl is variadic; leave argtypes unset and pass c_int values.
OPUS_APPLICATION_AUDIO = 2049
OPUS_SET_BITRATE_REQUEST = 4002
OPUS_SET_VBR_REQUEST = 4006
OPUS_SET_COMPLEXITY_REQUEST = 4010
RATE, CH, FRAME, OPUS_BYTES = 48000, 2, 480, 200 # 480 = 10 ms; 200 B = 160 kbps CBR
def make_encoder():
err = ctypes.c_int(0)
enc = opus.opus_encoder_create(RATE, CH, OPUS_APPLICATION_AUDIO, ctypes.byref(err))
if err.value != 0 or not enc:
sys.exit(f"opus_encoder_create failed: {err.value}")
enc_p = ctypes.c_void_p(enc)
# match firmware core1_entry(): 160 kbps, CBR, complexity 0
opus.opus_encoder_ctl(enc_p, OPUS_SET_BITRATE_REQUEST, ctypes.c_int(OPUS_BYTES*8*100))
opus.opus_encoder_ctl(enc_p, OPUS_SET_VBR_REQUEST, ctypes.c_int(0))
opus.opus_encoder_ctl(enc_p, OPUS_SET_COMPLEXITY_REQUEST, ctypes.c_int(0))
return enc_p
# ---- 63-byte SetStateData, verbatim from firmware src/state_mgr.cpp ----
STATE_DATA = bytes([
0xfd, 0xf7, 0x00, 0x00,
0x7f, 0x64, # VolHeadphonesMax, VolSpeaker
0x40, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a,
0x07, 0x00, 0x00, 0x02, 0x01,
0x00,
0xff, 0xd7, 0x00, # RGB
]) + bytes(16) # trailing zeros -> 63 total
assert len(STATE_DATA) == 63
# ---- CRC-32, 0xA2-seeded, verbatim from firmware src/utils.h ----
def ds_crc32(data):
crc = (~0xEADA2D49) & 0xFFFFFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 1: crc = (crc >> 1) ^ 0xEDB88320
else: crc >>= 1
return (~crc) & 0xFFFFFFFF
REPORT_SIZE, SAMPLE_SIZE = 398, 64 # original firmware layout
def build_packet(payload, seq, counter, headset, audbuf):
pkt = bytearray(REPORT_SIZE)
pkt[0] = 0x36
pkt[1] = (seq & 0x0F) << 4
pkt[2] = 0x11 | 0x80 # 0x91
pkt[3] = 7
pkt[4] = 0xFE # audio enable, mic OFF
for i in range(5, 10): pkt[i] = audbuf & 0xFF
pkt[10] = counter & 0xFF
pkt[11] = 0x10 | 0x80 # 0x90 SetStateData
pkt[12] = 63
pkt[13:76] = STATE_DATA
pkt[76] = 0x12 | 0x80 # 0x92 haptic
pkt[77] = SAMPLE_SIZE # haptic data pkt[78..141] = 0 (silent)
pkt[142] = (0x16 if headset else 0x13) | 0x80 # 0x96 headset / 0x93 speaker
pkt[143] = OPUS_BYTES
pkt[144:144+OPUS_BYTES] = payload
struct.pack_into("<I", pkt, REPORT_SIZE-4, ds_crc32(pkt[:REPORT_SIZE-4]))
return bytes(pkt)
def find_ds5_hidraw():
for ue in glob.glob("/sys/class/hidraw/hidraw*/device/uevent"):
txt = open(ue).read().upper()
if "054C" in txt and ("0CE6" in txt or "0DF2" in txt):
return "/dev/" + ue.split("/")[4]
return None
def main():
headset = "--headset" in sys.argv
audbuf = int(sys.argv[sys.argv.index("--audbuf")+1]) if "--audbuf" in sys.argv else 64
pos = [a for a in sys.argv[1:] if not a.startswith("--") and a.isdigit()]
secs = int(pos[0]) if pos else 15
node = find_ds5_hidraw()
if not node:
sys.exit("DualSense hidraw node not found — paired & connected? (bluetoothctl)")
print(f"DS5 hidraw: {node} | output: {'HEADSET' if headset else 'SPEAKER'} | "
f"AudBuf={audbuf} | {secs}s")
enc = make_encoder()
pcm = (ctypes.c_float * (FRAME*CH))()
out = (ctypes.c_ubyte * OPUS_BYTES)()
# Pre-build every packet so the timed send loop is pure I/O (no compute jitter).
n_frames = secs * 100
packets, seq, counter, samp = [], 0, 0, 0
first_nb = None
for _ in range(n_frames):
for i in range(FRAME):
v = 0.5 * math.sin(2*math.pi*440.0*samp/RATE)
pcm[i*2] = v; pcm[i*2+1] = v; samp += 1
nb = opus.opus_encode_float(enc, pcm, FRAME, out, OPUS_BYTES)
if nb < 0: sys.exit(f"opus_encode_float error {nb}")
if first_nb is None: first_nb = nb
payload = bytes(out[:nb]) + bytes(OPUS_BYTES - nb) if nb < OPUS_BYTES else bytes(out[:OPUS_BYTES])
packets.append(build_packet(payload, seq, counter, headset, audbuf))
seq = (seq+1) & 0x0F; counter = (counter+1) & 0xFF
print(f"Encoded {len(packets)} frames (first frame {first_nb} B — expect ~200 for CBR). Streaming...")
fd = os.open(node, os.O_RDWR)
short = 0
t0 = time.monotonic()
for n, pkt in enumerate(packets):
try:
w = os.write(fd, pkt)
except OSError as e:
print(f"write failed at frame {n}: {e}"); break
if w != REPORT_SIZE: short += 1
target = t0 + (n+1)*0.01 # 10 ms grid -> 100 pkt/s
dt = target - time.monotonic()
if dt > 0: time.sleep(dt)
os.close(fd)
print(f"Done — {n+1} frames sent (~{(n+1)/100:.1f}s).", end="")
print(f" WARNING: {short} short writes (hidraw truncating the 398 B report!)" if short else " All writes full-length.")
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Host-side dev helper to exercise the firmware button-remap path over the
already-declared 0xF6 (SET) / 0xF7 (GET) vendor feature reports — no web tool
needed. Linux only (reads /dev/hidraw directly), same role as mic_diag.sh.
The remap rides 0xF6/0xF7 with a magic+version frame (see src/cmd.cpp):
SET 0xF6: [func=0x10]['R']['M'][ver][table[16]]
GET 0xF7: <Config_body(35)> ['R']['M'][ver][rev_lo][rev_hi][table[16]]
Usage:
remap_test.py get # show revision + current table
remap_test.py swap CIRCLE CROSS # swap two buttons (and persist)
remap_test.py set SRC TGT # route SRC -> TGT (one-way)
remap_test.py off SRC # disable SRC (0xFF)
remap_test.py reset # identity (no remap)
Run with sudo if /dev/hidraw needs root.
"""
import fcntl, glob, struct, sys
VID, PID = 0x054c, 0x0ce6
CONFIG_LEN = 35 # sizeof(Config_body), see src/config.h
PROTO_VER = 1 # kRemapProtoVer
COUNT = 16 # kRemapCount
# Must match RemapButton order in src/remap.cpp.
NAMES = ["L2", "L1", "CREATE", "DPAD_UP", "DPAD_LEFT", "DPAD_DOWN",
"DPAD_RIGHT", "L3", "R2", "R1", "OPTIONS", "TRIANGLE",
"CIRCLE", "CROSS", "SQUARE", "R3"]
IDX = {n: i for i, n in enumerate(NAMES)}
def hidiocg(size): # HIDIOCGFEATURE(size)
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x07
def hidiocs(size): # HIDIOCSFEATURE(size)
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x06
def read_f7(f):
"""Return (rev, table[16]) or None if the response lacks the remap block."""
size = 64 # 1 report-id byte + up to 63 payload
buf = bytearray(size)
buf[0] = 0xF7
fcntl.ioctl(f, hidiocg(size), buf)
payload = bytes(buf[1:]) # kernel prepends report id at byte 0
blk = payload[CONFIG_LEN:CONFIG_LEN + 5 + COUNT]
if len(blk) < 5 + COUNT or blk[0] != ord('R') or blk[1] != ord('M'):
return None
ver = blk[2]
rev = blk[3] | (blk[4] << 8)
table = list(blk[5:5 + COUNT])
return ver, rev, table
def find_dongle():
for path in sorted(glob.glob('/dev/hidraw*')):
try:
f = open(path, 'rb+', buffering=0)
except (OSError, PermissionError):
continue
try:
if read_f7(f) is not None:
return f, path
except OSError:
pass
f.close()
return None, None
def write_table(f, table):
assert len(table) == COUNT
payload = bytes([0xF6, 0x10, ord('R'), ord('M'), PROTO_VER]) + bytes(table)
buf = bytearray(payload)
fcntl.ioctl(f, hidiocs(len(buf)), buf)
def show(label, ver, rev, table):
print(f"{label}: proto v{ver}, revision {rev}")
active = [(s, t) for s, t in enumerate(table) if t != s]
if not active:
print(" identity (no remap active)")
return
for s, t in active:
tn = "DISABLED" if t == 0xFF else NAMES[t]
print(f" {NAMES[s]:<10} -> {tn}")
def parse_button(arg):
key = arg.upper()
if key not in IDX:
sys.exit(f"unknown button '{arg}'. choices: {', '.join(NAMES)}")
return IDX[key]
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(2)
cmd = sys.argv[1].lower()
f, path = find_dongle()
if f is None:
sys.exit("no DS5 dongle with remap support found "
"(check /dev/hidraw permissions and firmware version)")
print(f"dongle: {path}")
ver, rev, table = read_f7(f)
if cmd == "get":
show("current", ver, rev, table)
return
if cmd == "reset":
table = list(range(COUNT))
elif cmd == "off" and len(sys.argv) == 3:
table[parse_button(sys.argv[2])] = 0xFF
elif cmd == "set" and len(sys.argv) == 4:
table[parse_button(sys.argv[2])] = parse_button(sys.argv[3])
elif cmd == "swap" and len(sys.argv) == 4:
a, b = parse_button(sys.argv[2]), parse_button(sys.argv[3])
table[a], table[b] = b, a
else:
print(__doc__)
sys.exit(2)
show("writing", ver, rev, table)
write_table(f, table)
nver, nrev, ntable = read_f7(f)
show("read-back", nver, nrev, ntable)
if ntable == table and nrev != rev:
print("OK: table applied and revision bumped")
else:
print("WARNING: read-back mismatch or revision did not change")
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Send a 440 Hz sine wave on channels 1+2 only (speaker), silence on 3+4 (haptic).
Usage: python3 scripts/sine_ch12.py [seconds]
"""
import subprocess, struct, math, sys, re
RATE = 48000
FREQ = 440
DURATION = int(sys.argv[1]) if len(sys.argv) > 1 else 5
CHANNELS = 4
SAMPLES = RATE * DURATION
# Generate 4-channel S16_LE: sine on ch1+ch2, silence on ch3+ch4
data = bytearray()
for i in range(SAMPLES):
val = int(6000 * math.sin(2 * math.pi * FREQ * i / RATE)) # ~-15 dBFS (was 32767 / 0 dBFS) — clipping test
s = struct.pack('<h', val)
data += s + s + b'\x00\x00' + b'\x00\x00' # L, R, hapL=0, hapR=0
# Auto-detect the dongle's ALSA card — it enumerates as "DualSense Wireless
# Controller", and the card number shifts across reboots, so never hardcode it.
def find_dualsense_card():
out = subprocess.check_output(['aplay', '-l'], text=True)
for line in out.splitlines():
m = re.match(r'card (\d+):', line)
if m and 'DualSense' in line:
return int(m.group(1))
return None
card = find_dualsense_card()
if card is None:
print("DualSense dongle not found in `aplay -l` — paired and enumerated?", file=sys.stderr)
sys.exit(1)
device = f'hw:{card},0'
print(f"Playing to {device} (DualSense dongle)")
proc = subprocess.Popen(
['aplay', '-D', device, '-f', 'S16_LE', '-c', '4', '-r', '48000', '-'],
stdin=subprocess.PIPE
)
proc.stdin.write(data)
proc.stdin.close()
proc.wait()
print(f"Played {DURATION}s of {FREQ} Hz sine on ch1+ch2 (speaker only)")
+123 -40
View File
@@ -20,7 +20,7 @@
#define INPUT_CHANNELS 4 #define INPUT_CHANNELS 4
#define OUTPUT_CHANNELS 2 #define OUTPUT_CHANNELS 2
#define SAMPLE_SIZE 64 #define SAMPLE_SIZE 60 // 60 B = 30 haptic frames @ 3 kHz = 10.00 ms/packet → true 100 Hz cadence
#define REPORT_SIZE 398 #define REPORT_SIZE 398
#define REPORT_ID 0x36 #define REPORT_ID 0x36
// #define VOLUME_GAIN 2 // #define VOLUME_GAIN 2
@@ -61,8 +61,26 @@ 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_want() { return g_mic_last_want; }
uint16_t audio_mic_last_wrote() { return g_mic_last_wrote; } 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[480 * 2]; // exactly one 10 ms Opus frame (480 stereo samples)
}; };
void set_headset(bool state) { void set_headset(bool state) {
@@ -78,6 +96,10 @@ uint32_t opus_fifo_drops() { return 0; }
// emulator's USB / BT rate display. Updated below. // emulator's USB / BT rate display. Updated below.
static volatile uint32_t g_usb_frames = 0; static volatile uint32_t g_usb_frames = 0;
static volatile uint32_t g_bt_packets = 0; static volatile uint32_t g_bt_packets = 0;
static volatile int32_t g_opus_last_ret = 0;
static volatile uint32_t g_fifo_drops = 0;
static volatile uint32_t g_opus_encodes = 0;
static volatile bool g_opus_ready = false;
uint32_t audio_usb_frames() { return g_usb_frames; } uint32_t audio_usb_frames() { return g_usb_frames; }
uint32_t audio_bt_packets() { return g_bt_packets; } uint32_t audio_bt_packets() { return g_bt_packets; }
@@ -161,23 +183,61 @@ void audio_loop() {
// push to the host via tud_audio_write. Runs once per loop iteration so // 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. // it keeps up with the ~100 Hz arrival rate of mic-tagged BT frames.
if (mic_decoder != nullptr) { if (mic_decoder != nullptr) {
static mic_element packet{}; const uint64_t now = time_us_64();
if (queue_try_remove(&mic_fifo, &packet)) {
static int16_t mono[MIC_FRAMES]; // Decode stage: drain incoming Opus into the jitter buffer as fast as it
const int decoded = opus_decode(mic_decoder, packet.data, // arrives (absorbs bursty BT delivery), up to the buffer's capacity.
MIC_OPUS_SIZE, mono, MIC_FRAMES, 0); static mic_element pkt{};
g_mic_last_decoded = decoded; // observed in OLED Diag while (queue_get_level(&mic_decode_fifo) < MIC_DECODE_DEPTH
if (decoded > 0) { && queue_try_remove(&mic_fifo, &pkt)) {
static int16_t stereo[MIC_FRAMES * 2]; static mic_decoded_element dec{};
for (int i = 0; i < decoded; i++) { const int n = opus_decode(mic_decoder, pkt.data, MIC_OPUS_SIZE,
stereo[i * 2] = mono[i]; dec.mono, MIC_FRAMES, 0);
stereo[i * 2 + 1] = mono[i]; g_mic_last_decoded = n; // observed in OLED Diag
if (n > 0) {
queue_try_add(&mic_decode_fifo, &dec);
mic_last_real_us = now;
}
}
// Playout stage: emit one frame every 10 ms. Pre-buffer a few frames to
// absorb jitter, then play a real frame if buffered, else conceal with an
// Opus PLC frame during an active session (transient loss) so the host
// hears continuity instead of a hole. If real frames have been gone for a
// while (mic off/idle), stop so we don't emit comfort noise forever.
if (!mic_playout_started
&& queue_get_level(&mic_decode_fifo) >= MIC_PLAYOUT_START) {
mic_playout_started = true;
mic_next_playout_us = now;
}
if (mic_playout_started && (int64_t)(now - mic_next_playout_us) >= 0) {
static mic_decoded_element out{};
bool have = queue_try_remove(&mic_decode_fifo, &out);
if (!have) {
if (now - mic_last_real_us < MIC_SESSION_US) {
const int n = opus_decode(mic_decoder, nullptr, 0,
out.mono, MIC_FRAMES, 0); // PLC
if (n > 0) { have = true; g_mic_plc_frames++; }
} else {
mic_playout_started = false; // session ended — re-buffer next time
} }
const uint16_t want = (uint16_t)(decoded * 2 * sizeof(int16_t)); }
const uint16_t wrote = tud_audio_write(stereo, want); if (have) {
static int16_t stereo[MIC_FRAMES * 2];
for (int i = 0; i < MIC_FRAMES; i++) {
stereo[i * 2] = out.mono[i];
stereo[i * 2 + 1] = out.mono[i];
}
const uint16_t want = (uint16_t)(MIC_FRAMES * 2 * sizeof(int16_t));
g_mic_last_wrote = tud_audio_write(stereo, want);
g_mic_last_want = want; g_mic_last_want = want;
g_mic_last_wrote = wrote;
g_mic_frames++; 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;
}
} }
} }
} }
@@ -202,7 +262,7 @@ void audio_loop() {
} }
g_usb_frames += (uint32_t)frames; g_usb_frames += (uint32_t)frames;
static float audio_buf[512 * 2]; static float audio_buf[480 * 2];
static uint audio_buf_pos = 0; static uint audio_buf_pos = 0;
// 2. 从4ch中提取ch3/ch4,转换为float输入重采样器 // 2. 从4ch中提取ch3/ch4,转换为float输入重采样器
WDL_ResampleSample *in_buf; WDL_ResampleSample *in_buf;
@@ -250,11 +310,12 @@ void audio_loop() {
#if !DISABLE_SPEAKER_PROC #if !DISABLE_SPEAKER_PROC
audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS] / 32768.0f * audio_gain; audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS] / 32768.0f * audio_gain;
audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS + 1] / 32768.0f * audio_gain; audio_buf[audio_buf_pos++] = raw[i * INPUT_CHANNELS + 1] / 32768.0f * audio_gain;
if (audio_buf_pos == 512 * 2) { if (audio_buf_pos == 480 * 2) {
static audio_raw_element element{}; static audio_raw_element element{};
memcpy(element.data, audio_buf, 512 * 2 * 4); memcpy(element.data, audio_buf, 480 * 2 * 4);
if (queue_is_full(&audio_fifo)) { if (queue_is_full(&audio_fifo)) {
queue_try_remove(&audio_fifo,NULL); queue_try_remove(&audio_fifo,NULL);
g_fifo_drops++;
} }
if (!queue_try_add(&audio_fifo, &element)) { if (!queue_try_add(&audio_fifo, &element)) {
printf("[Audio] Warning: audio_fifo add failed\n"); printf("[Audio] Warning: audio_fifo add failed\n");
@@ -346,20 +407,45 @@ void audio_loop() {
pkt[77] = SAMPLE_SIZE; pkt[77] = SAMPLE_SIZE;
memcpy(pkt + 78, haptic_buf, SAMPLE_SIZE); memcpy(pkt + 78, haptic_buf, SAMPLE_SIZE);
#if !DISABLE_SPEAKER_PROC #if !DISABLE_SPEAKER_PROC
// Speaker Audio Data // Speaker Audio Data — MUST immediately follow the haptic block. The DS5
pkt[142] = (plug_headset ? 0x16 : 0x13) | 0 << 6 | 1 << 7; // Speaker: 0x13 // parses sub-reports sequentially (header + len + data), so this offset is
// 78 + SAMPLE_SIZE, NOT a fixed 142. At SAMPLE_SIZE 64 that worked out to
// 142; shrinking the haptic block to 60 without moving this is what
// silenced the speaker (controller couldn't locate the speaker sub-report).
constexpr int kSpkOff = 78 + SAMPLE_SIZE; // = 138 at SAMPLE_SIZE 60
pkt[kSpkOff] = (plug_headset ? 0x16 : 0x13) | 0 << 6 | 1 << 7; // Speaker: 0x13
// L Headset Mono: 0x14 // L Headset Mono: 0x14
// L Headset R Speaker: 0x15 // L Headset R Speaker: 0x15
// Headset: 0x16 // Headset: 0x16
pkt[143] = 200; pkt[kSpkOff + 1] = 200;
critical_section_enter_blocking(&opus_cs); critical_section_enter_blocking(&opus_cs);
memcpy(pkt + 144, opus_buf, 200); memcpy(pkt + kSpkOff + 2, opus_buf, 200);
critical_section_exit(&opus_cs); critical_section_exit(&opus_cs);
#endif #endif
bt_write(pkt, sizeof(pkt)); bt_write(pkt, sizeof(pkt));
g_bt_packets++; g_bt_packets++;
haptic_buf_pos = 0; haptic_buf_pos = 0;
// Debug: dump 5 consecutive Opus frames (skip first 10 to let encoder settle)
{
static int dump_count = 0;
if (g_bt_packets > 10 && dump_count < 5) {
dump_count++;
printf("[OPUS_FRAME_%d] ", dump_count);
critical_section_enter_blocking(&opus_cs);
for (int di = 0; di < 200; di++) printf("%02x", opus_buf[di]);
critical_section_exit(&opus_cs);
printf("\n");
}
if ((g_bt_packets % 94) == 0) {
printf("[AUD] usb=%lu enc=%lu bt=%lu opus_ret=%ld fifo_drop=%lu hs=%d\n",
(unsigned long)g_usb_frames, (unsigned long)g_opus_encodes,
(unsigned long)g_bt_packets, (long)g_opus_last_ret,
(unsigned long)g_fifo_drops,
plug_headset ? 1 : 0);
}
}
} }
} }
@@ -376,7 +462,8 @@ void audio_init() {
// Mic path: queue + decoder live on core0 (audio_loop), separate from // Mic path: queue + decoder live on core0 (audio_loop), separate from
// the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames. // the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames.
queue_init(&mic_fifo, sizeof(mic_element), 2); queue_init(&mic_fifo, sizeof(mic_element), MIC_DECODE_DEPTH); // deeper: tolerate BT bursts
queue_init(&mic_decode_fifo, sizeof(mic_decoded_element), MIC_DECODE_DEPTH); // decoded-PCM jitter buffer
int dec_error = 0; int dec_error = 0;
mic_decoder = opus_decoder_create(48000, MIC_CHANNELS, &dec_error); mic_decoder = opus_decoder_create(48000, MIC_CHANNELS, &dec_error);
if (dec_error != 0 || mic_decoder == nullptr) { if (dec_error != 0 || mic_decoder == nullptr) {
@@ -386,7 +473,6 @@ void audio_init() {
} }
static OpusEncoder *encoder; static OpusEncoder *encoder;
static WDL_Resampler resampler_audio;
void core1_entry() { void core1_entry() {
int error = 0; int error = 0;
@@ -398,28 +484,25 @@ void core1_entry() {
opus_encoder_ctl(encoder,OPUS_SET_EXPERT_FRAME_DURATION(OPUS_FRAMESIZE_10_MS)); opus_encoder_ctl(encoder,OPUS_SET_EXPERT_FRAME_DURATION(OPUS_FRAMESIZE_10_MS));
opus_encoder_ctl(encoder,OPUS_SET_BITRATE(200 * 8 * 100)); opus_encoder_ctl(encoder,OPUS_SET_BITRATE(200 * 8 * 100));
opus_encoder_ctl(encoder,OPUS_SET_VBR(false)); opus_encoder_ctl(encoder,OPUS_SET_VBR(false));
opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // max 4 opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // 5 overloaded core1 (stale frames -> worse); 0 keeps up
resampler_audio.SetMode(true, 0, false);
resampler_audio.SetRates(51200, 48000);
resampler_audio.SetFeedMode(true);
resampler_audio.Prealloc(2, 512, 480);
while (true) { while (true) {
static audio_raw_element audio_element{}; static audio_raw_element audio_element{};
queue_remove_blocking(&audio_fifo, &audio_element); queue_remove_blocking(&audio_fifo, &audio_element);
// 将 512 frames 重采样成 480 frames 以解决噪音问题。感谢 @Junhoo // audio_element is exactly 480 stereo frames (10 ms @ 48 kHz) = one native
WDL_ResampleSample *in_buf; // Opus frame, so encode it directly. The old 512→480 (51200→48000) resample
int nframes = resampler_audio.ResamplePrepare(512, 2, &in_buf); // only existed to coerce a 512-sample buffer into a legal Opus frame size; it
for (int i = 0; i < nframes * 2; i++) { // shipped 480 samples every 10.667 ms (haptic-gated cadence) = 45 kHz into the
in_buf[i] = audio_element.data[i]; // DS5's free-running 48 kHz DAC → ~6.25% underrun = the periodic gaps/crackle.
} // SAMPLE_SIZE 60 + a 480-sample buffer put the whole 0x36 frame on a true
static WDL_ResampleSample out_buf[480 * 2]; // 10 ms / 100 Hz grid: 100 × 480 = 48000 samples/s, matched, no gaps.
resampler_audio.ResampleOut(out_buf, nframes, 480, 2);
static uint8_t out[200]; static uint8_t out[200];
(void) opus_encode_float(encoder, out_buf, 480, out, 200); int enc_ret = opus_encode_float(encoder, audio_element.data, 480, out, 200);
g_opus_last_ret = enc_ret;
g_opus_encodes++;
critical_section_enter_blocking(&opus_cs); critical_section_enter_blocking(&opus_cs);
memcpy(opus_buf, out, 200); memcpy(opus_buf, out, 200);
critical_section_exit(&opus_cs); critical_section_exit(&opus_cs);
g_opus_ready = true;
} }
} }
+1
View File
@@ -26,6 +26,7 @@ int32_t audio_mic_last_decoded(); // last opus_decode return — neg = error, 4
uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write
uint16_t audio_mic_last_wrote(); // bytes TinyUSB FIFO actually accepted 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) 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 // 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 // 0x31 input report. Buffer must point at MIC_OPUS_SIZE (71) bytes of
+5
View File
@@ -748,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];
+4
View File
@@ -22,6 +22,10 @@ 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);
+48 -3
View File
@@ -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"
@@ -86,11 +87,34 @@ bool is_pico_cmd(uint8_t report_id) {
uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) { 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) {
@@ -268,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");
}
} }
+11 -3
View File
@@ -72,7 +72,7 @@ void config_valid() {
printf("[Config] polling_rate_mode is invalid\n"); printf("[Config] polling_rate_mode is invalid\n");
} }
if (body->audio_buffer_length < 16 || body->audio_buffer_length > 128) { if (body->audio_buffer_length < 16 || body->audio_buffer_length > 128) {
body->audio_buffer_length = 64; body->audio_buffer_length = 16; // low buffer avoids the DS5's periodic re-buffer gap
printf("[Config] haptics_buffer_length is invalid\n"); printf("[Config] haptics_buffer_length is invalid\n");
} }
if (body->controller_mode > 2) { if (body->controller_mode > 2) {
@@ -84,8 +84,8 @@ void config_valid() {
printf("[Config] current_slot is invalid\n"); printf("[Config] current_slot is invalid\n");
} }
if (body->auto_haptics_enable > 3) { if (body->auto_haptics_enable > 3) {
body->auto_haptics_enable = 1; // Fallback default body->auto_haptics_enable = 0; // default OFF (was 1/Fallback) — it derives erratic rumble from the speaker
printf("[Config] auto_haptics_enable invalid, defaulting to 1 (Fallback)\n"); printf("[Config] auto_haptics_enable invalid, defaulting to 0 (Off)\n");
} }
if (body->auto_haptics_gain > 200) { if (body->auto_haptics_gain > 200) {
body->auto_haptics_gain = 100; body->auto_haptics_gain = 100;
@@ -113,6 +113,14 @@ void config_valid() {
body->bt_mic_enable = 1; body->bt_mic_enable = 1;
printf("[Config] bt_mic_enable invalid, defaulting to 1 (on)\n"); 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");
+10
View File
@@ -44,6 +44,16 @@ struct __attribute__((packed)) Config_body {
// over BT and the dongle decodes it to the USB capture endpoint. Costs extra // over BT and the dongle decodes it to the USB capture endpoint. Costs extra
// DS5 battery (keeps its audio subsystem awake), hence the toggle. // DS5 battery (keeps its audio subsystem awake), hence the toggle.
uint8_t bt_mic_enable; 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 {
+11 -1
View File
@@ -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"
@@ -118,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;
@@ -137,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)) {
@@ -377,6 +386,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);
+225 -24
View File
@@ -52,6 +52,10 @@ constexpr int kRowBytes = kW / 8;
constexpr int kFbBytes = kRowBytes * kH; constexpr int kFbBytes = kRowBytes * kH;
uint8_t fb[kFbBytes]; uint8_t fb[kFbBytes];
uint8_t fb_tx[kFbBytes];
uint8_t reverse_lut[256];
int flush_progress = -1;
constexpr int kFlushChunkRows = 8;
uint32_t last_render_us = 0; uint32_t last_render_us = 0;
constexpr uint32_t kFrameUs = 100000; constexpr uint32_t kFrameUs = 100000;
@@ -126,15 +130,16 @@ constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9; constexpr int kNumLbModes = 9;
// Settings screen state // Settings screen state
constexpr int kNumSettingsItems = 16; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Reset + Wipe constexpr int kNumSettingsItems = 17; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Ctrl-wake + Reset + Wipe
constexpr int kSettingsAutoHapEnaIdx = 8; constexpr int kSettingsAutoHapEnaIdx = 8;
constexpr int kSettingsAutoHapGainIdx = 9; constexpr int kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10; constexpr int kSettingsAutoHapLpIdx = 10;
constexpr int kSettingsScrDimIdx = 11; constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsScrOffIdx = 12; constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsBtMicIdx = 13; constexpr int kSettingsBtMicIdx = 13;
constexpr int kSettingsResetIdx = 14; constexpr int kSettingsCtrlWakeIdx = 14;
constexpr int kSettingsWipeSlotsIdx = 15; constexpr int kSettingsResetIdx = 15;
constexpr int kSettingsWipeSlotsIdx = 16;
Config_body settings_local{}; Config_body settings_local{};
int settings_sel = 0; int settings_sel = 0;
bool settings_dirty = false; bool settings_dirty = false;
@@ -236,7 +241,9 @@ void sh1107_init() {
// lives near the other text-drawing helpers below. // lives near the other text-drawing helpers below.
void draw_button_chrome(); void draw_button_chrome();
void flush_fb_raw() { // Blocking SPI flush — used only during boot splash before the main loop
// starts (no audio/BT to service yet, so blocking is fine).
void flush_fb_raw_blocking() {
cmd(0xB0); cmd(0xB0);
for (int j = 0; j < kH; j++) { for (int j = 0; j < kH; j++) {
const uint8_t col = kH - 1 - j; const uint8_t col = kH - 1 - j;
@@ -248,11 +255,45 @@ void flush_fb_raw() {
} }
} }
void flush_fb() { // Prepare a chunked (non-blocking) SPI flush. Pre-reverses the framebuffer
draw_button_chrome(); // into fb_tx via the LUT and sets flush_progress = 0. The actual SPI
flush_fb_raw(); // transfer is driven by flush_chunk(), called from oled_loop() on
// subsequent iterations — each chunk flushes kFlushChunkRows rows (~130 µs)
// then yields back to the main loop so audio_loop / tud_task /
// cyw43_arch_poll stay serviced. This eliminates the ~1.1 ms blocking
// window that caused audio distortion when the OLED was active (issue #7).
void flush_prepare(bool chrome) {
if (chrome) draw_button_chrome();
for (int i = 0; i < kFbBytes; i++) fb_tx[i] = reverse_lut[fb[i]];
flush_progress = 0;
} }
bool flush_chunk() {
if (flush_progress < 0) return true;
if (flush_progress == 0) cmd(0xB0);
const int end = (flush_progress + kFlushChunkRows < kH)
? flush_progress + kFlushChunkRows : kH;
for (int j = flush_progress; j < end; j++) {
const uint8_t col = kH - 1 - j;
cmd(0x00 + (col & 0x0F));
cmd(0x10 + (col >> 4));
gpio_put(kPinDC, 1);
gpio_put(kPinCS, 0);
spi_write_blocking(spi1, &fb_tx[j * kRowBytes], kRowBytes);
gpio_put(kPinCS, 1);
}
flush_progress = end;
if (flush_progress >= kH) {
flush_progress = -1;
return true;
}
return false;
}
void flush_fb_raw() { flush_prepare(false); }
void flush_fb() { flush_prepare(true); }
void fb_clear() { memset(fb, 0, sizeof(fb)); } void fb_clear() { memset(fb, 0, sizeof(fb)); }
void px(int x, int y, bool on) { void px(int x, int y, bool on) {
@@ -273,6 +314,16 @@ void rect_filled(int x, int y, int w, int h) {
px(x + i, y + j, true); px(x + i, y + j, true);
} }
// XOR-invert every pixel in a region (used to flash a control "pressed").
void rect_invert(int x, int y, int w, int h) {
for (int j = 0; j < h; j++)
for (int i = 0; i < w; i++) {
const int xx = x + i, yy = y + j;
if (xx < 0 || xx >= kW || yy < 0 || yy >= kH) continue;
fb[yy * kRowBytes + (xx / 8)] ^= 1 << (7 - (xx % 8));
}
}
void draw_char(int x, int y, char c) { void draw_char(int x, int y, char c) {
if (c < 0x20 || c > 0x7E) return; if (c < 0x20 || c > 0x7E) return;
const uint8_t *g = kFont5x7[c - 0x20]; const uint8_t *g = kFont5x7[c - 0x20];
@@ -511,6 +562,14 @@ void handle_buttons() {
last_activity_us = time_us_64(); last_activity_us = time_us_64();
if (held > kLongPressUs) { if (held > kLongPressUs) {
bright_idx = (bright_idx + 1) % kNumBrightLevels; bright_idx = (bright_idx + 1) % kNumBrightLevels;
// Persist so the choice survives a power cycle (issue #9). Keep
// settings_local in sync too, so a later Settings-screen save can't
// clobber screen_brightness with its stale snapshot.
Config_body b = get_config();
b.screen_brightness = (uint8_t)bright_idx;
set_config(b);
config_save();
settings_local.screen_brightness = (uint8_t)bright_idx;
} else { } else {
current_screen = (current_screen - 1 + kNumScreens) % kNumScreens; current_screen = (current_screen - 1 + kNumScreens) % kNumScreens;
last_render_us = 0; last_render_us = 0;
@@ -523,8 +582,10 @@ void handle_buttons() {
// --- Charge ETA tracker -------------------------------------------------- // --- Charge ETA tracker --------------------------------------------------
// The DS5 only reports battery in 10% steps (interrupt_in_data[52] low // The DS5 only reports battery in 10% steps (interrupt_in_data[52] low
// nibble, 0..10; high nibble is power-state, 1 == charging). We can't read a // nibble, 0..10; high nibble is power-state, 1 == charging). We display the
// finer percentage over BT, so a smooth countdown is impossible. Instead we // midpoint of each band (+5), matching the kernel hid-playstation driver and
// Steam: 5, 15, 25, … 95, 100%. We can't read a finer percentage over BT,
// so a smooth countdown is impossible. Instead we
// time how long each 10% step takes while charging and extrapolate the // time how long each 10% step takes while charging and extrapolate the
// remaining steps. Sampled once per frame from oled_loop (continuously, so // remaining steps. Sampled once per frame from oled_loop (continuously, so
// the estimate stays current even while the panel is dimmed/off and even when // the estimate stays current even while the panel is dimmed/off and even when
@@ -550,6 +611,15 @@ ChargeEta g_charge_eta{};
// the "?") as soon as the first clean step completes. // the "?") as soon as the first clean step completes.
constexpr float kDefaultStepUs = 15.0f * 60.0f * 1000000.0f; constexpr float kDefaultStepUs = 15.0f * 60.0f * 1000000.0f;
// Ceiling on a single timed step's bulk-equivalent duration. A genuine idle 10%
// step on this dongle is ~15 min; anything past ~30 min is almost always an
// anomalous/under-load sample (e.g. the controller in use while charging, or a
// battery-nibble bounce) that would otherwise balloon the projection — observed
// reading ~222m at 70% off one ~47-min step. We clamp such samples instead of
// trusting them, and pair that with a median over kRing steps so one bad reading
// can't dominate the estimate.
constexpr float kMaxStepUs = 30.0f * 60.0f * 1000000.0f;
// Relative time the step *ending* at `to_level` (10% units, 1..10) takes vs a // Relative time the step *ending* at `to_level` (10% units, 1..10) takes vs a
// bulk step. Tuned to the Li-ion CV taper: ~80% onward stretches out. // bulk step. Tuned to the Li-ion CV taper: ~80% onward stretches out.
static float charge_step_weight(int to_level) { static float charge_step_weight(int to_level) {
@@ -559,7 +629,7 @@ static float charge_step_weight(int to_level) {
} }
void sample_charge_eta() { void sample_charge_eta() {
constexpr int kRing = 3; // average the last few steps constexpr int kRing = 5; // median over the last few steps
static float ring[kRing] = {0}; // bulk-equivalent step durations (us) static float ring[kRing] = {0}; // bulk-equivalent step durations (us)
static int ring_count = 0; static int ring_count = 0;
static int ring_head = 0; static int ring_head = 0;
@@ -598,7 +668,9 @@ void sample_charge_eta() {
if (first_step_pending) { if (first_step_pending) {
first_step_pending = false; first_step_pending = false;
} else { } else {
ring[ring_head] = dur / charge_step_weight(step); float be = dur / charge_step_weight(step);
if (be > kMaxStepUs) be = kMaxStepUs; // clamp under-load/anomalous outliers
ring[ring_head] = be;
ring_head = (ring_head + 1) % kRing; ring_head = (ring_head + 1) % kRing;
if (ring_count < kRing) ring_count++; if (ring_count < kRing) ring_count++;
} }
@@ -620,9 +692,18 @@ void sample_charge_eta() {
const bool measured = (ring_count > 0); const bool measured = (ring_count > 0);
float bulk; float bulk;
if (measured) { if (measured) {
bulk = 0.0f; // Median of the timed steps — robust to a single slow/fast outlier
for (int i = 0; i < ring_count; i++) bulk += ring[i]; // in a way the old mean wasn't (one 47-min under-load step used to
bulk /= (float)ring_count; // drag the whole projection up). kRing is tiny, so insertion-sort.
float tmp[kRing];
for (int i = 0; i < ring_count; i++) tmp[i] = ring[i];
for (int i = 1; i < ring_count; i++) {
const float v = tmp[i];
int j = i - 1;
while (j >= 0 && tmp[j] > v) { tmp[j + 1] = tmp[j]; j--; }
tmp[j + 1] = v;
}
bulk = tmp[ring_count / 2];
} else { } else {
bulk = kDefaultStepUs; bulk = kDefaultStepUs;
} }
@@ -662,8 +743,8 @@ __attribute__((noinline)) void render_screen() {
draw_text(kContentX, 9, buf); draw_text(kContentX, 9, buf);
const uint8_t pwr = interrupt_in_data[52]; const uint8_t pwr = interrupt_in_data[52];
int pct = (pwr & 0x0F) * 10; int raw = pwr & 0x0F;
if (pct > 100) pct = 100; int pct = (raw >= 10) ? 100 : raw * 10 + 5;
const uint8_t pstate = pwr >> 4; const uint8_t pstate = pwr >> 4;
char marker = ' '; char marker = ' ';
if (pstate == 1) marker = '+'; // Charging if (pstate == 1) marker = '+'; // Charging
@@ -694,11 +775,15 @@ __attribute__((noinline)) void render_screen() {
int lx = (kContentX + 2) + (interrupt_in_data[0] * 27) / 255; int lx = (kContentX + 2) + (interrupt_in_data[0] * 27) / 255;
int ly = 32 + (interrupt_in_data[1] * 27) / 255; int ly = 32 + (interrupt_in_data[1] * 27) / 255;
rect_filled(lx - 1, ly - 1, 3, 3); rect_filled(lx - 1, ly - 1, 3, 3);
// L3 (left stick click) — invert the whole box as a pressed indicator.
if (interrupt_in_data[8] & 0x40) rect_invert(kContentX, 30, 32, 32);
rect_outline(96, 30, 32, 32); rect_outline(96, 30, 32, 32);
int rx = 98 + (interrupt_in_data[2] * 27) / 255; int rx = 98 + (interrupt_in_data[2] * 27) / 255;
int ry = 32 + (interrupt_in_data[3] * 27) / 255; int ry = 32 + (interrupt_in_data[3] * 27) / 255;
rect_filled(rx - 1, ry - 1, 3, 3); rect_filled(rx - 1, ry - 1, 3, 3);
// R3 (right stick click) — invert the whole box.
if (interrupt_in_data[8] & 0x80) rect_invert(96, 30, 32, 32);
// L2/R2 analog trigger bars (vertical, fill from bottom). L2 sits // L2/R2 analog trigger bars (vertical, fill from bottom). L2 sits
// just right of the shifted left stick box. // just right of the shifted left stick box.
@@ -827,7 +912,7 @@ void sample_diag_rates() {
// Row list ordered by relevance: always-useful at top, parked-mic-investigation // Row list ordered by relevance: always-useful at top, parked-mic-investigation
// data at bottom. To add a row, bump kNumDiagRows and add a case. // data at bottom. To add a row, bump kNumDiagRows and add a case.
constexpr int kNumDiagRows = 11; constexpr int kNumDiagRows = 12;
__attribute__((noinline)) __attribute__((noinline))
void format_diag_row(int idx, char* line, size_t n) { void format_diag_row(int idx, char* line, size_t n) {
switch (idx) { switch (idx) {
@@ -872,7 +957,10 @@ void format_diag_row(int idx, char* line, size_t n) {
(long)audio_mic_last_decoded(), (long)audio_mic_last_decoded(),
(unsigned)audio_mic_last_wrote()); (unsigned)audio_mic_last_wrote());
break; break;
case 10: { case 10:
snprintf(line, n, "Mic PLC: %lu", (unsigned long)audio_mic_plc_frames());
break;
case 11: {
uint8_t pfx[6]; bt_31_mic_prefix(pfx); uint8_t pfx[6]; bt_31_mic_prefix(pfx);
snprintf(line, n, "%02X %02X %02X %02X %02X %02X", snprintf(line, n, "%02X %02X %02X %02X %02X %02X",
pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]); pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]);
@@ -1016,6 +1104,85 @@ __attribute__((noinline)) void render_screen_triggers() {
flush_fb(); flush_fb();
} }
// --- IMU calibration (DS5 feature report 0x05) ---------------------------
// The DualSense ships per-unit gyro/accel calibration in feature report 0x05,
// which bt.cpp already fetches and caches at connect (init_feature). Parsing it
// lets the Gyro Tilt screen and the tilt->RGB lightbar mode use bias- and
// sensitivity-corrected accel instead of raw counts, so the tilt dot recenters
// per controller. Parse + apply mirror SDL's SDL_hidapi_ps5.c (zlib-licensed)
// LoadCalibrationData/ApplyCalibrationData (credit); feature_data[0x05]'s byte
// layout matches SDL's data[] (index 0 = report id, calibration words from 1).
//
// imu_apply keeps accel in the same +-8192 == 1g count space the callers already
// scale by, so existing /8192 (gyro screen) and +-8192 (lightbar) math is
// unchanged — calibration only removes the per-axis zero offset and corrects gain.
struct ImuCal { int16_t bias; float sens; }; // 0..2 gyro P/Y/R, 3..5 accel X/Y/Z
ImuCal g_imu_cal[6];
bool g_imu_cal_valid = false; // a plausible calibration was loaded
bool g_imu_cal_tried = false; // 0x05 has been seen this connection (good or bad)
constexpr float kGyroResPerDeg = 1024.0f;
constexpr float kAccelResPerG = 8192.0f;
inline int16_t cal_ld16(const std::vector<uint8_t>& d, int i) {
return (int16_t)((uint16_t)d[i] | ((uint16_t)d[i + 1] << 8));
}
__attribute__((noinline))
void imu_cal_parse(const std::vector<uint8_t>& d) {
g_imu_cal_valid = false;
if (d.size() < 35) return; // SDL requires >= 35 calibration bytes
const int16_t gPB = cal_ld16(d, 1), gYB = cal_ld16(d, 3), gRB = cal_ld16(d, 5);
const int16_t gPp = cal_ld16(d, 7), gPm = cal_ld16(d, 9);
const int16_t gYp = cal_ld16(d, 11), gYm = cal_ld16(d, 13);
const int16_t gRp = cal_ld16(d, 15), gRm = cal_ld16(d, 17);
const int16_t gSp = cal_ld16(d, 19), gSm = cal_ld16(d, 21);
const int16_t aXp = cal_ld16(d, 23), aXm = cal_ld16(d, 25);
const int16_t aYp = cal_ld16(d, 27), aYm = cal_ld16(d, 29);
const int16_t aZp = cal_ld16(d, 31), aZm = cal_ld16(d, 33);
const float num = (float)(gSp + gSm) * kGyroResPerDeg;
g_imu_cal[0] = { gPB, num / (float)(gPp - gPm) };
g_imu_cal[1] = { gYB, num / (float)(gYp - gYm) };
g_imu_cal[2] = { gRB, num / (float)(gRp - gRm) };
int16_t r;
r = aXp - aXm; g_imu_cal[3] = { (int16_t)(aXp - r / 2), 2.0f * kAccelResPerG / (float)r };
r = aYp - aYm; g_imu_cal[4] = { (int16_t)(aYp - r / 2), 2.0f * kAccelResPerG / (float)r };
r = aZp - aZm; g_imu_cal[5] = { (int16_t)(aZp - r / 2), 2.0f * kAccelResPerG / (float)r };
// Sanity gate (same as SDL): a wild bias or a gain off by >50% means a bad
// factory cal or a short/garbled read — fall back to raw rather than amplify it.
for (int i = 0; i < 6; i++) {
const float divisor = (i < 3) ? 64.0f : 1.0f;
const int ab = g_imu_cal[i].bias < 0 ? -g_imu_cal[i].bias : g_imu_cal[i].bias;
float gain = 1.0f - g_imu_cal[i].sens / divisor;
if (gain < 0) gain = -gain;
if (ab > 1024 || gain > 0.5f) return; // leave g_imu_cal_valid = false
}
g_imu_cal_valid = true;
}
// Poll once per frame from oled_loop: parse 0x05 the first time it is available
// for this controller, and reset on disconnect so the next controller re-reads.
void imu_cal_service() {
if (!bt_is_connected()) { g_imu_cal_valid = false; g_imu_cal_tried = false; return; }
if (g_imu_cal_tried) return;
auto d = bt_peek_feature(0x05);
if (d.size() < 35) return; // not arrived yet — retry next frame
imu_cal_parse(d);
g_imu_cal_tried = true;
}
// index 0..2 gyro, 3..5 accel. Returns the calibrated value in the same count
// scale the raw value used (+-8192 == 1g for accel); identity when no valid
// calibration is loaded, so behaviour matches the pre-calibration firmware.
inline int16_t imu_apply(int index, int16_t raw) {
if (!g_imu_cal_valid) return raw;
return (int16_t)((float)(raw - g_imu_cal[index].bias) * g_imu_cal[index].sens);
}
__attribute__((noinline)) void render_screen_gyro() { __attribute__((noinline)) void render_screen_gyro() {
fb_clear(); fb_clear();
draw_text(kContentX, 0, "Gyro Tilt"); draw_text(kContentX, 0, "Gyro Tilt");
@@ -1024,6 +1191,9 @@ __attribute__((noinline)) void render_screen_gyro() {
memcpy(&ax, &interrupt_in_data[21], 2); memcpy(&ax, &interrupt_in_data[21], 2);
memcpy(&ay, &interrupt_in_data[23], 2); memcpy(&ay, &interrupt_in_data[23], 2);
memcpy(&az, &interrupt_in_data[25], 2); memcpy(&az, &interrupt_in_data[25], 2);
ax = imu_apply(3, ax); // bias/sensitivity-corrected accel (identity if no cal)
ay = imu_apply(4, ay);
az = imu_apply(5, az);
char buf[16]; char buf[16];
snprintf(buf, sizeof(buf), "X%+5d", ax); draw_text(kContentX, 10, buf); snprintf(buf, sizeof(buf), "X%+5d", ax); draw_text(kContentX, 10, buf);
snprintf(buf, sizeof(buf), "Y%+5d", ay); draw_text(50, 10, buf); snprintf(buf, sizeof(buf), "Y%+5d", ay); draw_text(50, 10, buf);
@@ -1033,8 +1203,15 @@ __attribute__((noinline)) void render_screen_gyro() {
rect_outline(bx, by, bw, bh); rect_outline(bx, by, bw, bh);
for (int x = bx + 1; x < bx + bw - 1; x++) px(x, by + bh / 2, true); for (int x = bx + 1; x < bx + bw - 1; x++) px(x, by + bh / 2, true);
for (int y = by + 1; y < by + bh - 1; y++) px(bx + bw / 2, y, true); for (int y = by + 1; y < by + bh - 1; y++) px(bx + bw / 2, y, true);
int dx = ((int)ax * (bw / 2 - 3)) / 8192; // Plot the two axes that read ~0 when the controller lies flat: X (roll,
int dy = ((int)ay * (bh / 2 - 3)) / 8192; // left/right) and Z (pitch, fwd/back). Gravity rests on Y when flat, so
// driving the dot from Y pegged it to the bottom edge at rest — using Z
// keeps the dot centred flat and it tracks as you tilt. (Readout above
// still shows all three raw axes.)
// Negated so the dot follows the tilt direction: tilt left -> dot left,
// tilt forward -> dot up (gravity pulls the opposite way on the axis).
int dx = -((int)ax * (bw / 2 - 3)) / 8192;
int dy = -((int)az * (bh / 2 - 3)) / 8192;
int cx = bx + bw / 2 + dx; int cx = bx + bw / 2 + dx;
int cy = by + bh / 2 + dy; int cy = by + bh / 2 + dy;
if (cx < bx + 2) cx = bx + 2; if (cx < bx + 2) cx = bx + 2;
@@ -1213,6 +1390,9 @@ void lightbar_compute_mode(int mode, uint32_t now_ms) {
memcpy(&ax, &interrupt_in_data[21], 2); memcpy(&ax, &interrupt_in_data[21], 2);
memcpy(&ay, &interrupt_in_data[23], 2); memcpy(&ay, &interrupt_in_data[23], 2);
memcpy(&az, &interrupt_in_data[25], 2); memcpy(&az, &interrupt_in_data[25], 2);
ax = imu_apply(3, ax); // calibrated accel keeps the +-8192 == 1g scale below
ay = imu_apply(4, ay);
az = imu_apply(5, az);
const int rr = ((int)ax + 8192) * 255 / 16384; const int rr = ((int)ax + 8192) * 255 / 16384;
const int gg = ((int)ay + 8192) * 255 / 16384; const int gg = ((int)ay + 8192) * 255 / 16384;
const int bb = ((int)az + 8192) * 255 / 16384; const int bb = ((int)az + 8192) * 255 / 16384;
@@ -1418,6 +1598,7 @@ void settings_adjust(int delta) {
break; break;
} }
case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off
case 14: c.controller_wakes_display ^= 1; break; // controller activity wakes OLED on/off
} }
} }
@@ -1520,8 +1701,9 @@ __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t
else snprintf(line, n, "%s ScrOff %umin", cur, c.screen_off_timeout); else snprintf(line, n, "%s ScrOff %umin", cur, c.screen_off_timeout);
break; break;
case 13: snprintf(line, n, "%s BT Mic %s", cur, c.bt_mic_enable ? "on" : "off"); break; case 13: snprintf(line, n, "%s BT Mic %s", cur, c.bt_mic_enable ? "on" : "off"); break;
case 14: snprintf(line, n, "%s Reset to defaults", cur); break; case 14: snprintf(line, n, "%s CtrlWake %s", cur, c.controller_wakes_display ? "on" : "off"); break;
case 15: snprintf(line, n, "%s Wipe all slots", cur); break; case 15: snprintf(line, n, "%s Reset to defaults", cur); break;
case 16: snprintf(line, n, "%s Wipe all slots", cur); break;
} }
} }
@@ -1667,13 +1849,15 @@ void boot_splash() {
draw_text(cx_for(l1), 16, l1); draw_text(cx_for(l1), 16, l1);
draw_text(cx_for(l2), 30, l2); draw_text(cx_for(l2), 30, l2);
draw_text(cx_for(l3), 44, l3); draw_text(cx_for(l3), 44, l3);
flush_fb(); draw_button_chrome();
flush_fb_raw_blocking();
sleep_ms(1500); sleep_ms(1500);
} }
} // namespace } // namespace
void oled_init() { void oled_init() {
for (int i = 0; i < 256; i++) reverse_lut[i] = reverse_byte((uint8_t)i);
spi_init(spi1, 10 * 1000 * 1000); spi_init(spi1, 10 * 1000 * 1000);
gpio_set_function(kPinCLK, GPIO_FUNC_SPI); gpio_set_function(kPinCLK, GPIO_FUNC_SPI);
gpio_set_function(kPinMOSI, GPIO_FUNC_SPI); gpio_set_function(kPinMOSI, GPIO_FUNC_SPI);
@@ -1693,6 +1877,11 @@ void oled_init() {
// Restore the persisted lightbar mode + favorites (config_load() already ran // Restore the persisted lightbar mode + favorites (config_load() already ran
// in main() before this). Defaults to HOST passthrough on a fresh flash. // in main() before this). Defaults to HOST passthrough on a fresh flash.
lightbar_load_config(); lightbar_load_config();
// Restore the persisted OLED brightness (KEY1-long-press choice). config_valid
// clamps screen_brightness to a legal kBrightLevels index, so this is safe to
// use directly. Fresh flash → 0 (full brightness). Issue #9.
bright_idx = get_config().screen_brightness;
} }
// Dim-tier renderer: blank the panel and draw a tiny "I'm alive" dot that // Dim-tier renderer: blank the panel and draw a tiny "I'm alive" dot that
@@ -1729,11 +1918,18 @@ void oled_loop() {
handle_buttons(); handle_buttons();
const uint32_t now = time_us_32(); const uint32_t now = time_us_32();
rumble_burst_tick(now); rumble_burst_tick(now);
if (flush_progress >= 0) {
flush_chunk();
return;
}
if ((now - last_render_us) < kFrameUs) return; if ((now - last_render_us) < kFrameUs) return;
last_render_us = now; last_render_us = now;
// Track charge progress every frame — before the power-ladder early-returns // Track charge progress every frame — before the power-ladder early-returns
// below, so step timing stays correct even while the panel is dimmed/off. // below, so step timing stays correct even while the panel is dimmed/off.
sample_charge_eta(); sample_charge_eta();
// Parse the DS5's per-unit IMU calibration once it lands (no-op until then),
// so the tilt screen + tilt->RGB lightbar use corrected accel. See imu_apply().
imu_cal_service();
// Drive the controller LED every frame (any screen / power state): charging // Drive the controller LED every frame (any screen / power state): charging
// pulse, selected OLED mode, or hand-off to the host. See lightbar_service(). // pulse, selected OLED mode, or hand-off to the host. See lightbar_service().
lightbar_service(); lightbar_service();
@@ -1753,7 +1949,12 @@ void oled_loop() {
} }
if (hash != last_input_hash) { if (hash != last_input_hash) {
last_input_hash = hash; last_input_hash = hash;
last_activity_us = time_us_64(); // Controller input only keeps the panel awake when the user has left
// "CtrlWake" on (the default). With it off, the dim/off timers count
// down during gameplay and only KEY0/KEY1 wake the screen — see
// handle_buttons(), which bumps last_activity_us unconditionally.
// Issues #8 / #9.
if (get_config().controller_wakes_display) last_activity_us = time_us_64();
} }
// Rising-edge: BT-connect itself counts as activity, so the screen wakes // Rising-edge: BT-connect itself counts as activity, so the screen wakes
// the moment a controller pairs rather than waiting for the first input. // the moment a controller pairs rather than waiting for the first input.
+197
View File
@@ -0,0 +1,197 @@
// Button remapping. See remap.h. Flash-sector pattern mirrors slots.cpp;
// the apply logic + button set are ported from SundayMoments/DS5_Bridge.
#include "remap.h"
#include <cstring>
#include <cstdio>
#include <algorithm>
#include "hardware/flash.h"
#include "hardware/sync.h"
namespace {
// Source/target button indices. Order is arbitrary but MUST match the web
// editor's expectation (DS5Dongle-OLED-Config-Web src/protocol/remap.ts).
enum RemapButton : uint8_t {
RemapL2, RemapL1, RemapCreate, RemapDpadUp, RemapDpadLeft, RemapDpadDown,
RemapDpadRight, RemapL3, RemapR2, RemapR1, RemapOptions, RemapTriangle,
RemapCircle, RemapCross, RemapSquare, RemapR3, RemapButtonCount,
};
static_assert(RemapButtonCount == kRemapCount, "kRemapCount must match RemapButton");
// interrupt_in_data[8]: shoulders / sticks / system bits.
constexpr uint8_t kL1Bit = 0x01, kR1Bit = 0x02, kL2Bit = 0x04, kR2Bit = 0x08,
kCreateBit = 0x10, kOptionsBit = 0x20, kL3Bit = 0x40, kR3Bit = 0x80;
// interrupt_in_data[7]: D-pad hat (low nibble) + face buttons (high nibble).
constexpr uint8_t kSquareBit = 0x10, kCrossBit = 0x20, kCircleBit = 0x40,
kTriangleBit = 0x80, kDpadMask = 0x0F;
// D-pad hat values.
constexpr uint8_t kUp = 0, kUpRight = 1, kRight = 2, kDownRight = 3, kDown = 4,
kDownLeft = 5, kLeft = 6, kUpLeft = 7, kNeutral = 8;
constexpr uint32_t REMAP_MAGIC = 0x44533503u; // "DS5\x03"
constexpr uint32_t REMAP_FLASH_OFFSET = PICO_FLASH_SIZE_BYTES - 3u * FLASH_SECTOR_SIZE;
struct __attribute__((packed)) RemapData {
uint32_t magic;
uint8_t table[kRemapCount];
};
static_assert(sizeof(RemapData) <= FLASH_PAGE_SIZE);
static_assert(REMAP_FLASH_OFFSET % FLASH_SECTOR_SIZE == 0);
RemapData g_remap{};
bool g_active = false; // false = identity → remap_apply() fast-returns
uint16_t g_revision = 0;
const RemapData *flash_remap() {
return reinterpret_cast<const RemapData *>(XIP_BASE + REMAP_FLASH_OFFSET);
}
void set_identity() {
for (int i = 0; i < kRemapCount; i++) g_remap.table[i] = (uint8_t) i;
}
void recompute_active() {
g_active = false;
for (int i = 0; i < kRemapCount; i++) {
if (g_remap.table[i] != i) { g_active = true; return; }
}
}
bool valid_table(const uint8_t *t) {
for (int i = 0; i < kRemapCount; i++) {
if (t[i] >= kRemapCount && t[i] != 0xFF) return false; // <16 or disabled
}
return true;
}
bool save_to_flash() {
alignas(4) uint8_t page[FLASH_PAGE_SIZE];
memset(page, 0xff, sizeof(page));
memcpy(page, &g_remap, sizeof(g_remap));
const uint32_t interrupts = save_and_disable_interrupts();
flash_range_erase(REMAP_FLASH_OFFSET, FLASH_SECTOR_SIZE);
flash_range_program(REMAP_FLASH_OFFSET, page, sizeof(page));
restore_interrupts(interrupts);
RemapData verify{};
memcpy(&verify, flash_remap(), sizeof(verify));
if (memcmp(&verify, &g_remap, sizeof(g_remap)) == 0) {
printf("[Remap] flash write verified\n");
return true;
}
printf("[Remap] flash write VERIFY FAILED\n");
return false;
}
bool dpad_has(uint8_t dir, RemapButton b) {
switch (b) {
case RemapDpadUp: return dir == kUp || dir == kUpRight || dir == kUpLeft;
case RemapDpadRight: return dir == kRight || dir == kUpRight || dir == kDownRight;
case RemapDpadDown: return dir == kDown || dir == kDownRight || dir == kDownLeft;
case RemapDpadLeft: return dir == kLeft || dir == kUpLeft || dir == kDownLeft;
default: return false;
}
}
uint8_t dpad_from(bool up, bool right, bool down, bool left) {
if (up && right && !down && !left) return kUpRight;
if (right && down && !up && !left) return kDownRight;
if (down && left && !up && !right) return kDownLeft;
if (left && up && !right && !down) return kUpLeft;
if (up && !down) return kUp;
if (right && !left) return kRight;
if (down && !up) return kDown;
if (left && !right) return kLeft;
return kNeutral;
}
} // namespace
void remap_load() {
memcpy(&g_remap, flash_remap(), sizeof(g_remap));
if (g_remap.magic != REMAP_MAGIC || !valid_table(g_remap.table)) {
printf("[Remap] flash sector empty/invalid, defaulting to identity\n");
g_remap.magic = REMAP_MAGIC;
set_identity();
save_to_flash();
}
recompute_active();
printf("[Remap] loaded (active=%d)\n", g_active);
}
void remap_get(uint8_t out[kRemapCount]) { memcpy(out, g_remap.table, kRemapCount); }
uint16_t remap_revision() { return g_revision; }
bool remap_set(const uint8_t *table) {
if (!valid_table(table)) return false;
memcpy(g_remap.table, table, kRemapCount);
g_remap.magic = REMAP_MAGIC;
recompute_active();
g_revision++;
return save_to_flash();
}
void remap_apply(uint8_t *report) {
if (!g_active) return; // identity → no-op (hot-path fast return)
bool src[kRemapCount]{};
uint8_t src_analog[kRemapCount]{};
const uint8_t dir = report[7] & kDpadMask;
src[RemapL2] = (report[8] & kL2Bit) != 0;
src[RemapL1] = (report[8] & kL1Bit) != 0;
src[RemapCreate] = (report[8] & kCreateBit) != 0;
src[RemapDpadUp] = dpad_has(dir, RemapDpadUp);
src[RemapDpadLeft] = dpad_has(dir, RemapDpadLeft);
src[RemapDpadDown] = dpad_has(dir, RemapDpadDown);
src[RemapDpadRight] = dpad_has(dir, RemapDpadRight);
src[RemapL3] = (report[8] & kL3Bit) != 0;
src[RemapR2] = (report[8] & kR2Bit) != 0;
src[RemapR1] = (report[8] & kR1Bit) != 0;
src[RemapOptions] = (report[8] & kOptionsBit) != 0;
src[RemapTriangle] = (report[7] & kTriangleBit) != 0;
src[RemapCircle] = (report[7] & kCircleBit) != 0;
src[RemapCross] = (report[7] & kCrossBit) != 0;
src[RemapSquare] = (report[7] & kSquareBit) != 0;
src[RemapR3] = (report[8] & kR3Bit) != 0;
for (int i = 0; i < kRemapCount; i++) src_analog[i] = src[i] ? 0xFF : 0;
src_analog[RemapL2] = report[4]; // L2 analog
src_analog[RemapR2] = report[5]; // R2 analog
bool tgt[kRemapCount]{};
uint8_t tgt_analog[kRemapCount]{};
for (int s = 0; s < kRemapCount; s++) {
const uint8_t t = g_remap.table[s];
if (t >= kRemapCount) continue; // 0xFF = disabled (source produces nothing)
if (src[s]) tgt[t] = true;
tgt_analog[t] = std::max(tgt_analog[t], src_analog[s]); // OR digital / max analog
}
report[4] = tgt_analog[RemapL2];
report[5] = tgt_analog[RemapR2];
// Byte 7 is entirely D-pad + face (all 8 bits) — rebuild it.
report[7] = dpad_from(tgt[RemapDpadUp], tgt[RemapDpadRight],
tgt[RemapDpadDown], tgt[RemapDpadLeft]);
if (tgt[RemapSquare]) report[7] |= kSquareBit;
if (tgt[RemapCross]) report[7] |= kCrossBit;
if (tgt[RemapCircle]) report[7] |= kCircleBit;
if (tgt[RemapTriangle]) report[7] |= kTriangleBit;
// Byte 8 is entirely shoulders/sticks/Create/Options (all 8 bits) — rebuild it.
report[8] = 0;
if (tgt[RemapL1]) report[8] |= kL1Bit;
if (tgt[RemapR1]) report[8] |= kR1Bit;
if (tgt[RemapL2]) report[8] |= kL2Bit;
if (tgt[RemapR2]) report[8] |= kR2Bit;
if (tgt[RemapCreate]) report[8] |= kCreateBit;
if (tgt[RemapOptions]) report[8] |= kOptionsBit;
if (tgt[RemapL3]) report[8] |= kL3Bit;
if (tgt[RemapR3]) report[8] |= kR3Bit;
}
+43
View File
@@ -0,0 +1,43 @@
//
// Button remapping — a 16-entry table persisted in its own flash sector,
// applied to the OUTGOING host HID report only (never the raw interrupt_in_data
// the OLED / reboot-combo logic reads). Edited from the web config tool over the
// already-declared 0xF6/0xF7 vendor reports. Apply logic + button set ported
// from SundayMoments/DS5_Bridge (credit).
//
#ifndef DS5_BRIDGE_REMAP_H
#define DS5_BRIDGE_REMAP_H
#include <cstdint>
// Number of remappable buttons (see RemapButton in remap.cpp). The table maps
// source index -> target index; 0xFF means "disabled" (source does nothing).
constexpr int kRemapCount = 16;
// Wire-protocol version for the remap get/set framing carried over the existing
// 0xF6/0xF7 vendor reports (see cmd.cpp). Bump only on incompatible layout
// changes so the web tool can refuse a mismatched firmware.
constexpr uint8_t kRemapProtoVer = 1;
// Load the table from its dedicated flash sector. Call once at boot, after
// config_load(). A fresh/invalid sector defaults to identity (no remap).
void remap_load();
// Remap a 63-byte DS5 input-report COPY in place (buttons live in report[4,5,7,8]).
// No-op fast path when the table is identity. Must only ever touch the outgoing
// host report, not the raw interrupt_in_data.
void remap_apply(uint8_t *report);
// Validate + store + persist a new 16-entry table (each entry < 16, or 0xFF =
// disabled). Bumps the revision on success. Returns false if the table is invalid.
bool remap_set(const uint8_t *table);
// Copy the current 16-entry table out.
void remap_get(uint8_t out[kRemapCount]);
// Monotonic counter bumped on each successful remap_set — the web polls it to
// confirm a write landed. Runtime only (not persisted).
uint16_t remap_revision();
#endif // DS5_BRIDGE_REMAP_H
+10
View File
@@ -82,6 +82,16 @@ void state_update(const uint8_t *data, const uint8_t size) {
set_bit(state[0], 0, update.EnableRumbleEmulation); set_bit(state[0], 0, update.EnableRumbleEmulation);
set_bit(state[0], 1, update.UseRumbleNotHaptics); set_bit(state[0], 1, update.UseRumbleNotHaptics);
// Mirror the host's adaptive-trigger "apply" flags into the outgoing state.
// Without these, copy_if_allowed below writes the 11-byte FFB params into
// state[] but the DS5 receives them with AllowRight/LeftTriggerFFB cleared
// and discards them — so adaptive triggers were dead through the dongle on
// BOTH the standalone 0x31 path and the 0x36 audio fold, while direct USB
// (which carries the host's flags verbatim) worked. The on-device Trigger
// Test screen sets these same bits (0x0C) directly, which is why it worked.
// (issue #6)
set_bit(state[0], 2, update.AllowRightTriggerFFB);
set_bit(state[0], 3, update.AllowLeftTriggerFFB);
set_bit(state[38], 2, update.EnableImprovedRumbleEmulation); set_bit(state[38], 2, update.EnableImprovedRumbleEmulation);
copy_if_allowed( copy_if_allowed(
update.UseRumbleNotHaptics || update.EnableRumbleEmulation, update.UseRumbleNotHaptics || update.EnableRumbleEmulation,