Gate controller pairing behind BOOTSEL

This commit is contained in:
Joey Yakimowich-Payne 2026-08-31 08:25:08 -06:00
commit 4a73680bd2
24 changed files with 934 additions and 145 deletions

View file

@ -90,10 +90,14 @@ add_executable(switch-pico
switch_haptics.cpp switch_haptics.cpp
) )
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
target_sources(switch-pico PRIVATE bluepad32_input_backend.cpp) target_sources(switch-pico PRIVATE
bluepad32_input_backend.cpp
bootsel_pairing_button.cpp
)
target_compile_definitions(switch-pico PRIVATE target_compile_definitions(switch-pico PRIVATE
SWITCH_PICO_BLUEPAD32=1 SWITCH_PICO_BLUEPAD32=1
SWITCH_PICO_HID_INSTANCE_COUNT=4 SWITCH_PICO_HID_INSTANCE_COUNT=4
PICO_FLASH_ASSUME_CORE1_SAFE=0
) )
else() else()
target_compile_definitions(switch-pico PRIVATE target_compile_definitions(switch-pico PRIVATE
@ -125,6 +129,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
pico_btstack_classic pico_btstack_classic
pico_btstack_cyw43 pico_btstack_cyw43
pico_multicore pico_multicore
pico_flash
) )
endif() endif()

View file

@ -46,40 +46,38 @@ The default `python3 build.py` command and `firmware/switch-pico.*` artifacts re
Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0. Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0.
### Pairing two controllers ### Pairing up to four controllers
1. Flash and connect the Pico 2 W to the Switch. 1. Flash and connect the Pico 2 W to the Switch.
2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`. 2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`.
3. Put the first controller into Bluetooth pairing mode: 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:
- DualSense: hold Create + PS. - DualSense: hold Create + PS.
- DualShock 4: hold Share + PS. - DualShock 4: hold Share + PS.
- Switch Pro: press its sync button. - Switch Pro: press its sync button.
- Xbox Bluetooth controller: hold its pair button. - Xbox Bluetooth controller: hold its pair button.
- 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required. - 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required.
4. Wait for the first controller to connect and become ready. The LED continues slow-blinking because the second slot remains open. Pairing keys persist across Pico reboots. 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.
5. Put the second controller into pairing mode and wait for it to connect and become ready. The LED turns solid only after both controllers are active.
During initial setup, pairing order determines the initial slot assignment: the first controller paired occupies slot 0, and the second occupies slot 1. Pairing keys persist, so both controllers can reconnect after a Pico reboot without re-pairing. Slot numbers are not permanently bound to physical controllers: while one controller remains connected, a returning controller fills the other open slot; after a reboot or whenever both slots are empty, whichever persisted controller reconnects first receives slot 0, so the physical controllers can swap USB interfaces. 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.
### LED meanings and device state ### LED meanings and device state
The Pico 2 W onboard LED reports the overall Bluetooth state: The Pico 2 W onboard LED reports the overall Bluetooth state:
- **Slow blink (0.5 s period)**: at least one slot is open and scanning for a Bluetooth controller. - **Double blink**: the bounded pairing window is open.
- **Fast blink (0.1 s period)**: at least one controller is connected but not yet ready (handshake in progress). - **Fast blink**: a controller connection is still completing its handshake.
- **Solid**: both slots are filled and both controllers are ready for input. - **Solid**: at least one controller is active.
- **Solid immediately after boot that never starts blinking**: Bluepad32 initialization did not complete; check firmware flashing and UART logs. - **Slow blink**: no controller is active and pairing is locked.
- **Solid immediately after boot that never transitions**: Bluepad32 initialization did not complete; check firmware flashing and UART logs.
The LED transitions to slow blink as soon as any slot becomes empty (e.g., a controller is turned off or unpaired). Scanning resumes automatically.
### Managing controller disconnect and reconnect ### Managing controller disconnect and reconnect
Controllers can disconnect and reconnect independently: - **Disconnect a controller**: its slot immediately publishes neutral buttons, sticks, and motion. Other connected controllers are unaffected.
- **Disconnect one controller**: that controller's slot becomes empty. The LED transitions to slow blink if both slots are no longer filled. The other controller continues sending input. - **Reconnect a paired controller**: hold BOOTSEL until the LED double-blinks, then power on the controller normally.
- **Reconnect while the other controller remains connected**: the returning controller fills the only open slot, preserving the current assignment. The LED transitions through fast blink and back to solid. - **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
- **Reconnect after both slots become empty or after reboot**: reconnection/autoconnect order determines the assignments. The physical controllers can swap USB interfaces if their order changes. - **Pairing window expires**: scanning and incoming connections stop; already connected controllers remain connected.
- **Turn off or unpair a controller**: delete it from Bluetooth settings on the Pico or reset pairing entirely using Bluepad32 commands. It will no longer auto-reconnect; the slot remains open for a new controller.
When a controller disconnects, the Pico immediately publishes neutral buttons, sticks, and motion for that slot. The other controller is unaffected.
### Controller capabilities ### Controller capabilities
@ -98,15 +96,15 @@ Rumble effects are per-slot and independent. The Switch sends rumble commands to
### Hardware validation ### Hardware validation
The dual-interface AIO build has been verified on a real Switch with two DualSense controllers: the Switch assigned two controller slots; buttons, sticks, calibrated motion, and rumble remained independent; disconnecting either controller left the other working; scanning resumed and the disconnected controller reconnected to the open slot. The four-interface AIO build has been verified on a real Switch with two DualSense controllers: the Switch assigned independent controller slots, and buttons, sticks, calibrated motion, rumble, and disconnect isolation worked per controller. Fresh DualSense pairing through the BOOTSEL-open window has also been verified on hardware.
To reproduce the validation: To reproduce the validation:
1. **Verify USB enumeration**: Connect the Pico 2 W to a USB host (PC, Mac, or USB analyzer). Confirm that two HID devices are present (e.g., `lsusb -v` on Linux shows interface 0 and interface 1, both with Product ID 0x2009). 1. **Verify USB enumeration**: Connect the Pico 2 W to a USB host or analyzer. Confirm that four HID interfaces are present, using IN/OUT endpoint pairs `0x81/0x01` through `0x84/0x04`.
2. **Verify Bluetooth pairing**: Pair two controllers via Bluepad32. Confirm the LED transitions from scanning → fast blink → solid. 2. **Verify Bluetooth pairing**: Hold BOOTSEL until the LED double-blinks, put a controller into explicit pairing mode, and confirm its player light settles.
3. **Verify input on one controller**: Move sticks, press buttons, and check that the controller paired first during initial setup appears in slot 0. 3. **Verify input on one controller**: Move sticks and press buttons; confirm only its assigned Switch slot changes.
4. **Verify input on two controllers**: Move sticks on the controller paired second during initial setup, and confirm its inputs appear in slot 1 while the first controller is unaffected. 4. **Verify input on two controllers**: Move the second controller independently and confirm the first controller's slot is unaffected.
5. **Verify disconnect and reconnect**: Turn off one controller while leaving the other connected. The LED reverts to slow blink. Turn the disconnected controller back on; it reconnects to the only open slot. Verify the occupied slot continues reporting the other controller's input. 5. **Verify the pairing gate**: Disconnect a controller and confirm it does not reconnect while locked. Open the BOOTSEL window, power it on, and confirm it can connect.
6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates. 6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates.
7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output). 7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output).

View file

@ -15,6 +15,7 @@
#define ENABLE_LE_DATA_LENGTH_EXTENSION #define ENABLE_LE_DATA_LENGTH_EXTENSION
#define ENABLE_LE_PERIPHERAL #define ENABLE_LE_PERIPHERAL
#define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION #define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION
#define ENABLE_LE_RESOLVING_LIST
#define ENABLE_LE_SECURE_CONNECTIONS #define ENABLE_LE_SECURE_CONNECTIONS
#else #else
#error "BP32: ENABLE_BLE should be defined" #error "BP32: ENABLE_BLE should be defined"

View file

@ -2,10 +2,12 @@
#include <limits.h> #include <limits.h>
#include <stddef.h> #include <stddef.h>
#include <string.h>
#include <btstack_run_loop.h> #include <btstack_run_loop.h>
#include <pico/critical_section.h> #include <pico/critical_section.h>
#include <pico/cyw43_arch.h> #include <pico/cyw43_arch.h>
#include <pico/flash.h>
#include <pico/multicore.h> #include <pico/multicore.h>
#include <pico/stdlib.h> #include <pico/stdlib.h>
#include <uni.h> #include <uni.h>
@ -20,6 +22,7 @@ constexpr int32_t kTriggerThreshold = (kTriggerMaximum * 35) / 100;
constexpr uint16_t kRumbleDurationMs = 50; constexpr uint16_t kRumbleDurationMs = 50;
constexpr uint32_t kRumblePollIntervalMs = 5; constexpr uint32_t kRumblePollIntervalMs = 5;
constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT;
constexpr uint32_t kPairingWindowDurationMs = 60000;
static_assert(kSlotCount == 4); static_assert(kSlotCount == 4);
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount); static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount);
@ -31,6 +34,14 @@ enum class ConnectionStatus {
Ready, Ready,
}; };
enum class ConnectionPolicyState {
Uninitialized,
Open,
Locked,
Paused,
FailedClosed,
};
struct RumbleEnvelope { struct RumbleEnvelope {
uint8_t slot; uint8_t slot;
uint32_t connection_generation; uint32_t connection_generation;
@ -51,16 +62,22 @@ struct BackendSlot {
critical_section_t g_state_lock; critical_section_t g_state_lock;
BackendSlot g_slots[kSlotCount]; BackendSlot g_slots[kSlotCount];
// These acknowledgement generations are only read or written by Core 0. // These acknowledgement generations and the pairing request producer are only
// used by Core 0. The request is transferred under the cross-core state lock.
uint32_t g_consumed_generation[kSlotCount]{}; uint32_t g_consumed_generation[kSlotCount]{};
uint32_t g_last_snapshot_generation[kSlotCount]{}; uint32_t g_last_snapshot_generation[kSlotCount]{};
bool g_pairing_window_requested = false;
bool g_initialized = false; bool g_initialized = false;
bool g_started = false; bool g_started = false;
// The timer and connection status are only read or written by Core 1 / BTstack. // These fields are only read or written by Core 1 / BTstack.
btstack_timer_source_t g_rumble_timer{}; btstack_timer_source_t g_rumble_timer{};
ConnectionStatus g_connection_status = ConnectionStatus::Initializing; ConnectionStatus g_connection_status = ConnectionStatus::Initializing;
ConnectionPolicyState g_connection_policy_state =
ConnectionPolicyState::Uninitialized;
uint32_t g_pairing_window_deadline_ms = 0;
uint16_t g_status_led_tick = 0; uint16_t g_status_led_tick = 0;
bool g_pairing_window_open = false;
bool g_status_led_on = false; bool g_status_led_on = false;
SwitchInputState make_neutral_state() { SwitchInputState make_neutral_state() {
@ -76,6 +93,26 @@ bool valid_slot(uint8_t slot) {
return slot < kSlotCount; return slot < kSlotCount;
} }
bool has_free_slot() {
critical_section_enter_blocking(&g_state_lock);
bool free_slot = false;
for (const BackendSlot& slot : g_slots) {
free_slot = free_slot || slot.device == nullptr;
}
critical_section_exit(&g_state_lock);
return free_slot;
}
bool has_active_controller() {
critical_section_enter_blocking(&g_state_lock);
bool active_controller = false;
for (const BackendSlot& slot : g_slots) {
active_controller = active_controller || slot.active;
}
critical_section_exit(&g_state_lock);
return active_controller;
}
int slot_for_device(const uni_hid_device_t* device) { int slot_for_device(const uni_hid_device_t* device) {
if (device == nullptr) { if (device == nullptr) {
return -1; return -1;
@ -125,6 +162,8 @@ void publish_all_neutral() {
} }
critical_section_exit(&g_state_lock); critical_section_exit(&g_state_lock);
g_connection_status = ConnectionStatus::Initializing; g_connection_status = ConnectionStatus::Initializing;
g_connection_policy_state = ConnectionPolicyState::FailedClosed;
g_pairing_window_open = false;
g_status_led_tick = 0; g_status_led_tick = 0;
} }
@ -240,21 +279,79 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
return state; return state;
} }
bool pairing_window_active_at(uint32_t now_ms) {
return g_pairing_window_open &&
static_cast<int32_t>(now_ms - g_pairing_window_deadline_ms) < 0;
}
bool update_pairing_window(uint32_t now_ms) {
critical_section_enter_blocking(&g_state_lock);
const bool requested = g_pairing_window_requested;
g_pairing_window_requested = false;
critical_section_exit(&g_state_lock);
if (requested) {
g_pairing_window_open = true;
g_pairing_window_deadline_ms = now_ms + kPairingWindowDurationMs;
g_status_led_tick = 0;
return true;
}
if (g_pairing_window_open && !pairing_window_active_at(now_ms)) {
g_pairing_window_open = false;
g_status_led_tick = 0;
return true;
}
return false;
}
void apply_connection_policy(uint32_t now_ms) {
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)) {
return;
}
uni_bt_allow_incoming_connections(false);
uni_bt_stop_scanning_unsafe();
if (!free_slot) {
g_connection_policy_state = ConnectionPolicyState::Paused;
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.
uni_bt_allow_incoming_connections(true);
uni_bt_start_scanning_and_autoconnect_unsafe();
g_connection_policy_state = ConnectionPolicyState::Open;
}
void update_status_led() { void update_status_led() {
++g_status_led_tick; ++g_status_led_tick;
bool led_on = false; bool led_on = false;
switch (g_connection_status) {
case ConnectionStatus::Initializing: if (pairing_window_active_at(btstack_run_loop_get_time_ms())) {
case ConnectionStatus::Ready: const uint16_t phase = g_status_led_tick % 200;
led_on = true; led_on = phase < 20 || (phase >= 40 && phase < 60);
break; } else if (g_connection_status == ConnectionStatus::Connecting) {
case ConnectionStatus::Scanning: led_on = (g_status_led_tick % 40) < 20;
led_on = (g_status_led_tick % 200) < 100; } else if (g_connection_status == ConnectionStatus::Initializing ||
break; has_active_controller()) {
case ConnectionStatus::Connecting: led_on = true;
led_on = (g_status_led_tick % 40) < 20; } else {
break; led_on = (g_status_led_tick % 200) < 100;
} }
if (led_on != g_status_led_on) { if (led_on != g_status_led_on) {
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, led_on); cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, led_on);
g_status_led_on = led_on; g_status_led_on = led_on;
@ -262,6 +359,11 @@ void update_status_led() {
} }
void process_rumble_timer(btstack_timer_source_t* timer) { 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);
}
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
RumbleEnvelope envelope{}; RumbleEnvelope envelope{};
uni_hid_device_t* device = nullptr; uni_hid_device_t* device = nullptr;
@ -274,14 +376,16 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
slot.rumble_pending = false; slot.rumble_pending = false;
dispatch = envelope.slot == slot_index && slot.active && dispatch = envelope.slot == slot_index && slot.active &&
slot.device != nullptr && slot.device != nullptr &&
envelope.connection_generation == slot.connection_generation; envelope.connection_generation ==
slot.connection_generation;
if (dispatch) { if (dispatch) {
device = slot.device; device = slot.device;
} }
} }
critical_section_exit(&g_state_lock); critical_section_exit(&g_state_lock);
if (dispatch && device->report_parser.play_dual_rumble != nullptr) { if (dispatch &&
device->report_parser.play_dual_rumble != nullptr) {
device->report_parser.play_dual_rumble( device->report_parser.play_dual_rumble(
device, 0, kRumbleDurationMs, device, 0, kRumbleDurationMs,
envelope.rumble.high_frequency_magnitude, envelope.rumble.high_frequency_magnitude,
@ -294,19 +398,12 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
btstack_run_loop_add_timer(timer); btstack_run_loop_add_timer(timer);
} }
void resume_connections() {
uni_bt_allow_incoming_connections(true);
uni_bt_start_scanning_and_autoconnect_unsafe();
}
void recompute_connection_status() { void recompute_connection_status() {
const uint32_t now_ms = btstack_run_loop_get_time_ms();
update_pairing_window(now_ms);
g_connection_status = compute_connection_status(); g_connection_status = compute_connection_status();
g_status_led_tick = 0; g_status_led_tick = 0;
if (g_connection_status == ConnectionStatus::Ready) { apply_connection_policy(now_ms);
uni_bt_stop_scanning_unsafe();
uni_bt_allow_incoming_connections(false);
} else {
resume_connections();
}
} }
void platform_init(int argc, const char** argv) { void platform_init(int argc, const char** argv) {
@ -315,23 +412,35 @@ void platform_init(int argc, const char** argv) {
} }
void platform_on_init_complete() { void platform_on_init_complete() {
// Discovery and incoming connections remain disabled until BOOTSEL opens
// the bounded pairing window.
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs); btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs);
btstack_run_loop_add_timer(&g_rumble_timer); btstack_run_loop_add_timer(&g_rumble_timer);
recompute_connection_status(); recompute_connection_status();
} }
uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) { uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name,
uint16_t cod, uint8_t rssi) {
(void)addr; (void)addr;
(void)name; (void)name;
(void)cod; (void)cod;
(void)rssi; (void)rssi;
return compute_connection_status() == ConnectionStatus::Ready return has_free_slot() &&
? UNI_ERROR_IGNORE_DEVICE g_connection_policy_state == ConnectionPolicyState::Open
: UNI_ERROR_SUCCESS; ? UNI_ERROR_SUCCESS
: UNI_ERROR_IGNORE_DEVICE;
} }
void platform_on_device_connected(uni_hid_device_t* device) { void platform_on_device_connected(uni_hid_device_t* device) {
if (device == nullptr) {
return;
}
if (g_connection_policy_state != ConnectionPolicyState::Open) {
uni_hid_device_disconnect(device);
return;
}
const int slot_index = slot_for_device(device); const int slot_index = slot_for_device(device);
if (slot_index < 0) { if (slot_index < 0) {
return; return;
@ -453,22 +562,26 @@ uni_platform* get_platform() {
return &platform; return &platform;
} }
[[noreturn]] void halt_wireless_backend() {
publish_all_neutral();
while (true) {
tight_loop_contents();
}
}
[[noreturn]] void core1_main() { [[noreturn]] void core1_main() {
if (!flash_safe_execute_core_init()) {
halt_wireless_backend();
}
if (cyw43_arch_init() != 0) { if (cyw43_arch_init() != 0) {
publish_all_neutral(); halt_wireless_backend();
while (true) {
tight_loop_contents();
}
} }
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, true); cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, true);
g_status_led_on = true; g_status_led_on = true;
uni_platform_set_custom(get_platform()); uni_platform_set_custom(get_platform());
if (uni_init(0, nullptr) != 0) { if (uni_init(0, nullptr) != 0) {
publish_all_neutral(); halt_wireless_backend();
while (true) {
tight_loop_contents();
}
} }
btstack_run_loop_execute(); btstack_run_loop_execute();
@ -493,6 +606,11 @@ void bluepad32_input_backend_init() {
g_consumed_generation[slot_index] = 0; g_consumed_generation[slot_index] = 0;
g_last_snapshot_generation[slot_index] = 0; g_last_snapshot_generation[slot_index] = 0;
} }
g_pairing_window_requested = false;
g_connection_status = ConnectionStatus::Initializing;
g_connection_policy_state = ConnectionPolicyState::Uninitialized;
g_pairing_window_deadline_ms = 0;
g_pairing_window_open = false;
g_initialized = true; g_initialized = true;
} }
@ -503,11 +621,28 @@ void bluepad32_input_backend_start() {
if (g_started) { if (g_started) {
return; return;
} }
// Core 0 services USB from flash while Core 1 owns BTstack. Register both
// cores before either side can initiate a flash-backed BTstack TLV write.
if (!flash_safe_execute_core_init()) {
g_connection_policy_state = ConnectionPolicyState::FailedClosed;
return;
}
g_started = true; g_started = true;
multicore_launch_core1(core1_main); multicore_launch_core1(core1_main);
} }
void bluepad32_input_backend_open_pairing_window() {
if (!g_initialized) {
bluepad32_input_backend_init();
}
critical_section_enter_blocking(&g_state_lock);
g_pairing_window_requested = true;
critical_section_exit(&g_state_lock);
}
bool bluepad32_input_backend_snapshot(uint8_t slot_index, SwitchInputState* out) { bool bluepad32_input_backend_snapshot(uint8_t slot_index, SwitchInputState* out) {
if (out == nullptr || !valid_slot(slot_index)) { if (out == nullptr || !valid_slot(slot_index)) {
return false; return false;

View file

@ -9,6 +9,7 @@ constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4;
void bluepad32_input_backend_init(); void bluepad32_input_backend_init();
void bluepad32_input_backend_start(); void bluepad32_input_backend_start();
void bluepad32_input_backend_open_pairing_window();
bool bluepad32_input_backend_snapshot(uint8_t slot, SwitchInputState* out); bool bluepad32_input_backend_snapshot(uint8_t slot, SwitchInputState* out);
void bluepad32_input_backend_report_sent(uint8_t slot); void bluepad32_input_backend_report_sent(uint8_t slot);
void bluepad32_input_backend_queue_rumble(uint8_t slot, void bluepad32_input_backend_queue_rumble(uint8_t slot,

View file

@ -0,0 +1,99 @@
#include "bootsel_pairing_button.h"
#include "hardware/gpio.h"
#include "hardware/structs/ioqspi.h"
#include "hardware/structs/sio.h"
#include "pico/flash.h"
#include "pico/time.h"
#if PICO_RP2350
#include "hardware/regs/sio.h"
#endif
namespace {
constexpr uint32_t kPollIntervalMs = 100;
constexpr uint32_t kFlashSafeTimeoutMs = 100;
constexpr uint32_t kQspiCsPinIndex = 1;
BootselPairingButtonHoldFsm g_hold_fsm;
uint32_t g_last_sample_ms = 0;
// QSPI CSn sampling adapted from awalol/DS5Dongle's button_functions.cpp:
// https://github.com/awalol/DS5Dongle/blob/master/src/button_functions.cpp
// Copyright (c) 2026 awalol; used under the MIT License.
//
// This callback and everything it executes while CSn is floated must remain in
// SRAM or be an inlined hardware-register operation. In particular, do not add
// logging or ordinary flash-backed data access here.
void __no_inline_not_in_flash_func(read_bootsel_callback)(void* parameter) {
auto* pressed = static_cast<bool*>(parameter);
hw_write_masked(
&ioqspi_hw->io[kQspiCsPinIndex].ctrl,
GPIO_OVERRIDE_LOW << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB,
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS);
for (volatile uint32_t delay = 0; delay < 1000; ++delay) {
}
#if PICO_RP2350
*pressed =
(sio_hw->gpio_hi_in & SIO_GPIO_HI_IN_QSPI_CSN_BITS) == 0;
#else
*pressed = (sio_hw->gpio_hi_in & (1u << kQspiCsPinIndex)) == 0;
#endif
hw_write_masked(
&ioqspi_hw->io[kQspiCsPinIndex].ctrl,
GPIO_OVERRIDE_NORMAL << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB,
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS);
}
BootselPairingButtonSample sample_bootsel() {
bool pressed = false;
const int result = flash_safe_execute(read_bootsel_callback, &pressed,
kFlashSafeTimeoutMs);
if (result != PICO_OK) {
return BootselPairingButtonSample::kUnread;
}
return pressed ? BootselPairingButtonSample::kPressed
: BootselPairingButtonSample::kReleased;
}
} // namespace
bool BootselPairingButtonHoldFsm::update(
BootselPairingButtonSample sample) {
if (sample == BootselPairingButtonSample::kUnread) {
return false;
}
if (sample == BootselPairingButtonSample::kReleased) {
pressed_samples_ = 0;
hold_reported_ = false;
return false;
}
if (hold_reported_) {
return false;
}
++pressed_samples_;
if (pressed_samples_ < kHoldSamples) {
return false;
}
hold_reported_ = true;
return true;
}
bool bootsel_pairing_button_task() {
const uint32_t now_ms =
static_cast<uint32_t>(to_ms_since_boot(get_absolute_time()));
if (now_ms - g_last_sample_ms < kPollIntervalMs) {
return false;
}
g_last_sample_ms = now_ms;
return g_hold_fsm.update(sample_bootsel());
}

23
bootsel_pairing_button.h Normal file
View file

@ -0,0 +1,23 @@
#pragma once
#include <cstdint>
enum class BootselPairingButtonSample : uint8_t {
kUnread,
kReleased,
kPressed,
};
class BootselPairingButtonHoldFsm {
public:
static constexpr uint8_t kHoldSamples = 20;
bool update(BootselPairingButtonSample sample);
private:
uint8_t pressed_samples_ = 0;
bool hold_reported_ = false;
};
// Polls BOOTSEL at 10 Hz. Returns true once when a 20-sample hold completes.
bool bootsel_pairing_button_task();

Binary file not shown.

Binary file not shown.

View file

@ -7,6 +7,7 @@
#include "hardware/uart.h" #include "hardware/uart.h"
#else #else
#include "bluepad32_input_backend.h" #include "bluepad32_input_backend.h"
#include "bootsel_pairing_button.h"
#endif #endif
#ifdef SWITCH_PICO_LOG #ifdef SWITCH_PICO_LOG
@ -232,6 +233,9 @@ int main() {
while (true) { while (true) {
tud_task(); // USB device tasks tud_task(); // USB device tasks
#ifdef SWITCH_PICO_BLUEPAD32 #ifdef SWITCH_PICO_BLUEPAD32
if (bootsel_pairing_button_task()) {
bluepad32_input_backend_open_pairing_window();
}
for (uint8_t instance = 0; for (uint8_t instance = 0;
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
bluepad32_input_backend_snapshot(instance, bluepad32_input_backend_snapshot(instance,

View file

@ -7,13 +7,29 @@
namespace { namespace {
bool incoming_connections = false; bool incoming_connections = false;
int scan_starts = 0; int scan_starts = 0;
int scan_stops = 0; int scan_stops = 0;
bool scanning_enabled = false; bool scanning_enabled = false;
int classic_scan_starts = 0;
int classic_scan_stops = 0;
bool classic_scanning_enabled = false;
uni_platform* installed_platform = nullptr; uni_platform* installed_platform = nullptr;
bool observed_status_led_on = false; bool observed_status_led_on = false;
int observed_status_led_writes = 0; int observed_status_led_writes = 0;
uint32_t now_ms = 0;
bool flash_core_init_result = true;
int flash_core_init_calls = 0;
int core1_launch_calls = 0;
int cyw43_init_calls = 0;
int uni_init_calls = 0;
int device_disconnect_calls = 0;
uni_hid_device_t* last_disconnected_device = nullptr;
struct CoreStopped {};
void require(bool condition, const char* message) { void require(bool condition, const char* message) {
if (!condition) { if (!condition) {
@ -29,10 +45,14 @@ void play_rumble(uni_hid_device_t* device, uint16_t, uint16_t,
device->last_low = low; device->last_low = low;
} }
uni_hid_device_t device(int idx, bool gamepad = true) { uni_hid_device_t device(
int idx, bool gamepad = true,
uni_bt_conn_protocol_t protocol = UNI_BT_CONN_PROTOCOL_NONE) {
uni_hid_device_t result{}; uni_hid_device_t result{};
result.idx = idx; result.idx = idx;
result.gamepad = gamepad; result.gamepad = gamepad;
result.conn.protocol = protocol;
result.conn.btaddr[5] = static_cast<uint8_t>(idx + 1);
result.report_parser.play_dual_rumble = play_rumble; result.report_parser.play_dual_rumble = play_rumble;
return result; return result;
} }
@ -47,29 +67,66 @@ int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device) {
return device == nullptr ? -1 : device->idx; return device == nullptr ? -1 : device->idx;
} }
void uni_hid_device_disconnect(uni_hid_device_t* device) {
++device_disconnect_calls;
last_disconnected_device = device;
}
void uni_bt_allow_incoming_connections(bool enabled) { void uni_bt_allow_incoming_connections(bool enabled) {
incoming_connections = enabled; incoming_connections = enabled;
} }
void uni_bt_start_scanning_and_autoconnect_unsafe() {
void uni_bt_bredr_scan_start() {
++classic_scan_starts;
classic_scanning_enabled = true;
}
void uni_bt_bredr_scan_stop() {
if (classic_scanning_enabled) {
++classic_scan_stops;
}
classic_scanning_enabled = false;
}
void uni_bt_le_scan_start() {
++scan_starts; ++scan_starts;
scanning_enabled = true; scanning_enabled = true;
} }
void uni_bt_stop_scanning_unsafe() { void uni_bt_le_scan_stop() {
++scan_stops; if (scanning_enabled) {
++scan_stops;
}
scanning_enabled = false; scanning_enabled = false;
} }
void uni_bt_start_scanning_and_autoconnect_unsafe() {
uni_bt_bredr_scan_start();
uni_bt_le_scan_start();
}
void uni_bt_stop_scanning_unsafe() {
uni_bt_bredr_scan_stop();
uni_bt_le_scan_stop();
}
void uni_platform_set_custom(uni_platform* platform) { void uni_platform_set_custom(uni_platform* platform) {
installed_platform = platform; installed_platform = platform;
} }
int uni_init(int, const char**) { int uni_init(int, const char**) {
++uni_init_calls;
return 0; return 0;
} }
bool flash_safe_execute_core_init() {
++flash_core_init_calls;
return flash_core_init_result;
}
int cyw43_arch_init() { int cyw43_arch_init() {
++cyw43_init_calls;
return 0; return 0;
} }
@ -78,7 +135,18 @@ void cyw43_arch_gpio_put(int, bool enabled) {
++observed_status_led_writes; ++observed_status_led_writes;
} }
void multicore_launch_core1(void (*)()) {} void multicore_launch_core1(void (*)()) {
++core1_launch_calls;
}
void tight_loop_contents() {
throw CoreStopped{};
}
uint32_t btstack_run_loop_get_time_ms() {
return now_ms;
}
#include "../bluepad32_input_backend.cpp" #include "../bluepad32_input_backend.cpp"
@ -87,9 +155,22 @@ namespace {
void start_backend() { void start_backend() {
bluepad32_input_backend_init(); bluepad32_input_backend_init();
platform_on_init_complete(); platform_on_init_complete();
require(incoming_connections, "initialization must allow connections"); require(!incoming_connections,
require(scan_starts == 1, "initialization must start scanning"); "initialization must keep incoming connections closed");
require(scan_starts == 0,
"initialization must not scan before a BOOTSEL request");
} }
void start_pairing_backend() {
start_backend();
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(g_connection_policy_state == ConnectionPolicyState::Open &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections,
"test connection setup requires an open pairing window");
}
void tick_backend_timer(int ticks) { void tick_backend_timer(int ticks) {
for (int tick = 0; tick < ticks; ++tick) { for (int tick = 0; tick < ticks; ++tick) {
@ -98,7 +179,7 @@ void tick_backend_timer(int ticks) {
} }
void test_ready_order(bool reverse) { void test_ready_order(bool reverse) {
start_backend(); start_pairing_backend();
uni_hid_device_t devices[kSlotCount] = { uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)}; device(0), device(1), device(2), device(3)};
uni_hid_device_t replacements[kSlotCount] = { uni_hid_device_t replacements[kSlotCount] = {
@ -107,23 +188,7 @@ void test_ready_order(bool reverse) {
const int backward[kSlotCount] = {3, 2, 1, 0}; const int backward[kSlotCount] = {3, 2, 1, 0};
const int* order = reverse ? backward : forward; const int* order = reverse ? backward : forward;
tick_backend_timer(99);
require(observed_status_led_on,
"scanning LED must stay on for the first slow-blink half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"scanning LED must turn off at the slow-blink half-cycle");
platform_on_device_connected(&devices[order[0]]); platform_on_device_connected(&devices[order[0]]);
tick_backend_timer(19);
require(observed_status_led_on,
"connecting LED must stay on for the first fast-blink half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"connecting LED must turn off at the fast-blink half-cycle");
tick_backend_timer(20);
require(observed_status_led_on,
"connecting LED must turn on for the next fast-blink cycle");
for (int position = 0; position < kSlotCount; ++position) { for (int position = 0; position < kSlotCount; ++position) {
const int slot = order[position]; const int slot = order[position];
@ -146,14 +211,6 @@ void test_ready_order(bool reverse) {
"scanning must continue while any slot remains free"); "scanning must continue while any slot remains free");
require(incoming_connections, require(incoming_connections,
"incoming connections must remain enabled before all slots are ready"); "incoming connections must remain enabled before all slots are ready");
if (position == 0) {
tick_backend_timer(99);
require(observed_status_led_on,
"a partially full backend must use the scanning LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"a partially full backend must use the scanning LED off half-cycle");
}
} }
} }
@ -161,14 +218,6 @@ void test_ready_order(bool reverse) {
"scanning must stop exactly when all four slots are ready"); "scanning must stop exactly when all four slots are ready");
require(!incoming_connections, require(!incoming_connections,
"incoming connections must be disabled only when all slots are full"); "incoming connections must be disabled only when all slots are full");
tick_backend_timer(1);
require(observed_status_led_on,
"four ready slots must turn the status LED on");
const int ready_led_writes = observed_status_led_writes;
tick_backend_timer(200);
require(observed_status_led_on &&
observed_status_led_writes == ready_led_writes,
"four ready slots must keep the status LED solid");
for (int slot = 0; slot < kSlotCount; ++slot) { for (int slot = 0; slot < kSlotCount; ++slot) {
const int starts_before_disconnect = scan_starts; const int starts_before_disconnect = scan_starts;
@ -245,19 +294,13 @@ void test_rejections() {
} }
void test_independent_lifecycle() { void test_independent_lifecycle() {
start_backend(); start_pairing_backend();
uni_hid_device_t aborted = device(0); uni_hid_device_t aborted = device(0);
const uint32_t aborted_generation = g_slots[0].connection_generation; const uint32_t aborted_generation = g_slots[0].connection_generation;
platform_on_device_connected(&aborted); platform_on_device_connected(&aborted);
require(g_slots[0].device == &aborted && !g_slots[0].active, require(g_slots[0].device == &aborted && !g_slots[0].active,
"connected device must remain identifiable while becoming ready"); "connected device must remain identifiable while becoming ready");
tick_backend_timer(19);
require(observed_status_led_on,
"a lone pending connection must use the fast LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"a lone pending connection must use the fast LED off half-cycle");
const int starts_before_aborted_disconnect = scan_starts; const int starts_before_aborted_disconnect = scan_starts;
platform_on_device_disconnected(&aborted); platform_on_device_disconnected(&aborted);
@ -266,15 +309,10 @@ void test_independent_lifecycle() {
require(g_slots[0].connection_generation == aborted_generation + 1, require(g_slots[0].connection_generation == aborted_generation + 1,
"pre-ready disconnect must invalidate its connection generation"); "pre-ready disconnect must invalidate its connection generation");
require(g_connection_status == ConnectionStatus::Scanning && require(g_connection_status == ConnectionStatus::Scanning &&
scanning_enabled && incoming_connections && scanning_enabled && classic_scanning_enabled &&
scan_starts == starts_before_aborted_disconnect + 1, incoming_connections &&
"pre-ready disconnect with no peer must resume scanning"); scan_starts == starts_before_aborted_disconnect,
tick_backend_timer(99); "pre-ready disconnect must preserve the open pairing scan");
require(observed_status_led_on,
"pre-ready disconnect must restore the slow LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"pre-ready disconnect must restore the slow LED off half-cycle");
uni_hid_device_t devices[kSlotCount] = { uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)}; device(0), device(1), device(2), device(3)};
@ -284,12 +322,6 @@ void test_independent_lifecycle() {
!g_slots[slot].active, !g_slots[slot].active,
"each pending device must retain its indexed identity"); "each pending device must retain its indexed identity");
} }
tick_backend_timer(19);
require(observed_status_led_on,
"concurrent pending devices must use the fast LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"concurrent pending devices must use the fast LED off half-cycle");
const uint32_t first_pending_generation = const uint32_t first_pending_generation =
g_slots[0].connection_generation; g_slots[0].connection_generation;
@ -306,15 +338,10 @@ void test_independent_lifecycle() {
first_pending_generation + 1, first_pending_generation + 1,
"pending disconnect beside peers must invalidate its generation"); "pending disconnect beside peers must invalidate its generation");
require(g_connection_status == ConnectionStatus::Connecting && require(g_connection_status == ConnectionStatus::Connecting &&
scanning_enabled && incoming_connections && scanning_enabled && classic_scanning_enabled &&
incoming_connections &&
scan_starts == starts_before_first_pending_disconnect + 1, scan_starts == starts_before_first_pending_disconnect + 1,
"open slot must scan while other slots remain connecting"); "open pairing slot must preserve pending peers and resume scanning");
tick_backend_timer(19);
require(observed_status_led_on,
"surviving pending devices must retain the fast LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"surviving pending devices must retain the fast LED off half-cycle");
for (int slot = 1; slot < kSlotCount; ++slot) { for (int slot = 1; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS, require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
@ -412,11 +439,6 @@ void test_independent_lifecycle() {
devices[0].last_low == 115 && devices[0].last_low == 115 &&
devices[0].last_high == 116, devices[0].last_high == 116,
"slot 0 rumble must continue while slot 3 is disconnected"); "slot 0 rumble must continue while slot 3 is disconnected");
require(observed_status_led_on,
"disconnect scanning must use the slow LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"disconnect scanning must reach the slow LED off half-cycle");
uni_hid_device_t slot_three_replacement = device(3); uni_hid_device_t slot_three_replacement = device(3);
require(platform_on_device_ready(&slot_three_replacement) == require(platform_on_device_ready(&slot_three_replacement) ==
@ -425,13 +447,6 @@ void test_independent_lifecycle() {
process_rumble_timer(&g_rumble_timer); process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 0, require(slot_three_replacement.rumble_calls == 0,
"slot 3 replacement must not receive disconnected device rumble"); "slot 3 replacement must not receive disconnected device rumble");
require(observed_status_led_on,
"slot 3 replacement must restore the solid ready LED");
const int replacement_ready_led_writes = observed_status_led_writes;
tick_backend_timer(100);
require(observed_status_led_on &&
observed_status_led_writes == replacement_ready_led_writes,
"replacement quartet must keep the ready LED solid");
g_slots[3].pending_rumble = { g_slots[3].pending_rumble = {
3, disconnected_generation, SwitchRumbleOutput{77, 88}}; 3, disconnected_generation, SwitchRumbleOutput{77, 88}};
@ -536,6 +551,128 @@ void test_independent_lifecycle() {
"slot 0 activity must not evict the slot 3 mailbox"); "slot 0 activity must not evict the slot 3 mailbox");
} }
void test_pairing_window_policy() {
bd_addr_t address = {1, 2, 3, 4, 5, 6};
bluepad32_input_backend_init();
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"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(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"locked policy must reject every discovery");
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");
bluepad32_input_backend_open_pairing_window();
require(!g_pairing_window_open,
"Core0 request must wait for Core1 consumption");
process_rumble_timer(&g_rumble_timer);
require(g_pairing_window_open &&
g_pairing_window_deadline_ms == 60000 &&
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);
tick_backend_timer(20);
require(!observed_status_led_on,
"pairing double blink must finish its first pulse");
tick_backend_timer(20);
require(observed_status_led_on,
"pairing double blink must start its second pulse");
tick_backend_timer(20);
require(!observed_status_led_on,
"pairing double blink must finish its second pulse");
now_ms = 30000;
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(g_pairing_window_deadline_ms == 90000,
"pairing request must extend deadline from current Core1 time");
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (int slot = 0; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"policy test devices must fill all slots");
}
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");
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");
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(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"expired pairing policy must reject discovery");
}
void test_flash_core_start_contract() {
bluepad32_input_backend_init();
flash_core_init_result = false;
bluepad32_input_backend_start();
require(flash_core_init_calls == 1 && core1_launch_calls == 0 &&
g_connection_policy_state ==
ConnectionPolicyState::FailedClosed,
"Core0 flash-safe init failure must prevent Core1 launch");
flash_core_init_result = true;
bluepad32_input_backend_start();
require(flash_core_init_calls == 2 && core1_launch_calls == 1,
"Core0 must register as a flash-safe victim before Core1 launch");
bluepad32_input_backend_start();
require(flash_core_init_calls == 2 && core1_launch_calls == 1,
"backend start must remain idempotent");
}
void test_flash_core_init_fatal() {
bluepad32_input_backend_init();
flash_core_init_result = false;
bool stopped = false;
try {
core1_main();
} catch (const CoreStopped&) {
stopped = true;
}
require(stopped && flash_core_init_calls == 1 &&
cyw43_init_calls == 0 && uni_init_calls == 0 &&
g_connection_policy_state ==
ConnectionPolicyState::FailedClosed,
"flash-safe Core1 init failure must halt before CYW43 init");
}
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
@ -549,6 +686,12 @@ int main(int argc, char** argv) {
test_rejections(); test_rejections();
} else if (scenario == "lifecycle") { } else if (scenario == "lifecycle") {
test_independent_lifecycle(); test_independent_lifecycle();
} else if (scenario == "pairing-policy") {
test_pairing_window_policy();
} else if (scenario == "flash-core-start") {
test_flash_core_start_contract();
} else if (scenario == "flash-core-failure") {
test_flash_core_init_fatal();
} else { } else {
require(false, "unknown scenario"); require(false, "unknown scenario");
} }

View file

@ -19,4 +19,5 @@ inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer,
} }
inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {} inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {}
uint32_t btstack_run_loop_get_time_ms();
inline void btstack_run_loop_execute() {} inline void btstack_run_loop_execute() {}

View file

@ -0,0 +1,3 @@
#pragma once
bool flash_safe_execute_core_init();

View file

@ -1,3 +1,3 @@
#pragma once #pragma once
inline void tight_loop_contents() {} void tight_loop_contents();

View file

@ -3,6 +3,30 @@
#include <stdint.h> #include <stdint.h>
typedef uint8_t bd_addr_t[6]; typedef uint8_t bd_addr_t[6];
typedef uint8_t link_key_t[16];
typedef uint8_t sm_key_t[16];
typedef int link_key_type_t;
enum bd_addr_type_t {
BD_ADDR_TYPE_LE_PUBLIC = 0,
BD_ADDR_TYPE_LE_RANDOM = 1,
BD_ADDR_TYPE_LE_PUBLIC_IDENTITY = 2,
BD_ADDR_TYPE_LE_RANDOM_IDENTITY = 3,
BD_ADDR_TYPE_UNKNOWN = 0xfe,
};
enum hci_link_type_t {
HCI_LINK_TYPE_SCO = 0,
HCI_LINK_TYPE_ACL = 1,
};
struct btstack_link_key_iterator_t {
int index;
};
enum {
ERROR_CODE_SUCCESS = 0,
};
typedef int uni_property_idx_t; typedef int uni_property_idx_t;
typedef int uni_platform_oob_event_t; typedef int uni_platform_oob_event_t;
struct uni_property_t {}; struct uni_property_t {};
@ -63,7 +87,20 @@ struct uni_report_parser_t {
uni_play_dual_rumble_t play_dual_rumble; uni_play_dual_rumble_t play_dual_rumble;
}; };
enum uni_bt_conn_protocol_t {
UNI_BT_CONN_PROTOCOL_NONE,
UNI_BT_CONN_PROTOCOL_BR_EDR,
UNI_BT_CONN_PROTOCOL_BLE,
};
struct uni_bt_conn_t {
bd_addr_t btaddr;
uni_bt_conn_protocol_t protocol;
};
struct uni_hid_device_t { struct uni_hid_device_t {
uni_bt_conn_t conn;
int idx; int idx;
bool gamepad; bool gamepad;
uni_report_parser_t report_parser; uni_report_parser_t report_parser;
@ -91,8 +128,14 @@ struct uni_platform {
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device); bool uni_hid_device_is_gamepad(const uni_hid_device_t* device);
int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device); int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device);
void uni_hid_device_disconnect(uni_hid_device_t* device);
void uni_bt_allow_incoming_connections(bool enabled); void uni_bt_allow_incoming_connections(bool enabled);
void uni_bt_start_scanning_and_autoconnect_unsafe(); void uni_bt_start_scanning_and_autoconnect_unsafe();
void uni_bt_stop_scanning_unsafe(); void uni_bt_stop_scanning_unsafe();
void uni_bt_bredr_scan_start();
void uni_bt_bredr_scan_stop();
void uni_bt_le_scan_start();
void uni_bt_le_scan_stop();
void uni_platform_set_custom(uni_platform* platform); void uni_platform_set_custom(uni_platform* platform);
int uni_init(int argc, const char** argv); int uni_init(int argc, const char** argv);

View file

@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
using io_rw_32 = volatile uint32_t;
enum gpio_override {
GPIO_OVERRIDE_NORMAL = 0,
GPIO_OVERRIDE_LOW = 2,
};
void bootsel_test_masked_write(io_rw_32* address, uint32_t values,
uint32_t mask);
inline void hw_write_masked(io_rw_32* address, uint32_t values,
uint32_t mask) {
*address = (*address & ~mask) | (values & mask);
bootsel_test_masked_write(address, values, mask);
}
#if PICO_RP2350
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB 14u
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS 0x0000c000u
#else
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB 12u
#define IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS 0x00003000u
#endif

View file

@ -0,0 +1,3 @@
#pragma once
#define SIO_GPIO_HI_IN_QSPI_CSN_BITS 0x08000000u

View file

@ -0,0 +1,14 @@
#pragma once
#include "hardware/gpio.h"
struct ioqspi_status_ctrl_hw_t {
io_rw_32 status;
io_rw_32 ctrl;
};
struct ioqspi_hw_t {
ioqspi_status_ctrl_hw_t io[6];
};
extern ioqspi_hw_t* ioqspi_hw;

View file

@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
struct sio_hw_t {
volatile uint32_t gpio_in;
volatile uint32_t gpio_hi_in;
};
extern sio_hw_t* sio_hw;

View file

@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
#define __no_inline_not_in_flash_func(function_name) function_name
constexpr int PICO_OK = 0;
int flash_safe_execute(void (*function)(void*), void* parameter,
uint32_t enter_exit_timeout_ms);

View file

@ -0,0 +1,8 @@
#pragma once
#include <cstdint>
using absolute_time_t = uint64_t;
absolute_time_t get_absolute_time();
uint64_t to_ms_since_boot(absolute_time_t time);

View file

@ -0,0 +1,224 @@
#include "bootsel_pairing_button.h"
#include <cstdlib>
#include <cstdint>
#include <iostream>
#include <vector>
#include "hardware/gpio.h"
#include "hardware/regs/sio.h"
#include "hardware/structs/ioqspi.h"
#include "hardware/structs/sio.h"
#include "pico/flash.h"
#include "pico/time.h"
namespace {
#if PICO_RP2350
constexpr uint32_t kBootselInputMask = SIO_GPIO_HI_IN_QSPI_CSN_BITS;
#else
constexpr uint32_t kBootselInputMask = 1u << 1u;
#endif
struct FlashResponse {
int result;
bool pressed;
};
ioqspi_hw_t qspi_registers{};
sio_hw_t sio_registers{};
uint64_t now_ms = 0;
std::vector<FlashResponse> flash_responses;
std::size_t next_flash_response = 0;
std::vector<uint32_t> qspi_override_writes;
int flash_safe_calls = 0;
bool inside_flash_safe_callback = false;
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
int apply_pressed(BootselPairingButtonHoldFsm& fsm, int count) {
int events = 0;
for (int sample = 0; sample < count; ++sample) {
if (fsm.update(BootselPairingButtonSample::kPressed)) {
++events;
}
}
return events;
}
void test_short_press() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 19) == 0,
"a 19-sample press must not complete the hold");
require(!fsm.update(BootselPairingButtonSample::kReleased),
"a short-press release must not report a hold");
require(apply_pressed(fsm, 19) == 0,
"a release must discard the previous short press");
}
void test_exact_and_long_hold_once() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 19) == 0,
"the hold must not fire before sample 20");
require(fsm.update(BootselPairingButtonSample::kPressed),
"the hold must fire on exactly sample 20");
require(apply_pressed(fsm, 100) == 0,
"a continuously held button must not repeat");
}
void test_release_and_rearm() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 20) == 1,
"the initial hold must fire once");
require(!fsm.update(BootselPairingButtonSample::kReleased),
"release must rearm without reporting an event");
require(apply_pressed(fsm, 20) == 1,
"a valid release must permit one later hold");
}
void test_unread_samples_do_not_transition() {
BootselPairingButtonHoldFsm fsm;
require(apply_pressed(fsm, 10) == 0,
"the first half of a hold must not fire");
for (int sample = 0; sample < 8; ++sample) {
require(!fsm.update(BootselPairingButtonSample::kUnread),
"unread press samples must not report or reset a hold");
}
require(apply_pressed(fsm, 9) == 0,
"valid pressed samples must resume after unread samples");
require(fsm.update(BootselPairingButtonSample::kPressed),
"20 valid pressed samples must fire despite unread samples");
require(!fsm.update(BootselPairingButtonSample::kUnread),
"an unread release must not rearm a completed hold");
require(apply_pressed(fsm, 20) == 0,
"the held state must persist until a valid release");
require(!fsm.update(BootselPairingButtonSample::kReleased),
"a valid release must only rearm");
require(apply_pressed(fsm, 20) == 1,
"the FSM must fire after the eventual valid release");
}
bool run_sample(uint64_t sample_time_ms, int result, bool pressed) {
flash_responses.push_back({result, pressed});
now_ms = sample_time_ms;
const std::size_t expected_consumed = flash_responses.size();
const bool event = bootsel_pairing_button_task();
require(next_flash_response == expected_consumed,
"a due poll must invoke flash_safe_execute exactly once");
return event;
}
void test_sampler_cadence_and_callback_failure() {
now_ms = 0;
require(!bootsel_pairing_button_task(),
"the sampler must wait for its first 100 ms cadence");
now_ms = 99;
require(!bootsel_pairing_button_task(),
"the sampler must not poll before 100 ms");
require(flash_safe_calls == 0,
"sub-cadence task calls must not enter flash-safe execution");
require(!run_sample(100, PICO_OK, true),
"the first valid pressed sample must only start the hold");
require(flash_safe_calls == 1 && qspi_override_writes.size() == 2,
"a successful sample must float and restore QSPI CSn once");
const uint32_t disabled =
GPIO_OVERRIDE_LOW << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB;
require(qspi_override_writes[0] == disabled,
"the callback must float QSPI CSn before reading BOOTSEL");
require(qspi_override_writes[1] == 0,
"the callback must restore normal QSPI CSn control");
now_ms = 199;
require(!bootsel_pairing_button_task(),
"the sampler must remain gated between 10 Hz polls");
require(flash_safe_calls == 1,
"an early task call must not sample BOOTSEL");
const std::size_t writes_before_failure = qspi_override_writes.size();
require(!run_sample(200, -1, true),
"flash-safe failure must be treated as unread");
require(qspi_override_writes.size() == writes_before_failure,
"a failed flash-safe entry must not invoke the callback");
for (uint64_t time = 300; time < 2100; time += 100) {
require(!run_sample(time, PICO_OK, true),
"the sampler must wait for 20 valid pressed samples");
}
require(run_sample(2100, PICO_OK, true),
"a failed sample must not reset the valid pressed count");
require(!run_sample(2200, PICO_OK, true),
"a held button must not repeat after firing");
require(!run_sample(2300, -1, false),
"a failed release sample must remain unread");
require(!run_sample(2400, PICO_OK, true),
"an unread release must not rearm the sampler FSM");
require(!run_sample(2500, PICO_OK, false),
"a valid release must rearm without firing");
for (uint64_t time = 2600; time < 4500; time += 100) {
require(!run_sample(time, PICO_OK, true),
"the rearmed sampler must count a fresh hold");
}
require(run_sample(4500, PICO_OK, true),
"a valid release must permit a second completed hold");
}
} // namespace
ioqspi_hw_t* ioqspi_hw = &qspi_registers;
sio_hw_t* sio_hw = &sio_registers;
absolute_time_t get_absolute_time() {
return now_ms;
}
uint64_t to_ms_since_boot(absolute_time_t time) {
return time;
}
void bootsel_test_masked_write(io_rw_32* address, uint32_t, uint32_t mask) {
require(inside_flash_safe_callback,
"QSPI override writes must occur inside flash_safe_execute");
require(address == &ioqspi_hw->io[1].ctrl,
"the callback must only override QSPI CSn");
qspi_override_writes.push_back(*address & mask);
}
int flash_safe_execute(void (*function)(void*), void* parameter,
uint32_t enter_exit_timeout_ms) {
require(enter_exit_timeout_ms == 100,
"BOOTSEL sampling must use the 100 ms flash-safe timeout");
require(next_flash_response < flash_responses.size(),
"flash-safe execution requires a queued test response");
++flash_safe_calls;
const FlashResponse response = flash_responses[next_flash_response++];
if (response.result != PICO_OK) {
return response.result;
}
sio_hw->gpio_hi_in = response.pressed ? 0 : kBootselInputMask;
inside_flash_safe_callback = true;
function(parameter);
inside_flash_safe_callback = false;
require((ioqspi_hw->io[1].ctrl &
IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS) == 0,
"the callback must restore QSPI CSn before returning");
return PICO_OK;
}
int main() {
test_short_press();
test_exact_and_long_hold_once();
test_release_and_rearm();
test_unread_samples_do_not_transition();
test_sampler_cadence_and_callback_failure();
return 0;
}

View file

@ -35,5 +35,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"ready-reverse", "ready-reverse",
"rejections", "rejections",
"lifecycle", "lifecycle",
"pairing-policy",
"flash-core-start",
"flash-core-failure",
): ):
subprocess.run([str(executable), scenario], check=True, cwd=root) subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -0,0 +1,34 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_bootsel_pairing_button_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
for platform, rp2350 in (("rp2350", 1), ("rp2040", 0)):
executable = tmp_path / f"bootsel_pairing_button_test_{platform}"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-DPICO_RP2350={rp2350}",
f"-I{root / 'tests' / 'bootsel_native_stubs'}",
f"-I{root}",
str(root / "bootsel_pairing_button.cpp"),
str(root / "tests" / "bootsel_pairing_button_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)