Compare commits

...
Author SHA1 Message Date
MarcelineVPQandClaude Opus 4.7 a3881a4bda docs(changelog): cut v0.6.9 — button remapping + visual web remap editor + mic PLC
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:02:42 -06:00
MarcelineVPQandClaude Opus 4.7 3d90ad4aa2 docs: highlight button remapping (README + CHANGELOG)
README: add a "Button remapping" feature bullet and a "Remap tab" entry
under the Web Config Tool section. CHANGELOG [Unreleased]: add a Companion
web tool note documenting the visual Remap editor + Zacksly CC BY 3.0
asset attribution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:58:32 -06:00
MarcelineVPQandClaude Opus 4.7 1b1944c006 feat(remap): on-dongle button remapping over 0xF6/0xF7
16-entry source→target table in its own flash sector (-3, magic DS5\x03),
identity default, 0xFF = disabled. Applied to the OUTGOING host report copy
only — raw interrupt_in_data (OLED screens + PS+Mute reboot combo) stays
physical. Edited via the existing 0xF6/0xF7 vendor reports with a hardened
RM+version frame (no HID-descriptor change, so Windows enumeration is
unaffected) plus a revision counter the host polls to confirm a write.
Apply logic + button set ported from SundayMoments/DS5_Bridge (credit).

scripts/remap_test.py exercises the path over /dev/hidraw without the web
tool. Verified on hardware: swap applied, read back, survived reboot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:42:29 -06:00
MarcelineVPQandClaude Opus 4.7 da63e2bb72 feat(audio): mic packet-loss concealment (jitter buffer + Opus PLC)
The BT mic decode path gained a decoded-frame jitter buffer (8 frames) drained
at a steady 10 ms playout cadence. Bursty BT delivery is smoothed; a dropped
mic frame during an active session is concealed with an Opus PLC frame
(opus_decode(decoder, NULL, 0, ...)) instead of leaving a hole the host hears
as a click/dropout. Playout pre-buffers 3 frames and stops after 300 ms of no
real frames so it never emits comfort noise when the mic is idle. New "Mic PLC:"
Diagnostics counter climbs only when concealment fires (a live link-quality
gauge). Verified on hardware: forced BT loss kept captured audio gap-free
(longest zero-run ~0 ms) while the counter climbed; clean link leaves it idle.
Adds ~30 ms mic latency (the pre-buffer). Design ported from
SundayMoments/DS5_Bridge (credit). Unreleased — batching with the next feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:27:11 -06:00
MarcelineVPQandClaude Opus 4.7 9920516f52 ci(release): stop publishing the debug UF2 as a release download
The debug build (ENABLE_SERIAL=ON) compiles out tud_connect/disconnect and
enumerates as a serial console, not a working HID/audio bridge — an end user
who downloads it gets a non-functional dongle. Keep it buildable on demand
(-DENABLE_SERIAL=ON) and in build.yml PR CI for compile coverage, but don't
ship it on the public release page. Releases now upload the standard UF2 only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:44:32 -06:00
12 changed files with 543 additions and 31 deletions
+5 -10
View File
@@ -93,16 +93,11 @@ jobs:
mkdir -p artifacts
cp build/standard/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-${{ github.event.release.tag_name }}.uf2"
- name: Build Debug firmware
run: |
cmake -S . -B build/debug -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DPICO_SDK_PATH="$PICO_SDK_PATH" \
-DENABLE_SERIAL=ON \
-DENABLE_VERBOSE=ON \
-DVERSION="$FIRMWARE_VERSION"
cmake --build build/debug --target ds5-bridge
cp build/debug/ds5-bridge-oled.uf2 "artifacts/ds5-bridge-oled-debug-${{ github.event.release.tag_name }}.uf2"
# The debug build (ENABLE_SERIAL=ON) compiles out tud_connect/disconnect and
# comes up as a serial console rather than a working HID/audio bridge — a
# developer diagnostic, not an end-user UF2. It is NOT published as a release
# download (footgun) — build it on demand with -DENABLE_SERIAL=ON, or use the
# PR-CI compile in build.yml. See CHANGELOG/CLAUDE.md.
- name: Compute UF2 checksums + append to release notes
env:
+18
View File
@@ -10,6 +10,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
---
## [0.6.9-oled-edition] — 2026-05-24
Headline feature: **on-dongle button remapping** — reassign any of the 16 digital controls, stored on the dongle so it works in every game and on every OS with no host-side software, edited visually in the web config tool's new **Remap** tab (a click-the-controller diagram built on Zacksly's CC BY 3.0 DualSense art). Also ships **packet-loss concealment** for the BT microphone. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.9-oled-edition) (built by `.github/workflows/release.yml`); this is the first release without the debug UF2 as a download.
### Added
- **Button remapping.** Any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) can be remapped to any other — stored on the dongle, applied transparently before the host sees the report, so it works on every OS and every game with no host-side software. The remap table lives in its own dedicated flash sector (`PICO_FLASH_SIZE_BYTES - 3·FLASH_SECTOR_SIZE`, magic `DS5\x03`, below the slots sector) and survives reboot; identity (no remap) is the default. Multiple sources mapping to one target OR together (analog L2/R2 take the max); a source can be set to *disabled* (`0xFF`). The remap acts on the **outgoing host report copy only** — the raw input the OLED screens and the PS+Mute reboot combo read is untouched. Edited over the existing `0xF6`/`0xF7` vendor reports with a hardened `RM`+version frame (**no HID-descriptor change**, so Windows enumeration is unaffected) and a revision counter the host polls to confirm a write landed. New `src/remap.{h,cpp}`; apply logic + button set ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Dev helper `scripts/remap_test.py` exercises the path over `/dev/hidraw` without the web tool.
### Changed
- **BT microphone now has packet-loss concealment (PLC).** The mic decode path gained a small decoded-frame jitter buffer (8 frames) drained at a steady 10 ms playout cadence: bursty BT delivery is smoothed, and a dropped mic frame during an active session is concealed with an Opus PLC frame (`opus_decode(decoder, NULL, 0, …)`) instead of leaving a hole the host hears as a click/dropout. Playout pre-buffers 3 frames and stops after 300 ms of no real frames (so it never emits comfort noise when the mic is idle). A new **`Mic PLC:`** counter on the Diagnostics screen climbs only when concealment fires — effectively a live BT link-quality gauge. Verified: forced BT loss kept the captured audio gap-free (longest zero-run ~0 ms) while the counter climbed. Design ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Adds ~30 ms mic latency (the pre-buffer).
### Companion web tool
- **Visual button-remapping editor (new Remap tab).** `DS5Dongle-OLED-Config-Web` gains a dedicated **Remap** tab built on the new firmware remap protocol: click a button on a live, theme-aware DualSense diagram to reassign it, with the shoulders/triggers (L1/L2/R1/R2) floated to the corners as labeled glyphs + leader lines (they have no target in a front view). Remapped/selected buttons glow; a collapsible full dropdown list is kept as a fallback. Reads the remap block appended to the `0xF7` config response and writes over `0xF6` (func `0x10`), independent of `Config_body`. Controller outline + button glyphs are [Zacksly's "PS5 Button Icons and Controls"](https://zacksly.itch.io/ps5-button-icons-and-controls) (CC BY 3.0, recolored to `currentColor` and cropped), credited in the footer, each asset file, and a bundled license. Strings translated across all 7 locales.
---
## [0.6.8-oled-edition] — 2026-05-24
Two big items: **DualSense microphone over Bluetooth** (long believed impossible — turned out to be a single enable bit; credit [awalol](https://github.com/awalol/DS5Dongle) upstream) and a **USB 3.0 connection-interference watchdog**. UF2s attached to [the GitHub release](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/releases/tag/v0.6.8-oled-edition) (built by `.github/workflows/release.yml`). The companion `DS5Dongle-OLED-Config-Web` config tool gains a **BT microphone** toggle.
+1
View File
@@ -90,6 +90,7 @@ add_executable(ds5-bridge
src/state_mgr.cpp
src/oled.cpp
src/slots.cpp
src/remap.cpp
)
if (ENABLE_BATT_LED)
+3
View File
@@ -18,6 +18,7 @@ The web tool is a one-stop shop — **no installs, no command line, no `picotool
> **What is BOOTSEL mode?** It's the Pico's built-in flashing mode. To enter it: press and hold the small white button labeled **BOOTSEL** on the Pico, *then* plug the USB cable in (or, if it's already plugged in, briefly disconnect and reconnect while holding BOOTSEL). The Pico will appear to your computer as a removable drive — that's how you know it's in BOOTSEL mode. After the web tool flashes the firmware, the Pico auto-reboots into normal mode and is ready to use.
- **Config tab** — once the dongle is flashed and reconnected, edit haptics gain, speaker volume, polling rate, audio auto-haptics mode, and the rest of the persistent settings; save to the dongle's flash with one click. Powered by WebHID.
- **Remap tab** — visual button remapper: click a button on a live DualSense diagram to reassign it to any other (the shoulders/triggers float to the corners as labeled glyphs). The map is stored on the dongle and applied before the host sees the report, so it works in every game and on every OS. Powered by WebHID.
- **OLED Preview tab** — pixel-perfect emulation of all 11 OLED screens. Use the in-page KEY0/KEY1 buttons (or the controller's △ / R1 / D-pad when a DualSense is paired) to navigate. Adaptive triggers actually fire on the controller when you cycle the Trigger Test preset.
Works in any Chromium-based browser (Chrome, Edge, Brave, Opera). Firefox + Safari don't expose WebHID or WebUSB, so flashing and live config aren't available there — the OLED Preview still renders with mock data.
@@ -43,6 +44,7 @@ This project enables the Raspberry Pi Pico2W to function as a Bluetooth bridge f
**OLED Edition additions:**
- Optional Pico-OLED-1.3 status display with **11 screens** (status, slots, lightbar, trigger test, gyro tilt, touchpad, diagnostics, CPU/clock, RSSI, VU meters, settings)
- **Button remapping** — reassign any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) to any other. Stored on the dongle and applied before the host sees the report, so it works in **every game and OS with no host-side software**; identity (no remap) is the default. Edit it visually in the [web config tool](#-web-config-tool)'s **Remap tab**, or headlessly via `scripts/remap_test.py`. Persisted in its own flash sector, survives reboot.
- **4-slot persistent multi-controller pairing** — bond up to four DualSenses, switch between them from the OLED, slot 0 reconnects automatically on boot
- **Lightbar color picker** with 4 user favorite slots + breathing / rainbow / fade effect presets
- **Persistent settings menu** for the 8 firmware config fields (haptics gain, speaker volume, polling rate, etc.) with hold-to-confirm Reset and Wipe-all-slots actions
@@ -157,6 +159,7 @@ This was long believed impossible — earlier versions of this fork documented i
- Mic audio is **mono** (decoded mono, duplicated across the stereo capture endpoint).
- Toggling off mid-session stops the host feed immediately, but the controller keeps streaming until it next reconnects (there's no known "stop" command); connecting fresh with the toggle off never enables it.
- The OLED **Diagnostics** screen's `Mic in:` counter reads ~100/s while the mic is streaming — a quick way to confirm it's live.
- **Packet-loss concealment:** dropped mic frames (weak BT link, distance, interference) are concealed with Opus PLC so voice stays continuous instead of clicking/cutting out, at a small jitter-buffer latency (~30 ms). The Diag screen's `Mic PLC:` counter climbs only when frames are being concealed — effectively a live link-quality gauge.
## Known Issues
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Host-side dev helper to exercise the firmware button-remap path over the
already-declared 0xF6 (SET) / 0xF7 (GET) vendor feature reports — no web tool
needed. Linux only (reads /dev/hidraw directly), same role as mic_diag.sh.
The remap rides 0xF6/0xF7 with a magic+version frame (see src/cmd.cpp):
SET 0xF6: [func=0x10]['R']['M'][ver][table[16]]
GET 0xF7: <Config_body(35)> ['R']['M'][ver][rev_lo][rev_hi][table[16]]
Usage:
remap_test.py get # show revision + current table
remap_test.py swap CIRCLE CROSS # swap two buttons (and persist)
remap_test.py set SRC TGT # route SRC -> TGT (one-way)
remap_test.py off SRC # disable SRC (0xFF)
remap_test.py reset # identity (no remap)
Run with sudo if /dev/hidraw needs root.
"""
import fcntl, glob, struct, sys
VID, PID = 0x054c, 0x0ce6
CONFIG_LEN = 35 # sizeof(Config_body), see src/config.h
PROTO_VER = 1 # kRemapProtoVer
COUNT = 16 # kRemapCount
# Must match RemapButton order in src/remap.cpp.
NAMES = ["L2", "L1", "CREATE", "DPAD_UP", "DPAD_LEFT", "DPAD_DOWN",
"DPAD_RIGHT", "L3", "R2", "R1", "OPTIONS", "TRIANGLE",
"CIRCLE", "CROSS", "SQUARE", "R3"]
IDX = {n: i for i, n in enumerate(NAMES)}
def hidiocg(size): # HIDIOCGFEATURE(size)
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x07
def hidiocs(size): # HIDIOCSFEATURE(size)
return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x06
def read_f7(f):
"""Return (rev, table[16]) or None if the response lacks the remap block."""
size = 64 # 1 report-id byte + up to 63 payload
buf = bytearray(size)
buf[0] = 0xF7
fcntl.ioctl(f, hidiocg(size), buf)
payload = bytes(buf[1:]) # kernel prepends report id at byte 0
blk = payload[CONFIG_LEN:CONFIG_LEN + 5 + COUNT]
if len(blk) < 5 + COUNT or blk[0] != ord('R') or blk[1] != ord('M'):
return None
ver = blk[2]
rev = blk[3] | (blk[4] << 8)
table = list(blk[5:5 + COUNT])
return ver, rev, table
def find_dongle():
for path in sorted(glob.glob('/dev/hidraw*')):
try:
f = open(path, 'rb+', buffering=0)
except (OSError, PermissionError):
continue
try:
if read_f7(f) is not None:
return f, path
except OSError:
pass
f.close()
return None, None
def write_table(f, table):
assert len(table) == COUNT
payload = bytes([0xF6, 0x10, ord('R'), ord('M'), PROTO_VER]) + bytes(table)
buf = bytearray(payload)
fcntl.ioctl(f, hidiocs(len(buf)), buf)
def show(label, ver, rev, table):
print(f"{label}: proto v{ver}, revision {rev}")
active = [(s, t) for s, t in enumerate(table) if t != s]
if not active:
print(" identity (no remap active)")
return
for s, t in active:
tn = "DISABLED" if t == 0xFF else NAMES[t]
print(f" {NAMES[s]:<10} -> {tn}")
def parse_button(arg):
key = arg.upper()
if key not in IDX:
sys.exit(f"unknown button '{arg}'. choices: {', '.join(NAMES)}")
return IDX[key]
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(2)
cmd = sys.argv[1].lower()
f, path = find_dongle()
if f is None:
sys.exit("no DS5 dongle with remap support found "
"(check /dev/hidraw permissions and firmware version)")
print(f"dongle: {path}")
ver, rev, table = read_f7(f)
if cmd == "get":
show("current", ver, rev, table)
return
if cmd == "reset":
table = list(range(COUNT))
elif cmd == "off" and len(sys.argv) == 3:
table[parse_button(sys.argv[2])] = 0xFF
elif cmd == "set" and len(sys.argv) == 4:
table[parse_button(sys.argv[2])] = parse_button(sys.argv[3])
elif cmd == "swap" and len(sys.argv) == 4:
a, b = parse_button(sys.argv[2]), parse_button(sys.argv[3])
table[a], table[b] = b, a
else:
print(__doc__)
sys.exit(2)
show("writing", ver, rev, table)
write_table(f, table)
nver, nrev, ntable = read_f7(f)
show("read-back", nver, nrev, ntable)
if ntable == table and nrev != rev:
print("OK: table applied and revision bumped")
else:
print("WARNING: read-back mismatch or revision did not change")
if __name__ == "__main__":
main()
+72 -15
View File
@@ -61,6 +61,24 @@ int32_t audio_mic_last_decoded() { return g_mic_last_decoded; }
uint16_t audio_mic_last_want() { return g_mic_last_want; }
uint16_t audio_mic_last_wrote() { return g_mic_last_wrote; }
// Mic jitter buffer + packet-loss concealment. Decoded mono frames land here
// (filled as Opus arrives, drained at a steady 10 ms playout cadence) so bursty
// BT delivery is smoothed and a dropped frame is concealed via Opus PLC instead
// of underrunning the host with a click/hole. Design ported from
// SundayMoments/DS5_Bridge (credit there). PLC keeps voice continuous on a
// lossy BT link (e.g. controller moved away, USB 3.0 RF interference).
struct mic_decoded_element { int16_t mono[MIC_FRAMES]; };
static queue_t mic_decode_fifo;
static constexpr int MIC_DECODE_DEPTH = 8; // jitter-buffer capacity (frames)
static constexpr int MIC_PLAYOUT_START = 3; // pre-buffer before playout begins
static constexpr uint64_t MIC_FRAME_US = 10000; // 10 ms per Opus frame @ 48 kHz
static constexpr uint64_t MIC_SESSION_US = 300000; // no real frame this long → stop playout
static bool mic_playout_started = false;
static uint64_t mic_next_playout_us = 0;
static uint64_t mic_last_real_us = 0;
static volatile uint32_t g_mic_plc_frames = 0; // concealed frames generated (Diag)
uint32_t audio_mic_plc_frames() { return g_mic_plc_frames; }
struct audio_raw_element {
float data[512 * 2];
};
@@ -161,23 +179,61 @@ void audio_loop() {
// push to the host via tud_audio_write. Runs once per loop iteration so
// it keeps up with the ~100 Hz arrival rate of mic-tagged BT frames.
if (mic_decoder != nullptr) {
static mic_element packet{};
if (queue_try_remove(&mic_fifo, &packet)) {
static int16_t mono[MIC_FRAMES];
const int decoded = opus_decode(mic_decoder, packet.data,
MIC_OPUS_SIZE, mono, MIC_FRAMES, 0);
g_mic_last_decoded = decoded; // observed in OLED Diag
if (decoded > 0) {
static int16_t stereo[MIC_FRAMES * 2];
for (int i = 0; i < decoded; i++) {
stereo[i * 2] = mono[i];
stereo[i * 2 + 1] = mono[i];
const uint64_t now = time_us_64();
// Decode stage: drain incoming Opus into the jitter buffer as fast as it
// arrives (absorbs bursty BT delivery), up to the buffer's capacity.
static mic_element pkt{};
while (queue_get_level(&mic_decode_fifo) < MIC_DECODE_DEPTH
&& queue_try_remove(&mic_fifo, &pkt)) {
static mic_decoded_element dec{};
const int n = opus_decode(mic_decoder, pkt.data, MIC_OPUS_SIZE,
dec.mono, MIC_FRAMES, 0);
g_mic_last_decoded = n; // observed in OLED Diag
if (n > 0) {
queue_try_add(&mic_decode_fifo, &dec);
mic_last_real_us = now;
}
}
// Playout stage: emit one frame every 10 ms. Pre-buffer a few frames to
// absorb jitter, then play a real frame if buffered, else conceal with an
// Opus PLC frame during an active session (transient loss) so the host
// hears continuity instead of a hole. If real frames have been gone for a
// while (mic off/idle), stop so we don't emit comfort noise forever.
if (!mic_playout_started
&& queue_get_level(&mic_decode_fifo) >= MIC_PLAYOUT_START) {
mic_playout_started = true;
mic_next_playout_us = now;
}
if (mic_playout_started && (int64_t)(now - mic_next_playout_us) >= 0) {
static mic_decoded_element out{};
bool have = queue_try_remove(&mic_decode_fifo, &out);
if (!have) {
if (now - mic_last_real_us < MIC_SESSION_US) {
const int n = opus_decode(mic_decoder, nullptr, 0,
out.mono, MIC_FRAMES, 0); // PLC
if (n > 0) { have = true; g_mic_plc_frames++; }
} else {
mic_playout_started = false; // session ended — re-buffer next time
}
const uint16_t want = (uint16_t)(decoded * 2 * sizeof(int16_t));
const uint16_t wrote = tud_audio_write(stereo, want);
}
if (have) {
static int16_t stereo[MIC_FRAMES * 2];
for (int i = 0; i < MIC_FRAMES; i++) {
stereo[i * 2] = out.mono[i];
stereo[i * 2 + 1] = out.mono[i];
}
const uint16_t want = (uint16_t)(MIC_FRAMES * 2 * sizeof(int16_t));
g_mic_last_wrote = tud_audio_write(stereo, want);
g_mic_last_want = want;
g_mic_last_wrote = wrote;
g_mic_frames++;
mic_next_playout_us += MIC_FRAME_US;
// Drift guard: if we've fallen many frames behind (loop stall),
// resync the cadence instead of bursting to catch up.
if ((int64_t)(now - mic_next_playout_us) > (int64_t)(4 * MIC_FRAME_US)) {
mic_next_playout_us = now + MIC_FRAME_US;
}
}
}
}
@@ -376,7 +432,8 @@ void audio_init() {
// Mic path: queue + decoder live on core0 (audio_loop), separate from
// the core1 speaker encoder. Mic Opus is mono / 48 kHz / 10 ms frames.
queue_init(&mic_fifo, sizeof(mic_element), 2);
queue_init(&mic_fifo, sizeof(mic_element), MIC_DECODE_DEPTH); // deeper: tolerate BT bursts
queue_init(&mic_decode_fifo, sizeof(mic_decoded_element), MIC_DECODE_DEPTH); // decoded-PCM jitter buffer
int dec_error = 0;
mic_decoder = opus_decoder_create(48000, MIC_CHANNELS, &dec_error);
if (dec_error != 0 || mic_decoder == nullptr) {
+1
View File
@@ -26,6 +26,7 @@ int32_t audio_mic_last_decoded(); // last opus_decode return — neg = error, 4
uint16_t audio_mic_last_want(); // bytes asked of tud_audio_write
uint16_t audio_mic_last_wrote(); // bytes TinyUSB FIFO actually accepted
uint8_t audio_mic_last_toc(); // first byte of last Opus packet (frame config)
uint32_t audio_mic_plc_frames(); // count of packet-loss-concealment frames generated
// Called from on_bt_data() in main.cpp when the DS5 sends a mic-tagged
// 0x31 input report. Buffer must point at MIC_OPUS_SIZE (71) bytes of
+48 -3
View File
@@ -14,6 +14,7 @@
#include "device/usbd.h"
#include "pico/time.h"
#include "slots.h"
#include "remap.h"
#include "hardware/clocks.h"
#include "hardware/adc.h"
#include "hardware/vreg.h"
@@ -86,11 +87,34 @@ bool is_pico_cmd(uint8_t report_id) {
uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
if (report_id == 0xf7) {
printf("[HID] Receive 0xf7 getting config\n");
if (sizeof(Config_body) > reqlen) {
const size_t cfg_len = sizeof(Config_body);
if (cfg_len > reqlen) {
printf("[Config] Warning: Config_body overflow\n");
}
const auto len = std::min(sizeof(Config_body),static_cast<size_t>(reqlen));
memcpy(buffer,&get_config(),len);
const auto len = std::min(cfg_len, static_cast<size_t>(reqlen));
memcpy(buffer, &get_config(), len);
// OLED Edition: append the button-remap block right after Config_body
// when the host asked for enough room. Old clients request exactly
// sizeof(Config_body) and never see it; new web tools read config +
// remap in one GET (the 0xF6/0xF7 reports are 63 bytes, plenty).
// [+0] 'R'
// [+1] 'M'
// [+2] protocol version (kRemapProtoVer)
// [+3..+4] revision uint16 LE (bumps on each successful set)
// [+5..+20] 16-byte remap table (source idx -> target idx, 0xFF=off)
constexpr size_t kRemapBlock = 5 + kRemapCount;
if (reqlen >= cfg_len + kRemapBlock) {
uint8_t *p = buffer + cfg_len;
p[0] = 'R';
p[1] = 'M';
p[2] = kRemapProtoVer;
const uint16_t rev = remap_revision();
p[3] = (uint8_t)(rev & 0xFF);
p[4] = (uint8_t)((rev >> 8) & 0xFF);
remap_get(p + 5);
return cfg_len + kRemapBlock;
}
return len;
}
if (report_id == 0xf8) {
@@ -268,4 +292,25 @@ void pico_cmd_set(uint8_t report_id, uint8_t const *buffer, uint16_t bufsize) {
sleep_ms(150);
tud_connect();
}
// 0x10 set button-remap table (OLED Edition). Hardened framing so a stray
// write to 0xF6 can't corrupt the map: magic 'R''M' + protocol version gate
// before remap_set() (which itself validates each entry <16 or 0xFF=off).
// [0] 0x10 func-id
// [1] 'R'
// [2] 'M'
// [3] protocol version (must == kRemapProtoVer)
// [4..19] 16-byte remap table
if (buffer[0] == 0x10) {
constexpr uint16_t kNeed = 4 + kRemapCount;
if (bufsize < kNeed) {
printf("[CMD] 0x10 remap-set too short (%u<%u)\n", bufsize, kNeed);
return;
}
if (buffer[1] != 'R' || buffer[2] != 'M' || buffer[3] != kRemapProtoVer) {
printf("[CMD] 0x10 remap-set bad magic/version\n");
return;
}
if (remap_set(buffer + 4)) printf("[CMD] remap set ok (rev=%u)\n", remap_revision());
else printf("[CMD] remap set rejected (invalid table)\n");
}
}
+11 -1
View File
@@ -22,6 +22,7 @@
#include "battery_led.h"
#endif
#include "oled.h"
#include "remap.h"
// Pico SDK speciifically for waiting on conditions
#include "pico/critical_section.h"
@@ -118,7 +119,12 @@ void interrupt_loop() {
// TODO: Refactor for better code reuse
if (get_config().polling_rate_mode != 2) {
if (!tud_hid_report(0x01, interrupt_in_data, 63)) {
// Remap acts on the OUTGOING copy only — interrupt_in_data stays raw so
// the reboot combo above and every OLED screen keep seeing physical input.
uint8_t out[63];
memcpy(out, interrupt_in_data, 63);
remap_apply(out);
if (!tud_hid_report(0x01, out, 63)) {
printf("[USBHID] tud_hid_report error\n");
}
return;
@@ -137,6 +143,9 @@ void interrupt_loop() {
}
critical_section_exit(&report_cs);
// Remap the snapshot, not interrupt_in_data (outgoing copy only — see above).
if (should_send) remap_apply(safe_report);
// Only send to TinyUSB if we actually grabbed fresh data
if (should_send) {
if (!tud_hid_report(0x01, safe_report, 63)) {
@@ -377,6 +386,7 @@ int main() {
critical_section_init(&report_cs);
config_load();
remap_load();
bt_init();
bt_register_data_callback(on_bt_data);
+5 -2
View File
@@ -827,7 +827,7 @@ void sample_diag_rates() {
// 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.
constexpr int kNumDiagRows = 11;
constexpr int kNumDiagRows = 12;
__attribute__((noinline))
void format_diag_row(int idx, char* line, size_t n) {
switch (idx) {
@@ -872,7 +872,10 @@ void format_diag_row(int idx, char* line, size_t n) {
(long)audio_mic_last_decoded(),
(unsigned)audio_mic_last_wrote());
break;
case 10: {
case 10:
snprintf(line, n, "Mic PLC: %lu", (unsigned long)audio_mic_plc_frames());
break;
case 11: {
uint8_t pfx[6]; bt_31_mic_prefix(pfx);
snprintf(line, n, "%02X %02X %02X %02X %02X %02X",
pfx[0], pfx[1], pfx[2], pfx[3], pfx[4], pfx[5]);
+197
View File
@@ -0,0 +1,197 @@
// Button remapping. See remap.h. Flash-sector pattern mirrors slots.cpp;
// the apply logic + button set are ported from SundayMoments/DS5_Bridge.
#include "remap.h"
#include <cstring>
#include <cstdio>
#include <algorithm>
#include "hardware/flash.h"
#include "hardware/sync.h"
namespace {
// Source/target button indices. Order is arbitrary but MUST match the web
// editor's expectation (DS5Dongle-OLED-Config-Web src/protocol/remap.ts).
enum RemapButton : uint8_t {
RemapL2, RemapL1, RemapCreate, RemapDpadUp, RemapDpadLeft, RemapDpadDown,
RemapDpadRight, RemapL3, RemapR2, RemapR1, RemapOptions, RemapTriangle,
RemapCircle, RemapCross, RemapSquare, RemapR3, RemapButtonCount,
};
static_assert(RemapButtonCount == kRemapCount, "kRemapCount must match RemapButton");
// interrupt_in_data[8]: shoulders / sticks / system bits.
constexpr uint8_t kL1Bit = 0x01, kR1Bit = 0x02, kL2Bit = 0x04, kR2Bit = 0x08,
kCreateBit = 0x10, kOptionsBit = 0x20, kL3Bit = 0x40, kR3Bit = 0x80;
// interrupt_in_data[7]: D-pad hat (low nibble) + face buttons (high nibble).
constexpr uint8_t kSquareBit = 0x10, kCrossBit = 0x20, kCircleBit = 0x40,
kTriangleBit = 0x80, kDpadMask = 0x0F;
// D-pad hat values.
constexpr uint8_t kUp = 0, kUpRight = 1, kRight = 2, kDownRight = 3, kDown = 4,
kDownLeft = 5, kLeft = 6, kUpLeft = 7, kNeutral = 8;
constexpr uint32_t REMAP_MAGIC = 0x44533503u; // "DS5\x03"
constexpr uint32_t REMAP_FLASH_OFFSET = PICO_FLASH_SIZE_BYTES - 3u * FLASH_SECTOR_SIZE;
struct __attribute__((packed)) RemapData {
uint32_t magic;
uint8_t table[kRemapCount];
};
static_assert(sizeof(RemapData) <= FLASH_PAGE_SIZE);
static_assert(REMAP_FLASH_OFFSET % FLASH_SECTOR_SIZE == 0);
RemapData g_remap{};
bool g_active = false; // false = identity → remap_apply() fast-returns
uint16_t g_revision = 0;
const RemapData *flash_remap() {
return reinterpret_cast<const RemapData *>(XIP_BASE + REMAP_FLASH_OFFSET);
}
void set_identity() {
for (int i = 0; i < kRemapCount; i++) g_remap.table[i] = (uint8_t) i;
}
void recompute_active() {
g_active = false;
for (int i = 0; i < kRemapCount; i++) {
if (g_remap.table[i] != i) { g_active = true; return; }
}
}
bool valid_table(const uint8_t *t) {
for (int i = 0; i < kRemapCount; i++) {
if (t[i] >= kRemapCount && t[i] != 0xFF) return false; // <16 or disabled
}
return true;
}
bool save_to_flash() {
alignas(4) uint8_t page[FLASH_PAGE_SIZE];
memset(page, 0xff, sizeof(page));
memcpy(page, &g_remap, sizeof(g_remap));
const uint32_t interrupts = save_and_disable_interrupts();
flash_range_erase(REMAP_FLASH_OFFSET, FLASH_SECTOR_SIZE);
flash_range_program(REMAP_FLASH_OFFSET, page, sizeof(page));
restore_interrupts(interrupts);
RemapData verify{};
memcpy(&verify, flash_remap(), sizeof(verify));
if (memcmp(&verify, &g_remap, sizeof(g_remap)) == 0) {
printf("[Remap] flash write verified\n");
return true;
}
printf("[Remap] flash write VERIFY FAILED\n");
return false;
}
bool dpad_has(uint8_t dir, RemapButton b) {
switch (b) {
case RemapDpadUp: return dir == kUp || dir == kUpRight || dir == kUpLeft;
case RemapDpadRight: return dir == kRight || dir == kUpRight || dir == kDownRight;
case RemapDpadDown: return dir == kDown || dir == kDownRight || dir == kDownLeft;
case RemapDpadLeft: return dir == kLeft || dir == kUpLeft || dir == kDownLeft;
default: return false;
}
}
uint8_t dpad_from(bool up, bool right, bool down, bool left) {
if (up && right && !down && !left) return kUpRight;
if (right && down && !up && !left) return kDownRight;
if (down && left && !up && !right) return kDownLeft;
if (left && up && !right && !down) return kUpLeft;
if (up && !down) return kUp;
if (right && !left) return kRight;
if (down && !up) return kDown;
if (left && !right) return kLeft;
return kNeutral;
}
} // namespace
void remap_load() {
memcpy(&g_remap, flash_remap(), sizeof(g_remap));
if (g_remap.magic != REMAP_MAGIC || !valid_table(g_remap.table)) {
printf("[Remap] flash sector empty/invalid, defaulting to identity\n");
g_remap.magic = REMAP_MAGIC;
set_identity();
save_to_flash();
}
recompute_active();
printf("[Remap] loaded (active=%d)\n", g_active);
}
void remap_get(uint8_t out[kRemapCount]) { memcpy(out, g_remap.table, kRemapCount); }
uint16_t remap_revision() { return g_revision; }
bool remap_set(const uint8_t *table) {
if (!valid_table(table)) return false;
memcpy(g_remap.table, table, kRemapCount);
g_remap.magic = REMAP_MAGIC;
recompute_active();
g_revision++;
return save_to_flash();
}
void remap_apply(uint8_t *report) {
if (!g_active) return; // identity → no-op (hot-path fast return)
bool src[kRemapCount]{};
uint8_t src_analog[kRemapCount]{};
const uint8_t dir = report[7] & kDpadMask;
src[RemapL2] = (report[8] & kL2Bit) != 0;
src[RemapL1] = (report[8] & kL1Bit) != 0;
src[RemapCreate] = (report[8] & kCreateBit) != 0;
src[RemapDpadUp] = dpad_has(dir, RemapDpadUp);
src[RemapDpadLeft] = dpad_has(dir, RemapDpadLeft);
src[RemapDpadDown] = dpad_has(dir, RemapDpadDown);
src[RemapDpadRight] = dpad_has(dir, RemapDpadRight);
src[RemapL3] = (report[8] & kL3Bit) != 0;
src[RemapR2] = (report[8] & kR2Bit) != 0;
src[RemapR1] = (report[8] & kR1Bit) != 0;
src[RemapOptions] = (report[8] & kOptionsBit) != 0;
src[RemapTriangle] = (report[7] & kTriangleBit) != 0;
src[RemapCircle] = (report[7] & kCircleBit) != 0;
src[RemapCross] = (report[7] & kCrossBit) != 0;
src[RemapSquare] = (report[7] & kSquareBit) != 0;
src[RemapR3] = (report[8] & kR3Bit) != 0;
for (int i = 0; i < kRemapCount; i++) src_analog[i] = src[i] ? 0xFF : 0;
src_analog[RemapL2] = report[4]; // L2 analog
src_analog[RemapR2] = report[5]; // R2 analog
bool tgt[kRemapCount]{};
uint8_t tgt_analog[kRemapCount]{};
for (int s = 0; s < kRemapCount; s++) {
const uint8_t t = g_remap.table[s];
if (t >= kRemapCount) continue; // 0xFF = disabled (source produces nothing)
if (src[s]) tgt[t] = true;
tgt_analog[t] = std::max(tgt_analog[t], src_analog[s]); // OR digital / max analog
}
report[4] = tgt_analog[RemapL2];
report[5] = tgt_analog[RemapR2];
// Byte 7 is entirely D-pad + face (all 8 bits) — rebuild it.
report[7] = dpad_from(tgt[RemapDpadUp], tgt[RemapDpadRight],
tgt[RemapDpadDown], tgt[RemapDpadLeft]);
if (tgt[RemapSquare]) report[7] |= kSquareBit;
if (tgt[RemapCross]) report[7] |= kCrossBit;
if (tgt[RemapCircle]) report[7] |= kCircleBit;
if (tgt[RemapTriangle]) report[7] |= kTriangleBit;
// Byte 8 is entirely shoulders/sticks/Create/Options (all 8 bits) — rebuild it.
report[8] = 0;
if (tgt[RemapL1]) report[8] |= kL1Bit;
if (tgt[RemapR1]) report[8] |= kR1Bit;
if (tgt[RemapL2]) report[8] |= kL2Bit;
if (tgt[RemapR2]) report[8] |= kR2Bit;
if (tgt[RemapCreate]) report[8] |= kCreateBit;
if (tgt[RemapOptions]) report[8] |= kOptionsBit;
if (tgt[RemapL3]) report[8] |= kL3Bit;
if (tgt[RemapR3]) report[8] |= kR3Bit;
}
+43
View File
@@ -0,0 +1,43 @@
//
// Button remapping — a 16-entry table persisted in its own flash sector,
// applied to the OUTGOING host HID report only (never the raw interrupt_in_data
// the OLED / reboot-combo logic reads). Edited from the web config tool over the
// already-declared 0xF6/0xF7 vendor reports. Apply logic + button set ported
// from SundayMoments/DS5_Bridge (credit).
//
#ifndef DS5_BRIDGE_REMAP_H
#define DS5_BRIDGE_REMAP_H
#include <cstdint>
// Number of remappable buttons (see RemapButton in remap.cpp). The table maps
// source index -> target index; 0xFF means "disabled" (source does nothing).
constexpr int kRemapCount = 16;
// Wire-protocol version for the remap get/set framing carried over the existing
// 0xF6/0xF7 vendor reports (see cmd.cpp). Bump only on incompatible layout
// changes so the web tool can refuse a mismatched firmware.
constexpr uint8_t kRemapProtoVer = 1;
// Load the table from its dedicated flash sector. Call once at boot, after
// config_load(). A fresh/invalid sector defaults to identity (no remap).
void remap_load();
// Remap a 63-byte DS5 input-report COPY in place (buttons live in report[4,5,7,8]).
// No-op fast path when the table is identity. Must only ever touch the outgoing
// host report, not the raw interrupt_in_data.
void remap_apply(uint8_t *report);
// Validate + store + persist a new 16-entry table (each entry < 16, or 0xFF =
// disabled). Bumps the revision on success. Returns false if the table is invalid.
bool remap_set(const uint8_t *table);
// Copy the current 16-entry table out.
void remap_get(uint8_t out[kRemapCount]);
// Monotonic counter bumped on each successful remap_set — the web polls it to
// confirm a write landed. Runtime only (not persisted).
uint16_t remap_revision();
#endif // DS5_BRIDGE_REMAP_H