diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33803cd..73acc89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
- **OLED idle power ladder.** Replaces the single-tier 5-min auto-dim with a three-stage state machine: at 2 min idle the panel wipes black and a 2×2 "breathing dot" (1 s on / 1 s off) walks through 8 evenly-spaced positions every 30 s; at 15 min idle the SH1107 is sent `cmd(0xAE)` (display off) entirely. Wakes instantly on KEY0/KEY1, controller pair (BT-connect rising edge), or any input-report change. Why this shape: on the Waveshare panel, bench-testing `kDimContrast = 0x10` and `0x02` both produced only ~10 % perceptual reduction (SH1107's contrast register vs apparent brightness is heavily non-linear on this hardware), so the only reliable per-pixel dim available is *rendering fewer pixels*. The breathing dot lights ~4 of 8 192 pixels half the time — roughly a 1 000× drop in cumulative current — while still indicating "the dongle is alive," and the rotating position spreads OLED wear across the panel.
- **Trigger-flow diagnostic counters on the Diagnostics screen.** `host02` (total `0x02` HID OUT reports from host) / `trig` (those where the host set `AllowRight|LeftTriggerFFB` in `valid_flag0`) / `tx` (forwarded as BT `0x31` sub-`0x10`). Added in response to issue #3 ("trigger tension missing in Death Stranding 2"). Lets the user triage in one game session whether the dongle, the host driver, or the controller is the source of the missing adaptive-trigger effect — without a UART or BT sniffer.
- **Diagnostics screen now scrolls with the controller D-pad.** Refactored to a row-list (10 rows currently: Uptime / BT state / host02 / trig+tx / BT31 in/s / USB aud/s / BT32 out/s / Mic in/s / Mic dec=&w= / Mic prefix). 5 rows visible at a time; ▲/▼ glyphs at the right edge mark "more above/below." Read-only — no cursor, unlike Settings, since there's nothing to select.
+- **Host-side trigger-flow triage via `scripts/mic_diag.sh bt-trace`.** The firmware's `0xFD` vendor feature report grew a second section (bytes 32–43) with the trigger counters; `bt-trace`'s Python decoder now reads them and prints a one-line verdict — "host driver isn't setting Allow*TriggerFFB" / "trigger Allow bits set but speaker path stole the BT pipe" / "full chain reached the controller". Lets the user diagnose issue #3 without a UART cable or OLED-relay-per-flash.
+- **`README.md` "Diagnostics & debug tooling" section** documents `scripts/mic_diag.sh` and its subcommands. The script existed but was only mentioned inside `BLUETOOTH_AUDIO_NOTES.md` — invisible to anyone who hadn't already read the parked-mic notes.
### Changed
diff --git a/CLAUDE.md b/CLAUDE.md
index 8bdad72..74d92fd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -139,6 +139,7 @@ When asked to modify behavior, the *first* file to read is usually one of:
- New BT pairing / connection state behavior → `src/bt.cpp` (HCI + L2CAP event handlers).
- New OLED screen or change to existing one → `src/oled.cpp`.
- New diagnostic counter on the OLED Diagnostics screen → bump `kNumDiagRows` in `src/oled.cpp` and add a `case` to `format_diag_row()` (single switch, one row per case). The screen scrolls automatically; no D-pad wiring needed. Counters that need rate-per-second arithmetic should be sampled in `sample_diag_rates()` and read from `g_diag_rates`. Counter globals themselves typically live in `src/main.cpp` next to `g_bt_31_packets` etc. with `extern` declarations near the top of `src/oled.cpp`.
+- Host-side diagnostics → `scripts/mic_diag.sh` (Linux only, reads `/dev/hidraw` directly). Subcommands: `status` / `capture [secs]` / `watch` / `bt-trace`. `bt-trace` reads the firmware's `0xFD` vendor feature report (defined in `src/cmd.cpp`'s `tud_hid_get_report_cb`), which currently exposes BT-input counters + the host-output trigger-flow counters. To add a new counter visible to `bt-trace`: extend the `0xFD` payload in `src/cmd.cpp` (bump `want`, write at the new offset), bump `IOCTL_SIZE` in `bt_trace()` of the script, and add the field to its `decode()` dict. The `0xFD` report ID is not declared in the HID descriptor (Linux hidraw ioctls don't enforce that; WebHID would reject undeclared IDs, which is why config goes through `0xF6`).
- New persistent config field → `src/config.h` (struct), `src/config.cpp:config_valid()` (defaults + clamping), `src/oled.cpp:format_settings_item()` (UI), `src/oled.cpp:settings_adjust()` (D-pad ▶◀ behavior). Update `CHANGELOG.md`.
- USB descriptor or interface change → `src/usb_descriptors.cpp` + `src/tusb_config.h`.
- Audio / haptic path → `src/audio.cpp`. **Don't** add stack arrays sized smaller than the resampler / Opus expects (this is the C1 bug that caused the long-standing "audio stuttering" issue — fix landed in upstream `5b04cbd`, but the lesson stands).
diff --git a/README.md b/README.md
index 674e8bd..9a5733b 100644
--- a/README.md
+++ b/README.md
@@ -186,6 +186,34 @@ Build flags worth knowing:
- `-DENABLE_SERIAL=ON` — route printf to USB CDC for debugging (default OFF; releases UART for production builds).
- `-DPICO_W_BUILD=ON` — build for the original Pico W (drops audio, lowers clock). Default targets Pico 2 W.
+## Diagnostics & debug tooling
+
+Two ways to triage bridge issues — on-device via the OLED Diagnostics screen, and host-side via `scripts/mic_diag.sh` (Linux). The host-side path is faster: no screen-switching, no flash cycle, runs while the controller is in active use.
+
+```
+# One-shot snapshot — is the dongle on USB? Did ALSA enumerate it? Is the
+# capture stream live? Is a controller currently paired?
+scripts/mic_diag.sh status
+
+# 3-second arecord on the mic IN endpoint — reports peak / RMS / non-zero
+# count so we can tell "stream is silent" from "stream is producing audio".
+scripts/mic_diag.sh capture 3
+
+# Same as `status` but in a loop, prints only on state change. Useful for
+# catching the exact second pairing completes or audio streams open / close.
+scripts/mic_diag.sh watch
+
+# Live read of the firmware's 0xFD vendor feature report (via /dev/hidraw):
+# BT input counts + rates, last seen non-0x31 IDs, byte prefixes, AND the
+# trigger-flow counters (host 0x02 received / with AllowTriggerFFB set /
+# forwarded to BT). bt-trace prints a verdict — "host driver isn't sending
+# trigger Allow bits" vs "forwarded but controller didn't actuate" — which
+# is what would otherwise need a USB protocol analyzer.
+scripts/mic_diag.sh bt-trace
+```
+
+Originally written to triage the parked DS5 BT-microphone investigation (see [BLUETOOTH_AUDIO_NOTES.md](./BLUETOOTH_AUDIO_NOTES.md)). The `0xFD` feature report and `bt-trace` decoder now also carry the trigger-flow counters added for [issue #3](https://github.com/MarcelineVPQ/DS5Dongle-OLED-Edition/issues/3) (missing adaptive trigger tension in some games).
+
## OLED Display Add-on (optional)
If you plug a [Waveshare Pico-OLED-1.3](#hardware) onto the Pico2W's headers, the firmware drives it automatically as a live status display. No configuration needed — the firmware no-ops gracefully when no OLED is present.
@@ -249,7 +277,9 @@ Live render of the touchpad surface. Dots appear at current finger positions; th
#### 7. Diagnostics
-Uptime, BT state, USB-audio frames/sec, BT 0x32 packets/sec, and HCI error counter — live values for verifying the audio path is moving bytes without needing a UART cable.
+Scrollable list of live counters — uptime, BT state, host → BT trigger flow (`host02` / `trig` / `tx`), BT 0x31 input rate, USB audio frames/sec, BT 0x32 packets/sec, and parked mic-investigation counters at the bottom. Controller D-pad ▲/▼ scrolls; tiny `^` / `v` glyphs at the right edge mark "more above/below." Read-only, so no cursor. Useful for verifying the bridge is moving bytes without needing a UART cable.
+
+The same counters are also exported on HID feature report `0xFD` for host-side tooling — see `scripts/mic_diag.sh bt-trace` below.
diff --git a/scripts/mic_diag.sh b/scripts/mic_diag.sh
index 4285e1f..524f9c9 100755
--- a/scripts/mic_diag.sh
+++ b/scripts/mic_diag.sh
@@ -131,28 +131,30 @@ watch_status() {
}
bt_trace() {
- # Query the firmware's 0xFD vendor feature report via /dev/hidraw —
- # exposes BT-side packet counts, last seen non-0x31 report ID, byte
- # prefixes. Lets us find where the mic stream actually lives without
- # an OLED-relay flash cycle per change.
+ # Query the firmware's 0xFD vendor feature report via /dev/hidraw.
+ # 0xFD carries two sections:
+ # Section 1 (bytes 0..31) — mic-investigation: BT 0x31 / non-0x31
+ # counts, byte[2] OR mask, frame prefixes. Originally used to
+ # locate the mic stream; kept for any future BT-input triage.
+ # Section 2 (bytes 32..43) — host -> dongle -> BT trigger flow
+ # counters (issue #3): host 0x02 OUT received total, of those
+ # where AllowRight/LeftTriggerFFB was set, and of those forwarded
+ # as BT 0x31 sub-0x10. Lets the user triage adaptive-trigger
+ # issues without needing an OLED in the loop.
+ # The ioctl buffer is 45 bytes (44 payload + 1 byte that the kernel
+ # fills with the report ID).
python3 - <<'PY'
import fcntl, glob, struct, sys, time
VID, PID = 0x054c, 0x0ce6
+IOCTL_SIZE = 45 # 1 byte report ID + 44 bytes firmware payload
def find_dongle():
for path in sorted(glob.glob('/dev/hidraw*')):
try:
f = open(path, 'rb+')
- info = bytearray(8)
- HIDIOCGRAWINFO = 0x80084803
- try:
- fcntl.ioctl(f, HIDIOCGRAWINFO, info)
- except OSError:
- pass
- # Try feature 0xFD; if it returns 64 bytes we know it's our dongle
- buf = bytearray(32); buf[0] = 0xFD
- ioctl_num = (3 << 30) | (32 << 16) | (ord('H') << 8) | 0x07
+ buf = bytearray(IOCTL_SIZE); buf[0] = 0xFD
+ ioctl_num = (3 << 30) | (IOCTL_SIZE << 16) | (ord('H') << 8) | 0x07
try:
fcntl.ioctl(f, ioctl_num, buf)
return f
@@ -167,39 +169,61 @@ if f is None:
print('no dongle found (or no /dev/hidraw permission)')
sys.exit(1)
-ioctl_num = (3 << 30) | (64 << 16) | (ord('H') << 8) | 0x07
-
def query():
- buf = bytearray(32); buf[0] = 0xFD
- ioctl_num_32 = (3 << 30) | (32 << 16) | (ord('H') << 8) | 0x07
- fcntl.ioctl(f, ioctl_num_32, buf)
+ buf = bytearray(IOCTL_SIZE); buf[0] = 0xFD
+ ioctl_num = (3 << 30) | (IOCTL_SIZE << 16) | (ord('H') << 8) | 0x07
+ fcntl.ioctl(f, ioctl_num, buf)
# Kernel prepends the report ID at byte 0; firmware payload starts at byte 1.
return bytes(buf[1:])
def decode(b):
- bt31 = struct.unpack('= 36 else 0,
+ 'host02_trig':struct.unpack('= 40 else 0,
+ 'host02_tx': struct.unpack('= 44 else 0,
+ }
s1 = query(); time.sleep(1.0); s2 = query()
d1 = decode(s1); d2 = decode(s2)
-bt31_rate = d2[0] - d1[0]
-btoth_rate = d2[1] - d1[1]
+
+# Mic-investigation section
+bt31_rate = d2['bt31'] - d1['bt31']
+btoth_rate = d2['btoth'] - d1['btoth']
+print('-- BT input (mic investigation legacy) --')
print(f'rates: 0x31={bt31_rate}/s, non-0x31={btoth_rate}/s')
-print(f'len range: {d2[6]}-{d2[7]} bytes')
-print(f'byte[2] OR mask across 0x31 frames: 0x{d2[4]:02X} last=0x{d2[5]:02X}')
-print(f'non-0x31 report IDs: OR mask=0x{d2[3]:02X} most recent=0x{d2[2]:02X}')
-print(f'last non-0x31 prefix (data[0..7]): {d2[8]}')
-print(f'last ANY frame (data[0..7]): {d2[9]}')
+print(f'len range: {d2["lmin"]}-{d2["lmax"]} bytes')
+print(f'byte[2] OR mask across 0x31 frames: 0x{d2["b2_or"]:02X} last=0x{d2["b2_last"]:02X}')
+print(f'non-0x31 report IDs: OR mask=0x{d2["other_or"]:02X} most recent=0x{d2["other_id"]:02X}')
+print(f'last non-0x31 prefix (data[0..7]): {d2["othpfx"]}')
+print(f'last ANY frame (data[0..7]): {d2["anypfx"]}')
+
+# Trigger-flow section
+o02_rate = d2['host02'] - d1['host02']
+trig_rate = d2['host02_trig'] - d1['host02_trig']
+tx_rate = d2['host02_tx'] - d1['host02_tx']
+print()
+print('-- Host -> dongle -> BT trigger flow (issue #3) --')
+print(f'host 0x02 OUT: total={d2["host02"]} ({o02_rate}/s)')
+print(f' w/ AllowTrigFFB: total={d2["host02_trig"]} ({trig_rate}/s)')
+print(f' forwarded to BT: total={d2["host02_tx"]} ({tx_rate}/s)')
+if d2['host02'] > 0 and d2['host02_trig'] == 0:
+ print('verdict: host is sending 0x02 reports but never sets Allow*TriggerFFB.')
+ print(' The host driver is not requesting adaptive trigger effects.')
+elif d2['host02_trig'] > 0 and d2['host02_tx'] < d2['host02_trig']:
+ print('verdict: trigger Allow bits are set but some reports are not reaching BT.')
+ print(' Likely the speaker-active gate in main.cpp swallowed them.')
+elif d2['host02_trig'] > 0:
+ print('verdict: full chain reached the controller. Tension still missing -> Sony BT limit.')
PY
}
diff --git a/src/cmd.cpp b/src/cmd.cpp
index 238bb2d..2d5378f 100644
--- a/src/cmd.cpp
+++ b/src/cmd.cpp
@@ -63,6 +63,9 @@ extern volatile uint8_t g_last_other_prefix[8];
extern volatile uint8_t g_last_any_prefix[16];
extern volatile uint16_t g_longest_len;
extern volatile uint8_t g_longest_frame[80];
+extern volatile uint32_t g_host_out02_total;
+extern volatile uint32_t g_host_out02_trig_allow;
+extern volatile uint32_t g_host_out02_to_bt;
bool is_pico_cmd(uint8_t report_id) {
if (report_id == 0xf6 ||
@@ -180,8 +183,8 @@ uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
return want;
}
if (report_id == 0xfd) {
- // Mic-debug feature report. 32-byte payload (under typical
- // GET_REPORT control transfer cap; want=64 came back empty).
+ // Bridge-diagnostics feature report. 44-byte payload.
+ // Section 1: mic-investigation counters (original 0..31).
// [0..3] uint32 BT 0x31 input report count
// [4..7] uint32 BT non-0x31 input report count
// [8] uint8 last non-0x31 report ID seen
@@ -192,10 +195,11 @@ uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
// [14..15] uint16 max frame length seen
// [16..23] uint8[8] first 8 bytes of last non-0x31 frame
// [24..31] uint8[8] first 8 bytes of most recent ANY frame
- constexpr uint16_t want = 32;
- // Diagnostic: do NOT bail if reqlen < want — write what we can
- // and set sentinel. If we still see 0x00 at byte[31] the handler
- // isn't reached at all.
+ // Section 2: trigger-flow counters (issue #3 triage).
+ // [32..35] uint32 host 0x02 OUT reports received total
+ // [36..39] uint32 ...of those, with Allow*TriggerFFB set
+ // [40..43] uint32 ...forwarded as BT 0x31 sub-0x10
+ constexpr uint16_t want = 44;
for (uint16_t i = 0; i < want && i < reqlen; i++) buffer[i] = 0;
const uint32_t bt31 = g_bt_31_packets;
@@ -213,6 +217,13 @@ uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) {
memcpy(buffer + 14, &lmax, 2);
for (int i = 0; i < 8 && (16 + i) < reqlen; i++) buffer[16 + i] = g_last_other_prefix[i];
for (int i = 0; i < 8 && (24 + i) < reqlen; i++) buffer[24 + i] = g_last_any_prefix[i];
+
+ const uint32_t out02 = g_host_out02_total;
+ const uint32_t out02_t = g_host_out02_trig_allow;
+ const uint32_t out02_b = g_host_out02_to_bt;
+ if ((32 + 4) <= reqlen) memcpy(buffer + 32, &out02, 4);
+ if ((36 + 4) <= reqlen) memcpy(buffer + 36, &out02_t, 4);
+ if ((40 + 4) <= reqlen) memcpy(buffer + 40, &out02_b, 4);
return (reqlen < want) ? reqlen : want;
}
if (report_id == 0xfe) {