diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ef93ba..223ce8c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,10 +90,14 @@ add_executable(switch-pico switch_haptics.cpp ) 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 SWITCH_PICO_BLUEPAD32=1 SWITCH_PICO_HID_INSTANCE_COUNT=4 + PICO_FLASH_ASSUME_CORE1_SAFE=0 ) else() target_compile_definitions(switch-pico PRIVATE @@ -125,6 +129,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") pico_btstack_classic pico_btstack_cyw43 pico_multicore + pico_flash ) endif() diff --git a/README.md b/README.md index 2dff74b..7e32b40 100644 --- a/README.md +++ b/README.md @@ -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. -### Pairing two controllers +### Pairing up to four controllers 1. Flash and connect the Pico 2 W to the Switch. 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. - 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. -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. 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. +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. -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 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. -- **Fast blink (0.1 s period)**: at least one controller is connected but not yet ready (handshake in progress). -- **Solid**: both slots are filled and both controllers are ready for input. -- **Solid immediately after boot that never starts blinking**: 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. +- **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. +- **Solid immediately after boot that never transitions**: Bluepad32 initialization did not complete; check firmware flashing and UART logs. ### Managing controller disconnect and reconnect -Controllers can disconnect and reconnect independently: -- **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 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. -- **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. -- **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. +- **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. ### Controller capabilities @@ -98,15 +96,15 @@ Rumble effects are per-slot and independent. The Switch sends rumble commands to ### 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: -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). -2. **Verify Bluetooth pairing**: Pair two controllers via Bluepad32. Confirm the LED transitions from scanning → fast blink → solid. -3. **Verify input on one controller**: Move sticks, press buttons, and check that the controller paired first during initial setup appears in slot 0. -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. -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. +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**: 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 and press buttons; confirm only its assigned Switch slot changes. +4. **Verify input on two controllers**: Move the second controller independently and confirm the first controller's slot is unaffected. +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. 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). diff --git a/bluepad32_config/btstack_config.h b/bluepad32_config/btstack_config.h index c2880e2..424d437 100644 --- a/bluepad32_config/btstack_config.h +++ b/bluepad32_config/btstack_config.h @@ -15,6 +15,7 @@ #define ENABLE_LE_DATA_LENGTH_EXTENSION #define ENABLE_LE_PERIPHERAL #define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION +#define ENABLE_LE_RESOLVING_LIST #define ENABLE_LE_SECURE_CONNECTIONS #else #error "BP32: ENABLE_BLE should be defined" diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index e515da2..1b84169 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -2,10 +2,12 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -20,6 +22,7 @@ constexpr int32_t kTriggerThreshold = (kTriggerMaximum * 35) / 100; constexpr uint16_t kRumbleDurationMs = 50; constexpr uint32_t kRumblePollIntervalMs = 5; constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; +constexpr uint32_t kPairingWindowDurationMs = 60000; static_assert(kSlotCount == 4); static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount); @@ -31,6 +34,14 @@ enum class ConnectionStatus { Ready, }; +enum class ConnectionPolicyState { + Uninitialized, + Open, + Locked, + Paused, + FailedClosed, +}; + struct RumbleEnvelope { uint8_t slot; uint32_t connection_generation; @@ -51,16 +62,22 @@ struct BackendSlot { critical_section_t g_state_lock; 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_last_snapshot_generation[kSlotCount]{}; +bool g_pairing_window_requested = false; bool g_initialized = 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{}; 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; +bool g_pairing_window_open = false; bool g_status_led_on = false; SwitchInputState make_neutral_state() { @@ -76,6 +93,26 @@ bool valid_slot(uint8_t slot) { 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) { if (device == nullptr) { return -1; @@ -125,6 +162,8 @@ void publish_all_neutral() { } critical_section_exit(&g_state_lock); g_connection_status = ConnectionStatus::Initializing; + g_connection_policy_state = ConnectionPolicyState::FailedClosed; + g_pairing_window_open = false; g_status_led_tick = 0; } @@ -240,21 +279,79 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) { return state; } +bool pairing_window_active_at(uint32_t now_ms) { + return g_pairing_window_open && + static_cast(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() { ++g_status_led_tick; bool led_on = false; - switch (g_connection_status) { - case ConnectionStatus::Initializing: - case ConnectionStatus::Ready: - led_on = true; - break; - case ConnectionStatus::Scanning: - led_on = (g_status_led_tick % 200) < 100; - break; - case ConnectionStatus::Connecting: - led_on = (g_status_led_tick % 40) < 20; - break; + + if (pairing_window_active_at(btstack_run_loop_get_time_ms())) { + const uint16_t phase = g_status_led_tick % 200; + led_on = phase < 20 || (phase >= 40 && phase < 60); + } else if (g_connection_status == ConnectionStatus::Connecting) { + led_on = (g_status_led_tick % 40) < 20; + } else if (g_connection_status == ConnectionStatus::Initializing || + has_active_controller()) { + led_on = true; + } else { + led_on = (g_status_led_tick % 200) < 100; } + if (led_on != g_status_led_on) { cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 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) { + 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) { RumbleEnvelope envelope{}; uni_hid_device_t* device = nullptr; @@ -274,14 +376,16 @@ void process_rumble_timer(btstack_timer_source_t* timer) { slot.rumble_pending = false; dispatch = envelope.slot == slot_index && slot.active && slot.device != nullptr && - envelope.connection_generation == slot.connection_generation; + envelope.connection_generation == + slot.connection_generation; if (dispatch) { device = slot.device; } } 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, 0, kRumbleDurationMs, envelope.rumble.high_frequency_magnitude, @@ -294,19 +398,12 @@ void process_rumble_timer(btstack_timer_source_t* 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() { + const uint32_t now_ms = btstack_run_loop_get_time_ms(); + update_pairing_window(now_ms); g_connection_status = compute_connection_status(); g_status_led_tick = 0; - if (g_connection_status == ConnectionStatus::Ready) { - uni_bt_stop_scanning_unsafe(); - uni_bt_allow_incoming_connections(false); - } else { - resume_connections(); - } + apply_connection_policy(now_ms); } 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() { + // 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(&g_rumble_timer, kRumblePollIntervalMs); btstack_run_loop_add_timer(&g_rumble_timer); 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)name; (void)cod; (void)rssi; - return compute_connection_status() == ConnectionStatus::Ready - ? UNI_ERROR_IGNORE_DEVICE - : UNI_ERROR_SUCCESS; + return has_free_slot() && + g_connection_policy_state == ConnectionPolicyState::Open + ? UNI_ERROR_SUCCESS + : UNI_ERROR_IGNORE_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); if (slot_index < 0) { return; @@ -453,22 +562,26 @@ uni_platform* get_platform() { return &platform; } +[[noreturn]] void halt_wireless_backend() { + publish_all_neutral(); + while (true) { + tight_loop_contents(); + } +} + [[noreturn]] void core1_main() { + if (!flash_safe_execute_core_init()) { + halt_wireless_backend(); + } if (cyw43_arch_init() != 0) { - publish_all_neutral(); - while (true) { - tight_loop_contents(); - } + halt_wireless_backend(); } cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, true); g_status_led_on = true; uni_platform_set_custom(get_platform()); if (uni_init(0, nullptr) != 0) { - publish_all_neutral(); - while (true) { - tight_loop_contents(); - } + halt_wireless_backend(); } btstack_run_loop_execute(); @@ -493,6 +606,11 @@ void bluepad32_input_backend_init() { g_consumed_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; } @@ -503,11 +621,28 @@ void bluepad32_input_backend_start() { if (g_started) { 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; 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) { if (out == nullptr || !valid_slot(slot_index)) { return false; diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index 16b7e00..b969635 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -9,6 +9,7 @@ constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4; void bluepad32_input_backend_init(); void bluepad32_input_backend_start(); +void bluepad32_input_backend_open_pairing_window(); bool bluepad32_input_backend_snapshot(uint8_t slot, SwitchInputState* out); void bluepad32_input_backend_report_sent(uint8_t slot); void bluepad32_input_backend_queue_rumble(uint8_t slot, diff --git a/bootsel_pairing_button.cpp b/bootsel_pairing_button.cpp new file mode 100644 index 0000000..ab9de55 --- /dev/null +++ b/bootsel_pairing_button.cpp @@ -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(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(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()); +} diff --git a/bootsel_pairing_button.h b/bootsel_pairing_button.h new file mode 100644 index 0000000..7a881b0 --- /dev/null +++ b/bootsel_pairing_button.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +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(); diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 9c1edc0..eb9df86 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index d5b54a8..e177a55 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/switch-pico.cpp b/switch-pico.cpp index ebe3007..ba3ba3f 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -7,6 +7,7 @@ #include "hardware/uart.h" #else #include "bluepad32_input_backend.h" +#include "bootsel_pairing_button.h" #endif #ifdef SWITCH_PICO_LOG @@ -232,6 +233,9 @@ int main() { while (true) { tud_task(); // USB device tasks #ifdef SWITCH_PICO_BLUEPAD32 + if (bootsel_pairing_button_task()) { + bluepad32_input_backend_open_pairing_window(); + } for (uint8_t instance = 0; instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { bluepad32_input_backend_snapshot(instance, diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index d28cc88..253b74c 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -7,13 +7,29 @@ namespace { + bool incoming_connections = false; int scan_starts = 0; int scan_stops = 0; bool scanning_enabled = false; +int classic_scan_starts = 0; +int classic_scan_stops = 0; +bool classic_scanning_enabled = false; uni_platform* installed_platform = nullptr; bool observed_status_led_on = false; 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) { if (!condition) { @@ -29,10 +45,14 @@ void play_rumble(uni_hid_device_t* device, uint16_t, uint16_t, 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{}; result.idx = idx; result.gamepad = gamepad; + result.conn.protocol = protocol; + result.conn.btaddr[5] = static_cast(idx + 1); result.report_parser.play_dual_rumble = play_rumble; 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; } +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) { 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; scanning_enabled = true; } -void uni_bt_stop_scanning_unsafe() { - ++scan_stops; +void uni_bt_le_scan_stop() { + if (scanning_enabled) { + ++scan_stops; + } 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) { installed_platform = platform; } int uni_init(int, const char**) { + ++uni_init_calls; return 0; } +bool flash_safe_execute_core_init() { + ++flash_core_init_calls; + return flash_core_init_result; +} + int cyw43_arch_init() { + ++cyw43_init_calls; return 0; } @@ -78,7 +135,18 @@ void cyw43_arch_gpio_put(int, bool enabled) { ++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" @@ -87,9 +155,22 @@ namespace { void start_backend() { bluepad32_input_backend_init(); platform_on_init_complete(); - require(incoming_connections, "initialization must allow connections"); - require(scan_starts == 1, "initialization must start scanning"); + require(!incoming_connections, + "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) { for (int tick = 0; tick < ticks; ++tick) { @@ -98,7 +179,7 @@ void tick_backend_timer(int ticks) { } void test_ready_order(bool reverse) { - start_backend(); + start_pairing_backend(); uni_hid_device_t devices[kSlotCount] = { device(0), device(1), device(2), device(3)}; 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* 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]]); - 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) { const int slot = order[position]; @@ -146,14 +211,6 @@ void test_ready_order(bool reverse) { "scanning must continue while any slot remains free"); require(incoming_connections, "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"); require(!incoming_connections, "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) { const int starts_before_disconnect = scan_starts; @@ -245,19 +294,13 @@ void test_rejections() { } void test_independent_lifecycle() { - start_backend(); + start_pairing_backend(); uni_hid_device_t aborted = device(0); const uint32_t aborted_generation = g_slots[0].connection_generation; platform_on_device_connected(&aborted); require(g_slots[0].device == &aborted && !g_slots[0].active, "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; platform_on_device_disconnected(&aborted); @@ -266,15 +309,10 @@ void test_independent_lifecycle() { require(g_slots[0].connection_generation == aborted_generation + 1, "pre-ready disconnect must invalidate its connection generation"); require(g_connection_status == ConnectionStatus::Scanning && - scanning_enabled && incoming_connections && - scan_starts == starts_before_aborted_disconnect + 1, - "pre-ready disconnect with no peer must resume scanning"); - tick_backend_timer(99); - 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"); + scanning_enabled && classic_scanning_enabled && + incoming_connections && + scan_starts == starts_before_aborted_disconnect, + "pre-ready disconnect must preserve the open pairing scan"); uni_hid_device_t devices[kSlotCount] = { device(0), device(1), device(2), device(3)}; @@ -284,12 +322,6 @@ void test_independent_lifecycle() { !g_slots[slot].active, "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 = g_slots[0].connection_generation; @@ -306,15 +338,10 @@ void test_independent_lifecycle() { first_pending_generation + 1, "pending disconnect beside peers must invalidate its generation"); 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, - "open slot must scan while other slots remain connecting"); - 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"); + "open pairing slot must preserve pending peers and resume scanning"); for (int slot = 1; slot < kSlotCount; ++slot) { 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_high == 116, "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); require(platform_on_device_ready(&slot_three_replacement) == @@ -425,13 +447,6 @@ void test_independent_lifecycle() { process_rumble_timer(&g_rumble_timer); require(slot_three_replacement.rumble_calls == 0, "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 = { 3, disconnected_generation, SwitchRumbleOutput{77, 88}}; @@ -536,6 +551,128 @@ void test_independent_lifecycle() { "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 int main(int argc, char** argv) { @@ -549,6 +686,12 @@ int main(int argc, char** argv) { test_rejections(); } else if (scenario == "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 { require(false, "unknown scenario"); } diff --git a/tests/bluepad32_native_stubs/btstack_run_loop.h b/tests/bluepad32_native_stubs/btstack_run_loop.h index 2d422d6..5487f88 100644 --- a/tests/bluepad32_native_stubs/btstack_run_loop.h +++ b/tests/bluepad32_native_stubs/btstack_run_loop.h @@ -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*) {} +uint32_t btstack_run_loop_get_time_ms(); inline void btstack_run_loop_execute() {} diff --git a/tests/bluepad32_native_stubs/pico/flash.h b/tests/bluepad32_native_stubs/pico/flash.h new file mode 100644 index 0000000..fd6a2c8 --- /dev/null +++ b/tests/bluepad32_native_stubs/pico/flash.h @@ -0,0 +1,3 @@ +#pragma once + +bool flash_safe_execute_core_init(); diff --git a/tests/bluepad32_native_stubs/pico/stdlib.h b/tests/bluepad32_native_stubs/pico/stdlib.h index d5ce0cb..510d071 100644 --- a/tests/bluepad32_native_stubs/pico/stdlib.h +++ b/tests/bluepad32_native_stubs/pico/stdlib.h @@ -1,3 +1,3 @@ #pragma once -inline void tight_loop_contents() {} +void tight_loop_contents(); diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index 2de0fa7..b229a86 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -3,6 +3,30 @@ #include 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_platform_oob_event_t; struct uni_property_t {}; @@ -63,7 +87,20 @@ struct uni_report_parser_t { 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 { + uni_bt_conn_t conn; int idx; bool gamepad; 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); 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_start_scanning_and_autoconnect_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); int uni_init(int argc, const char** argv); + diff --git a/tests/bootsel_native_stubs/hardware/gpio.h b/tests/bootsel_native_stubs/hardware/gpio.h new file mode 100644 index 0000000..32af518 --- /dev/null +++ b/tests/bootsel_native_stubs/hardware/gpio.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +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 diff --git a/tests/bootsel_native_stubs/hardware/regs/sio.h b/tests/bootsel_native_stubs/hardware/regs/sio.h new file mode 100644 index 0000000..6b0f9eb --- /dev/null +++ b/tests/bootsel_native_stubs/hardware/regs/sio.h @@ -0,0 +1,3 @@ +#pragma once + +#define SIO_GPIO_HI_IN_QSPI_CSN_BITS 0x08000000u diff --git a/tests/bootsel_native_stubs/hardware/structs/ioqspi.h b/tests/bootsel_native_stubs/hardware/structs/ioqspi.h new file mode 100644 index 0000000..9a7e80b --- /dev/null +++ b/tests/bootsel_native_stubs/hardware/structs/ioqspi.h @@ -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; diff --git a/tests/bootsel_native_stubs/hardware/structs/sio.h b/tests/bootsel_native_stubs/hardware/structs/sio.h new file mode 100644 index 0000000..9564835 --- /dev/null +++ b/tests/bootsel_native_stubs/hardware/structs/sio.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +struct sio_hw_t { + volatile uint32_t gpio_in; + volatile uint32_t gpio_hi_in; +}; + +extern sio_hw_t* sio_hw; diff --git a/tests/bootsel_native_stubs/pico/flash.h b/tests/bootsel_native_stubs/pico/flash.h new file mode 100644 index 0000000..e33f7f3 --- /dev/null +++ b/tests/bootsel_native_stubs/pico/flash.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +#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); diff --git a/tests/bootsel_native_stubs/pico/time.h b/tests/bootsel_native_stubs/pico/time.h new file mode 100644 index 0000000..c3921f1 --- /dev/null +++ b/tests/bootsel_native_stubs/pico/time.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +using absolute_time_t = uint64_t; + +absolute_time_t get_absolute_time(); +uint64_t to_ms_since_boot(absolute_time_t time); diff --git a/tests/bootsel_pairing_button_test.cpp b/tests/bootsel_pairing_button_test.cpp new file mode 100644 index 0000000..1d30ff6 --- /dev/null +++ b/tests/bootsel_pairing_button_test.cpp @@ -0,0 +1,224 @@ +#include "bootsel_pairing_button.h" + +#include +#include +#include +#include + +#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 flash_responses; +std::size_t next_flash_response = 0; +std::vector 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; +} diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index dedb67b..b9fdb8d 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -35,5 +35,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "ready-reverse", "rejections", "lifecycle", + "pairing-policy", + "flash-core-start", + "flash-core-failure", ): subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_bootsel_pairing_button_native.py b/tests/test_bootsel_pairing_button_native.py new file mode 100644 index 0000000..b05f1b7 --- /dev/null +++ b/tests/test_bootsel_pairing_button_native.py @@ -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)