Compare commits
23
Commits
@@ -93,16 +93,11 @@ jobs:
|
|||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
cp build/standard/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-${{ github.event.release.tag_name }}.uf2"
|
cp build/standard/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-${{ github.event.release.tag_name }}.uf2"
|
||||||
|
|
||||||
- name: Build Debug firmware
|
# The debug build (ENABLE_SERIAL=ON) compiles out tud_connect/disconnect and
|
||||||
run: |
|
# comes up as a serial console rather than a working HID/audio bridge — a
|
||||||
cmake -S . -B build/debug -G Ninja \
|
# developer diagnostic, not an end-user UF2. It is NOT published as a release
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
# download (footgun) — build it on demand with -DENABLE_SERIAL=ON, or use the
|
||||||
-DPICO_SDK_PATH="$PICO_SDK_PATH" \
|
# PR-CI compile in build.yml. See CHANGELOG/CLAUDE.md.
|
||||||
-DENABLE_SERIAL=ON \
|
|
||||||
-DENABLE_VERBOSE=ON \
|
|
||||||
-DVERSION="$FIRMWARE_VERSION"
|
|
||||||
cmake --build build/debug --target ds5-bridge
|
|
||||||
cp build/debug/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-debug-${{ github.event.release.tag_name }}.uf2"
|
|
||||||
|
|
||||||
- name: Compute UF2 checksums + append to release notes
|
- name: Compute UF2 checksums + append to release notes
|
||||||
env:
|
env:
|
||||||
|
|||||||
+24
-64
@@ -1,77 +1,37 @@
|
|||||||
# Bluetooth microphone investigation — current status
|
# Bluetooth microphone — SOLVED
|
||||||
|
|
||||||
**TL;DR:** The DualSense's built-in microphone does **not** work when the controller is paired to this dongle over Bluetooth. It works fine when the controller is connected directly to a host over USB. This is a Sony / DS5-firmware-side limitation we currently can't work around without reverse engineering or BT-sniffer access to PS5 ↔ DS5 traffic. The same limitation is documented in the upstream Linux kernel driver (`drivers/hid/hid-playstation.c` line ~1509: *"Bluetooth audio is currently not supported"*).
|
**TL;DR:** The DualSense's built-in microphone **works over this dongle's Bluetooth pairing** as of v0.6.8. It is decoded and presented to the host as the standard DualSense USB capture device. Earlier versions of this document concluded the opposite — that it was a Sony-side limitation, probably encrypted, a dead end. **That conclusion was wrong.** The whole thing hinged on a single enable bit. Full credit to **[awalol](https://github.com/awalol/DS5Dongle)** (upstream `mic` branch) for identifying it.
|
||||||
|
|
||||||
This file is a hand-off / research log for the next person who tries.
|
## How it works
|
||||||
|
|
||||||
## What does work
|
1. **Enable bit (dongle → DS5).** In the outbound `0x36` BT audio report, the audio-control sub-report's first flag byte (`pkt[4]`) has **bit 0 = mic-enable**. Setting it (`0b11111110` → `0b11111111`; bits 1–7 were already the speaker/haptic enables) tells the DS5 to start streaming its mic. See `src/audio.cpp`.
|
||||||
|
2. **Mic frames (DS5 → dongle).** Once enabled, the DS5 tags certain `0x31` BT input reports as mic frames by setting **bit 1 of byte 2** (`(data[2] >> 1) & 1`); the payload is a **71-byte Opus packet at offset +4**. `src/main.cpp:on_bt_data()` routes those to `mic_add_queue()`.
|
||||||
|
3. **Decode + present.** `src/audio.cpp` Opus-decodes each frame to mono 48 kHz (480-sample / 10 ms frames), duplicates mono → stereo, and `tud_audio_write`s it to the UAC1 capture endpoint. The host sees a normal DualSense mic.
|
||||||
|
4. **Sticky.** Once the DS5 starts streaming it **keeps going for the rest of the session** even after the enable bit / audio output stops (verified: mic stays live with no further `0x36` frames).
|
||||||
|
5. **Always-on.** Because the enable normally only rides the audio-gated `0x36` frames, the dongle sends a **control-only `0x36` keep-alive** (enable bit + `SetStateData` + silent haptic, no speaker payload) at ~4 Hz *only until mic frames start arriving*, then stops (sticky takes over). So the mic works with no game audio playing, at minimal extra BT traffic. See `mic_enable_keepalive()` in `src/audio.cpp`.
|
||||||
|
6. **Toggle.** Gated by `Config_body.bt_mic_enable` (default on) — OLED **Settings → BT Mic** and the web config tool. Off = no enable sent, no keep-alive, and inbound mic frames are not routed (so it's off host-side even if a previously-enabled DS5 is still streaming). Off by toggle saves DS5 battery (always-on keeps its audio subsystem awake).
|
||||||
|
|
||||||
- **Direct USB-C from DS5 → host:** mic enumerates as a UAC1 IN endpoint at 48 kHz / 16-bit / 2 channels on `EP 0x82`, max packet 196 bytes. ALSA recognizes it as `card N: Controller [DualSense Wireless Controller]`. `arecord` captures real audio after raising the `Headset Capture Volume` mixer control (it defaults to 0 dB).
|
## Why the original conclusion was wrong (the lesson)
|
||||||
- **Our dongle's USB descriptor** correctly mirrors the DS5's UAC1 layout — same interfaces, same alt settings, same endpoint addresses, same packet sizes. Verified with `lsusb -v` against a real DS5.
|
|
||||||
- **All the firmware-side decode infrastructure for BT mic is in place** (Opus decoder, `mic_fifo` queue, `tud_audio_write` to the IN endpoint, mono → stereo duplication) — see `src/audio.cpp`. It's currently gated behind `if (false)` in `src/main.cpp`'s `on_bt_data()` because we have nothing to feed it.
|
|
||||||
|
|
||||||
## What doesn't, and why
|
The earlier investigation ported awalol's *receive* side (the `(data[2]>>1)&1` trigger + Opus decode) and then watched for that bit — but **never sent the enable bit**, because this fork's `audio.cpp` had diverged (the pre-`3a31bd7` SetStateData revert) and sat at `pkt[4] = 0b11111110`. With nothing telling the DS5 to start, it never streamed, so bit 1 of byte 2 never set — which got misread as "the trigger never fires → the channel must be encrypted / it's a dead end."
|
||||||
|
|
||||||
The DS5 firmware on the test controller (build date `Jul 4 2025`, queried via feature report 0x20) **does not stream microphone audio over the standard BT-HID L2CAP channels** (PSM 0x11 control + 0x13 interrupt).
|
It was never encrypted. It was one un-set bit on the *transmit* side. Lesson: when porting a two-sided protocol, confirm **both** halves (enable *and* receive) before concluding the device "can't" do something.
|
||||||
|
|
||||||
What we tried:
|
## What we use (host-side)
|
||||||
|
|
||||||
1. **Upstream `awalol/DS5Dongle` `mic` branch as reference.** That branch claims to extract a 71-byte Opus packet at `data + 4` of any BT input report where `(data[2] >> 1) & 1` is set. On our DS5 firmware, **bit 1 of byte 2 is never set** (verified across thousands of frames via the `g_31_b2_or` OR mask). The upstream RE was likely done on a different (older) DS5 firmware revision.
|
- **`scripts/mic_diag.sh`** — `status` / `capture [secs]` / `watch` / `bt-trace`. `capture` arecords the DualSense mic card and reports peak/RMS/non-zero, the fastest way to confirm real audio.
|
||||||
2. **Bit 0 of byte 2** matches roughly all standard input reports — confirmed by reading the supposed "mic prefix" via `0xFD` feature report and seeing live stick X/Y values (not Opus data). Not a mic flag.
|
- The DualSense capture card's **`Headset` capture control defaults low** — raise it (`amixer -c <card> sset 'Headset' 90%`) or captures look silent.
|
||||||
3. **Frame length sweep.** Longest BT 0x31 frame we ever see is 79 bytes — a fully-decoded standard DS5 input report (sticks + IMU + touchpad + battery + sensor timestamp + trailing zeros). No audio bytes appended anywhere.
|
- **OLED Diagnostics** `Mic in:` (~100/s when streaming) + `Mic dec=` (480 = good Opus decode) are the on-device confirmation.
|
||||||
4. **Other report IDs.** Counted ALL incoming BT input reports by report ID. Only `0x01` (rare) and `0x31` (common). No 0x33 / 0x35 / 0x36 / 0x39 / etc. The DS5 isn't sending anything mic-shaped on a different ID.
|
- Vendor HID feature reports `0xFD` / `0xFE` and the BT counters remain useful general audio-debug infra.
|
||||||
5. **State configuration matching the kernel.** Set `AllowAudioControl=1`, `AllowMicVolume=1`, `AllowAudioMute=1`, `MicSelect=Internal`, `VolumeMic=0x40`, `MicMute=0`, `AudioPowerSave=0` — exactly what `hid-playstation.c` sets when calling its "Enable microphone" path. DS5 still doesn't stream.
|
|
||||||
6. **Bidirectional audio session hypothesis.** Maybe the DS5 only streams mic when there's also active speaker audio (`0x36` packets) flowing. Tested: ran `aplay /dev/zero` simultaneously with `arecord`. No change in BT-side counters, no new report IDs, no longer frames. Disproved.
|
|
||||||
7. **State refresh on host UAC1 alt-setting change.** Considered hooking `tud_audio_set_itf_cb(itf=2, alt=1)` to send the DS5 a fresh "enable mic" state update. Not implemented — given the kernel comment and our state matching the kernel's own "enable" sequence, this wouldn't have helped.
|
|
||||||
|
|
||||||
What we **did not** try (real next steps if anyone picks this up):
|
## Open follow-ups
|
||||||
|
|
||||||
- **SDP browse the DS5** over BT after pairing. Discover what L2CAP PSMs / services it advertises beyond HID. If there's a Sony proprietary audio PSM we haven't subscribed to, that's where mic traffic might live.
|
- **No documented "stop" command.** Disabling mid-session relies on gating the receive side; the DS5 keeps streaming until reconnect. If a real stop/disable bit is found, wire it into the toggle to stop the DS5-side battery drain immediately.
|
||||||
- **Open additional L2CAP channels** (proprietary audio PSM if found, or standard ones like A2DP=0x19 / RFCOMM=0x03) and watch for unsolicited inbound data.
|
- **Mono only.** Decoded mono is duplicated to the stereo endpoint; the DS5 mic is mono so this is fine, but the descriptor could be made truly mono (as awalol's branch does) to halve endpoint bandwidth.
|
||||||
- **Compare DS5 firmware revisions.** Test with an older DS5 (pre-2024 manufacture) and see if it streams mic over BT — that would tell us whether Sony removed the feature or just nobody documented the protocol. (We only have one DS5; can't test.)
|
- **Name the `pkt[4]` bits precisely.** `daidr/dualsense-tester`'s `outputStruct.ts` documents the *standard* output report flags but not the `0x36` BT-audio sub-report; the bit meanings beyond bit 0 are inferred from the working speaker/haptic path.
|
||||||
- **BT sniffer** between a PS5 console and a DS5 during voice chat. Tells us exactly what L2CAP channels and bytes Sony uses for mic. Equipment-intensive (~$50–200 for an Ubertooth or commercial sniffer).
|
|
||||||
- **DS5 firmware disassembly.** Legally fraught, almost certainly EULA-violating.
|
|
||||||
|
|
||||||
## Strongest hypothesis: the channel is encrypted
|
|
||||||
|
|
||||||
The shape of all the negative evidence — kernel maintainers giving up, no public RE project succeeding, our matching every documented "enable" bit and getting nothing — strongly suggests the channel is **encrypted with a session key derived during pairing**, not just transported on an undocumented PSM. Sony's incentives line up perfectly:
|
|
||||||
|
|
||||||
- **PR / privacy:** a $40 third-party dongle routing a user's PS5 voice chat to a malicious host is a worst-case PR scenario. Encrypting the mic channel is the obvious defense.
|
|
||||||
- **GDPR-class regulation:** voice biometrics from a console controller over plaintext BT is the kind of thing EU regulators ask hard questions about.
|
|
||||||
- **Anti-spoofing:** prevents injecting fake mic data into a PS5 session, which is its own threat model.
|
|
||||||
|
|
||||||
Mechanism that fits the evidence:
|
|
||||||
|
|
||||||
- During pairing, BT Classic SSP produces a link key. The PS5 + DS5 firmware likely run a Sony-proprietary KDF on top of that link key to produce an audio-channel session key.
|
|
||||||
- Mic audio is transported on a Sony-allocated proprietary L2CAP PSM (not in the standard BT-SIG ranges) and encrypted with that session key (AES-CCM or similar).
|
|
||||||
- A third-party dongle could connect to the PSM if it knew the number, but without the KDF / session key the payload would be opaque encrypted blobs.
|
|
||||||
|
|
||||||
**Implication:** a BT sniffer might tell us the PSM and packet timing/sizes, but not the payload contents. Building a PS5-impersonating dongle that derives valid session keys would require either Sony system-software disassembly or DS5 firmware disassembly — legally fraught, and a much bigger undertaking than what this project is set up for.
|
|
||||||
|
|
||||||
This re-frames "we can't get mic over BT" from "we haven't tried hard enough" to "the architecture is intentionally hardened against exactly this." That's not nothing — it's a clear answer to give users who ask, and a clear bar to clear if anyone wants to actually pursue it.
|
|
||||||
|
|
||||||
## What we built that's useful regardless
|
|
||||||
|
|
||||||
These all stay shipped — they're general-purpose audio-debug infrastructure now:
|
|
||||||
|
|
||||||
- **`scripts/mic_diag.sh`** with subcommands `status`, `capture [secs]`, `watch`, `bt-trace`. Drives the entire diagnostic loop from the host without needing OLED-relay-through-the-user; reads vendor feature reports via `/dev/hidraw`.
|
|
||||||
- **Vendor HID feature report `0xFD`** (32 bytes): BT input-report counter, non-0x31 counter, last seen non-0x31 report ID, OR mask of byte 2 across 0x31 frames, length range, hex prefix of last frame.
|
|
||||||
- **Vendor HID feature report `0xFE`** (82 bytes): full content of the longest 0x31 frame seen, for byte-level inspection.
|
|
||||||
- **OLED Diagnostics screen** carries BT31/Mic rate + recent frame prefix + opus dec/wrote bytes — useful for any future audio-path debugging at the bench.
|
|
||||||
- **`src/audio.cpp`** mic-decode infrastructure (Opus decoder on core0, FIFO, mono → stereo duplication, `tud_audio_write` to IN endpoint). Disabled at the `mic_add_queue` call site, ready to re-enable the moment a real mic trigger is identified.
|
|
||||||
- **`src/state_mgr.cpp`** initial state corrected — `VolumeMic` was `0xff` (out of valid range per spec; max is `0x40`), `MuteControl` had all `*PowerSave` bits set which would have power-gated the audio DSP. These corrections don't enable BT mic but they're the right defaults regardless.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- Linux kernel `drivers/hid/hid-playstation.c`. Quoted lines: ~1407–1420 (mic enable/disable), ~1509 (*"Bluetooth audio is currently not supported"*). [Raw source on GitHub mirror](https://raw.githubusercontent.com/torvalds/linux/master/drivers/hid/hid-playstation.c).
|
- Upstream `awalol/DS5Dongle` branch `mic` (commits `9c197fc`, `3829163`) — the source of the enable bit (`pkt[4]` bit 0) and the receive-side decode. awalol confirmed bit 0 is the key.
|
||||||
- PSDevWiki [DualSense HID Commands](https://www.psdevwiki.com/ps5/DualSense_HID_Commands) — has factory/manufacturer commands (report IDs 128, 129, 160, 164, 165) for BT patches and audio codec selection, but explicitly notes most "do not work with retail controllers". Not a path forward.
|
- Linux kernel `drivers/hid/hid-playstation.c` (~line 1509, *"Bluetooth audio is currently not supported"*) — still true for the kernel driver; not for this dongle.
|
||||||
- Upstream `awalol/DS5Dongle` branch `mic` (commits `9c197fc feat: mic work`, `3829163 mic mono channel`). RE'd a working mic path for an older DS5 firmware revision; we ported the data plumbing but the BT-side trigger differs on current firmware.
|
- `daidr/dualsense-tester` — `src/router/DualSense/views/_OutputPanel/outputStruct.ts` for the standard output-report flag layout.
|
||||||
- dualsensectl: `command_microphone on/off` sets `valid_flag0 |= DS_OUTPUT_VALID_FLAG0_AUDIO_CONTROL_ENABLE` and clears `DS_OUTPUT_POWER_SAVE_CONTROL_MIC_MUTE`. Same as what we already do on connect.
|
|
||||||
|
|
||||||
## For users asking about the mic
|
|
||||||
|
|
||||||
When users report "the mic doesn't work":
|
|
||||||
|
|
||||||
- **Plug the DS5 into the host via USB.** Mic works out of the box. You may need to raise the `Headset Capture Volume` mixer control if it defaults to 0 dB.
|
|
||||||
- **Over the dongle's Bluetooth pairing, the mic is currently a known limitation** — not something a firmware update on our side can fix without further reverse engineering of the DS5's proprietary BT audio path.
|
|
||||||
- The diagnostic tools in `scripts/mic_diag.sh` are available if you want to help reverse engineer this; PRs welcome.
|
|
||||||
|
|||||||
+137
@@ -8,6 +8,143 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`polling_rate_mode` now defaults to realtime / 1000 Hz** (was 250 Hz) so a fresh flash matches a wired DualSense's 1 ms input latency instead of polling 4× slower. Mode 2 also bypasses the report-throttling used by the 250/500 Hz modes, forwarding every BT input report immediately. Existing saved configs keep their value; this only affects fresh flashes / Reset-to-defaults. (Out-of-box "acts like USB" pass.)
|
||||||
|
- **`bt_mic_enable` now defaults to Off** (was On) — the DualSense BT microphone path has a confirmed playback-rate bug (issue #10: recordings come out ~2× too fast / half-duration). Until that's root-caused on hardware, the mic is opt-in: fresh flashes / Reset-to-defaults boot with it off, and it can be turned on from the OLED **Settings** screen (`BT Mic`) or the web config. Existing saved configs keep their current value. (Web config default should be flipped to match in `DS5Dongle-OLED-Config-Web`.)
|
||||||
|
|
||||||
|
### Known issues
|
||||||
|
|
||||||
|
- **BT microphone records ~2× too fast (#10).** The mic playout stage is wall-clock paced at 10 ms / 480 samples into a 48 kHz endpoint, which should be real-time, so the 2× points at the on-wire frame size/rate differing from the assumed 480 samples. Needs the OLED **Diagnostics** `Mic dec=` (samples per `opus_decode`) and `Mic in/s` (arrival rate) read on hardware to make the fix deterministic — `Mic dec=240` would confirm a half-rate stream. Mic is opt-in (off by default) in the meantime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.11-oled-edition] — 2026-06-02
|
||||||
|
|
||||||
|
Headline: **adaptive triggers now actually work through the dongle** (#6) — host trigger force-feedback was being forwarded with its enable bits cleared, so the DualSense silently discarded it; this is the fix. Also adds an opt-in **`CtrlWake`** setting so the OLED can sleep while you play (#8/#9), persists the **OLED brightness** choice across power cycles (#9), and ships an in-progress **speaker-audio retiming** pass aimed at the periodic crackle (#7). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.11-oled-edition) (built by `.github/workflows/release.yml`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`CtrlWake` setting — let the OLED sleep while the controller is in use (issues #8, #9).** The idle power-ladder (dim → off) previously never fired during gameplay, because every controller input bumped the activity timer and kept the panel awake — so a configured dim/off timeout effectively did nothing while playing. New Settings item `CtrlWake` (default **on**, preserving the old behavior); set it **off** and controller input no longer wakes the screen — only the OLED's KEY0/KEY1 do — so the dim/off timers count down during play and the panel can sleep, requiring a button press to wake.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Speaker audio path retimed onto a true 10 ms / 100 Hz grid (issue #7, experimental).** The old path resampled 512→480 samples and shipped a 480-sample Opus frame on the haptic-gated cadence (~every 10.667 ms ≈ 45 kHz into the DS5's free-running 48 kHz DAC), a ~6.25 % underrun that produced a periodic gap/crackle. The resampler is removed and the `0x36` frame now carries exactly one native 10 ms / 48 kHz Opus frame (`SAMPLE_SIZE` 64→60, 480-sample buffer), with the speaker sub-report offset computed from the haptic block length rather than hard-coded. This is an in-progress change aimed at the long-standing crackle; the audio path also carries debug counters (inert in the production UF2, visible only with `ENABLE_SERIAL`).
|
||||||
|
- **`audio_buffer_length` now defaults to 16** (was 64) — a lower buffer avoids the DS5's periodic re-buffer gap. Existing saved configs keep their value; this only affects fresh flashes / Reset-to-defaults.
|
||||||
|
- **`auto_haptics_enable` now defaults to Off** (was Fallback) — the speaker-derived rumble fallback could produce erratic haptics, so it is opt-in.
|
||||||
|
- **OLED SPI flush is now chunked / non-blocking.** The framebuffer is sent in row-chunks across main-loop iterations instead of one ~1.1 ms blocking transfer, so the OLED no longer stalls `tud_task` / `audio_loop` / BTstack polling while it refreshes (the OLED-on side of the #7 audio distortion).
|
||||||
|
- **Battery percentage shows the mid-point of each 10 % band** (5, 15, …, 95, 100 %), matching the kernel `hid-playstation` driver and Steam, instead of the band floor.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Adaptive trigger effects now work through the dongle (issue #6).** Host-sent trigger force-feedback was silently dropped: `state_update()` copied the 11-byte `RightTriggerFFB`/`LeftTriggerFFB` parameter blocks into the outgoing controller-state but never set the `AllowRightTriggerFFB`/`AllowLeftTriggerFFB` "apply" bits in byte 0, so the DualSense received the data with the enable flags cleared and discarded it. Affected **both** output paths (standalone `0x31` and the `0x36` audio-frame fold), which is why triggers felt absent through the dongle but worked on direct USB. The on-dongle Trigger Test screen always set those bits directly (`0x0C`), which is why it worked. Now the host's two allow-flags are mirrored into the state alongside the FFB data, like the rumble flags already were.
|
||||||
|
- **OLED brightness now persists across a power cycle (issue #9).** The KEY1-long-press brightness level was a runtime-only value that reset to full on every boot; it is now stored in config and restored at init.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.10-oled-edition] — 2026-05-25
|
||||||
|
|
||||||
|
Headline: the **Gyro Tilt screen** is now actually usable — it applies the controller's per-unit factory **IMU calibration**, the dot **centres when the controller lies flat**, and tilt **tracks the direction** you move it. Also adds an **L3 / R3 stick-click indicator** on the Status screen and makes the **charge-ETA** robust so it no longer over-reports off a single slow charge step. Everything here is on-dongle display only — **what games receive is unchanged** (the full gyro/accel stream is still forwarded byte-for-byte). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.10-oled-edition) (built by `.github/workflows/release.yml`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **L3 / R3 click indicator on the OLED Status screen.** Clicking a stick in now flashes its analog-stick box inverse (white box, black dot) for as long as it's held — previously the stick clicks had no on-screen feedback. Mirrored in the web config tool's OLED Preview.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Gyro Tilt screen reworked: per-unit IMU calibration, a tilt dot that centres when flat, and intuitive direction.** Three things, all display-only — the host input report (all gyro + accel axes) is still forwarded byte-for-byte, so in-game motion is unaffected:
|
||||||
|
- **Calibration.** The DualSense ships factory gyro/accel bias + sensitivity in feature report `0x05` (which the dongle already fetches and caches at connect). Previously the tilt visuals used raw accel counts; now the cached `0x05` is parsed once per connection (re-read per controller, so it stays correct across the 4 pairing slots) and applied as `(raw − bias) × sensitivity`, keeping the same ±8192 ≈ 1 g scale. A bad/short read is rejected (same sanity gate SDL uses) and it falls back to raw — no regression when calibration is unavailable. The tilt→RGB lightbar mode uses the corrected accel too. Parse/apply mirror SDL's `SDL_hidapi_ps5.c` (zlib-licensed; credit).
|
||||||
|
- **Centred when flat.** The dot is now driven by the X (roll) and **Z** (pitch) axes — the two that read ~0 when the controller lies flat — instead of X/Y. Gravity rests on Y when flat, so the old Y mapping pegged the dot to the bottom edge at rest; it now sits centred.
|
||||||
|
- **Direction follows the controller.** Both axes are negated so tilting left moves the dot left and tilting forward moves it up, instead of mirrored.
|
||||||
|
- The dot centring + direction are mirrored in the web config tool's OLED Preview (its mock IMU now also rests gravity on Y to match real hardware).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Charge-ETA no longer balloons off a single slow 10% step.** The Status-screen `~Nm` charge estimate timed each 10% battery step and projected the rest, but one anomalously slow step (e.g. ~47 min — observed reading `~222m` at 70% on the dock) used to drag the whole projection up because the rate was a mean over only 3 steps. Each timed step's bulk-equivalent is now clamped to a 30-min ceiling and the rate is taken as the **median** over the last 5 steps, so a single under-load/anomalous reading can't dominate. Mirrored in the web OLED Preview emulator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.9-oled-edition] — 2026-05-24
|
||||||
|
|
||||||
|
Headline feature: **on-dongle button remapping** — reassign any of the 16 digital controls, stored on the dongle so it works in every game and on every OS with no host-side software, edited visually in the web config tool's new **Remap** tab (a click-the-controller diagram built on Zacksly's CC BY 3.0 DualSense art). Also ships **packet-loss concealment** for the BT microphone. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.9-oled-edition) (built by `.github/workflows/release.yml`); this is the first release without the debug UF2 as a download.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Button remapping.** Any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) can be remapped to any other — stored on the dongle, applied transparently before the host sees the report, so it works on every OS and every game with no host-side software. The remap table lives in its own dedicated flash sector (`PICO_FLASH_SIZE_BYTES - 3·FLASH_SECTOR_SIZE`, magic `DS5\x03`, below the slots sector) and survives reboot; identity (no remap) is the default. Multiple sources mapping to one target OR together (analog L2/R2 take the max); a source can be set to *disabled* (`0xFF`). The remap acts on the **outgoing host report copy only** — the raw input the OLED screens and the PS+Mute reboot combo read is untouched. Edited over the existing `0xF6`/`0xF7` vendor reports with a hardened `RM`+version frame (**no HID-descriptor change**, so Windows enumeration is unaffected) and a revision counter the host polls to confirm a write landed. New `src/remap.{h,cpp}`; apply logic + button set ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Dev helper `scripts/remap_test.py` exercises the path over `/dev/hidraw` without the web tool.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **BT microphone now has packet-loss concealment (PLC).** The mic decode path gained a small decoded-frame jitter buffer (8 frames) drained at a steady 10 ms playout cadence: bursty BT delivery is smoothed, and a dropped mic frame during an active session is concealed with an Opus PLC frame (`opus_decode(decoder, NULL, 0, …)`) instead of leaving a hole the host hears as a click/dropout. Playout pre-buffers 3 frames and stops after 300 ms of no real frames (so it never emits comfort noise when the mic is idle). A new **`Mic PLC:`** counter on the Diagnostics screen climbs only when concealment fires — effectively a live BT link-quality gauge. Verified: forced BT loss kept the captured audio gap-free (longest zero-run ~0 ms) while the counter climbed. Design ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Adds ~30 ms mic latency (the pre-buffer).
|
||||||
|
|
||||||
|
### Companion web tool
|
||||||
|
|
||||||
|
- **Visual button-remapping editor (new Remap tab).** `DS5Dongle-OLED-Config-Web` gains a dedicated **Remap** tab built on the new firmware remap protocol: click a button on a live, theme-aware DualSense diagram to reassign it, with the shoulders/triggers (L1/L2/R1/R2) floated to the corners as labeled glyphs + leader lines (they have no target in a front view). Remapped/selected buttons glow; a collapsible full dropdown list is kept as a fallback. Reads the remap block appended to the `0xF7` config response and writes over `0xF6` (func `0x10`), independent of `Config_body`. Controller outline + button glyphs are [Zacksly's "PS5 Button Icons and Controls"](https://zacksly.itch.io/ps5-button-icons-and-controls) (CC BY 3.0, recolored to `currentColor` and cropped), credited in the footer, each asset file, and a bundled license. Strings translated across all 7 locales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.8-oled-edition] — 2026-05-24
|
||||||
|
|
||||||
|
Two big items: **DualSense microphone over Bluetooth** (long believed impossible — turned out to be a single enable bit; credit [awalol](https://github.com/awalol/DS5Dongle) upstream) and a **USB 3.0 connection-interference watchdog**. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.8-oled-edition) (built by `.github/workflows/release.yml`). The companion `DS5Dongle-OLED-Config-Web` config tool gains a **BT microphone** toggle.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **DualSense microphone over Bluetooth.** The controller's built-in mic now works over the dongle's BT pairing — decoded from the DS5's Opus stream and presented to the host as the standard DualSense USB capture device, usable by any app (Discord, OBS, in-game voice). This fork had previously documented BT mic as a hard Sony-firmware limitation (likely encrypted); that conclusion was **wrong** — it hinged on a single enable bit (`pkt[4]` bit 0 in the outbound `0x36` audio report). Credit to **[awalol](https://github.com/awalol/DS5Dongle)** (upstream) for identifying it. The DS5 streams mic as 71-byte Opus packets tagged in `0x31` reports (`(data[2]>>1)&1`); `src/audio.cpp` decodes mono→stereo to the UAC1 endpoint. **Always-on:** the enable is sticky once streaming, so a control-only `0x36` keep-alive asserts it at ~4 Hz only until frames arrive, then backs off — mic works with no game audio, at minimal BT traffic. **Toggle:** new `bt_mic_enable` config field (default on; off saves DS5 battery since always-on keeps its audio subsystem awake) — OLED **Settings → BT Mic** and the web config tool's **BT microphone** switch. `BLUETOOTH_AUDIO_NOTES.md` rewritten from "dead end" to the working mechanism; README gains a user-facing **"DualSense Microphone over Bluetooth"** section.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Connection no longer hangs permanently on the amber lightbar (USB 3.0 interference recovery).** Users reported the DualSense getting stuck mid-connect (solid amber/yellow, never enumerates) on USB 3.0 host ports while USB 2.0 worked — caused by USB 3.0's broadband ~2.4 GHz RF noise desensitizing the CYW43 Bluetooth radio. The firmware's connection flow had dead-end states (ACL-fail and auth-fail re-inquiry were commented out; the controller-type feature-packet wait had no timeout), so a single lost packet stalled forever until a replug. Added a **connection-attempt watchdog** (`src/bt.cpp`): a 10 s timeout armed when a connection commits to a device and cleared when it reaches USB enumeration; on expiry it tears down via the existing `HCI_EVENT_DISCONNECTION_COMPLETE` path and restarts inquiry, so a stalled connect auto-retries instead of hanging. Re-enabled the ACL-fail / create-connection-reject / auth-fail recovery paths for faster recovery when the controller *does* report a failure. The watchdog is inert during a healthy established session (no effect on normal play, slot-switching, or idle-disconnect). Helps any marginal-RF setup, not just USB 3.0.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- New README section **"USB 3.0 ports & Bluetooth interference"** + a Known Issues bullet: explains the 2.4 GHz RFI cause (referencing Intel's white paper) and lists mitigations (USB 2.0 port, short USB 2.0 extension cable, powered USB 2.0 hub, ferrite bead, distance/line-of-sight).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.7-oled-edition] — 2026-05-23
|
||||||
|
|
||||||
|
UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.7-oled-edition) (built by `.github/workflows/release.yml`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Charge ETA now shows a provisional estimate immediately on plug-in.** Instead of sitting on `~--m` for the ~15-20 min until the first 10% step is timed, the Status screen shows a default-rate estimate `~Nm?` (the trailing `?` marks it provisional) the moment charging starts. The `?` drops and the number switches to the measured rate once a clean 10% step completes. Default is ~15 min per 10% step (`kDefaultStepUs`), taper-weighted exactly like the measured path, so the provisional figure is in the right ballpark and self-corrects.
|
||||||
|
|
||||||
|
### Companion web tool
|
||||||
|
|
||||||
|
- `DS5Dongle-OLED-Config-Web` gains **lightbar controls** (mode dropdown + four favorite-color pickers) in the config view, the provisional charge-ETA token in the OLED preview to match this firmware, and translations for two preview notes that were English-only. Build housekeeping: `tsconfig.tsbuildinfo` is no longer tracked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.6-oled-edition] — 2026-05-23
|
||||||
|
|
||||||
|
Community-issue follow-ups: configurable OLED idle-ladder thresholds (#5) and a diagnostic counter clarifying the trigger-flow numbers (#6). UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.6-oled-edition) (built by `.github/workflows/release.yml`). The companion `DS5Dongle-OLED-Config-Web` config tool gains matching screen-timeout controls and is synced to the v0.6.5+ `Config_body` layout (fixes a latent issue where saving via the old web tool would zero the lightbar fields).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Configurable OLED idle-ladder thresholds (issue #5, requested by @TerryFrench).** The dim and off tiers are no longer hardcoded at 2 / 15 min — two new `Config_body` fields `screen_dim_timeout` / `screen_off_timeout` (minutes, `0 = that tier disabled`, range `[0,250]`) are editable on the Settings screen (`ScrDim`/`ScrOff`) and persist to flash. Defaults preserve the previous 2 / 15 ladder; on upgrade the unset fields read as those defaults via the `config_valid()` clamp. The idle timer moved from `time_us_32()` to 64-bit µs so the full 250-min range is representable without the ~71-min wrap. Power users with always-on dongles can bias shorter; status-watchers can bias longer or set `0` to keep a tier lit.
|
||||||
|
- **`trig fold` counter on the Diagnostics screen (issue #6).** Counts trigger-bearing `0x02` host reports that arrived while the speaker stream was active and were therefore folded into the `0x36` audio frames (via `state[]`) instead of sent as a standalone `0x31`. Makes `trig_allow == to_bt(trig) + fold` visible, confirming the apparent `trig`/`tx` gap is audio-path folding, not dropped trigger reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.5-oled-edition] — 2026-05-23
|
||||||
|
|
||||||
|
Charging UX (Status-screen battery ETA + amber lightbar pulse), persistent and screen-sticky lightbar control, and a charging-aware idle power ladder. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.5-oled-edition) (built by `.github/workflows/release.yml`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Charge ETA on the OLED Status screen.** While the DualSense is charging, the battery line shows an estimated time-to-full (`~43m`) to the right of the battery icon. The DS5 only reports battery in 10 % steps over BT (`interrupt_in_data[52]` low nibble, 0–10; high nibble is power-state, 1 = charging), so a smooth countdown is impossible — instead `sample_charge_eta()` times how long each 10 % step takes and extrapolates the remaining steps. It shows `~--m` while calibrating (the first estimate can't appear until one full step has been timed, ~15–20 min after plug-in), then refines on each subsequent notch. The partial step in progress at plug-in is discarded so the first estimate isn't skewed by a half-measured step; a 3-entry moving average smooths the rest. **Li-ion taper correction:** a flat "time-per-step × steps-left" runs optimistic in the constant-voltage tail, so each measured step is normalised to a bulk-equivalent duration (divide out a per-step weight: 1.0× in the bulk region, 1.5× for 80→90 %, 2.2× for 90→100 %) and the remaining steps are re-weighted — keeping the estimate consistent whether the user plugs in near-empty or near-full. Sampled once per frame from `oled_loop` ahead of the idle power-ladder early-returns, so step timing stays correct even while the panel is dimmed/off or the user is on another screen.
|
||||||
|
- **Lightbar settings persist across reboot and stick across every screen.** The selected lightbar mode and the four favorite colors are now saved to the config flash sector (new `Config_body` fields `lightbar_mode` + `lb_fav_{r,g,b}[4]`), so a chosen mode/color survives a power cycle. A new **HOST** mode (the default) hands the LED back to the host/game so the dongle doesn't hijack player-indicator LEDs out of the box; on upgrade from ≤0.6.4 the unset field reads as HOST, preserving prior behavior. Mode/favorite edits made on the Lightbar screen are batched into a single flash write when you navigate away (tracked by a dirty flag) to spare flash endurance.
|
||||||
|
- **Lightbar pulses amber-orange while charging.** A slow ~4.6 s breathing pulse (base `(255,100,0)`, sine-enveloped from dim to bright via the existing 32-step LUT) shows charging at a glance from any screen. Implemented in the unified `lightbar_service()` (below) as the top-priority owner of the LED, so it overrides the selected mode while charging and reverts to it when unplugged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The OLED no longer fully sleeps while the controller is charging.** The idle power ladder is capped at the Dim tier (the low-power breathing dot) instead of advancing to full Off (`cmd(0xAE)`) when `g_charge_eta.charging` is true. The charge-ETA tracker already runs while the panel is off, but users were unplugging the controller to "wake" the dongle — which reset the ETA calibration and restarted the wait-for-the-next-10%-notch. Capping at the dot tier (which draws ~no current) removes the reason to unplug. Normal Active→Dim→Off behavior resumes once charging stops.
|
||||||
|
- **A single `lightbar_service()` now owns the controller LED, every frame, on every screen.** Previously the OLED only drove the lightbar via a transient `0x31` packet sent from inside `render_screen_lightbar()` — so the color was only asserted while that screen was open. The service (run from `oled_loop` ahead of the power-ladder early-returns) instead writes the chosen color into the persistent `state[]` block (`SetStateData` `LedRed/Green/Blue`, via new `state_set_led()`), so it rides every outbound host/audio packet, and also actively pushes a `0x31` when audio is idle so animations keep moving. A new `g_lightbar_override` flag gates `state_update()` so the host's `AllowLedColor` writes can't stomp a firmware-chosen mode. During audio the active `0x31` push is suppressed — the `0x36` frames already carry `state[]`'s LED, and slipping a `0x31` between them would intrude on the load-bearing audio path.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OLED idle dim/dot tier now actually engages while a controller is connected.** The activity detector hashed `interrupt_in_data[0..9]` with an exact compare, but the analog sticks jitter by ±1 LSB at rest, so the hash changed every few frames and reset the idle timer — meaning the breathing-dot/dim tier only ever kicked in when no controller was paired. Now it mirrors `bt.cpp`'s inactivity heuristic: the stick bytes' rest band `[120,140]` is collapsed to a constant and the volatile counter byte (`idata[6]`) is skipped, so a resting controller reads as idle. Confirmed against a live `/dev/hidraw` capture (only the left-stick X byte was flickering 129↔128).
|
||||||
|
- **Lightbar no longer reverts the instant you leave the Lightbar screen.** Root cause: the OLED's `send_lightbar_color()` wrote a one-off `0x31` packet and never touched the persistent `state[]` block, while the host's `0x02` output reports, every audio frame, and reconnect all re-stamp `state[]` (incl. the LED) into the controller. Off the Lightbar screen the OLED stopped pushing, so the next `state[]`-based packet overwrote the color — which is why saved favorites and animated modes (Rainbow/Breathing/Fade) never "stuck." Now that the lightbar is owned through `state[]` with a host override gate (see Changed), the selected mode holds across screens and through active gameplay/audio.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [0.6.4-oled-edition] — 2026-05-19
|
## [0.6.4-oled-edition] — 2026-05-19
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ add_executable(ds5-bridge
|
|||||||
src/state_mgr.cpp
|
src/state_mgr.cpp
|
||||||
src/oled.cpp
|
src/oled.cpp
|
||||||
src/slots.cpp
|
src/slots.cpp
|
||||||
|
src/remap.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
if (ENABLE_BATT_LED)
|
if (ENABLE_BATT_LED)
|
||||||
|
|||||||
+388
-37
@@ -1,53 +1,404 @@
|
|||||||
# Pico2W DualSense 5 Bridge
|
# Pico2W DualSense 5 Bridge — OLED Edition
|
||||||
|
|
||||||
[English](./README.md)
|
[English](./README.md)
|
||||||
> 将 Pico2W 变成 DS5 手柄的无线适配器
|
|
||||||
|
|
||||||
# 功能特点
|
> 将 Raspberry Pi Pico2W 变成 DualSense (DS5) 手柄的无线适配器 —— 并可选配板载状态显示屏。
|
||||||
- 支持HD震动
|
|
||||||
|
|
||||||
# 使用方法
|
> **OLED 版**是 **[awalol/DS5Dongle](https://github.com/awalol/DS5Dongle)**(上游)的一个分支,增加了可选的 Pico-OLED-1.3 128×64 显示屏插件,提供 11 个屏幕(状态、4 槽多手柄配对、带收藏与特效预设的灯条调色器、扳机测试、陀螺仪倾斜、触摸板、诊断、CPU/时钟、蓝牙信号强度、音频 VU 表,以及一个持久化设置菜单),外加 DS5 按键组合软重启。核心桥接固件以上游为权威来源;本分支跟踪上游并在其之上叠加插件功能。
|
||||||
1. 按住 Pico 上的BOOTSEL进入刷机
|
|
||||||
2. 将 .uf2 文件拖入进去
|
|
||||||
3. 将 DS5 手柄进入蓝牙配对模式
|
|
||||||
4. Enjoy it
|
|
||||||
|
|
||||||
***你可能需要在控制器处于匹配模式时重新插拔 pico***
|
---
|
||||||
|
|
||||||
- 手柄连接到pico以后,系统才会显示设备
|
## 🛠️ 网页配置工具
|
||||||
|
|
||||||
# Pico 配置调整
|
**[→ 打开 OLED 版网页配置](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/#config)**
|
||||||
你可以通过网页调整Pico的内部设置
|
|
||||||
|
|
||||||
- 用于正式固件: https://ds5.awalol.eu.org
|
网页工具是一站式方案 —— **无需安装、无需命令行、无需 `picotool`**。一块全新的 Pico 2 W 可以全程在浏览器里从"刚开箱"做到完整刷写并配置完成:
|
||||||
- 用于测试固件: https://ds5-dev.awalol.eu.org
|
|
||||||
|
|
||||||
### Pico W 版本
|
- **刷写固件标签页** —— 让 Pico 进入 **BOOTSEL 模式**,然后在浏览器中点击 *Connect to Pico*,再点 *Flash now*。站点会捆绑最新发布的 UF2,你也可以载入自己编译的本地 `.uf2`。基于 WebUSB。
|
||||||
|
|
||||||
Pico W 由于性能问题,只能支持震动,不支持扬声器。
|
> **什么是 BOOTSEL 模式?** 这是 Pico 内置的刷写模式。进入方法:按住 Pico 上标有 **BOOTSEL** 的白色小按钮,*然后*插入 USB 线(若已插好,则在按住 BOOTSEL 的同时短暂拔插一次)。Pico 会作为可移动磁盘出现在电脑上 —— 看到它就说明已进入 BOOTSEL 模式。网页工具刷完固件后,Pico 会自动重启进入正常模式即可使用。
|
||||||
你可以通过开启 `-DPICO_W_BUILD=ON` 编译项去开启 Pico W 固件编译,或者在 Github Action 下载预编译的固件
|
- **配置标签页** —— 设备刷好并重新连接后,可编辑振动增益、扬声器音量、轮询率、音频自动触感模式以及其余持久化设置;一键保存到设备闪存。基于 WebHID。
|
||||||
|
- **重映射标签页** —— 可视化按键重映射器:在实时 DualSense 示意图上点击某个按键即可把它重新指派为任意其他按键(肩键/扳机会以带标签的图标浮动到角落)。映射保存在设备上并在主机看到报告之前应用,因此在任何游戏、任何操作系统中都生效。基于 WebHID。
|
||||||
|
- **OLED 预览标签页** —— 像素级精确模拟全部 11 个 OLED 屏幕。用页面内的 KEY0/KEY1 按钮(或在 DualSense 配对时用手柄的 △ / R1 / 方向键)来导航。循环切换扳机测试预设时,自适应扳机会在手柄上真实触发。
|
||||||
|
|
||||||
### USB 唤醒支持
|
可在任何基于 Chromium 的浏览器中使用(Chrome、Edge、Brave、Opera)。Firefox 与 Safari 不提供 WebHID 或 WebUSB,因此那里无法刷写和实时配置 —— 但 OLED 预览仍会用模拟数据渲染。
|
||||||
这是一项实验性的功能。如果你需要该功能,请前往 feat/usb-wake 分支进行编译,或者使用该分支对应的 Github Action 预编译固件。`ds5-bridge-wake.uf2` 为该功能的固件
|
|
||||||
|
|
||||||
极为建议在使用该功能前阅读 #60 和 #61
|
> 网页工具源码:**[MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)**([awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web) 的分支)。
|
||||||
|
|
||||||
### 社区分支
|
---
|
||||||
https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition
|
|
||||||
https://github.com/zurce/DS5Dongle-OLED
|
|
||||||
|
|
||||||
# 当前问题:
|
## 概述
|
||||||
- 声音可能有点小卡顿
|
|
||||||
- 由于编码需要,需要对pico进行超频,当前的参数是1.2V 320MHz。
|
|
||||||
- 若您的pico使用该超频参数无法启动,请自行增加电压或者降低频率
|
|
||||||
|
|
||||||
# 未来计划
|
本项目让 Raspberry Pi Pico2W 作为 DualSense 手柄的蓝牙桥接器,实现无线连接并增强触感支持。
|
||||||
请查看[DS5Dongle plan](https://github.com/users/awalol/projects/5)
|
|
||||||
|
|
||||||
# 编译
|
## 功能特点
|
||||||
需要将pico sdk里面的tinyusb版本升级到最新
|
|
||||||
|
|
||||||
# 致谢
|
**核心桥接(来自上游):**
|
||||||
- [rafaelvaloto/Pico_W-Dualsense](https://github.com/rafaelvaloto/Pico_W-Dualsense) - 灵感来源
|
|
||||||
- [egormanga/SAxense](https://github.com/egormanga/SAxense) - 震动报文
|
- 通过 Pico2W 完整连接 DualSense
|
||||||
- [https://controllers.fandom.com/wiki/Sony_DualSense](https://controllers.fandom.com/wiki/Sony_DualSense) - 数据报文结构
|
- HD 触感(高级振动反馈)
|
||||||
- [Paliverse/DualSenseX](https://github.com/Paliverse/DualSenseX) - 扬声器数据包报文
|
- 无线蓝牙桥接
|
||||||
|
- 通过麦克风音量调节触感增益
|
||||||
|
- 可配置的 LED 与断连行为
|
||||||
|
|
||||||
|
**OLED 版新增:**
|
||||||
|
|
||||||
|
- 可选的 Pico-OLED-1.3 状态显示屏,含 **11 个屏幕**(状态、配对槽、灯条、扳机测试、陀螺仪倾斜、触摸板、诊断、CPU/时钟、RSSI、VU 表、设置)
|
||||||
|
- **按键重映射** —— 把 16 个数字控件(面板按键、方向键、肩键/扳机、摇杆按下、Create/Options)中的任意一个重新指派为其他按键。映射保存在设备上并在主机看到报告之前应用,因此在**任何游戏和操作系统中都无需任何主机端软件**即可生效;默认为恒等映射(无重映射)。可在[网页配置工具](#️-网页配置工具)的**重映射标签页**中可视化编辑,或通过 `scripts/remap_test.py` 无界面编辑。保存在专属闪存扇区,重启后保留。
|
||||||
|
- **4 槽持久化多手柄配对** —— 可绑定至多四个 DualSense,在 OLED 上切换,开机时自动重连槽 0
|
||||||
|
- **灯条调色器**,含 4 个用户收藏槽 + 呼吸 / 彩虹 / 渐变特效预设
|
||||||
|
- **持久化设置菜单**,涵盖 8 个固件配置字段(振动增益、扬声器音量、轮询率等),并提供按住确认的"重置"与"清除全部槽"操作
|
||||||
|
- **OLED 空闲省电阶梯** —— 手动亮度循环(长按 KEY1)、空闲 2 分钟自动深度变暗并显示一个呼吸小点、空闲 15 分钟完全熄屏。按键、手柄配对或输入时立即唤醒。是真正的防烧屏,而不只是调对比度。
|
||||||
|
- **软重启**,无需拔 USB:长按 DS5 的 `PS + Mute`(可无界面工作),或在 OLED 插件上**同时按住 KEY0 + KEY1 持续 1 秒**(取代了旧的 KEY0 双击手势 —— 快速翻页时容易误触发)
|
||||||
|
- **核心桥接的审计修复** —— 修复音频路径中关键的栈溢出(解决长期存在的"音频卡顿")、安全加固、看门狗、HID/L2CAP 边界处的长度校验(见 [CHANGELOG.md](./CHANGELOG.md))
|
||||||
|
|
||||||
|
## 硬件
|
||||||
|
|
||||||
|
### 必需
|
||||||
|
|
||||||
|
| 物品 | 说明 | 大致价格 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Raspberry Pi Pico 2 W** | 搭载 RP2350 MCU,板载 CYW43 蓝牙/WiFi。[官方产品页](https://www.raspberrypi.com/products/raspberry-pi-pico-2/) | 约 $7 USD |
|
||||||
|
| **索尼 DualSense 手柄** | 任何标准 PS5 DualSense(VID `054C:0CE6`)。 | — |
|
||||||
|
| **USB-C 线缆** | 将 Pico 2 W 连接到主机 PC。 | — |
|
||||||
|
|
||||||
|
### 可选(强烈推荐)
|
||||||
|
|
||||||
|
| 物品 | 说明 | 大致价格 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Waveshare Pico-OLED-1.3** | 128×64 SH1107 OLED 插件板(SKU HIPI1798)。直接插到 Pico 2 W 排针上。固件检测到时自动驱动,缺失时优雅地不做任何动作。[产品页](https://www.waveshare.com/pico-oled-1.3.htm) · [Wiki](https://www.waveshare.com/wiki/Pico-OLED-1.3) | 约 $6 USD |
|
||||||
|
| **小散热片**(用于 RP2350) | 固件将 MCU 超频到 320 MHz @ 1.20 V(见[性能 / 超频](#性能--超频))。持续游玩时小散热片或导热垫有帮助。 | $1–3 USD |
|
||||||
|
|
||||||
|
### 哪里购买
|
||||||
|
|
||||||
|
Pico 2 W 与 Waveshare Pico-OLED-1.3 在全球都很容易买到:
|
||||||
|
|
||||||
|
- **Adafruit**、**Pimoroni**、**The Pi Hut**、**DigiKey**、**Mouser** —— 主要电子元件分销商(美/欧)
|
||||||
|
- **Waveshare 自营商店** 购买 OLED 插件
|
||||||
|
- 各地区 **Amazon** 店面 —— 搜索 `Raspberry Pi Pico 2 W` 与 `Waveshare Pico-OLED-1.3`(或 SKU `HIPI1798`)
|
||||||
|
- **AliExpress(速卖通)** —— 原装 Waveshare 与 Pico 货源以及克隆品;注意查看卖家评分
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 刷写固件
|
||||||
|
|
||||||
|
1. 按住 Pico2W 上的 BOOTSEL 按钮
|
||||||
|
2. 通过 USB 将 Pico2W 连接到电脑
|
||||||
|
3. 设备会挂载为 USB 存储设备
|
||||||
|
4. 将 .uf2 固件文件拖放到该设备上
|
||||||
|
|
||||||
|
### 配对手柄
|
||||||
|
|
||||||
|
1. 让 DualSense 手柄进入蓝牙配对模式
|
||||||
|
2. 等待 Pico2W 检测并连接
|
||||||
|
3. 连接后,设备会出现在主机系统中
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
有四种方式配置固件:
|
||||||
|
|
||||||
|
**网页配置(推荐,任何基于 Chromium 的浏览器):** 在 Chrome、Edge、Vivaldi、Brave 或 Opera 中打开 **[DS5 Bridge Config — OLED Edition](https://marcelinevpq.github.io/DS5Dongle-OLED-Config-Web/)**(不支持 Firefox —— Mozilla 拒绝实现 WebHID)。点击 **Connect**,在浏览器对话框中选择 DualSense,然后用熟悉的表单界面编辑任意字段。页面通过 WebHID 直接与 Pico 通信 —— 无驱动、无安装,数据不离开你的机器。源码见 [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web)。
|
||||||
|
|
||||||
|
**设备端(已装 OLED 插件):** 使用屏上的**设置**菜单(第 11 个屏幕)。方向键 ▲▼ 移动选择,▶◀ 调整数值,△ 保存到闪存。
|
||||||
|
|
||||||
|
**终端 CLI(任何系统、任何浏览器):** 安装 hidapi,然后使用 `scripts/set_ds5.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install hidapi
|
||||||
|
scripts/set_ds5.py # 显示当前配置
|
||||||
|
scripts/set_ds5.py --auto-haptics fallback # 修改某字段并持久化到闪存
|
||||||
|
scripts/set_ds5.py --speaker-volume -10 --haptics-gain 1.5
|
||||||
|
scripts/set_ds5.py --slot 2 # 切换活动多槽配对
|
||||||
|
scripts/set_ds5.py --version # 固件版本
|
||||||
|
scripts/set_ds5.py --rssi # 实时蓝牙 RSSI(dBm)
|
||||||
|
scripts/set_ds5.py --help # 完整参数列表
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本通过 USB HID 特性报告 `0xF6`/`0xF7`/`0xF8`/`0xF9` 与固件通信 —— 在 Linux、macOS、Windows 的任何终端下都可用,与你用哪个浏览器无关。移植自 [loteran/DS5Dongle](https://github.com/loteran/DS5Dongle) 并为本分支的 `current_slot` 字段做了扩展。
|
||||||
|
|
||||||
|
**DualSense 手柄按键(旧式后备方案,无 OLED、无 CLI):**
|
||||||
|
|
||||||
|
### 麦克风音量
|
||||||
|
|
||||||
|
控制触感增益倍率。范围:`[1.0 – 2.0]`。
|
||||||
|
|
||||||
|
### 扬声器静音
|
||||||
|
|
||||||
|
禁用 LED 连接指示灯。手柄重连后生效。
|
||||||
|
|
||||||
|
### 麦克风静音
|
||||||
|
|
||||||
|
禁用静默断连行为。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
只有在手柄连接之后,Pico 设备才会对系统可见
|
||||||
|
|
||||||
|
某些行为需要经过重连周期才会生效
|
||||||
|
|
||||||
|
### 低电量 LED 指示
|
||||||
|
|
||||||
|
当所连 DualSense 报告其电量等于或低于 10%(且未在充电)时,Pico 板载 LED 会从常亮切换为 1 Hz 闪烁,让你一眼看到警告。一旦手柄插上充电或其报告电量回升,LED 恢复常亮。即使设置了 `disable_pico_led`,该闪烁也会触发 —— 该警告被视为关键,会覆盖 LED 关闭偏好;电量恢复或手柄开始充电后,LED 回到其禁用(熄灭)状态。
|
||||||
|
|
||||||
|
如需在编译期退出此功能,用 `-DENABLE_BATT_LED=OFF` 配置。默认为 ON。
|
||||||
|
|
||||||
|
## 蓝牙 DualSense 麦克风
|
||||||
|
|
||||||
|
**DualSense 的内置麦克风可通过适配器的蓝牙配对工作**(自 v0.6.8 起)。手柄将麦克风以 Opus 音频流式传输;适配器解码后作为标准 DualSense USB 采集设备呈现给主机,因此任何应用(Discord、OBS、游戏内语音)都能像普通麦克风一样使用它。
|
||||||
|
|
||||||
|
这一度被认为不可能实现 —— 本分支早期版本曾将其记录为索尼固件的硬性限制(Linux 的 `hid-playstation` 内核驱动至今仍不支持)。结果发现它取决于适配器外发音频报告中的一个使能位。**完全归功于 [awalol](https://github.com/awalol/DS5Dongle)(上游)发现了它。** 更正后的调查记录见 [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md)。
|
||||||
|
|
||||||
|
**使用方法:**
|
||||||
|
|
||||||
|
- **默认开启。** 配对手柄后一两秒内麦克风便开始流式传输 —— 无需游戏音频(适配器会自行保活该流)。
|
||||||
|
- **在主机端调高采集音量** —— 它默认很低。在 Linux 上,用 `arecord -l` 找到声卡,然后例如 `amixer -c <DualSense 声卡> sset 'Headset' 90%`。用 `scripts/mic_diag.sh capture` 验证采集。
|
||||||
|
- **关闭它以节省手柄电量** —— OLED **设置 → BT Mic**,或[网页配置工具](#️-网页配置工具)中的 **BT microphone** 开关。常开麦克风会让 DS5 的音频子系统保持唤醒,明显加快耗电,所以不用语音时请关闭。
|
||||||
|
|
||||||
|
**注意点:**
|
||||||
|
|
||||||
|
- 麦克风音频为**单声道**(解码为单声道,在立体声采集端点上复制)。
|
||||||
|
- 会话中途关闭会立即停止主机端的馈送,但手柄会持续流式传输直到下次重连(目前没有已知的"停止"命令);以关闭状态全新连接则永远不会启用它。
|
||||||
|
- OLED **诊断**屏幕的 `Mic in:` 计数器在麦克风流式传输时约为 ~100/s —— 是确认它在工作的快捷方式。
|
||||||
|
- **丢包隐藏:** 丢失的麦克风帧(蓝牙链路弱、距离远、干扰)会用 Opus PLC 隐藏,使语音保持连续而不卡顿/中断,代价是少量抖动缓冲延迟(~30 ms)。诊断屏的 `Mic PLC:` 计数器仅在隐藏帧时增长 —— 实质上是一个实时链路质量计。
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
- 超频到 320 MHz @ 1.20 V 是稳定蓝牙配对所**必需**的。把电压降到 1.10 V 或时钟降回默认会破坏 CYW43 PIO SPI 总线,蓝牙将停止工作。持续游玩时建议在 RP2350 上加小散热片。
|
||||||
|
- 在 Linux + Steam 上,HD 触感未必在每个游戏中都触发;这是游戏侧问题(部分作品仅在 Windows 专用 API 下发送 HD 触感音频)。在《漫威蜘蛛侠:重制版》中测试可用;在《对马岛之魂》中未送达 —— 同一固件、同一手柄。
|
||||||
|
- **USB 3.0 端口可能干扰配对** —— 手柄可能卡在常亮的琥珀/黄色灯条上始终连不上,而同一适配器在 USB 2.0 端口上工作正常。这是射频干扰,并非固件 bug;见下文 [USB 3.0 端口与蓝牙干扰](#usb-30-端口与蓝牙干扰)。(自 v0.6.8 起,固件会自动重试卡住的连接而不是一直挂着,能恢复许多 —— 但非全部 —— 边缘情况。)
|
||||||
|
|
||||||
|
## USB 3.0 端口与蓝牙干扰
|
||||||
|
|
||||||
|
如果适配器在某个端口能用而在另一个不能用,**请先试试 USB 2.0 端口。** USB 3.0 端口(尤其是 USB 3.0 延长线)会发出以 2.4 GHz 附近为中心的宽带射频噪声 —— 正是适配器蓝牙电台与手柄通信所用的频段。这是业界有充分记录的问题(Intel,*"USB 3.0 Radio Frequency Interference Impact on 2.4 GHz Wireless Devices"*),并非本固件特有。该噪声会降低适配器蓝牙接收机的灵敏度,于是手柄可以开始连接(琥珀灯条)但链路太吵无法完成 —— 卡在黄色上。
|
||||||
|
|
||||||
|
缓解措施,大致按效果排序:
|
||||||
|
|
||||||
|
1. **把适配器插到 USB 2.0 端口**(往往是最简单的修复 —— 许多主板/机箱两者都有)。
|
||||||
|
2. **用一根短的 USB 2.0 延长线** 让适配器离 USB 3.0 端口/金属机箱几英寸远,改善对手柄的视线。要特别避免 USB 3.0 延长线。
|
||||||
|
3. **使用插在 USB 3.0 端口上的有源 USB 2.0 集线器** —— 集线器会把链路降速并增加距离。
|
||||||
|
4. **在靠近适配器处的线缆上夹一个磁环。**
|
||||||
|
5. **配对时让手柄更靠近 / 与适配器保持视线。**
|
||||||
|
|
||||||
|
固件会自行持续重试卡住的连接,因此灯条变琥珀后让它插着等约 10–20 秒,也许无需拔插即可恢复。
|
||||||
|
|
||||||
|
## 性能 / 超频
|
||||||
|
|
||||||
|
**你无需为此做任何事 —— 超频已内置在固件中。** 当你刷入本仓库的 UF2 时,Pico 2 W 会自动以下列设置启动。没有单独要运行的工具、没有要编辑的配置文件、没有要烧的熔丝。
|
||||||
|
|
||||||
|
内置设置:
|
||||||
|
|
||||||
|
- **电压:1.20 V**(`vreg_set_voltage(VREG_VOLTAGE_1_20)`)
|
||||||
|
- **时钟:320 MHz**(`set_sys_clock_khz(SYS_CLOCK_KHZ, true)`)
|
||||||
|
|
||||||
|
为何必需:在默认时钟/电压下,CYW43 PIO SPI 总线(固件用来与板载蓝牙芯片通信的通路)不可靠,配对会失败。320 MHz @ 1.20 V 是我们验证过能在本板上产生稳定蓝牙链路的最低组合。
|
||||||
|
|
||||||
|
如果你自己编译的构建无法启动(不常见 —— 仅当你改过源码时才相关),可尝试在 `src/main.cpp` 中略微提高电压或降低时钟。运行官方 UF2 发布版的最终用户无需改动此处。
|
||||||
|
|
||||||
|
持续游玩**建议**在 RP2350 上加小散热片,但配对或短时使用并不需要。
|
||||||
|
|
||||||
|
## 编译说明
|
||||||
|
|
||||||
|
从源码编译本项目:
|
||||||
|
|
||||||
|
1. 安装 Pico SDK 2.2.0(或更新版本)。编译使用 `pico_sdk_import.cmake`。
|
||||||
|
2. **在 Pico SDK 内将 TinyUSB 固定到 0.20.0**(`$PICO_SDK_PATH/lib/tinyusb`)。本项目的 `tusb_config.h` 使用了 `TUD_AUDIO_EP_SIZE` 的四参数形式,而 Pico SDK 2.2.0 捆绑的 0.18.0 版本没有它:
|
||||||
|
```bash
|
||||||
|
cd "$PICO_SDK_PATH/lib/tinyusb"
|
||||||
|
git fetch --tags
|
||||||
|
git checkout 0.20.0
|
||||||
|
```
|
||||||
|
3. 用 CMake + Ninja(或 Make)配置并编译:
|
||||||
|
```bash
|
||||||
|
cd /path/to/DS5Dongle
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPICO_SDK_PATH="$PICO_SDK_PATH"
|
||||||
|
cmake --build build --target ds5-bridge
|
||||||
|
```
|
||||||
|
4. UF2 会生成在 `build/ds5-bridge-oled.uf2`。照常用 BOOTSEL 刷写。
|
||||||
|
|
||||||
|
值得了解的编译标志:
|
||||||
|
|
||||||
|
- `-DENABLE_BATT_LED=ON`(默认)—— DS5 低电量时闪烁 Pico LED。
|
||||||
|
- `-DENABLE_SERIAL=ON` —— 将 printf 路由到 USB CDC 用于调试(默认 OFF;生产构建释放 UART)。
|
||||||
|
- `-DPICO_W_BUILD=ON` —— 为原版 Pico W 编译(去掉音频、降低时钟)。默认面向 Pico 2 W。
|
||||||
|
|
||||||
|
## 诊断与调试工具
|
||||||
|
|
||||||
|
排查桥接问题有两种方式 —— 设备端通过 OLED 诊断屏幕,主机端通过 `scripts/mic_diag.sh`(Linux)。主机端更快:无需切屏、无需刷写周期,可在手柄正在使用时运行。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 一次性快照 —— 适配器在 USB 上吗?ALSA 枚举到了吗?采集流在跑吗?当前有手柄配对吗?
|
||||||
|
scripts/mic_diag.sh status
|
||||||
|
|
||||||
|
# 对麦克风 IN 端点做 3 秒 arecord —— 报告峰值 / RMS / 非零计数,
|
||||||
|
# 以便区分"流是静音的"和"流在产生音频"。
|
||||||
|
scripts/mic_diag.sh capture 3
|
||||||
|
|
||||||
|
# 同 `status` 但循环运行,仅在状态变化时打印。可用于
|
||||||
|
# 捕捉配对完成或音频流开/关的确切时刻。
|
||||||
|
scripts/mic_diag.sh watch
|
||||||
|
|
||||||
|
# 实时读取固件的 0xFD 厂商特性报告(经由 /dev/hidraw):
|
||||||
|
# 蓝牙输入计数与速率、最近见到的非 0x31 ID、字节前缀,以及
|
||||||
|
# 扳机流计数器(收到的主机 0x02 / 置了 AllowTriggerFFB 的 /
|
||||||
|
# 转发到蓝牙的)。bt-trace 会给出结论 —— "主机驱动没有发送
|
||||||
|
# 扳机 Allow 位" 还是 "已转发但手柄未触动" —— 这原本需要
|
||||||
|
# USB 协议分析仪才能判断。
|
||||||
|
scripts/mic_diag.sh bt-trace
|
||||||
|
```
|
||||||
|
|
||||||
|
最初是为排查被搁置的 DS5 蓝牙麦克风调查而写的(见 [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md))。`0xFD` 特性报告与 `bt-trace` 解码器现在还携带为 [issue #3](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/issues/3)(部分游戏缺少自适应扳机张力)新增的扳机流计数器。
|
||||||
|
|
||||||
|
## OLED 显示插件(可选)
|
||||||
|
|
||||||
|
如果你把 [Waveshare Pico-OLED-1.3](#硬件) 插到 Pico2W 的排针上,固件会自动将其作为实时状态显示屏驱动。无需任何配置 —— 没有 OLED 时固件会优雅地不做动作。
|
||||||
|
|
||||||
|
### 开机启动画面(上电后 1.5 秒)
|
||||||
|
|
||||||
|
空屏居中显示固件版本 1.5 秒,然后跳转到状态屏幕。
|
||||||
|
|
||||||
|
### 11 个屏幕,用插件上的 KEY0 循环
|
||||||
|
|
||||||
|
循环顺序:**状态 → 配对槽 → 灯条 → 扳机测试 → 陀螺仪倾斜 → 触摸板 → 诊断 → CPU/时钟 → 蓝牙信号 → VU 表 → 设置 →** 回绕。**短按 KEY0 前进;短按 KEY1 后退** —— 在*每个*屏幕上都如此。各屏幕内的交互(循环扳机预设、循环灯条模式、移动设置光标、切换槽)都放在 **DualSense 手柄按键**上,绝不放在 KEY0/KEY1 上,因此 OLED 插件上这两个物理按键始终表示同一含义。
|
||||||
|
|
||||||
|
每个屏幕还会在左上边缘(KEY0 旁)画 **`>`**、在左下边缘(KEY1 旁)画 **`<`**,让屏上标签与按键在物理上配对。
|
||||||
|
|
||||||
|
#### 1. 状态
|
||||||
|
|
||||||
|
连接状态、已配对 DualSense 的蓝牙地址、带条形的电量百分比(`+` 充电中 / `*` 已充满 / `!` 错误)、实时摇杆位置(按下 **L3 / R3** 时,对应的摇杆框会反色闪烁 —— 白底黑点 —— 直到松开)、方向键、面板按键(△ ◯ ✕ □)、L1/R1,以及 L2/R2 模拟扳机填充条。链路指示与电量使用小像素图标。充电时,电池旁会显示到 100% 的预计时间 `~Nm`。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc01.jpg" alt="Status screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 2. 配对槽
|
||||||
|
|
||||||
|
持久化 4 槽多手柄配对。浏览已存手柄、切换活动槽,或清除单个槽。`>` 是光标,`*` 标记当前活动槽。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc02.jpg" alt="Slots screen on the OLED" width="420">
|
||||||
|
|
||||||
|
- **方向键 ▲▼** —— 在槽 0–3 间移动光标
|
||||||
|
- **△** —— 切换到光标所在槽(断开当前,重连到该槽存储的手柄)
|
||||||
|
- **□ 按住 1.5 秒** —— 清除光标所在槽(删除 bd_addr + BTstack 链路密钥)
|
||||||
|
- 活动槽会被持久化;适配器下次开机时重连到它
|
||||||
|
|
||||||
|
#### 3. 灯条调色器
|
||||||
|
|
||||||
|
在各轴上倾斜手柄来调出 R / G / B;固件以 10 Hz 把得到的颜色发送到 DualSense 实际的灯条,因此灯条本身就是可视预览(OLED 是单色的)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc03.jpg" alt="Lightbar color picker on the OLED" width="420">
|
||||||
|
|
||||||
|
- 在手柄上按 **△ ◯ ✕ □** 把当前颜色**保存**到收藏槽 0 / 1 / 2 / 3
|
||||||
|
- 在手柄上按 **R1** 循环模式标签:`[LIVE]` → `[FAV0]` → `[FAV1]` → `[FAV2]` → `[FAV3]` → 特效(呼吸 / 彩虹 / 渐变)→ 回到 `[LIVE]`
|
||||||
|
- 默认收藏:红、绿、蓝、白
|
||||||
|
|
||||||
|
#### 4. 扳机测试
|
||||||
|
|
||||||
|
在手柄上按 **△** 循环七种应用到 L2 和 R2 的自适应扳机效果。扣动各扳机来感受效果。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc04.jpg" alt="Trigger Test screen on the OLED" width="420">
|
||||||
|
|
||||||
|
循环顺序:**关 → 反馈 → 武器 → 振动 → 弓 → 疾驰 → 机枪 → 关 …** 效果参数依据 [dualsensectl](https://github.com/nowrep/dualsensectl) 的逆向工程按位打包,全部为最大强度。
|
||||||
|
|
||||||
|
#### 5. 陀螺仪倾斜
|
||||||
|
|
||||||
|
实时 X/Y/Z 加速度计数值,配 40×40 十字准线框。点会实时跟随手柄的倾斜,并在手柄**平放时居中** —— 它使用手柄自带的逐台出厂 IMU 校准(从特性报告 `0x05` 解析),因此每个手柄的静止位置与增益都准确。向左/右、向前/后倾斜时,点会朝相应方向移动。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc05.jpg" alt="Gyro Tilt screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 6. 触摸板
|
||||||
|
|
||||||
|
触摸板表面的实时渲染。当前手指位置处出现圆点;计数随手指触碰/离开而更新。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc06.jpg" alt="Touchpad screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 7. 诊断
|
||||||
|
|
||||||
|
可滚动的实时计数器列表 —— 运行时间、蓝牙状态、主机 → 蓝牙扳机流(`host02` / `trig` / `tx`)、蓝牙 0x31 输入速率、USB 音频帧/秒、蓝牙 0x32 包/秒,底部还有被搁置的麦克风调查计数器。手柄方向键 ▲/▼ 滚动;右边缘的小 `^` / `v` 标记"上方/下方还有更多"。只读,所以没有光标。无需 UART 线即可验证桥接器在搬运字节。
|
||||||
|
|
||||||
|
同一批计数器也通过 HID 特性报告 `0xFD` 导出给主机端工具 —— 见下文 `scripts/mic_diag.sh bt-trace`。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc07.jpg" alt="Diagnostics screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 8. CPU / 时钟
|
||||||
|
|
||||||
|
实时 RP2350 指标:配置的(`Set`)与实际运行的(`Real`)系统时钟(以晶振参考测量)、从稳压器回读的核心电压,以及片上温度(256 次采样平均 + 慢速 EMA,使数值跟踪真实芯片温度而非 ADC 噪声)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc08.jpg" alt="CPU / Clock diagnostics screen on the OLED" width="420">
|
||||||
|
|
||||||
|
同样的遥测也通过 HID 特性报告 `0xFC` 导出给工具。
|
||||||
|
|
||||||
|
#### 9. 蓝牙信号
|
||||||
|
|
||||||
|
活动链路的实时蓝牙信号强度,以 dBm 显示并带条形。越接近 0 dBm 越强;−90 dBm 为弱。含定性标签(差 / 一般 / 好 / 极佳)。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc09.jpg" alt="BT Signal screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 10. VU 表
|
||||||
|
|
||||||
|
扬声器与触感音频路径的实时峰值表。可在手柄未插入主机时验证音频路由。
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc10.jpg" alt="VU Meters screen on the OLED" width="420">
|
||||||
|
|
||||||
|
#### 11. 设置
|
||||||
|
|
||||||
|
持久化配置编辑器。方向键 ▲▼ 移动选择,▶◀ 调整数值,△ 保存到闪存。包含固件配置字段(振动增益、扬声器音量、空闲超时、轮询率)、音频自动触感控件,以及两个按住确认的操作:
|
||||||
|
|
||||||
|
<img src="./assets/oled/oled_sc11.jpg" alt="Settings screen on the OLED" width="420">
|
||||||
|
|
||||||
|
- **AutoHap Off / Fallback / Mix / Replace** —— 选择音频自动触感模式。默认 `Fallback` 仅在游戏不发送原生触感数据时(如 Linux 上的《对马岛之魂》)才触发派生震动;确实发送原生触感的游戏(《漫威蜘蛛侠:重制版》)则原样通过。`Mix` 在原生之上叠加派生,`Replace` 完全忽略原生,`Off` 禁用。
|
||||||
|
- **AH Gain N%** —— 派生信号增益,0–200%,10% 步进。默认 100%。
|
||||||
|
- **AH LP 80/160/250/400 Hz** —— 在包络跟随之前应用到扬声器音频的低通截止。越低越偏重低音,越高越偏临场感。默认 160 Hz。
|
||||||
|
- **Reset to defaults** —— 按住 △ 2 秒恢复所有配置字段
|
||||||
|
- **Wipe all slots** —— 按住 △ 2 秒删除全部 4 个已配对手柄 + 全部 BTstack 链路密钥
|
||||||
|
|
||||||
|
### 按键参考
|
||||||
|
|
||||||
|
OLED 插件上的两个物理按键**严格用于导航**:
|
||||||
|
|
||||||
|
| 按键 | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| **KEY0** 短按 | 下一屏(前进) |
|
||||||
|
| **KEY1** 短按 | 上一屏(后退) |
|
||||||
|
| **KEY1** 长按(≥ 1.5 秒) | 循环 OLED 亮度等级 |
|
||||||
|
| **KEY0 + KEY1** 同时按住 ≥ 1 秒 | `watchdog_reboot` —— 无需拔 USB 的软重启 |
|
||||||
|
|
||||||
|
各屏幕内的状态变更(循环扳机预设、循环灯条模式、移动设置光标、切换槽、把颜色保存到收藏槽)全部发生在 **DualSense 手柄按键**上 —— 绝不在 KEY0 / KEY1 上 —— 因此这两个物理按键在每个屏幕上始终表示同一含义。各按键对应哪个手柄操作,见上文每个屏幕的小节。
|
||||||
|
|
||||||
|
### 引脚定义(标准 Waveshare Pico HAT 布局)
|
||||||
|
|
||||||
|
| 功能 | GPIO |
|
||||||
|
|---|---|
|
||||||
|
| MOSI | 11 |
|
||||||
|
| SCK | 10 |
|
||||||
|
| CS | 9 |
|
||||||
|
| DC | 8 |
|
||||||
|
| RST | 12 |
|
||||||
|
| KEY0 | 15 |
|
||||||
|
| KEY1 | 17 |
|
||||||
|
|
||||||
|
### 软重启恢复
|
||||||
|
|
||||||
|
两种无需拔 USB 重启适配器的方式 —— 配对卡住或想要干净状态时很方便:
|
||||||
|
|
||||||
|
- **OLED 上同时按住 KEY0 + KEY1 ≥ 1 秒** → `watchdog_reboot`。取代了早期版本的"KEY0 双击"手势,因为快速前进导航总会误触双击计时器。
|
||||||
|
- **按住 DualSense 的 `PS + Mute` 2 秒** → `watchdog_reboot`(无论是否装有 OLED 都可用 —— 无界面后备)。
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
本分支中的部分功能与设计思路借鉴自上游的其他分支,并予以致谢:
|
||||||
|
|
||||||
|
- **[zurce/DS5Dongle-OLED](https://github.com/zurce/DS5Dongle-OLED)** —— OLED 状态头部的像素图标(视觉方案)、设置屏 "Reset to defaults" 项使用的"按住以恢复出厂"交互模式(按住 △ 2 秒确认),以及新增配对槽屏上的多槽持久化蓝牙配对系统(4 个已绑定手柄、方向键导航、△ 切换槽、□ 按住清除某槽,外加设置菜单中的 "Wipe all slots")。
|
||||||
|
- **[loteran/DS5Dongle](https://github.com/loteran/DS5Dongle)** —— 独立地重新发现了上游 `3a31bd7` 破坏扬声器/HD 触感输出的回归(提交 `c7a8d3c`);我们 `src/audio.cpp` 中的修复恢复了同样的 SetStateData 子报告。也是音频自动触感 DSP(1 极点 LP + 包络跟随器,见 设置 → Auto Haptics)以及"不要把 USB 侧 UAC1 音量同步到持久化配置"修复的来源。
|
||||||
|
- **[awalol/ds5dongle-config-web](https://github.com/awalol/ds5dongle-config-web)** —— 我们分支版网页配置应用 [MarcelineVPQ/DS5Dongle-OLED-Config-Web](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Config-Web) 的基础。该分支把上游的 Config_body 布局适配为我们的版本,并为我们的新增功能(多槽配对、Auto Haptics)添加了 UI。
|
||||||
|
- **[PS5 Button Icons and Controls](https://zacksly.itch.io/ps5-button-icons-and-controls)**(作者 **Zacksly**)—— 网页配置工具重映射标签页所用的 DualSense 控制器轮廓与按键图标,采用 [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) 授权(已重新着色为 `currentColor` 并裁剪以适配主题)。
|
||||||
|
|
||||||
|
## 路线图
|
||||||
|
- 请查看 [DS5Dongle plan](https://github.com/users/awalol/projects/5)
|
||||||
|
|
||||||
|
## 社区
|
||||||
|
- 加入 Discord 服务器:[Discord Server](https://discord.gg/hM4ntchGCa)
|
||||||
|
- 如果你遇到 bug,请改为开 issue。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
- [rafaelvaloto/Pico_W-Dualsense](https://github.com/rafaelvaloto/Pico_W-Dualsense) —— 项目灵感
|
||||||
|
- [egormanga/SAxense](https://github.com/egormanga/SAxense) —— 蓝牙触感 POC
|
||||||
|
- [https://controllers.fandom.com/wiki/Sony_DualSense](https://controllers.fandom.com/wiki/Sony_DualSense) —— DualSense 数据报告结构文档
|
||||||
|
- [Paliverse/DualSenseX](https://github.com/Paliverse/DualSenseX) —— 扬声器报告数据包
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ The web tool is a one-stop shop — **no installs, no command line, no `picotool
|
|||||||
|
|
||||||
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use.
|
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use.
|
||||||
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID.
|
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID.
|
||||||
|
- **Remap tab** — visual button remapper: click a button on a live DualSense diagram to reassign it to any other (the shoulders/triggers float to the corners as labeled glyphs). The map is stored on the dongle and applied before the host sees the report, so it works in every game and on every OS. Powered by WebHID.
|
||||||
- **OLED Preview tab** — pixel-perfect emulation of all 11 OLED screens. Use the in-page KEY0/KEY1 buttons (or the controller's △ / R1 / D-pad when a DualSense is paired) to navigate. Adaptive triggers actually fire on the controller when you cycle the Trigger Test preset.
|
- **OLED Preview tab** — pixel-perfect emulation of all 11 OLED screens. Use the in-page KEY0/KEY1 buttons (or the controller's △ / R1 / D-pad when a DualSense is paired) to navigate. Adaptive triggers actually fire on the controller when you cycle the Trigger Test preset.
|
||||||
|
|
||||||
Works in any Chromium-based browser (Chrome, Edge, Brave, Opera). Firefox + Safari don't expose WebHID or WebUSB, so flashing and live config aren't available there — the OLED Preview still renders with mock data.
|
Works in any Chromium-based browser (Chrome, Edge, Brave, Opera). Firefox + Safari don't expose WebHID or WebUSB, so flashing and live config aren't available there — the OLED Preview still renders with mock data.
|
||||||
@@ -43,6 +44,7 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
|
|||||||
**OLED Edition additions:**
|
**OLED Edition additions:**
|
||||||
|
|
||||||
- Optional Pico-OLED-1.3 status display with **11 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, RSSI, VU meters, settings)
|
- Optional Pico-OLED-1.3 status display with **11 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, RSSI, VU meters, settings)
|
||||||
|
- **Button remapping** — reassign any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) to any other. Stored on the dongle and applied before the host sees the report, so it works in **every game and OS with no host-side software**; identity (no remap) is the default. Edit it visually in the [web config tool](#️-web-config-tool)'s **Remap tab**, or headlessly via `scripts/remap_test.py`. Persisted in its own flash sector, survives reboot.
|
||||||
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
|
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
|
||||||
- **Lightbar color picker** with 4 user favorite slots + breathing / rainbow / fade effect presets
|
- **Lightbar color picker** with 4 user favorite slots + breathing / rainbow / fade effect presets
|
||||||
- **Persistent settings menu** for the 8 firmware config fields (haptics gain, speaker volume, polling rate, etc.) with hold-to-confirm Reset and Wipe-all-slots actions
|
- **Persistent settings menu** for the 8 firmware config fields (haptics gain, speaker volume, polling rate, etc.) with hold-to-confirm Reset and Wipe-all-slots actions
|
||||||
@@ -140,11 +142,44 @@ When the connected DualSense reports its battery at or below 10% (and it is not
|
|||||||
|
|
||||||
To opt out at build time, configure with `-DENABLE_BATT_LED=OFF`. Default is ON.
|
To opt out at build time, configure with `-DENABLE_BATT_LED=OFF`. Default is ON.
|
||||||
|
|
||||||
|
## DualSense Microphone over Bluetooth
|
||||||
|
|
||||||
|
**The DualSense's built-in microphone works over the dongle's Bluetooth pairing** (since v0.6.8). The controller streams its mic as Opus audio; the dongle decodes it and presents it to the host as the standard DualSense USB capture device, so any app (Discord, OBS, in-game voice) can use it like a normal microphone.
|
||||||
|
|
||||||
|
This was long believed impossible — earlier versions of this fork documented it as a hard Sony-firmware limitation (and the Linux `hid-playstation` kernel driver still doesn't support it). It turned out to hinge on a single enable bit in the dongle's outbound audio report. **Full credit to [awalol](https://github.com/awalol/DS5Dongle) (upstream) for identifying it.** The corrected investigation log lives in [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md).
|
||||||
|
|
||||||
|
**Using it:**
|
||||||
|
|
||||||
|
- **On by default.** Pair the controller and the mic begins streaming within a second or two — no game audio required (the dongle keeps the stream alive on its own).
|
||||||
|
- **Raise the capture volume on the host** — it defaults low. On Linux, find the card with `arecord -l`, then e.g. `amixer -c <DualSense card> sset 'Headset' 90%`. Verify capture with `scripts/mic_diag.sh capture`.
|
||||||
|
- **Toggle it off to save controller battery** — OLED **Settings → BT Mic**, or the **BT microphone** switch in the [web config tool](#web-config-tool). Always-on mic keeps the DS5's audio subsystem awake, which drains its battery noticeably faster, so disable it if you don't use voice.
|
||||||
|
|
||||||
|
**Caveats:**
|
||||||
|
|
||||||
|
- Mic audio is **mono** (decoded mono, duplicated across the stereo capture endpoint).
|
||||||
|
- Toggling off mid-session stops the host feed immediately, but the controller keeps streaming until it next reconnects (there's no known "stop" command); connecting fresh with the toggle off never enables it.
|
||||||
|
- The OLED **Diagnostics** screen's `Mic in:` counter reads ~100/s while the mic is streaming — a quick way to confirm it's live.
|
||||||
|
- **Packet-loss concealment:** dropped mic frames (weak BT link, distance, interference) are concealed with Opus PLC so voice stays continuous instead of clicking/cutting out, at a small jitter-buffer latency (~30 ms). The Diag screen's `Mic PLC:` counter climbs only when frames are being concealed — effectively a live link-quality gauge.
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
- Overclocking to 320 MHz @ 1.20 V is **required** for stable BT pairing. Dropping voltage to 1.10 V or clock to stock breaks the CYW43 PIO SPI bus and BT stops working. A small heatsink on the RP2350 is recommended for sustained gameplay.
|
- Overclocking to 320 MHz @ 1.20 V is **required** for stable BT pairing. Dropping voltage to 1.10 V or clock to stock breaks the CYW43 PIO SPI bus and BT stops working. A small heatsink on the RP2350 is recommended for sustained gameplay.
|
||||||
- HD haptics may not fire in every game on Linux + Steam; this is game-side (some titles only send HD-haptic audio under Windows-specific APIs). Tested working in Spider-Man Remastered; not delivered in Ghost of Tsushima — same firmware, same controller.
|
- HD haptics may not fire in every game on Linux + Steam; this is game-side (some titles only send HD-haptic audio under Windows-specific APIs). Tested working in Spider-Man Remastered; not delivered in Ghost of Tsushima — same firmware, same controller.
|
||||||
- **DualSense microphone does not work over the Bluetooth pairing.** This is a Sony / DS5-firmware-side limitation also documented in the upstream Linux kernel driver (`drivers/hid/hid-playstation.c` line ~1509: *"Bluetooth audio is currently not supported"*). The mic works fine when the controller is connected directly to the host via USB-C. See [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md) for the full investigation log + what's already wired firmware-side if a future contributor cracks the BT-side trigger.
|
- **USB 3.0 ports can disrupt pairing** — the controller may get stuck on a solid amber/yellow lightbar and never connect, while the same dongle works fine on a USB 2.0 port. This is RF interference, not a firmware bug; see [USB 3.0 ports & Bluetooth interference](#usb-30-ports--bluetooth-interference) below. (As of v0.6.8 the firmware auto-retries a stalled connection instead of hanging, which recovers many — but not all — marginal cases.)
|
||||||
|
|
||||||
|
## USB 3.0 ports & Bluetooth interference
|
||||||
|
|
||||||
|
If the dongle works on one port but not another, **try a USB 2.0 port first.** USB 3.0 ports and (especially) USB 3.0 extension cables emit broadband RF noise centered near 2.4 GHz — the same band the dongle's Bluetooth radio uses to talk to the controller. This is a well-documented industry issue (Intel, *"USB 3.0 Radio Frequency Interference Impact on 2.4 GHz Wireless Devices"*), not specific to this firmware. The noise desensitizes the dongle's BT receiver, so the controller can start connecting (amber lightbar) but the link is too noisy to complete — it hangs on yellow.
|
||||||
|
|
||||||
|
Mitigations, roughly in order of effectiveness:
|
||||||
|
|
||||||
|
1. **Plug the dongle into a USB 2.0 port** (often the simplest fix — many motherboards/cases have both).
|
||||||
|
2. **Use a short USB 2.0 extension cable** to get the dongle a few inches away from the USB 3.0 ports / metal chassis, improving line-of-sight to the controller. Avoid USB 3.0 extension cables specifically.
|
||||||
|
3. **Use a powered USB 2.0 hub** plugged into the USB 3.0 port — the hub downshifts the link and adds distance.
|
||||||
|
4. **Clip a ferrite bead** onto the cable near the dongle.
|
||||||
|
5. **Keep the controller closer / in line of sight** of the dongle during pairing.
|
||||||
|
|
||||||
|
The firmware will keep retrying a stalled connection on its own, so leaving it plugged in for ~10–20 s after the lightbar goes amber may let it recover without a replug.
|
||||||
|
|
||||||
## Performance / Overclocking
|
## Performance / Overclocking
|
||||||
|
|
||||||
@@ -230,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">
|
||||||
|
|
||||||
@@ -265,7 +300,7 @@ Cycle order: **Off → Feedback → Weapon → Vibration → Bow → Gallop →
|
|||||||
|
|
||||||
#### 5. Gyro Tilt
|
#### 5. Gyro Tilt
|
||||||
|
|
||||||
Live X/Y/Z accelerometer values with a 40×40 crosshair box. Tilt the controller and the dot tracks in real time.
|
Live X/Y/Z accelerometer values with a 40×40 crosshair box. The dot tracks the controller's tilt in real time and **sits centered when the controller lies flat** — it's driven by the controller's own per-unit factory IMU calibration (parsed from feature report `0x05`), so the rest position and gain are correct on every controller. Tilting left/right and forward/back moves the dot in the matching direction.
|
||||||
|
|
||||||
<img src="./assets/oled/oled_sc05.jpg" alt="Gyro Tilt screen on the OLED" width="420">
|
<img src="./assets/oled/oled_sc05.jpg" alt="Gyro Tilt screen on the OLED" width="420">
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decode raw Opus frames captured from firmware serial output.
|
||||||
|
|
||||||
|
Reads [OPUS_FRAME_N] hex lines from stdin or a file, decodes each frame
|
||||||
|
with libopus, and writes the result as a WAV file + prints a summary
|
||||||
|
(peak amplitude, zero-crossing rate) to diagnose encoder output quality.
|
||||||
|
"""
|
||||||
|
import ctypes
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
import re
|
||||||
|
|
||||||
|
lib = ctypes.cdll.LoadLibrary("libopus.so.0")
|
||||||
|
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
CHANNELS = 2
|
||||||
|
FRAME_SIZE = 480 # 10ms at 48kHz
|
||||||
|
|
||||||
|
# Create decoder
|
||||||
|
err = ctypes.c_int(0)
|
||||||
|
decoder = lib.opus_decoder_create(SAMPLE_RATE, CHANNELS, ctypes.byref(err))
|
||||||
|
if err.value != 0:
|
||||||
|
print(f"opus_decoder_create failed: {err.value}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
infile = sys.argv[1] if len(sys.argv) > 1 else "/tmp/ds5_opus.log"
|
||||||
|
with open(infile) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
for line in lines:
|
||||||
|
m = re.match(r'\[OPUS_FRAME_\d+\]\s+([0-9a-fA-F]+)', line.strip())
|
||||||
|
if m:
|
||||||
|
frames.append(bytes.fromhex(m.group(1)))
|
||||||
|
|
||||||
|
if not frames:
|
||||||
|
print("No [OPUS_FRAME_N] lines found in input.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Found {len(frames)} Opus frames, decoding...")
|
||||||
|
|
||||||
|
all_pcm = b""
|
||||||
|
for i, frame_data in enumerate(frames):
|
||||||
|
pcm = (ctypes.c_int16 * (FRAME_SIZE * CHANNELS))()
|
||||||
|
ret = lib.opus_decode(
|
||||||
|
decoder,
|
||||||
|
frame_data, len(frame_data),
|
||||||
|
pcm, FRAME_SIZE,
|
||||||
|
0 # no FEC
|
||||||
|
)
|
||||||
|
if ret < 0:
|
||||||
|
errstr = lib.opus_strerror(ret)
|
||||||
|
print(f" Frame {i+1}: DECODE ERROR {ret} ({ctypes.string_at(errstr).decode()})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
samples = list(pcm)
|
||||||
|
peak = max(abs(s) for s in samples)
|
||||||
|
nonzero = sum(1 for s in samples if s != 0)
|
||||||
|
print(f" Frame {i+1}: {ret} samples decoded, peak={peak}, nonzero={nonzero}/{len(samples)}")
|
||||||
|
print(f" TOC=0x{frame_data[0]:02x} first 8 bytes: {frame_data[:8].hex()}")
|
||||||
|
all_pcm += struct.pack(f"<{ret * CHANNELS}h", *samples[:ret * CHANNELS])
|
||||||
|
|
||||||
|
outpath = "/tmp/ds5_opus_decoded.wav"
|
||||||
|
with wave.open(outpath, "w") as wf:
|
||||||
|
wf.setnchannels(CHANNELS)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(SAMPLE_RATE)
|
||||||
|
wf.writeframes(all_pcm)
|
||||||
|
|
||||||
|
print(f"\nDecoded audio written to {outpath}")
|
||||||
|
print(f"Play with: aplay {outpath}")
|
||||||
|
print(f"View spectrogram: sox {outpath} -n spectrogram -o /tmp/ds5_opus_spectrogram.png")
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Send a 440 Hz sine to the DualSense speaker over Bluetooth from a Linux host
|
||||||
|
(Raspberry Pi), via raw /dev/hidraw writes — replicating the DS5Dongle firmware's
|
||||||
|
0x36 audio report BYTE-FOR-BYTE (same Opus settings, same SetStateData, same CRC).
|
||||||
|
|
||||||
|
Purpose: test whether a NON-tunneled BT path (Pi's BlueZ over a real UART) delivers
|
||||||
|
clean audio to the DS5 speaker, vs the Pico 2 W's BT-over-gSPI tunnel. If clean here,
|
||||||
|
the Pico's tunnel is the crackle culprit.
|
||||||
|
|
||||||
|
Pair the DS5 first (bluetoothctl), then run as root (hidraw needs RW):
|
||||||
|
sudo python3 pi_ds5_audio_test.py [seconds] [--headset] [--audbuf N]
|
||||||
|
"""
|
||||||
|
import ctypes, struct, math, sys, os, glob, time
|
||||||
|
|
||||||
|
# ---- libopus via ctypes (same loader style as decode_opus_dump.py) ----
|
||||||
|
opus = ctypes.cdll.LoadLibrary("libopus.so.0")
|
||||||
|
opus.opus_encoder_create.restype = ctypes.c_void_p
|
||||||
|
opus.opus_encoder_create.argtypes = [ctypes.c_int32, ctypes.c_int, ctypes.c_int,
|
||||||
|
ctypes.POINTER(ctypes.c_int)]
|
||||||
|
opus.opus_encode_float.restype = ctypes.c_int32
|
||||||
|
opus.opus_encode_float.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_float),
|
||||||
|
ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte),
|
||||||
|
ctypes.c_int32]
|
||||||
|
# opus_encoder_ctl is variadic; leave argtypes unset and pass c_int values.
|
||||||
|
OPUS_APPLICATION_AUDIO = 2049
|
||||||
|
OPUS_SET_BITRATE_REQUEST = 4002
|
||||||
|
OPUS_SET_VBR_REQUEST = 4006
|
||||||
|
OPUS_SET_COMPLEXITY_REQUEST = 4010
|
||||||
|
|
||||||
|
RATE, CH, FRAME, OPUS_BYTES = 48000, 2, 480, 200 # 480 = 10 ms; 200 B = 160 kbps CBR
|
||||||
|
|
||||||
|
def make_encoder():
|
||||||
|
err = ctypes.c_int(0)
|
||||||
|
enc = opus.opus_encoder_create(RATE, CH, OPUS_APPLICATION_AUDIO, ctypes.byref(err))
|
||||||
|
if err.value != 0 or not enc:
|
||||||
|
sys.exit(f"opus_encoder_create failed: {err.value}")
|
||||||
|
enc_p = ctypes.c_void_p(enc)
|
||||||
|
# match firmware core1_entry(): 160 kbps, CBR, complexity 0
|
||||||
|
opus.opus_encoder_ctl(enc_p, OPUS_SET_BITRATE_REQUEST, ctypes.c_int(OPUS_BYTES*8*100))
|
||||||
|
opus.opus_encoder_ctl(enc_p, OPUS_SET_VBR_REQUEST, ctypes.c_int(0))
|
||||||
|
opus.opus_encoder_ctl(enc_p, OPUS_SET_COMPLEXITY_REQUEST, ctypes.c_int(0))
|
||||||
|
return enc_p
|
||||||
|
|
||||||
|
# ---- 63-byte SetStateData, verbatim from firmware src/state_mgr.cpp ----
|
||||||
|
STATE_DATA = bytes([
|
||||||
|
0xfd, 0xf7, 0x00, 0x00,
|
||||||
|
0x7f, 0x64, # VolHeadphonesMax, VolSpeaker
|
||||||
|
0x40, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a,
|
||||||
|
0x07, 0x00, 0x00, 0x02, 0x01,
|
||||||
|
0x00,
|
||||||
|
0xff, 0xd7, 0x00, # RGB
|
||||||
|
]) + bytes(16) # trailing zeros -> 63 total
|
||||||
|
assert len(STATE_DATA) == 63
|
||||||
|
|
||||||
|
# ---- CRC-32, 0xA2-seeded, verbatim from firmware src/utils.h ----
|
||||||
|
def ds_crc32(data):
|
||||||
|
crc = (~0xEADA2D49) & 0xFFFFFFFF
|
||||||
|
for b in data:
|
||||||
|
crc ^= b
|
||||||
|
for _ in range(8):
|
||||||
|
if crc & 1: crc = (crc >> 1) ^ 0xEDB88320
|
||||||
|
else: crc >>= 1
|
||||||
|
return (~crc) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
REPORT_SIZE, SAMPLE_SIZE = 398, 64 # original firmware layout
|
||||||
|
|
||||||
|
def build_packet(payload, seq, counter, headset, audbuf):
|
||||||
|
pkt = bytearray(REPORT_SIZE)
|
||||||
|
pkt[0] = 0x36
|
||||||
|
pkt[1] = (seq & 0x0F) << 4
|
||||||
|
pkt[2] = 0x11 | 0x80 # 0x91
|
||||||
|
pkt[3] = 7
|
||||||
|
pkt[4] = 0xFE # audio enable, mic OFF
|
||||||
|
for i in range(5, 10): pkt[i] = audbuf & 0xFF
|
||||||
|
pkt[10] = counter & 0xFF
|
||||||
|
pkt[11] = 0x10 | 0x80 # 0x90 SetStateData
|
||||||
|
pkt[12] = 63
|
||||||
|
pkt[13:76] = STATE_DATA
|
||||||
|
pkt[76] = 0x12 | 0x80 # 0x92 haptic
|
||||||
|
pkt[77] = SAMPLE_SIZE # haptic data pkt[78..141] = 0 (silent)
|
||||||
|
pkt[142] = (0x16 if headset else 0x13) | 0x80 # 0x96 headset / 0x93 speaker
|
||||||
|
pkt[143] = OPUS_BYTES
|
||||||
|
pkt[144:144+OPUS_BYTES] = payload
|
||||||
|
struct.pack_into("<I", pkt, REPORT_SIZE-4, ds_crc32(pkt[:REPORT_SIZE-4]))
|
||||||
|
return bytes(pkt)
|
||||||
|
|
||||||
|
def find_ds5_hidraw():
|
||||||
|
for ue in glob.glob("/sys/class/hidraw/hidraw*/device/uevent"):
|
||||||
|
txt = open(ue).read().upper()
|
||||||
|
if "054C" in txt and ("0CE6" in txt or "0DF2" in txt):
|
||||||
|
return "/dev/" + ue.split("/")[4]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
headset = "--headset" in sys.argv
|
||||||
|
audbuf = int(sys.argv[sys.argv.index("--audbuf")+1]) if "--audbuf" in sys.argv else 64
|
||||||
|
pos = [a for a in sys.argv[1:] if not a.startswith("--") and a.isdigit()]
|
||||||
|
secs = int(pos[0]) if pos else 15
|
||||||
|
|
||||||
|
node = find_ds5_hidraw()
|
||||||
|
if not node:
|
||||||
|
sys.exit("DualSense hidraw node not found — paired & connected? (bluetoothctl)")
|
||||||
|
print(f"DS5 hidraw: {node} | output: {'HEADSET' if headset else 'SPEAKER'} | "
|
||||||
|
f"AudBuf={audbuf} | {secs}s")
|
||||||
|
|
||||||
|
enc = make_encoder()
|
||||||
|
pcm = (ctypes.c_float * (FRAME*CH))()
|
||||||
|
out = (ctypes.c_ubyte * OPUS_BYTES)()
|
||||||
|
|
||||||
|
# Pre-build every packet so the timed send loop is pure I/O (no compute jitter).
|
||||||
|
n_frames = secs * 100
|
||||||
|
packets, seq, counter, samp = [], 0, 0, 0
|
||||||
|
first_nb = None
|
||||||
|
for _ in range(n_frames):
|
||||||
|
for i in range(FRAME):
|
||||||
|
v = 0.5 * math.sin(2*math.pi*440.0*samp/RATE)
|
||||||
|
pcm[i*2] = v; pcm[i*2+1] = v; samp += 1
|
||||||
|
nb = opus.opus_encode_float(enc, pcm, FRAME, out, OPUS_BYTES)
|
||||||
|
if nb < 0: sys.exit(f"opus_encode_float error {nb}")
|
||||||
|
if first_nb is None: first_nb = nb
|
||||||
|
payload = bytes(out[:nb]) + bytes(OPUS_BYTES - nb) if nb < OPUS_BYTES else bytes(out[:OPUS_BYTES])
|
||||||
|
packets.append(build_packet(payload, seq, counter, headset, audbuf))
|
||||||
|
seq = (seq+1) & 0x0F; counter = (counter+1) & 0xFF
|
||||||
|
print(f"Encoded {len(packets)} frames (first frame {first_nb} B — expect ~200 for CBR). Streaming...")
|
||||||
|
|
||||||
|
fd = os.open(node, os.O_RDWR)
|
||||||
|
short = 0
|
||||||
|
t0 = time.monotonic()
|
||||||
|
for n, pkt in enumerate(packets):
|
||||||
|
try:
|
||||||
|
w = os.write(fd, pkt)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"write failed at frame {n}: {e}"); break
|
||||||
|
if w != REPORT_SIZE: short += 1
|
||||||
|
target = t0 + (n+1)*0.01 # 10 ms grid -> 100 pkt/s
|
||||||
|
dt = target - time.monotonic()
|
||||||
|
if dt > 0: time.sleep(dt)
|
||||||
|
os.close(fd)
|
||||||
|
print(f"Done — {n+1} frames sent (~{(n+1)/100:.1f}s).", end="")
|
||||||
|
print(f" WARNING: {short} short writes (hidraw truncating the 398 B report!)" if short else " All writes full-length.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Host-side dev helper to exercise the firmware button-remap path over the
|
||||||
|
already-declared 0xF6 (SET) / 0xF7 (GET) vendor feature reports — no web tool
|
||||||
|
needed. Linux only (reads /dev/hidraw directly), same role as mic_diag.sh.
|
||||||
|
|
||||||
|
The remap rides 0xF6/0xF7 with a magic+version frame (see src/cmd.cpp):
|
||||||
|
SET 0xF6: [func=0x10]['R']['M'][ver][table[16]]
|
||||||
|
GET 0xF7: <Config_body(35)> ['R']['M'][ver][rev_lo][rev_hi][table[16]]
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
remap_test.py get # show revision + current table
|
||||||
|
remap_test.py swap CIRCLE CROSS # swap two buttons (and persist)
|
||||||
|
remap_test.py set SRC TGT # route SRC -> TGT (one-way)
|
||||||
|
remap_test.py off SRC # disable SRC (0xFF)
|
||||||
|
remap_test.py reset # identity (no remap)
|
||||||
|
|
||||||
|
Run with sudo if /dev/hidraw needs root.
|
||||||
|
"""
|
||||||
|
import fcntl, glob, struct, sys
|
||||||
|
|
||||||
|
VID, PID = 0x054c, 0x0ce6
|
||||||
|
CONFIG_LEN = 35 # sizeof(Config_body), see src/config.h
|
||||||
|
PROTO_VER = 1 # kRemapProtoVer
|
||||||
|
COUNT = 16 # kRemapCount
|
||||||
|
|
||||||
|
# Must match RemapButton order in src/remap.cpp.
|
||||||
|
NAMES = ["L2", "L1", "CREATE", "DPAD_UP", "DPAD_LEFT", "DPAD_DOWN",
|
||||||
|
"DPAD_RIGHT", "L3", "R2", "R1", "OPTIONS", "TRIANGLE",
|
||||||
|
"CIRCLE", "CROSS", "SQUARE", "R3"]
|
||||||
|
IDX = {n: i for i, n in enumerate(NAMES)}
|
||||||
|
|
||||||
|
|
||||||
|
def hidiocg(size): # HIDIOCGFEATURE(size)
|
||||||
|
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x07
|
||||||
|
|
||||||
|
|
||||||
|
def hidiocs(size): # HIDIOCSFEATURE(size)
|
||||||
|
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x06
|
||||||
|
|
||||||
|
|
||||||
|
def read_f7(f):
|
||||||
|
"""Return (rev, table[16]) or None if the response lacks the remap block."""
|
||||||
|
size = 64 # 1 report-id byte + up to 63 payload
|
||||||
|
buf = bytearray(size)
|
||||||
|
buf[0] = 0xF7
|
||||||
|
fcntl.ioctl(f, hidiocg(size), buf)
|
||||||
|
payload = bytes(buf[1:]) # kernel prepends report id at byte 0
|
||||||
|
blk = payload[CONFIG_LEN:CONFIG_LEN + 5 + COUNT]
|
||||||
|
if len(blk) < 5 + COUNT or blk[0] != ord('R') or blk[1] != ord('M'):
|
||||||
|
return None
|
||||||
|
ver = blk[2]
|
||||||
|
rev = blk[3] | (blk[4] << 8)
|
||||||
|
table = list(blk[5:5 + COUNT])
|
||||||
|
return ver, rev, table
|
||||||
|
|
||||||
|
|
||||||
|
def find_dongle():
|
||||||
|
for path in sorted(glob.glob('/dev/hidraw*')):
|
||||||
|
try:
|
||||||
|
f = open(path, 'rb+', buffering=0)
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if read_f7(f) is not None:
|
||||||
|
return f, path
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
f.close()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def write_table(f, table):
|
||||||
|
assert len(table) == COUNT
|
||||||
|
payload = bytes([0xF6, 0x10, ord('R'), ord('M'), PROTO_VER]) + bytes(table)
|
||||||
|
buf = bytearray(payload)
|
||||||
|
fcntl.ioctl(f, hidiocs(len(buf)), buf)
|
||||||
|
|
||||||
|
|
||||||
|
def show(label, ver, rev, table):
|
||||||
|
print(f"{label}: proto v{ver}, revision {rev}")
|
||||||
|
active = [(s, t) for s, t in enumerate(table) if t != s]
|
||||||
|
if not active:
|
||||||
|
print(" identity (no remap active)")
|
||||||
|
return
|
||||||
|
for s, t in active:
|
||||||
|
tn = "DISABLED" if t == 0xFF else NAMES[t]
|
||||||
|
print(f" {NAMES[s]:<10} -> {tn}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_button(arg):
|
||||||
|
key = arg.upper()
|
||||||
|
if key not in IDX:
|
||||||
|
sys.exit(f"unknown button '{arg}'. choices: {', '.join(NAMES)}")
|
||||||
|
return IDX[key]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(2)
|
||||||
|
cmd = sys.argv[1].lower()
|
||||||
|
|
||||||
|
f, path = find_dongle()
|
||||||
|
if f is None:
|
||||||
|
sys.exit("no DS5 dongle with remap support found "
|
||||||
|
"(check /dev/hidraw permissions and firmware version)")
|
||||||
|
print(f"dongle: {path}")
|
||||||
|
ver, rev, table = read_f7(f)
|
||||||
|
|
||||||
|
if cmd == "get":
|
||||||
|
show("current", ver, rev, table)
|
||||||
|
return
|
||||||
|
|
||||||
|
if cmd == "reset":
|
||||||
|
table = list(range(COUNT))
|
||||||
|
elif cmd == "off" and len(sys.argv) == 3:
|
||||||
|
table[parse_button(sys.argv[2])] = 0xFF
|
||||||
|
elif cmd == "set" and len(sys.argv) == 4:
|
||||||
|
table[parse_button(sys.argv[2])] = parse_button(sys.argv[3])
|
||||||
|
elif cmd == "swap" and len(sys.argv) == 4:
|
||||||
|
a, b = parse_button(sys.argv[2]), parse_button(sys.argv[3])
|
||||||
|
table[a], table[b] = b, a
|
||||||
|
else:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
show("writing", ver, rev, table)
|
||||||
|
write_table(f, table)
|
||||||
|
nver, nrev, ntable = read_f7(f)
|
||||||
|
show("read-back", nver, nrev, ntable)
|
||||||
|
if ntable == table and nrev != rev:
|
||||||
|
print("OK: table applied and revision bumped")
|
||||||
|
else:
|
||||||
|
print("WARNING: read-back mismatch or revision did not change")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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)")
|
||||||
+178
-42
@@ -13,13 +13,14 @@
|
|||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include "pico/multicore.h"
|
#include "pico/multicore.h"
|
||||||
#include "pico/util/queue.h"
|
#include "pico/util/queue.h"
|
||||||
|
#include "pico/time.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "state_mgr.h"
|
#include "state_mgr.h"
|
||||||
#include "usb.h"
|
#include "usb.h"
|
||||||
|
|
||||||
#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
|
||||||
@@ -60,8 +61,26 @@ int32_t audio_mic_last_decoded() { return g_mic_last_decoded; }
|
|||||||
uint16_t audio_mic_last_want() { return g_mic_last_want; }
|
uint16_t audio_mic_last_want() { return g_mic_last_want; }
|
||||||
uint16_t audio_mic_last_wrote() { return g_mic_last_wrote; }
|
uint16_t audio_mic_last_wrote() { return g_mic_last_wrote; }
|
||||||
|
|
||||||
|
// Mic jitter buffer + packet-loss concealment. Decoded mono frames land here
|
||||||
|
// (filled as Opus arrives, drained at a steady 10 ms playout cadence) so bursty
|
||||||
|
// BT delivery is smoothed and a dropped frame is concealed via Opus PLC instead
|
||||||
|
// of underrunning the host with a click/hole. Design ported from
|
||||||
|
// SundayMoments/DS5_Bridge (credit there). PLC keeps voice continuous on a
|
||||||
|
// lossy BT link (e.g. controller moved away, USB 3.0 RF interference).
|
||||||
|
struct mic_decoded_element { int16_t mono[MIC_FRAMES]; };
|
||||||
|
static queue_t mic_decode_fifo;
|
||||||
|
static constexpr int MIC_DECODE_DEPTH = 8; // jitter-buffer capacity (frames)
|
||||||
|
static constexpr int MIC_PLAYOUT_START = 3; // pre-buffer before playout begins
|
||||||
|
static constexpr uint64_t MIC_FRAME_US = 10000; // 10 ms per Opus frame @ 48 kHz
|
||||||
|
static constexpr uint64_t MIC_SESSION_US = 300000; // no real frame this long → stop playout
|
||||||
|
static bool mic_playout_started = false;
|
||||||
|
static uint64_t mic_next_playout_us = 0;
|
||||||
|
static uint64_t mic_last_real_us = 0;
|
||||||
|
static volatile uint32_t g_mic_plc_frames = 0; // concealed frames generated (Diag)
|
||||||
|
uint32_t audio_mic_plc_frames() { return g_mic_plc_frames; }
|
||||||
|
|
||||||
struct audio_raw_element {
|
struct audio_raw_element {
|
||||||
float data[512 * 2];
|
float data[480 * 2]; // exactly one 10 ms Opus frame (480 stereo samples)
|
||||||
};
|
};
|
||||||
|
|
||||||
void set_headset(bool state) {
|
void set_headset(bool state) {
|
||||||
@@ -77,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; }
|
||||||
|
|
||||||
@@ -114,35 +137,122 @@ void mic_add_queue(const uint8_t *data) {
|
|||||||
queue_try_add(&mic_fifo, &packet);
|
queue_try_add(&mic_fifo, &packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-assert the DS5 mic-enable (pkt[4] bit 0) so the controller streams its mic
|
||||||
|
// even when no audio is being output to it. Normally the enable only rides the
|
||||||
|
// 0x36 audio frames, which are gated on active USB audio — so without this, mic
|
||||||
|
// only works while a game plays sound. The enable is sticky (the DS5 keeps
|
||||||
|
// streaming once it starts), so we send a control-only 0x36 (enable + the
|
||||||
|
// load-bearing SetStateData sub-report + a silent haptic block, no speaker
|
||||||
|
// payload → makes no sound) at ~4 Hz ONLY until mic frames start arriving, then
|
||||||
|
// stop — minimizing BT traffic and DS5 battery. Resumes if the stream stalls.
|
||||||
|
static void mic_enable_keepalive() {
|
||||||
|
if (!bt_is_connected() || !get_config().bt_mic_enable) return;
|
||||||
|
const uint64_t now = time_us_64();
|
||||||
|
static uint32_t last_frames = 0;
|
||||||
|
static uint64_t last_frame_us = 0;
|
||||||
|
static uint64_t last_send_us = 0;
|
||||||
|
const uint32_t frames = g_mic_frames;
|
||||||
|
if (frames != last_frames) { last_frames = frames; last_frame_us = now; }
|
||||||
|
if (last_frame_us != 0 && (now - last_frame_us) < 1000000ULL) return; // streaming → sticky, no resend
|
||||||
|
if (last_send_us != 0 && (now - last_send_us) < 250000ULL) return; // throttle to ~4 Hz while arming
|
||||||
|
last_send_us = now;
|
||||||
|
|
||||||
|
uint8_t pkt[REPORT_SIZE]{};
|
||||||
|
pkt[0] = REPORT_ID;
|
||||||
|
pkt[1] = reportSeqCounter << 4;
|
||||||
|
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
|
||||||
|
pkt[2] = 0x11 | 1 << 7;
|
||||||
|
pkt[3] = 7;
|
||||||
|
pkt[4] = 0b11111111; // mic-enable (bit 0)
|
||||||
|
const auto buf_len = get_config().audio_buffer_length;
|
||||||
|
pkt[5] = pkt[6] = pkt[7] = pkt[8] = pkt[9] = buf_len;
|
||||||
|
pkt[10] = packetCounter++;
|
||||||
|
pkt[11] = 0x10 | 1 << 7; // SetStateData sub-report (load-bearing — keeps actuators alive)
|
||||||
|
pkt[12] = 63;
|
||||||
|
state_set(pkt + 13, 63);
|
||||||
|
pkt[76] = 0x12 | 1 << 7; // haptic sub-report; samples left zero = silent
|
||||||
|
pkt[77] = SAMPLE_SIZE;
|
||||||
|
// no speaker sub-report (pkt[142..] stays zero) → control-only, no audio out
|
||||||
|
bt_write(pkt, sizeof(pkt));
|
||||||
|
g_bt_packets++;
|
||||||
|
}
|
||||||
|
|
||||||
void audio_loop() {
|
void audio_loop() {
|
||||||
// Mic-in path: pull one Opus packet from the BT-side FIFO, decode to
|
// Mic-in path: pull one Opus packet from the BT-side FIFO, decode to
|
||||||
// mono PCM, duplicate to stereo (our UAC1 endpoint declares 2 channels),
|
// mono PCM, duplicate to stereo (our UAC1 endpoint declares 2 channels),
|
||||||
// push to the host via tud_audio_write. Runs once per loop iteration so
|
// push to the host via tud_audio_write. Runs once per loop iteration so
|
||||||
// it keeps up with the ~100 Hz arrival rate of mic-tagged BT frames.
|
// it keeps up with the ~100 Hz arrival rate of mic-tagged BT frames.
|
||||||
if (mic_decoder != nullptr) {
|
if (mic_decoder != nullptr) {
|
||||||
static mic_element packet{};
|
const uint64_t now = time_us_64();
|
||||||
if (queue_try_remove(&mic_fifo, &packet)) {
|
|
||||||
static int16_t mono[MIC_FRAMES];
|
// Decode stage: drain incoming Opus into the jitter buffer as fast as it
|
||||||
const int decoded = opus_decode(mic_decoder, packet.data,
|
// arrives (absorbs bursty BT delivery), up to the buffer's capacity.
|
||||||
MIC_OPUS_SIZE, mono, MIC_FRAMES, 0);
|
static mic_element pkt{};
|
||||||
g_mic_last_decoded = decoded; // observed in OLED Diag
|
while (queue_get_level(&mic_decode_fifo) < MIC_DECODE_DEPTH
|
||||||
if (decoded > 0) {
|
&& queue_try_remove(&mic_fifo, &pkt)) {
|
||||||
static int16_t stereo[MIC_FRAMES * 2];
|
static mic_decoded_element dec{};
|
||||||
for (int i = 0; i < decoded; i++) {
|
const int n = opus_decode(mic_decoder, pkt.data, MIC_OPUS_SIZE,
|
||||||
stereo[i * 2] = mono[i];
|
dec.mono, MIC_FRAMES, 0);
|
||||||
stereo[i * 2 + 1] = mono[i];
|
g_mic_last_decoded = n; // observed in OLED Diag
|
||||||
|
if (n > 0) {
|
||||||
|
queue_try_add(&mic_decode_fifo, &dec);
|
||||||
|
mic_last_real_us = now;
|
||||||
}
|
}
|
||||||
const uint16_t want = (uint16_t)(decoded * 2 * sizeof(int16_t));
|
}
|
||||||
const uint16_t wrote = tud_audio_write(stereo, want);
|
|
||||||
|
// Playout stage: emit one frame every 10 ms. Pre-buffer a few frames to
|
||||||
|
// absorb jitter, then play a real frame if buffered, else conceal with an
|
||||||
|
// Opus PLC frame during an active session (transient loss) so the host
|
||||||
|
// hears continuity instead of a hole. If real frames have been gone for a
|
||||||
|
// while (mic off/idle), stop so we don't emit comfort noise forever.
|
||||||
|
if (!mic_playout_started
|
||||||
|
&& queue_get_level(&mic_decode_fifo) >= MIC_PLAYOUT_START) {
|
||||||
|
mic_playout_started = true;
|
||||||
|
mic_next_playout_us = now;
|
||||||
|
}
|
||||||
|
if (mic_playout_started && (int64_t)(now - mic_next_playout_us) >= 0) {
|
||||||
|
static mic_decoded_element out{};
|
||||||
|
bool have = queue_try_remove(&mic_decode_fifo, &out);
|
||||||
|
if (!have) {
|
||||||
|
if (now - mic_last_real_us < MIC_SESSION_US) {
|
||||||
|
const int n = opus_decode(mic_decoder, nullptr, 0,
|
||||||
|
out.mono, MIC_FRAMES, 0); // PLC
|
||||||
|
if (n > 0) { have = true; g_mic_plc_frames++; }
|
||||||
|
} else {
|
||||||
|
mic_playout_started = false; // session ended — re-buffer next time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (have) {
|
||||||
|
static int16_t stereo[MIC_FRAMES * 2];
|
||||||
|
for (int i = 0; i < MIC_FRAMES; i++) {
|
||||||
|
stereo[i * 2] = out.mono[i];
|
||||||
|
stereo[i * 2 + 1] = out.mono[i];
|
||||||
|
}
|
||||||
|
const uint16_t want = (uint16_t)(MIC_FRAMES * 2 * sizeof(int16_t));
|
||||||
|
g_mic_last_wrote = tud_audio_write(stereo, want);
|
||||||
g_mic_last_want = want;
|
g_mic_last_want = want;
|
||||||
g_mic_last_wrote = wrote;
|
|
||||||
g_mic_frames++;
|
g_mic_frames++;
|
||||||
|
mic_next_playout_us += MIC_FRAME_US;
|
||||||
|
// Drift guard: if we've fallen many frames behind (loop stall),
|
||||||
|
// resync the cadence instead of bursting to catch up.
|
||||||
|
if ((int64_t)(now - mic_next_playout_us) > (int64_t)(4 * MIC_FRAME_US)) {
|
||||||
|
mic_next_playout_us = now + MIC_FRAME_US;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 读取 USB 音频数据
|
// 1. 读取 USB 音频数据
|
||||||
if (!tud_audio_available()) return;
|
if (!tud_audio_available()) {
|
||||||
|
// Keep the DS5 mic streaming even without output audio — but ONLY once
|
||||||
|
// the host has enumerated us (tud_mounted). Running it during the
|
||||||
|
// fresh-pair feature handshake floods BT TX and delays controller-type
|
||||||
|
// detection past the connection watchdog's timeout, which then tears the
|
||||||
|
// link down (~10-15s "shutdown" on fresh pair). After enumeration the
|
||||||
|
// handshake is done, so it's safe — and always-on mic still works.
|
||||||
|
if (tud_mounted()) mic_enable_keepalive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int16_t raw[192];
|
int16_t raw[192];
|
||||||
uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes
|
uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes
|
||||||
@@ -152,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;
|
||||||
@@ -200,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");
|
||||||
@@ -276,7 +387,10 @@ void audio_loop() {
|
|||||||
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
|
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
|
||||||
pkt[2] = 0x11 | 0 << 6 | 1 << 7;
|
pkt[2] = 0x11 | 0 << 6 | 1 << 7;
|
||||||
pkt[3] = 7;
|
pkt[3] = 7;
|
||||||
pkt[4] = 0b11111110;
|
// bit 0 = mic-enable: tells the DS5 to stream its mic over BT (awalol
|
||||||
|
// confirmed this is the key). Bits 1-7 are the pre-existing speaker/
|
||||||
|
// haptic audio-enable flags. Gated on the bt_mic_enable config toggle.
|
||||||
|
pkt[4] = get_config().bt_mic_enable ? 0b11111111 : 0b11111110;
|
||||||
const auto buf_len = get_config().audio_buffer_length;
|
const auto buf_len = get_config().audio_buffer_length;
|
||||||
pkt[5] = buf_len;
|
pkt[5] = buf_len;
|
||||||
pkt[6] = buf_len;
|
pkt[6] = buf_len;
|
||||||
@@ -293,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +462,8 @@ void audio_init() {
|
|||||||
|
|
||||||
// Mic path: queue + decoder live on core0 (audio_loop), separate from
|
// Mic path: queue + decoder live on core0 (audio_loop), separate from
|
||||||
// the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames.
|
// the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames.
|
||||||
queue_init(&mic_fifo, sizeof(mic_element), 2);
|
queue_init(&mic_fifo, sizeof(mic_element), MIC_DECODE_DEPTH); // deeper: tolerate BT bursts
|
||||||
|
queue_init(&mic_decode_fifo, sizeof(mic_decoded_element), MIC_DECODE_DEPTH); // decoded-PCM jitter buffer
|
||||||
int dec_error = 0;
|
int dec_error = 0;
|
||||||
mic_decoder = opus_decoder_create(48000, MIC_CHANNELS, &dec_error);
|
mic_decoder = opus_decoder_create(48000, MIC_CHANNELS, &dec_error);
|
||||||
if (dec_error != 0 || mic_decoder == nullptr) {
|
if (dec_error != 0 || mic_decoder == nullptr) {
|
||||||
@@ -333,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;
|
||||||
@@ -345,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ int32_t audio_mic_last_decoded(); // last opus_decode return — neg = error, 4
|
|||||||
uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write
|
uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write
|
||||||
uint16_t audio_mic_last_wrote(); // bytes TinyUSB FIFO actually accepted
|
uint16_t audio_mic_last_wrote(); // bytes TinyUSB FIFO actually accepted
|
||||||
uint8_t audio_mic_last_toc(); // first byte of last Opus packet (frame config)
|
uint8_t audio_mic_last_toc(); // first byte of last Opus packet (frame config)
|
||||||
|
uint32_t audio_mic_plc_frames(); // count of packet-loss-concealment frames generated
|
||||||
|
|
||||||
// Called from on_bt_data() in main.cpp when the DS5 sends a mic-tagged
|
// Called from on_bt_data() in main.cpp when the DS5 sends a mic-tagged
|
||||||
// 0x31 input report. Buffer must point at MIC_OPUS_SIZE (71) bytes of
|
// 0x31 input report. Buffer must point at MIC_OPUS_SIZE (71) bytes of
|
||||||
|
|||||||
+63
-3
@@ -26,6 +26,15 @@
|
|||||||
#define MTU_CONTROL 672
|
#define MTU_CONTROL 672
|
||||||
#define MTU_INTERRUPT 672
|
#define MTU_INTERRUPT 672
|
||||||
|
|
||||||
|
// Connection-attempt watchdog: if a connection commits to a device (inquiry
|
||||||
|
// found one / incoming request accepted) but doesn't reach USB-enumeration
|
||||||
|
// within this window, tear down and retry. Catches the silent stalls caused by
|
||||||
|
// USB 3.0 2.4 GHz RF interference on the CYW43 BT radio (DualSense stuck on the
|
||||||
|
// amber init lightbar, never enumerates) — see README troubleshooting. A
|
||||||
|
// healthy or slow re-pair finishes well under 6 s, so 10 s never trips a real
|
||||||
|
// connection but heals before the user reaches to replug.
|
||||||
|
#define CONNECT_WATCHDOG_TIMEOUT_US (10 * 1000 * 1000)
|
||||||
|
|
||||||
using std::unordered_map;
|
using std::unordered_map;
|
||||||
using std::vector;
|
using std::vector;
|
||||||
using std::queue;
|
using std::queue;
|
||||||
@@ -54,6 +63,12 @@ struct send_element {
|
|||||||
|
|
||||||
absolute_time_t inactive_time = 0; // 手柄长时间静默
|
absolute_time_t inactive_time = 0; // 手柄长时间静默
|
||||||
|
|
||||||
|
// Connection-attempt watchdog timestamp. 0 == not armed; armed == a connection
|
||||||
|
// attempt is in flight (committed to a device, not yet USB-enumerating). Set
|
||||||
|
// when an attempt begins, cleared the instant the controller type is identified
|
||||||
|
// (USB connects) and on every teardown. Checked by bt_connection_watchdog_tick().
|
||||||
|
static absolute_time_t connect_attempt_started = 0;
|
||||||
|
|
||||||
// Multi-slot pairing state. Modeled on zurce/DS5Dongle-OLED.
|
// Multi-slot pairing state. Modeled on zurce/DS5Dongle-OLED.
|
||||||
static int g_current_slot = 0;
|
static int g_current_slot = 0;
|
||||||
|
|
||||||
@@ -166,6 +181,35 @@ bool bt_disconnect() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called every main-loop iteration. If a connection attempt has stalled past
|
||||||
|
// the timeout, tear it down so the state machine retries instead of hanging
|
||||||
|
// (e.g. on the amber lightbar under USB 3.0 RF interference). Inert unless a
|
||||||
|
// connection attempt is in flight, so it never touches a healthy session.
|
||||||
|
void bt_connection_watchdog_tick() {
|
||||||
|
if (connect_attempt_started == 0) return; // not armed
|
||||||
|
if (absolute_time_diff_us(connect_attempt_started, get_absolute_time())
|
||||||
|
< CONNECT_WATCHDOG_TIMEOUT_US) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf("[BT] Connection watchdog: attempt stalled, recovering\n");
|
||||||
|
connect_attempt_started = 0; // disarm; the next attempt re-arms
|
||||||
|
|
||||||
|
if (acl_handle != HCI_CON_HANDLE_INVALID) {
|
||||||
|
// ACL is up but setup stalled (auth/encryption/L2CAP/feature-wait).
|
||||||
|
// Route through the proven HCI_EVENT_DISCONNECTION_COMPLETE teardown.
|
||||||
|
bt_disconnect();
|
||||||
|
} else {
|
||||||
|
// No ACL yet (stalled before/at create-connection) — reset by hand
|
||||||
|
// and kick a fresh inquiry.
|
||||||
|
device_found = false;
|
||||||
|
new_pair = false;
|
||||||
|
gap_inquiry_stop();
|
||||||
|
gap_inquiry_start(30);
|
||||||
|
gap_connectable_control(1);
|
||||||
|
update_discoverable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void bt_get_signal_strength(int8_t *rssi) {
|
void bt_get_signal_strength(int8_t *rssi) {
|
||||||
// gap_read_rssi() completes asynchronously, so this function can only
|
// gap_read_rssi() completes asynchronously, so this function can only
|
||||||
// return the last cached RSSI value. Trigger a refresh afterwards so a
|
// return the last cached RSSI value. Trigger a refresh afterwards so a
|
||||||
@@ -298,6 +342,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
if (device_found) {
|
if (device_found) {
|
||||||
printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr));
|
printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr));
|
||||||
new_pair = true;
|
new_pair = true;
|
||||||
|
connect_attempt_started = get_absolute_time(); // arm connection watchdog
|
||||||
hci_send_cmd(&hci_create_connection, current_device_addr,
|
hci_send_cmd(&hci_create_connection, current_device_addr,
|
||||||
hci_usable_acl_packet_types(), 0, 0, 0, 1);
|
hci_usable_acl_packet_types(), 0, 0, 0, 1);
|
||||||
break;
|
break;
|
||||||
@@ -317,8 +362,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
if (opcode == HCI_OPCODE_HCI_CREATE_CONNECTION && status != ERROR_CODE_SUCCESS) {
|
if (opcode == HCI_OPCODE_HCI_CREATE_CONNECTION && status != ERROR_CODE_SUCCESS) {
|
||||||
device_found = false;
|
device_found = false;
|
||||||
new_pair = false;
|
new_pair = false;
|
||||||
|
connect_attempt_started = 0; // disarm; failed before an ACL existed
|
||||||
printf("[HCI] Create connection rejected, restart inquiry\n");
|
printf("[HCI] Create connection rejected, restart inquiry\n");
|
||||||
// gap_inquiry_start(30);
|
gap_inquiry_start(30);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -350,8 +396,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
} else {
|
} else {
|
||||||
device_found = false;
|
device_found = false;
|
||||||
new_pair = false;
|
new_pair = false;
|
||||||
|
connect_attempt_started = 0; // disarm; no ACL was established
|
||||||
printf("[HCI] ACL connect failed status=0x%02X, restart inquiry\n", status);
|
printf("[HCI] ACL connect failed status=0x%02X, restart inquiry\n", status);
|
||||||
// gap_inquiry_start(30);
|
gap_inquiry_start(30);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -401,7 +448,11 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
if (status != ERROR_CODE_SUCCESS) {
|
if (status != ERROR_CODE_SUCCESS) {
|
||||||
printf("[HCI] Authentication failed, drop stored key for %s\n", bd_addr_to_str(current_device_addr));
|
printf("[HCI] Authentication failed, drop stored key for %s\n", bd_addr_to_str(current_device_addr));
|
||||||
gap_drop_link_key_for_bd_addr(current_device_addr);
|
gap_drop_link_key_for_bd_addr(current_device_addr);
|
||||||
// gap_inquiry_start(30);
|
connect_attempt_started = 0; // disarm; teardown below re-inquires
|
||||||
|
// ACL is still up — route through the clean disconnect path
|
||||||
|
// (HCI_EVENT_DISCONNECTION_COMPLETE restarts inquiry) rather
|
||||||
|
// than leaving a half-open ACL.
|
||||||
|
bt_disconnect();
|
||||||
} else {
|
} else {
|
||||||
hci_send_cmd(&hci_set_connection_encryption, handle, 1);
|
hci_send_cmd(&hci_set_connection_encryption, handle, 1);
|
||||||
}
|
}
|
||||||
@@ -438,6 +489,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
bd_addr_copy(current_device_addr, addr);
|
bd_addr_copy(current_device_addr, addr);
|
||||||
gap_inquiry_stop();
|
gap_inquiry_stop();
|
||||||
hci_send_cmd(&hci_accept_connection_request, addr, 0x01);
|
hci_send_cmd(&hci_accept_connection_request, addr, 0x01);
|
||||||
|
connect_attempt_started = get_absolute_time(); // arm watchdog (incoming path)
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -451,6 +503,7 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
|
|||||||
const uint8_t reason = hci_event_disconnection_complete_get_reason(packet);
|
const uint8_t reason = hci_event_disconnection_complete_get_reason(packet);
|
||||||
device_found = false;
|
device_found = false;
|
||||||
new_pair = false;
|
new_pair = false;
|
||||||
|
connect_attempt_started = 0; // disarm — every teardown clears here
|
||||||
acl_handle = HCI_CON_HANDLE_INVALID;
|
acl_handle = HCI_CON_HANDLE_INVALID;
|
||||||
bt_rssi = 0;
|
bt_rssi = 0;
|
||||||
hid_control_cid = 0;
|
hid_control_cid = 0;
|
||||||
@@ -508,6 +561,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
|
|||||||
printf("Connected DSE Controller\n");
|
printf("Connected DSE Controller\n");
|
||||||
check_dse = false;
|
check_dse = false;
|
||||||
is_dse = true;
|
is_dse = true;
|
||||||
|
connect_attempt_started = 0; // fully up — disarm watchdog
|
||||||
#if !ENABLE_SERIAL
|
#if !ENABLE_SERIAL
|
||||||
tud_connect();
|
tud_connect();
|
||||||
#endif
|
#endif
|
||||||
@@ -515,6 +569,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
|
|||||||
printf("Connected DS5 Controller\n");
|
printf("Connected DS5 Controller\n");
|
||||||
check_dse = false;
|
check_dse = false;
|
||||||
is_dse = false;
|
is_dse = false;
|
||||||
|
connect_attempt_started = 0; // fully up — disarm watchdog
|
||||||
#if !ENABLE_SERIAL
|
#if !ENABLE_SERIAL
|
||||||
tud_connect();
|
tud_connect();
|
||||||
#endif
|
#endif
|
||||||
@@ -693,6 +748,11 @@ vector<uint8_t> get_feature_data(uint8_t reportId, uint16_t len) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> bt_peek_feature(uint8_t reportId) {
|
||||||
|
auto it = feature_data.find(reportId);
|
||||||
|
return (it != feature_data.end()) ? it->second : std::vector<uint8_t>{};
|
||||||
|
}
|
||||||
|
|
||||||
void set_feature_data(uint8_t reportId, uint8_t *data, uint16_t len) {
|
void set_feature_data(uint8_t reportId, uint8_t *data, uint16_t len) {
|
||||||
if (hid_control_cid != 0) {
|
if (hid_control_cid != 0) {
|
||||||
uint8_t get_feature[len + 2];
|
uint8_t get_feature[len + 2];
|
||||||
|
|||||||
@@ -22,9 +22,18 @@ void bt_send_control(uint8_t *data, uint16_t len);
|
|||||||
void bt_write(const uint8_t *data, uint16_t len);
|
void bt_write(const uint8_t *data, uint16_t len);
|
||||||
void bt_get_signal_strength(int8_t *rssi);
|
void bt_get_signal_strength(int8_t *rssi);
|
||||||
std::vector<uint8_t> get_feature_data(uint8_t reportId,uint16_t len);
|
std::vector<uint8_t> get_feature_data(uint8_t reportId,uint16_t len);
|
||||||
|
// Side-effect-free read of an already-cached feature report (empty vector if it
|
||||||
|
// hasn't arrived yet). Unlike get_feature_data(), never issues an L2CAP request,
|
||||||
|
// so it is safe to poll every frame — used by the OLED IMU-calibration parse.
|
||||||
|
std::vector<uint8_t> bt_peek_feature(uint8_t reportId);
|
||||||
void init_feature();
|
void init_feature();
|
||||||
void set_feature_data(uint8_t reportId, uint8_t* data,uint16_t len);
|
void set_feature_data(uint8_t reportId, uint8_t* data,uint16_t len);
|
||||||
|
|
||||||
|
// Connection-attempt watchdog: call once per main-loop iteration. Recovers a
|
||||||
|
// stalled connection (auto re-inquiry) so a transient RF glitch — e.g. USB 3.0
|
||||||
|
// 2.4 GHz interference — doesn't hang the dongle on the amber lightbar.
|
||||||
|
void bt_connection_watchdog_tick();
|
||||||
|
|
||||||
// OLED add-on accessors.
|
// OLED add-on accessors.
|
||||||
bool bt_is_connected();
|
bool bt_is_connected();
|
||||||
void bt_get_addr(uint8_t out[6]);
|
void bt_get_addr(uint8_t out[6]);
|
||||||
|
|||||||
+48
-3
@@ -14,6 +14,7 @@
|
|||||||
#include "device/usbd.h"
|
#include "device/usbd.h"
|
||||||
#include "pico/time.h"
|
#include "pico/time.h"
|
||||||
#include "slots.h"
|
#include "slots.h"
|
||||||
|
#include "remap.h"
|
||||||
#include "hardware/clocks.h"
|
#include "hardware/clocks.h"
|
||||||
#include "hardware/adc.h"
|
#include "hardware/adc.h"
|
||||||
#include "hardware/vreg.h"
|
#include "hardware/vreg.h"
|
||||||
@@ -86,11 +87,34 @@ bool is_pico_cmd(uint8_t report_id) {
|
|||||||
uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
|
uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
|
||||||
if (report_id == 0xf7) {
|
if (report_id == 0xf7) {
|
||||||
printf("[HID] Receive 0xf7 getting config\n");
|
printf("[HID] Receive 0xf7 getting config\n");
|
||||||
if (sizeof(Config_body) > reqlen) {
|
const size_t cfg_len = sizeof(Config_body);
|
||||||
|
if (cfg_len > reqlen) {
|
||||||
printf("[Config] Warning: Config_body overflow\n");
|
printf("[Config] Warning: Config_body overflow\n");
|
||||||
}
|
}
|
||||||
const auto len = std::min(sizeof(Config_body),static_cast<size_t>(reqlen));
|
const auto len = std::min(cfg_len, static_cast<size_t>(reqlen));
|
||||||
memcpy(buffer,&get_config(),len);
|
memcpy(buffer, &get_config(), len);
|
||||||
|
|
||||||
|
// OLED Edition: append the button-remap block right after Config_body
|
||||||
|
// when the host asked for enough room. Old clients request exactly
|
||||||
|
// sizeof(Config_body) and never see it; new web tools read config +
|
||||||
|
// remap in one GET (the 0xF6/0xF7 reports are 63 bytes, plenty).
|
||||||
|
// [+0] 'R'
|
||||||
|
// [+1] 'M'
|
||||||
|
// [+2] protocol version (kRemapProtoVer)
|
||||||
|
// [+3..+4] revision uint16 LE (bumps on each successful set)
|
||||||
|
// [+5..+20] 16-byte remap table (source idx -> target idx, 0xFF=off)
|
||||||
|
constexpr size_t kRemapBlock = 5 + kRemapCount;
|
||||||
|
if (reqlen >= cfg_len + kRemapBlock) {
|
||||||
|
uint8_t *p = buffer + cfg_len;
|
||||||
|
p[0] = 'R';
|
||||||
|
p[1] = 'M';
|
||||||
|
p[2] = kRemapProtoVer;
|
||||||
|
const uint16_t rev = remap_revision();
|
||||||
|
p[3] = (uint8_t)(rev & 0xFF);
|
||||||
|
p[4] = (uint8_t)((rev >> 8) & 0xFF);
|
||||||
|
remap_get(p + 5);
|
||||||
|
return cfg_len + kRemapBlock;
|
||||||
|
}
|
||||||
return len;
|
return len;
|
||||||
}
|
}
|
||||||
if (report_id == 0xf8) {
|
if (report_id == 0xf8) {
|
||||||
@@ -268,4 +292,25 @@ void pico_cmd_set(uint8_t report_id, uint8_t const *buffer, uint16_t bufsize) {
|
|||||||
sleep_ms(150);
|
sleep_ms(150);
|
||||||
tud_connect();
|
tud_connect();
|
||||||
}
|
}
|
||||||
|
// 0x10 set button-remap table (OLED Edition). Hardened framing so a stray
|
||||||
|
// write to 0xF6 can't corrupt the map: magic 'R''M' + protocol version gate
|
||||||
|
// before remap_set() (which itself validates each entry <16 or 0xFF=off).
|
||||||
|
// [0] 0x10 func-id
|
||||||
|
// [1] 'R'
|
||||||
|
// [2] 'M'
|
||||||
|
// [3] protocol version (must == kRemapProtoVer)
|
||||||
|
// [4..19] 16-byte remap table
|
||||||
|
if (buffer[0] == 0x10) {
|
||||||
|
constexpr uint16_t kNeed = 4 + kRemapCount;
|
||||||
|
if (bufsize < kNeed) {
|
||||||
|
printf("[CMD] 0x10 remap-set too short (%u<%u)\n", bufsize, kNeed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buffer[1] != 'R' || buffer[2] != 'M' || buffer[3] != kRemapProtoVer) {
|
||||||
|
printf("[CMD] 0x10 remap-set bad magic/version\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (remap_set(buffer + 4)) printf("[CMD] remap set ok (rev=%u)\n", remap_revision());
|
||||||
|
else printf("[CMD] remap set rejected (invalid table)\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-6
@@ -67,12 +67,14 @@ void config_valid() {
|
|||||||
body->disable_pico_led = 0;
|
body->disable_pico_led = 0;
|
||||||
printf("[Config] disable_pico_led is invalid\n");
|
printf("[Config] disable_pico_led is invalid\n");
|
||||||
}
|
}
|
||||||
if (body->polling_rate_mode > 2) {
|
if (body->polling_rate_mode > 2) { // 0xFF erased / out of range → default
|
||||||
body->polling_rate_mode = 0;
|
body->polling_rate_mode = 2; // realtime / 1000 Hz — match a wired DS5's
|
||||||
printf("[Config] polling_rate_mode is invalid\n");
|
// 1 ms polling (was 0 / 250 Hz). Existing
|
||||||
|
// saved configs keep their value.
|
||||||
|
printf("[Config] polling_rate_mode invalid, defaulting to 2 (1000 Hz)\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 +86,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;
|
||||||
@@ -95,6 +97,36 @@ void config_valid() {
|
|||||||
body->auto_haptics_lowpass = 1; // 160 Hz
|
body->auto_haptics_lowpass = 1; // 160 Hz
|
||||||
printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n");
|
printf("[Config] auto_haptics_lowpass invalid, defaulting to 1 (160 Hz)\n");
|
||||||
}
|
}
|
||||||
|
if (body->lightbar_mode > 8) { // 0..7 OLED modes + 8 = HOST passthrough (default)
|
||||||
|
body->lightbar_mode = 8;
|
||||||
|
printf("[Config] lightbar_mode invalid, defaulting to 8 (HOST passthrough)\n");
|
||||||
|
}
|
||||||
|
// lb_fav_{r,g,b} need no validation — any 0..255 is a legal color, and an
|
||||||
|
// erased flash sector (0xFF) yields 4 white favorites, a usable default.
|
||||||
|
if (body->screen_dim_timeout > 250) { // 0xFF erased / out of range → default
|
||||||
|
body->screen_dim_timeout = 2; // mirrors the original 2-min dim tier
|
||||||
|
printf("[Config] screen_dim_timeout invalid, defaulting to 2 min\n");
|
||||||
|
}
|
||||||
|
if (body->screen_off_timeout > 250) {
|
||||||
|
body->screen_off_timeout = 15; // mirrors the original 15-min off tier
|
||||||
|
printf("[Config] screen_off_timeout invalid, defaulting to 15 min\n");
|
||||||
|
}
|
||||||
|
if (body->bt_mic_enable > 1) { // 0xFF erased / upgrade → default OFF
|
||||||
|
body->bt_mic_enable = 0; // opt-in: the BT mic path has a known
|
||||||
|
// 2x-playback-rate bug (#10), so it is
|
||||||
|
// off until explicitly enabled (Settings
|
||||||
|
// screen / web config). Existing saved
|
||||||
|
// configs keep their value.
|
||||||
|
printf("[Config] bt_mic_enable invalid, defaulting to 0 (off)\n");
|
||||||
|
}
|
||||||
|
if (body->screen_brightness > 3) { // kBrightLevels has 4 entries (0..3)
|
||||||
|
body->screen_brightness = 0; // full brightness
|
||||||
|
printf("[Config] screen_brightness invalid, defaulting to 0 (full)\n");
|
||||||
|
}
|
||||||
|
if (body->controller_wakes_display > 1) { // 0xFF erased / upgrade → default ON
|
||||||
|
body->controller_wakes_display = 1;
|
||||||
|
printf("[Config] controller_wakes_display invalid, defaulting to 1 (on)\n");
|
||||||
|
}
|
||||||
if (body->config_version != CONFIG_VERSION) {
|
if (body->config_version != CONFIG_VERSION) {
|
||||||
body->config_version = CONFIG_VERSION;
|
body->config_version = CONFIG_VERSION;
|
||||||
printf("[Config] Warning: Config may breaking change\n");
|
printf("[Config] Warning: Config may breaking change\n");
|
||||||
|
|||||||
@@ -23,6 +23,37 @@ struct __attribute__((packed)) Config_body {
|
|||||||
uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace
|
uint8_t auto_haptics_enable; // 0=Off, 1=Fallback (default), 2=Mix, 3=Replace
|
||||||
uint8_t auto_haptics_gain; // [0,200] percent, default 100
|
uint8_t auto_haptics_gain; // [0,200] percent, default 100
|
||||||
uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz
|
uint8_t auto_haptics_lowpass; // 0=80Hz, 1=160Hz (default), 2=250Hz, 3=400Hz
|
||||||
|
// Lightbar (OLED Edition Phase H): persisted so the chosen mode/colors
|
||||||
|
// survive reboot and stick across all screens. lightbar_mode indexes the
|
||||||
|
// OLED Lightbar screen's mode list — 0=LIVE, 1..4=FAV0..3, 5=BREATHING,
|
||||||
|
// 6=RAINBOW, 7=FADE, 8=HOST (passthrough, the safe default that lets the
|
||||||
|
// host/game own the LED). Keep this numbering in sync with kNumLbModes /
|
||||||
|
// kLbModeHost in src/oled.cpp. Erased flash (0xFF) → HOST + white favorites.
|
||||||
|
uint8_t lightbar_mode;
|
||||||
|
uint8_t lb_fav_r[4];
|
||||||
|
uint8_t lb_fav_g[4];
|
||||||
|
uint8_t lb_fav_b[4];
|
||||||
|
// OLED idle power-ladder thresholds, in minutes. 0 = that tier disabled.
|
||||||
|
// Defaults preserve the original hardcoded ladder (2 min dim, 15 min off).
|
||||||
|
// Range [0,250] (0xFF erased flash → default via config_valid clamp). The
|
||||||
|
// idle timer is 64-bit µs so the full range is representable. Issue #5.
|
||||||
|
uint8_t screen_dim_timeout;
|
||||||
|
uint8_t screen_off_timeout;
|
||||||
|
// DualSense mic over Bluetooth (Phase I). 0 = off, 1 = on (default). When on,
|
||||||
|
// the dongle asserts the DS5 mic-enable bit so the controller streams its mic
|
||||||
|
// over BT and the dongle decodes it to the USB capture endpoint. Costs extra
|
||||||
|
// DS5 battery (keeps its audio subsystem awake), hence the toggle.
|
||||||
|
uint8_t bt_mic_enable;
|
||||||
|
// OLED brightness, as an index into kBrightLevels[] (src/oled.cpp). Persisted
|
||||||
|
// so the KEY1-long-press brightness choice survives a power cycle. Erased
|
||||||
|
// flash (0xFF) → clamped to 0 (full brightness) by config_valid. Issue #9.
|
||||||
|
uint8_t screen_brightness;
|
||||||
|
// When 0, controller input no longer keeps the OLED awake — only the OLED's
|
||||||
|
// own KEY0/KEY1 do — so the dim/off timers actually count down during
|
||||||
|
// gameplay and the panel can sleep while the controller is in use. Default 1
|
||||||
|
// preserves the original "any controller activity wakes the screen"
|
||||||
|
// behavior. Issues #8 (dim timeout never fired during play) and #9.
|
||||||
|
uint8_t controller_wakes_display;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct __attribute__((packed)) Config {
|
struct __attribute__((packed)) Config {
|
||||||
|
|||||||
+43
-6
@@ -22,6 +22,7 @@
|
|||||||
#include "battery_led.h"
|
#include "battery_led.h"
|
||||||
#endif
|
#endif
|
||||||
#include "oled.h"
|
#include "oled.h"
|
||||||
|
#include "remap.h"
|
||||||
|
|
||||||
// Pico SDK speciifically for waiting on conditions
|
// Pico SDK speciifically for waiting on conditions
|
||||||
#include "pico/critical_section.h"
|
#include "pico/critical_section.h"
|
||||||
@@ -66,13 +67,21 @@ void bt_31_mic_prefix(uint8_t out[6]) {
|
|||||||
// out02_to_bt - 0x02 reports that we forwarded to the controller as
|
// out02_to_bt - 0x02 reports that we forwarded to the controller as
|
||||||
// a BT 0x31 sub-0x10 packet (gated off when speaker is
|
// a BT 0x31 sub-0x10 packet (gated off when speaker is
|
||||||
// active; audio.cpp's 0x36 path carries state then)
|
// active; audio.cpp's 0x36 path carries state then)
|
||||||
|
// out02_trig_folded - of the trig_allow reports, how many arrived while the
|
||||||
|
// speaker stream was active and were therefore NOT sent as
|
||||||
|
// a standalone 0x31 — their trigger FFB was folded into the
|
||||||
|
// 0x36 audio frames via state[]. So trig_allow == to_bt's
|
||||||
|
// trigger share + this, proving the "missing" forwards
|
||||||
|
// (issue #6) aren't drops. Surfaced on the Diag screen.
|
||||||
// Surfaced on the OLED Diagnostics screen.
|
// Surfaced on the OLED Diagnostics screen.
|
||||||
volatile uint32_t g_host_out02_total = 0;
|
volatile uint32_t g_host_out02_total = 0;
|
||||||
volatile uint32_t g_host_out02_trig_allow = 0;
|
volatile uint32_t g_host_out02_trig_allow = 0;
|
||||||
volatile uint32_t g_host_out02_to_bt = 0;
|
volatile uint32_t g_host_out02_to_bt = 0;
|
||||||
|
volatile uint32_t g_host_out02_trig_folded = 0;
|
||||||
uint32_t host_out02_total() { return g_host_out02_total; }
|
uint32_t host_out02_total() { return g_host_out02_total; }
|
||||||
uint32_t host_out02_trig_allow() { return g_host_out02_trig_allow; }
|
uint32_t host_out02_trig_allow() { return g_host_out02_trig_allow; }
|
||||||
uint32_t host_out02_to_bt() { return g_host_out02_to_bt; }
|
uint32_t host_out02_to_bt() { return g_host_out02_to_bt; }
|
||||||
|
uint32_t host_out02_trig_folded() { return g_host_out02_trig_folded; }
|
||||||
|
|
||||||
uint8_t interrupt_in_data[63] = {
|
uint8_t interrupt_in_data[63] = {
|
||||||
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
|
0x7f, 0x7d, 0x7f, 0x7e, 0x00, 0x00, 0xa7,
|
||||||
@@ -110,7 +119,12 @@ void interrupt_loop() {
|
|||||||
|
|
||||||
// TODO: Refactor for better code reuse
|
// TODO: Refactor for better code reuse
|
||||||
if (get_config().polling_rate_mode != 2) {
|
if (get_config().polling_rate_mode != 2) {
|
||||||
if (!tud_hid_report(0x01, interrupt_in_data, 63)) {
|
// Remap acts on the OUTGOING copy only — interrupt_in_data stays raw so
|
||||||
|
// the reboot combo above and every OLED screen keep seeing physical input.
|
||||||
|
uint8_t out[63];
|
||||||
|
memcpy(out, interrupt_in_data, 63);
|
||||||
|
remap_apply(out);
|
||||||
|
if (!tud_hid_report(0x01, out, 63)) {
|
||||||
printf("[USBHID] tud_hid_report error\n");
|
printf("[USBHID] tud_hid_report error\n");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -129,6 +143,9 @@ void interrupt_loop() {
|
|||||||
}
|
}
|
||||||
critical_section_exit(&report_cs);
|
critical_section_exit(&report_cs);
|
||||||
|
|
||||||
|
// Remap the snapshot, not interrupt_in_data (outgoing copy only — see above).
|
||||||
|
if (should_send) remap_apply(safe_report);
|
||||||
|
|
||||||
// Only send to TinyUSB if we actually grabbed fresh data
|
// Only send to TinyUSB if we actually grabbed fresh data
|
||||||
if (should_send) {
|
if (should_send) {
|
||||||
if (!tud_hid_report(0x01, safe_report, 63)) {
|
if (!tud_hid_report(0x01, safe_report, 63)) {
|
||||||
@@ -181,11 +198,24 @@ void on_bt_data(CHANNEL_TYPE channel, uint8_t *data, uint16_t len) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mic-add tap DISABLED — was decoding standard input (button/stick
|
// Mic-in tap (TEST): once the dongle asserts the mic-enable bit in the
|
||||||
// bytes) as Opus and producing INT16_MIN garbage on the USB IN
|
// outgoing 0x36 audio report (pkt[4] bit 0, see audio.cpp — awalol
|
||||||
// endpoint. Re-enable once we identify the actual mic transport.
|
// confirmed this is the key), the DS5 streams its mic as a 71-byte Opus
|
||||||
// (Standard input handling below resumes — Status screen + HID
|
// packet at data+4 of a 0x31 report with bit 1 of data[2] set. Route those
|
||||||
// reports to host need this.)
|
// to the mic decoder instead of treating them as a standard input report.
|
||||||
|
// The length guard (4-byte header + 71-byte Opus) keeps a stray short
|
||||||
|
// frame from over-reading. The diagnostic counters above still observe
|
||||||
|
// these frames, so the Diag screen's data[2] OR-mask will show bit 1 set
|
||||||
|
// once the enable bit takes effect.
|
||||||
|
// A mic-tagged 0x31 frame carries Opus audio at data+4, NOT a standard input
|
||||||
|
// report — so it must ALWAYS be diverted here (decoded when mic is on, dropped
|
||||||
|
// when off), never fall through to the input handler below. Letting it through
|
||||||
|
// would copy Opus bytes into interrupt_in_data and corrupt sticks/buttons.
|
||||||
|
if (channel == INTERRUPT && data[1] == 0x31 && ((data[2] >> 1) & 1)
|
||||||
|
&& len >= 75) {
|
||||||
|
if (get_config().bt_mic_enable) mic_add_queue(data + 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (channel == INTERRUPT && data[1] == 0x31) {
|
if (channel == INTERRUPT && data[1] == 0x31) {
|
||||||
if ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
|
if ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
|
||||||
@@ -274,6 +304,11 @@ void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t rep
|
|||||||
}
|
}
|
||||||
state_update(buffer + 1, bufsize - 1);
|
state_update(buffer + 1, bufsize - 1);
|
||||||
if (spk_active) {
|
if (spk_active) {
|
||||||
|
// Not forwarded as a standalone 0x31 — the trigger FFB just
|
||||||
|
// written into state[] rides the 0x36 audio frames instead.
|
||||||
|
// Count the trigger-bearing ones so the Diag screen shows
|
||||||
|
// trig_allow == to_bt(trig) + folded (issue #6: not drops).
|
||||||
|
if (bufsize > 1 && (buffer[1] & 0x0C)) g_host_out02_trig_folded++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
uint8_t outputData[78]{};
|
uint8_t outputData[78]{};
|
||||||
@@ -351,6 +386,7 @@ int main() {
|
|||||||
critical_section_init(&report_cs);
|
critical_section_init(&report_cs);
|
||||||
|
|
||||||
config_load();
|
config_load();
|
||||||
|
remap_load();
|
||||||
|
|
||||||
bt_init();
|
bt_init();
|
||||||
bt_register_data_callback(on_bt_data);
|
bt_register_data_callback(on_bt_data);
|
||||||
@@ -368,6 +404,7 @@ int main() {
|
|||||||
watchdog_update();
|
watchdog_update();
|
||||||
#endif
|
#endif
|
||||||
cyw43_arch_poll();
|
cyw43_arch_poll();
|
||||||
|
bt_connection_watchdog_tick();
|
||||||
tud_task();
|
tud_task();
|
||||||
audio_loop();
|
audio_loop();
|
||||||
interrupt_loop();
|
interrupt_loop();
|
||||||
|
|||||||
+591
-86
@@ -4,6 +4,7 @@
|
|||||||
#include "slots.h"
|
#include "slots.h"
|
||||||
#include "audio.h"
|
#include "audio.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "state_mgr.h"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -22,11 +23,18 @@ extern uint32_t bt_31_packet_count();
|
|||||||
extern uint32_t host_out02_total();
|
extern uint32_t host_out02_total();
|
||||||
extern uint32_t host_out02_trig_allow();
|
extern uint32_t host_out02_trig_allow();
|
||||||
extern uint32_t host_out02_to_bt();
|
extern uint32_t host_out02_to_bt();
|
||||||
|
extern uint32_t host_out02_trig_folded();
|
||||||
extern uint8_t bt_31_last_byte2();
|
extern uint8_t bt_31_last_byte2();
|
||||||
extern uint8_t bt_31_b2_or_mask();
|
extern uint8_t bt_31_b2_or_mask();
|
||||||
extern uint16_t bt_31_len_min();
|
extern uint16_t bt_31_len_min();
|
||||||
extern uint16_t bt_31_len_max();
|
extern uint16_t bt_31_len_max();
|
||||||
extern void bt_31_mic_prefix(uint8_t out[6]);
|
extern void bt_31_mic_prefix(uint8_t out[6]);
|
||||||
|
extern bool spk_active; // main.cpp: true while host USB speaker stream is open
|
||||||
|
|
||||||
|
// Global (not in the anon namespace below) so state_mgr.cpp can extern it:
|
||||||
|
// true while an OLED lightbar mode or the charging pulse owns the LED, which
|
||||||
|
// tells state_update() to ignore the host's AllowLedColor writes.
|
||||||
|
bool g_lightbar_override = false;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -44,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;
|
||||||
@@ -76,16 +88,18 @@ uint8_t current_contrast = 0xFF;
|
|||||||
|
|
||||||
// Auto-dim / auto-off after idle. Tracks last button/input activity.
|
// Auto-dim / auto-off after idle. Tracks last button/input activity.
|
||||||
// Tier 1: Active → full brightness (bright_idx).
|
// Tier 1: Active → full brightness (bright_idx).
|
||||||
// Tier 2: idle > kAutoDimUs → contrast drops to kDimContrast (deep dim).
|
// Tier 2: idle > dim threshold → contrast drops to kDimContrast (deep dim).
|
||||||
// Tier 3: idle > kAutoOffUs → SH1107 panel turned fully off (cmd 0xAE)
|
// Tier 3: idle > off threshold → SH1107 panel turned fully off (cmd 0xAE)
|
||||||
// to prevent OLED burn-in on long unattended sits.
|
// to prevent OLED burn-in on long unattended sits.
|
||||||
|
// The two thresholds are user-configurable (Config_body.screen_dim_timeout /
|
||||||
|
// screen_off_timeout, minutes; 0 = tier disabled) — issue #5. last_activity_us
|
||||||
|
// is 64-bit µs so the full 0..250 min range is representable without the ~71 min
|
||||||
|
// wrap of time_us_32().
|
||||||
// kDimContrast tuned by eye: 0x10 looked like only ~10% reduction on this
|
// kDimContrast tuned by eye: 0x10 looked like only ~10% reduction on this
|
||||||
// panel (contrast-vs-brightness is heavily non-linear near the bottom of
|
// panel (contrast-vs-brightness is heavily non-linear near the bottom of
|
||||||
// the register range). 0x02 is visibly dim while still legible up close.
|
// the register range). 0x02 is visibly dim while still legible up close.
|
||||||
uint32_t last_activity_us = 0;
|
uint64_t last_activity_us = 0;
|
||||||
uint32_t last_input_hash = 0;
|
uint32_t last_input_hash = 0;
|
||||||
constexpr uint32_t kAutoDimUs = 2UL * 60UL * 1000000UL; // 2 min — generous for pairing
|
|
||||||
constexpr uint32_t kAutoOffUs = 15UL * 60UL * 1000000UL; // 15 min
|
|
||||||
constexpr uint8_t kDimContrast = 0x01;
|
constexpr uint8_t kDimContrast = 0x01;
|
||||||
enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF };
|
enum OledPowerState { OLED_ACTIVE, OLED_DIM, OLED_OFF };
|
||||||
OledPowerState oled_power_state = OLED_ACTIVE;
|
OledPowerState oled_power_state = OLED_ACTIVE;
|
||||||
@@ -108,16 +122,24 @@ constexpr int kScreenSettings = 10;
|
|||||||
constexpr int kNumScreens = 11;
|
constexpr int kNumScreens = 11;
|
||||||
int current_screen = 0;
|
int current_screen = 0;
|
||||||
|
|
||||||
// Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE
|
// Lightbar mode cycle: 0=LIVE, 1-4=FAV0-3, 5=BREATHING, 6=RAINBOW, 7=FADE,
|
||||||
constexpr int kNumLbModes = 8;
|
// 8=HOST (passthrough — let the host/game own the LED). HOST is the default so
|
||||||
|
// the dongle doesn't hijack game player-indicator LEDs out of the box. Keep
|
||||||
|
// this numbering in sync with Config_body::lightbar_mode (src/config.h).
|
||||||
|
constexpr int kLbModeHost = 8;
|
||||||
|
constexpr int kNumLbModes = 9;
|
||||||
|
|
||||||
// Settings screen state
|
// Settings screen state
|
||||||
constexpr int kNumSettingsItems = 13; // 8 fields + 3 auto-haptic + 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 kSettingsResetIdx = 11;
|
constexpr int kSettingsScrDimIdx = 11;
|
||||||
constexpr int kSettingsWipeSlotsIdx = 12;
|
constexpr int kSettingsScrOffIdx = 12;
|
||||||
|
constexpr int kSettingsBtMicIdx = 13;
|
||||||
|
constexpr int kSettingsCtrlWakeIdx = 14;
|
||||||
|
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;
|
||||||
@@ -134,12 +156,16 @@ constexpr uint32_t kResetHoldUs = 2000000;
|
|||||||
|
|
||||||
uint8_t lb_r = 0, lb_g = 0, lb_b = 0;
|
uint8_t lb_r = 0, lb_g = 0, lb_b = 0;
|
||||||
|
|
||||||
// Lightbar mode + favorite slots: 0 = LIVE tilt preview; 1..4 = saved slots F0..F3
|
// Lightbar mode + favorite slots: 0 = LIVE tilt preview; 1..4 = saved slots F0..F3.
|
||||||
int lb_mode = 0;
|
// These are seeded from flash (lightbar_load_config) at boot; the defaults here
|
||||||
|
// only apply before that runs. lb_dirty tracks an unsaved mode/favorite change
|
||||||
|
// so we persist once on leaving the Lightbar screen instead of per button press.
|
||||||
|
int lb_mode = kLbModeHost;
|
||||||
uint8_t lb_fav_r[4] = {255, 0, 0, 255}; // Red, Green, Blue, White defaults
|
uint8_t lb_fav_r[4] = {255, 0, 0, 255}; // Red, Green, Blue, White defaults
|
||||||
uint8_t lb_fav_g[4] = {0, 255, 0, 255};
|
uint8_t lb_fav_g[4] = {0, 255, 0, 255};
|
||||||
uint8_t lb_fav_b[4] = {0, 0, 255, 255};
|
uint8_t lb_fav_b[4] = {0, 0, 255, 255};
|
||||||
uint8_t lb_last_face = 0;
|
uint8_t lb_last_face = 0;
|
||||||
|
bool lb_dirty = false;
|
||||||
|
|
||||||
uint32_t rumble_off_at_us = 0;
|
uint32_t rumble_off_at_us = 0;
|
||||||
bool rumble_active = false;
|
bool rumble_active = false;
|
||||||
@@ -215,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;
|
||||||
@@ -227,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) {
|
||||||
@@ -252,6 +314,16 @@ void rect_filled(int x, int y, int w, int h) {
|
|||||||
px(x + i, y + j, true);
|
px(x + i, y + j, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// XOR-invert every pixel in a region (used to flash a control "pressed").
|
||||||
|
void rect_invert(int x, int y, int w, int h) {
|
||||||
|
for (int j = 0; j < h; j++)
|
||||||
|
for (int i = 0; i < w; i++) {
|
||||||
|
const int xx = x + i, yy = y + j;
|
||||||
|
if (xx < 0 || xx >= kW || yy < 0 || yy >= kH) continue;
|
||||||
|
fb[yy * kRowBytes + (xx / 8)] ^= 1 << (7 - (xx % 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void draw_char(int x, int y, char c) {
|
void draw_char(int x, int y, char c) {
|
||||||
if (c < 0x20 || c > 0x7E) return;
|
if (c < 0x20 || c > 0x7E) return;
|
||||||
const uint8_t *g = kFont5x7[c - 0x20];
|
const uint8_t *g = kFont5x7[c - 0x20];
|
||||||
@@ -464,13 +536,13 @@ void handle_buttons() {
|
|||||||
if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) {
|
if (!k0 && key0_prev && (now - key0_t_us) > kDebounceUs) {
|
||||||
key0_t_us = now;
|
key0_t_us = now;
|
||||||
key0_armed = true;
|
key0_armed = true;
|
||||||
last_activity_us = now;
|
last_activity_us = time_us_64();
|
||||||
}
|
}
|
||||||
if (k0 && !key0_prev && key0_armed) {
|
if (k0 && !key0_prev && key0_armed) {
|
||||||
key0_armed = false;
|
key0_armed = false;
|
||||||
current_screen = (current_screen + 1) % kNumScreens;
|
current_screen = (current_screen + 1) % kNumScreens;
|
||||||
last_render_us = 0;
|
last_render_us = 0;
|
||||||
last_activity_us = now;
|
last_activity_us = time_us_64();
|
||||||
}
|
}
|
||||||
|
|
||||||
// KEY1: arm on press, fire on release. Short press = back; long press
|
// KEY1: arm on press, fire on release. Short press = back; long press
|
||||||
@@ -482,14 +554,22 @@ void handle_buttons() {
|
|||||||
key1_t_us = now;
|
key1_t_us = now;
|
||||||
key1_press_us = now;
|
key1_press_us = now;
|
||||||
key1_was_pressed = true;
|
key1_was_pressed = true;
|
||||||
last_activity_us = now;
|
last_activity_us = time_us_64();
|
||||||
}
|
}
|
||||||
if (k1 && !key1_prev && key1_was_pressed) {
|
if (k1 && !key1_prev && key1_was_pressed) {
|
||||||
key1_was_pressed = false;
|
key1_was_pressed = false;
|
||||||
const uint32_t held = now - key1_press_us;
|
const uint32_t held = now - key1_press_us;
|
||||||
last_activity_us = now;
|
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;
|
||||||
@@ -500,6 +580,149 @@ void handle_buttons() {
|
|||||||
key1_prev = k1;
|
key1_prev = k1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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 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
|
||||||
|
// the user is on another screen); render_screen reads g_charge_eta.
|
||||||
|
//
|
||||||
|
// Taper correction: Li-ion CC/CV charging slows sharply near the top, so a
|
||||||
|
// flat "time per step × steps left" runs optimistic in the last ~20%. Each
|
||||||
|
// measured step is normalised to a bulk-equivalent duration (divide out the
|
||||||
|
// step's taper weight); the remaining steps are then re-weighted. This makes
|
||||||
|
// the estimate consistent whether the user plugged in near-empty or near-full.
|
||||||
|
struct ChargeEta {
|
||||||
|
bool charging; // pstate == 1 (so the token shows only while charging)
|
||||||
|
bool valid; // minutes is meaningful (provisional or measured)
|
||||||
|
bool provisional; // true until a full step is timed — using the default rate
|
||||||
|
int minutes; // estimated minutes to 100%
|
||||||
|
};
|
||||||
|
ChargeEta g_charge_eta{};
|
||||||
|
|
||||||
|
// Default bulk-step duration used for a provisional estimate before any real
|
||||||
|
// step has been timed, so the token shows "~Nm?" immediately on plug-in instead
|
||||||
|
// of sitting on "~--m" for ~15-20 min. Tuned to an observed ~15 min per 10% on
|
||||||
|
// this dongle's charge current; it self-corrects to the measured rate (and drops
|
||||||
|
// the "?") as soon as the first clean step completes.
|
||||||
|
constexpr float kDefaultStepUs = 15.0f * 60.0f * 1000000.0f;
|
||||||
|
|
||||||
|
// Ceiling on a single timed step's bulk-equivalent duration. A genuine idle 10%
|
||||||
|
// step on this dongle is ~15 min; anything past ~30 min is almost always an
|
||||||
|
// anomalous/under-load sample (e.g. the controller in use while charging, or a
|
||||||
|
// battery-nibble bounce) that would otherwise balloon the projection — observed
|
||||||
|
// reading ~222m at 70% off one ~47-min step. We clamp such samples instead of
|
||||||
|
// trusting them, and pair that with a median over kRing steps so one bad reading
|
||||||
|
// can't dominate the estimate.
|
||||||
|
constexpr float kMaxStepUs = 30.0f * 60.0f * 1000000.0f;
|
||||||
|
|
||||||
|
// Relative time the step *ending* at `to_level` (10% units, 1..10) takes vs a
|
||||||
|
// bulk step. Tuned to the Li-ion CV taper: ~80% onward stretches out.
|
||||||
|
static float charge_step_weight(int to_level) {
|
||||||
|
if (to_level >= 10) return 2.2f; // 90→100% (constant-voltage tail)
|
||||||
|
if (to_level == 9) return 1.5f; // 80→90% (taper begins)
|
||||||
|
return 1.0f; // bulk constant-current region
|
||||||
|
}
|
||||||
|
|
||||||
|
void sample_charge_eta() {
|
||||||
|
constexpr int kRing = 5; // median over the last few steps
|
||||||
|
static float ring[kRing] = {0}; // bulk-equivalent step durations (us)
|
||||||
|
static int ring_count = 0;
|
||||||
|
static int ring_head = 0;
|
||||||
|
static int cur_step = -1; // last observed 10% step
|
||||||
|
static uint64_t step_start_us = 0;
|
||||||
|
static bool was_charging = false;
|
||||||
|
static bool first_step_pending = false; // discard the partial step at plug-in
|
||||||
|
|
||||||
|
const uint8_t pwr = interrupt_in_data[52];
|
||||||
|
int step = pwr & 0x0F;
|
||||||
|
if (step > 10) step = 10;
|
||||||
|
const uint8_t pstate = pwr >> 4;
|
||||||
|
const bool charging = bt_is_connected() && (pstate == 1);
|
||||||
|
|
||||||
|
if (!charging) {
|
||||||
|
g_charge_eta = ChargeEta{}; // clears charging/valid/minutes
|
||||||
|
ring_count = ring_head = 0;
|
||||||
|
cur_step = -1;
|
||||||
|
was_charging = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint64_t now = time_us_64();
|
||||||
|
if (!was_charging) {
|
||||||
|
// Just plugged in: start timing from here. The step in progress is
|
||||||
|
// partial, so its duration gets discarded when it completes.
|
||||||
|
cur_step = step;
|
||||||
|
step_start_us = now;
|
||||||
|
ring_count = ring_head = 0;
|
||||||
|
first_step_pending = true;
|
||||||
|
was_charging = true;
|
||||||
|
} else if (step == cur_step + 1) {
|
||||||
|
// One clean step completed. Skip the first (partial) one; otherwise
|
||||||
|
// record its bulk-equivalent duration.
|
||||||
|
const float dur = (float)(now - step_start_us);
|
||||||
|
if (first_step_pending) {
|
||||||
|
first_step_pending = false;
|
||||||
|
} else {
|
||||||
|
float be = dur / charge_step_weight(step);
|
||||||
|
if (be > kMaxStepUs) be = kMaxStepUs; // clamp under-load/anomalous outliers
|
||||||
|
ring[ring_head] = be;
|
||||||
|
ring_head = (ring_head + 1) % kRing;
|
||||||
|
if (ring_count < kRing) ring_count++;
|
||||||
|
}
|
||||||
|
cur_step = step;
|
||||||
|
step_start_us = now;
|
||||||
|
} else if (step != cur_step) {
|
||||||
|
// Multi-step jump (e.g. woke from sleep across several steps) or a
|
||||||
|
// small dip under heavy use — can't attribute timing cleanly, so just
|
||||||
|
// resync without polluting the ring.
|
||||||
|
cur_step = step;
|
||||||
|
step_start_us = now;
|
||||||
|
first_step_pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_charge_eta.charging = true;
|
||||||
|
if (cur_step < 10) {
|
||||||
|
// Use the measured rate once we have a timed step; until then fall back
|
||||||
|
// to the default rate and flag the estimate provisional (renders "?").
|
||||||
|
const bool measured = (ring_count > 0);
|
||||||
|
float bulk;
|
||||||
|
if (measured) {
|
||||||
|
// Median of the timed steps — robust to a single slow/fast outlier
|
||||||
|
// in a way the old mean wasn't (one 47-min under-load step used to
|
||||||
|
// drag the whole projection up). kRing is tiny, so insertion-sort.
|
||||||
|
float tmp[kRing];
|
||||||
|
for (int i = 0; i < ring_count; i++) tmp[i] = ring[i];
|
||||||
|
for (int i = 1; i < ring_count; i++) {
|
||||||
|
const float v = tmp[i];
|
||||||
|
int j = i - 1;
|
||||||
|
while (j >= 0 && tmp[j] > v) { tmp[j + 1] = tmp[j]; j--; }
|
||||||
|
tmp[j + 1] = v;
|
||||||
|
}
|
||||||
|
bulk = tmp[ring_count / 2];
|
||||||
|
} else {
|
||||||
|
bulk = kDefaultStepUs;
|
||||||
|
}
|
||||||
|
float rem_us = 0.0f;
|
||||||
|
for (int L = cur_step + 1; L <= 10; L++) rem_us += bulk * charge_step_weight(L);
|
||||||
|
int mins = (int)(rem_us / 60000000.0f + 0.5f);
|
||||||
|
if (mins < 0) mins = 0;
|
||||||
|
if (mins > 999) mins = 999;
|
||||||
|
g_charge_eta.valid = true;
|
||||||
|
g_charge_eta.provisional = !measured;
|
||||||
|
g_charge_eta.minutes = mins;
|
||||||
|
} else {
|
||||||
|
// cur_step == 10 → essentially full; nothing meaningful to count down.
|
||||||
|
g_charge_eta.valid = true;
|
||||||
|
g_charge_eta.provisional = false;
|
||||||
|
g_charge_eta.minutes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
__attribute__((noinline)) void render_screen() {
|
__attribute__((noinline)) void render_screen() {
|
||||||
fb_clear();
|
fb_clear();
|
||||||
|
|
||||||
@@ -520,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
|
||||||
@@ -532,17 +755,35 @@ __attribute__((noinline)) void render_screen() {
|
|||||||
draw_text(kContentX, 18, bbuf);
|
draw_text(kContentX, 18, bbuf);
|
||||||
draw_battery_icon(36, 18, pct);
|
draw_battery_icon(36, 18, pct);
|
||||||
|
|
||||||
|
// Charge ETA, right of the battery icon (icon ends at x≈90). Shown only
|
||||||
|
// while charging: "~43m?" is the provisional default-rate estimate shown
|
||||||
|
// immediately on plug-in; the "?" drops to "~43m" once a real 10% step
|
||||||
|
// has been timed and the measured rate takes over. See sample_charge_eta().
|
||||||
|
if (g_charge_eta.charging) {
|
||||||
|
char ebuf[8];
|
||||||
|
if (g_charge_eta.valid)
|
||||||
|
snprintf(ebuf, sizeof(ebuf), "~%dm%s", g_charge_eta.minutes,
|
||||||
|
g_charge_eta.provisional ? "?" : "");
|
||||||
|
else
|
||||||
|
snprintf(ebuf, sizeof(ebuf), "~--m");
|
||||||
|
draw_text(94, 18, ebuf);
|
||||||
|
}
|
||||||
|
|
||||||
// Left-half visuals are shifted right by kContentX so the < button
|
// Left-half visuals are shifted right by kContentX so the < button
|
||||||
// chrome at (x=0, y=49) doesn't paint over the live stick dot.
|
// chrome at (x=0, y=49) doesn't paint over the live stick dot.
|
||||||
rect_outline(kContentX, 30, 32, 32);
|
rect_outline(kContentX, 30, 32, 32);
|
||||||
int lx = (kContentX + 2) + (interrupt_in_data[0] * 27) / 255;
|
int lx = (kContentX + 2) + (interrupt_in_data[0] * 27) / 255;
|
||||||
int ly = 32 + (interrupt_in_data[1] * 27) / 255;
|
int ly = 32 + (interrupt_in_data[1] * 27) / 255;
|
||||||
rect_filled(lx - 1, ly - 1, 3, 3);
|
rect_filled(lx - 1, ly - 1, 3, 3);
|
||||||
|
// L3 (left stick click) — invert the whole box as a pressed indicator.
|
||||||
|
if (interrupt_in_data[8] & 0x40) rect_invert(kContentX, 30, 32, 32);
|
||||||
|
|
||||||
rect_outline(96, 30, 32, 32);
|
rect_outline(96, 30, 32, 32);
|
||||||
int rx = 98 + (interrupt_in_data[2] * 27) / 255;
|
int rx = 98 + (interrupt_in_data[2] * 27) / 255;
|
||||||
int ry = 32 + (interrupt_in_data[3] * 27) / 255;
|
int ry = 32 + (interrupt_in_data[3] * 27) / 255;
|
||||||
rect_filled(rx - 1, ry - 1, 3, 3);
|
rect_filled(rx - 1, ry - 1, 3, 3);
|
||||||
|
// R3 (right stick click) — invert the whole box.
|
||||||
|
if (interrupt_in_data[8] & 0x80) rect_invert(96, 30, 32, 32);
|
||||||
|
|
||||||
// L2/R2 analog trigger bars (vertical, fill from bottom). L2 sits
|
// L2/R2 analog trigger bars (vertical, fill from bottom). L2 sits
|
||||||
// just right of the shifted left stick box.
|
// just right of the shifted left stick box.
|
||||||
@@ -671,7 +912,7 @@ void sample_diag_rates() {
|
|||||||
|
|
||||||
// Row list ordered by relevance: always-useful at top, parked-mic-investigation
|
// Row list ordered by relevance: always-useful at top, parked-mic-investigation
|
||||||
// data at bottom. To add a row, bump kNumDiagRows and add a case.
|
// data at bottom. To add a row, bump kNumDiagRows and add a case.
|
||||||
constexpr int kNumDiagRows = 10;
|
constexpr int kNumDiagRows = 12;
|
||||||
__attribute__((noinline))
|
__attribute__((noinline))
|
||||||
void format_diag_row(int idx, char* line, size_t n) {
|
void format_diag_row(int idx, char* line, size_t n) {
|
||||||
switch (idx) {
|
switch (idx) {
|
||||||
@@ -695,23 +936,31 @@ void format_diag_row(int idx, char* line, size_t n) {
|
|||||||
(unsigned long)host_out02_to_bt());
|
(unsigned long)host_out02_to_bt());
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate);
|
// Trigger reports folded into the 0x36 audio path (speaker active),
|
||||||
|
// not sent as 0x31. trig == tx-trig-share + this → no drops (#6).
|
||||||
|
snprintf(line, n, "trig fold: %lu", (unsigned long)host_out02_trig_folded());
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate);
|
snprintf(line, n, "BT31 in: %lu/s", (unsigned long)g_diag_rates.bt31_rate);
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 6:
|
||||||
snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate);
|
snprintf(line, n, "USB aud: %lu/s", (unsigned long)g_diag_rates.usb_rate);
|
||||||
break;
|
break;
|
||||||
case 7:
|
case 7:
|
||||||
snprintf(line, n, "Mic in: %lu/s", (unsigned long)g_diag_rates.mic_rate);
|
snprintf(line, n, "BT32 out: %lu/s", (unsigned long)g_diag_rates.bt_rate);
|
||||||
break;
|
break;
|
||||||
case 8:
|
case 8:
|
||||||
|
snprintf(line, n, "Mic in: %lu/s", (unsigned long)g_diag_rates.mic_rate);
|
||||||
|
break;
|
||||||
|
case 9:
|
||||||
snprintf(line, n, "Mic dec=%ld w=%u",
|
snprintf(line, n, "Mic dec=%ld w=%u",
|
||||||
(long)audio_mic_last_decoded(),
|
(long)audio_mic_last_decoded(),
|
||||||
(unsigned)audio_mic_last_wrote());
|
(unsigned)audio_mic_last_wrote());
|
||||||
break;
|
break;
|
||||||
case 9: {
|
case 10:
|
||||||
|
snprintf(line, n, "Mic PLC: %lu", (unsigned long)audio_mic_plc_frames());
|
||||||
|
break;
|
||||||
|
case 11: {
|
||||||
uint8_t pfx[6]; bt_31_mic_prefix(pfx);
|
uint8_t pfx[6]; bt_31_mic_prefix(pfx);
|
||||||
snprintf(line, n, "%02X %02X %02X %02X %02X %02X",
|
snprintf(line, n, "%02X %02X %02X %02X %02X %02X",
|
||||||
pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]);
|
pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]);
|
||||||
@@ -855,6 +1104,85 @@ __attribute__((noinline)) void render_screen_triggers() {
|
|||||||
flush_fb();
|
flush_fb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- IMU calibration (DS5 feature report 0x05) ---------------------------
|
||||||
|
// The DualSense ships per-unit gyro/accel calibration in feature report 0x05,
|
||||||
|
// which bt.cpp already fetches and caches at connect (init_feature). Parsing it
|
||||||
|
// lets the Gyro Tilt screen and the tilt->RGB lightbar mode use bias- and
|
||||||
|
// sensitivity-corrected accel instead of raw counts, so the tilt dot recenters
|
||||||
|
// per controller. Parse + apply mirror SDL's SDL_hidapi_ps5.c (zlib-licensed)
|
||||||
|
// LoadCalibrationData/ApplyCalibrationData (credit); feature_data[0x05]'s byte
|
||||||
|
// layout matches SDL's data[] (index 0 = report id, calibration words from 1).
|
||||||
|
//
|
||||||
|
// imu_apply keeps accel in the same +-8192 == 1g count space the callers already
|
||||||
|
// scale by, so existing /8192 (gyro screen) and +-8192 (lightbar) math is
|
||||||
|
// unchanged — calibration only removes the per-axis zero offset and corrects gain.
|
||||||
|
struct ImuCal { int16_t bias; float sens; }; // 0..2 gyro P/Y/R, 3..5 accel X/Y/Z
|
||||||
|
ImuCal g_imu_cal[6];
|
||||||
|
bool g_imu_cal_valid = false; // a plausible calibration was loaded
|
||||||
|
bool g_imu_cal_tried = false; // 0x05 has been seen this connection (good or bad)
|
||||||
|
|
||||||
|
constexpr float kGyroResPerDeg = 1024.0f;
|
||||||
|
constexpr float kAccelResPerG = 8192.0f;
|
||||||
|
|
||||||
|
inline int16_t cal_ld16(const std::vector<uint8_t>& d, int i) {
|
||||||
|
return (int16_t)((uint16_t)d[i] | ((uint16_t)d[i + 1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
__attribute__((noinline))
|
||||||
|
void imu_cal_parse(const std::vector<uint8_t>& d) {
|
||||||
|
g_imu_cal_valid = false;
|
||||||
|
if (d.size() < 35) return; // SDL requires >= 35 calibration bytes
|
||||||
|
|
||||||
|
const int16_t gPB = cal_ld16(d, 1), gYB = cal_ld16(d, 3), gRB = cal_ld16(d, 5);
|
||||||
|
const int16_t gPp = cal_ld16(d, 7), gPm = cal_ld16(d, 9);
|
||||||
|
const int16_t gYp = cal_ld16(d, 11), gYm = cal_ld16(d, 13);
|
||||||
|
const int16_t gRp = cal_ld16(d, 15), gRm = cal_ld16(d, 17);
|
||||||
|
const int16_t gSp = cal_ld16(d, 19), gSm = cal_ld16(d, 21);
|
||||||
|
const int16_t aXp = cal_ld16(d, 23), aXm = cal_ld16(d, 25);
|
||||||
|
const int16_t aYp = cal_ld16(d, 27), aYm = cal_ld16(d, 29);
|
||||||
|
const int16_t aZp = cal_ld16(d, 31), aZm = cal_ld16(d, 33);
|
||||||
|
|
||||||
|
const float num = (float)(gSp + gSm) * kGyroResPerDeg;
|
||||||
|
g_imu_cal[0] = { gPB, num / (float)(gPp - gPm) };
|
||||||
|
g_imu_cal[1] = { gYB, num / (float)(gYp - gYm) };
|
||||||
|
g_imu_cal[2] = { gRB, num / (float)(gRp - gRm) };
|
||||||
|
|
||||||
|
int16_t r;
|
||||||
|
r = aXp - aXm; g_imu_cal[3] = { (int16_t)(aXp - r / 2), 2.0f * kAccelResPerG / (float)r };
|
||||||
|
r = aYp - aYm; g_imu_cal[4] = { (int16_t)(aYp - r / 2), 2.0f * kAccelResPerG / (float)r };
|
||||||
|
r = aZp - aZm; g_imu_cal[5] = { (int16_t)(aZp - r / 2), 2.0f * kAccelResPerG / (float)r };
|
||||||
|
|
||||||
|
// Sanity gate (same as SDL): a wild bias or a gain off by >50% means a bad
|
||||||
|
// factory cal or a short/garbled read — fall back to raw rather than amplify it.
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
const float divisor = (i < 3) ? 64.0f : 1.0f;
|
||||||
|
const int ab = g_imu_cal[i].bias < 0 ? -g_imu_cal[i].bias : g_imu_cal[i].bias;
|
||||||
|
float gain = 1.0f - g_imu_cal[i].sens / divisor;
|
||||||
|
if (gain < 0) gain = -gain;
|
||||||
|
if (ab > 1024 || gain > 0.5f) return; // leave g_imu_cal_valid = false
|
||||||
|
}
|
||||||
|
g_imu_cal_valid = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll once per frame from oled_loop: parse 0x05 the first time it is available
|
||||||
|
// for this controller, and reset on disconnect so the next controller re-reads.
|
||||||
|
void imu_cal_service() {
|
||||||
|
if (!bt_is_connected()) { g_imu_cal_valid = false; g_imu_cal_tried = false; return; }
|
||||||
|
if (g_imu_cal_tried) return;
|
||||||
|
auto d = bt_peek_feature(0x05);
|
||||||
|
if (d.size() < 35) return; // not arrived yet — retry next frame
|
||||||
|
imu_cal_parse(d);
|
||||||
|
g_imu_cal_tried = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// index 0..2 gyro, 3..5 accel. Returns the calibrated value in the same count
|
||||||
|
// scale the raw value used (+-8192 == 1g for accel); identity when no valid
|
||||||
|
// calibration is loaded, so behaviour matches the pre-calibration firmware.
|
||||||
|
inline int16_t imu_apply(int index, int16_t raw) {
|
||||||
|
if (!g_imu_cal_valid) return raw;
|
||||||
|
return (int16_t)((float)(raw - g_imu_cal[index].bias) * g_imu_cal[index].sens);
|
||||||
|
}
|
||||||
|
|
||||||
__attribute__((noinline)) void render_screen_gyro() {
|
__attribute__((noinline)) void render_screen_gyro() {
|
||||||
fb_clear();
|
fb_clear();
|
||||||
draw_text(kContentX, 0, "Gyro Tilt");
|
draw_text(kContentX, 0, "Gyro Tilt");
|
||||||
@@ -863,6 +1191,9 @@ __attribute__((noinline)) void render_screen_gyro() {
|
|||||||
memcpy(&ax, &interrupt_in_data[21], 2);
|
memcpy(&ax, &interrupt_in_data[21], 2);
|
||||||
memcpy(&ay, &interrupt_in_data[23], 2);
|
memcpy(&ay, &interrupt_in_data[23], 2);
|
||||||
memcpy(&az, &interrupt_in_data[25], 2);
|
memcpy(&az, &interrupt_in_data[25], 2);
|
||||||
|
ax = imu_apply(3, ax); // bias/sensitivity-corrected accel (identity if no cal)
|
||||||
|
ay = imu_apply(4, ay);
|
||||||
|
az = imu_apply(5, az);
|
||||||
char buf[16];
|
char buf[16];
|
||||||
snprintf(buf, sizeof(buf), "X%+5d", ax); draw_text(kContentX, 10, buf);
|
snprintf(buf, sizeof(buf), "X%+5d", ax); draw_text(kContentX, 10, buf);
|
||||||
snprintf(buf, sizeof(buf), "Y%+5d", ay); draw_text(50, 10, buf);
|
snprintf(buf, sizeof(buf), "Y%+5d", ay); draw_text(50, 10, buf);
|
||||||
@@ -872,8 +1203,15 @@ __attribute__((noinline)) void render_screen_gyro() {
|
|||||||
rect_outline(bx, by, bw, bh);
|
rect_outline(bx, by, bw, bh);
|
||||||
for (int x = bx + 1; x < bx + bw - 1; x++) px(x, by + bh / 2, true);
|
for (int x = bx + 1; x < bx + bw - 1; x++) px(x, by + bh / 2, true);
|
||||||
for (int y = by + 1; y < by + bh - 1; y++) px(bx + bw / 2, y, true);
|
for (int y = by + 1; y < by + bh - 1; y++) px(bx + bw / 2, y, true);
|
||||||
int dx = ((int)ax * (bw / 2 - 3)) / 8192;
|
// Plot the two axes that read ~0 when the controller lies flat: X (roll,
|
||||||
int dy = ((int)ay * (bh / 2 - 3)) / 8192;
|
// left/right) and Z (pitch, fwd/back). Gravity rests on Y when flat, so
|
||||||
|
// driving the dot from Y pegged it to the bottom edge at rest — using Z
|
||||||
|
// keeps the dot centred flat and it tracks as you tilt. (Readout above
|
||||||
|
// still shows all three raw axes.)
|
||||||
|
// Negated so the dot follows the tilt direction: tilt left -> dot left,
|
||||||
|
// tilt forward -> dot up (gravity pulls the opposite way on the axis).
|
||||||
|
int dx = -((int)ax * (bw / 2 - 3)) / 8192;
|
||||||
|
int dy = -((int)az * (bh / 2 - 3)) / 8192;
|
||||||
int cx = bx + bw / 2 + dx;
|
int cx = bx + bw / 2 + dx;
|
||||||
int cy = by + bh / 2 + dy;
|
int cy = by + bh / 2 + dy;
|
||||||
if (cx < bx + 2) cx = bx + 2;
|
if (cx < bx + 2) cx = bx + 2;
|
||||||
@@ -966,6 +1304,7 @@ const char* lb_mode_tag(int mode) {
|
|||||||
case 5: return "[BREA]";
|
case 5: return "[BREA]";
|
||||||
case 6: return "[RAIN]";
|
case 6: return "[RAIN]";
|
||||||
case 7: return "[FADE]";
|
case 7: return "[FADE]";
|
||||||
|
case 8: return "[HOST]";
|
||||||
default: return "[????]";
|
default: return "[????]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -981,6 +1320,7 @@ void lightbar_handle_input() {
|
|||||||
const bool r1_prev = (lb_last_buttons & 0x02) != 0;
|
const bool r1_prev = (lb_last_buttons & 0x02) != 0;
|
||||||
if (r1_now && !r1_prev) {
|
if (r1_now && !r1_prev) {
|
||||||
lb_mode = (lb_mode + 1) % kNumLbModes;
|
lb_mode = (lb_mode + 1) % kNumLbModes;
|
||||||
|
lb_dirty = true; // persisted on leaving the Lightbar screen
|
||||||
}
|
}
|
||||||
lb_last_buttons = btns;
|
lb_last_buttons = btns;
|
||||||
}
|
}
|
||||||
@@ -992,49 +1332,8 @@ __attribute__((noinline)) void render_screen_lightbar() {
|
|||||||
draw_text(86, 0, lb_mode_tag(lb_mode));
|
draw_text(86, 0, lb_mode_tag(lb_mode));
|
||||||
|
|
||||||
if (bt_is_connected()) {
|
if (bt_is_connected()) {
|
||||||
const uint32_t now_ms = time_us_32() / 1000;
|
// lb_r/lb_g/lb_b are computed every frame by lightbar_service() (which
|
||||||
if (lb_mode == 0) {
|
// runs ahead of this render in oled_loop), so here we only display them.
|
||||||
// LIVE: tilt -> RGB
|
|
||||||
int16_t ax, ay, az;
|
|
||||||
memcpy(&ax, &interrupt_in_data[21], 2);
|
|
||||||
memcpy(&ay, &interrupt_in_data[23], 2);
|
|
||||||
memcpy(&az, &interrupt_in_data[25], 2);
|
|
||||||
const int rr = ((int)ax + 8192) * 255 / 16384;
|
|
||||||
const int gg = ((int)ay + 8192) * 255 / 16384;
|
|
||||||
const int bb = ((int)az + 8192) * 255 / 16384;
|
|
||||||
lb_r = (uint8_t)(rr < 0 ? 0 : rr > 255 ? 255 : rr);
|
|
||||||
lb_g = (uint8_t)(gg < 0 ? 0 : gg > 255 ? 255 : gg);
|
|
||||||
lb_b = (uint8_t)(bb < 0 ? 0 : bb > 255 ? 255 : bb);
|
|
||||||
} else if (lb_mode <= 4) {
|
|
||||||
// FAV slot: fixed color
|
|
||||||
const int slot = lb_mode - 1;
|
|
||||||
lb_r = lb_fav_r[slot];
|
|
||||||
lb_g = lb_fav_g[slot];
|
|
||||||
lb_b = lb_fav_b[slot];
|
|
||||||
} else if (lb_mode == 5) {
|
|
||||||
// BREATHING: modulate FAV0 brightness with a sine wave (~3 s cycle)
|
|
||||||
const uint8_t phase = (uint8_t)(now_ms / 12);
|
|
||||||
const int s = sin_lut(phase); // -127..127
|
|
||||||
const uint16_t scale = (uint16_t)(32 + (s + 127) / 2); // 32..191
|
|
||||||
lb_r = (uint8_t)((lb_fav_r[0] * scale) / 255);
|
|
||||||
lb_g = (uint8_t)((lb_fav_g[0] * scale) / 255);
|
|
||||||
lb_b = (uint8_t)((lb_fav_b[0] * scale) / 255);
|
|
||||||
} else if (lb_mode == 6) {
|
|
||||||
// RAINBOW: hue sweep over ~6 s
|
|
||||||
const uint16_t hue = (uint16_t)((now_ms / 17) % 360);
|
|
||||||
hsv_to_rgb(hue, 255, 255, &lb_r, &lb_g, &lb_b);
|
|
||||||
} else {
|
|
||||||
// FADE between FAV slots, 2 s per slot
|
|
||||||
const uint32_t kSlotMs = 2000;
|
|
||||||
const uint32_t total = now_ms % (4 * kSlotMs);
|
|
||||||
const int slot = (int)(total / kSlotMs);
|
|
||||||
const int next = (slot + 1) & 3;
|
|
||||||
const uint16_t blend = (uint16_t)(((total - slot * kSlotMs) * 256u) / kSlotMs);
|
|
||||||
lb_r = (uint8_t)((lb_fav_r[slot] * (255 - blend) + lb_fav_r[next] * blend) / 255);
|
|
||||||
lb_g = (uint8_t)((lb_fav_g[slot] * (255 - blend) + lb_fav_g[next] * blend) / 255);
|
|
||||||
lb_b = (uint8_t)((lb_fav_b[slot] * (255 - blend) + lb_fav_b[next] * blend) / 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
char buf[16];
|
char buf[16];
|
||||||
snprintf(buf, sizeof(buf), "R:%3u", lb_r); draw_text(kContentX, 12, buf);
|
snprintf(buf, sizeof(buf), "R:%3u", lb_r); draw_text(kContentX, 12, buf);
|
||||||
snprintf(buf, sizeof(buf), "G:%3u", lb_g); draw_text(48, 12, buf);
|
snprintf(buf, sizeof(buf), "G:%3u", lb_g); draw_text(48, 12, buf);
|
||||||
@@ -1058,6 +1357,7 @@ __attribute__((noinline)) void render_screen_lightbar() {
|
|||||||
lb_fav_r[save_slot] = lb_r;
|
lb_fav_r[save_slot] = lb_r;
|
||||||
lb_fav_g[save_slot] = lb_g;
|
lb_fav_g[save_slot] = lb_g;
|
||||||
lb_fav_b[save_slot] = lb_b;
|
lb_fav_b[save_slot] = lb_b;
|
||||||
|
lb_dirty = true; // persisted on leaving the Lightbar screen
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_text(kContentX, 38, "Sv:T=0 C=1 X=2 S=3");
|
draw_text(kContentX, 38, "Sv:T=0 C=1 X=2 S=3");
|
||||||
@@ -1066,10 +1366,11 @@ __attribute__((noinline)) void render_screen_lightbar() {
|
|||||||
(lb_mode == 5) ? "Breathing FAV0" :
|
(lb_mode == 5) ? "Breathing FAV0" :
|
||||||
(lb_mode == 6) ? "Rainbow sweep" :
|
(lb_mode == 6) ? "Rainbow sweep" :
|
||||||
(lb_mode == 7) ? "Fade thru FAVs" :
|
(lb_mode == 7) ? "Fade thru FAVs" :
|
||||||
|
(lb_mode == kLbModeHost) ? "Host controls" :
|
||||||
"Locked to fav";
|
"Locked to fav";
|
||||||
draw_text(kContentX, 48, hint);
|
draw_text(kContentX, 48, hint);
|
||||||
|
// No send here: lightbar_service() owns pushing the color to the
|
||||||
send_lightbar_color(lb_r, lb_g, lb_b);
|
// controller every frame, on this screen and every other.
|
||||||
} else {
|
} else {
|
||||||
draw_text(kContentX, 30, "(no controller)");
|
draw_text(kContentX, 30, "(no controller)");
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1378,127 @@ __attribute__((noinline)) void render_screen_lightbar() {
|
|||||||
flush_fb();
|
flush_fb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute lb_r/lb_g/lb_b for an OLED lightbar mode (0..7). HOST (8) is handled
|
||||||
|
// by the caller (no firmware color). noinline keeps the float/HSV literals out
|
||||||
|
// of lightbar_service's / oled_loop's literal pool (same Thumb reach constraint
|
||||||
|
// the render_screen_* functions hit).
|
||||||
|
__attribute__((noinline))
|
||||||
|
void lightbar_compute_mode(int mode, uint32_t now_ms) {
|
||||||
|
if (mode == 0) {
|
||||||
|
// LIVE: tilt -> RGB
|
||||||
|
int16_t ax, ay, az;
|
||||||
|
memcpy(&ax, &interrupt_in_data[21], 2);
|
||||||
|
memcpy(&ay, &interrupt_in_data[23], 2);
|
||||||
|
memcpy(&az, &interrupt_in_data[25], 2);
|
||||||
|
ax = imu_apply(3, ax); // calibrated accel keeps the +-8192 == 1g scale below
|
||||||
|
ay = imu_apply(4, ay);
|
||||||
|
az = imu_apply(5, az);
|
||||||
|
const int rr = ((int)ax + 8192) * 255 / 16384;
|
||||||
|
const int gg = ((int)ay + 8192) * 255 / 16384;
|
||||||
|
const int bb = ((int)az + 8192) * 255 / 16384;
|
||||||
|
lb_r = (uint8_t)(rr < 0 ? 0 : rr > 255 ? 255 : rr);
|
||||||
|
lb_g = (uint8_t)(gg < 0 ? 0 : gg > 255 ? 255 : gg);
|
||||||
|
lb_b = (uint8_t)(bb < 0 ? 0 : bb > 255 ? 255 : bb);
|
||||||
|
} else if (mode <= 4) {
|
||||||
|
// FAV slot: fixed color
|
||||||
|
const int slot = mode - 1;
|
||||||
|
lb_r = lb_fav_r[slot];
|
||||||
|
lb_g = lb_fav_g[slot];
|
||||||
|
lb_b = lb_fav_b[slot];
|
||||||
|
} else if (mode == 5) {
|
||||||
|
// BREATHING: modulate FAV0 brightness with a sine wave (~3 s cycle)
|
||||||
|
const uint8_t phase = (uint8_t)(now_ms / 12);
|
||||||
|
const int s = sin_lut(phase); // -127..127
|
||||||
|
const uint16_t scale = (uint16_t)(32 + (s + 127) / 2); // 32..191
|
||||||
|
lb_r = (uint8_t)((lb_fav_r[0] * scale) / 255);
|
||||||
|
lb_g = (uint8_t)((lb_fav_g[0] * scale) / 255);
|
||||||
|
lb_b = (uint8_t)((lb_fav_b[0] * scale) / 255);
|
||||||
|
} else if (mode == 6) {
|
||||||
|
// RAINBOW: hue sweep over ~6 s
|
||||||
|
const uint16_t hue = (uint16_t)((now_ms / 17) % 360);
|
||||||
|
hsv_to_rgb(hue, 255, 255, &lb_r, &lb_g, &lb_b);
|
||||||
|
} else {
|
||||||
|
// FADE between FAV slots, 2 s per slot
|
||||||
|
const uint32_t kSlotMs = 2000;
|
||||||
|
const uint32_t total = now_ms % (4 * kSlotMs);
|
||||||
|
const int slot = (int)(total / kSlotMs);
|
||||||
|
const int next = (slot + 1) & 3;
|
||||||
|
const uint16_t blend = (uint16_t)(((total - slot * kSlotMs) * 256u) / kSlotMs);
|
||||||
|
lb_r = (uint8_t)((lb_fav_r[slot] * (255 - blend) + lb_fav_r[next] * blend) / 255);
|
||||||
|
lb_g = (uint8_t)((lb_fav_g[slot] * (255 - blend) + lb_fav_g[next] * blend) / 255);
|
||||||
|
lb_b = (uint8_t)((lb_fav_b[slot] * (255 - blend) + lb_fav_b[next] * blend) / 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single owner of the controller LED. Runs every frame (~10 Hz) from
|
||||||
|
// oled_loop, on every screen, so a chosen mode "sticks" everywhere instead of
|
||||||
|
// only while the Lightbar screen renders. Priority:
|
||||||
|
// 1. Charging -> amber-orange breathing pulse (status indicator).
|
||||||
|
// 2. lb_mode != HOST -> the selected OLED mode/color.
|
||||||
|
// 3. HOST (or disconnected) -> hand the LED back to the host/game.
|
||||||
|
// When the firmware owns the LED it (a) writes state[] so the color rides every
|
||||||
|
// host/audio packet and (b) actively pushes it via send_lightbar_color so it
|
||||||
|
// updates even when the host is idle and animations keep moving. g_lightbar_
|
||||||
|
// override gates state_update() so host AllowLedColor writes can't stomp us.
|
||||||
|
__attribute__((noinline))
|
||||||
|
void lightbar_service() {
|
||||||
|
if (!bt_is_connected()) { g_lightbar_override = false; return; }
|
||||||
|
const uint32_t now_ms = time_us_32() / 1000;
|
||||||
|
|
||||||
|
if (g_charge_eta.charging) {
|
||||||
|
// ~4.6 s breathing cycle (256 phase steps × 18 ms). Base amber
|
||||||
|
// (255,100,0) sine-enveloped from dim (24) to bright (240).
|
||||||
|
const uint8_t phase = (uint8_t)(now_ms / 18);
|
||||||
|
const int s = sin_lut(phase); // -127..127
|
||||||
|
const uint16_t scale = (uint16_t)(24 + ((s + 127) * 216) / 254); // 24..240
|
||||||
|
lb_r = (uint8_t)((255u * scale) / 255u);
|
||||||
|
lb_g = (uint8_t)((100u * scale) / 255u);
|
||||||
|
lb_b = 0;
|
||||||
|
} else if (lb_mode == kLbModeHost) {
|
||||||
|
// Reflect the host's current LED on the OLED bars, then stand down.
|
||||||
|
state_get_led(&lb_r, &lb_g, &lb_b);
|
||||||
|
g_lightbar_override = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
lightbar_compute_mode(lb_mode, now_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_lightbar_override = true;
|
||||||
|
state_set_led(lb_r, lb_g, lb_b); // ride every host/audio frame
|
||||||
|
if (!spk_active) {
|
||||||
|
// Active push so the LED updates when the host is idle and animations
|
||||||
|
// keep moving. Skipped during audio: the 0x36 frames already carry
|
||||||
|
// state[]'s LED at audio rate, and slipping a 0x31 between them would
|
||||||
|
// intrude on the load-bearing audio/haptic packet cadence.
|
||||||
|
send_lightbar_color(lb_r, lb_g, lb_b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void lightbar_load_config() {
|
||||||
|
const Config_body& c = get_config();
|
||||||
|
lb_mode = c.lightbar_mode;
|
||||||
|
if (lb_mode < 0 || lb_mode >= kNumLbModes) lb_mode = kLbModeHost;
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
lb_fav_r[i] = c.lb_fav_r[i];
|
||||||
|
lb_fav_g[i] = c.lb_fav_g[i];
|
||||||
|
lb_fav_b[i] = c.lb_fav_b[i];
|
||||||
|
}
|
||||||
|
lb_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void lightbar_save_config() {
|
||||||
|
Config_body b = get_config();
|
||||||
|
b.lightbar_mode = (uint8_t)lb_mode;
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
b.lb_fav_r[i] = lb_fav_r[i];
|
||||||
|
b.lb_fav_g[i] = lb_fav_g[i];
|
||||||
|
b.lb_fav_b[i] = lb_fav_b[i];
|
||||||
|
}
|
||||||
|
set_config(b);
|
||||||
|
config_save();
|
||||||
|
lb_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
__attribute__((noinline)) void render_screen_vu() {
|
__attribute__((noinline)) void render_screen_vu() {
|
||||||
fb_clear();
|
fb_clear();
|
||||||
draw_text(kContentX, 0, "Audio Meters");
|
draw_text(kContentX, 0, "Audio Meters");
|
||||||
@@ -1163,6 +1585,20 @@ void settings_adjust(int delta) {
|
|||||||
c.auto_haptics_lowpass = (uint8_t)v;
|
c.auto_haptics_lowpass = (uint8_t)v;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 11: { // screen_dim_timeout [0,250] min, 0 = disabled
|
||||||
|
int v = (int)c.screen_dim_timeout + delta;
|
||||||
|
if (v < 0) v = 0; if (v > 250) v = 250;
|
||||||
|
c.screen_dim_timeout = (uint8_t)v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 12: { // screen_off_timeout [0,250] min, 0 = disabled
|
||||||
|
int v = (int)c.screen_off_timeout + delta;
|
||||||
|
if (v < 0) v = 0; if (v > 250) v = 250;
|
||||||
|
c.screen_off_timeout = (uint8_t)v;
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,6 +1633,7 @@ void settings_handle_input() {
|
|||||||
config_default();
|
config_default();
|
||||||
if (config_save()) {
|
if (config_save()) {
|
||||||
settings_local = get_config();
|
settings_local = get_config();
|
||||||
|
lightbar_load_config(); // refresh RAM lightbar state (no reboot here)
|
||||||
settings_dirty = false;
|
settings_dirty = false;
|
||||||
settings_save_status = "Reset!";
|
settings_save_status = "Reset!";
|
||||||
} else {
|
} else {
|
||||||
@@ -1255,8 +1692,18 @@ __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t
|
|||||||
snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]);
|
snprintf(line, n, "%s AH LP %s", cur, names[c.auto_haptics_lowpass & 3]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 11: snprintf(line, n, "%s Reset to defaults", cur); break;
|
case 11:
|
||||||
case 12: snprintf(line, n, "%s Wipe all slots", cur); break;
|
if (c.screen_dim_timeout == 0) snprintf(line, n, "%s ScrDim off", cur);
|
||||||
|
else snprintf(line, n, "%s ScrDim %umin", cur, c.screen_dim_timeout);
|
||||||
|
break;
|
||||||
|
case 12:
|
||||||
|
if (c.screen_off_timeout == 0) snprintf(line, n, "%s ScrOff off", cur);
|
||||||
|
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 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1402,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);
|
||||||
@@ -1424,6 +1873,15 @@ void oled_init() {
|
|||||||
sh1107_init();
|
sh1107_init();
|
||||||
fb_clear();
|
fb_clear();
|
||||||
boot_splash();
|
boot_splash();
|
||||||
|
|
||||||
|
// 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
|
// Dim-tier renderer: blank the panel and draw a tiny "I'm alive" dot that
|
||||||
@@ -1460,24 +1918,63 @@ 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;
|
||||||
// Bump activity on controller input changes (cheap rolling hash over input bytes)
|
// Track charge progress every frame — before the power-ladder early-returns
|
||||||
|
// below, so step timing stays correct even while the panel is dimmed/off.
|
||||||
|
sample_charge_eta();
|
||||||
|
// Parse the DS5's per-unit IMU calibration once it lands (no-op until then),
|
||||||
|
// so the tilt screen + tilt->RGB lightbar use corrected accel. See imu_apply().
|
||||||
|
imu_cal_service();
|
||||||
|
// Drive the controller LED every frame (any screen / power state): charging
|
||||||
|
// pulse, selected OLED mode, or hand-off to the host. See lightbar_service().
|
||||||
|
lightbar_service();
|
||||||
|
// Bump activity on controller input changes (cheap rolling hash over input
|
||||||
|
// bytes). Mirror bt.cpp's inactivity heuristic so resting-controller noise
|
||||||
|
// doesn't read as activity: the analog sticks (idata[0..3]) jitter by ±1 LSB
|
||||||
|
// at rest, so collapse their rest band [120,140] to a constant, and skip
|
||||||
|
// idata[6] (the volatile counter byte bt.cpp's idle check also ignores).
|
||||||
|
// Without this the dot/dim tier never engages while a controller is
|
||||||
|
// connected, because a stick flicker resets the idle timer every few frames.
|
||||||
uint32_t hash = 0;
|
uint32_t hash = 0;
|
||||||
for (int i = 0; i < 10; i++) hash = hash * 31u + interrupt_in_data[i];
|
for (int i = 0; i < 10; i++) {
|
||||||
|
if (i == 6) continue;
|
||||||
|
uint8_t b = interrupt_in_data[i];
|
||||||
|
if (i < 4 && b >= 120 && b <= 140) b = 128; // stick deadzone
|
||||||
|
hash = hash * 31u + b;
|
||||||
|
}
|
||||||
if (hash != last_input_hash) {
|
if (hash != last_input_hash) {
|
||||||
last_input_hash = hash;
|
last_input_hash = hash;
|
||||||
last_activity_us = now;
|
// 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.
|
||||||
const bool bt_connected_now = bt_is_connected();
|
const bool bt_connected_now = bt_is_connected();
|
||||||
if (bt_connected_now && !prev_bt_connected) last_activity_us = now;
|
if (bt_connected_now && !prev_bt_connected) last_activity_us = time_us_64();
|
||||||
prev_bt_connected = bt_connected_now;
|
prev_bt_connected = bt_connected_now;
|
||||||
|
|
||||||
// Power-state ladder: Active → Dim (breathing dot) → Off based on idle time.
|
// Power-state ladder: Active → Dim (breathing dot) → Off based on idle time.
|
||||||
const uint32_t idle = now - last_activity_us;
|
// Thresholds are user-configurable (minutes; 0 = that tier disabled) — #5.
|
||||||
if (idle > kAutoOffUs) {
|
// While charging we cap the ladder at Dim — the panel keeps doing the
|
||||||
|
// low-power breathing dot but never fully sleeps. This stops the user from
|
||||||
|
// unplugging the controller just to "wake" the dongle (which would reset the
|
||||||
|
// charge-ETA calibration). The dot tier already draws ~no current, so this
|
||||||
|
// costs little; sample_charge_eta() runs before this block regardless.
|
||||||
|
const uint64_t idle = time_us_64() - last_activity_us;
|
||||||
|
const uint64_t dim_us = (uint64_t)get_config().screen_dim_timeout * 60ULL * 1000000ULL;
|
||||||
|
const uint64_t off_us = (uint64_t)get_config().screen_off_timeout * 60ULL * 1000000ULL;
|
||||||
|
const bool off_enabled = get_config().screen_off_timeout != 0;
|
||||||
|
const bool dim_enabled = get_config().screen_dim_timeout != 0;
|
||||||
|
if (off_enabled && idle > off_us && !g_charge_eta.charging) {
|
||||||
if (oled_power_state != OLED_OFF) {
|
if (oled_power_state != OLED_OFF) {
|
||||||
cmd(0xAE);
|
cmd(0xAE);
|
||||||
oled_power_state = OLED_OFF;
|
oled_power_state = OLED_OFF;
|
||||||
@@ -1485,10 +1982,10 @@ void oled_loop() {
|
|||||||
return; // panel is off, nothing to draw
|
return; // panel is off, nothing to draw
|
||||||
}
|
}
|
||||||
if (oled_power_state == OLED_OFF) cmd(0xAF); // wake panel before drawing
|
if (oled_power_state == OLED_OFF) cmd(0xAF); // wake panel before drawing
|
||||||
if (idle > kAutoDimUs) {
|
if (dim_enabled && idle > dim_us) {
|
||||||
sh1107_set_contrast(kDimContrast);
|
sh1107_set_contrast(kDimContrast);
|
||||||
oled_power_state = OLED_DIM;
|
oled_power_state = OLED_DIM;
|
||||||
render_dim_pulse(idle - kAutoDimUs);
|
render_dim_pulse((uint32_t)(idle - dim_us));
|
||||||
return; // skip the regular per-screen render path
|
return; // skip the regular per-screen render path
|
||||||
}
|
}
|
||||||
sh1107_set_contrast(kBrightLevels[bright_idx]);
|
sh1107_set_contrast(kBrightLevels[bright_idx]);
|
||||||
@@ -1511,6 +2008,14 @@ void oled_loop() {
|
|||||||
send_trigger_effect(0);
|
send_trigger_effect(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leaving the Lightbar screen → persist mode/favorite changes made there,
|
||||||
|
// batched into a single flash write instead of one per button press.
|
||||||
|
if (last_rendered_screen == kScreenLightbar
|
||||||
|
&& current_screen != kScreenLightbar
|
||||||
|
&& lb_dirty) {
|
||||||
|
lightbar_save_config();
|
||||||
|
}
|
||||||
|
|
||||||
last_rendered_screen = current_screen;
|
last_rendered_screen = current_screen;
|
||||||
|
|
||||||
switch (current_screen) {
|
switch (current_screen) {
|
||||||
|
|||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
// Button remapping. See remap.h. Flash-sector pattern mirrors slots.cpp;
|
||||||
|
// the apply logic + button set are ported from SundayMoments/DS5_Bridge.
|
||||||
|
|
||||||
|
#include "remap.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "hardware/flash.h"
|
||||||
|
#include "hardware/sync.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Source/target button indices. Order is arbitrary but MUST match the web
|
||||||
|
// editor's expectation (DS5Dongle-OLED-Config-Web src/protocol/remap.ts).
|
||||||
|
enum RemapButton : uint8_t {
|
||||||
|
RemapL2, RemapL1, RemapCreate, RemapDpadUp, RemapDpadLeft, RemapDpadDown,
|
||||||
|
RemapDpadRight, RemapL3, RemapR2, RemapR1, RemapOptions, RemapTriangle,
|
||||||
|
RemapCircle, RemapCross, RemapSquare, RemapR3, RemapButtonCount,
|
||||||
|
};
|
||||||
|
static_assert(RemapButtonCount == kRemapCount, "kRemapCount must match RemapButton");
|
||||||
|
|
||||||
|
// interrupt_in_data[8]: shoulders / sticks / system bits.
|
||||||
|
constexpr uint8_t kL1Bit = 0x01, kR1Bit = 0x02, kL2Bit = 0x04, kR2Bit = 0x08,
|
||||||
|
kCreateBit = 0x10, kOptionsBit = 0x20, kL3Bit = 0x40, kR3Bit = 0x80;
|
||||||
|
// interrupt_in_data[7]: D-pad hat (low nibble) + face buttons (high nibble).
|
||||||
|
constexpr uint8_t kSquareBit = 0x10, kCrossBit = 0x20, kCircleBit = 0x40,
|
||||||
|
kTriangleBit = 0x80, kDpadMask = 0x0F;
|
||||||
|
// D-pad hat values.
|
||||||
|
constexpr uint8_t kUp = 0, kUpRight = 1, kRight = 2, kDownRight = 3, kDown = 4,
|
||||||
|
kDownLeft = 5, kLeft = 6, kUpLeft = 7, kNeutral = 8;
|
||||||
|
|
||||||
|
constexpr uint32_t REMAP_MAGIC = 0x44533503u; // "DS5\x03"
|
||||||
|
constexpr uint32_t REMAP_FLASH_OFFSET = PICO_FLASH_SIZE_BYTES - 3u * FLASH_SECTOR_SIZE;
|
||||||
|
|
||||||
|
struct __attribute__((packed)) RemapData {
|
||||||
|
uint32_t magic;
|
||||||
|
uint8_t table[kRemapCount];
|
||||||
|
};
|
||||||
|
static_assert(sizeof(RemapData) <= FLASH_PAGE_SIZE);
|
||||||
|
static_assert(REMAP_FLASH_OFFSET % FLASH_SECTOR_SIZE == 0);
|
||||||
|
|
||||||
|
RemapData g_remap{};
|
||||||
|
bool g_active = false; // false = identity → remap_apply() fast-returns
|
||||||
|
uint16_t g_revision = 0;
|
||||||
|
|
||||||
|
const RemapData *flash_remap() {
|
||||||
|
return reinterpret_cast<const RemapData *>(XIP_BASE + REMAP_FLASH_OFFSET);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_identity() {
|
||||||
|
for (int i = 0; i < kRemapCount; i++) g_remap.table[i] = (uint8_t) i;
|
||||||
|
}
|
||||||
|
|
||||||
|
void recompute_active() {
|
||||||
|
g_active = false;
|
||||||
|
for (int i = 0; i < kRemapCount; i++) {
|
||||||
|
if (g_remap.table[i] != i) { g_active = true; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid_table(const uint8_t *t) {
|
||||||
|
for (int i = 0; i < kRemapCount; i++) {
|
||||||
|
if (t[i] >= kRemapCount && t[i] != 0xFF) return false; // <16 or disabled
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool save_to_flash() {
|
||||||
|
alignas(4) uint8_t page[FLASH_PAGE_SIZE];
|
||||||
|
memset(page, 0xff, sizeof(page));
|
||||||
|
memcpy(page, &g_remap, sizeof(g_remap));
|
||||||
|
|
||||||
|
const uint32_t interrupts = save_and_disable_interrupts();
|
||||||
|
flash_range_erase(REMAP_FLASH_OFFSET, FLASH_SECTOR_SIZE);
|
||||||
|
flash_range_program(REMAP_FLASH_OFFSET, page, sizeof(page));
|
||||||
|
restore_interrupts(interrupts);
|
||||||
|
|
||||||
|
RemapData verify{};
|
||||||
|
memcpy(&verify, flash_remap(), sizeof(verify));
|
||||||
|
if (memcmp(&verify, &g_remap, sizeof(g_remap)) == 0) {
|
||||||
|
printf("[Remap] flash write verified\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
printf("[Remap] flash write VERIFY FAILED\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dpad_has(uint8_t dir, RemapButton b) {
|
||||||
|
switch (b) {
|
||||||
|
case RemapDpadUp: return dir == kUp || dir == kUpRight || dir == kUpLeft;
|
||||||
|
case RemapDpadRight: return dir == kRight || dir == kUpRight || dir == kDownRight;
|
||||||
|
case RemapDpadDown: return dir == kDown || dir == kDownRight || dir == kDownLeft;
|
||||||
|
case RemapDpadLeft: return dir == kLeft || dir == kUpLeft || dir == kDownLeft;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t dpad_from(bool up, bool right, bool down, bool left) {
|
||||||
|
if (up && right && !down && !left) return kUpRight;
|
||||||
|
if (right && down && !up && !left) return kDownRight;
|
||||||
|
if (down && left && !up && !right) return kDownLeft;
|
||||||
|
if (left && up && !right && !down) return kUpLeft;
|
||||||
|
if (up && !down) return kUp;
|
||||||
|
if (right && !left) return kRight;
|
||||||
|
if (down && !up) return kDown;
|
||||||
|
if (left && !right) return kLeft;
|
||||||
|
return kNeutral;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void remap_load() {
|
||||||
|
memcpy(&g_remap, flash_remap(), sizeof(g_remap));
|
||||||
|
if (g_remap.magic != REMAP_MAGIC || !valid_table(g_remap.table)) {
|
||||||
|
printf("[Remap] flash sector empty/invalid, defaulting to identity\n");
|
||||||
|
g_remap.magic = REMAP_MAGIC;
|
||||||
|
set_identity();
|
||||||
|
save_to_flash();
|
||||||
|
}
|
||||||
|
recompute_active();
|
||||||
|
printf("[Remap] loaded (active=%d)\n", g_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
void remap_get(uint8_t out[kRemapCount]) { memcpy(out, g_remap.table, kRemapCount); }
|
||||||
|
|
||||||
|
uint16_t remap_revision() { return g_revision; }
|
||||||
|
|
||||||
|
bool remap_set(const uint8_t *table) {
|
||||||
|
if (!valid_table(table)) return false;
|
||||||
|
memcpy(g_remap.table, table, kRemapCount);
|
||||||
|
g_remap.magic = REMAP_MAGIC;
|
||||||
|
recompute_active();
|
||||||
|
g_revision++;
|
||||||
|
return save_to_flash();
|
||||||
|
}
|
||||||
|
|
||||||
|
void remap_apply(uint8_t *report) {
|
||||||
|
if (!g_active) return; // identity → no-op (hot-path fast return)
|
||||||
|
|
||||||
|
bool src[kRemapCount]{};
|
||||||
|
uint8_t src_analog[kRemapCount]{};
|
||||||
|
const uint8_t dir = report[7] & kDpadMask;
|
||||||
|
|
||||||
|
src[RemapL2] = (report[8] & kL2Bit) != 0;
|
||||||
|
src[RemapL1] = (report[8] & kL1Bit) != 0;
|
||||||
|
src[RemapCreate] = (report[8] & kCreateBit) != 0;
|
||||||
|
src[RemapDpadUp] = dpad_has(dir, RemapDpadUp);
|
||||||
|
src[RemapDpadLeft] = dpad_has(dir, RemapDpadLeft);
|
||||||
|
src[RemapDpadDown] = dpad_has(dir, RemapDpadDown);
|
||||||
|
src[RemapDpadRight] = dpad_has(dir, RemapDpadRight);
|
||||||
|
src[RemapL3] = (report[8] & kL3Bit) != 0;
|
||||||
|
src[RemapR2] = (report[8] & kR2Bit) != 0;
|
||||||
|
src[RemapR1] = (report[8] & kR1Bit) != 0;
|
||||||
|
src[RemapOptions] = (report[8] & kOptionsBit) != 0;
|
||||||
|
src[RemapTriangle] = (report[7] & kTriangleBit) != 0;
|
||||||
|
src[RemapCircle] = (report[7] & kCircleBit) != 0;
|
||||||
|
src[RemapCross] = (report[7] & kCrossBit) != 0;
|
||||||
|
src[RemapSquare] = (report[7] & kSquareBit) != 0;
|
||||||
|
src[RemapR3] = (report[8] & kR3Bit) != 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < kRemapCount; i++) src_analog[i] = src[i] ? 0xFF : 0;
|
||||||
|
src_analog[RemapL2] = report[4]; // L2 analog
|
||||||
|
src_analog[RemapR2] = report[5]; // R2 analog
|
||||||
|
|
||||||
|
bool tgt[kRemapCount]{};
|
||||||
|
uint8_t tgt_analog[kRemapCount]{};
|
||||||
|
for (int s = 0; s < kRemapCount; s++) {
|
||||||
|
const uint8_t t = g_remap.table[s];
|
||||||
|
if (t >= kRemapCount) continue; // 0xFF = disabled (source produces nothing)
|
||||||
|
if (src[s]) tgt[t] = true;
|
||||||
|
tgt_analog[t] = std::max(tgt_analog[t], src_analog[s]); // OR digital / max analog
|
||||||
|
}
|
||||||
|
|
||||||
|
report[4] = tgt_analog[RemapL2];
|
||||||
|
report[5] = tgt_analog[RemapR2];
|
||||||
|
|
||||||
|
// Byte 7 is entirely D-pad + face (all 8 bits) — rebuild it.
|
||||||
|
report[7] = dpad_from(tgt[RemapDpadUp], tgt[RemapDpadRight],
|
||||||
|
tgt[RemapDpadDown], tgt[RemapDpadLeft]);
|
||||||
|
if (tgt[RemapSquare]) report[7] |= kSquareBit;
|
||||||
|
if (tgt[RemapCross]) report[7] |= kCrossBit;
|
||||||
|
if (tgt[RemapCircle]) report[7] |= kCircleBit;
|
||||||
|
if (tgt[RemapTriangle]) report[7] |= kTriangleBit;
|
||||||
|
|
||||||
|
// Byte 8 is entirely shoulders/sticks/Create/Options (all 8 bits) — rebuild it.
|
||||||
|
report[8] = 0;
|
||||||
|
if (tgt[RemapL1]) report[8] |= kL1Bit;
|
||||||
|
if (tgt[RemapR1]) report[8] |= kR1Bit;
|
||||||
|
if (tgt[RemapL2]) report[8] |= kL2Bit;
|
||||||
|
if (tgt[RemapR2]) report[8] |= kR2Bit;
|
||||||
|
if (tgt[RemapCreate]) report[8] |= kCreateBit;
|
||||||
|
if (tgt[RemapOptions]) report[8] |= kOptionsBit;
|
||||||
|
if (tgt[RemapL3]) report[8] |= kL3Bit;
|
||||||
|
if (tgt[RemapR3]) report[8] |= kR3Bit;
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// Button remapping — a 16-entry table persisted in its own flash sector,
|
||||||
|
// applied to the OUTGOING host HID report only (never the raw interrupt_in_data
|
||||||
|
// the OLED / reboot-combo logic reads). Edited from the web config tool over the
|
||||||
|
// already-declared 0xF6/0xF7 vendor reports. Apply logic + button set ported
|
||||||
|
// from SundayMoments/DS5_Bridge (credit).
|
||||||
|
//
|
||||||
|
|
||||||
|
#ifndef DS5_BRIDGE_REMAP_H
|
||||||
|
#define DS5_BRIDGE_REMAP_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// Number of remappable buttons (see RemapButton in remap.cpp). The table maps
|
||||||
|
// source index -> target index; 0xFF means "disabled" (source does nothing).
|
||||||
|
constexpr int kRemapCount = 16;
|
||||||
|
|
||||||
|
// Wire-protocol version for the remap get/set framing carried over the existing
|
||||||
|
// 0xF6/0xF7 vendor reports (see cmd.cpp). Bump only on incompatible layout
|
||||||
|
// changes so the web tool can refuse a mismatched firmware.
|
||||||
|
constexpr uint8_t kRemapProtoVer = 1;
|
||||||
|
|
||||||
|
// Load the table from its dedicated flash sector. Call once at boot, after
|
||||||
|
// config_load(). A fresh/invalid sector defaults to identity (no remap).
|
||||||
|
void remap_load();
|
||||||
|
|
||||||
|
// Remap a 63-byte DS5 input-report COPY in place (buttons live in report[4,5,7,8]).
|
||||||
|
// No-op fast path when the table is identity. Must only ever touch the outgoing
|
||||||
|
// host report, not the raw interrupt_in_data.
|
||||||
|
void remap_apply(uint8_t *report);
|
||||||
|
|
||||||
|
// Validate + store + persist a new 16-entry table (each entry < 16, or 0xFF =
|
||||||
|
// disabled). Bumps the revision on success. Returns false if the table is invalid.
|
||||||
|
bool remap_set(const uint8_t *table);
|
||||||
|
|
||||||
|
// Copy the current 16-entry table out.
|
||||||
|
void remap_get(uint8_t out[kRemapCount]);
|
||||||
|
|
||||||
|
// Monotonic counter bumped on each successful remap_set — the web polls it to
|
||||||
|
// confirm a write landed. Runtime only (not persisted).
|
||||||
|
uint16_t remap_revision();
|
||||||
|
|
||||||
|
#endif // DS5_BRIDGE_REMAP_H
|
||||||
+29
-1
@@ -6,6 +6,12 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
|
#include "state_mgr.h"
|
||||||
|
|
||||||
|
// Set by the OLED lightbar service (src/oled.cpp). While true, the firmware
|
||||||
|
// owns the lightbar (an OLED mode or the charging pulse) and the host's
|
||||||
|
// AllowLedColor writes are suppressed below so they can't stomp it.
|
||||||
|
extern bool g_lightbar_override;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
|
constexpr size_t kAudioControlOffset = offsetof(SetStateData, MuteLightMode) - sizeof(uint8_t);
|
||||||
@@ -41,6 +47,18 @@ void state_set(uint8_t *data, const uint8_t size) {
|
|||||||
memcpy(data, state, size);
|
memcpy(data, state, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void state_set_led(uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
state[offsetof(SetStateData, LedRed) + 0] = r;
|
||||||
|
state[offsetof(SetStateData, LedRed) + 1] = g;
|
||||||
|
state[offsetof(SetStateData, LedRed) + 2] = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b) {
|
||||||
|
*r = state[offsetof(SetStateData, LedRed) + 0];
|
||||||
|
*g = state[offsetof(SetStateData, LedRed) + 1];
|
||||||
|
*b = state[offsetof(SetStateData, LedRed) + 2];
|
||||||
|
}
|
||||||
|
|
||||||
void state_update(const uint8_t *data, const uint8_t size) {
|
void state_update(const uint8_t *data, const uint8_t size) {
|
||||||
if (size < sizeof(SetStateData)) {
|
if (size < sizeof(SetStateData)) {
|
||||||
printf(
|
printf(
|
||||||
@@ -64,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,
|
||||||
@@ -147,7 +175,7 @@ void state_update(const uint8_t *data, const uint8_t size) {
|
|||||||
sizeof(uint8_t)
|
sizeof(uint8_t)
|
||||||
);
|
);
|
||||||
copy_if_allowed(
|
copy_if_allowed(
|
||||||
update.AllowLedColor,
|
update.AllowLedColor && !g_lightbar_override,
|
||||||
offsetof(SetStateData, LedRed),
|
offsetof(SetStateData, LedRed),
|
||||||
sizeof(update.LedRed) * 3
|
sizeof(update.LedRed) * 3
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,17 @@
|
|||||||
#ifndef DS5_BRIDGE_STATE_MGR_H
|
#ifndef DS5_BRIDGE_STATE_MGR_H
|
||||||
#define DS5_BRIDGE_STATE_MGR_H
|
#define DS5_BRIDGE_STATE_MGR_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
void state_init();
|
void state_init();
|
||||||
void state_set(uint8_t *data, const uint8_t size);
|
void state_set(uint8_t *data, const uint8_t size);
|
||||||
void state_update(const uint8_t *data, const uint8_t size);
|
void state_update(const uint8_t *data, const uint8_t size);
|
||||||
|
|
||||||
|
// Lightbar RGB lives in the persistent state[] block (SetStateData LedRed/
|
||||||
|
// Green/Blue) that gets stamped into every outbound BT packet. The OLED
|
||||||
|
// lightbar service writes it directly so a firmware-chosen color rides every
|
||||||
|
// host/audio frame instead of only the transient send_lightbar_color() packet.
|
||||||
|
void state_set_led(uint8_t r, uint8_t g, uint8_t b);
|
||||||
|
void state_get_led(uint8_t *r, uint8_t *g, uint8_t *b);
|
||||||
|
|
||||||
#endif //DS5_BRIDGE_STATE_MGR_H
|
#endif //DS5_BRIDGE_STATE_MGR_H
|
||||||
|
|||||||
Reference in New Issue
Block a user