Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4e34f8da | ||
|
|
20b41d80a1 | ||
|
|
89847ed06e | ||
|
|
0eceb1d5e2 | ||
|
|
d99689bf75 | ||
|
|
c65e0c9802 | ||
|
|
585a385219 | ||
|
|
4edcd20181 |
+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.
|
|
||||||
|
|||||||
@@ -10,6 +10,71 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
Trigger-flow diagnostics (in response to issue #3) + the OLED idle power ladder. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.4-oled-edition) (built by `.github/workflows/release.yml`).
|
Trigger-flow diagnostics (in response to issue #3) + the OLED idle power ladder. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.4-oled-edition) (built by `.github/workflows/release.yml`).
|
||||||
|
|||||||
@@ -140,11 +140,43 @@ 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.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
+55
-2
@@ -13,6 +13,7 @@
|
|||||||
#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"
|
||||||
@@ -114,6 +115,46 @@ 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),
|
||||||
@@ -142,7 +183,16 @@ void audio_loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -276,7 +326,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;
|
||||||
|
|||||||
+58
-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
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ std::vector<uint8_t> get_feature_data(uint8_t reportId,uint16_t len);
|
|||||||
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]);
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ 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 ON
|
||||||
|
body->bt_mic_enable = 1;
|
||||||
|
printf("[Config] bt_mic_enable 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,27 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct __attribute__((packed)) Config {
|
struct __attribute__((packed)) Config {
|
||||||
|
|||||||
+35
-8
@@ -66,13 +66,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;
|
||||||
uint32_t host_out02_total() { return g_host_out02_total; }
|
volatile uint32_t g_host_out02_trig_folded = 0;
|
||||||
uint32_t host_out02_trig_allow() { return g_host_out02_trig_allow; }
|
uint32_t host_out02_total() { return g_host_out02_total; }
|
||||||
uint32_t host_out02_to_bt() { return g_host_out02_to_bt; }
|
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_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,
|
||||||
@@ -181,11 +189,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 +295,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]{};
|
||||||
@@ -368,6 +394,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();
|
||||||
|
|||||||
+386
-82
@@ -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 {
|
||||||
|
|
||||||
@@ -76,16 +84,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 +118,23 @@ 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 = 16; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + 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 kSettingsResetIdx = 14;
|
||||||
|
constexpr int kSettingsWipeSlotsIdx = 15;
|
||||||
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 +151,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;
|
||||||
@@ -464,13 +485,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,12 +503,12 @@ 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;
|
||||||
} else {
|
} else {
|
||||||
@@ -500,6 +521,127 @@ 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 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;
|
||||||
|
|
||||||
|
// 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 = 3; // average 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 {
|
||||||
|
ring[ring_head] = dur / charge_step_weight(step);
|
||||||
|
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) {
|
||||||
|
bulk = 0.0f;
|
||||||
|
for (int i = 0; i < ring_count; i++) bulk += ring[i];
|
||||||
|
bulk /= (float)ring_count;
|
||||||
|
} 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();
|
||||||
|
|
||||||
@@ -532,6 +674,20 @@ __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);
|
||||||
@@ -671,7 +827,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 = 11;
|
||||||
__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 +851,28 @@ 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: {
|
||||||
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]);
|
||||||
@@ -966,6 +1127,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 +1143,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 +1155,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,18 +1180,20 @@ __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");
|
||||||
const char* hint =
|
const char* hint =
|
||||||
(lb_mode == 0) ? "Tilt = R/G/B" :
|
(lb_mode == 0) ? "Tilt = R/G/B" :
|
||||||
(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" :
|
||||||
"Locked to fav";
|
(lb_mode == kLbModeHost) ? "Host controls" :
|
||||||
|
"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 +1201,124 @@ __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);
|
||||||
|
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 +1405,19 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,6 +1452,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 +1511,17 @@ __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 Reset to defaults", cur); break;
|
||||||
|
case 15: snprintf(line, n, "%s Wipe all slots", cur); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1424,6 +1689,10 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1462,22 +1731,49 @@ void oled_loop() {
|
|||||||
rumble_burst_tick(now);
|
rumble_burst_tick(now);
|
||||||
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();
|
||||||
|
// 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;
|
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 +1781,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 +1807,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) {
|
||||||
|
|||||||
+19
-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(
|
||||||
@@ -147,7 +165,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