Extends the firmware's 0xFD vendor feature report from 32 to 44 bytes
to carry the three host -> dongle -> BT trigger counters added in
5da1be4 (host_out02_total / host_out02_trig_allow / host_out02_to_bt).
mic_diag.sh's bt-trace decoder reads them and prints a one-line
verdict so the user can triage issue #3 in one terminal command
instead of switching to the OLED Diagnostics screen mid-game.
Verdicts encoded:
host02 > 0, trig == 0 -> host driver never sets the trigger
Allow bits; not a firmware problem.
trig > 0, tx < trig -> trigger Allow bits arrive but the
speaker-active gate in main.cpp
swallowed them.
full chain populated -> dongle delivered, controller didn't
actuate; Sony BT-side limit.
The 0xFD report ID stays undeclared in the HID descriptor — Linux
hidraw ioctls don't enforce that and WebHID never sees these reports
(config goes through 0xF6). bt-trace's IOCTL_SIZE bumped to 45
(1 byte for the kernel-prepended report ID + 44 bytes payload).
Docs:
README.md - new "Diagnostics & debug tooling" section walks
through the four mic_diag.sh subcommands; previously
the script was only mentioned inside
BLUETOOTH_AUDIO_NOTES.md.
README.md - Diagnostics-screen description updated for the
scrolling row-list + cross-link to bt-trace.
CLAUDE.md - "Where features live" gets a host-side-diagnostics
entry explaining how to extend the 0xFD payload and
wire it into bt-trace's decoder.
CHANGELOG - records both the firmware extension and the README
discoverability fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
240 lines
8.5 KiB
Bash
Executable File
240 lines
8.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Mic-path host-side diagnostic for the DS5Dongle (OLED Edition).
|
|
#
|
|
# Subcommands:
|
|
# status — one-shot snapshot of dongle USB / ALSA / capture stream state.
|
|
# Prints whether the dongle enumerated, what ALSA card # it
|
|
# took, the capture stream's current alt setting + sync mode,
|
|
# and whether a paired DualSense is reachable.
|
|
# capture — runs a 3-second arecord on the mic IN endpoint, reports
|
|
# ALSA result code, captured byte count, and a non-silence
|
|
# indicator (peak abs sample value via Python's wave module).
|
|
# Tells us in one shot whether the firmware is producing
|
|
# actual isoc-IN data and whether anything audio-like is
|
|
# showing up.
|
|
# watch — loops `status` every 2 seconds, prints only on changes —
|
|
# useful for catching the moment pairing completes or the
|
|
# arecord stream opens/closes.
|
|
#
|
|
# Why a script: lets the assistant query mic-path state directly from
|
|
# the host rather than waiting for the user to relay OLED counters
|
|
# through chat, which dominated the early Phase-3 debugging time.
|
|
#
|
|
# Requirements (all already installed on the user's machine):
|
|
# - arecord (alsa-utils)
|
|
# - lsusb (usbutils)
|
|
# - python3 (for wave-file stats)
|
|
|
|
set -u
|
|
|
|
VID=054c
|
|
PID=0ce6
|
|
DEV_NAME_RE='DualSense Wireless Controller'
|
|
|
|
find_card() {
|
|
arecord -l 2>/dev/null | awk -v re="$DEV_NAME_RE" '
|
|
$0 ~ re {
|
|
for (i = 1; i <= NF; i++) {
|
|
if ($i == "card") { gsub(":", "", $(i+1)); print $(i+1); exit }
|
|
}
|
|
}'
|
|
}
|
|
|
|
show_status() {
|
|
local card
|
|
card="$(find_card)"
|
|
|
|
# USB layer — is the device visible?
|
|
if lsusb -d "${VID}:${PID}" >/dev/null 2>&1; then
|
|
printf 'usb: present (%s:%s)\n' "$VID" "$PID"
|
|
else
|
|
printf 'usb: NOT FOUND — is the dongle plugged in?\n'
|
|
return 1
|
|
fi
|
|
|
|
if [[ -z "$card" ]]; then
|
|
printf 'alsa: dongle is on USB but not exposed as an audio card\n'
|
|
return 1
|
|
fi
|
|
printf 'alsa: card %s\n' "$card"
|
|
|
|
# Capture stream details (interface 2 alt 1 mic-IN endpoint)
|
|
if [[ -r "/proc/asound/card${card}/stream0" ]]; then
|
|
# Grep just the Capture block so we see status + altset + endpoint
|
|
awk '/^Capture:/,0' "/proc/asound/card${card}/stream0" | head -10 | sed 's/^/ /'
|
|
else
|
|
printf ' (no /proc/asound/card%s/stream0 — older kernel?)\n' "$card"
|
|
fi
|
|
}
|
|
|
|
run_capture() {
|
|
local card secs="${1:-3}"
|
|
card="$(find_card)"
|
|
if [[ -z "$card" ]]; then
|
|
printf 'no dongle capture device found\n'
|
|
return 1
|
|
fi
|
|
|
|
local tmp
|
|
tmp="$(mktemp -t mic_diag.XXXXXX.wav)"
|
|
printf 'capturing %ss from card %s into %s ...\n' "$secs" "$card" "$tmp"
|
|
|
|
local err
|
|
err="$(arecord -q -D "plughw:${card},0" -f S16_LE -c 2 -r 48000 -d "$secs" "$tmp" 2>&1)"
|
|
local rc=$?
|
|
if (( rc != 0 )); then
|
|
printf 'arecord exit=%d: %s\n' "$rc" "$err"
|
|
rm -f "$tmp"
|
|
return "$rc"
|
|
fi
|
|
|
|
# Stats via Python — peak abs sample is enough to distinguish "stream
|
|
# produced silence" from "stream produced actual audio".
|
|
python3 - "$tmp" <<'PY'
|
|
import sys, wave, struct
|
|
path = sys.argv[1]
|
|
with wave.open(path, 'rb') as w:
|
|
nframes = w.getnframes()
|
|
sw = w.getsampwidth()
|
|
ch = w.getnchannels()
|
|
fr = w.getframerate()
|
|
raw = w.readframes(nframes)
|
|
nsamples = nframes * ch
|
|
fmt = '<' + ('h' * nsamples)
|
|
data = struct.unpack(fmt, raw)
|
|
peak = max(abs(s) for s in data) if data else 0
|
|
nonzero = sum(1 for s in data if s != 0)
|
|
rms = (sum(s*s for s in data) / max(len(data), 1)) ** 0.5
|
|
print(f'wav: {nframes} frames, {ch} ch, {sw*8}-bit, {fr} Hz')
|
|
print(f'samples: nonzero={nonzero}/{nsamples} peak={peak} rms={rms:.1f}')
|
|
if peak == 0:
|
|
print('verdict: STREAM IS SILENT — firmware not producing isoc-IN data')
|
|
elif peak < 100:
|
|
print('verdict: extremely quiet — possibly DC offset only')
|
|
else:
|
|
print('verdict: AUDIO PRESENT')
|
|
PY
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
watch_status() {
|
|
local prev=""
|
|
while :; do
|
|
local now
|
|
now="$(show_status 2>&1)"
|
|
if [[ "$now" != "$prev" ]]; then
|
|
printf '\n=== %s ===\n%s\n' "$(date '+%H:%M:%S')" "$now"
|
|
prev="$now"
|
|
fi
|
|
sleep 2
|
|
done
|
|
}
|
|
|
|
bt_trace() {
|
|
# 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+')
|
|
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
|
|
except OSError:
|
|
f.close()
|
|
except (OSError, PermissionError):
|
|
pass
|
|
return None
|
|
|
|
f = find_dongle()
|
|
if f is None:
|
|
print('no dongle found (or no /dev/hidraw permission)')
|
|
sys.exit(1)
|
|
|
|
def query():
|
|
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):
|
|
return {
|
|
'bt31': struct.unpack('<I', b[0:4])[0],
|
|
'btoth': struct.unpack('<I', b[4:8])[0],
|
|
'other_id': b[8],
|
|
'other_or': b[9],
|
|
'b2_or': b[10],
|
|
'b2_last': b[11],
|
|
'lmin': struct.unpack('<H', b[12:14])[0],
|
|
'lmax': struct.unpack('<H', b[14:16])[0],
|
|
'othpfx': b[16:24].hex(),
|
|
'anypfx': b[24:32].hex(),
|
|
'host02': struct.unpack('<I', b[32:36])[0] if len(b) >= 36 else 0,
|
|
'host02_trig':struct.unpack('<I', b[36:40])[0] if len(b) >= 40 else 0,
|
|
'host02_tx': struct.unpack('<I', b[40:44])[0] if len(b) >= 44 else 0,
|
|
}
|
|
|
|
s1 = query(); time.sleep(1.0); s2 = query()
|
|
d1 = decode(s1); d2 = decode(s2)
|
|
|
|
# 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["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
|
|
}
|
|
|
|
case "${1:-status}" in
|
|
status) show_status ;;
|
|
capture) shift; run_capture "${1:-3}" ;;
|
|
watch) watch_status ;;
|
|
bt-trace) bt_trace ;;
|
|
*)
|
|
printf 'usage: %s {status|capture [secs]|watch|bt-trace}\n' "$0" >&2
|
|
exit 2
|
|
;;
|
|
esac
|