Add persistent controller profile foundation
This commit is contained in:
parent
7afc9981fe
commit
3f90d04a50
29 changed files with 6157 additions and 70 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
162
tests/controller_profile_test.cpp
Normal file
162
tests/controller_profile_test.cpp
Normal 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;
|
||||
}
|
||||
186
tests/profile_service_test.cpp
Normal file
186
tests/profile_service_test.cpp
Normal 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;
|
||||
}
|
||||
235
tests/profile_storage_test.cpp
Normal file
235
tests/profile_storage_test.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
30
tests/test_controller_profile_native.py
Normal file
30
tests/test_controller_profile_native.py
Normal 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)
|
||||
33
tests/test_profile_service_native.py
Normal file
33
tests/test_profile_service_native.py
Normal 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)
|
||||
31
tests/test_profile_storage_native.py
Normal file
31
tests/test_profile_storage_native.py
Normal 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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue