feat: DualSense BT microphone + USB 3.0 connection watchdog

BT microphone over Bluetooth: the DS5 mic now works over the dongle's BT
pairing — decoded from the controller's Opus stream to the USB capture
endpoint. Hinges on pkt[4] bit 0 (mic-enable) in the outbound 0x36 audio
report; credit to awalol (upstream) for identifying it. Mic-tagged 0x31
frames ((data[2]>>1)&1) are ALWAYS diverted out of the input path (decoded
when on, dropped when off) so Opus payload can never corrupt sticks/buttons.
Always-on via a sticky-latch keep-alive that only runs post-enumeration
(tud_mounted) so it never floods the fresh-pair handshake (which otherwise
delayed controller detection past the watchdog and tore the link down).
Toggle: bt_mic_enable config field (default on) — OLED Settings + web config.
README gains a "DualSense Microphone over Bluetooth" section;
BLUETOOTH_AUDIO_NOTES.md rewritten from "dead end" to the working mechanism.

USB 3.0 connection watchdog: auto-recovers a stalled connection (re-inquiry)
instead of hanging on the amber lightbar, for USB 3.0 ~2.4 GHz RF interference
that desensitizes the CYW43 BT radio. Re-enabled the ACL-fail / auth-fail /
create-connection-reject recovery paths. README "USB 3.0 ports & Bluetooth
interference" section with mitigations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MarcelineVPQ
2026-05-24 01:30:37 -06:00
co-authored by Claude Opus 4.7
parent 89847ed06e
commit 20b41d80a1
9 changed files with 211 additions and 80 deletions
+55 -2
View File
@@ -13,6 +13,7 @@
#include "utils.h"
#include "pico/multicore.h"
#include "pico/util/queue.h"
#include "pico/time.h"
#include "config.h"
#include "state_mgr.h"
#include "usb.h"
@@ -114,6 +115,46 @@ void mic_add_queue(const uint8_t *data) {
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() {
// 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),
@@ -142,7 +183,16 @@ void audio_loop() {
}
// 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];
uint32_t bytes_read = tud_audio_read(raw, sizeof(raw)); // 每次读入 384 bytes
@@ -276,7 +326,10 @@ void audio_loop() {
reportSeqCounter = (reportSeqCounter + 1) & 0x0F;
pkt[2] = 0x11 | 0 << 6 | 1 << 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;
pkt[5] = buf_len;
pkt[6] = buf_len;
+58 -3
View File
@@ -26,6 +26,15 @@
#define MTU_CONTROL 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::vector;
using std::queue;
@@ -54,6 +63,12 @@ struct send_element {
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.
static int g_current_slot = 0;
@@ -166,6 +181,35 @@ bool bt_disconnect() {
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) {
// gap_read_rssi() completes asynchronously, so this function can only
// 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) {
printf("[HCI] Connecting to %s...\n", bd_addr_to_str(current_device_addr));
new_pair = true;
connect_attempt_started = get_absolute_time(); // arm connection watchdog
hci_send_cmd(&hci_create_connection, current_device_addr,
hci_usable_acl_packet_types(), 0, 0, 0, 1);
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) {
device_found = false;
new_pair = false;
connect_attempt_started = 0; // disarm; failed before an ACL existed
printf("[HCI] Create connection rejected, restart inquiry\n");
// gap_inquiry_start(30);
gap_inquiry_start(30);
}
break;
}
@@ -350,8 +396,9 @@ static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *p
} else {
device_found = 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);
// gap_inquiry_start(30);
gap_inquiry_start(30);
}
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) {
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_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 {
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);
gap_inquiry_stop();
hci_send_cmd(&hci_accept_connection_request, addr, 0x01);
connect_attempt_started = get_absolute_time(); // arm watchdog (incoming path)
}
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);
device_found = false;
new_pair = false;
connect_attempt_started = 0; // disarm — every teardown clears here
acl_handle = HCI_CON_HANDLE_INVALID;
bt_rssi = 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");
check_dse = false;
is_dse = true;
connect_attempt_started = 0; // fully up — disarm watchdog
#if !ENABLE_SERIAL
tud_connect();
#endif
@@ -515,6 +569,7 @@ static void l2cap_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t
printf("Connected DS5 Controller\n");
check_dse = false;
is_dse = false;
connect_attempt_started = 0; // fully up — disarm watchdog
#if !ENABLE_SERIAL
tud_connect();
#endif
+5
View File
@@ -25,6 +25,11 @@ std::vector<uint8_t> get_feature_data(uint8_t reportId,uint16_t len);
void init_feature();
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.
bool bt_is_connected();
void bt_get_addr(uint8_t out[6]);
+4
View File
@@ -109,6 +109,10 @@ void config_valid() {
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) {
body->config_version = CONFIG_VERSION;
printf("[Config] Warning: Config may breaking change\n");
+5
View File
@@ -39,6 +39,11 @@ struct __attribute__((packed)) Config_body {
// 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 {
+19 -5
View File
@@ -189,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
// bytes) as Opus and producing INT16_MIN garbage on the USB IN
// endpoint. Re-enable once we identify the actual mic transport.
// (Standard input handling below resumes — Status screen + HID
// reports to host need this.)
// Mic-in tap (TEST): once the dongle asserts the mic-enable bit in the
// outgoing 0x36 audio report (pkt[4] bit 0, see audio.cpp — awalol
// confirmed this is the key), the DS5 streams its mic as a 71-byte Opus
// packet at data+4 of a 0x31 report with bit 1 of data[2] set. Route those
// 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 ((data[56] & 1) != (interrupt_in_data[53] & 1)) {
@@ -381,6 +394,7 @@ int main() {
watchdog_update();
#endif
cyw43_arch_poll();
bt_connection_watchdog_tick();
tud_task();
audio_loop();
interrupt_loop();
+8 -5
View File
@@ -126,14 +126,15 @@ constexpr int kLbModeHost = 8;
constexpr int kNumLbModes = 9;
// Settings screen state
constexpr int kNumSettingsItems = 15; // 8 fields + 3 auto-haptic + 2 screen-timeout + Reset + Wipe
constexpr int kNumSettingsItems = 16; // 8 fields + 3 auto-haptic + 2 screen-timeout + BT mic + Reset + Wipe
constexpr int kSettingsAutoHapEnaIdx = 8;
constexpr int kSettingsAutoHapGainIdx = 9;
constexpr int kSettingsAutoHapLpIdx = 10;
constexpr int kSettingsScrDimIdx = 11;
constexpr int kSettingsScrOffIdx = 12;
constexpr int kSettingsResetIdx = 13;
constexpr int kSettingsWipeSlotsIdx = 14;
constexpr int kSettingsBtMicIdx = 13;
constexpr int kSettingsResetIdx = 14;
constexpr int kSettingsWipeSlotsIdx = 15;
Config_body settings_local{};
int settings_sel = 0;
bool settings_dirty = false;
@@ -1416,6 +1417,7 @@ void settings_adjust(int delta) {
c.screen_off_timeout = (uint8_t)v;
break;
}
case 13: c.bt_mic_enable ^= 1; break; // BT mic on/off
}
}
@@ -1517,8 +1519,9 @@ __attribute__((noinline)) void format_settings_item(int idx, char* line, size_t
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 Reset to defaults", cur); break;
case 14: snprintf(line, n, "%s Wipe all slots", cur); 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;
}
}