Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
878a742bfc | ||
|
|
84393c00e8 | ||
|
|
71cead401d | ||
|
|
8fc369b1af | ||
|
|
c879b24364 | ||
|
|
c7e6c5b914 | ||
|
|
7e21a1fbea | ||
|
|
3556cdedb3 | ||
|
|
a7f20cfd5a |
@@ -10,6 +10,51 @@ 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
|
## [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.
|
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.
|
||||||
|
|||||||
+388
-37
@@ -1,53 +1,404 @@
|
|||||||
# Pico2W DualSense 5 Bridge
|
# Pico2W DualSense 5 Bridge — OLED Edition
|
||||||
|
|
||||||
[English](./README.md)
|
[English](./README.md)
|
||||||
> 将 Pico2W 变成 DS5 手柄的无线适配器
|
|
||||||
|
|
||||||
# 功能特点
|
> 将 Raspberry Pi Pico2W 变成 DualSense (DS5) 手柄的无线适配器 —— 并可选配板载状态显示屏。
|
||||||
- 支持HD震动
|
|
||||||
|
|
||||||
# 使用方法
|
> **OLED 版**是 **[awalol/DS5Dongle](https://github.com/awalol/DS5Dongle)**(上游)的一个分支,增加了可选的 Pico-OLED-1.3 128×64 显示屏插件,提供 11 个屏幕(状态、4 槽多手柄配对、带收藏与特效预设的灯条调色器、扳机测试、陀螺仪倾斜、触摸板、诊断、CPU/时钟、蓝牙信号强度、音频 VU 表,以及一个持久化设置菜单),外加 DS5 按键组合软重启。核心桥接固件以上游为权威来源;本分支跟踪上游并在其之上叠加插件功能。
|
||||||
1. 按住 Pico 上的BOOTSEL进入刷机
|
|
||||||
2. 将 .uf2 文件拖入进去
|
|
||||||
3. 将 DS5 手柄进入蓝牙配对模式
|
|
||||||
4. Enjoy it
|
|
||||||
|
|
||||||
***你可能需要在控制器处于匹配模式时重新插拔 pico***
|
---
|
||||||
|
|
||||||
- 手柄连接到pico以后,系统才会显示设备
|
## 🛠️ 网页配置工具
|
||||||
|
|
||||||
# Pico 配置调整
|
**[→ 打开 OLED 版网页配置](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/#config)**
|
||||||
你可以通过网页调整Pico的内部设置
|
|
||||||
|
|
||||||
- 用于正式固件: https://ds5.awalol.eu.org
|
网页工具是一站式方案 —— **无需安装、无需命令行、无需 `picotool`**。一块全新的 Pico 2 W 可以全程在浏览器里从"刚开箱"做到完整刷写并配置完成:
|
||||||
- 用于测试固件: https://ds5-dev.awalol.eu.org
|
|
||||||
|
|
||||||
### Pico W 版本
|
- **刷写固件标签页** —— 让 Pico 进入 **BOOTSEL 模式**,然后在浏览器中点击 *Connect to Pico*,再点 *Flash now*。站点会捆绑最新发布的 UF2,你也可以载入自己编译的本地 `.uf2`。基于 WebUSB。
|
||||||
|
|
||||||
Pico W 由于性能问题,只能支持震动,不支持扬声器。
|
> **什么是 BOOTSEL 模式?** 这是 Pico 内置的刷写模式。进入方法:按住 Pico 上标有 **BOOTSEL** 的白色小按钮,*然后*插入 USB 线(若已插好,则在按住 BOOTSEL 的同时短暂拔插一次)。Pico 会作为可移动磁盘出现在电脑上 —— 看到它就说明已进入 BOOTSEL 模式。网页工具刷完固件后,Pico 会自动重启进入正常模式即可使用。
|
||||||
你可以通过开启 `-DPICO_W_BUILD=ON` 编译项去开启 Pico W 固件编译,或者在 Github Action 下载预编译的固件
|
- **配置标签页** —— 设备刷好并重新连接后,可编辑振动增益、扬声器音量、轮询率、音频自动触感模式以及其余持久化设置;一键保存到设备闪存。基于 WebHID。
|
||||||
|
- **重映射标签页** —— 可视化按键重映射器:在实时 DualSense 示意图上点击某个按键即可把它重新指派为任意其他按键(肩键/扳机会以带标签的图标浮动到角落)。映射保存在设备上并在主机看到报告之前应用,因此在任何游戏、任何操作系统中都生效。基于 WebHID。
|
||||||
|
- **OLED 预览标签页** —— 像素级精确模拟全部 11 个 OLED 屏幕。用页面内的 KEY0/KEY1 按钮(或在 DualSense 配对时用手柄的 △ / R1 / 方向键)来导航。循环切换扳机测试预设时,自适应扳机会在手柄上真实触发。
|
||||||
|
|
||||||
### USB 唤醒支持
|
可在任何基于 Chromium 的浏览器中使用(Chrome、Edge、Brave、Opera)。Firefox 与 Safari 不提供 WebHID 或 WebUSB,因此那里无法刷写和实时配置 —— 但 OLED 预览仍会用模拟数据渲染。
|
||||||
这是一项实验性的功能。如果你需要该功能,请前往 feat/usb-wake 分支进行编译,或者使用该分支对应的 Github Action 预编译固件。`ds5-bridge-wake.uf2` 为该功能的固件
|
|
||||||
|
|
||||||
极为建议在使用该功能前阅读 #60 和 #61
|
> 网页工具源码:**[MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)**([awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web) 的分支)。
|
||||||
|
|
||||||
### 社区分支
|
---
|
||||||
https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition
|
|
||||||
https://github.com/zurce/DS5Dongle-OLED
|
|
||||||
|
|
||||||
# 当前问题:
|
## 概述
|
||||||
- 声音可能有点小卡顿
|
|
||||||
- 由于编码需要,需要对pico进行超频,当前的参数是1.2V 320MHz。
|
|
||||||
- 若您的pico使用该超频参数无法启动,请自行增加电压或者降低频率
|
|
||||||
|
|
||||||
# 未来计划
|
本项目让 Raspberry Pi Pico2W 作为 DualSense 手柄的蓝牙桥接器,实现无线连接并增强触感支持。
|
||||||
请查看[DS5Dongle plan](https://github.com/users/awalol/projects/5)
|
|
||||||
|
|
||||||
# 编译
|
## 功能特点
|
||||||
需要将pico sdk里面的tinyusb版本升级到最新
|
|
||||||
|
|
||||||
# 致谢
|
**核心桥接(来自上游):**
|
||||||
- [rafaelvaloto/Pico_W-Dualsense](https://github.com/rafaelvaloto/Pico_W-Dualsense) - 灵感来源
|
|
||||||
- [egormanga/SAxense](https://github.com/egormanga/SAxense) - 震动报文
|
- 通过 Pico2W 完整连接 DualSense
|
||||||
- [https://controllers.fandom.com/wiki/Sony_DualSense](https://controllers.fandom.com/wiki/Sony_DualSense) - 数据报文结构
|
- HD 触感(高级振动反馈)
|
||||||
- [Paliverse/DualSenseX](https://github.com/Paliverse/DualSenseX) - 扬声器数据包报文
|
- 无线蓝牙桥接
|
||||||
|
- 通过麦克风音量调节触感增益
|
||||||
|
- 可配置的 LED 与断连行为
|
||||||
|
|
||||||
|
**OLED 版新增:**
|
||||||
|
|
||||||
|
- 可选的 Pico-OLED-1.3 状态显示屏,含 **11 个屏幕**(状态、配对槽、灯条、扳机测试、陀螺仪倾斜、触摸板、诊断、CPU/时钟、RSSI、VU 表、设置)
|
||||||
|
- **按键重映射** —— 把 16 个数字控件(面板按键、方向键、肩键/扳机、摇杆按下、Create/Options)中的任意一个重新指派为其他按键。映射保存在设备上并在主机看到报告之前应用,因此在**任何游戏和操作系统中都无需任何主机端软件**即可生效;默认为恒等映射(无重映射)。可在[网页配置工具](#️-网页配置工具)的**重映射标签页**中可视化编辑,或通过 `scripts/remap_test.py` 无界面编辑。保存在专属闪存扇区,重启后保留。
|
||||||
|
- **4 槽持久化多手柄配对** —— 可绑定至多四个 DualSense,在 OLED 上切换,开机时自动重连槽 0
|
||||||
|
- **灯条调色器**,含 4 个用户收藏槽 + 呼吸 / 彩虹 / 渐变特效预设
|
||||||
|
- **持久化设置菜单**,涵盖 8 个固件配置字段(振动增益、扬声器音量、轮询率等),并提供按住确认的"重置"与"清除全部槽"操作
|
||||||
|
- **OLED 空闲省电阶梯** —— 手动亮度循环(长按 KEY1)、空闲 2 分钟自动深度变暗并显示一个呼吸小点、空闲 15 分钟完全熄屏。按键、手柄配对或输入时立即唤醒。是真正的防烧屏,而不只是调对比度。
|
||||||
|
- **软重启**,无需拔 USB:长按 DS5 的 `PS + Mute`(可无界面工作),或在 OLED 插件上**同时按住 KEY0 + KEY1 持续 1 秒**(取代了旧的 KEY0 双击手势 —— 快速翻页时容易误触发)
|
||||||
|
- **核心桥接的审计修复** —— 修复音频路径中关键的栈溢出(解决长期存在的"音频卡顿")、安全加固、看门狗、HID/L2CAP 边界处的长度校验(见 [CHANGELOG.md](./CHANGELOG.md))
|
||||||
|
|
||||||
|
## 硬件
|
||||||
|
|
||||||
|
### 必需
|
||||||
|
|
||||||
|
| 物品 | 说明 | 大致价格 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Raspberry Pi Pico 2 W** | 搭载 RP2350 MCU,板载 CYW43 蓝牙/WiFi。[官方产品页](https://www.raspberrypi.com/products/raspberry-pi-pico-2/) | 约 $7 USD |
|
||||||
|
| **索尼 DualSense 手柄** | 任何标准 PS5 DualSense(VID `054C:0CE6`)。 | — |
|
||||||
|
| **USB-C 线缆** | 将 Pico 2 W 连接到主机 PC。 | — |
|
||||||
|
|
||||||
|
### 可选(强烈推荐)
|
||||||
|
|
||||||
|
| 物品 | 说明 | 大致价格 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Waveshare Pico-OLED-1.3** | 128×64 SH1107 OLED 插件板(SKU HIPI1798)。直接插到 Pico 2 W 排针上。固件检测到时自动驱动,缺失时优雅地不做任何动作。[产品页](https://www.waveshare.com/pico-oled-1.3.htm) · [Wiki](https://www.waveshare.com/wiki/Pico-OLED-1.3) | 约 $6 USD |
|
||||||
|
| **小散热片**(用于 RP2350) | 固件将 MCU 超频到 320 MHz @ 1.20 V(见[性能 / 超频](#性能--超频))。持续游玩时小散热片或导热垫有帮助。 | $1–3 USD |
|
||||||
|
|
||||||
|
### 哪里购买
|
||||||
|
|
||||||
|
Pico 2 W 与 Waveshare Pico-OLED-1.3 在全球都很容易买到:
|
||||||
|
|
||||||
|
- **Adafruit**、**Pimoroni**、**The Pi Hut**、**DigiKey**、**Mouser** —— 主要电子元件分销商(美/欧)
|
||||||
|
- **Waveshare 自营商店** 购买 OLED 插件
|
||||||
|
- 各地区 **Amazon** 店面 —— 搜索 `Raspberry Pi Pico 2 W` 与 `Waveshare Pico-OLED-1.3`(或 SKU `HIPI1798`)
|
||||||
|
- **AliExpress(速卖通)** —— 原装 Waveshare 与 Pico 货源以及克隆品;注意查看卖家评分
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 刷写固件
|
||||||
|
|
||||||
|
1. 按住 Pico2W 上的 BOOTSEL 按钮
|
||||||
|
2. 通过 USB 将 Pico2W 连接到电脑
|
||||||
|
3. 设备会挂载为 USB 存储设备
|
||||||
|
4. 将 .uf2 固件文件拖放到该设备上
|
||||||
|
|
||||||
|
### 配对手柄
|
||||||
|
|
||||||
|
1. 让 DualSense 手柄进入蓝牙配对模式
|
||||||
|
2. 等待 Pico2W 检测并连接
|
||||||
|
3. 连接后,设备会出现在主机系统中
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
有四种方式配置固件:
|
||||||
|
|
||||||
|
**网页配置(推荐,任何基于 Chromium 的浏览器):** 在 Chrome、Edge、Vivaldi、Brave 或 Opera 中打开 **[DS5 Bridge Config — OLED Edition](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/)**(不支持 Firefox —— Mozilla 拒绝实现 WebHID)。点击 **Connect**,在浏览器对话框中选择 DualSense,然后用熟悉的表单界面编辑任意字段。页面通过 WebHID 直接与 Pico 通信 —— 无驱动、无安装,数据不离开你的机器。源码见 [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)。
|
||||||
|
|
||||||
|
**设备端(已装 OLED 插件):** 使用屏上的**设置**菜单(第 11 个屏幕)。方向键 ▲▼ 移动选择,▶◀ 调整数值,△ 保存到闪存。
|
||||||
|
|
||||||
|
**终端 CLI(任何系统、任何浏览器):** 安装 hidapi,然后使用 `scripts/set_ds5.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install hidapi
|
||||||
|
scripts/set_ds5.py # 显示当前配置
|
||||||
|
scripts/set_ds5.py --auto-haptics fallback # 修改某字段并持久化到闪存
|
||||||
|
scripts/set_ds5.py --speaker-volume -10 --haptics-gain 1.5
|
||||||
|
scripts/set_ds5.py --slot 2 # 切换活动多槽配对
|
||||||
|
scripts/set_ds5.py --version # 固件版本
|
||||||
|
scripts/set_ds5.py --rssi # 实时蓝牙 RSSI(dBm)
|
||||||
|
scripts/set_ds5.py --help # 完整参数列表
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本通过 USB HID 特性报告 `0xF6`/`0xF7`/`0xF8`/`0xF9` 与固件通信 —— 在 Linux、macOS、Windows 的任何终端下都可用,与你用哪个浏览器无关。移植自 [loteran/DS5Dongle](https://github.com/loteran/DS5Dongle) 并为本分支的 `current_slot` 字段做了扩展。
|
||||||
|
|
||||||
|
**DualSense 手柄按键(旧式后备方案,无 OLED、无 CLI):**
|
||||||
|
|
||||||
|
### 麦克风音量
|
||||||
|
|
||||||
|
控制触感增益倍率。范围:`[1.0 – 2.0]`。
|
||||||
|
|
||||||
|
### 扬声器静音
|
||||||
|
|
||||||
|
禁用 LED 连接指示灯。手柄重连后生效。
|
||||||
|
|
||||||
|
### 麦克风静音
|
||||||
|
|
||||||
|
禁用静默断连行为。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
只有在手柄连接之后,Pico 设备才会对系统可见
|
||||||
|
|
||||||
|
某些行为需要经过重连周期才会生效
|
||||||
|
|
||||||
|
### 低电量 LED 指示
|
||||||
|
|
||||||
|
当所连 DualSense 报告其电量等于或低于 10%(且未在充电)时,Pico 板载 LED 会从常亮切换为 1 Hz 闪烁,让你一眼看到警告。一旦手柄插上充电或其报告电量回升,LED 恢复常亮。即使设置了 `disable_pico_led`,该闪烁也会触发 —— 该警告被视为关键,会覆盖 LED 关闭偏好;电量恢复或手柄开始充电后,LED 回到其禁用(熄灭)状态。
|
||||||
|
|
||||||
|
如需在编译期退出此功能,用 `-DENABLE_BATT_LED=OFF` 配置。默认为 ON。
|
||||||
|
|
||||||
|
## 蓝牙 DualSense 麦克风
|
||||||
|
|
||||||
|
**DualSense 的内置麦克风可通过适配器的蓝牙配对工作**(自 v0.6.8 起)。手柄将麦克风以 Opus 音频流式传输;适配器解码后作为标准 DualSense USB 采集设备呈现给主机,因此任何应用(Discord、OBS、游戏内语音)都能像普通麦克风一样使用它。
|
||||||
|
|
||||||
|
这一度被认为不可能实现 —— 本分支早期版本曾将其记录为索尼固件的硬性限制(Linux 的 `hid-playstation` 内核驱动至今仍不支持)。结果发现它取决于适配器外发音频报告中的一个使能位。**完全归功于 [awalol](https://github.com/awalol/DS5Dongle)(上游)发现了它。** 更正后的调查记录见 [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md)。
|
||||||
|
|
||||||
|
**使用方法:**
|
||||||
|
|
||||||
|
- **默认开启。** 配对手柄后一两秒内麦克风便开始流式传输 —— 无需游戏音频(适配器会自行保活该流)。
|
||||||
|
- **在主机端调高采集音量** —— 它默认很低。在 Linux 上,用 `arecord -l` 找到声卡,然后例如 `amixer -c <DualSense 声卡> sset 'Headset' 90%`。用 `scripts/mic_diag.sh capture` 验证采集。
|
||||||
|
- **关闭它以节省手柄电量** —— OLED **设置 → BT Mic**,或[网页配置工具](#️-网页配置工具)中的 **BT microphone** 开关。常开麦克风会让 DS5 的音频子系统保持唤醒,明显加快耗电,所以不用语音时请关闭。
|
||||||
|
|
||||||
|
**注意点:**
|
||||||
|
|
||||||
|
- 麦克风音频为**单声道**(解码为单声道,在立体声采集端点上复制)。
|
||||||
|
- 会话中途关闭会立即停止主机端的馈送,但手柄会持续流式传输直到下次重连(目前没有已知的"停止"命令);以关闭状态全新连接则永远不会启用它。
|
||||||
|
- OLED **诊断**屏幕的 `Mic in:` 计数器在麦克风流式传输时约为 ~100/s —— 是确认它在工作的快捷方式。
|
||||||
|
- **丢包隐藏:** 丢失的麦克风帧(蓝牙链路弱、距离远、干扰)会用 Opus PLC 隐藏,使语音保持连续而不卡顿/中断,代价是少量抖动缓冲延迟(~30 ms)。诊断屏的 `Mic PLC:` 计数器仅在隐藏帧时增长 —— 实质上是一个实时链路质量计。
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
- 超频到 320 MHz @ 1.20 V 是稳定蓝牙配对所**必需**的。把电压降到 1.10 V 或时钟降回默认会破坏 CYW43 PIO SPI 总线,蓝牙将停止工作。持续游玩时建议在 RP2350 上加小散热片。
|
||||||
|
- 在 Linux + Steam 上,HD 触感未必在每个游戏中都触发;这是游戏侧问题(部分作品仅在 Windows 专用 API 下发送 HD 触感音频)。在《漫威蜘蛛侠:重制版》中测试可用;在《对马岛之魂》中未送达 —— 同一固件、同一手柄。
|
||||||
|
- **USB 3.0 端口可能干扰配对** —— 手柄可能卡在常亮的琥珀/黄色灯条上始终连不上,而同一适配器在 USB 2.0 端口上工作正常。这是射频干扰,并非固件 bug;见下文 [USB 3.0 端口与蓝牙干扰](#usb-30-端口与蓝牙干扰)。(自 v0.6.8 起,固件会自动重试卡住的连接而不是一直挂着,能恢复许多 —— 但非全部 —— 边缘情况。)
|
||||||
|
|
||||||
|
## USB 3.0 端口与蓝牙干扰
|
||||||
|
|
||||||
|
如果适配器在某个端口能用而在另一个不能用,**请先试试 USB 2.0 端口。** USB 3.0 端口(尤其是 USB 3.0 延长线)会发出以 2.4 GHz 附近为中心的宽带射频噪声 —— 正是适配器蓝牙电台与手柄通信所用的频段。这是业界有充分记录的问题(Intel,*"USB 3.0 Radio Frequency Interference Impact on 2.4 GHz Wireless Devices"*),并非本固件特有。该噪声会降低适配器蓝牙接收机的灵敏度,于是手柄可以开始连接(琥珀灯条)但链路太吵无法完成 —— 卡在黄色上。
|
||||||
|
|
||||||
|
缓解措施,大致按效果排序:
|
||||||
|
|
||||||
|
1. **把适配器插到 USB 2.0 端口**(往往是最简单的修复 —— 许多主板/机箱两者都有)。
|
||||||
|
2. **用一根短的 USB 2.0 延长线** 让适配器离 USB 3.0 端口/金属机箱几英寸远,改善对手柄的视线。要特别避免 USB 3.0 延长线。
|
||||||
|
3. **使用插在 USB 3.0 端口上的有源 USB 2.0 集线器** —— 集线器会把链路降速并增加距离。
|
||||||
|
4. **在靠近适配器处的线缆上夹一个磁环。**
|
||||||
|
5. **配对时让手柄更靠近 / 与适配器保持视线。**
|
||||||
|
|
||||||
|
固件会自行持续重试卡住的连接,因此灯条变琥珀后让它插着等约 10–20 秒,也许无需拔插即可恢复。
|
||||||
|
|
||||||
|
## 性能 / 超频
|
||||||
|
|
||||||
|
**你无需为此做任何事 —— 超频已内置在固件中。** 当你刷入本仓库的 UF2 时,Pico 2 W 会自动以下列设置启动。没有单独要运行的工具、没有要编辑的配置文件、没有要烧的熔丝。
|
||||||
|
|
||||||
|
内置设置:
|
||||||
|
|
||||||
|
- **电压:1.20 V**(`vreg_set_voltage(VREG_VOLTAGE_1_20)`)
|
||||||
|
- **时钟:320 MHz**(`set_sys_clock_khz(SYS_CLOCK_KHZ, true)`)
|
||||||
|
|
||||||
|
为何必需:在默认时钟/电压下,CYW43 PIO SPI 总线(固件用来与板载蓝牙芯片通信的通路)不可靠,配对会失败。320 MHz @ 1.20 V 是我们验证过能在本板上产生稳定蓝牙链路的最低组合。
|
||||||
|
|
||||||
|
如果你自己编译的构建无法启动(不常见 —— 仅当你改过源码时才相关),可尝试在 `src/main.cpp` 中略微提高电压或降低时钟。运行官方 UF2 发布版的最终用户无需改动此处。
|
||||||
|
|
||||||
|
持续游玩**建议**在 RP2350 上加小散热片,但配对或短时使用并不需要。
|
||||||
|
|
||||||
|
## 编译说明
|
||||||
|
|
||||||
|
从源码编译本项目:
|
||||||
|
|
||||||
|
1. 安装 Pico SDK 2.2.0(或更新版本)。编译使用 `pico_sdk_import.cmake`。
|
||||||
|
2. **在 Pico SDK 内将 TinyUSB 固定到 0.20.0**(`$PICO_SDK_PATH/lib/tinyusb`)。本项目的 `tusb_config.h` 使用了 `TUD_AUDIO_EP_SIZE` 的四参数形式,而 Pico SDK 2.2.0 捆绑的 0.18.0 版本没有它:
|
||||||
|
```bash
|
||||||
|
cd "$PICO_SDK_PATH/lib/tinyusb"
|
||||||
|
git fetch --tags
|
||||||
|
git checkout 0.20.0
|
||||||
|
```
|
||||||
|
3. 用 CMake + Ninja(或 Make)配置并编译:
|
||||||
|
```bash
|
||||||
|
cd /path/to/DS5Dongle
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPICO_SDK_PATH="$PICO_SDK_PATH"
|
||||||
|
cmake --build build --target ds5-bridge
|
||||||
|
```
|
||||||
|
4. UF2 会生成在 `build/ds5-bridge-oled.uf2`。照常用 BOOTSEL 刷写。
|
||||||
|
|
||||||
|
值得了解的编译标志:
|
||||||
|
|
||||||
|
- `-DENABLE_BATT_LED=ON`(默认)—— DS5 低电量时闪烁 Pico LED。
|
||||||
|
- `-DENABLE_SERIAL=ON` —— 将 printf 路由到 USB CDC 用于调试(默认 OFF;生产构建释放 UART)。
|
||||||
|
- `-DPICO_W_BUILD=ON` —— 为原版 Pico W 编译(去掉音频、降低时钟)。默认面向 Pico 2 W。
|
||||||
|
|
||||||
|
## 诊断与调试工具
|
||||||
|
|
||||||
|
排查桥接问题有两种方式 —— 设备端通过 OLED 诊断屏幕,主机端通过 `scripts/mic_diag.sh`(Linux)。主机端更快:无需切屏、无需刷写周期,可在手柄正在使用时运行。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 一次性快照 —— 适配器在 USB 上吗?ALSA 枚举到了吗?采集流在跑吗?当前有手柄配对吗?
|
||||||
|
scripts/mic_diag.sh status
|
||||||
|
|
||||||
|
# 对麦克风 IN 端点做 3 秒 arecord —— 报告峰值 / RMS / 非零计数,
|
||||||
|
# 以便区分"流是静音的"和"流在产生音频"。
|
||||||
|
scripts/mic_diag.sh capture 3
|
||||||
|
|
||||||
|
# 同 `status` 但循环运行,仅在状态变化时打印。可用于
|
||||||
|
# 捕捉配对完成或音频流开/关的确切时刻。
|
||||||
|
scripts/mic_diag.sh watch
|
||||||
|
|
||||||
|
# 实时读取固件的 0xFD 厂商特性报告(经由 /dev/hidraw):
|
||||||
|
# 蓝牙输入计数与速率、最近见到的非 0x31 ID、字节前缀,以及
|
||||||
|
# 扳机流计数器(收到的主机 0x02 / 置了 AllowTriggerFFB 的 /
|
||||||
|
# 转发到蓝牙的)。bt-trace 会给出结论 —— "主机驱动没有发送
|
||||||
|
# 扳机 Allow 位" 还是 "已转发但手柄未触动" —— 这原本需要
|
||||||
|
# USB 协议分析仪才能判断。
|
||||||
|
scripts/mic_diag.sh bt-trace
|
||||||
|
```
|
||||||
|
|
||||||
|
最初是为排查被搁置的 DS5 蓝牙麦克风调查而写的(见 [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md))。`0xFD` 特性报告与 `bt-trace` 解码器现在还携带为 [issue #3](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/issues/3)(部分游戏缺少自适应扳机张力)新增的扳机流计数器。
|
||||||
|
|
||||||
|
## OLED 显示插件(可选)
|
||||||
|
|
||||||
|
如果你把 [Waveshare Pico-OLED-1.3](#硬件) 插到 Pico2W 的排针上,固件会自动将其作为实时状态显示屏驱动。无需任何配置 —— 没有 OLED 时固件会优雅地不做动作。
|
||||||
|
|
||||||
|
### 开机启动画面(上电后 1.5 秒)
|
||||||
|
|
||||||
|
空屏居中显示固件版本 1.5 秒,然后跳转到状态屏幕。
|
||||||
|
|
||||||
|
### 11 个屏幕,用插件上的 KEY0 循环
|
||||||
|
|
||||||
|
循环顺序:**状态 → 配对槽 → 灯条 → 扳机测试 → 陀螺仪倾斜 → 触摸板 → 诊断 → CPU/时钟 → 蓝牙信号 → VU 表 → 设置 →** 回绕。**短按 KEY0 前进;短按 KEY1 后退** —— 在*每个*屏幕上都如此。各屏幕内的交互(循环扳机预设、循环灯条模式、移动设置光标、切换槽)都放在 **DualSense 手柄按键**上,绝不放在 KEY0/KEY1 上,因此 OLED 插件上这两个物理按键始终表示同一含义。
|
||||||
|
|
||||||
|
每个屏幕还会在左上边缘(KEY0 旁)画 **`>`**、在左下边缘(KEY1 旁)画 **`<`**,让屏上标签与按键在物理上配对。
|
||||||
|
|
||||||
|
#### 1. 状态
|
||||||
|
|
||||||
|
连接状态、已配对 DualSense 的蓝牙地址、带条形的电量百分比(`+` 充电中 / `*` 已充满 / `!` 错误)、实时摇杆位置(按下 **L3 / R3** 时,对应的摇杆框会反色闪烁 —— 白底黑点 —— 直到松开)、方向键、面板按键(△ ◯ ✕ □)、L1/R1,以及 L2/R2 模拟扳机填充条。链路指示与电量使用小像素图标。充电时,电池旁会显示到 100% 的预计时间 `~Nm`。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc01.jpg" alt="Status screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 2. 配对槽
|
||||||
|
|
||||||
|
持久化 4 槽多手柄配对。浏览已存手柄、切换活动槽,或清除单个槽。`>` 是光标,`*` 标记当前活动槽。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc02.jpg" alt="Slots screen on the OLED" width="420">
|
||||||
|
|
||||||
|
- **方向键 ▲▼** —— 在槽 0–3 间移动光标
|
||||||
|
- **△** —— 切换到光标所在槽(断开当前,重连到该槽存储的手柄)
|
||||||
|
- **□ 按住 1.5 秒** —— 清除光标所在槽(删除 bd_addr + BTstack 链路密钥)
|
||||||
|
- 活动槽会被持久化;适配器下次开机时重连到它
|
||||||
|
|
||||||
|
#### 3. 灯条调色器
|
||||||
|
|
||||||
|
在各轴上倾斜手柄来调出 R / G / B;固件以 10 Hz 把得到的颜色发送到 DualSense 实际的灯条,因此灯条本身就是可视预览(OLED 是单色的)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc03.jpg" alt="Lightbar color picker on the OLED" width="420">
|
||||||
|
|
||||||
|
- 在手柄上按 **△ ◯ ✕ □** 把当前颜色**保存**到收藏槽 0 / 1 / 2 / 3
|
||||||
|
- 在手柄上按 **R1** 循环模式标签:`[LIVE]` → `[FAV0]` → `[FAV1]` → `[FAV2]` → `[FAV3]` → 特效(呼吸 / 彩虹 / 渐变)→ 回到 `[LIVE]`
|
||||||
|
- 默认收藏:红、绿、蓝、白
|
||||||
|
|
||||||
|
#### 4. 扳机测试
|
||||||
|
|
||||||
|
在手柄上按 **△** 循环七种应用到 L2 和 R2 的自适应扳机效果。扣动各扳机来感受效果。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc04.jpg" alt="Trigger Test screen on the OLED" width="420">
|
||||||
|
|
||||||
|
循环顺序:**关 → 反馈 → 武器 → 振动 → 弓 → 疾驰 → 机枪 → 关 …** 效果参数依据 [dualsensectl](https://github.com/nowrep/dualsensectl) 的逆向工程按位打包,全部为最大强度。
|
||||||
|
|
||||||
|
#### 5. 陀螺仪倾斜
|
||||||
|
|
||||||
|
实时 X/Y/Z 加速度计数值,配 40×40 十字准线框。点会实时跟随手柄的倾斜,并在手柄**平放时居中** —— 它使用手柄自带的逐台出厂 IMU 校准(从特性报告 `0x05` 解析),因此每个手柄的静止位置与增益都准确。向左/右、向前/后倾斜时,点会朝相应方向移动。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc05.jpg" alt="Gyro Tilt screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 6. 触摸板
|
||||||
|
|
||||||
|
触摸板表面的实时渲染。当前手指位置处出现圆点;计数随手指触碰/离开而更新。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc06.jpg" alt="Touchpad screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 7. 诊断
|
||||||
|
|
||||||
|
可滚动的实时计数器列表 —— 运行时间、蓝牙状态、主机 → 蓝牙扳机流(`host02` / `trig` / `tx`)、蓝牙 0x31 输入速率、USB 音频帧/秒、蓝牙 0x32 包/秒,底部还有被搁置的麦克风调查计数器。手柄方向键 ▲/▼ 滚动;右边缘的小 `^` / `v` 标记"上方/下方还有更多"。只读,所以没有光标。无需 UART 线即可验证桥接器在搬运字节。
|
||||||
|
|
||||||
|
同一批计数器也通过 HID 特性报告 `0xFD` 导出给主机端工具 —— 见下文 `scripts/mic_diag.sh bt-trace`。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 8. CPU / 时钟
|
||||||
|
|
||||||
|
实时 RP2350 指标:配置的(`Set`)与实际运行的(`Real`)系统时钟(以晶振参考测量)、从稳压器回读的核心电压,以及片上温度(256 次采样平均 + 慢速 EMA,使数值跟踪真实芯片温度而非 ADC 噪声)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc08.jpg" alt="CPU / Clock diagnostics screen on the OLED" width="420">
|
||||||
|
|
||||||
|
同样的遥测也通过 HID 特性报告 `0xFC` 导出给工具。
|
||||||
|
|
||||||
|
#### 9. 蓝牙信号
|
||||||
|
|
||||||
|
活动链路的实时蓝牙信号强度,以 dBm 显示并带条形。越接近 0 dBm 越强;−90 dBm 为弱。含定性标签(差 / 一般 / 好 / 极佳)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc09.jpg" alt="BT Signal screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 10. VU 表
|
||||||
|
|
||||||
|
扬声器与触感音频路径的实时峰值表。可在手柄未插入主机时验证音频路由。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc10.jpg" alt="VU Meters screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 11. 设置
|
||||||
|
|
||||||
|
持久化配置编辑器。方向键 ▲▼ 移动选择,▶◀ 调整数值,△ 保存到闪存。包含固件配置字段(振动增益、扬声器音量、空闲超时、轮询率)、音频自动触感控件,以及两个按住确认的操作:
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc11.jpg" alt="Settings screen on the OLED" width="420">
|
||||||
|
|
||||||
|
- **AutoHap Off / Fallback / Mix / Replace** —— 选择音频自动触感模式。默认 `Fallback` 仅在游戏不发送原生触感数据时(如 Linux 上的《对马岛之魂》)才触发派生震动;确实发送原生触感的游戏(《漫威蜘蛛侠:重制版》)则原样通过。`Mix` 在原生之上叠加派生,`Replace` 完全忽略原生,`Off` 禁用。
|
||||||
|
- **AH Gain N%** —— 派生信号增益,0–200%,10% 步进。默认 100%。
|
||||||
|
- **AH LP 80/160/250/400 Hz** —— 在包络跟随之前应用到扬声器音频的低通截止。越低越偏重低音,越高越偏临场感。默认 160 Hz。
|
||||||
|
- **Reset to defaults** —— 按住 △ 2 秒恢复所有配置字段
|
||||||
|
- **Wipe all slots** —— 按住 △ 2 秒删除全部 4 个已配对手柄 + 全部 BTstack 链路密钥
|
||||||
|
|
||||||
|
### 按键参考
|
||||||
|
|
||||||
|
OLED 插件上的两个物理按键**严格用于导航**:
|
||||||
|
|
||||||
|
| 按键 | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| **KEY0** 短按 | 下一屏(前进) |
|
||||||
|
| **KEY1** 短按 | 上一屏(后退) |
|
||||||
|
| **KEY1** 长按(≥ 1.5 秒) | 循环 OLED 亮度等级 |
|
||||||
|
| **KEY0 + KEY1** 同时按住 ≥ 1 秒 | `watchdog_reboot` —— 无需拔 USB 的软重启 |
|
||||||
|
|
||||||
|
各屏幕内的状态变更(循环扳机预设、循环灯条模式、移动设置光标、切换槽、把颜色保存到收藏槽)全部发生在 **DualSense 手柄按键**上 —— 绝不在 KEY0 / KEY1 上 —— 因此这两个物理按键在每个屏幕上始终表示同一含义。各按键对应哪个手柄操作,见上文每个屏幕的小节。
|
||||||
|
|
||||||
|
### 引脚定义(标准 Waveshare Pico HAT 布局)
|
||||||
|
|
||||||
|
| 功能 | GPIO |
|
||||||
|
|---|---|
|
||||||
|
| MOSI | 11 |
|
||||||
|
| SCK | 10 |
|
||||||
|
| CS | 9 |
|
||||||
|
| DC | 8 |
|
||||||
|
| RST | 12 |
|
||||||
|
| KEY0 | 15 |
|
||||||
|
| KEY1 | 17 |
|
||||||
|
|
||||||
|
### 软重启恢复
|
||||||
|
|
||||||
|
两种无需拔 USB 重启适配器的方式 —— 配对卡住或想要干净状态时很方便:
|
||||||
|
|
||||||
|
- **OLED 上同时按住 KEY0 + KEY1 ≥ 1 秒** → `watchdog_reboot`。取代了早期版本的"KEY0 双击"手势,因为快速前进导航总会误触双击计时器。
|
||||||
|
- **按住 DualSense 的 `PS + Mute` 2 秒** → `watchdog_reboot`(无论是否装有 OLED 都可用 —— 无界面后备)。
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
本分支中的部分功能与设计思路借鉴自上游的其他分支,并予以致谢:
|
||||||
|
|
||||||
|
- **[zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED)** —— OLED 状态头部的像素图标(视觉方案)、设置屏 "Reset to defaults" 项使用的"按住以恢复出厂"交互模式(按住 △ 2 秒确认),以及新增配对槽屏上的多槽持久化蓝牙配对系统(4 个已绑定手柄、方向键导航、△ 切换槽、□ 按住清除某槽,外加设置菜单中的 "Wipe all slots")。
|
||||||
|
- **[loteran/DS5Dongle](https://github.com/loteran/DS5Dongle)** —— 独立地重新发现了上游 `3a31bd7` 破坏扬声器/HD 触感输出的回归(提交 `c7a8d3c`);我们 `src/audio.cpp` 中的修复恢复了同样的 SetStateData 子报告。也是音频自动触感 DSP(1 极点 LP + 包络跟随器,见 设置 → Auto Haptics)以及"不要把 USB 侧 UAC1 音量同步到持久化配置"修复的来源。
|
||||||
|
- **[awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)** —— 我们分支版网页配置应用 [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web) 的基础。该分支把上游的 Config_body 布局适配为我们的版本,并为我们的新增功能(多槽配对、Auto Haptics)添加了 UI。
|
||||||
|
- **[PS5 Button Icons and Controls](https://zacksly.itch.io/ps5-button-icons-and-controls)**(作者 **Zacksly**)—— 网页配置工具重映射标签页所用的 DualSense 控制器轮廓与按键图标,采用 [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) 授权(已重新着色为 `currentColor` 并裁剪以适配主题)。
|
||||||
|
|
||||||
|
## 路线图
|
||||||
|
- 请查看 [DS5Dongle plan](https://github.com/users/awalol/projects/5)
|
||||||
|
|
||||||
|
## 社区
|
||||||
|
- 加入 Discord 服务器:[Discord Server](https://discord.gg/hM4ntchGCa)
|
||||||
|
- 如果你遇到 bug,请改为开 issue。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
- [rafaelvaloto/Pico_W-Dualsense](https://github.com/rafaelvaloto/Pico_W-Dualsense) —— 项目灵感
|
||||||
|
- [egormanga/SAxense](https://github.com/egormanga/SAxense) —— 蓝牙触感 POC
|
||||||
|
- [https://controllers.fandom.com/wiki/Sony_DualSense](https://controllers.fandom.com/wiki/Sony_DualSense) —— DualSense 数据报告结构文档
|
||||||
|
- [Paliverse/DualSenseX](https://github.com/Paliverse/DualSenseX) —— 扬声器报告数据包
|
||||||
|
|||||||
@@ -265,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">
|
||||||
|
|
||||||
@@ -300,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">
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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)")
|
||||||
+51
-25
@@ -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
|
||||||
@@ -80,7 +80,7 @@ static volatile uint32_t g_mic_plc_frames = 0; // concealed frames genera
|
|||||||
uint32_t audio_mic_plc_frames() { return g_mic_plc_frames; }
|
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) {
|
||||||
@@ -96,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; }
|
||||||
|
|
||||||
@@ -258,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;
|
||||||
@@ -306,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");
|
||||||
@@ -402,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,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;
|
||||||
@@ -455,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+220
-22
@@ -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.
|
||||||
@@ -1019,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");
|
||||||
@@ -1027,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);
|
||||||
@@ -1036,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;
|
||||||
@@ -1216,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;
|
||||||
@@ -1421,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1523,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1670,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);
|
||||||
@@ -1696,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
|
||||||
@@ -1732,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();
|
||||||
@@ -1756,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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user