Skip to content

Commit 77d6dfd

Browse files
committed
feat(bluetooth): stream input over usb (2026.6.20.3-8A2E)
Route Bluetooth personas over local CDC for live controller input. Add Bluetooth bundle reports and Pico BT state diagnostics. Keep run-mode CDC ports out of setup recovery.
1 parent 0e9f379 commit 77d6dfd

23 files changed

Lines changed: 1727 additions & 191 deletions

bridge/src/cdc.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ use zeroize::Zeroize;
1919

2020
use crate::firmware_version::FirmwareVersion;
2121

22-
/// Pico setup-mode USB IDs. Distinct from the run-mode HID PID so Windows
23-
/// does not cache one driver binding across a descriptor change.
22+
/// CouchLink CDC USB IDs. Setup mode and Bluetooth run mode both use this
23+
/// identity; wired USB-output run personas use separate descriptors.
2424
pub const SETUP_VID: u16 = 0x2E8A;
2525
pub const SETUP_PID: u16 = 0xCAF0;
2626

@@ -42,6 +42,8 @@ pub const CMD_SET_DEVICE_NAME: u8 = 0x08;
4242
pub const CMD_GET_UNIQUE_ID: u8 = 0x09;
4343
pub const CMD_GET_LOG_BUFFER: u8 = 0x0A;
4444
pub const CMD_REBOOT_TO_BOOTSEL: u8 = 0x0B;
45+
pub const CMD_BT_STATE: u8 = 0x0C;
46+
pub const CMD_BT_HEARTBEAT: u8 = 0x0D;
4547

4648
// Response opcodes
4749
pub const RSP_HELLO: u8 = 0x81;
@@ -54,6 +56,8 @@ pub const RSP_SET_DEVICE_NAME: u8 = 0x88;
5456
pub const RSP_UNIQUE_ID: u8 = 0x89;
5557
pub const RSP_LOG_BUFFER: u8 = 0x8A;
5658
pub const RSP_REBOOT_TO_BOOTSEL: u8 = 0x8B;
59+
pub const RSP_BT_STATE: u8 = 0x8C;
60+
pub const RSP_BT_HEARTBEAT: u8 = 0x8D;
5761
pub const RSP_NACK: u8 = 0xFE;
5862

5963
/// Short human label for a response opcode, used in the rare "unexpected
@@ -71,6 +75,8 @@ fn response_name(command: u8) -> &'static str {
7175
RSP_UNIQUE_ID => "UNIQUE_ID",
7276
RSP_LOG_BUFFER => "LOG_BUFFER",
7377
RSP_REBOOT_TO_BOOTSEL => "REBOOT_TO_BOOTSEL_ACK",
78+
RSP_BT_STATE => "BT_STATE_ACK",
79+
RSP_BT_HEARTBEAT => "BT_HEARTBEAT_ACK",
7480
RSP_NACK => "NACK",
7581
_ => "unknown",
7682
}
@@ -88,6 +94,11 @@ pub const ERR_AUTH_FAIL: u8 = 0x11;
8894
pub const ERR_NO_2G_NETWORK: u8 = 0x12;
8995
pub const ERR_INTERNAL: u8 = 0xFF;
9096

97+
pub const HELLO_FLAG_CREDS_PRESENT: u8 = 0x01;
98+
pub const HELLO_FLAG_WIFI_JOINED: u8 = 0x02;
99+
pub const HELLO_FLAG_RUN_MODE_OK: u8 = 0x04;
100+
pub const HELLO_FLAG_RUN_MODE_ACTIVE: u8 = 0x08;
101+
91102
pub fn err_name(code: u8) -> &'static str {
92103
match code {
93104
ERR_BAD_CRC => "bad CRC",
@@ -231,6 +242,15 @@ impl PicoSetup {
231242
Ok(resp)
232243
}
233244

245+
pub fn write_frame_no_response(&mut self, command: u8, seq: u8, payload: &[u8]) -> Result<()> {
246+
let frame = encode(command, seq, payload);
247+
self.port.write_all(&frame).context("writing CDC frame")?;
248+
if let Err(e) = self.port.flush() {
249+
tracing::debug!("cdc: flush after write returned {e:?}");
250+
}
251+
Ok(())
252+
}
253+
234254
// Like exchange() but produces a command-specific NACK error message.
235255
fn exchange_named(
236256
&mut self,
@@ -712,7 +732,11 @@ pub struct HelloAck {
712732

713733
impl HelloAck {
714734
pub fn creds_present(&self) -> bool {
715-
self.flags & 0x01 != 0
735+
self.flags & HELLO_FLAG_CREDS_PRESENT != 0
736+
}
737+
738+
pub fn run_mode_active(&self) -> bool {
739+
self.flags & HELLO_FLAG_RUN_MODE_ACTIVE != 0
716740
}
717741

718742
pub fn firmware_version(&self) -> FirmwareVersion {

bridge/src/cmd_bundle/manifest.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::{config, logfile};
88

99
use super::collect::BUNDLE_LOG_FILES_PER_PREFIX;
1010

11-
pub(super) const BUNDLE_SCHEMA_VERSION: u8 = 22;
11+
pub(super) const BUNDLE_SCHEMA_VERSION: u8 = 23;
1212

1313
#[derive(Clone, Debug, Serialize)]
1414
pub(super) struct ManifestPicoCapture {
@@ -24,6 +24,7 @@ pub(super) struct ManifestPicoCapture {
2424
pub pico_state_status: String,
2525
pub usb_packet_dump_status: String,
2626
pub usb_packet_dump_count: usize,
27+
pub bluetooth_report_status: String,
2728
pub cached_state_included: bool,
2829
}
2930

@@ -73,6 +74,10 @@ pub(super) struct Manifest {
7374
adapter_survey_path: &'static str,
7475
adapter_survey_json_included: bool,
7576
adapter_survey_json_path: &'static str,
77+
bluetooth_report_included: bool,
78+
bluetooth_report_path: &'static str,
79+
bluetooth_report_json_included: bool,
80+
bluetooth_report_json_path: &'static str,
7681
app_log_retention_count: usize,
7782
bundled_log_files_per_prefix: usize,
7883
debug_packet_log_retention_count: usize,
@@ -164,6 +169,10 @@ pub(super) async fn build_manifest(
164169
adapter_survey_path: "adapter-survey.txt",
165170
adapter_survey_json_included: true,
166171
adapter_survey_json_path: "adapter-survey.json",
172+
bluetooth_report_included: true,
173+
bluetooth_report_path: "bluetooth-report.txt",
174+
bluetooth_report_json_included: true,
175+
bluetooth_report_json_path: "bluetooth-report.json",
167176
app_log_retention_count: logfile::LOG_FILE_RETENTION,
168177
bundled_log_files_per_prefix: BUNDLE_LOG_FILES_PER_PREFIX,
169178
debug_packet_log_retention_count: crate::debug_packets::DEBUG_PACKET_FILE_RETENTION,
@@ -198,6 +207,8 @@ pub(super) async fn build_manifest(
198207
"initial-usb-capture.txt preserves USB packet lines harvested before bundle switches personas.",
199208
"adapter-survey.txt and adapter-survey.json record live persona checks for PS3, generic HID, PS4, keyboard, XInput, Xbox One, and Maple shapes without prompting, including expected, attempted, missing, failed, coverage_status, and stop_reason fields.",
200209
"Bundle restores the original persona after the adapter survey pass.",
210+
"Bluetooth mode is captured separately in bluetooth-report.txt and bluetooth-report.json because the Pico stays plugged into the PC and receives live controller input over USB CDC.",
211+
"Bluetooth mode does not use the USB adapter survey; its live controller packet path is PC USB CDC to Pico, then Bluetooth HID to the paired receiver.",
201212
"Debug input mode uses the XInput USB shape; debug evidence is not treated as proof that a HID persona works with an adapter.",
202213
"When a surveyed persona gets descriptor traffic but does not configure, bundle requests a one-shot USB packet capture boot for that same persona.",
203214
"If persona switching, USB diagnostic queries, packet capture, or persona restore cannot complete, bundle-capture.txt records the failed step, duration, and reason.",
@@ -266,6 +277,7 @@ mod tests {
266277
pico_state_status: "captured".to_string(),
267278
usb_packet_dump_status: "captured".to_string(),
268279
usb_packet_dump_count: 2,
280+
bluetooth_report_status: "not_applicable".to_string(),
269281
cached_state_included: true,
270282
};
271283
let host = ManifestHostSnapshot {
@@ -324,6 +336,10 @@ mod tests {
324336
assert_eq!(json["adapter_survey_path"], "adapter-survey.txt");
325337
assert_eq!(json["adapter_survey_json_included"], true);
326338
assert_eq!(json["adapter_survey_json_path"], "adapter-survey.json");
339+
assert_eq!(json["bluetooth_report_included"], true);
340+
assert_eq!(json["bluetooth_report_path"], "bluetooth-report.txt");
341+
assert_eq!(json["bluetooth_report_json_included"], true);
342+
assert_eq!(json["bluetooth_report_json_path"], "bluetooth-report.json");
327343
assert_eq!(
328344
json["retained_debug_packet_logs"][0],
329345
"usb-packets-20260615-214000-02E22DA9.log"

0 commit comments

Comments
 (0)