Complete controller profile runtime

This commit is contained in:
Joey Yakimowich-Payne 2026-09-02 20:28:45 -06:00
commit 7358670e63
26 changed files with 1565 additions and 324 deletions

View file

@ -40,12 +40,15 @@ int ble_bond_count = 0;
bool flash_core_init_result = true;
int flash_core_init_calls = 0;
int core1_launch_calls = 0;
bool expect_configuration_timer_prearmed = false;
uint32_t expected_configuration_timer_add_count = 0;
int cyw43_init_calls = 0;
int uni_init_calls = 0;
int device_disconnect_calls = 0;
uni_hid_device_t* last_disconnected_device = nullptr;
uni_hid_device_t* lookup_devices[8]{};
size_t lookup_device_count = 0;
gap_connection_type_t gap_connection_types[256]{};
struct CoreStopped {};
@ -90,6 +93,12 @@ uni_hid_device_t device(
result.vendor_id = static_cast<uint16_t>(0x1000 + idx);
result.product_id = static_cast<uint16_t>(0x2000 + idx);
result.report_parser.play_dual_rumble = play_rumble;
gap_connection_types[result.conn.handle] =
protocol == UNI_BT_CONN_PROTOCOL_BR_EDR
? GAP_CONNECTION_ACL
: protocol == UNI_BT_CONN_PROTOCOL_BLE
? GAP_CONNECTION_LE
: GAP_CONNECTION_INVALID;
return result;
}
@ -170,6 +179,15 @@ void uni_bt_del_keys_unsafe() {
ble_bond_count = 0;
}
gap_connection_type_t gap_get_connection_type(
hci_con_handle_t connection_handle) {
return connection_handle <
sizeof(gap_connection_types) /
sizeof(gap_connection_types[0])
? gap_connection_types[connection_handle]
: GAP_CONNECTION_INVALID;
}
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator) {
iterator->index = 0;
return 1;
@ -420,10 +438,22 @@ ControllerIdentity observed_profile_identities[8]{};
size_t observed_profile_identity_count = 0;
void configuration_service_prepare() {}
void configuration_service_initialize_on_storage_core() {}
void configuration_service_task_on_storage_core(uint32_t) {}
void configuration_service_task_on_storage_core(uint32_t) {
if (expect_configuration_timer_prearmed) {
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count,
"configuration work ran before its timer was rearmed");
}
}
void profile_service_prepare() {}
void profile_service_initialize_on_storage_core() {}
void profile_service_task_on_storage_core(uint32_t) {}
void profile_service_task_on_storage_core(uint32_t) {
if (expect_configuration_timer_prearmed) {
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count,
"profile work ran before its timer was rearmed");
}
}
bool profile_service_observe_identity_on_storage_core(
const ControllerIdentity& identity) {
require(observed_profile_identity_count <
@ -736,6 +766,19 @@ void test_independent_lifecycle() {
test_identity_encoding_contract();
start_pairing_backend();
uni_hid_device_t invalid_transport =
device(6, true, UNI_BT_CONN_PROTOCOL_BLE);
invalid_transport.conn.handle = 0xffff;
require(controller_identity_is_global(
identity_for_device(&invalid_transport)),
"invalid GAP handles must remain on the global identity");
uni_hid_device_t sco_transport =
device(7, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
gap_connection_types[sco_transport.conn.handle] = GAP_CONNECTION_SCO;
require(controller_identity_is_global(
identity_for_device(&sco_transport)),
"SCO links must remain on the global identity");
uni_hid_device_t aborted = device(0);
const uint32_t aborted_generation = g_slots[0].connection_generation;
platform_on_device_connected(&aborted);
@ -765,10 +808,14 @@ void test_independent_lifecycle() {
"pre-ready disconnect must restart Classic and BLE scans");
uni_hid_device_t devices[kSlotCount] = {
device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR),
device(1, true, UNI_BT_CONN_PROTOCOL_BLE),
device(2, true, UNI_BT_CONN_PROTOCOL_BLE),
device(3, true, UNI_BT_CONN_PROTOCOL_BLE)};
device(0, true, UNI_BT_CONN_PROTOCOL_NONE),
device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR),
device(2, true, UNI_BT_CONN_PROTOCOL_NONE),
device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR)};
gap_connection_types[devices[0].conn.handle] = GAP_CONNECTION_ACL;
gap_connection_types[devices[1].conn.handle] = GAP_CONNECTION_LE;
gap_connection_types[devices[2].conn.handle] = GAP_CONNECTION_LE;
gap_connection_types[devices[3].conn.handle] = GAP_CONNECTION_LE;
const bd_addr_t classic_address =
{0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
const bd_addr_t resolved_connection_address =
@ -785,6 +832,18 @@ void test_independent_lifecycle() {
sizeof(created_connection_address));
memcpy(devices[3].conn.btaddr, reencrypted_connection_address,
sizeof(reencrypted_connection_address));
require(
devices[0].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE &&
gap_get_connection_type(devices[0].conn.handle) ==
GAP_CONNECTION_ACL &&
devices[1].conn.protocol == UNI_BT_CONN_PROTOCOL_BR_EDR &&
gap_get_connection_type(devices[1].conn.handle) ==
GAP_CONNECTION_LE &&
devices[2].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE &&
gap_get_connection_type(devices[2].conn.handle) ==
GAP_CONNECTION_LE,
"identity fixtures must expose authoritative GAP transports over "
"missing or stale cached protocols");
const bd_addr_t resolved_address =
{0x20, 0x21, 0x22, 0x23, 0x24, 0x25};
const bd_addr_t reencrypted_address =
@ -878,6 +937,18 @@ void test_independent_lifecycle() {
observed_profile_identities[2],
lifecycle_snapshots[0].identity),
"only stable ready identities must be enrolled for profiles");
size_t classic_identity_observations = 0;
for (size_t index = 0; index < observed_profile_identity_count;
++index) {
if (controller_identity_equal(
observed_profile_identities[index],
lifecycle_snapshots[0].identity)) {
++classic_identity_observations;
}
}
require(classic_identity_observations == 1,
"active GAP ACL with no cached protocol must enroll its stable "
"Classic identity exactly once");
require(baseline_connection_generations[0] ==
first_pending_generation + 1 &&
baseline_connection_generations[1] ==
@ -1021,6 +1092,15 @@ void test_independent_lifecycle() {
device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
memcpy(slot_three_replacement.conn.btaddr,
devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr));
require(
slot_three_replacement.conn.handle == devices[3].conn.handle &&
gap_get_connection_type(slot_three_replacement.conn.handle) ==
GAP_CONNECTION_LE &&
memcmp(slot_three_replacement.conn.btaddr,
devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr)) ==
0,
"replacement isolation fixture must reuse the active BLE handle "
"and address");
require(platform_on_device_ready(&slot_three_replacement) ==
UNI_ERROR_SUCCESS,
"slot 3 replacement must bind to the freed indexed slot");
@ -1319,7 +1399,7 @@ void test_profile_feedback_scheduler() {
generations[slot] = snapshot.connection_generation;
require(devices[slot].rumble_calls == 0 &&
!g_slots[slot].profile_feedback.active &&
!g_slots[slot].profile_feedback_pending,
g_slots[slot].pending_profile_feedback_count == 0,
"initial controller/profile load scheduled confirmation feedback");
bluepad32_input_backend_queue_profile_feedback(
slot, generations[slot], static_cast<uint8_t>(slot + 1u),
@ -1404,6 +1484,12 @@ void test_profile_feedback_scheduler() {
"four-pulse confirmation did not release host rumble at 600 ms");
const int slot_zero_lightbar_calls = devices[0].lightbar_calls;
const int slot_one_player_led_calls_before_slot_zero =
devices[1].player_led_calls;
const int slot_two_lightbar_calls_before_slot_zero =
devices[2].lightbar_calls;
const int slot_three_player_led_calls_before_slot_zero =
devices[3].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kNone);
@ -1436,7 +1522,8 @@ void test_profile_feedback_scheduler() {
kProfileLightbarPalette[1].blue &&
observed_status_led_on &&
g_slots[0].rumble_pending,
"LED policy did not set persistent profile color and first onboard blink");
"LED policy did not set transient profile color and first "
"onboard blink");
now_ms = 875;
process_rumble_timer(&g_rumble_timer);
require(!observed_status_led_on &&
@ -1453,10 +1540,24 @@ void test_profile_feedback_scheduler() {
"host rumble interrupted the final onboard off phase");
now_ms = 1100;
process_rumble_timer(&g_rumble_timer);
const SwitchRgbColor slot_zero_color =
switch_pro_get_slot_light_color(0);
require(devices[0].rumble_calls == 4 &&
devices[0].last_high == 0x42 &&
!g_slots[0].rumble_pending,
"LED-only sequence did not release deferred host rumble");
!g_slots[0].rumble_pending &&
devices[0].lightbar_calls ==
slot_zero_lightbar_calls + 2 &&
devices[0].lightbar_red == slot_zero_color.red &&
devices[0].lightbar_green == slot_zero_color.green &&
devices[0].lightbar_blue == slot_zero_color.blue &&
devices[1].player_led_calls ==
slot_one_player_led_calls_before_slot_zero &&
devices[2].lightbar_calls ==
slot_two_lightbar_calls_before_slot_zero &&
devices[3].player_led_calls ==
slot_three_player_led_calls_before_slot_zero,
"LED-only sequence did not restore its slot color in "
"isolation after the final gap");
const int slot_one_player_led_calls =
devices[1].player_led_calls;
@ -1482,13 +1583,22 @@ void test_profile_feedback_scheduler() {
kXInputHostRumbleDurationMs
? 7
: 6) &&
!g_slots[1].profile_feedback.active,
"combined three-pulse sequence did not terminate");
!g_slots[1].profile_feedback.active &&
devices[1].player_led_calls ==
slot_one_player_led_calls + 2 &&
devices[1].player_leds == (1u << 1u),
"combined three-pulse sequence did not terminate and "
"restore slot player lighting");
const int old_rumble_calls = devices[2].rumble_calls;
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 4,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 1,
ControllerProfileConfirmationPolicy::kLed);
require(g_slots[2].pending_profile_feedback_count == 2,
"two queued profile events did not fill the bounded FIFO");
platform_on_device_disconnected(&devices[2]);
uni_hid_device_t replacement = device(2);
replacement.report_parser.set_lightbar_color = set_lightbar;
@ -1501,7 +1611,7 @@ void test_profile_feedback_scheduler() {
replacement.rumble_calls == 0 &&
replacement.lightbar_calls == 1 &&
!g_slots[2].profile_feedback.active &&
!g_slots[2].profile_feedback_pending,
g_slots[2].pending_profile_feedback_count == 0,
"slot replacement accepted stale queued profile feedback");
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 4,
@ -1534,8 +1644,12 @@ void test_profile_feedback_scheduler() {
process_rumble_timer(&g_rumble_timer);
require(!g_slots[3].profile_feedback.active &&
devices[3].rumble_calls ==
slot_three_rumble_calls,
"one-blink LED-only profile indication did not terminate cleanly");
slot_three_rumble_calls &&
devices[3].player_led_calls ==
slot_three_player_led_calls + 2 &&
devices[3].player_leds == (1u << 3u),
"one-blink LED-only profile indication did not restore "
"slot lighting cleanly");
bluepad32_input_backend_queue_profile_feedback(
3, generations[3], 4,
@ -1543,7 +1657,7 @@ void test_profile_feedback_scheduler() {
now_ms = 2000;
process_rumble_timer(&g_rumble_timer);
require(devices[3].player_leds == 0x0f,
"profile 4 did not persist four player LEDs");
"profile 4 player count was not shown during its sequence");
for (uint8_t pulse = 1; pulse <= 4; ++pulse) {
require(observed_status_led_on &&
g_slots[3].profile_feedback.on &&
@ -1565,8 +1679,122 @@ void test_profile_feedback_scheduler() {
process_rumble_timer(&g_rumble_timer);
require(!g_slots[3].profile_feedback.active &&
devices[3].rumble_calls ==
slot_three_rumble_calls,
"four-blink LED-only profile indication did not terminate");
slot_three_rumble_calls &&
devices[3].player_led_calls ==
slot_three_player_led_calls + 4 &&
devices[3].player_leds == (1u << 3u),
"four-blink LED-only profile indication did not restore "
"slot lighting");
Bluepad32SlotSnapshot replacement_snapshot{};
bluepad32_input_backend_snapshot(2, &replacement_snapshot);
const int replacement_lightbar_calls = replacement.lightbar_calls;
bluepad32_input_backend_queue_profile_feedback(
2, replacement_snapshot.connection_generation, 1,
ControllerProfileConfirmationPolicy::kLed);
now_ms = 2700;
process_rumble_timer(&g_rumble_timer);
require(replacement.lightbar_calls ==
replacement_lightbar_calls + 1,
"current-generation profile lighting was not applied");
now_ms = 2775;
process_rumble_timer(&g_rumble_timer);
platform_on_device_disconnected(&replacement);
uni_hid_device_t second_replacement = device(2);
second_replacement.report_parser.set_lightbar_color =
set_lightbar;
require(platform_on_device_ready(&second_replacement) ==
UNI_ERROR_SUCCESS &&
second_replacement.lightbar_calls == 1,
"second replacement did not receive steady slot lighting");
now_ms = 2850;
process_rumble_timer(&g_rumble_timer);
require(second_replacement.lightbar_calls == 1 &&
replacement.lightbar_calls ==
replacement_lightbar_calls + 1 &&
!g_slots[2].profile_feedback.active,
"stale final-gap restore touched a replacement connection");
const int fifo_lightbar_calls = devices[0].lightbar_calls;
const int fifo_rumble_calls = devices[0].rumble_calls;
const int isolated_slot_one_lighting =
devices[1].player_led_calls;
const int isolated_slot_two_lighting =
second_replacement.lightbar_calls;
const int isolated_slot_three_lighting =
devices[3].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 1,
ControllerProfileConfirmationPolicy::kLed);
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
require(g_slots[0].pending_profile_feedback_count == 2,
"initial and switched profile events were not queued");
now_ms = 3000;
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].profile_feedback.active &&
g_slots[0].profile_feedback.pulse_count == 1 &&
!g_slots[0].profile_feedback.rumble_enabled &&
g_slots[0].pending_profile_feedback_count == 1 &&
devices[0].rumble_calls == fifo_rumble_calls &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 1 &&
devices[0].lightbar_red ==
kProfileLightbarPalette[0].red,
"LED-only initial event did not run first from the FIFO");
now_ms = 3075;
process_rumble_timer(&g_rumble_timer);
now_ms = 3150;
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].profile_feedback.active &&
g_slots[0].profile_feedback.pulse_count == 2 &&
g_slots[0].profile_feedback.rumble_enabled &&
g_slots[0].pending_profile_feedback_count == 0 &&
devices[0].rumble_calls == fifo_rumble_calls + 1 &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 3 &&
devices[0].lightbar_red ==
kProfileLightbarPalette[1].red,
"switched profile event did not follow initial indication "
"after its final gap");
now_ms = 3225;
process_rumble_timer(&g_rumble_timer);
now_ms = 3300;
process_rumble_timer(&g_rumble_timer);
now_ms = 3375;
process_rumble_timer(&g_rumble_timer);
now_ms = 3450;
process_rumble_timer(&g_rumble_timer);
const SwitchRgbColor final_slot_zero_color =
switch_pro_get_slot_light_color(0);
const bool stateful_host_rumble =
host_rumble_duration_ms() == kXInputHostRumbleDurationMs;
require(!g_slots[0].profile_feedback.active &&
devices[0].rumble_calls ==
fifo_rumble_calls +
(stateful_host_rumble ? 3 : 2) &&
(!stateful_host_rumble ||
(devices[0].last_high == 0x42 &&
devices[0].last_low == 0x32)) &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 4 &&
devices[0].lightbar_red ==
final_slot_zero_color.red &&
devices[0].lightbar_green ==
final_slot_zero_color.green &&
devices[0].lightbar_blue ==
final_slot_zero_color.blue &&
devices[1].player_led_calls ==
isolated_slot_one_lighting &&
second_replacement.lightbar_calls ==
isolated_slot_two_lighting &&
devices[3].player_led_calls ==
isolated_slot_three_lighting,
"ordered profile FIFO did not restore or remain slot-local");
}
void test_stateful_host_rumble_restore() {
@ -1932,6 +2160,19 @@ void test_clear_pairings() {
"pairing reset request must execute only once");
}
void test_configuration_timer_rearms_before_storage_work() {
const uint32_t adds_before = g_configuration_timer.add_count;
expected_configuration_timer_add_count = adds_before + 1;
expect_configuration_timer_prearmed = true;
process_configuration_timer(&g_configuration_timer);
expect_configuration_timer_prearmed = false;
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count &&
g_configuration_timer.timeout_ms ==
kConfigurationPollIntervalMs,
"configuration timer did not remain recurring");
}
void test_flash_core_start_contract() {
bluepad32_input_backend_init();
flash_core_init_result = false;
@ -1997,6 +2238,8 @@ int main(int argc, char** argv) {
test_host_rumble_mode_duration();
} else if (scenario == "clear-pairings") {
test_clear_pairings();
} else if (scenario == "configuration-timer") {
test_configuration_timer_rearms_before_storage_work();
} else if (scenario == "flash-core-start") {
test_flash_core_start_contract();
} else if (scenario == "flash-core-failure") {

View file

@ -5,6 +5,7 @@
struct btstack_timer_source_t {
void (*handler)(btstack_timer_source_t*);
uint32_t timeout_ms;
uint32_t add_count;
};
inline void btstack_run_loop_set_timer_handler(
@ -18,6 +19,8 @@ inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer,
timer->timeout_ms = timeout_ms;
}
inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {}
inline void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
++timer->add_count;
}
uint32_t btstack_run_loop_get_time_ms();
inline void btstack_run_loop_execute() {}

View file

@ -21,6 +21,13 @@ enum hci_link_type_t {
HCI_LINK_TYPE_ACL = 1,
};
enum gap_connection_type_t {
GAP_CONNECTION_INVALID,
GAP_CONNECTION_ACL,
GAP_CONNECTION_SCO,
GAP_CONNECTION_LE,
};
struct btstack_link_key_iterator_t {
int index;
};
@ -174,6 +181,8 @@ void uni_bt_bredr_scan_stop();
void uni_bt_le_scan_start();
void uni_bt_le_scan_stop();
void uni_bt_del_keys_unsafe();
gap_connection_type_t gap_get_connection_type(
hci_con_handle_t connection_handle);
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator);
int gap_link_key_iterator_get_next(
btstack_link_key_iterator_t* iterator, bd_addr_t address,

View file

@ -109,6 +109,15 @@ ControllerProfileRuntimeProfileChangeEvent take_profile_change(
return event;
}
ControllerProfileRuntimeProfileChangeEvent
take_initial_profile_indication(uint8_t slot, bool* available) {
ControllerProfileRuntimeProfileChangeEvent event{};
*available =
controller_profile_runtime_take_initial_profile_indication(
slot, &event);
return event;
}
constexpr uint16_t logical_button_bit(
ControllerProfileLogicalButton button) {
return static_cast<uint16_t>(
@ -285,6 +294,101 @@ void test_analog_thresholds_rumble_and_local_confirmation() {
"local confirmation was scaled or lost its profile policy");
}
void test_initial_profile_indication_once_per_connection() {
prepare_profiles();
std::array<Bluepad32SlotSnapshot,
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT>
snapshots{};
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
rows[slot].active_profile = slot;
rows[slot].profiles[slot].confirmation_policy =
ControllerProfileConfirmationPolicy::kRumbleAndLed;
snapshots[slot] = make_snapshot(slot);
(void)runtime_transform(slot, snapshots[slot]);
}
constexpr uint8_t kTakeOrder[CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT] = {
2, 0, 3, 1};
for (const uint8_t slot : kTakeOrder) {
bool available = false;
const ControllerProfileRuntimeProfileChangeEvent event =
take_initial_profile_indication(slot, &available);
require(available &&
event.connection_generation == 1 &&
event.database_generation == database_generation &&
event.active_profile_number == slot + 1u &&
event.policy ==
ControllerProfileConfirmationPolicy::kLed,
"initial profile 1..4 indication was not isolated, "
"LED-only, or generation-bound");
(void)take_initial_profile_indication(slot, &available);
require(!available,
"initial profile indication repeated without a new "
"connection");
(void)take_profile_change(slot, &available);
require(!available,
"initial profile resolution published switch feedback");
}
++database_generation;
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
(void)runtime_transform(slot, snapshots[slot]);
bool available = true;
(void)take_initial_profile_indication(slot, &available);
require(!available,
"database refresh repeated initial profile indication");
}
rows[2].identity = controller_identity_global();
snapshots[0].identity = controller_identity_global();
snapshots[0].connection_generation = 2;
(void)runtime_transform(0, snapshots[0]);
bool available = false;
const ControllerProfileRuntimeProfileChangeEvent unresolved_event =
take_initial_profile_indication(0, &available);
require(available &&
unresolved_event.connection_generation == 2 &&
unresolved_event.active_profile_number == 3,
"first committed unresolved-identity profile was not "
"indicated");
snapshots[0].identity = rows[1].identity;
(void)runtime_transform(0, snapshots[0]);
(void)take_initial_profile_indication(0, &available);
require(!available,
"identity promotion repeated initial profile indication");
snapshots[0].connection_generation = 3;
(void)runtime_transform(0, snapshots[0]);
const ControllerProfileRuntimeProfileChangeEvent reconnect_event =
take_initial_profile_indication(0, &available);
require(available &&
reconnect_event.connection_generation == 3 &&
reconnect_event.active_profile_number == 2 &&
reconnect_event.policy ==
ControllerProfileConfirmationPolicy::kLed,
"true reconnection did not publish one fresh LED-only "
"profile indication");
rows[1].profiles[1].confirmation_policy =
ControllerProfileConfirmationPolicy::kRumble;
snapshots[1].connection_generation = 2;
(void)runtime_transform(1, snapshots[1]);
(void)take_initial_profile_indication(1, &available);
require(!available,
"rumble-only initial policy disturbed controller feedback");
rows[3].profiles[3].confirmation_policy =
ControllerProfileConfirmationPolicy::kNone;
snapshots[3].connection_generation = 2;
(void)runtime_transform(3, snapshots[3]);
(void)take_initial_profile_indication(3, &available);
require(!available,
"disabled initial policy disturbed controller feedback");
}
void test_default_switching_retry_commit_and_feedback() {
prepare_profiles();
ControllerProfile& initial_profile = rows[0].profiles[0];
@ -782,6 +886,7 @@ int main() {
test_four_slot_cache_and_unchanged_generation();
test_activation_disconnect_and_default_preservation();
test_analog_thresholds_rumble_and_local_confirmation();
test_initial_profile_indication_once_per_connection();
test_default_switching_retry_commit_and_feedback();
test_identity_promotion_preserves_held_switching();
test_switching_uses_pre_hotkey_buttons_only();

View file

@ -12,6 +12,7 @@ namespace {
struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE];
int bank_replacements = 0;
};
FakeFlash flash{};
@ -35,33 +36,27 @@ bool fake_read(void* context, uint8_t bank, size_t offset,
return true;
}
bool fake_erase_sector(void* context, uint8_t bank, size_t offset) {
bool fake_replace_bank(void* context, uint8_t bank,
const uint8_t* payload, size_t payload_size,
const uint8_t* header, size_t header_size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT ||
offset % PROFILE_STORAGE_SECTOR_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
PROFILE_STORAGE_SECTOR_SIZE > PROFILE_STORAGE_BANK_SIZE - offset) {
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
header == nullptr ||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) {
return false;
}
memset(&storage->bytes[bank][offset], 0xff,
PROFILE_STORAGE_SECTOR_SIZE);
return true;
}
bool fake_program(void* context, uint8_t bank, size_t offset,
const uint8_t* data, size_t size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT || data == nullptr ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
return false;
}
for (size_t index = 0; index < size; ++index) {
storage->bytes[bank][offset + index] &= data[index];
}
return true;
++storage->bank_replacements;
memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE);
memcpy(
&storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE],
payload, payload_size);
memcpy(storage->bytes[bank], header, header_size);
return memcmp(
&storage->bytes[bank][
PROFILE_STORAGE_RECORD_HEADER_SIZE],
payload, payload_size) == 0 &&
memcmp(storage->bytes[bank], header, header_size) == 0;
}
ProfileStorageIo fake_io() {
@ -71,8 +66,7 @@ ProfileStorageIo fake_io() {
PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE,
fake_read,
fake_erase_sector,
fake_program,
fake_replace_bank,
};
}
@ -305,6 +299,105 @@ void test_host_and_controller_mutations_are_serialized() {
"controller activation did not publish while preserving host-visible status");
}
void test_completed_write_then_dirty_identity_activation() {
const int replacements_before = flash.bank_replacements;
const ControllerIdentity global = controller_identity_global();
constexpr uint8_t kWrittenProfileIndex = 1;
ControllerProfile customized =
controller_profile_default(global, kWrittenProfileIndex);
customized.strong_rumble_scale = 31;
customized.weak_rumble_scale = 47;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(customized, encoded, sizeof(encoded)),
"sequential mutation fixture profile did not encode");
constexpr uint32_t kWriteTransactionId = 0x31415926;
require(profile_service_begin(
kWriteTransactionId, global, kWrittenProfileIndex,
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_append(kWriteTransactionId, 0, encoded,
sizeof(encoded)) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_commit(kWriteTransactionId) ==
ConfigurationTransactionStatus::kPending,
"sequential profile write did not reach pending");
profile_service_task_on_storage_core(6000);
const ProfileServiceTransactionSnapshot written =
transaction_snapshot();
require(written.transaction.transaction_id == kWriteTransactionId &&
written.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
flash.bank_replacements == replacements_before + 1,
"completed write lost correlation or used multiple bank replacements");
ControllerIdentity connected{};
connected.stable = true;
connected.transport = ControllerTransport::kClassic;
connected.address[0] = 0x10;
connected.address[1] = 0x20;
connected.address[2] = 0x30;
connected.address[3] = 0x40;
connected.address[4] = 0x50;
connected.address[5] = 0x60;
connected.vendor_id = 0x1234;
connected.product_id = 0xabcd;
require(profile_service_observe_identity_on_storage_core(connected),
"connected identity did not enter the dirty database");
constexpr uint32_t kActivateTransactionId = 0x27182818;
constexpr uint8_t kActivatedProfileIndex = 2;
require(profile_service_activate(
kActivateTransactionId, connected,
kActivatedProfileIndex) ==
ConfigurationTransactionStatus::kPending,
"activation after completed write did not reach pending");
const ProfileServiceTransactionSnapshot pending =
transaction_snapshot();
require(pending.transaction.transaction_id ==
kActivateTransactionId &&
pending.transaction.status ==
ConfigurationTransactionStatus::kPending &&
pending.transaction.stored_generation == 0 &&
pending.transaction.stored_crc == 0,
"pending activation was not correlated to its own transaction");
profile_service_task_on_storage_core(7000);
const ProfileServiceTransactionSnapshot activated =
transaction_snapshot();
require(activated.transaction.transaction_id ==
kActivateTransactionId &&
activated.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
activated.transaction.stored_generation ==
written.transaction.stored_generation + 1 &&
flash.bank_replacements == replacements_before + 2,
"activation did not complete as one next correlated bank replacement");
const ControllerProfileDatabase recovered = reload_database(
activated, activated.transaction.stored_generation);
const ControllerProfileDatabaseEntry* connected_entry =
controller_profile_database_find(recovered, connected);
require(connected_entry != nullptr &&
connected_entry->active_profile ==
kActivatedProfileIndex &&
recovered.fallback_profiles[kWrittenProfileIndex]
.strong_rumble_scale ==
customized.strong_rumble_scale &&
recovered.fallback_profiles[kWrittenProfileIndex]
.weak_rumble_scale ==
customized.weak_rumble_scale,
"activation did not atomically persist the dirty identity and prior write");
const ProfileServiceActiveProfileSnapshot active =
active_profile_snapshot(connected);
require(active.valid &&
active.metadata.generation ==
activated.transaction.stored_generation &&
active.profile_index == kActivatedProfileIndex,
"completed activation did not publish the dirty identity");
}
} // namespace
ProfileStorageIo pico_profile_storage_io() {
@ -314,5 +407,6 @@ ProfileStorageIo pico_profile_storage_io() {
int main() {
test_pending_commands_are_not_decoded_as_profile_writes();
test_host_and_controller_mutations_are_serialized();
test_completed_write_then_dirty_identity_activation();
return 0;
}

View file

@ -14,14 +14,17 @@ struct FakeFlash {
int successful_programs = 0;
int fail_after_programs = -1;
bool corrupt_next_program = false;
int corrupt_header_padding_offset = -1;
bool fail_reads_after_header_program = false;
bool header_programmed = false;
int erase_count = 0;
int bank_replacements = 0;
};
FakeFlash flash{};
ControllerProfileDatabase database{};
ControllerProfileDatabase recovered_database{};
uint8_t encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{};
void require(bool condition, const char* message) {
if (!condition) {
@ -35,9 +38,11 @@ void erase_all() {
flash.successful_programs = 0;
flash.fail_after_programs = -1;
flash.corrupt_next_program = false;
flash.corrupt_header_padding_offset = -1;
flash.fail_reads_after_header_program = false;
flash.header_programmed = false;
flash.erase_count = 0;
flash.bank_replacements = 0;
}
bool fake_read(void* context, uint8_t bank, size_t offset,
@ -56,47 +61,68 @@ bool fake_read(void* context, uint8_t bank, size_t offset,
return true;
}
bool fake_erase_sector(void* context, uint8_t bank, size_t offset) {
bool fake_replace_bank(void* context, uint8_t bank,
const uint8_t* payload, size_t payload_size,
const uint8_t* header, size_t header_size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT ||
offset % PROFILE_STORAGE_SECTOR_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
PROFILE_STORAGE_SECTOR_SIZE >
PROFILE_STORAGE_BANK_SIZE - offset) {
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
header == nullptr ||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) {
return false;
}
++storage->erase_count;
memset(&storage->bytes[bank][offset], 0xff,
PROFILE_STORAGE_SECTOR_SIZE);
return true;
}
bool fake_program(void* context, uint8_t bank, size_t offset,
const uint8_t* data, size_t size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT || data == nullptr ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
++storage->bank_replacements;
memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE);
storage->erase_count +=
static_cast<int>(PROFILE_STORAGE_SECTORS_PER_BANK);
uint8_t final_page[PROFILE_STORAGE_PAGE_SIZE]{};
for (size_t offset = 0; offset < payload_size;
offset += PROFILE_STORAGE_PAGE_SIZE) {
if (storage->fail_after_programs >= 0 &&
storage->successful_programs >=
storage->fail_after_programs) {
return false;
}
const size_t remaining = payload_size - offset;
const uint8_t* page = &payload[offset];
if (remaining < PROFILE_STORAGE_PAGE_SIZE) {
memcpy(final_page, page, remaining);
page = final_page;
}
memcpy(
&storage->bytes[bank][
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset],
page, PROFILE_STORAGE_PAGE_SIZE);
if (storage->corrupt_next_program) {
storage->bytes[bank][
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset] ^= 1;
storage->corrupt_next_program = false;
}
++storage->successful_programs;
}
if (memcmp(
&storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE],
payload, payload_size) != 0) {
return false;
}
if (storage->fail_after_programs >= 0 &&
storage->successful_programs >= storage->fail_after_programs) {
return false;
}
for (size_t index = 0; index < size; ++index) {
storage->bytes[bank][offset + index] &= data[index];
}
if (storage->corrupt_next_program) {
storage->bytes[bank][offset] ^= 1;
storage->corrupt_next_program = false;
}
if (offset == 0) {
storage->header_programmed = true;
}
memcpy(storage->bytes[bank], header, header_size);
storage->header_programmed = true;
++storage->successful_programs;
return true;
if (storage->corrupt_header_padding_offset >= 24 &&
static_cast<size_t>(
storage->corrupt_header_padding_offset) < header_size) {
storage->bytes[bank][
static_cast<size_t>(
storage->corrupt_header_padding_offset)] ^= 1;
}
return memcmp(storage->bytes[bank], header, header_size) == 0;
}
ProfileStorageIo fake_io() {
@ -106,8 +132,7 @@ ProfileStorageIo fake_io() {
PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE,
fake_read,
fake_erase_sector,
fake_program,
fake_replace_bank,
};
}
@ -196,6 +221,15 @@ void install_legacy_database_bank_fixture() {
fixture_write_u32(&record[20], profile_storage_crc32(record, 20));
}
void test_initialize_requires_batch_replacement() {
erase_all();
ProfileStorageIo io = fake_io();
io.replace_bank = nullptr;
ProfileStorage storage;
require(!storage.initialize(io, &database),
"profile storage initialized without bank replacement");
}
void test_two_bank_recovery() {
erase_all();
controller_profile_database_default(&database);
@ -203,16 +237,22 @@ void test_two_bank_recovery() {
require(storage.initialize(fake_io(), &database) &&
!storage.snapshot().valid,
"erased profile storage did not initialize empty");
require(storage.commit(database) == ProfileStorageResult::kOk &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().generation == 1,
"first profile database did not commit");
const int programs_after_first = flash.successful_programs;
require(storage.commit(database) == ProfileStorageResult::kUnchanged &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kUnchanged &&
flash.successful_programs == programs_after_first,
"unchanged profile database consumed flash writes");
database.fallback_profiles[0].button_map[0] = 1;
require(storage.commit(database) == ProfileStorageResult::kOk &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().generation == 2,
"second profile database generation did not commit");
ProfileStorage reloaded;
@ -235,11 +275,15 @@ void test_interrupted_commit_retains_previous_bank() {
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database) == ProfileStorageResult::kOk,
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"interruption baseline did not commit");
database.fallback_profiles[1].button_map[2] = 3;
flash.fail_after_programs = flash.successful_programs + 1;
require(storage.commit(database) == ProfileStorageResult::kIoError,
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError,
"interrupted profile write reported success");
flash.fail_after_programs = -1;
@ -255,13 +299,17 @@ void test_successful_header_program_is_commit_point() {
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database) == ProfileStorageResult::kOk,
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"commit-point baseline did not commit");
database.fallback_profiles[1].strong_rumble_scale = 17;
flash.header_programmed = false;
flash.fail_reads_after_header_program = true;
require(storage.commit(database) == ProfileStorageResult::kOk &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().generation == 2,
"successful header program was rolled back by a later read");
@ -279,7 +327,9 @@ void test_payload_corruption_prevents_header_publication() {
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database) == ProfileStorageResult::kOk,
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"corruption baseline did not commit");
const ProfileStorageSnapshot previous = storage.snapshot();
const uint8_t target_bank = previous.active_bank ^ 1u;
@ -291,7 +341,9 @@ void test_payload_corruption_prevents_header_publication() {
database.fallback_profiles[1].button_map[2] = 3;
flash.corrupt_next_program = true;
require(storage.commit(database) == ProfileStorageResult::kIoError &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError &&
flash.successful_programs ==
programs_before_corruption + kPayloadProgramCount,
"corrupt payload programming reached the header program");
@ -314,6 +366,115 @@ void test_payload_corruption_prevents_header_publication() {
"headerless corrupt payload was recovered");
}
void test_batched_bank_replacement_is_one_atomic_operation() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
constexpr int kPayloadProgramCount =
(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE +
PROFILE_STORAGE_PAGE_SIZE - 1) /
PROFILE_STORAGE_PAGE_SIZE;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
flash.bank_replacements == 1 &&
flash.erase_count == static_cast<int>(
PROFILE_STORAGE_SECTORS_PER_BANK) &&
flash.successful_programs ==
kPayloadProgramCount + 1,
"batched commit did not replace one bank in one operation");
const ProfileStorageSnapshot previous = storage.snapshot();
const uint8_t target_bank = previous.active_bank ^ 1u;
const int programs_before_corruption = flash.successful_programs;
database.fallback_profiles[1].button_map[2] = 3;
flash.corrupt_next_program = true;
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError &&
flash.bank_replacements == 2 &&
flash.successful_programs ==
programs_before_corruption +
kPayloadProgramCount,
"corrupt batched payload reached header publication");
for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE;
++index) {
require(flash.bytes[target_bank][index] == 0xff,
"failed batched replacement published a header");
}
require(storage.snapshot().generation == previous.generation &&
storage.snapshot().payload_crc ==
previous.payload_crc &&
storage.snapshot().active_bank ==
previous.active_bank,
"failed batched replacement changed the committed snapshot");
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation ==
previous.generation &&
recovered.snapshot().active_bank ==
previous.active_bank &&
recovered_database.fallback_profiles[1]
.button_map[2] == 2,
"headerless batched payload replaced the prior bank");
}
void test_header_padding_corruption_fails_commit_and_recovery() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"header padding baseline did not commit");
const ProfileStorageSnapshot previous = storage.snapshot();
const uint8_t previous_scale =
database.fallback_profiles[1].strong_rumble_scale;
database.fallback_profiles[1].strong_rumble_scale = 17;
for (size_t offset = 24;
offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) {
flash.corrupt_header_padding_offset =
static_cast<int>(offset);
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError,
"corrupt header padding did not fail the commit");
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation ==
previous.generation &&
recovered.snapshot().active_bank ==
previous.active_bank &&
recovered_database.fallback_profiles[1]
.strong_rumble_scale == previous_scale,
"corrupt header padding was accepted on recovery");
}
flash.corrupt_header_padding_offset = -1;
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"valid full header page did not commit");
for (size_t offset = 24;
offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) {
require(
flash.bytes[storage.snapshot().active_bank][offset] == 0,
"valid committed header contained nonzero padding");
}
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation ==
previous.generation + 1u &&
recovered_database.fallback_profiles[1]
.strong_rumble_scale == 17,
"valid full header page was rejected on recovery");
}
void test_legacy_database_bank_migration() {
install_legacy_database_bank_fixture();
ProfileStorage storage;
@ -395,7 +556,8 @@ void test_legacy_database_bank_migration() {
"legacy entry raw trigger ranges were not preserved");
recovered_database.fallback_profiles[2].weak_rumble_scale = 17;
require(storage.commit(recovered_database) ==
require(storage.commit(recovered_database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().active_bank == 0 &&
storage.snapshot().generation == 42,
@ -485,9 +647,12 @@ void test_legacy_database_bank_migration() {
} // namespace
int main() {
test_two_bank_recovery();
test_initialize_requires_batch_replacement();
test_interrupted_commit_retains_previous_bank();
test_successful_header_program_is_commit_point();
test_payload_corruption_prevents_header_publication();
test_batched_bank_replacement_is_one_atomic_operation();
test_header_padding_corruption_fails_commit_and_recovery();
test_legacy_database_bank_migration();
return 0;
}

View file

@ -49,6 +49,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"analog-state",
"rumble-mode",
"clear-pairings",
"configuration-timer",
"flash-core-start",
"flash-core-failure",
):

208
tests/test_build.py Normal file
View file

@ -0,0 +1,208 @@
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location("switch_pico_build", ROOT / "build.py")
build_script = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(build_script)
def make_sdk(path):
path.mkdir(parents=True)
(path / "pico_sdk_init.cmake").touch()
return path
def make_toolchain(path):
bin_dir = path / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / build_script.TOOLCHAIN_COMPILER).touch()
return path
def no_compiler(_name, *, path):
return None
def test_parse_cmake_cache_ignores_comments_and_malformed_lines(tmp_path):
cache = tmp_path / "CMakeCache.txt"
cache.write_text(
"// comment\n"
"# internal comment\n"
"PICO_SDK_PATH:PATH=/sdk\n"
"VALUE_WITH_EQUALS:STRING=left=right\n"
"malformed\n",
encoding="utf-8",
)
assert build_script.parse_cmake_cache(cache) == {
"PICO_SDK_PATH": "/sdk",
"VALUE_WITH_EQUALS": "left=right",
}
def test_explicit_environment_wins_over_cache_and_fallback(tmp_path):
explicit_sdk = make_sdk(tmp_path / "explicit-sdk")
explicit_toolchain = make_toolchain(tmp_path / "explicit-toolchain")
cached_sdk = make_sdk(tmp_path / "cached-sdk")
cached_toolchain = make_toolchain(tmp_path / "cached-toolchain")
fallback_sdk = make_sdk(tmp_path / "fallback-sdk")
fallback_toolchain = make_toolchain(tmp_path / "fallback-toolchain")
cache = tmp_path / "build" / "CMakeCache.txt"
cache.parent.mkdir()
cache.write_text(
f"PICO_SDK_PATH:PATH={cached_sdk}\n"
f"PICO_TOOLCHAIN_PATH:PATH={cached_toolchain}\n",
encoding="utf-8",
)
environ = {
"PATH": "",
"PICO_SDK_PATH": str(explicit_sdk),
"PICO_TOOLCHAIN_PATH": str(explicit_toolchain),
}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[cache],
sdk_candidates=[("fallback", fallback_sdk)],
toolchain_candidates=[("fallback", fallback_toolchain)],
which=no_compiler,
)
assert updates == {}
assert environ["PICO_SDK_PATH"] == str(explicit_sdk)
assert environ["PICO_TOOLCHAIN_PATH"] == str(explicit_toolchain)
@pytest.mark.parametrize(
("variable", "invalid", "expected"),
[
("PICO_SDK_PATH", "stale-sdk", "pico_sdk_init.cmake"),
(
"PICO_TOOLCHAIN_PATH",
"stale-toolchain",
f"bin/{build_script.TOOLCHAIN_COMPILER}",
),
],
)
def test_stale_explicit_environment_is_an_error(
tmp_path, variable, invalid, expected
):
sdk = make_sdk(tmp_path / "sdk")
toolchain = make_toolchain(tmp_path / "toolchain")
environ = {
"PATH": "",
"PICO_SDK_PATH": str(sdk),
"PICO_TOOLCHAIN_PATH": str(toolchain),
variable: str(tmp_path / invalid),
}
with pytest.raises(build_script.BuildEnvironmentError) as error:
build_script.configure_pico_environment(
environ=environ,
cache_paths=[],
sdk_candidates=[],
toolchain_candidates=[],
which=no_compiler,
)
assert variable in str(error.value)
assert expected in str(error.value)
def test_cache_paths_win_over_fallbacks(tmp_path, capsys):
cached_sdk = make_sdk(tmp_path / "cached-sdk")
cached_toolchain = make_toolchain(tmp_path / "cached-toolchain")
fallback_sdk = make_sdk(tmp_path / "fallback-sdk")
fallback_toolchain = make_toolchain(tmp_path / "fallback-toolchain")
cache = tmp_path / "build" / "CMakeCache.txt"
cache.parent.mkdir()
cache.write_text(
f"PICO_SDK_PATH:PATH={cached_sdk}\n"
f"CMAKE_C_COMPILER:FILEPATH="
f"{cached_toolchain / 'bin' / build_script.TOOLCHAIN_COMPILER}\n",
encoding="utf-8",
)
environ = {"PATH": ""}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[cache],
sdk_candidates=[("fallback", fallback_sdk)],
toolchain_candidates=[("fallback", fallback_toolchain)],
which=no_compiler,
)
assert updates == {
"PICO_SDK_PATH": str(cached_sdk),
"PICO_TOOLCHAIN_PATH": str(cached_toolchain),
}
output = capsys.readouterr().out
assert f"Auto-detected PICO_SDK_PATH={cached_sdk}" in output
assert f"Auto-detected PICO_TOOLCHAIN_PATH={cached_toolchain}" in output
assert str(cache) in output
def test_project_local_fallback_ignores_stale_cache(tmp_path, monkeypatch):
project_build = tmp_path / "build"
local_sdk = make_sdk(project_build / "_deps" / "pico_sdk-src")
local_toolchain = make_toolchain(project_build / "toolchain")
cache = tmp_path / "old-build" / "CMakeCache.txt"
cache.parent.mkdir()
cache.write_text(
f"PICO_SDK_PATH:PATH={tmp_path / 'missing-sdk'}\n"
f"PICO_TOOLCHAIN_PATH:PATH={tmp_path / 'missing-toolchain'}\n",
encoding="utf-8",
)
monkeypatch.setattr(build_script, "BUILD_DIR", project_build)
environ = {"PATH": ""}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[cache],
which=no_compiler,
)
assert updates == {
"PICO_SDK_PATH": str(local_sdk),
"PICO_TOOLCHAIN_PATH": str(local_toolchain),
}
def test_compiler_on_path_avoids_toolchain_override(tmp_path):
sdk = make_sdk(tmp_path / "sdk")
compiler = tmp_path / "path-bin" / build_script.TOOLCHAIN_COMPILER
environ = {"PATH": str(compiler.parent), "PICO_SDK_PATH": str(sdk)}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[],
sdk_candidates=[],
toolchain_candidates=[],
which=lambda name, *, path: str(compiler),
)
assert updates == {}
assert "PICO_TOOLCHAIN_PATH" not in environ
def test_missing_dependencies_name_only_actionable_overrides():
environ = {"PATH": ""}
with pytest.raises(build_script.BuildEnvironmentError) as error:
build_script.configure_pico_environment(
environ=environ,
cache_paths=[],
sdk_candidates=[],
toolchain_candidates=[],
which=no_compiler,
)
assert str(error.value) == (
"Missing build prerequisite(s): Pico SDK (set PICO_SDK_PATH); "
"Arm GNU toolchain (set PICO_TOOLCHAIN_PATH or add "
"arm-none-eabi-gcc to PATH)."
)

View file

@ -658,6 +658,15 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
)
def test_default_timeout_covers_batched_profile_commit() -> None:
args = config_manager.build_parser().parse_args(["profiles", "list"])
assert (
args.timeout
== config_manager.DEFAULT_OPERATION_TIMEOUT_SECONDS
== 15.0
)
def test_profile_list_select_read_and_chunked_commit() -> None:
device = FakeDevice()
entries = config_manager.list_profiles(device)