Compare commits

...
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.8 03647048f0 docs: handoff for the trigger-buzz fix (#11) — keep #6, kill the side-effect
Design-complete, not yet implemented. Captures the root cause (verified vs
upstream: #6 enabling the trigger apply-bits is the only haptic deviation; the
0x36 audio frame re-broadcasts state[] at ~100 Hz, re-firing the latched
trigger effect on the audio clock = the buzz), and the exact USB-faithful fix:
a state_set_frame() one-shot that emits trigger FFB once per host update then
goes trigger-neutral, plus the three call-site swaps. Includes build + HIL
verification steps. Resume on the main PC with hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:18:49 -06:00
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
12 changed files with 628 additions and 47 deletions
+23
View File
@@ -10,6 +10,29 @@ 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 ## [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`). 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`).
+171
View File
@@ -0,0 +1,171 @@
# HANDOFF — fix the constant trigger buzz (#11) without losing #6
_Written 2026-06-05 on the laptop (no Pico on hand). Resume on the main PC with hardware._
**Status: design complete + agreed. NOT implemented, NOT built, NOT flashed.** This doc has the
full root-cause and the exact code so you can drop it in, build, and HIL-verify in one sitting.
---
## The decision (don't re-litigate this)
- **Ground truth for the whole project:** the dongle should make the DualSense behave **as close to a
wired-USB controller as possible.** Every "should we add a setting?" gets answered with "does USB do
it? then match that" — not a new toggle.
- **Keep the #6 adaptive-trigger fix.** Users have confirmed games work with it. It was correct.
- **#11 (constant trigger buzz) is an unintended side-effect of #6**, not a reason to revert it.
- **We rejected a toggle.** A toggle treats the symptom and makes you babysit it. We fix the cause.
---
## Root cause (verified against upstream `awalol/DS5Dongle`)
I fetched upstream and diffed the haptic path. Fork point: `0ed05d3` ("fix: rumble").
- **The only haptic-relevant deviation from upstream is #6** (`71cead4`): in `src/state_mgr.cpp` we added
```c
set_bit(state[0], 2, update.AllowRightTriggerFFB);
set_bit(state[0], 3, update.AllowLeftTriggerFFB);
```
Upstream copies the trigger FFB *params* (its `copy_if_allowed` lines are identical to ours) but
**never sets these two apply-bits**, so upstream's adaptive triggers are silently dead. We turned
them on. That's the entire difference.
- **The re-broadcast is NOT our deviation.** Both upstream and our fork stamp the full controller state
(`state_set(pkt+13, 63)`) into **every `0x36` audio frame** — it's load-bearing for keeping the
speaker/HD-haptic actuators alive (the `0x7f 0x7f` volume bytes must ride every frame; see CLAUDE.md).
- **So the buzz = (our enabled trigger bits) × (the audio-rate re-broadcast).** A host-latched trigger
effect gets re-commanded ~100×/second on the **audio clock**. Wired USB never does that — the host
drives trigger updates on **its** clock and the controller latches the last effect.
- Upstream doesn't buzz only because it never enables triggers (and pays for it with dead triggers).
Neither upstream (no triggers) nor our current master (buzzing triggers) matches USB. The fix below does.
`auto_haptics` (speaker-derived rumble) is a fork-only feature but is **off by default** — not the culprit.
---
## How state reaches the controller — the four egress points
| Site | Report | Cadence | Carries triggers today? |
|---|---|---|---|
| `src/main.cpp:322` | `0x31` | only on a host output report, **and only when audio is OFF** | yes (already USB-like) |
| `src/bt.cpp:636` | `0x32` | **once**, at L2CAP connect (init handshake) | init only; params are zero |
| `src/audio.cpp:172` | `0x36` | ~4 Hz keepalive (audio armed but idle) | yes — re-fires |
| `src/audio.cpp:404` | `0x36` | **~100 Hz** while audio streams | yes — re-fires (the loud buzz) |
Critical detail at `src/main.cpp:307-311`: **when audio is active the firmware deliberately does NOT send
the standalone `0x31`** — it lets the trigger FFB written into `state[]` "ride the `0x36` audio frames
instead." So during gameplay-with-audio, the 100 Hz `0x36` re-broadcast is the *only* trigger delivery
path. That is exactly the path that buzzes.
---
## The fix — one-shot trigger emit (mirrors USB cadence)
Deliver the trigger apply-bits **once per host update**, then go trigger-neutral so the controller
*latches* the effect instead of being re-hit by it. The `0x0C` apply-bits are literally named
"*Enable setting* RightTriggerFFB" — cleared means "leave triggers as-is," so the DS5 holds the last
applied effect (same as USB).
### `src/state_mgr.cpp`
1. Add a file-static one-shot (single-core: `state_update` and the frame senders all run on core 0, so
no atomics needed):
```c
static bool trigger_oneshot = false; // armed by a host trigger write, consumed by the next outbound frame
```
2. In `state_update()`, right after the two existing `set_bit(state[0], 2/3, ...)` lines, arm it:
```c
// Trigger FFB is a host-latched effect, not a continuous level: arm a one-shot so exactly ONE
// outbound frame carries the apply-bits after each host update, then state_set_frame() masks them
// off. The controller holds the latched effect instead of it being re-fired on every 0x36 audio
// frame (the #11 buzz). Mirrors wired USB. Keeps #6 fully intact.
if (update.AllowRightTriggerFFB || update.AllowLeftTriggerFFB) trigger_oneshot = true;
```
3. Add a new frame-copy next to `state_set()`:
```c
// Like state_set(), but for REPEATING outbound frames (the 0x36 audio frames, the 0x31 host echo).
// Emits the adaptive-trigger apply-bits only on the first frame after a host trigger update, then
// clears them so the effect isn't re-fired at audio rate (#11). Rumble (bits 0/1) is a continuous
// level and is intentionally left re-asserted every frame.
void state_set_frame(uint8_t *data, const uint8_t size) {
state_set(data, size);
if (trigger_oneshot) trigger_oneshot = false; // this frame carries the triggers
else if (size > 0) data[0] &= ~0x0C; // clear AllowRight/LeftTriggerFFB → hold latched effect
}
```
### `src/state_mgr.h`
Declare it:
```c
void state_set_frame(uint8_t *data, const uint8_t size);
```
### Swap the three *repeating* senders (leave `bt.cpp:636` init as plain `state_set`)
- `src/audio.cpp:172` → `state_set_frame(pkt + 13, 63);`
- `src/audio.cpp:404` → `state_set_frame(pkt + 13, 63);`
- `src/main.cpp:322` → `state_set_frame(outputData + 3, sizeof(SetStateData));`
### Why this is safe
- Additive + 3 one-line swaps; fully reversible.
- Can't break #6: every host trigger update still produces exactly one trigger-bearing frame to the DS5.
- Can't regress rumble: only byte-0 bits 2,3 are masked; rumble bits 0,1 still re-assert every frame.
- Worst case if the buzz had another cause: it's a no-op move toward USB cadence, not a regression.
### Offsets (already confirmed in `src/utils.h:289` `SetStateData`)
- byte 0 bit 2 = `AllowRightTriggerFFB`, bit 3 = `AllowLeftTriggerFFB` → mask `0x0C`.
- `RightTriggerFFB[11]` at state byte 10; `LeftTriggerFFB[11]` at byte 21. (Params don't need zeroing —
the DS5 ignores them when the apply-bit is clear. Masking the apply-bit is sufficient.)
---
## Build + flash (main PC)
```bash
git fetch origin && git checkout master # this fix branches off master (clean 0.6.12)
git checkout -b fix/trigger-ffb-latch
# ...apply the edits above...
# Toolchain (Ubuntu 26.04 — the apt install was started on the laptop, finish/verify it):
# sudo apt-get install -y cmake ninja-build build-essential \
# gcc-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib python3 git
# Pico SDK 2.2.0 + export PICO_SDK_PATH, then PIN TinyUSB (or the audio config won't compile):
( cd "$PICO_SDK_PATH/lib/tinyusb" && git fetch --tags && git checkout 0.20.0 )
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPICO_SDK_PATH="$PICO_SDK_PATH"
cmake --build build --target ds5-bridge # → build/ds5-bridge-oled.uf2
# BOOTSEL the board, copy the UF2 onto the RP2350 mount
```
## HIL verification (the part that actually needs the hardware)
1. **Triggers still work (#6 intact):** in a game with adaptive triggers (or the on-dongle Trigger Test
screen), confirm resistance/effects still fire. Test **with audio active** specifically (that's the
path we changed).
2. **Buzz is gone (#11):** set a trigger effect in a game, then idle — the constant low-level
buzz/vibration should not persist.
3. **Decisive instrument:** OLED **Diagnostics** screen — watch the `trig` / `host02` counters during
the buzz scenario. (These are `g_host_out02_trig_allow` / `_to_bt` / `_folded` from `src/main.cpp`.)
Confirms whether the host is streaming trigger FFB vs a stale latch.
4. **Latch assumption check:** the fix relies on the DS5 holding the last effect when apply-bit=0. If an
effect unexpectedly *clears* between updates, the assumption is wrong — fall back to "emit on change"
(shadow-compare the trigger bytes) instead of the one-shot. (Not expected; apply-bit semantics say hold.)
Commit only after HIL passes (one-UF2-per-feature cadence). Trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. Branch off master, push to `origin` only. Then it can ship as 0.6.12 (update `CHANGELOG.md [Unreleased]` → Fixed).
---
## Other open threads (unchanged, not blocking this)
- **`audio/speaker-rate-trim`** branch — experimental `SpkTrim` crackle sweep (#7). See `HANDOFF.md` on
that branch. Separate work.
- **`defaults/usb-faithful`** branch — 1000 Hz polling + mic-off defaults; needs a soak-test before merge.
- **Toolchain install** was started on the laptop (Ubuntu 26.04, apt) but not finished/verified; Pico SDK
not yet cloned there. The main PC presumably already has a working build env.
## One-paste kickoff for a fresh session on the main PC
> Read `HANDOFF-trigger-buzz.md`. We're keeping the #6 adaptive-trigger fix and removing its side-effect,
> the constant trigger buzz (#11), by delivering trigger FFB once per host update instead of re-firing it
> on every 0x36 audio frame — wired-USB cadence. The exact edits (a `state_set_frame` one-shot in
> `state_mgr.cpp` + three call-site swaps) are in the doc. Help me apply them on a branch off master,
> build the UF2, and run the HIL verification in the doc.
+2 -2
View File
@@ -264,7 +264,7 @@ scripts/mic_diag.sh bt-trace
#### 1. 状态 #### 1. 状态
连接状态、已配对 DualSense 的蓝牙地址、带条形的电量百分比(`+` 充电中 / `*` 已充满 / `!` 错误)、实时摇杆位置、方向键、面板按键(△ ◯ ✕ □)、L1/R1,以及 L2/R2 模拟扳机填充条。链路指示与电量使用小像素图标。 连接状态、已配对 DualSense 的蓝牙地址、带条形的电量百分比(`+` 充电中 / `*` 已充满 / `!` 错误)、实时摇杆位置(按下 **L3 / R3** 时,对应的摇杆框会反色闪烁 —— 白底黑点 —— 直到松开)、方向键、面板按键(△ ◯ ✕ □)、L1/R1,以及 L2/R2 模拟扳机填充条。链路指示与电量使用小像素图标。充电时,电池旁会显示到 100% 的预计时间 `~Nm`。
<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">
@@ -299,7 +299,7 @@ scripts/mic_diag.sh bt-trace
#### 5. 陀螺仪倾斜 #### 5. 陀螺仪倾斜
实时 X/Y/Z 加速度计数值,配 40×40 十字准线框。倾斜手柄,点会实时跟随。 实时 X/Y/Z 加速度计数值,配 40×40 十字准线框。点会实时跟随手柄的倾斜,并在手柄**平放时居中** —— 它使用手柄自带的逐台出厂 IMU 校准(从特性报告 `0x05` 解析),因此每个手柄的静止位置与增益都准确。向左/右、向前/后倾斜时,点会朝相应方向移动
<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">
+2 -2
View File
@@ -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">
+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()
+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)")
+51 -25
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
@@ -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;
} }
} }
+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 {
+84 -15
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) {
@@ -521,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;
@@ -533,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
@@ -692,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
@@ -1547,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
} }
} }
@@ -1649,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;
} }
} }
@@ -1796,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);
@@ -1822,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
@@ -1858,6 +1918,10 @@ 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
@@ -1885,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.
+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,