Add persistent controller profile foundation

This commit is contained in:
Joey Yakimowich-Payne 2026-09-02 16:52:59 -06:00
commit 3f90d04a50
29 changed files with 6157 additions and 70 deletions

View file

@ -25,6 +25,7 @@ bool ssp_auto_accept = true;
uint8_t accepted_stk_methods = 0xff;
uint16_t link_supervision_timeout = 0;
btstack_packet_handler_t pairing_event_handler = nullptr;
btstack_packet_handler_t identity_event_handler = nullptr;
int confirmation_accepts = 0;
int confirmation_rejections = 0;
int passkey_accepts = 0;
@ -43,6 +44,8 @@ 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;
struct CoreStopped {};
@ -82,11 +85,21 @@ uni_hid_device_t device(
result.idx = idx;
result.gamepad = gamepad;
result.conn.protocol = protocol;
result.conn.handle = static_cast<hci_con_handle_t>(0x40 + idx);
result.conn.btaddr[5] = static_cast<uint8_t>(idx + 1);
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;
return result;
}
void register_lookup_device(uni_hid_device_t* candidate) {
require(lookup_device_count <
sizeof(lookup_devices) / sizeof(lookup_devices[0]),
"test BLE lookup registry overflow");
lookup_devices[lookup_device_count++] = candidate;
}
} // namespace
@ -98,6 +111,16 @@ int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device) {
return device == nullptr ? -1 : device->idx;
}
uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(
hci_con_handle_t handle) {
for (size_t index = 0; index < lookup_device_count; ++index) {
if (lookup_devices[index]->conn.handle == handle) {
return lookup_devices[index];
}
}
return nullptr;
}
void uni_hid_device_disconnect(uni_hid_device_t* device) {
++device_disconnect_calls;
last_disconnected_device = device;
@ -226,6 +249,11 @@ void hci_add_event_handler(
pairing_event_handler = callback_handler->callback;
}
void sm_add_event_handler(
btstack_packet_callback_registration_t* callback_handler) {
identity_event_handler = callback_handler->callback;
}
uint8_t hci_event_packet_get_type(const uint8_t* packet) {
return packet[0];
}
@ -246,6 +274,108 @@ void hci_event_user_passkey_request_get_bd_addr(
copy_event_address(packet, address);
}
hci_con_handle_t sm_event_handle(const uint8_t* packet) {
return static_cast<hci_con_handle_t>(packet[2]) |
static_cast<hci_con_handle_t>(packet[3] << 8);
}
void copy_sm_event_address(const uint8_t* packet, size_t offset,
bd_addr_t address) {
for (size_t index = 0; index < sizeof(bd_addr_t); ++index) {
address[index] = packet[offset + sizeof(bd_addr_t) - 1 - index];
}
}
hci_con_handle_t sm_event_identity_resolving_started_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
hci_con_handle_t sm_event_identity_resolving_failed_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
hci_con_handle_t sm_event_identity_resolving_succeeded_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
uint8_t sm_event_identity_resolving_succeeded_get_addr_type(
const uint8_t* packet) {
return packet[4];
}
void sm_event_identity_resolving_succeeded_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
uint8_t sm_event_identity_resolving_succeeded_get_identity_addr_type(
const uint8_t* packet) {
return packet[11];
}
void sm_event_identity_resolving_succeeded_get_identity_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 12, address);
}
hci_con_handle_t sm_event_identity_created_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
void sm_event_identity_created_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
uint8_t sm_event_identity_created_get_identity_addr_type(
const uint8_t* packet) {
return packet[11];
}
void sm_event_identity_created_get_identity_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 12, address);
}
hci_con_handle_t sm_event_reencryption_started_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
uint8_t sm_event_reencryption_started_get_addr_type(
const uint8_t* packet) {
return packet[4];
}
void sm_event_reencryption_started_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
hci_con_handle_t sm_event_reencryption_complete_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
uint8_t sm_event_reencryption_complete_get_addr_type(
const uint8_t* packet) {
return packet[4];
}
void sm_event_reencryption_complete_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
uint8_t sm_event_reencryption_complete_get_status(
const uint8_t* packet) {
return packet[11];
}
void uni_platform_set_custom(uni_platform* platform) {
installed_platform = platform;
@ -284,10 +414,26 @@ uint32_t btstack_run_loop_get_time_ms() {
}
#include "../controller_identity.cpp"
#include "../bluepad32_input_backend.cpp"
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 profile_service_prepare() {}
void profile_service_initialize_on_storage_core() {}
void profile_service_task_on_storage_core(uint32_t) {}
bool profile_service_observe_identity_on_storage_core(
const ControllerIdentity& identity) {
require(observed_profile_identity_count <
sizeof(observed_profile_identities) /
sizeof(observed_profile_identities[0]),
"profile identity observation fixture overflow");
observed_profile_identities[observed_profile_identity_count++] =
identity;
return true;
}
void configuration_service_snapshot(ConfigurationServiceSnapshot* output) {
*output = {};
output->state = ConfigurationServiceState::kReady;
@ -319,6 +465,15 @@ SwitchRgbColor switch_pro_get_slot_light_color(uint8_t instance) {
namespace {
bool read_controller_state(uint8_t slot, ControllerState* output) {
Bluepad32SlotSnapshot snapshot{};
bluepad32_input_backend_snapshot(slot, &snapshot);
if (output != nullptr) {
*output = snapshot.state;
}
return snapshot.active;
}
void start_backend() {
bluepad32_input_backend_init();
platform_on_init_complete();
@ -327,8 +482,9 @@ void start_backend() {
link_supervision_timeout ==
kClassicLinkSupervisionTimeout &&
!bondable && accepted_stk_methods == 0 &&
!ssp_auto_accept && pairing_event_handler != nullptr,
"initialization must configure liveness and pairing policy");
!ssp_auto_accept && pairing_event_handler != nullptr &&
identity_event_handler != nullptr,
"initialization must register Classic and BLE identity policy");
}
void start_pairing_backend() {
start_backend();
@ -344,6 +500,114 @@ void dispatch_pairing_event(uint8_t event_type) {
pairing_event_handler(HCI_EVENT_PACKET, 0, packet, sizeof(packet));
}
void write_event_address(uint8_t* packet, size_t offset,
const bd_addr_t address) {
for (size_t index = 0; index < sizeof(bd_addr_t); ++index) {
packet[offset + index] =
address[sizeof(bd_addr_t) - 1 - index];
}
}
void dispatch_identity_event(uint8_t event_type,
const uni_hid_device_t& controller,
uint8_t identity_address_type,
const bd_addr_t identity_address,
uint8_t status = ERROR_CODE_SUCCESS) {
uint8_t packet[20]{};
size_t packet_size = 0;
packet[0] = event_type;
packet[2] = static_cast<uint8_t>(controller.conn.handle);
packet[3] = static_cast<uint8_t>(controller.conn.handle >> 8);
switch (event_type) {
case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED:
packet_size = sizeof(packet);
packet[4] = BD_ADDR_TYPE_LE_RANDOM;
write_event_address(packet, 5, controller.conn.btaddr);
packet[11] = identity_address_type;
write_event_address(packet, 12, identity_address);
break;
case SM_EVENT_IDENTITY_CREATED:
packet_size = sizeof(packet);
packet[4] = identity_address_type;
write_event_address(packet, 5, identity_address);
packet[11] = identity_address_type;
write_event_address(packet, 12, identity_address);
break;
case SM_EVENT_REENCRYPTION_STARTED:
packet_size = 11;
packet[4] = identity_address_type;
write_event_address(packet, 5, identity_address);
break;
case SM_EVENT_REENCRYPTION_COMPLETE:
packet_size = 12;
packet[4] = identity_address_type;
write_event_address(packet, 5, identity_address);
packet[11] = status;
break;
default:
require(false, "unsupported identity event fixture");
}
packet[1] = static_cast<uint8_t>(packet_size - 2);
identity_event_handler(
HCI_EVENT_PACKET, 0, packet,
static_cast<uint16_t>(packet_size));
}
void require_identity(const ControllerIdentity& actual, bool stable,
ControllerTransport transport, uint8_t address_type,
const bd_addr_t address, uint16_t vendor_id,
uint16_t product_id, const char* message) {
ControllerIdentity expected{};
expected.stable = stable;
expected.transport = transport;
expected.address_type = address_type;
memcpy(expected.address, address, sizeof(expected.address));
expected.vendor_id = vendor_id;
expected.product_id = product_id;
require(controller_identity_equal(actual, expected), message);
}
void test_identity_encoding_contract() {
ControllerIdentity identity{};
identity.stable = true;
identity.transport = ControllerTransport::kBle;
identity.address_type = BD_ADDR_TYPE_LE_RANDOM_IDENTITY;
const bd_addr_t address = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
memcpy(identity.address, address, sizeof(address));
identity.vendor_id = 0x1234;
identity.product_id = 0xabcd;
uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{};
const uint8_t expected[CONTROLLER_IDENTITY_ENCODED_SIZE] = {
1, 2, 3, 0, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
0x34, 0x12, 0xcd, 0xab};
require(controller_identity_encode(identity, encoded, sizeof(encoded)) &&
memcmp(encoded, expected, sizeof(expected)) == 0,
"controller identity wire encoding changed");
ControllerIdentity decoded{};
require(controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
controller_identity_equal(identity, decoded),
"controller identity wire round trip failed");
decoded.product_id ^= 1;
require(!controller_identity_equal(identity, decoded),
"controller identity equality must include every field");
encoded[3] = 1;
require(!controller_identity_decode(encoded, sizeof(encoded), &decoded),
"controller identity decoder must reject a nonzero reserved byte");
const ControllerIdentity global = controller_identity_global();
memset(encoded, 0xff, sizeof(encoded));
require(controller_identity_is_global(global) &&
controller_identity_encode(global, encoded,
sizeof(encoded)),
"global identity helper must produce an encodable fallback");
for (uint8_t byte : encoded) {
require(byte == 0,
"global fallback identity must encode as all zeroes");
}
}
@ -376,7 +640,7 @@ void test_ready_order(bool reverse) {
for (int ready = 0; ready <= position; ++ready) {
expected_active = expected_active || order[ready] == candidate;
}
require(bluepad32_input_backend_snapshot(candidate, &snapshot) ==
require(read_controller_state(candidate, &snapshot) ==
expected_active,
"only ready indexed slots may become active");
}
@ -403,7 +667,7 @@ void test_ready_order(bool reverse) {
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(candidate, &snapshot) ==
require(read_controller_state(candidate, &snapshot) ==
(candidate != slot),
"disconnect must preserve every surviving slot");
}
@ -444,7 +708,7 @@ void test_rejections() {
collision_data.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&collision, &collision_data);
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(0, &snapshot),
require(read_controller_state(0, &snapshot),
"occupied slot must stay active");
require(!snapshot.button_east,
"mismatched device input must not enter the occupied slot");
@ -453,13 +717,13 @@ void test_rejections() {
slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
slot_zero_data.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_zero, &slot_zero_data);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"valid slot input must remain observable");
require(!bluepad32_input_backend_snapshot(4, &snapshot),
require(!read_controller_state(4, &snapshot),
"public snapshot must reject slot 4");
bluepad32_input_backend_report_sent(4);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"slot 4 acknowledgement must not consume slot 0 IMU");
bluepad32_input_backend_queue_rumble(4, ControllerRumbleOutput{1, 2});
@ -469,6 +733,7 @@ void test_rejections() {
}
void test_independent_lifecycle() {
test_identity_encoding_contract();
start_pairing_backend();
uni_hid_device_t aborted = device(0);
@ -500,7 +765,40 @@ void test_independent_lifecycle() {
"pre-ready disconnect must restart Classic and BLE scans");
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
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)};
const bd_addr_t classic_address =
{0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
const bd_addr_t resolved_connection_address =
{0x41, 0x21, 0x22, 0x23, 0x24, 0x25};
const bd_addr_t created_connection_address =
{0x42, 0x31, 0x32, 0x33, 0x34, 0x35};
const bd_addr_t reencrypted_connection_address =
{0x43, 0x41, 0x42, 0x43, 0x44, 0x45};
memcpy(devices[0].conn.btaddr, classic_address,
sizeof(classic_address));
memcpy(devices[1].conn.btaddr, resolved_connection_address,
sizeof(resolved_connection_address));
memcpy(devices[2].conn.btaddr, created_connection_address,
sizeof(created_connection_address));
memcpy(devices[3].conn.btaddr, reencrypted_connection_address,
sizeof(reencrypted_connection_address));
const bd_addr_t resolved_address =
{0x20, 0x21, 0x22, 0x23, 0x24, 0x25};
const bd_addr_t reencrypted_address =
{0x30, 0x31, 0x32, 0x33, 0x34, 0x35};
dispatch_identity_event(
SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, devices[1],
BD_ADDR_TYPE_LE_PUBLIC, resolved_address);
register_lookup_device(&devices[3]);
dispatch_identity_event(
SM_EVENT_REENCRYPTION_STARTED, devices[3],
BD_ADDR_TYPE_LE_RANDOM, reencrypted_address);
dispatch_identity_event(
SM_EVENT_REENCRYPTION_COMPLETE, devices[3],
BD_ADDR_TYPE_LE_RANDOM, reencrypted_address);
for (int slot = 0; slot < kSlotCount; ++slot) {
platform_on_device_connected(&devices[slot]);
require(g_slots[slot].device == &devices[slot] &&
@ -539,6 +837,73 @@ void test_independent_lifecycle() {
!scanning_enabled && !incoming_connections,
"four ready lifecycle devices must stop connection policy");
Bluepad32SlotSnapshot lifecycle_snapshots[kSlotCount]{};
uint32_t baseline_connection_generations[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_snapshot(
static_cast<uint8_t>(slot), &lifecycle_snapshots[slot]);
require(lifecycle_snapshots[slot].active,
"ready slot snapshot must publish active state");
baseline_connection_generations[slot] =
lifecycle_snapshots[slot].connection_generation;
}
require_identity(
lifecycle_snapshots[0].identity, true,
ControllerTransport::kClassic, BD_ADDR_TYPE_UNKNOWN,
devices[0].conn.btaddr, devices[0].vendor_id,
devices[0].product_id,
"Classic snapshot identity must use the connected device address");
require_identity(
lifecycle_snapshots[1].identity, true,
ControllerTransport::kBle, BD_ADDR_TYPE_LE_PUBLIC,
resolved_address, devices[1].vendor_id, devices[1].product_id,
"resolved BLE snapshot must use the stable identity address");
require(controller_identity_is_global(
lifecycle_snapshots[2].identity),
"unresolved BLE snapshot must use the global unstable identity");
require_identity(
lifecycle_snapshots[3].identity, true,
ControllerTransport::kBle, BD_ADDR_TYPE_LE_RANDOM,
reencrypted_address, devices[3].vendor_id,
devices[3].product_id,
"reencrypted BLE snapshot must use the bonded identity address");
require(observed_profile_identity_count == 3 &&
controller_identity_equal(
observed_profile_identities[0],
lifecycle_snapshots[1].identity) &&
controller_identity_equal(
observed_profile_identities[1],
lifecycle_snapshots[3].identity) &&
controller_identity_equal(
observed_profile_identities[2],
lifecycle_snapshots[0].identity),
"only stable ready identities must be enrolled for profiles");
require(baseline_connection_generations[0] ==
first_pending_generation + 1 &&
baseline_connection_generations[1] ==
baseline_connection_generations[2] &&
baseline_connection_generations[2] ==
baseline_connection_generations[3],
"connection generations must isolate each slot lifecycle");
const bd_addr_t created_address =
{0x40, 0x41, 0x42, 0x43, 0x44, 0x45};
register_lookup_device(&devices[2]);
dispatch_identity_event(
SM_EVENT_IDENTITY_CREATED, devices[2],
BD_ADDR_TYPE_LE_RANDOM, created_address);
bluepad32_input_backend_snapshot(2, &lifecycle_snapshots[2]);
require_identity(
lifecycle_snapshots[2].identity, true,
ControllerTransport::kBle, BD_ADDR_TYPE_LE_RANDOM,
created_address, devices[2].vendor_id, devices[2].product_id,
"new BLE identity event must update an active slot snapshot");
require(observed_profile_identity_count == 4 &&
controller_identity_equal(
observed_profile_identities[3],
lifecycle_snapshots[2].identity),
"late BLE identity creation must enroll the stable identity");
const uint32_t buttons[kSlotCount] = {
BUTTON_B, BUTTON_A, BUTTON_X, BUTTON_Y};
uni_controller_t data[kSlotCount]{};
@ -552,7 +917,7 @@ void test_independent_lifecycle() {
ControllerState states[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
require(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
require(read_controller_state(slot, &states[slot]) &&
states[slot].motion_sample_count == 3,
"every slot must expose independent input and IMU");
}
@ -571,13 +936,13 @@ void test_independent_lifecycle() {
bluepad32_input_backend_report_sent(3);
for (int slot = 0; slot < kSlotCount; ++slot) {
require(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
require(read_controller_state(slot, &states[slot]) &&
states[slot].motion_sample_count == (slot == 3 ? 0 : 3),
"slot 3 acknowledgement must not consume slots 0-2 IMU");
}
for (int slot = 0; slot < 3; ++slot) {
bluepad32_input_backend_report_sent(slot);
require(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
require(read_controller_state(slot, &states[slot]) &&
states[slot].motion_sample_count == 0,
"each slot acknowledgement must consume only its own IMU");
}
@ -605,24 +970,43 @@ void test_independent_lifecycle() {
bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{55, 66});
const uint32_t disconnected_generation =
g_slots[3].connection_generation;
baseline_connection_generations[3];
const int starts_before_slot_three_disconnect = scan_starts;
platform_on_device_disconnected(&devices[3]);
require(scan_starts == starts_before_slot_three_disconnect + 1 &&
scanning_enabled && incoming_connections,
"slot 3 disconnect must resume scanning and incoming connections");
require(!bluepad32_input_backend_snapshot(3, &states[3]) &&
require(!read_controller_state(3, &states[3]) &&
!states[3].button_north && states[3].left_stick_x == 0,
"slot 3 disconnect must publish protocol-neutral state");
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
bluepad32_input_backend_snapshot(3, &lifecycle_snapshots[3]);
require(!lifecycle_snapshots[3].active &&
lifecycle_snapshots[3].connection_generation ==
disconnected_generation + 1 &&
controller_identity_is_global(
lifecycle_snapshots[3].identity) &&
!lifecycle_snapshots[3].state.button_north &&
lifecycle_snapshots[3].state.left_stick_x == 0,
"disconnect snapshot must atomically publish neutral state, "
"cleared identity, and a new connection generation");
for (int survivor = 0; survivor < 3; ++survivor) {
bluepad32_input_backend_snapshot(
static_cast<uint8_t>(survivor),
&lifecycle_snapshots[survivor]);
require(lifecycle_snapshots[survivor].active &&
lifecycle_snapshots[survivor].connection_generation ==
baseline_connection_generations[survivor],
"disconnect generation must not leak into surviving slots");
}
require(read_controller_state(0, &states[0]) &&
states[0].button_east &&
bluepad32_input_backend_snapshot(1, &states[1]) &&
read_controller_state(1, &states[1]) &&
states[1].button_south &&
bluepad32_input_backend_snapshot(2, &states[2]) &&
read_controller_state(2, &states[2]) &&
states[2].button_west,
"slot 3 disconnect must preserve slots 0-2");
platform_on_controller_data(&devices[0], &data[0]);
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
require(read_controller_state(0, &states[0]) &&
states[0].button_east,
"slot 0 input must continue while slot 3 is disconnected");
const int slot_zero_calls_while_scanning = devices[0].rumble_calls;
@ -633,13 +1017,26 @@ void test_independent_lifecycle() {
devices[0].last_high == 116,
"slot 0 rumble must continue while slot 3 is disconnected");
uni_hid_device_t slot_three_replacement = device(3);
uni_hid_device_t slot_three_replacement =
device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
memcpy(slot_three_replacement.conn.btaddr,
devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr));
require(platform_on_device_ready(&slot_three_replacement) ==
UNI_ERROR_SUCCESS,
"slot 3 replacement must bind to the freed indexed slot");
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 0,
"slot 3 replacement must not receive disconnected device rumble");
bluepad32_input_backend_snapshot(3, &lifecycle_snapshots[3]);
require(lifecycle_snapshots[3].active &&
lifecycle_snapshots[3].connection_generation ==
disconnected_generation + 1 &&
controller_identity_is_global(
lifecycle_snapshots[3].identity),
"replacement must keep the new generation and cannot inherit "
"the disconnected BLE identity");
require(observed_profile_identity_count == 4,
"unstable replacement must not be enrolled for profiles");
g_slots[3].pending_rumble = {
3, disconnected_generation, ControllerRumbleOutput{77, 88}};
@ -653,14 +1050,14 @@ void test_independent_lifecycle() {
replacement_data.gamepad.buttons = BUTTON_Y;
replacement_data.gamepad.accel[0] = 9000;
platform_on_controller_data(&slot_three_replacement, &replacement_data);
require(bluepad32_input_backend_snapshot(3, &states[3]) &&
require(read_controller_state(3, &states[3]) &&
states[3].button_north && states[3].motion_sample_count == 3,
"replacement input and IMU must populate only slot 3");
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
require(read_controller_state(0, &states[0]) &&
states[0].button_east &&
bluepad32_input_backend_snapshot(1, &states[1]) &&
read_controller_state(1, &states[1]) &&
states[1].button_south &&
bluepad32_input_backend_snapshot(2, &states[2]) &&
read_controller_state(2, &states[2]) &&
states[2].button_west,
"slot 3 replacement must not disturb slots 0-2");
@ -706,15 +1103,14 @@ void test_independent_lifecycle() {
require(scan_starts == starts_before_disconnect + 1 &&
scanning_enabled && incoming_connections,
"disconnecting slots 0-2 must resume connection policy");
require(!bluepad32_input_backend_snapshot(slot, &states[slot]) &&
require(!read_controller_state(slot, &states[slot]) &&
states[slot].left_stick_x == 0,
"disconnect must publish protocol-neutral state");
for (int survivor = 0; survivor < kSlotCount; ++survivor) {
if (survivor == slot) {
continue;
}
require(bluepad32_input_backend_snapshot(survivor,
&states[survivor]),
require(read_controller_state(survivor, &states[survivor]),
"disconnect must preserve all three survivors");
}
require(platform_on_device_ready(&replacements[slot]) ==
@ -898,7 +1294,7 @@ void test_abxy_hotkey() {
input.gamepad.buttons = BUTTON_A;
platform_on_controller_data(&slot_zero, &input);
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(0, &snapshot),
require(read_controller_state(0, &snapshot),
"slot 0 ABXY state was not published");
require_south_button_mapping(
snapshot, kDefaultSwapAbxy,
@ -908,7 +1304,7 @@ void test_abxy_hotkey() {
kAbxyHotkeyButtonMask | BUTTON_A;
input.gamepad.misc_buttons = kAbxyHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
require(bluepad32_input_backend_snapshot(0, &snapshot),
require(read_controller_state(0, &snapshot),
"toggled slot 0 state was not published");
require_south_button_mapping(
snapshot, !kDefaultSwapAbxy,
@ -960,7 +1356,7 @@ void test_abxy_hotkey() {
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
peer_input.gamepad.buttons = BUTTON_A;
platform_on_controller_data(&slot_one, &peer_input);
require(bluepad32_input_backend_snapshot(1, &snapshot),
require(read_controller_state(1, &snapshot),
"slot 1 ABXY state was not published");
require_south_button_mapping(
snapshot, kDefaultSwapAbxy,
@ -988,7 +1384,7 @@ void test_motion_hotkey() {
input.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_zero, &input);
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"slot 0 did not start with configured motion state");
@ -997,7 +1393,7 @@ void test_motion_hotkey() {
input.gamepad.buttons = kMotionHotkeyButtonMask;
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 0 : 3) &&
!snapshot.dpad_up && !snapshot.button_right_shoulder &&
@ -1030,7 +1426,7 @@ void test_motion_hotkey() {
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
peer_input.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_one, &peer_input);
require(bluepad32_input_backend_snapshot(1, &snapshot) &&
require(read_controller_state(1, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"slot 0 motion chord changed slot 1 motion state");
@ -1042,7 +1438,7 @@ void test_motion_hotkey() {
input.gamepad.buttons = kMotionHotkeyButtonMask;
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"released motion chord did not re-arm or restore motion");
@ -1078,7 +1474,7 @@ void test_protocol_neutral_analog_state() {
platform_on_controller_data(&controller, &input);
ControllerState state{};
require(bluepad32_input_backend_snapshot(0, &state),
require(read_controller_state(0, &state),
"analog state was not published");
require(state.left_stick_x == INT16_MIN &&
state.left_stick_y == 0 &&
@ -1096,7 +1492,7 @@ void test_protocol_neutral_analog_state() {
input.gamepad.buttons =
BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
platform_on_controller_data(&controller, &input);
require(bluepad32_input_backend_snapshot(0, &state) &&
require(read_controller_state(0, &state) &&
state.left_trigger == UINT16_MAX &&
state.right_trigger == UINT16_MAX,
"digital trigger buttons did not map to full analog range");

View file

@ -6,6 +6,7 @@ typedef uint8_t bd_addr_t[6];
typedef uint8_t link_key_t[16];
typedef uint8_t sm_key_t[16];
typedef int link_key_type_t;
typedef uint16_t hci_con_handle_t;
enum bd_addr_type_t {
BD_ADDR_TYPE_LE_PUBLIC = 0,
@ -29,6 +30,12 @@ enum {
HCI_EVENT_PACKET = 4,
HCI_EVENT_USER_CONFIRMATION_REQUEST = 0x33,
HCI_EVENT_USER_PASSKEY_REQUEST = 0x34,
SM_EVENT_IDENTITY_RESOLVING_STARTED = 0xcd,
SM_EVENT_IDENTITY_RESOLVING_FAILED = 0xce,
SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED = 0xcf,
SM_EVENT_IDENTITY_CREATED = 0xd3,
SM_EVENT_REENCRYPTION_STARTED = 0xd6,
SM_EVENT_REENCRYPTION_COMPLETE = 0xd7,
SM_STK_GENERATION_METHOD_JUST_WORKS = 0x01,
SM_STK_GENERATION_METHOD_OOB = 0x02,
SM_STK_GENERATION_METHOD_PASSKEY = 0x04,
@ -114,10 +121,13 @@ enum uni_bt_conn_protocol_t {
struct uni_bt_conn_t {
bd_addr_t btaddr;
hci_con_handle_t handle;
uni_bt_conn_protocol_t protocol;
};
struct uni_hid_device_t {
uint16_t vendor_id;
uint16_t product_id;
uni_bt_conn_t conn;
int idx;
bool gamepad;
@ -154,6 +164,8 @@ struct uni_platform {
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device);
int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device);
void uni_hid_device_disconnect(uni_hid_device_t* device);
uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(
hci_con_handle_t handle);
void uni_bt_allow_incoming_connections(bool enabled);
void uni_bt_start_scanning_and_autoconnect_unsafe();
void uni_bt_stop_scanning_unsafe();
@ -181,10 +193,48 @@ int gap_ssp_passkey_response(const bd_addr_t address, uint32_t passkey);
int gap_ssp_passkey_negative(const bd_addr_t address);
void hci_add_event_handler(
btstack_packet_callback_registration_t* callback_handler);
void sm_add_event_handler(
btstack_packet_callback_registration_t* callback_handler);
uint8_t hci_event_packet_get_type(const uint8_t* packet);
void hci_event_user_confirmation_request_get_bd_addr(
const uint8_t* packet, bd_addr_t address);
void hci_event_user_passkey_request_get_bd_addr(
const uint8_t* packet, bd_addr_t address);
hci_con_handle_t sm_event_identity_resolving_started_get_handle(
const uint8_t* packet);
hci_con_handle_t sm_event_identity_resolving_failed_get_handle(
const uint8_t* packet);
hci_con_handle_t sm_event_identity_resolving_succeeded_get_handle(
const uint8_t* packet);
uint8_t sm_event_identity_resolving_succeeded_get_addr_type(
const uint8_t* packet);
void sm_event_identity_resolving_succeeded_get_address(
const uint8_t* packet, bd_addr_t address);
uint8_t sm_event_identity_resolving_succeeded_get_identity_addr_type(
const uint8_t* packet);
void sm_event_identity_resolving_succeeded_get_identity_address(
const uint8_t* packet, bd_addr_t address);
hci_con_handle_t sm_event_identity_created_get_handle(
const uint8_t* packet);
void sm_event_identity_created_get_address(
const uint8_t* packet, bd_addr_t address);
uint8_t sm_event_identity_created_get_identity_addr_type(
const uint8_t* packet);
void sm_event_identity_created_get_identity_address(
const uint8_t* packet, bd_addr_t address);
hci_con_handle_t sm_event_reencryption_started_get_handle(
const uint8_t* packet);
uint8_t sm_event_reencryption_started_get_addr_type(
const uint8_t* packet);
void sm_event_reencryption_started_get_address(
const uint8_t* packet, bd_addr_t address);
hci_con_handle_t sm_event_reencryption_complete_get_handle(
const uint8_t* packet);
uint8_t sm_event_reencryption_complete_get_addr_type(
const uint8_t* packet);
void sm_event_reencryption_complete_get_address(
const uint8_t* packet, bd_addr_t address);
uint8_t sm_event_reencryption_complete_get_status(
const uint8_t* packet);
void uni_platform_set_custom(uni_platform* platform);
int uni_init(int argc, const char** argv);

View file

@ -0,0 +1,162 @@
#include "controller_identity.h"
#include "controller_profile.h"
#include <cstdlib>
#include <cstring>
#include <iostream>
namespace {
uint8_t encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{};
ControllerProfileDatabase database{};
ControllerProfileDatabase decoded_database{};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
ControllerIdentity identity(uint8_t suffix) {
ControllerIdentity value{};
value.stable = true;
value.transport = ControllerTransport::kClassic;
value.address[5] = suffix;
value.vendor_id = 0x057e;
value.product_id = static_cast<uint16_t>(0x2000u + suffix);
return value;
}
bool read_encoded_database(void*, size_t offset, uint8_t* output,
size_t size) {
if (offset > sizeof(encoded_database) ||
size > sizeof(encoded_database) - offset) {
return false;
}
memcpy(output, &encoded_database[offset], size);
return true;
}
void test_profile_wire_schema() {
const ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode");
require(encoded[0] == 1 && encoded[1] == 0 &&
encoded[2] == 0 && encoded[3] == 1,
"profile header is not little-endian v1/256");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index,
"default direct mapping is not identity");
}
require(encoded[26] == 0xff && encoded[27] == 0x7f &&
encoded[30] == 0,
"default stick encoding changed");
require(encoded[54] == 0xff && encoded[55] == 0xff &&
encoded[58] == 0x00 && encoded[59] == 0x80,
"default trigger encoding changed");
require(encoded[72] == 0xff && encoded[73] == 0xff &&
encoded[74] == 3 && encoded[78] == 0xff &&
encoded[79] == 0xff && encoded[80] == 1,
"default rumble or macro encoding changed");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
require(encoded[100 + index * 19] == 1,
"unused macro step is not canonical end");
}
ControllerProfile decoded{};
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
"default profile did not decode");
encoded[252] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero reserved profile byte was accepted");
ControllerProfile invalid = profile;
invalid.button_map[0] = 16;
require(!controller_profile_validate(invalid),
"invalid direct output was accepted");
invalid = profile;
invalid.sticks[0].inner_deadzone =
invalid.sticks[0].outer_saturation;
require(!controller_profile_validate(invalid),
"empty stick range was accepted");
invalid = profile;
invalid.triggers[0].digital_threshold = 0;
invalid.triggers[0].lower_deadzone = 1;
require(!controller_profile_validate(invalid),
"trigger threshold outside its range was accepted");
invalid = profile;
invalid.turbo_modes[0] =
static_cast<ControllerProfileTurboMode>(3);
require(!controller_profile_validate(invalid),
"invalid Turbo mode was accepted");
invalid = profile;
invalid.macro_step_count = 2;
invalid.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
invalid.macro_steps[0].duration_ms =
CONTROLLER_PROFILE_MAX_WAIT_MS + 1;
require(!controller_profile_validate(invalid),
"unbounded macro wait was accepted");
invalid.macro_steps[0].duration_ms =
CONTROLLER_PROFILE_MAX_WAIT_MS;
invalid.macro_steps[1].type =
ControllerProfileMacroStepType::kState;
require(!controller_profile_validate(invalid),
"macro without a final end was accepted");
}
void test_database_round_trip_and_capacity() {
controller_profile_database_default(&database);
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
ControllerProfileDatabaseEntry* entry = nullptr;
require(controller_profile_database_ensure(
&database, identity(static_cast<uint8_t>(index + 1)),
&entry) == ControllerProfileDatabaseResult::kOk &&
entry != nullptr,
"stable identity was not added");
entry->active_profile = index % CONTROLLER_PROFILE_COUNT;
}
ControllerProfileDatabaseEntry* rejected = nullptr;
require(controller_profile_database_ensure(
&database, identity(99), &rejected) ==
ControllerProfileDatabaseResult::kFull,
"seventeenth stable identity was not rejected");
require(controller_profile_database_find(database, identity(1)) !=
nullptr,
"full-table rejection evicted an existing identity");
for (size_t offset = 0; offset < sizeof(encoded_database);
offset += CONTROLLER_PROFILE_ENCODED_SIZE) {
const size_t size = sizeof(encoded_database) - offset <
CONTROLLER_PROFILE_ENCODED_SIZE
? sizeof(encoded_database) - offset
: CONTROLLER_PROFILE_ENCODED_SIZE;
require(controller_profile_database_encode_range(
database, offset, &encoded_database[offset], size),
"database range did not encode");
}
require(controller_profile_database_decode(
read_encoded_database, nullptr, &decoded_database),
"database did not decode");
require(controller_profile_database_find(
decoded_database, identity(16)) != nullptr,
"last database identity did not round trip");
encoded_database[12] = 1;
require(!controller_profile_database_decode(
read_encoded_database, nullptr, &decoded_database),
"nonzero database header reservation was accepted");
}
} // namespace
int main() {
test_profile_wire_schema();
test_database_round_trip_and_capacity();
return 0;
}

View file

@ -0,0 +1,186 @@
#include "controller_identity.h"
#include "controller_profile.h"
#include "pico_profile_storage.h"
#include "profile_service.h"
#include "profile_storage.h"
#include <cstdlib>
#include <cstring>
#include <iostream>
namespace {
struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE];
};
FakeFlash flash{};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
bool fake_read(void* context, uint8_t bank, size_t offset,
uint8_t* output, size_t size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr ||
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
return false;
}
memcpy(output, &storage->bytes[bank][offset], size);
return true;
}
bool fake_erase_sector(void* context, uint8_t bank, size_t offset) {
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) {
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;
}
ProfileStorageIo fake_io() {
return {
&flash,
PROFILE_STORAGE_BANK_SIZE,
PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE,
fake_read,
fake_erase_sector,
fake_program,
};
}
ProfileServiceTransactionSnapshot transaction_snapshot() {
ProfileServiceTransactionSnapshot snapshot{};
profile_service_transaction_snapshot(&snapshot);
return snapshot;
}
ControllerProfileDatabase reload_database(
const ProfileServiceTransactionSnapshot& transaction,
uint32_t expected_generation) {
ControllerProfileDatabase recovered{};
ProfileStorage storage;
require(storage.initialize(fake_io(), &recovered) &&
storage.snapshot().valid &&
storage.snapshot().generation == expected_generation &&
storage.snapshot().generation ==
transaction.transaction.stored_generation &&
storage.snapshot().payload_crc ==
transaction.transaction.stored_crc,
"terminal transaction status did not identify persisted storage");
return recovered;
}
void test_pending_commands_are_not_decoded_as_profile_writes() {
memset(flash.bytes, 0xff, sizeof(flash.bytes));
profile_service_prepare();
profile_service_initialize_on_storage_core();
const ControllerIdentity identity = controller_identity_global();
constexpr uint8_t kProfileIndex = 2;
ControllerProfile customized =
controller_profile_default(identity, kProfileIndex);
customized.strong_rumble_scale = 17;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(customized, encoded, sizeof(encoded)),
"customized profile did not encode");
constexpr uint32_t kWriteTransactionId = 0x10203040;
require(profile_service_begin(
kWriteTransactionId, identity, kProfileIndex,
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,
"profile write did not reach pending");
profile_service_task_on_storage_core(0);
require(transaction_snapshot().transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"profile write baseline did not commit");
constexpr uint32_t kResetTransactionId = 0xa5a55a5a;
require(profile_service_reset(kResetTransactionId, identity,
kProfileIndex) ==
ConfigurationTransactionStatus::kPending,
"profile reset did not reach pending");
ProfileServiceTransactionSnapshot reset = transaction_snapshot();
require(reset.transaction.transaction_id == kResetTransactionId &&
reset.transaction.status ==
ConfigurationTransactionStatus::kPending,
"pending reset lost its transaction identity");
profile_service_task_on_storage_core(1000);
reset = transaction_snapshot();
require(reset.transaction.transaction_id == kResetTransactionId &&
reset.transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"one reset tick decoded profile payload or published a malformed result");
ControllerProfileDatabase recovered = reload_database(reset, 2);
require(recovered.fallback_profiles[kProfileIndex].strong_rumble_scale ==
UINT8_MAX,
"terminal reset status was published before reset persisted");
constexpr uint32_t kActivateTransactionId = 0x50607080;
constexpr uint8_t kActivatedProfile = 3;
require(profile_service_activate(kActivateTransactionId, identity,
kActivatedProfile) ==
ConfigurationTransactionStatus::kPending,
"profile activation did not reach pending");
ProfileServiceTransactionSnapshot activate = transaction_snapshot();
require(activate.transaction.transaction_id == kActivateTransactionId &&
activate.transaction.status ==
ConfigurationTransactionStatus::kPending,
"pending activation lost its transaction identity");
profile_service_task_on_storage_core(2000);
activate = transaction_snapshot();
require(activate.transaction.transaction_id == kActivateTransactionId &&
activate.transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"one activation tick decoded profile payload or published a malformed result");
recovered = reload_database(activate, 3);
require(recovered.fallback_active_profile == kActivatedProfile,
"terminal activation status was published before activation persisted");
}
} // namespace
ProfileStorageIo pico_profile_storage_io() {
return fake_io();
}
int main() {
test_pending_commands_are_not_decoded_as_profile_writes();
return 0;
}

View file

@ -0,0 +1,235 @@
#include "controller_identity.h"
#include "controller_profile.h"
#include "profile_storage.h"
#include <cstdlib>
#include <cstring>
#include <iostream>
namespace {
struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE];
int successful_programs = 0;
int fail_after_programs = -1;
bool corrupt_next_program = false;
bool fail_reads_after_header_program = false;
bool header_programmed = false;
};
FakeFlash flash{};
ControllerProfileDatabase database{};
ControllerProfileDatabase recovered_database{};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
void erase_all() {
memset(flash.bytes, 0xff, sizeof(flash.bytes));
flash.successful_programs = 0;
flash.fail_after_programs = -1;
flash.corrupt_next_program = false;
flash.fail_reads_after_header_program = false;
flash.header_programmed = false;
}
bool fake_read(void* context, uint8_t bank, size_t offset,
uint8_t* output, size_t size) {
auto* storage = static_cast<FakeFlash*>(context);
if (storage->fail_reads_after_header_program &&
storage->header_programmed) {
return false;
}
if (bank >= PROFILE_STORAGE_BANK_COUNT ||
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
return false;
}
memcpy(output, &storage->bytes[bank][offset], size);
return true;
}
bool fake_erase_sector(void* context, uint8_t bank, size_t offset) {
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) {
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;
}
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;
}
++storage->successful_programs;
return true;
}
ProfileStorageIo fake_io() {
return {
&flash,
PROFILE_STORAGE_BANK_SIZE,
PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE,
fake_read,
fake_erase_sector,
fake_program,
};
}
void test_two_bank_recovery() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
!storage.snapshot().valid,
"erased profile storage did not initialize empty");
require(storage.commit(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 &&
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 &&
storage.snapshot().generation == 2,
"second profile database generation did not commit");
ProfileStorage reloaded;
require(reloaded.initialize(fake_io(), &recovered_database) &&
reloaded.snapshot().generation == 2 &&
recovered_database.fallback_profiles[0].button_map[0] == 1,
"latest profile database did not survive reload");
const uint8_t newest_bank = reloaded.snapshot().active_bank;
flash.bytes[newest_bank][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4] ^= 1;
ProfileStorage after_corruption;
require(after_corruption.initialize(fake_io(), &recovered_database) &&
after_corruption.snapshot().generation == 1 &&
recovered_database.fallback_profiles[0].button_map[0] == 0,
"corrupt newest profile bank did not roll back");
}
void test_interrupted_commit_retains_previous_bank() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(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,
"interrupted profile write reported success");
flash.fail_after_programs = -1;
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation == 1 &&
recovered_database.fallback_profiles[1].button_map[2] == 2,
"interrupted profile write replaced previous bank");
}
void test_successful_header_program_is_commit_point() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(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 &&
storage.snapshot().generation == 2,
"successful header program was rolled back by a later read");
flash.fail_reads_after_header_program = false;
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation == 2 &&
recovered_database.fallback_profiles[1]
.strong_rumble_scale == 17,
"committed header did not recover after transient read failure");
}
void test_payload_corruption_prevents_header_publication() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database) == ProfileStorageResult::kOk,
"corruption baseline did not commit");
const ProfileStorageSnapshot previous = storage.snapshot();
const uint8_t target_bank = previous.active_bank ^ 1u;
const int programs_before_corruption = flash.successful_programs;
constexpr int kPayloadProgramCount =
(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE +
PROFILE_STORAGE_PAGE_SIZE - 1) /
PROFILE_STORAGE_PAGE_SIZE;
database.fallback_profiles[1].button_map[2] = 3;
flash.corrupt_next_program = true;
require(storage.commit(database) == ProfileStorageResult::kIoError &&
flash.successful_programs ==
programs_before_corruption + kPayloadProgramCount,
"corrupt payload programming reached the header program");
for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE;
++index) {
require(flash.bytes[target_bank][index] == 0xff,
"rejected corrupt payload published a discoverable header");
}
require(storage.snapshot().valid == previous.valid &&
storage.snapshot().generation == previous.generation &&
storage.snapshot().payload_crc == previous.payload_crc &&
storage.snapshot().active_bank == previous.active_bank,
"rejected corrupt programming changed the storage 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 corrupt payload was recovered");
}
} // namespace
int main() {
test_two_bank_recovery();
test_interrupted_commit_retains_previous_bank();
test_successful_header_program_is_commit_point();
test_payload_corruption_prevents_header_publication();
return 0;
}

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import json
import struct
import zlib
from pathlib import Path
import pytest
@ -56,7 +58,45 @@ class FakeDevice:
),
]
self.pairing_generation = 4
self.global_identity = config_manager.ControllerIdentity.global_fallback()
self.stable_identity = config_manager.ControllerIdentity(
True,
config_manager.TRANSPORT_CLASSIC,
0,
bytes.fromhex("102030405060"),
0x045E,
0x02FD,
)
self.profile_identities = [
self.global_identity,
self.stable_identity,
]
self.active_profiles = {
identity.to_bytes(): index
for identity, index in zip(self.profile_identities, (0, 1))
}
default_profile = config_manager.ControllerProfile.default().to_bytes()
self.profiles = {
(identity.to_bytes(), index): default_profile
for identity in self.profile_identities
for index in range(config_manager.PROFILE_CAPACITY)
}
self.selected_profile = (self.global_identity.to_bytes(), 0)
self.profile_generation = 7
self.profile_transaction_id = 0
self.profile_transaction_identity = self.global_identity.to_bytes()
self.profile_transaction_index = 0
self.profile_transaction_payload = bytearray()
self.profile_transaction_expected_size = 0
self.profile_transaction_expected_crc = 0
self.profile_transaction_status = config_manager.STATUS_OK
self.fail_profile_commit_status: int | None = None
self.bad_profile_response_crc = False
self.requests: list[int] = []
self.profile_chunk_sizes: list[int] = []
self.pending_profile_mutation: tuple[int, bytes, int] | None = None
self.profile_transaction_pending_reads = 0
self.profile_status_responses: list[tuple[int, int]] = []
def _pairing_payload(self) -> bytes:
payload = bytearray([len(self.records), 0, 0, 0])
@ -77,6 +117,71 @@ class FakeDevice:
stored_crc,
)
def _profile_list_payload(self) -> bytes:
payload = bytearray([len(self.profile_identities)])
for identity in self.profile_identities:
payload.extend(identity.to_bytes())
payload.extend((self.active_profiles[identity.to_bytes()], 0))
return bytes(payload)
def _profile_transaction_payload(self) -> bytes:
stored = self.profiles.get(
(
self.profile_transaction_identity,
self.profile_transaction_index,
),
bytes(config_manager.PROFILE_SIZE),
)
return struct.pack(
"<IHHIII",
self.profile_transaction_id,
len(self.profile_transaction_payload),
self.profile_transaction_expected_size,
self.profile_transaction_expected_crc,
self.profile_generation,
zlib.crc32(stored) & 0xFFFFFFFF,
)
def _queue_profile_mutation(self, operation: int, payload: bytes) -> None:
assert len(payload) == 19
self.profile_transaction_id = struct.unpack_from("<I", payload)[0]
assert self.profile_transaction_id != 0
self.profile_transaction_identity = payload[4:18]
self.profile_transaction_index = payload[18]
self.profile_transaction_payload = bytearray()
self.profile_transaction_expected_size = 0
self.profile_transaction_expected_crc = 0
self.profile_transaction_status = config_manager.STATUS_PENDING
self.profile_transaction_pending_reads = 1
self.pending_profile_mutation = (
operation,
self.profile_transaction_identity,
self.profile_transaction_index,
)
def _complete_profile_mutation(self) -> None:
assert self.pending_profile_mutation is not None
operation, identity, profile_index = self.pending_profile_mutation
if self.fail_profile_commit_status is not None:
self.profile_transaction_status = self.fail_profile_commit_status
self.pending_profile_mutation = None
return
if operation == config_manager.OP_PROFILE_RESET:
indices = (
range(config_manager.PROFILE_CAPACITY)
if profile_index == config_manager.PROFILE_NONE_BUTTON
else (profile_index,)
)
default = config_manager.ControllerProfile.default().to_bytes()
for reset_index in indices:
self.profiles[(identity, reset_index)] = default
else:
assert operation == config_manager.OP_PROFILE_ACTIVATE
self.active_profiles[identity] = profile_index
self.profile_generation += 1
self.profile_transaction_status = config_manager.STATUS_OK
self.pending_profile_mutation = None
def ctrl_transfer(
self,
bm_request_type: int,
@ -92,7 +197,9 @@ class FakeDevice:
self.requests.append(request)
if bm_request_type == 0xC0:
if request == config_manager.OP_INFO:
return make_response(request, bytes([0, 2, 0, 2, 0, 0, 0, 2]))
return make_response(
request, bytes([0, 2, 0, 2, 0, 0, 0, 2])
)
if request == config_manager.OP_CONFIGURATION_READ:
return make_response(
request,
@ -114,6 +221,44 @@ class FakeDevice:
self._pairing_payload(),
generation=self.pairing_generation,
)
if request == config_manager.OP_PROFILE_LIST:
return make_response(
request,
self._profile_list_payload(),
schema=config_manager.PROFILE_SCHEMA_VERSION,
generation=self.profile_generation,
)
if request == config_manager.OP_PROFILE_READ:
response = bytearray(
make_response(
request,
self.profiles[self.selected_profile],
schema=config_manager.PROFILE_SCHEMA_VERSION,
generation=self.profile_generation,
)
)
if self.bad_profile_response_crc:
response[-1] ^= 1
return bytes(response)
if request == config_manager.OP_PROFILE_TRANSACTION_STATUS:
if self.profile_transaction_status == config_manager.STATUS_PENDING:
if self.profile_transaction_pending_reads:
self.profile_transaction_pending_reads -= 1
elif self.pending_profile_mutation is not None:
self._complete_profile_mutation()
self.profile_status_responses.append(
(
self.profile_transaction_id,
self.profile_transaction_status,
)
)
return make_response(
request,
self._profile_transaction_payload(),
status=self.profile_transaction_status,
schema=config_manager.PROFILE_SCHEMA_VERSION,
generation=self.profile_generation,
)
raise AssertionError(f"unexpected IN request {request}")
assert bm_request_type == 0x40
@ -165,11 +310,138 @@ class FakeDevice:
elif request == config_manager.OP_PAIRING_CLEAR:
self.records = []
self.pairing_generation += 1
elif request == config_manager.OP_PROFILE_SELECT:
assert len(payload) == 15
self.selected_profile = (payload[:14], payload[14])
assert self.selected_profile in self.profiles
elif request == config_manager.OP_PROFILE_BEGIN:
assert len(payload) == 28
self.profile_transaction_id = struct.unpack_from("<I", payload)[0]
self.profile_transaction_identity = payload[4:18]
(
self.profile_transaction_index,
reserved,
schema,
self.profile_transaction_expected_size,
self.profile_transaction_expected_crc,
) = struct.unpack_from("<BBHHI", payload, 18)
assert reserved == 0
assert schema == config_manager.PROFILE_SCHEMA_VERSION
assert (
self.profile_transaction_expected_size
== config_manager.PROFILE_SIZE
)
self.profile_transaction_payload = bytearray()
self.profile_transaction_status = config_manager.STATUS_PENDING
self.profile_chunk_sizes = []
elif request == config_manager.OP_PROFILE_CHUNK:
transaction_id, offset, chunk_size = struct.unpack_from(
"<IHH", payload
)
assert transaction_id == self.profile_transaction_id
assert offset == len(self.profile_transaction_payload)
chunk = payload[8 : 8 + chunk_size]
assert len(chunk) == chunk_size
self.profile_transaction_payload.extend(chunk)
self.profile_chunk_sizes.append(chunk_size)
elif request == config_manager.OP_PROFILE_COMMIT:
assert (
struct.unpack("<I", payload)[0]
== self.profile_transaction_id
)
assert (
len(self.profile_transaction_payload)
== self.profile_transaction_expected_size
)
assert (
zlib.crc32(self.profile_transaction_payload) & 0xFFFFFFFF
) == self.profile_transaction_expected_crc
if self.fail_profile_commit_status is None:
key = (
self.profile_transaction_identity,
self.profile_transaction_index,
)
self.profiles[key] = bytes(self.profile_transaction_payload)
self.profile_generation += 1
self.profile_transaction_status = config_manager.STATUS_OK
else:
self.profile_transaction_status = (
self.fail_profile_commit_status
)
elif request == config_manager.OP_PROFILE_RESET:
self._queue_profile_mutation(request, payload)
assert (
self.profile_transaction_index
== config_manager.PROFILE_NONE_BUTTON
or 0
<= self.profile_transaction_index
< config_manager.PROFILE_CAPACITY
)
elif request == config_manager.OP_PROFILE_ACTIVATE:
self._queue_profile_mutation(request, payload)
assert (
0
<= self.profile_transaction_index
< config_manager.PROFILE_CAPACITY
)
else:
raise AssertionError(f"unexpected OUT request {request}")
return len(encoded)
def custom_profile() -> config_manager.ControllerProfile:
return config_manager.ControllerProfile(
button_map=(
1,
0,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
15,
config_manager.PROFILE_NONE_BUTTON,
),
left_stick=config_manager.StickConfig(
-123, 456, 1000, 30000, 384, True, False
),
right_stick=config_manager.StickConfig(
789, -321, 500, 31000, 192, False, True
),
left_trigger=config_manager.TriggerConfig(100, 65000, 320, 32000),
right_trigger=config_manager.TriggerConfig(200, 64000, 224, 33000),
weak_rumble_scale=77,
strong_rumble_scale=201,
confirmation_policy=2,
switching_chord=(1 << 6) | (1 << 7),
macro_trigger=0,
macro_cancel=1,
macro_steps=(
config_manager.MacroStep(
0,
config_manager.MACRO_OVERRIDE_MASK,
config_manager.PROFILE_MAXIMUM_WAIT_MS,
(1 << 0) | (1 << 12),
-32768,
32767,
-1000,
1000,
12345,
54321,
),
config_manager.MacroStep.end(),
),
turbo_modes=(0, 1, 2) + (0,) * 13,
)
def test_response_validation() -> None:
payload = make_response(config_manager.OP_INFO, b"12345678")
envelope = config_manager.parse_response(payload, config_manager.OP_INFO)
@ -205,6 +477,352 @@ def test_configuration_transaction_and_reset() -> None:
assert config_manager.read_configuration(device).pairing_window_seconds == 60
def test_identity_and_profile_binary_json_round_trip() -> None:
identity = config_manager.ControllerIdentity(
True,
config_manager.TRANSPORT_BLE,
3,
bytes.fromhex("A1B2C3D4E5F6"),
0x1234,
0xABCD,
)
encoded_identity = identity.to_bytes()
assert encoded_identity == bytes.fromhex(
"01020300A1B2C3D4E5F63412CDAB"
)
assert config_manager.ControllerIdentity.from_bytes(encoded_identity) == identity
assert (
config_manager.ControllerIdentity.global_fallback().to_bytes()
== bytes(config_manager.CONTROLLER_IDENTITY_SIZE)
)
malformed_identity = bytearray(encoded_identity)
malformed_identity[3] = 1
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.from_bytes(malformed_identity)
profile = custom_profile()
encoded = profile.to_bytes()
assert len(encoded) == config_manager.PROFILE_SIZE
assert struct.unpack_from("<HH", encoded) == (
config_manager.PROFILE_SCHEMA_VERSION,
config_manager.PROFILE_SIZE,
)
assert encoded[75] == encoded[81] == 0
assert encoded[98:100] == b"\x00\x00"
assert encoded[252:] == bytes(4)
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
serialized = profile.to_json()
assert serialized.startswith('{\n "schema_version": 1,\n "size": 256,')
decoded = config_manager.ControllerProfile.from_json(serialized)
assert decoded == profile
assert decoded.to_json() == serialized
def test_profile_list_select_read_and_chunked_commit() -> None:
device = FakeDevice()
entries = config_manager.list_profiles(device)
assert entries == (
config_manager.ProfileListEntry(device.global_identity, 0),
config_manager.ProfileListEntry(device.stable_identity, 1),
)
assert (
config_manager.read_profile(device, device.stable_identity, 1)
== config_manager.ControllerProfile.default()
)
assert device.requests[-2:] == [
config_manager.OP_PROFILE_SELECT,
config_manager.OP_PROFILE_READ,
]
profile = custom_profile()
status = config_manager.write_profile(
device, device.stable_identity, 2, profile, 1.0
)
assert status.status == config_manager.STATUS_OK
assert status.stored_generation == 8
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
assert config_manager.OP_PROFILE_TRANSACTION_STATUS in device.requests
assert (
config_manager.read_profile(device, device.stable_identity, 2)
== profile
)
def test_profile_reset_and_activate_wait_for_correlated_transactions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
generated_ids = iter((0, 0xA5A55A5A))
monkeypatch.setattr(
config_manager.secrets, "randbits", lambda _bits: next(generated_ids)
)
identity = device.stable_identity
profile_key = (identity.to_bytes(), 2)
device.profiles[profile_key] = custom_profile().to_bytes()
reset = config_manager.reset_profile(device, identity, 2, 1.0)
assert reset.transaction_id == device.profile_transaction_id == 1
assert reset.status == config_manager.STATUS_OK
assert reset.stored_generation == 8
assert device.profile_status_responses == [
(1, config_manager.STATUS_PENDING),
(1, config_manager.STATUS_OK),
]
assert (
device.profiles[profile_key]
== config_manager.ControllerProfile.default().to_bytes()
)
device.profile_status_responses.clear()
activated = config_manager.activate_profile(device, identity, 3, 1.0)
assert (
activated.transaction_id
== device.profile_transaction_id
== 0xA5A55A5A
)
assert activated.status == config_manager.STATUS_OK
assert activated.stored_generation == 9
assert device.profile_status_responses == [
(0xA5A55A5A, config_manager.STATUS_PENDING),
(0xA5A55A5A, config_manager.STATUS_OK),
]
assert device.active_profiles[identity.to_bytes()] == 3
def test_profile_cli_surfaces_late_storage_failure(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
device = FakeDevice()
device.fail_profile_commit_status = 8
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
previous_active = device.active_profiles[device.stable_identity.to_bytes()]
assert (
config_manager.main(
["profiles", "activate", "4", "--identity", "1"]
)
== 1
)
output = capsys.readouterr()
assert output.out == ""
assert "storage failure" in output.err
assert (
device.active_profiles[device.stable_identity.to_bytes()]
== previous_active
)
assert device.profile_status_responses == [
(device.profile_transaction_id, config_manager.STATUS_PENDING),
(device.profile_transaction_id, 8),
]
def test_profile_cli_json_round_trip_activate_and_reset(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
device = FakeDevice()
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
export_path = tmp_path / "profile.json"
assert config_manager.main(["profiles", "list"]) == 0
output = capsys.readouterr().out
assert "0: global fallback (active profile 1)" in output
assert "1: Classic 10:20:30:40:50:60" in output
device.profiles[(device.stable_identity.to_bytes(), 1)] = (
custom_profile().to_bytes()
)
assert (
config_manager.main(
[
"profiles",
"export",
"2",
str(export_path),
"--identity",
"1",
]
)
== 0
)
_ = capsys.readouterr()
exported = config_manager.ControllerProfile.from_json(
export_path.read_text(encoding="utf-8")
)
assert exported == custom_profile()
device.profiles[(device.stable_identity.to_bytes(), 1)] = (
config_manager.ControllerProfile.default().to_bytes()
)
assert (
config_manager.main(
[
"profiles",
"import",
"2",
str(export_path),
"--identity",
"1",
]
)
== 0
)
assert (
device.profiles[(device.stable_identity.to_bytes(), 1)]
== custom_profile().to_bytes()
)
_ = capsys.readouterr()
assert (
config_manager.main(
["profiles", "activate", "4", "--identity", "1"]
)
== 0
)
assert device.active_profiles[device.stable_identity.to_bytes()] == 3
_ = capsys.readouterr()
before_reset_requests = len(device.requests)
assert (
config_manager.main(
["profiles", "reset", "2", "--identity", "1"]
)
== 2
)
assert "requires --yes" in capsys.readouterr().err
assert len(device.requests) == before_reset_requests
assert (
config_manager.main(
[
"profiles",
"reset",
"2",
"--identity",
"1",
"--yes",
]
)
== 0
)
assert (
device.profiles[(device.stable_identity.to_bytes(), 1)]
== config_manager.ControllerProfile.default().to_bytes()
)
_ = capsys.readouterr()
assert (
config_manager.main(
[
"profiles",
"reset",
"all",
"--identity",
"1",
"--yes",
]
)
== 0
)
def test_malformed_profiles_are_rejected_before_usb(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
malformed_binary = bytearray(config_manager.ControllerProfile.default().to_bytes())
malformed_binary[75] = 1
with pytest.raises(
config_manager.ConfigManagerError, match="reserved fields"
):
config_manager.ControllerProfile.from_bytes(malformed_binary)
profile_object = config_manager.ControllerProfile.default().to_json_object()
del profile_object["turbo"]
missing_path = tmp_path / "missing.json"
missing_path.write_text(json.dumps(profile_object), encoding="utf-8")
profile_object = config_manager.ControllerProfile.default().to_json_object()
profile_object["reserved"] = 0
unknown_path = tmp_path / "unknown.json"
unknown_path.write_text(json.dumps(profile_object), encoding="utf-8")
profile_object = config_manager.ControllerProfile.default().to_json_object()
profile_object["rumble"]["confirmation_policy"] = "invalid"
enum_path = tmp_path / "enum.json"
enum_path.write_text(json.dumps(profile_object), encoding="utf-8")
profile_object = config_manager.ControllerProfile.default().to_json_object()
profile_object["sticks"]["left"]["outer_saturation"] = 0
range_path = tmp_path / "range.json"
range_path.write_text(json.dumps(profile_object), encoding="utf-8")
usb_lookups = 0
def candidates() -> list[FakeDevice]:
nonlocal usb_lookups
usb_lookups += 1
return [FakeDevice()]
monkeypatch.setattr(config_manager, "_candidate_devices", candidates)
for path in (missing_path, unknown_path, enum_path, range_path):
assert (
config_manager.main(["profiles", "import", "1", str(path)]) == 1
)
assert "error:" in capsys.readouterr().err
assert usb_lookups == 0
def test_profile_crc_status_failures_and_identity_bounds(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
device = FakeDevice()
device.bad_profile_response_crc = True
with pytest.raises(
config_manager.ConfigManagerError, match="response CRC mismatch"
):
config_manager.read_profile(device, device.global_identity, 0)
failing_device = FakeDevice()
failing_device.fail_profile_commit_status = 6
with pytest.raises(config_manager.ConfigManagerError, match="CRC mismatch"):
config_manager.write_profile(
failing_device,
failing_device.global_identity,
0,
custom_profile(),
1.0,
)
bounded_device = FakeDevice()
monkeypatch.setattr(
config_manager, "_candidate_devices", lambda: [bounded_device]
)
assert (
config_manager.main(
[
"profiles",
"export",
"1",
str(tmp_path / "unused.json"),
"--identity",
"2",
]
)
== 1
)
assert "identity index 2 is out of range" in capsys.readouterr().err
assert config_manager.OP_PROFILE_SELECT not in bounded_device.requests
def test_status_and_pairing_commands(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],

View file

@ -0,0 +1,30 @@
import shutil
import subprocess
from pathlib import Path
def test_controller_profile_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "controller_profile_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root}",
str(root / "tests" / "controller_profile_test.cpp"),
str(root / "controller_identity.cpp"),
str(root / "controller_profile.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,33 @@
import shutil
import subprocess
from pathlib import Path
def test_profile_service_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "profile_service_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{root}",
str(root / "tests" / "profile_service_test.cpp"),
str(root / "controller_identity.cpp"),
str(root / "controller_profile.cpp"),
str(root / "profile_storage.cpp"),
str(root / "profile_service.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,31 @@
import shutil
import subprocess
from pathlib import Path
def test_profile_storage_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "profile_storage_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root}",
str(root / "tests" / "profile_storage_test.cpp"),
str(root / "controller_identity.cpp"),
str(root / "controller_profile.cpp"),
str(root / "profile_storage.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -11,6 +11,9 @@ namespace {
Bluepad32PairingSnapshot current_pairings{};
ConfigurationServiceSnapshot current_configuration{};
ProfileServiceListSnapshot current_profile_list{};
ProfileServiceSelectedSnapshot current_profile_selected{};
ProfileServiceTransactionSnapshot current_profile_transaction{};
bool refresh_requested = false;
bool clear_requested = false;
std::vector<uint8_t> control_payload;
@ -20,6 +23,16 @@ uint32_t append_transaction_id = 0;
uint32_t commit_transaction_id = 0;
size_t append_offset = 0;
std::vector<uint8_t> appended_bytes;
ControllerIdentity profile_identity{};
uint8_t profile_index = 0;
uint16_t profile_schema = 0;
size_t profile_size = 0;
uint32_t profile_crc = 0;
bool profile_reset_requested = false;
uint32_t profile_reset_transaction_id = 0;
uint32_t profile_commit_transaction_id = 0;
bool profile_activate_requested = false;
uint32_t profile_activate_transaction_id = 0;
void require(bool condition, const char* message) {
if (!condition) {
@ -42,6 +55,13 @@ void write_u32(std::vector<uint8_t>* output, size_t offset,
(*output)[offset + 3] = static_cast<uint8_t>(value >> 24);
}
uint32_t read_u32(const std::vector<uint8_t>& input, size_t offset) {
return static_cast<uint32_t>(input[offset]) |
(static_cast<uint32_t>(input[offset + 1]) << 8) |
(static_cast<uint32_t>(input[offset + 2]) << 16) |
(static_cast<uint32_t>(input[offset + 3]) << 24);
}
std::vector<uint8_t> make_request(
UsbConfigurationManagement::Operation operation,
const std::vector<uint8_t>& payload) {
@ -213,6 +233,173 @@ void test_vendor_requests() {
"request with invalid magic was accepted");
}
void test_profile_vendor_requests() {
using namespace UsbConfigurationManagement;
ControllerIdentity expected_identity{};
expected_identity.stable = true;
expected_identity.transport = ControllerTransport::kClassic;
expected_identity.address[5] = 7;
expected_identity.vendor_id = 0x057e;
expected_identity.product_id = 0x2009;
current_profile_list = {};
current_profile_list.metadata.state = ProfileServiceState::kReady;
current_profile_list.metadata.generation = 9;
current_profile_list.count = 2;
current_profile_list.rows[0].identity = controller_identity_global();
current_profile_list.rows[1].identity = expected_identity;
current_profile_list.rows[1].active_profile = 2;
tusb_control_request_t request = setup_request(
Operation::kProfileList, TUSB_DIR_IN, kMaximumResponseSize);
require(tud_vendor_control_xfer_cb(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() == kResponseHeaderSize + 33 &&
control_payload[5] ==
static_cast<uint8_t>(Operation::kProfileList) &&
control_payload[10] == CONTROLLER_PROFILE_SCHEMA_VERSION &&
control_payload[kResponseHeaderSize] == 2 &&
control_payload[kResponseHeaderSize + 31] == 2,
"profile list response was not encoded");
current_profile_selected = {};
current_profile_selected.metadata.state =
ProfileServiceState::kReady;
current_profile_selected.metadata.generation = 9;
current_profile_selected.valid = true;
current_profile_selected.status =
ConfigurationTransactionStatus::kCommitted;
current_profile_selected.identity = expected_identity;
current_profile_selected.profile_index = 2;
current_profile_selected.profile =
controller_profile_default(expected_identity, 2);
request = setup_request(
Operation::kProfileRead, TUSB_DIR_IN, kMaximumResponseSize);
require(tud_vendor_control_xfer_cb(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() ==
kResponseHeaderSize +
CONTROLLER_PROFILE_ENCODED_SIZE &&
control_payload[kResponseHeaderSize] == 1 &&
control_payload[kResponseHeaderSize + 2] == 0 &&
control_payload[kResponseHeaderSize + 3] == 1,
"selected profile response was not encoded");
current_profile_transaction = {};
current_profile_transaction.metadata.state =
ProfileServiceState::kReady;
current_profile_transaction.metadata.generation = 9;
current_profile_transaction.transaction.transaction_id = 0x01020304;
current_profile_transaction.transaction.status =
ConfigurationTransactionStatus::kPending;
request = setup_request(
Operation::kProfileTransactionStatus, TUSB_DIR_IN,
kMaximumResponseSize);
require(tud_vendor_control_xfer_cb(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() == kResponseHeaderSize + 20 &&
control_payload[6] ==
static_cast<uint8_t>(Status::kPending) &&
read_u32(control_payload, kResponseHeaderSize) ==
0x01020304,
"pending profile transaction status lost its transaction ID");
current_profile_transaction.transaction.status =
ConfigurationTransactionStatus::kCommitted;
current_profile_transaction.transaction.stored_generation =
0x11223344;
current_profile_transaction.transaction.stored_crc = 0xaabbccdd;
require(tud_vendor_control_xfer_cb(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
read_u32(control_payload, kResponseHeaderSize) ==
0x01020304 &&
read_u32(control_payload, kResponseHeaderSize + 12) ==
0x11223344 &&
read_u32(control_payload, kResponseHeaderSize + 16) ==
0xaabbccdd,
"final profile transaction status lost its commit result");
std::vector<uint8_t> identity_payload(15);
require(controller_identity_encode(
expected_identity, identity_payload.data(),
CONTROLLER_IDENTITY_ENCODED_SIZE),
"profile test identity did not encode");
identity_payload[14] = 2;
perform_out(Operation::kProfileSelect, identity_payload);
require(controller_identity_equal(expected_identity,
profile_identity) &&
profile_index == 2,
"profile selection was not dispatched");
std::vector<uint8_t> begin(28);
write_u32(&begin, 0, 0x55667788);
require(controller_identity_encode(
expected_identity, &begin[4],
CONTROLLER_IDENTITY_ENCODED_SIZE),
"profile begin identity did not encode");
begin[18] = 1;
write_u16(&begin, 20, CONTROLLER_PROFILE_SCHEMA_VERSION);
write_u16(&begin, 22, CONTROLLER_PROFILE_ENCODED_SIZE);
write_u32(&begin, 24, 0xaabbccdd);
perform_out(Operation::kProfileBegin, begin);
require(begin_transaction_id == 0x55667788 &&
profile_index == 1 &&
profile_schema == CONTROLLER_PROFILE_SCHEMA_VERSION &&
profile_size == CONTROLLER_PROFILE_ENCODED_SIZE &&
profile_crc == 0xaabbccdd,
"profile begin was not dispatched");
std::vector<uint8_t> chunk(48);
write_u32(&chunk, 0, 0x55667788);
write_u16(&chunk, 4, 0);
write_u16(&chunk, 6, 40);
perform_out(Operation::kProfileChunk, chunk);
require(append_transaction_id == 0x55667788 &&
append_offset == 0 && appended_bytes.size() == 40,
"profile chunk was not dispatched");
std::vector<uint8_t> commit(4);
write_u32(&commit, 0, 0x55667788);
perform_out(Operation::kProfileCommit, commit);
require(profile_commit_transaction_id == 0x55667788,
"profile commit was not dispatched");
std::vector<uint8_t> mutation(19);
write_u32(&mutation, 0, 0x10203040);
require(controller_identity_encode(
expected_identity, &mutation[4],
CONTROLLER_IDENTITY_ENCODED_SIZE),
"profile mutation identity did not encode");
mutation[18] = CONTROLLER_PROFILE_ALL;
perform_out(Operation::kProfileReset, mutation);
require(profile_reset_requested &&
profile_reset_transaction_id == 0x10203040,
"profile reset transaction was not dispatched");
write_u32(&mutation, 0, 0x50607080);
mutation[18] = 3;
perform_out(Operation::kProfileActivate, mutation);
require(profile_activate_requested && profile_index == 3 &&
profile_activate_transaction_id == 0x50607080,
"profile activation transaction was not dispatched");
write_u32(&mutation, 0, 0);
perform_out(Operation::kProfileActivate, mutation, false);
request = setup_request(
Operation::kProfileReset, TUSB_DIR_OUT, kRequestHeaderSize + 15);
require(!tud_vendor_control_xfer_cb(
0, CONTROL_STAGE_SETUP, &request),
"legacy profile reset payload was accepted");
begin[19] = 1;
perform_out(Operation::kProfileBegin, begin, false);
request = setup_request(
Operation::kProfileSelect, TUSB_DIR_OUT,
kRequestHeaderSize + 14);
require(!tud_vendor_control_xfer_cb(
0, CONTROL_STAGE_SETUP, &request),
"short profile selection request was accepted");
}
} // namespace
uint32_t configuration_crc32(const uint8_t* data, size_t size) {
@ -256,6 +443,75 @@ ConfigurationTransactionStatus configuration_service_reset(uint32_t) {
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_select(
const ControllerIdentity& identity, uint8_t selected_profile) {
profile_identity = identity;
profile_index = selected_profile;
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_begin(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile, uint16_t schema_version,
size_t payload_size, uint32_t payload_crc) {
begin_transaction_id = transaction_id;
profile_identity = identity;
profile_index = selected_profile;
profile_schema = schema_version;
profile_size = payload_size;
profile_crc = payload_crc;
return ConfigurationTransactionStatus::kReceiving;
}
ConfigurationTransactionStatus profile_service_append(
uint32_t transaction_id, size_t offset, const uint8_t* data,
size_t size) {
append_transaction_id = transaction_id;
append_offset = offset;
appended_bytes.assign(data, data + size);
return ConfigurationTransactionStatus::kReceiving;
}
ConfigurationTransactionStatus profile_service_commit(
uint32_t transaction_id) {
profile_commit_transaction_id = transaction_id;
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_reset(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile) {
profile_reset_transaction_id = transaction_id;
profile_identity = identity;
profile_index = selected_profile;
profile_reset_requested = true;
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_activate(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile) {
profile_activate_transaction_id = transaction_id;
profile_identity = identity;
profile_index = selected_profile;
profile_activate_requested = true;
return ConfigurationTransactionStatus::kPending;
}
void profile_service_list_snapshot(ProfileServiceListSnapshot* output) {
*output = current_profile_list;
}
void profile_service_selected_snapshot(
ProfileServiceSelectedSnapshot* output) {
*output = current_profile_selected;
}
void profile_service_transaction_snapshot(
ProfileServiceTransactionSnapshot* output) {
*output = current_profile_transaction;
}
void bluepad32_input_backend_request_pairing_snapshot() {
refresh_requested = true;
}
@ -288,6 +544,8 @@ bool tud_control_status(uint8_t, const tusb_control_request_t*) {
}
#include "../adapter_configuration.cpp"
#include "../controller_identity.cpp"
#include "../controller_profile.cpp"
#include "../usb_configuration_management.cpp"
int main() {
@ -297,5 +555,6 @@ int main() {
test_envelope_encoding();
test_pairing_encoding();
test_vendor_requests();
test_profile_vendor_requests();
return 0;
}