Complete controller profile runtime
This commit is contained in:
parent
045edc984e
commit
7358670e63
26 changed files with 1565 additions and 324 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
208
tests/test_build.py
Normal 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)."
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue