Skip to content

Commit 7a73c13

Browse files
committed
fix(bluetooth): avoid Pico self-selection as input (2026.6.23.0-CDDC)
1 parent 1938b17 commit 7a73c13

5 files changed

Lines changed: 199 additions & 9 deletions

File tree

bridge/src/cmd_home.rs

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ enum InputModeChoice {
134134
Family(&'static [protocol::Persona]),
135135
}
136136

137+
impl InputModeChoice {
138+
fn bluetooth_persona(self) -> Option<protocol::Persona> {
139+
match self {
140+
Self::Persona(persona) if persona.is_bluetooth() => Some(persona),
141+
_ => None,
142+
}
143+
}
144+
}
145+
137146
async fn scan_pico_inventory() -> Result<PicoInventory> {
138147
let mut inventory = PicoInventory::default();
139148
match cmd_run::discover_picos(Duration::from_secs(HOME_SCAN_SECONDS)).await {
@@ -473,7 +482,7 @@ fn missing_saved_card(pico: config::PicoIdentity) -> PicoCard {
473482
impl PicoAction {
474483
fn label(&self) -> String {
475484
match self {
476-
Self::StartStreaming { .. } => "Start streaming with Controller 1".to_string(),
485+
Self::StartStreaming { .. } => "Start streaming".to_string(),
477486
Self::ChooseRouting { .. } => "Choose controller and stream".to_string(),
478487
Self::CheckUsbAdapter { .. } => "Check console USB adapter".to_string(),
479488
Self::SwitchToUsbDebug { .. } => "Switch to USB debug mode".to_string(),
@@ -804,13 +813,22 @@ fn print_xinput_sources() {
804813
async fn route_one(picos: Vec<cmd_run::PicoTarget>) -> Result<()> {
805814
let pico_items: Vec<String> = picos.iter().map(|p| p.detail_label()).collect();
806815
let pico_index = select("Which Pico should receive the controller?", &pico_items, 0).await?;
816+
let pico = picos[pico_index].clone();
817+
let mode = choose_input_mode().await?;
818+
if mode.bluetooth_persona().is_some() {
819+
let routes = vec![cmd_run::StreamRoute {
820+
source_slot: 0,
821+
pico,
822+
}];
823+
let routes = prepare_routes_for_input_mode(routes, mode).await?;
824+
let routes = choose_bluetooth_source_slots(routes).await?;
825+
return start_stream(routes, true).await;
826+
}
827+
807828
let source_slot =
808829
choose_source_slot("Which Windows controller should feed that Pico?", None).await?;
809-
let routes = vec![cmd_run::StreamRoute {
810-
source_slot,
811-
pico: picos[pico_index].clone(),
812-
}];
813-
stream(routes, true).await
830+
let routes = vec![cmd_run::StreamRoute { source_slot, pico }];
831+
stream_with_mode(routes, mode, true).await
814832
}
815833

816834
async fn choose_source_slot(prompt: &str, default_slot: Option<u32>) -> Result<u32> {
@@ -829,7 +847,24 @@ fn slot_item(slot: u32) -> String {
829847

830848
async fn stream(routes: Vec<cmd_run::StreamRoute>, save: bool) -> Result<()> {
831849
let mode = choose_input_mode().await?;
850+
stream_with_mode(routes, mode, save).await
851+
}
852+
853+
async fn stream_with_mode(
854+
routes: Vec<cmd_run::StreamRoute>,
855+
mode: InputModeChoice,
856+
save: bool,
857+
) -> Result<()> {
832858
let routes = prepare_routes_for_input_mode(routes, mode).await?;
859+
let routes = if mode.bluetooth_persona().is_some() {
860+
choose_bluetooth_source_slots(routes).await?
861+
} else {
862+
routes
863+
};
864+
start_stream(routes, save).await
865+
}
866+
867+
async fn start_stream(routes: Vec<cmd_run::StreamRoute>, save: bool) -> Result<()> {
833868
println!();
834869
println!("Ready to stream:");
835870
for route in &routes {
@@ -849,6 +884,35 @@ async fn stream(routes: Vec<cmd_run::StreamRoute>, save: bool) -> Result<()> {
849884
.await
850885
}
851886

887+
async fn choose_bluetooth_source_slots(
888+
mut routes: Vec<cmd_run::StreamRoute>,
889+
) -> Result<Vec<cmd_run::StreamRoute>> {
890+
let targets: Vec<_> = routes.iter().map(|route| route.pico.clone()).collect();
891+
cmd_persona::wait_for_bluetooth_xinput_release(&targets, false).await?;
892+
893+
print_xinput_sources();
894+
if routes.len() == 1 {
895+
routes[0].source_slot = choose_source_slot(
896+
"Which Windows controller should feed that Bluetooth Pico?",
897+
Some(routes[0].source_slot),
898+
)
899+
.await?;
900+
return Ok(routes);
901+
}
902+
903+
for route in &mut routes {
904+
route.source_slot = choose_source_slot(
905+
&format!(
906+
"Which Windows controller should feed {}?",
907+
route.pico.short_label()
908+
),
909+
Some(route.source_slot),
910+
)
911+
.await?;
912+
}
913+
Ok(routes)
914+
}
915+
852916
async fn choose_input_mode() -> Result<InputModeChoice> {
853917
let choices = vec![
854918
menu_item(

bridge/src/cmd_home/tests.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,20 @@ fn seed_saved_picos_migrates_legacy_last_pico_once() {
192192
assert!(!seed_saved_picos_from_legacy_last(&mut cfg));
193193
assert_eq!(cfg.picos.len(), 1);
194194
}
195+
196+
#[test]
197+
fn input_mode_choice_identifies_bluetooth_personas() {
198+
assert_eq!(
199+
InputModeChoice::Persona(protocol::Persona::BluetoothHid).bluetooth_persona(),
200+
Some(protocol::Persona::BluetoothHid)
201+
);
202+
assert_eq!(
203+
InputModeChoice::Persona(protocol::Persona::BluetoothXbox).bluetooth_persona(),
204+
Some(protocol::Persona::BluetoothXbox)
205+
);
206+
assert_eq!(
207+
InputModeChoice::Persona(protocol::Persona::Xinput).bluetooth_persona(),
208+
None
209+
);
210+
assert_eq!(InputModeChoice::Auto.bluetooth_persona(), None);
211+
}

bridge/src/cmd_lab.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ pub async fn run(options: LabOptions) -> Result<()> {
129129
Ok(())
130130
}
131131

132+
pub(crate) fn connected_xinput_instance_ids_for_uids(uids: &BTreeSet<u32>) -> Result<Vec<String>> {
133+
let instances = pnp_instances_for_uids(uids, &[PnpPersona::Xinput])?;
134+
Ok(pnp_instance_ids(&instances))
135+
}
136+
132137
struct LabHarness {
133138
options: LabOptions,
134139
cfg: config::Config,

bridge/src/cmd_persona.rs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@
88
//! the same Wi-Fi persona switch before the streaming loop opens the local
99
//! USB diagnostic port.
1010
11-
use std::collections::HashSet;
11+
use std::collections::{BTreeSet, HashSet};
1212
use std::time::{Duration, Instant};
1313

1414
use anyhow::{bail, Result};
1515

1616
use crate::protocol::Persona;
17-
use crate::{cmd_run, pico_cache, pico_mode, support};
17+
use crate::{cmd_lab, cmd_run, pico_cache, pico_mode, support};
1818

1919
pub(crate) const DISCOVER: Duration = Duration::from_secs(5);
2020
pub(crate) const REBOOT_WAIT: Duration = Duration::from_secs(60);
21+
const XINPUT_RELEASE_WAIT: Duration = Duration::from_secs(8);
22+
const XINPUT_RELEASE_POLL: Duration = Duration::from_millis(500);
2123

2224
pub async fn run(desired: Persona, selectors: Vec<String>, all: bool, stream: bool) -> Result<()> {
2325
tracing::info!(
@@ -128,10 +130,80 @@ pub async fn run(desired: Persona, selectors: Vec<String>, all: bool, stream: bo
128130
desired.label()
129131
);
130132
}
131-
let routes = cmd_run::auto_routes(ready, Some((0..4).collect()))?;
133+
if desired.is_bluetooth() {
134+
wait_for_bluetooth_xinput_release(&ready, false).await?;
135+
}
136+
let routes = cmd_run::auto_routes(ready, preferred_source_slots_for_persona(desired))?;
132137
cmd_run::stream_routes(routes, cmd_run::StreamOptions::default()).await
133138
}
134139

140+
fn preferred_source_slots_for_persona(desired: Persona) -> Option<Vec<u32>> {
141+
if desired.is_bluetooth() {
142+
None
143+
} else {
144+
Some((0..4).collect())
145+
}
146+
}
147+
148+
pub(crate) async fn wait_for_bluetooth_xinput_release(
149+
targets: &[cmd_run::PicoTarget],
150+
quiet: bool,
151+
) -> Result<()> {
152+
let uids: BTreeSet<u32> = targets
153+
.iter()
154+
.map(|target| target.info.unique_id_short)
155+
.collect();
156+
if uids.is_empty() {
157+
return Ok(());
158+
}
159+
160+
let deadline = Instant::now() + XINPUT_RELEASE_WAIT;
161+
let mut announced = false;
162+
loop {
163+
let instance_ids = match cmd_lab::connected_xinput_instance_ids_for_uids(&uids) {
164+
Ok(instance_ids) if instance_ids.is_empty() => {
165+
if announced && !quiet {
166+
println!("Selected Pico XInput endpoint is gone.");
167+
}
168+
return Ok(());
169+
}
170+
Ok(instance_ids) => instance_ids,
171+
Err(e) => {
172+
if !quiet {
173+
println!(
174+
"Note: could not verify whether the selected Pico is still exposed as a Windows XInput source: {e:#}"
175+
);
176+
println!(
177+
"Choose the Parsec or local controller source, not the selected Pico."
178+
);
179+
}
180+
return Ok(());
181+
}
182+
};
183+
184+
if Instant::now() >= deadline {
185+
bail!("{}", selected_pico_xinput_release_error(&instance_ids));
186+
}
187+
188+
if !announced && !quiet {
189+
println!("Waiting for Windows to remove the selected Pico's old XInput source...");
190+
announced = true;
191+
}
192+
tokio::time::sleep(XINPUT_RELEASE_POLL).await;
193+
}
194+
}
195+
196+
fn selected_pico_xinput_release_error(instance_ids: &[String]) -> String {
197+
let details = if instance_ids.is_empty() {
198+
"no instance ID captured".to_string()
199+
} else {
200+
instance_ids.join(", ")
201+
};
202+
format!(
203+
"the selected Pico is still exposed as a local XInput device after switching to Bluetooth ({details}). Unplug/replug the Pico or wait for Windows to remove the old controller, then run the Bluetooth command again."
204+
)
205+
}
206+
135207
pub(crate) fn select_targets(
136208
picos: &[cmd_run::PicoTarget],
137209
selectors: &[String],
@@ -203,3 +275,6 @@ fn merge_targets(
203275
out.extend(reappeared);
204276
out
205277
}
278+
279+
#[cfg(test)]
280+
mod tests;

bridge/src/cmd_persona/tests.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
use super::*;
2+
3+
#[test]
4+
fn selected_pico_xinput_release_error_names_instance_ids() {
5+
let message =
6+
selected_pico_xinput_release_error(
7+
&[r"USB\VID_045E&PID_028E\E6613852837C242C".to_string()],
8+
);
9+
10+
assert!(message.contains("still exposed as a local XInput device"));
11+
assert!(message.contains(r"USB\VID_045E&PID_028E\E6613852837C242C"));
12+
assert!(message.contains("run the Bluetooth command again"));
13+
}
14+
15+
#[test]
16+
fn bluetooth_persona_commands_reselect_live_source_slots() {
17+
assert_eq!(
18+
preferred_source_slots_for_persona(Persona::BluetoothHid),
19+
None
20+
);
21+
assert_eq!(
22+
preferred_source_slots_for_persona(Persona::BluetoothXbox),
23+
None
24+
);
25+
assert_eq!(
26+
preferred_source_slots_for_persona(Persona::Xinput),
27+
Some(vec![0, 1, 2, 3])
28+
);
29+
}

0 commit comments

Comments
 (0)