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

@ -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<uint8_t>(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");
}

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*) {}
uint32_t btstack_run_loop_get_time_ms();
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
inline void tight_loop_contents() {}
void tight_loop_contents();

View file

@ -3,6 +3,30 @@
#include <stdint.h>
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);

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",
"rejections",
"lifecycle",
"pairing-policy",
"flash-core-start",
"flash-core-failure",
):
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)