Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03647048f0 | ||
|
|
878a742bfc | ||
|
|
84393c00e8 | ||
|
|
71cead401d | ||
|
|
8fc369b1af |
@@ -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
|
||||
|
||||
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`).
|
||||
|
||||
@@ -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
@@ -264,7 +264,7 @@ scripts/mic_diag.sh bt-trace
|
||||
|
||||
#### 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">
|
||||
|
||||
@@ -299,7 +299,7 @@ scripts/mic_diag.sh bt-trace
|
||||
|
||||
#### 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">
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ Every screen also paints **`>`** at the top-left edge (next to KEY0) and **`<`**
|
||||
|
||||
#### 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">
|
||||
|
||||
@@ -300,7 +300,7 @@ Cycle order: **Off → Feedback → Weapon → Vibration → Bow → Gallop →
|
||||
|
||||
#### 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">
|
||||
|
||||
|
||||
@@ -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 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_ID 0x36
|
||||
// #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; }
|
||||
|
||||
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) {
|
||||
@@ -96,6 +96,10 @@ uint32_t opus_fifo_drops() { return 0; }
|
||||
// emulator's USB / BT rate display. Updated below.
|
||||
static volatile uint32_t g_usb_frames = 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_bt_packets() { return g_bt_packets; }
|
||||
|
||||
@@ -258,7 +262,7 @@ void audio_loop() {
|
||||
}
|
||||
g_usb_frames += (uint32_t)frames;
|
||||
|
||||
static float audio_buf[512 * 2];
|
||||
static float audio_buf[480 * 2];
|
||||
static uint audio_buf_pos = 0;
|
||||
// 2. 从4ch中提取ch3/ch4,转换为float输入重采样器
|
||||
WDL_ResampleSample *in_buf;
|
||||
@@ -306,11 +310,12 @@ void audio_loop() {
|
||||
#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 + 1] / 32768.0f * audio_gain;
|
||||
if (audio_buf_pos == 512 * 2) {
|
||||
if (audio_buf_pos == 480 * 2) {
|
||||
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)) {
|
||||
queue_try_remove(&audio_fifo,NULL);
|
||||
g_fifo_drops++;
|
||||
}
|
||||
if (!queue_try_add(&audio_fifo, &element)) {
|
||||
printf("[Audio] Warning: audio_fifo add failed\n");
|
||||
@@ -402,20 +407,45 @@ void audio_loop() {
|
||||
pkt[77] = SAMPLE_SIZE;
|
||||
memcpy(pkt + 78, haptic_buf, SAMPLE_SIZE);
|
||||
#if !DISABLE_SPEAKER_PROC
|
||||
// Speaker Audio Data
|
||||
pkt[142] = (plug_headset ? 0x16 : 0x13) | 0 << 6 | 1 << 7; // Speaker: 0x13
|
||||
// Speaker Audio Data — MUST immediately follow the haptic block. The DS5
|
||||
// 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 R Speaker: 0x15
|
||||
// Headset: 0x16
|
||||
pkt[143] = 200;
|
||||
pkt[kSpkOff + 1] = 200;
|
||||
critical_section_enter_blocking(&opus_cs);
|
||||
memcpy(pkt + 144, opus_buf, 200);
|
||||
memcpy(pkt + kSpkOff + 2, opus_buf, 200);
|
||||
critical_section_exit(&opus_cs);
|
||||
#endif
|
||||
|
||||
bt_write(pkt, sizeof(pkt));
|
||||
g_bt_packets++;
|
||||
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 WDL_Resampler resampler_audio;
|
||||
|
||||
void core1_entry() {
|
||||
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_BITRATE(200 * 8 * 100));
|
||||
opus_encoder_ctl(encoder,OPUS_SET_VBR(false));
|
||||
opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // max 4
|
||||
resampler_audio.SetMode(true, 0, false);
|
||||
resampler_audio.SetRates(51200, 48000);
|
||||
resampler_audio.SetFeedMode(true);
|
||||
resampler_audio.Prealloc(2, 512, 480);
|
||||
opus_encoder_ctl(encoder,OPUS_SET_COMPLEXITY(0)); // 5 overloaded core1 (stale frames -> worse); 0 keeps up
|
||||
|
||||
while (true) {
|
||||
static audio_raw_element audio_element{};
|
||||
queue_remove_blocking(&audio_fifo, &audio_element);
|
||||
// 将 512 frames 重采样成 480 frames 以解决噪音问题。感谢 @Junhoo
|
||||
WDL_ResampleSample *in_buf;
|
||||
int nframes = resampler_audio.ResamplePrepare(512, 2, &in_buf);
|
||||
for (int i = 0; i < nframes * 2; i++) {
|
||||
in_buf[i] = audio_element.data[i];
|
||||
}
|
||||
static WDL_ResampleSample out_buf[480 * 2];
|
||||
resampler_audio.ResampleOut(out_buf, nframes, 480, 2);
|
||||
|
||||
// audio_element is exactly 480 stereo frames (10 ms @ 48 kHz) = one native
|
||||
// Opus frame, so encode it directly. The old 512→480 (51200→48000) resample
|
||||
// only existed to coerce a 512-sample buffer into a legal Opus frame size; it
|
||||
// shipped 480 samples every 10.667 ms (haptic-gated cadence) = 45 kHz into the
|
||||
// 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
|
||||
// 10 ms / 100 Hz grid: 100 × 480 = 48000 samples/s, matched, no gaps.
|
||||
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);
|
||||
memcpy(opus_buf, out, 200);
|
||||
critical_section_exit(&opus_cs);
|
||||
g_opus_ready = true;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -72,7 +72,7 @@ void config_valid() {
|
||||
printf("[Config] polling_rate_mode is invalid\n");
|
||||
}
|
||||
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");
|
||||
}
|
||||
if (body->controller_mode > 2) {
|
||||
@@ -84,8 +84,8 @@ void config_valid() {
|
||||
printf("[Config] current_slot is invalid\n");
|
||||
}
|
||||
if (body->auto_haptics_enable > 3) {
|
||||
body->auto_haptics_enable = 1; // Fallback default
|
||||
printf("[Config] auto_haptics_enable invalid, defaulting to 1 (Fallback)\n");
|
||||
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 0 (Off)\n");
|
||||
}
|
||||
if (body->auto_haptics_gain > 200) {
|
||||
body->auto_haptics_gain = 100;
|
||||
@@ -113,6 +113,14 @@ void config_valid() {
|
||||
body->bt_mic_enable = 1;
|
||||
printf("[Config] bt_mic_enable invalid, defaulting to 1 (on)\n");
|
||||
}
|
||||
if (body->screen_brightness > 3) { // kBrightLevels has 4 entries (0..3)
|
||||
body->screen_brightness = 0; // full brightness
|
||||
printf("[Config] screen_brightness invalid, defaulting to 0 (full)\n");
|
||||
}
|
||||
if (body->controller_wakes_display > 1) { // 0xFF erased / upgrade → default ON
|
||||
body->controller_wakes_display = 1;
|
||||
printf("[Config] controller_wakes_display invalid, defaulting to 1 (on)\n");
|
||||
}
|
||||
if (body->config_version != CONFIG_VERSION) {
|
||||
body->config_version = CONFIG_VERSION;
|
||||
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
|
||||
// DS5 battery (keeps its audio subsystem awake), hence the toggle.
|
||||
uint8_t bt_mic_enable;
|
||||
// OLED brightness, as an index into kBrightLevels[] (src/oled.cpp). Persisted
|
||||
// so the KEY1-long-press brightness choice survives a power cycle. Erased
|
||||
// flash (0xFF) → clamped to 0 (full brightness) by config_valid. Issue #9.
|
||||
uint8_t screen_brightness;
|
||||
// When 0, controller input no longer keeps the OLED awake — only the OLED's
|
||||
// own KEY0/KEY1 do — so the dim/off timers actually count down during
|
||||
// gameplay and the panel can sleep while the controller is in use. Default 1
|
||||
// preserves the original "any controller activity wakes the screen"
|
||||
// behavior. Issues #8 (dim timeout never fired during play) and #9.
|
||||
uint8_t controller_wakes_display;
|
||||
};
|
||||
|
||||
struct __attribute__((packed)) Config {
|
||||
|
||||
+84
-15
@@ -52,6 +52,10 @@ constexpr int kRowBytes = kW / 8;
|
||||
constexpr int kFbBytes = kRowBytes * kH;
|
||||
|
||||
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;
|
||||
constexpr uint32_t kFrameUs = 100000;
|
||||
@@ -126,15 +130,16 @@ constexpr int kLbModeHost = 8;
|
||||
constexpr int kNumLbModes = 9;
|
||||
|
||||
// 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 kSettingsAutoHapGainIdx = 9;
|
||||
constexpr int kSettingsAutoHapLpIdx = 10;
|
||||
constexpr int kSettingsScrDimIdx = 11;
|
||||
constexpr int kSettingsScrOffIdx = 12;
|
||||
constexpr int kSettingsBtMicIdx = 13;
|
||||
constexpr int kSettingsResetIdx = 14;
|
||||
constexpr int kSettingsWipeSlotsIdx = 15;
|
||||
constexpr int kSettingsCtrlWakeIdx = 14;
|
||||
constexpr int kSettingsResetIdx = 15;
|
||||
constexpr int kSettingsWipeSlotsIdx = 16;
|
||||
Config_body settings_local{};
|
||||
int settings_sel = 0;
|
||||
bool settings_dirty = false;
|
||||
@@ -236,7 +241,9 @@ void sh1107_init() {
|
||||
// lives near the other text-drawing helpers below.
|
||||
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);
|
||||
for (int j = 0; j < kH; j++) {
|
||||
const uint8_t col = kH - 1 - j;
|
||||
@@ -248,11 +255,45 @@ void flush_fb_raw() {
|
||||
}
|
||||
}
|
||||
|
||||
void flush_fb() {
|
||||
draw_button_chrome();
|
||||
flush_fb_raw();
|
||||
// Prepare a chunked (non-blocking) SPI flush. Pre-reverses the framebuffer
|
||||
// into fb_tx via the LUT and sets flush_progress = 0. The actual SPI
|
||||
// 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 px(int x, int y, bool on) {
|
||||
@@ -521,6 +562,14 @@ void handle_buttons() {
|
||||
last_activity_us = time_us_64();
|
||||
if (held > kLongPressUs) {
|
||||
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 {
|
||||
current_screen = (current_screen - 1 + kNumScreens) % kNumScreens;
|
||||
last_render_us = 0;
|
||||
@@ -533,8 +582,10 @@ void handle_buttons() {
|
||||
|
||||
// --- Charge ETA tracker --------------------------------------------------
|
||||
// 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
|
||||
// finer percentage over BT, so a smooth countdown is impossible. Instead we
|
||||
// nibble, 0..10; high nibble is power-state, 1 == charging). We display the
|
||||
// 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
|
||||
// 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
|
||||
@@ -692,8 +743,8 @@ __attribute__((noinline)) void render_screen() {
|
||||
draw_text(kContentX, 9, buf);
|
||||
|
||||
const uint8_t pwr = interrupt_in_data[52];
|
||||
int pct = (pwr & 0x0F) * 10;
|
||||
if (pct > 100) pct = 100;
|
||||
int raw = pwr & 0x0F;
|
||||
int pct = (raw >= 10) ? 100 : raw * 10 + 5;
|
||||
const uint8_t pstate = pwr >> 4;
|
||||
char marker = ' ';
|
||||
if (pstate == 1) marker = '+'; // Charging
|
||||
@@ -1547,6 +1598,7 @@ void settings_adjust(int delta) {
|
||||
break;
|
||||
}
|
||||
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);
|
||||
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 15: snprintf(line, n, "%s Wipe all slots", cur); break;
|
||||
case 14: snprintf(line, n, "%s CtrlWake %s", cur, c.controller_wakes_display ? "on" : "off"); 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(l2), 30, l2);
|
||||
draw_text(cx_for(l3), 44, l3);
|
||||
flush_fb();
|
||||
draw_button_chrome();
|
||||
flush_fb_raw_blocking();
|
||||
sleep_ms(1500);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void oled_init() {
|
||||
for (int i = 0; i < 256; i++) reverse_lut[i] = reverse_byte((uint8_t)i);
|
||||
spi_init(spi1, 10 * 1000 * 1000);
|
||||
gpio_set_function(kPinCLK, 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
|
||||
// in main() before this). Defaults to HOST passthrough on a fresh flash.
|
||||
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
|
||||
@@ -1858,6 +1918,10 @@ void oled_loop() {
|
||||
handle_buttons();
|
||||
const uint32_t now = time_us_32();
|
||||
rumble_burst_tick(now);
|
||||
if (flush_progress >= 0) {
|
||||
flush_chunk();
|
||||
return;
|
||||
}
|
||||
if ((now - last_render_us) < kFrameUs) return;
|
||||
last_render_us = now;
|
||||
// Track charge progress every frame — before the power-ladder early-returns
|
||||
@@ -1885,7 +1949,12 @@ void oled_loop() {
|
||||
}
|
||||
if (hash != last_input_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
|
||||
// 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], 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);
|
||||
copy_if_allowed(
|
||||
update.UseRumbleNotHaptics || update.EnableRumbleEmulation,
|
||||
|
||||
Reference in New Issue
Block a user