Allow controller reconnect without BOOTSEL

This commit is contained in:
Joey Yakimowich-Payne 2026-09-01 07:38:21 -06:00
commit 146c377311
5 changed files with 39 additions and 63 deletions

View file

@ -50,18 +50,17 @@ Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad3
1. Flash and connect the Pico 2 W to the Switch.
2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`.
3. Hold the Pico's BOOTSEL button for about two seconds, until the onboard LED starts double-blinking. This opens a 60-second pairing window.
4. Put a controller into Bluetooth pairing mode:
3. Put a controller into Bluetooth pairing mode:
- DualSense: hold Create + PS.
- DualShock 4: hold Share + PS.
- Switch Pro: press its sync button.
- Xbox Bluetooth controller: hold its pair button.
- 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required.
5. Wait for the controller's player light to settle. Repeat step 4 for additional controllers while the window remains open. Holding BOOTSEL again extends the window by 60 seconds from that point.
4. Wait for the controller's player light to settle. Repeat step 3 for additional controllers while slots remain free. Optionally hold BOOTSEL for about two seconds to show the 60-second double-blink pairing indication.
Pairing order determines the initial USB slot assignment. Up to four controllers map 1:1 to the four emulated Switch Pro Controller interfaces.
Outside the BOOTSEL-open window, Bluetooth discovery and incoming connections are disabled. The Pico does not scan for or reconnect disconnected controllers while locked. Pairing keys still persist, but reconnecting a previously paired controller also requires opening the BOOTSEL window before pressing its normal power button.
While a slot is free, the Pico continuously runs Bluepad32's normal Bluetooth discovery and autoconnect path. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button; BOOTSEL is not required. A controller placed into explicit Bluetooth pairing mode can also pair during this scan.
### LED meanings and device state
@ -69,15 +68,15 @@ The Pico 2 W onboard LED reports the overall Bluetooth state:
- **Double blink**: the bounded pairing window is open.
- **Fast blink**: a controller connection is still completing its handshake.
- **Solid**: at least one controller is active.
- **Slow blink**: no controller is active and pairing is locked.
- **Slow blink**: no controller is active; Bluetooth discovery and autoconnect are running.
- **Solid immediately after boot that never transitions**: Bluepad32 initialization did not complete; check firmware flashing and UART logs.
### Managing controller disconnect and reconnect
- **Disconnect a controller**: its slot immediately publishes neutral buttons, sticks, and motion. Other connected controllers are unaffected.
- **Reconnect a paired controller**: hold BOOTSEL until the LED double-blinks, then power on the controller normally.
- **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
- **Pairing window expires**: scanning and incoming connections stop; already connected controllers remain connected.
- **Reconnect a paired controller**: power it on normally with its Home, PS, or Xbox button.
- **Pair a new controller**: put it into its explicit Bluetooth pairing mode while a slot is free. BOOTSEL is optional and only changes the LED to the bounded double-blink indication.
- **Pairing indication expires**: the LED returns to its normal state; scanning continues while a slot is free.
### Per-controller ABXY layout

View file

@ -77,7 +77,6 @@ enum class ConnectionStatus {
enum class ConnectionPolicyState {
Uninitialized,
Open,
Locked,
Paused,
FailedClosed,
};
@ -467,15 +466,12 @@ bool update_pairing_window(uint32_t now_ms) {
return false;
}
void apply_connection_policy(uint32_t now_ms) {
void apply_connection_policy() {
const bool free_slot = has_free_slot();
const bool pairing_open = pairing_window_active_at(now_ms);
if ((!free_slot &&
g_connection_policy_state == ConnectionPolicyState::Paused) ||
(free_slot && pairing_open &&
g_connection_policy_state == ConnectionPolicyState::Open) ||
(free_slot && !pairing_open &&
g_connection_policy_state == ConnectionPolicyState::Locked)) {
(free_slot &&
g_connection_policy_state == ConnectionPolicyState::Open)) {
return;
}
@ -487,13 +483,8 @@ void apply_connection_policy(uint32_t now_ms) {
return;
}
if (!pairing_open) {
g_connection_policy_state = ConnectionPolicyState::Locked;
return;
}
// Use Bluepad32's normal pairing/autoconnect path while the physical
// BOOTSEL gesture has explicitly opened the pairing window.
// Bluepad32's normal scan/autoconnect path handles both remembered
// controllers powering on and controllers in explicit pairing mode.
uni_bt_allow_incoming_connections(true);
uni_bt_start_scanning_and_autoconnect_unsafe();
g_connection_policy_state = ConnectionPolicyState::Open;
@ -523,9 +514,7 @@ void update_status_led() {
void process_rumble_timer(btstack_timer_source_t* timer) {
const uint32_t now_ms = btstack_run_loop_get_time_ms();
if (update_pairing_window(now_ms)) {
apply_connection_policy(now_ms);
}
update_pairing_window(now_ms);
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
RumbleEnvelope envelope{};
@ -591,7 +580,7 @@ void recompute_connection_status() {
update_pairing_window(now_ms);
g_connection_status = compute_connection_status();
g_status_led_tick = 0;
apply_connection_policy(now_ms);
apply_connection_policy();
}
void platform_init(int argc, const char** argv) {
@ -600,8 +589,7 @@ void platform_init(int argc, const char** argv) {
}
void platform_on_init_complete() {
// Discovery and incoming connections remain disabled until BOOTSEL opens
// the bounded pairing window.
// Keep Bluepad32 autoconnect active whenever at least one slot is free.
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs);
btstack_run_loop_add_timer(&g_rumble_timer);

Binary file not shown.

Binary file not shown.

View file

@ -186,10 +186,9 @@ namespace {
void start_backend() {
bluepad32_input_backend_init();
platform_on_init_complete();
require(!incoming_connections,
"initialization must keep incoming connections closed");
require(scan_starts == 0,
"initialization must not scan before a BOOTSEL request");
require(incoming_connections && scanning_enabled &&
classic_scanning_enabled && scan_starts == 1,
"initialization must start Bluepad32 autoconnect");
}
void start_pairing_backend() {
start_backend();
@ -591,20 +590,20 @@ void test_pairing_window_policy() {
"discovery must remain closed before backend initialization");
start_backend();
require(g_connection_policy_state == ConnectionPolicyState::Locked &&
!classic_scanning_enabled && !scanning_enabled &&
!incoming_connections,
"boot must disable all discovery and incoming connections");
require(g_connection_policy_state == ConnectionPolicyState::Open &&
classic_scanning_enabled && scanning_enabled &&
incoming_connections,
"boot must allow normal Bluepad32 autoconnect");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"locked policy must reject every discovery");
UNI_ERROR_SUCCESS,
"boot policy must accept a discovered controller");
uni_hid_device_t rejected = device(0);
platform_on_device_connected(&rejected);
require(device_disconnect_calls == 1 &&
last_disconnected_device == &rejected &&
g_slots[0].device == nullptr,
"locked policy must disconnect every incoming controller");
uni_hid_device_t reconnecting = device(0);
platform_on_device_connected(&reconnecting);
require(device_disconnect_calls == 0 &&
g_slots[0].device == &reconnecting,
"boot policy must retain a reconnecting controller");
platform_on_device_disconnected(&reconnecting);
bluepad32_input_backend_open_pairing_window();
require(!g_pairing_window_open,
@ -615,17 +614,7 @@ void test_pairing_window_policy() {
g_connection_policy_state == ConnectionPolicyState::Open &&
classic_scanning_enabled && scanning_enabled &&
incoming_connections,
"BOOTSEL window must run Bluepad32's normal pairing scan");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_SUCCESS,
"open pairing window must accept a discovered controller");
uni_hid_device_t paired = device(0);
platform_on_device_connected(&paired);
require(device_disconnect_calls == 1 &&
g_slots[0].device == &paired,
"open pairing window must retain a connected controller");
platform_on_device_disconnected(&paired);
"BOOTSEL must expose pairing indication without interrupting scan");
tick_backend_timer(20);
require(!observed_status_led_on,
@ -652,21 +641,21 @@ void test_pairing_window_policy() {
require(g_connection_policy_state == ConnectionPolicyState::Paused &&
g_pairing_window_open && !scanning_enabled &&
!classic_scanning_enabled && !incoming_connections,
"full slots must pause pairing without closing the deadline");
"full slots must pause scanning without closing the deadline");
now_ms = 90000;
process_rumble_timer(&g_rumble_timer);
require(!g_pairing_window_open &&
g_connection_policy_state == ConnectionPolicyState::Paused,
"deadline must expire while slots remain full");
"pairing indication must expire while slots remain full");
platform_on_device_disconnected(&devices[3]);
require(g_connection_policy_state == ConnectionPolicyState::Locked &&
!classic_scanning_enabled && !scanning_enabled &&
!incoming_connections,
"a freed slot after expiry must remain closed");
require(g_connection_policy_state == ConnectionPolicyState::Open &&
classic_scanning_enabled && scanning_enabled &&
incoming_connections,
"a freed slot must resume autoconnect after pairing indication expires");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"expired pairing policy must reject discovery");
UNI_ERROR_SUCCESS,
"resumed autoconnect must accept a discovered controller");
}