Skip to content

Commit c184ea7

Browse files
committed
fix(bluetooth): recover source slot during stream (2026.6.23.2-9853)
1 parent d25fcff commit c184ea7

6 files changed

Lines changed: 151 additions & 13 deletions

File tree

bridge/src/cmd_run.rs

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -593,15 +593,11 @@ fn selected_pico_xinput_source_error(instance_ids: &[String]) -> String {
593593
}
594594

595595
async fn wait_for_bluetooth_source_slots(routes: &mut [StreamRoute], quiet: bool) -> Result<()> {
596-
let bluetooth_route_count = routes
597-
.iter()
598-
.filter(|route| route.pico.persona.is_bluetooth())
599-
.count();
600-
if bluetooth_route_count == 0 {
596+
if !routes.iter().any(|route| route.pico.persona.is_bluetooth()) {
601597
return Ok(());
602598
}
603599

604-
let allow_auto_switch = bluetooth_route_count == 1;
600+
let allow_auto_switch = should_auto_switch_bluetooth_source(routes);
605601
let deadline = Instant::now() + BLUETOOTH_SOURCE_WAIT;
606602
let mut announced_wait = false;
607603
loop {
@@ -723,6 +719,10 @@ fn bluetooth_source_slot_decision(
723719
BluetoothSourceSlotDecision::Missing
724720
}
725721

722+
fn should_auto_switch_bluetooth_source(routes: &[StreamRoute]) -> bool {
723+
routes.len() == 1 && routes[0].pico.persona.is_bluetooth()
724+
}
725+
726726
#[derive(Clone, Debug)]
727727
struct MissingBluetoothSource {
728728
pico_uid: String,
@@ -822,11 +822,12 @@ pub async fn stream_routes(routes: Vec<StreamRoute>, options: StreamOptions) ->
822822
print_stream_intro(&routes, socket.local_addr()?);
823823
}
824824

825+
let allow_bluetooth_source_auto_switch = should_auto_switch_bluetooth_source(&routes);
825826
let mut runtime: Vec<RouteRuntime> = routes
826827
.into_iter()
827828
.map(|route| {
828829
let bluetooth_usb = bluetooth_usb_links.remove(&route.pico.info.unique_id_short);
829-
RouteRuntime::new(route, bluetooth_usb)
830+
RouteRuntime::new(route, bluetooth_usb, allow_bluetooth_source_auto_switch)
830831
})
831832
.collect();
832833
// Bring the injected-input keyboard hook up before the first tick so a
@@ -972,14 +973,21 @@ struct RouteRuntime {
972973
last_key: protocol::KeyboardReport,
973974
last_packet_number: Option<u32>,
974975
source_connected: bool,
976+
allow_source_auto_switch: bool,
977+
source_auto_switch_count: u32,
978+
last_source_auto_switch: Option<String>,
975979
last_send_type: &'static str,
976980
last_recovery_attempt: Option<Instant>,
977981
recovery_hint_printed: bool,
978982
bluetooth_pairing_hint_printed: bool,
979983
}
980984

981985
impl RouteRuntime {
982-
fn new(route: StreamRoute, bluetooth_usb: Option<cdc::PicoSetup>) -> Self {
986+
fn new(
987+
route: StreamRoute,
988+
bluetooth_usb: Option<cdc::PicoSetup>,
989+
allow_source_auto_switch: bool,
990+
) -> Self {
983991
Self {
984992
route,
985993
bluetooth_usb,
@@ -996,6 +1004,9 @@ impl RouteRuntime {
9961004
last_key: protocol::KeyboardReport::default(),
9971005
last_packet_number: None,
9981006
source_connected: false,
1007+
allow_source_auto_switch,
1008+
source_auto_switch_count: 0,
1009+
last_source_auto_switch: None,
9991010
last_send_type: "heartbeat",
10001011
last_recovery_attempt: None,
10011012
recovery_hint_printed: false,
@@ -1069,7 +1080,8 @@ impl RouteRuntime {
10691080
}
10701081

10711082
fn next_controller_packet(&mut self) -> Packet {
1072-
let source = xinput::read_slot(self.route.source_slot);
1083+
let source = xinput::read_slot(self.route.source_slot)
1084+
.or_else(|| self.try_auto_switch_bluetooth_source());
10731085
let (state, packet_number, connected) = match source {
10741086
Some(snapshot) => (snapshot.state, Some(snapshot.packet_number), true),
10751087
None => (GamepadState::default(), None, false),
@@ -1091,6 +1103,40 @@ impl RouteRuntime {
10911103
packet
10921104
}
10931105

1106+
fn try_auto_switch_bluetooth_source(&mut self) -> Option<xinput::SlotSnapshot> {
1107+
if !self.allow_source_auto_switch || !self.route.pico.persona.is_bluetooth() {
1108+
return None;
1109+
}
1110+
let connected = xinput::connected_slots();
1111+
let BluetoothSourceSlotDecision::AutoSwitch { from, to } =
1112+
bluetooth_source_slot_decision(self.route.source_slot, &connected, true)
1113+
else {
1114+
return None;
1115+
};
1116+
let snapshot = connected
1117+
.iter()
1118+
.find(|snapshot| snapshot.slot == to)
1119+
.copied()?;
1120+
self.route.source_slot = to;
1121+
self.source_auto_switch_count = self.source_auto_switch_count.saturating_add(1);
1122+
self.last_source_auto_switch = Some(format!(
1123+
"{} -> {}",
1124+
xinput::user_slot_label(from),
1125+
xinput::user_slot_label(to)
1126+
));
1127+
self.last_packet_number = None;
1128+
self.source_connected = false;
1129+
tracing::warn!(
1130+
"stream: Bluetooth source auto-switched during stream pico={} from {} to {} packet={} {}",
1131+
self.route.pico.uid_hex(),
1132+
xinput::user_slot_label(from),
1133+
xinput::user_slot_label(to),
1134+
snapshot.packet_number,
1135+
format_xinput_snapshot(&snapshot)
1136+
);
1137+
Some(snapshot)
1138+
}
1139+
10941140
fn next_keyboard_packet(&mut self) -> Packet {
10951141
let report = keyboard::read_keyboard();
10961142
// The host keyboard is always present in the Parsec model, so the
@@ -1269,16 +1315,27 @@ fn print_status(routes: &mut [RouteRuntime]) {
12691315
sent_delta: Some(sent_delta),
12701316
last_inbound_ms_ago,
12711317
source_connected: Some(route.source_connected),
1318+
source_auto_switch_count: Some(route.source_auto_switch_count),
1319+
last_source_auto_switch: route.last_source_auto_switch.clone(),
12721320
last_send_type: Some(route.last_send_type.to_string()),
12731321
}),
12741322
);
1323+
let source_switch_note = match (
1324+
route.source_auto_switch_count,
1325+
route.last_source_auto_switch.as_deref(),
1326+
) {
1327+
(0, _) => String::new(),
1328+
(count, Some(last)) => format!(" | source auto-switches {count} last {last}"),
1329+
(count, None) => format!(" | source auto-switches {count}"),
1330+
};
12751331
if bluetooth_route {
12761332
println!(
1277-
" {} -> {} ({}) | {} | PC USB input +{} total {} | Bluetooth output {} | {} {}",
1333+
" {} -> {} ({}) | {}{} | PC USB input +{} total {} | Bluetooth output {} | {} {}",
12781334
route.route.source_label(),
12791335
route.route.pico.uid_hex(),
12801336
route.route.pico.persona.label(),
12811337
source_state,
1338+
source_switch_note,
12821339
sent_delta,
12831340
route.sent_total,
12841341
peer_state,
@@ -1287,10 +1344,11 @@ fn print_status(routes: &mut [RouteRuntime]) {
12871344
);
12881345
} else {
12891346
println!(
1290-
" {} -> {} | {} | out +{} total {} | in {} ({}) | {} {}",
1347+
" {} -> {} | {}{} | out +{} total {} | in {} ({}) | {} {}",
12911348
route.route.source_label(),
12921349
route.route.pico.uid_hex(),
12931350
source_state,
1351+
source_switch_note,
12941352
sent_delta,
12951353
route.sent_total,
12961354
route.inbound_total,

bridge/src/cmd_run/tests.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,28 @@ fn bluetooth_source_slot_decision_refuses_ambiguous_or_absent_source() {
188188
);
189189
}
190190

191+
#[test]
192+
fn bluetooth_source_auto_switch_only_allows_single_bluetooth_route() {
193+
let mut bluetooth = pico(0x07D37EB6, "192.168.50.226", protocol::BOARD_PICO_2_W);
194+
bluetooth.persona = Persona::BluetoothHid;
195+
let xinput = pico(0x523861E6, "192.168.50.4", protocol::BOARD_PICO_W_RP2040);
196+
197+
assert!(should_auto_switch_bluetooth_source(&[StreamRoute {
198+
source_slot: 1,
199+
pico: bluetooth.clone(),
200+
}]));
201+
assert!(!should_auto_switch_bluetooth_source(&[
202+
StreamRoute {
203+
source_slot: 1,
204+
pico: bluetooth,
205+
},
206+
StreamRoute {
207+
source_slot: 0,
208+
pico: xinput,
209+
},
210+
]));
211+
}
212+
191213
#[test]
192214
fn bluetooth_source_preflight_error_lists_live_slots() {
193215
let message = bluetooth_source_preflight_error(&[MissingBluetoothSource {
@@ -213,13 +235,15 @@ fn debug_packet_harvest_targets_only_include_enabled_debug_routes() {
213235
pico: debug,
214236
},
215237
None,
238+
false,
216239
),
217240
RouteRuntime::new(
218241
StreamRoute {
219242
source_slot: 1,
220243
pico: xinput,
221244
},
222245
None,
246+
false,
223247
),
224248
];
225249

@@ -289,6 +313,7 @@ fn bluetooth_routes_do_not_schedule_udp_recovery() {
289313
pico: bt,
290314
},
291315
None,
316+
true,
292317
)];
293318
routes[0].last_inbound = Instant::now() - (PEER_STALE_AFTER + Duration::from_secs(1));
294319

bridge/src/pico_cache.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ pub struct RouteSnapshot {
102102
pub sent_delta: Option<u64>,
103103
pub last_inbound_ms_ago: Option<u64>,
104104
pub source_connected: Option<bool>,
105+
pub source_auto_switch_count: Option<u32>,
106+
pub last_source_auto_switch: Option<String>,
105107
pub last_send_type: Option<String>,
106108
}
107109

pico-bridge/src/bt_hid.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ static void request_send_now(bool force) {
262262
hid_device_request_can_send_now_event(bt_hid_cid);
263263
}
264264

265+
void bt_hid_request_send_now(bool force) {
266+
request_send_now(force);
267+
}
268+
265269
static void send_pending_report(void) {
266270
uint8_t interrupt_report[BT_HID_INTERRUPT_REPORT_LEN];
267271
if (bt_hid_pending_report.len > BT_HID_MAX_WIRE_REPORT_LEN)

pico-bridge/src/bt_hid.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,6 @@ typedef struct {
8686
bool bt_hid_target_from_persona(run_persona_t persona, bt_hid_target_t *out);
8787
bool bt_hid_init(bt_hid_target_t target);
8888
void bt_hid_reset_stack_state(void);
89+
void bt_hid_request_send_now(bool force);
8990
void bt_hid_snapshot(bt_hid_snapshot_t *out);
9091
void bt_hid_task(void);

pico-bridge/src/cdc_handlers.c

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ static uint32_t bt_cdc_last_heartbeat_ms;
3333
static uint8_t bt_cdc_last_seq;
3434
static uint8_t bt_cdc_last_command;
3535
static uint8_t bt_cdc_last_flags;
36+
static bool bt_cdc_logged_first_frame;
37+
static bool bt_cdc_logged_parsec_connected;
38+
static bool bt_cdc_logged_non_neutral;
3639

3740
static void write_cdc_frame(const uint8_t *frame, size_t n) {
3841
if (!frame || n == 0)
@@ -168,7 +171,18 @@ static size_t handle_reboot_to_bootsel(uint8_t seq, uint8_t *reply, size_t cap)
168171
return cdc_encode(CDC_RSP_REBOOT_TO_BOOTSEL, seq, NULL, 0, reply, cap);
169172
}
170173

171-
static void apply_bt_state_body(const uint8_t *payload) {
174+
static bool bt_state_body_non_neutral(const uint8_t *payload) {
175+
const uint8_t *body = &payload[1];
176+
for (size_t i = 0; i < 12; i++) {
177+
if (body[i] != 0)
178+
return true;
179+
}
180+
return false;
181+
}
182+
183+
static bool apply_bt_state_body(const uint8_t *payload) {
184+
gamepad_state_t previous = g_gamepad_state;
185+
int previous_parsec_connected = g_parsec_connected;
172186
const uint8_t flags = payload[0];
173187
const uint8_t *body = &payload[1];
174188
g_gamepad_state.buttons = (uint16_t)body[0] | ((uint16_t)body[1] << 8);
@@ -180,6 +194,13 @@ static void apply_bt_state_body(const uint8_t *payload) {
180194
g_gamepad_state.right_y = (int16_t)((uint16_t)body[10] | ((uint16_t)body[11] << 8));
181195
g_parsec_connected = (flags & 0x01u) ? 1 : 0;
182196
g_last_packet_ms = to_ms_since_boot(get_absolute_time());
197+
return previous.buttons != g_gamepad_state.buttons ||
198+
previous.left_trigger != g_gamepad_state.left_trigger ||
199+
previous.right_trigger != g_gamepad_state.right_trigger ||
200+
previous.left_x != g_gamepad_state.left_x || previous.left_y != g_gamepad_state.left_y ||
201+
previous.right_x != g_gamepad_state.right_x ||
202+
previous.right_y != g_gamepad_state.right_y ||
203+
previous_parsec_connected != g_parsec_connected;
183204
}
184205

185206
static void note_bt_cdc_frame(const cdc_frame_view_t *req) {
@@ -190,6 +211,27 @@ static void note_bt_cdc_frame(const cdc_frame_view_t *req) {
190211
bt_cdc_last_flags = req->payload_len > 0 ? req->payload[0] : 0;
191212
}
192213

214+
static void log_bt_cdc_frame_milestones(const cdc_frame_view_t *req, bool changed) {
215+
if (!bt_cdc_logged_first_frame) {
216+
diag_log_printf("cdc: first Bluetooth input cmd=0x%02X seq=%u flags=0x%02X",
217+
(unsigned)req->command, (unsigned)req->seq, (unsigned)bt_cdc_last_flags);
218+
bt_cdc_logged_first_frame = true;
219+
}
220+
if (!bt_cdc_logged_parsec_connected && (bt_cdc_last_flags & 0x01u)) {
221+
diag_log_printf("cdc: Bluetooth input source connected seq=%u", (unsigned)req->seq);
222+
bt_cdc_logged_parsec_connected = true;
223+
}
224+
if (!bt_cdc_logged_non_neutral && bt_state_body_non_neutral(req->payload)) {
225+
const uint8_t *body = &req->payload[1];
226+
uint16_t buttons = (uint16_t)body[0] | ((uint16_t)body[1] << 8);
227+
diag_log_printf(
228+
"cdc: Bluetooth input non-neutral seq=%u changed=%u buttons=0x%04X lt=%u rt=%u",
229+
(unsigned)req->seq, changed ? 1u : 0u, (unsigned)buttons, (unsigned)body[2],
230+
(unsigned)body[3]);
231+
bt_cdc_logged_non_neutral = true;
232+
}
233+
}
234+
193235
static size_t handle_bt_state(const cdc_frame_view_t *req, uint8_t *reply, size_t cap) {
194236
note_bt_cdc_frame(req);
195237
if (boot_mode_current() != BOOT_MODE_RUN ||
@@ -210,7 +252,10 @@ static size_t handle_bt_state(const cdc_frame_view_t *req, uint8_t *reply, size_
210252
bt_cdc_heartbeat_count++;
211253
bt_cdc_last_heartbeat_ms = bt_cdc_last_frame_ms;
212254
}
213-
apply_bt_state_body(req->payload);
255+
bool changed = apply_bt_state_body(req->payload);
256+
log_bt_cdc_frame_milestones(req, changed);
257+
if (changed)
258+
bt_hid_request_send_now(true);
214259
return 0;
215260
}
216261

@@ -443,6 +488,9 @@ void cdc_handlers_init(void) {
443488
bt_cdc_last_seq = 0;
444489
bt_cdc_last_command = 0;
445490
bt_cdc_last_flags = 0;
491+
bt_cdc_logged_first_frame = false;
492+
bt_cdc_logged_parsec_connected = false;
493+
bt_cdc_logged_non_neutral = false;
446494
}
447495

448496
bool cdc_handlers_reboot_pending(void) {

0 commit comments

Comments
 (0)