feat: support Switch 2 controllers and expanded profiles
This commit is contained in:
parent
4e48b7a9e7
commit
a87917bf0b
51 changed files with 4371 additions and 365 deletions
|
|
@ -9,6 +9,8 @@
|
|||
#endif
|
||||
|
||||
#include <uni.h>
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "parser/uni_switch2_pairing.h"
|
||||
#include "platform/pico/controller_color_config.h"
|
||||
#include "input/switch2_wake.h"
|
||||
|
||||
|
|
@ -67,6 +69,11 @@ gap_connection_type_t gap_connection_types[256]{};
|
|||
uint8_t xbox_left_trigger = 0;
|
||||
uint8_t xbox_right_trigger = 0;
|
||||
unsigned xbox_quad_calls = 0;
|
||||
unsigned ordinary_smp_requests = 0;
|
||||
bd_addr_t switch2_pairings[UNI_SWITCH2_PAIRING_CAPACITY]{};
|
||||
uint8_t switch2_pairing_types[UNI_SWITCH2_PAIRING_CAPACITY]{};
|
||||
uint8_t switch2_pairing_count = 0;
|
||||
bool switch2_clear_succeeds = true;
|
||||
|
||||
struct CoreStopped {};
|
||||
|
||||
|
|
@ -145,6 +152,53 @@ void uni_hid_parser_xboxone_play_dual_rumble(
|
|||
}
|
||||
|
||||
|
||||
extern "C" void __real_sm_request_pairing(hci_con_handle_t) {
|
||||
++ordinary_smp_requests;
|
||||
}
|
||||
|
||||
extern "C" bool uni_switch2_pairing_get(
|
||||
uint8_t index, uint8_t* address_type, uint8_t address[6]) {
|
||||
if (index >= switch2_pairing_count) {
|
||||
return false;
|
||||
}
|
||||
*address_type = switch2_pairing_types[index];
|
||||
memcpy(address, switch2_pairings[index], sizeof(bd_addr_t));
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" bool uni_switch2_pairing_clear(void) {
|
||||
if (!switch2_clear_succeeds) {
|
||||
return false;
|
||||
}
|
||||
switch2_pairing_count = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_switch2_is_ble_device(
|
||||
const uni_hid_device_t* device) {
|
||||
return device != nullptr &&
|
||||
device->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE &&
|
||||
device->vendor_id == UNI_SW2_NINTENDO_VID &&
|
||||
(device->product_id == UNI_SW2_PRO_PID ||
|
||||
device->product_id == UNI_SW2_JOYCON_L_PID ||
|
||||
device->product_id == UNI_SW2_JOYCON_R_PID);
|
||||
}
|
||||
|
||||
extern "C" uint8_t uni_hid_parser_switch2_extra_buttons(
|
||||
const uni_hid_device_t* device) {
|
||||
return uni_hid_parser_switch2_is_ble_device(device)
|
||||
? device->switch2_extra_buttons : 0;
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_switch2_identity_address_type(
|
||||
const uni_hid_device_t* device, uint8_t* output) {
|
||||
if (!uni_hid_parser_switch2_is_ble_device(device) ||
|
||||
!device->switch2_identity_valid || output == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*output = device->switch2_identity_address_type;
|
||||
return true;
|
||||
}
|
||||
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) {
|
||||
return device != nullptr && device->gamepad;
|
||||
}
|
||||
|
|
@ -783,6 +837,377 @@ void tick_backend_timer(int ticks) {
|
|||
}
|
||||
}
|
||||
|
||||
uni_hid_device_t switch2_device(int index, uint16_t product) {
|
||||
uni_hid_device_t result = device(index, true, UNI_BT_CONN_PROTOCOL_BLE);
|
||||
result.vendor_id = UNI_SW2_NINTENDO_VID;
|
||||
result.product_id = product;
|
||||
result.switch2_identity_valid = true;
|
||||
result.switch2_identity_address_type = BD_ADDR_TYPE_LE_PUBLIC;
|
||||
result.report_parser.set_player_leds = set_player_leds;
|
||||
return result;
|
||||
}
|
||||
|
||||
void ready_switch2(uni_hid_device_t& controller) {
|
||||
platform_on_device_connected(&controller);
|
||||
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
|
||||
"Switch2 physical device must become ready");
|
||||
}
|
||||
|
||||
Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
|
||||
Bluepad32SlotSnapshot result{};
|
||||
bluepad32_input_backend_snapshot(index, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void test_switch2_pair_lifecycle(bool right_first) {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t left = switch2_device(
|
||||
right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
|
||||
uni_hid_device_t right = switch2_device(
|
||||
right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
|
||||
uni_hid_device_t& first = right_first ? right : left;
|
||||
uni_hid_device_t& second = right_first ? left : right;
|
||||
ready_switch2(first);
|
||||
|
||||
uni_controller_t left_data{};
|
||||
left_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
left_data.gamepad.dpad = DPAD_UP;
|
||||
left_data.gamepad.axis_x = -512;
|
||||
left_data.gamepad.axis_y = 100;
|
||||
left_data.gamepad.buttons = BUTTON_TRIGGER_L;
|
||||
left_data.gamepad.accel[0] = 8192;
|
||||
left.switch2_extra_buttons =
|
||||
UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR;
|
||||
uni_controller_t right_data{};
|
||||
right_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
right_data.gamepad.buttons = BUTTON_B | BUTTON_THUMB_R | BUTTON_TRIGGER_R;
|
||||
right_data.gamepad.axis_rx = 200;
|
||||
right_data.gamepad.axis_ry = -512;
|
||||
right_data.gamepad.accel[2] = 8192;
|
||||
right.switch2_extra_buttons =
|
||||
UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GR |
|
||||
UNI_SW2_BUTTON_RIGHT_SL | UNI_SW2_BUTTON_RIGHT_SR;
|
||||
platform_on_controller_data(&first, right_first ? &right_data : &left_data);
|
||||
const Bluepad32SlotSnapshot solo = slot_snapshot(0);
|
||||
require(solo.active && solo.state.button_left_shoulder &&
|
||||
solo.state.button_right_shoulder && solo.state.button_left_stick == right_first &&
|
||||
solo.state.right_stick_x == 0 && solo.state.right_stick_y == 0 &&
|
||||
(right_first ? solo.state.button_south : solo.state.button_west),
|
||||
"solo JoyCon must rotate face controls, stick click and rail shoulders");
|
||||
require(solo.state.left_stick_x ==
|
||||
(right_first ? INT16_MAX : scale_axis(100)) &&
|
||||
solo.state.left_stick_y ==
|
||||
(right_first ? scale_axis(200) : INT16_MAX),
|
||||
"solo JoyCon stick must rotate with its physical sideways orientation");
|
||||
require(bluepad32_input_backend_capture_start(
|
||||
0, solo.connection_generation, CaptureOptions{}),
|
||||
"solo capture must start on its logical generation");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{31, 41});
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, solo.connection_generation, 8,
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed);
|
||||
ready_switch2(second);
|
||||
|
||||
Bluepad32SlotSnapshot merged = slot_snapshot(0);
|
||||
require(merged.active && !slot_snapshot(1).active &&
|
||||
merged.connection_generation != solo.connection_generation &&
|
||||
controller_identity_equal(merged.identity, identity_for_device(&left)),
|
||||
"either connection order must merge into first output with left profile owner");
|
||||
Bluepad32CaptureSnapshot capture{};
|
||||
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
|
||||
capture.state == CaptureState::kDisconnected,
|
||||
"solo recording must not silently cross into the merged generation");
|
||||
require(!slot_snapshot(1).state.extra_buttons &&
|
||||
!slot_snapshot(1).state.button_south &&
|
||||
!slot_snapshot(1).state.button_west &&
|
||||
left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0,
|
||||
"pair merge must neutralize ghost output and stop old feedback");
|
||||
const int calls_after_merge = left.rumble_calls + right.rumble_calls;
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, solo.connection_generation, 8,
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left.rumble_calls + right.rumble_calls == calls_after_merge &&
|
||||
left.player_leds == 1 && right.player_leds == 1,
|
||||
"old-generation solo feedback must not reach the pair");
|
||||
platform_on_controller_data(&left, &left_data);
|
||||
platform_on_controller_data(&right, &right_data);
|
||||
merged = slot_snapshot(0);
|
||||
require(merged.state.dpad_up && merged.state.button_east &&
|
||||
merged.state.button_right_stick && !merged.state.button_left_stick &&
|
||||
!merged.state.button_left_shoulder && !merged.state.button_right_shoulder &&
|
||||
merged.state.left_stick_x == INT16_MIN &&
|
||||
merged.state.right_stick_x == scale_axis(200) &&
|
||||
merged.state.left_trigger == UINT16_MAX &&
|
||||
merged.state.right_trigger == UINT16_MAX &&
|
||||
merged.state.extra_buttons == 0x7f &&
|
||||
merged.state.motion_samples[0].accel_x == -4096 &&
|
||||
merged.state.motion_samples[0].accel_y == 0,
|
||||
"merged native controls and extras must combine with right-only aim motion");
|
||||
bluepad32_input_backend_report_sent(0);
|
||||
platform_on_controller_data(&left, &left_data);
|
||||
require(slot_snapshot(0).state.motion_sample_count == 0,
|
||||
"left reports must not replay the last right motion sample");
|
||||
|
||||
uni_hid_device_t ordinary = device(2);
|
||||
platform_on_device_connected(&ordinary);
|
||||
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS,
|
||||
"ordinary device must coexist with a pair");
|
||||
uni_controller_t ordinary_data{};
|
||||
ordinary_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
ordinary_data.gamepad.buttons = BUTTON_Y;
|
||||
platform_on_controller_data(&ordinary, &ordinary_data);
|
||||
const Bluepad32SlotSnapshot ordinary_before = slot_snapshot(2);
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{51, 61});
|
||||
bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left.last_low == 51 && right.last_low == 51 &&
|
||||
left.last_high == 61 && right.last_high == 61 &&
|
||||
ordinary.last_low == 71 && ordinary.last_high == 81,
|
||||
"pair feedback must fan out without reaching an ordinary player");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left.last_rumble_duration_ms == 0 &&
|
||||
right.last_rumble_duration_ms == 0 && ordinary.last_low == 71,
|
||||
"pair stop must stop both physical motors only");
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, merged.connection_generation, 2,
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left.player_leds == 3 && right.player_leds == 3 &&
|
||||
left.last_low == UINT8_MAX && right.last_low == UINT8_MAX,
|
||||
"profile feedback must light and rumble both halves");
|
||||
now_ms += 300;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left.player_leds == 1 && right.player_leds == 1,
|
||||
"profile completion must restore both halves' player indication");
|
||||
|
||||
uni_hid_device_t& lost = right_first ? left : right;
|
||||
uni_hid_device_t& survivor = right_first ? right : left;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101});
|
||||
platform_on_device_disconnected(&lost);
|
||||
const Bluepad32SlotSnapshot detached = slot_snapshot(0);
|
||||
require(detached.active && !slot_snapshot(1).active &&
|
||||
detached.connection_generation != merged.connection_generation &&
|
||||
controller_identity_equal(detached.identity, identity_for_device(&survivor)) &&
|
||||
detached.state.extra_buttons == survivor.switch2_extra_buttons &&
|
||||
detached.state.motion_sample_count == 0 &&
|
||||
!detached.state.dpad_up && !detached.state.button_east &&
|
||||
(right_first ? detached.state.button_south : detached.state.button_west),
|
||||
"either half detach must immediately publish only the rotated survivor and own profile");
|
||||
require(survivor.last_rumble_duration_ms == 0 &&
|
||||
slot_snapshot(2).connection_generation == ordinary_before.connection_generation &&
|
||||
slot_snapshot(2).state.button_north,
|
||||
"detach must cancel survivor feedback without changing unrelated player state");
|
||||
const int survivor_calls = survivor.rumble_calls;
|
||||
platform_on_controller_data(&lost, right_first ? &left_data : &right_data);
|
||||
require(platform_on_device_ready(&lost) == UNI_ERROR_NO_SLOTS,
|
||||
"late ready for a detached half must not resurrect its old generation");
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, merged.connection_generation, 8,
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(survivor.rumble_calls == survivor_calls &&
|
||||
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons,
|
||||
"late detached input and generation-bound feedback must be ignored");
|
||||
uni_hid_device_t replacement = switch2_device(lost.idx, lost.product_id);
|
||||
ready_switch2(replacement);
|
||||
platform_on_device_disconnected(&lost);
|
||||
require(slot_snapshot(0).active && !slot_snapshot(1).active &&
|
||||
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons &&
|
||||
slot_snapshot(0).connection_generation != detached.connection_generation,
|
||||
"replacement must re-pair without stale presses or a late old disconnect");
|
||||
const uint32_t clear_token = bluepad32_input_backend_clear_pairings();
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
Bluepad32PairingSnapshot cleared{};
|
||||
bluepad32_input_backend_pairing_snapshot(&cleared);
|
||||
require(bluepad32_input_backend_clear_pairings_completed(cleared, clear_token) &&
|
||||
device_disconnect_calls == 3 &&
|
||||
!slot_snapshot(0).active && !slot_snapshot(2).active &&
|
||||
!switch_pico_switch2_pairing_allowed(),
|
||||
"clear must disconnect every physical half and ordinary device, not just outputs");
|
||||
}
|
||||
|
||||
void test_switch2_multiple_pairs() {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID);
|
||||
uni_hid_device_t right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
uni_hid_device_t left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID);
|
||||
uni_hid_device_t left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID);
|
||||
ready_switch2(right0);
|
||||
ready_switch2(right1);
|
||||
ready_switch2(left0);
|
||||
ready_switch2(left1);
|
||||
require(slot_snapshot(0).active && slot_snapshot(1).active &&
|
||||
!slot_snapshot(2).active && !slot_snapshot(3).active &&
|
||||
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)) &&
|
||||
controller_identity_equal(slot_snapshot(1).identity, identity_for_device(&left1)) &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Paused &&
|
||||
!scanning_enabled && !incoming_connections,
|
||||
"two deterministic pairs must consume four physical resources but only two outputs");
|
||||
uni_controller_t data{};
|
||||
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
data.gamepad.buttons = BUTTON_A;
|
||||
right0.switch2_extra_buttons = UNI_SW2_BUTTON_C;
|
||||
platform_on_controller_data(&right0, &data);
|
||||
data.gamepad.buttons = BUTTON_Y;
|
||||
right1.switch2_extra_buttons = UNI_SW2_BUTTON_GR;
|
||||
platform_on_controller_data(&right1, &data);
|
||||
require(slot_snapshot(0).state.button_south && !slot_snapshot(0).state.button_north &&
|
||||
slot_snapshot(0).state.extra_buttons == UNI_SW2_BUTTON_C &&
|
||||
slot_snapshot(1).state.button_north && !slot_snapshot(1).state.button_south &&
|
||||
slot_snapshot(1).state.extra_buttons == UNI_SW2_BUTTON_GR,
|
||||
"two pairs must not cross-contaminate normal or extra source input");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{11, 21});
|
||||
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{31, 41});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left0.last_low == 11 && right0.last_low == 11 &&
|
||||
left1.last_low == 31 && right1.last_low == 31 &&
|
||||
left0.player_leds == 1 && right0.player_leds == 1 &&
|
||||
left1.player_leds == 2 && right1.player_leds == 2,
|
||||
"multiple pairs must retain isolated rumble and player lighting");
|
||||
const Bluepad32SlotSnapshot other_pair = slot_snapshot(1);
|
||||
platform_on_device_disconnected(&right0);
|
||||
uni_hid_device_t ordinary = device(0);
|
||||
platform_on_device_connected(&ordinary);
|
||||
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS &&
|
||||
slot_snapshot(0).active && slot_snapshot(2).active &&
|
||||
!slot_snapshot(3).active &&
|
||||
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)),
|
||||
"reusing freed physical index must allocate a free output, not evict the surviving half");
|
||||
data.gamepad.buttons = BUTTON_B;
|
||||
platform_on_controller_data(&ordinary, &data);
|
||||
bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{91, 101});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(slot_snapshot(2).state.button_east && !slot_snapshot(0).state.button_east &&
|
||||
ordinary.last_low == 91 && left1.last_low == 31 && right1.last_low == 31 &&
|
||||
slot_snapshot(1).connection_generation == other_pair.connection_generation &&
|
||||
slot_snapshot(1).state.button_north,
|
||||
"remapped ordinary physical index must isolate input and feedback from both pairs");
|
||||
}
|
||||
|
||||
void test_switch2_admission() {
|
||||
start_backend();
|
||||
require(!switch_pico_switch2_pairing_allowed(),
|
||||
"idle scanning must not grant fresh proprietary pairing");
|
||||
bluepad32_input_backend_open_pairing_window();
|
||||
require(!switch_pico_switch2_pairing_allowed(),
|
||||
"pending Core0 request must not grant pairing before policy consumes it");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch_pico_switch2_pairing_allowed(),
|
||||
"consumed explicit pairing window must permit proprietary pairing");
|
||||
now_ms = g_pairing_window_deadline_ms;
|
||||
require(!switch_pico_switch2_pairing_allowed(),
|
||||
"fresh pairing must close at its deadline even before timer processing");
|
||||
uni_hid_device_t pro = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
uni_hid_device_t ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BLE);
|
||||
register_lookup_device(&pro);
|
||||
register_lookup_device(&ordinary);
|
||||
__wrap_sm_request_pairing(pro.conn.handle);
|
||||
require(device_disconnect_calls == 1 && last_disconnected_device == &pro &&
|
||||
ordinary_smp_requests == 0 && delete_key_calls == 0,
|
||||
"Switch2 GATT auth failure must disconnect without SMP or bond deletion");
|
||||
__wrap_sm_request_pairing(ordinary.conn.handle);
|
||||
__wrap_sm_request_pairing(0x99);
|
||||
require(ordinary_smp_requests == 2 && device_disconnect_calls == 1,
|
||||
"ordinary and unknown handles must preserve standard SMP behavior");
|
||||
require(identity_for_device(&pro).stable &&
|
||||
controller_identity_is_global(identity_for_device(&ordinary)),
|
||||
"only parser-validated proprietary public identity may bypass SMP resolution");
|
||||
pro.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
|
||||
pro.conn.btaddr[0] = 0xc1;
|
||||
require(identity_for_device(&pro).stable &&
|
||||
identity_for_device(&pro).address_type == BD_ADDR_TYPE_LE_RANDOM,
|
||||
"parser-validated static random identity must retain its address type");
|
||||
pro.switch2_identity_valid = false;
|
||||
pro.conn.btaddr[0] = 0x41;
|
||||
require(controller_identity_is_global(identity_for_device(&pro)),
|
||||
"unvalidated proprietary RPA must remain on the global profile");
|
||||
pro.switch2_identity_valid = true;
|
||||
pro.conn.btaddr[0] = 0xc1;
|
||||
ready_switch2(pro);
|
||||
uni_controller_t input{};
|
||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
input.gamepad.buttons = BUTTON_B;
|
||||
input.gamepad.axis_x = 511;
|
||||
input.gamepad.axis_ry = -512;
|
||||
pro.switch2_extra_buttons =
|
||||
UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_GR;
|
||||
platform_on_controller_data(&pro, &input);
|
||||
require(slot_snapshot(0).state.button_east &&
|
||||
slot_snapshot(0).state.left_stick_x == INT16_MAX &&
|
||||
slot_snapshot(0).state.right_stick_y == INT16_MIN &&
|
||||
slot_snapshot(0).state.extra_buttons == 7,
|
||||
"Pro2 must preserve vertical normal controls and ingest remappable extras");
|
||||
}
|
||||
|
||||
void test_switch2_pairing_inventory() {
|
||||
start_pairing_backend();
|
||||
switch2_pairing_count = 2;
|
||||
switch2_pairing_types[0] = BD_ADDR_TYPE_LE_PUBLIC;
|
||||
switch2_pairing_types[1] = BD_ADDR_TYPE_LE_RANDOM;
|
||||
switch2_pairings[0][5] = 1;
|
||||
switch2_pairings[1][0] = 0xc1;
|
||||
switch2_pairings[1][5] = 2;
|
||||
bluepad32_input_backend_request_pairing_snapshot();
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
Bluepad32PairingSnapshot snapshot{};
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
require(snapshot.record_count == 2 && !snapshot.overflow &&
|
||||
snapshot.records[0].transport == Bluepad32PairingTransport::kBle &&
|
||||
snapshot.records[0].address_type == BD_ADDR_TYPE_LE_PUBLIC &&
|
||||
snapshot.records[0].address[5] == 1 &&
|
||||
snapshot.records[1].address_type == BD_ADDR_TYPE_LE_RANDOM &&
|
||||
snapshot.records[1].address[0] == 0xc1,
|
||||
"pairing inventory must include proprietary trust records with real BLE address types");
|
||||
switch2_pairing_count = UNI_SWITCH2_PAIRING_CAPACITY;
|
||||
classic_bond_count = 1;
|
||||
bluepad32_input_backend_request_pairing_snapshot();
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
require(snapshot.record_count == BLUEPAD32_PAIRING_RECORD_CAPACITY &&
|
||||
snapshot.overflow,
|
||||
"combined ordinary and proprietary inventory must preserve bounded overflow reporting");
|
||||
const uint32_t successful_token = bluepad32_input_backend_clear_pairings();
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
require(snapshot.record_count == 0 && !snapshot.overflow &&
|
||||
switch2_pairing_count == 0 &&
|
||||
bluepad32_input_backend_clear_pairings_completed(snapshot, successful_token),
|
||||
"clear completion must follow deletion of proprietary and ordinary pairing records");
|
||||
switch2_pairing_count = 1;
|
||||
switch2_clear_succeeds = false;
|
||||
const uint32_t failed_token = bluepad32_input_backend_clear_pairings();
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed &&
|
||||
snapshot.completed_clear_pairings_token == successful_token &&
|
||||
!bluepad32_input_backend_clear_pairings_completed(snapshot, failed_token) &&
|
||||
!incoming_connections && !scanning_enabled &&
|
||||
!switch_pico_switch2_pairing_allowed(),
|
||||
"failed trust deletion must never acknowledge clear or admit reconnects");
|
||||
bluepad32_input_backend_open_pairing_window();
|
||||
bluepad32_input_backend_request_pairing_snapshot();
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed &&
|
||||
snapshot.completed_clear_pairings_token == successful_token &&
|
||||
!switch_pico_switch2_pairing_allowed() &&
|
||||
g_connection_policy_state == ConnectionPolicyState::FailedClosed,
|
||||
"inventory refresh and pairing requests must not erase failed-clear state");
|
||||
switch2_clear_succeeds = true;
|
||||
const uint32_t retry_token = bluepad32_input_backend_clear_pairings();
|
||||
require(retry_token != failed_token, "explicit retry must receive a fresh clear token");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
require(snapshot.status == Bluepad32PairingSnapshotStatus::kReady &&
|
||||
snapshot.record_count == 0 &&
|
||||
bluepad32_input_backend_clear_pairings_completed(snapshot, retry_token) &&
|
||||
incoming_connections && scanning_enabled &&
|
||||
!switch_pico_switch2_pairing_allowed(),
|
||||
"successful explicit retry must restore normal reconnect policy, not fresh pairing");
|
||||
}
|
||||
|
||||
void test_ready_order(bool reverse) {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t devices[kSlotCount] = {
|
||||
|
|
@ -2837,7 +3262,17 @@ int main(int argc, char** argv) {
|
|||
return 0;
|
||||
}
|
||||
#endif
|
||||
if (scenario == "ready-forward") {
|
||||
if (scenario == "switch2-forward") {
|
||||
test_switch2_pair_lifecycle(false);
|
||||
} else if (scenario == "switch2-reverse") {
|
||||
test_switch2_pair_lifecycle(true);
|
||||
} else if (scenario == "switch2-multiple-pairs") {
|
||||
test_switch2_multiple_pairs();
|
||||
} else if (scenario == "switch2-admission") {
|
||||
test_switch2_admission();
|
||||
} else if (scenario == "switch2-pairing-inventory") {
|
||||
test_switch2_pairing_inventory();
|
||||
} else if (scenario == "ready-forward") {
|
||||
test_ready_order(false);
|
||||
} else if (scenario == "ready-reverse") {
|
||||
test_ready_order(true);
|
||||
|
|
|
|||
|
|
@ -163,6 +163,9 @@ struct uni_hid_device_s {
|
|||
int player_led_calls;
|
||||
uint8_t player_leds;
|
||||
uni_circular_buffer_t outgoing_buffer;
|
||||
uint8_t switch2_extra_buttons;
|
||||
bool switch2_identity_valid;
|
||||
uint8_t switch2_identity_address_type;
|
||||
};
|
||||
|
||||
void uni_hid_parser_xboxone_play_dual_rumble(
|
||||
|
|
|
|||
|
|
@ -61,3 +61,32 @@ constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_LEGACY_ENCODE
|
|||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
// Schema6 fixture with analog tuning, all macro playback modes, shortcuts,
|
||||
// Shift, and sparse Turbo overrides. Independent of the current encoder.
|
||||
constexpr uint8_t kLegacySchema6Profile[384] = {
|
||||
0x06, 0x00, 0x80, 0x01, 0x11, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
|
||||
0x0c, 0x0d, 0x0e, 0xff, 0x7b, 0x00, 0x38, 0xfe, 0xe8, 0x03, 0x30, 0x75, 0x80, 0x01, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xeb, 0xfc, 0x41, 0x01, 0xd0, 0x07, 0x18, 0x79, 0xc0, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x60, 0xea, 0x80, 0x01, 0x34, 0x12, 0x00, 0x00, 0xd0, 0x07,
|
||||
0xe8, 0xfd, 0xc0, 0x00, 0xcd, 0xab, 0x10, 0x00, 0x7b, 0xd2, 0x01, 0x21, 0x41, 0x00, 0x80, 0x00,
|
||||
0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03,
|
||||
0x01, 0x00, 0x41, 0x00, 0x01, 0x11, 0x02, 0x00, 0x46, 0x11, 0x01, 0x11, 0x04, 0x00, 0x24, 0x22,
|
||||
0x01, 0x11, 0x08, 0x00, 0x7c, 0x33, 0x01, 0x11, 0x1f, 0x19, 0x00, 0x01, 0x00, 0xfe, 0xff, 0x03,
|
||||
0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1a, 0x00, 0x02, 0x00, 0xfe, 0xff,
|
||||
0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1b, 0x00, 0x04, 0x00, 0xfe,
|
||||
0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1c, 0x00, 0x08, 0x00,
|
||||
0xfe, 0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x10, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, 0x11, 0xff, 0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x1e, 0x63, 0xff, 0x04, 0x80,
|
||||
0x01, 0x01, 0x01, 0x17, 0x25, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0xff, 0x01, 0xfe, 0x02, 0xfd, 0x03, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1160,6 +1160,61 @@ void test_shortcut_selector_rollover_without_modifier_release() {
|
|||
"swapping shortcut selectors while holding the modifier lost the new action");
|
||||
}
|
||||
|
||||
void test_extra_hotkeys_consume_mappings_and_rearm() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.switching_chord = (1u << 18) | (1u << 24);
|
||||
profile.motion_toggle_chord = (1u << 19) | (1u << 16);
|
||||
profile.shortcuts.modifier = 20;
|
||||
profile.shortcuts.selectors[7] = 0;
|
||||
profile.extra_button_map[0] = 3;
|
||||
profile.extra_button_map[1] = 1;
|
||||
profile.extra_button_map[2] = 2;
|
||||
profile.extra_button_map[6] = 17;
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
auto output = runtime_transform(0, snapshot, 1);
|
||||
require(activation_attempt_count == 0 && output.state.button_north,
|
||||
"partial extra switching chord was swallowed or activated");
|
||||
snapshot.state.extra_buttons = 0x41;
|
||||
output = runtime_transform(0, snapshot, 2);
|
||||
require(activation_attempt_count == 1 &&
|
||||
activation_attempts[0].profile_index == 1 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.right_trigger == 0 && output.state.extra_buttons == 0,
|
||||
"extra switching chord did not select the next profile and consume mappings");
|
||||
(void)runtime_transform(0, snapshot, 3);
|
||||
require(activation_attempt_count == 1, "held extra switching chord retriggered");
|
||||
snapshot.state = controller_neutral_state();
|
||||
(void)runtime_transform(0, snapshot, 4);
|
||||
snapshot.state.extra_buttons = 2;
|
||||
snapshot.state.left_trigger = UINT16_MAX;
|
||||
output = runtime_transform(0, snapshot, 5);
|
||||
require(motion_toggle_count == 1 && last_motion_toggle_slot == 0 &&
|
||||
last_motion_toggle_connection_generation == 9 &&
|
||||
!output.state.button_east && output.state.left_trigger == 0,
|
||||
"extra/trigger motion chord lost its logical slot or leaked mapped inputs");
|
||||
(void)runtime_transform(0, snapshot, 6);
|
||||
require(motion_toggle_count == 1, "held extra motion chord retriggered");
|
||||
snapshot.state = controller_neutral_state();
|
||||
(void)runtime_transform(0, snapshot, 7);
|
||||
apply_button_mask(1, &snapshot);
|
||||
snapshot.state.extra_buttons = 4;
|
||||
output = runtime_transform(0, snapshot, 8);
|
||||
require(activation_attempt_count == 2 &&
|
||||
activation_attempts[1].profile_index == 7 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"extra shortcut modifier failed to select profile eight or leaked inputs");
|
||||
apply_button_mask(0, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 9);
|
||||
snapshot.state.extra_buttons = 0x41;
|
||||
(void)runtime_transform(0, snapshot, 10);
|
||||
require(activation_attempt_count == 3 &&
|
||||
activation_attempts[2].profile_index == 1,
|
||||
"released extra switching chord did not rearm");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool bluepad32_input_backend_toggle_motion(
|
||||
|
|
@ -1232,5 +1287,6 @@ int main() {
|
|||
test_shift_slot_and_context_resets();
|
||||
test_held_synthetic_sources_and_disconnect_rearming();
|
||||
test_shortcut_selector_rollover_without_modifier_release();
|
||||
test_extra_hotkeys_consume_mappings_and_rearm();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ void test_profile_wire_schema() {
|
|||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"default profile did not encode");
|
||||
require(encoded[0] == 6 && encoded[1] == 0 &&
|
||||
require(encoded[0] == 7 && encoded[1] == 0 &&
|
||||
encoded[2] == 0x80 && encoded[3] == 1,
|
||||
"profile header is not little-endian v6/384");
|
||||
"profile header is not little-endian v7/384");
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
require(encoded[4 + index] == index,
|
||||
|
|
@ -135,7 +135,7 @@ void test_profile_wire_schema() {
|
|||
"nonzero reserved profile byte was accepted");
|
||||
|
||||
ControllerProfile invalid = profile;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"invalid direct output was accepted");
|
||||
invalid = profile;
|
||||
|
|
@ -225,7 +225,7 @@ void test_legacy_profile_migration() {
|
|||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
|
||||
encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL,
|
||||
"migrated default profile did not encode as v6");
|
||||
"migrated default profile did not encode as the current schema");
|
||||
|
||||
require(controller_profile_decode(
|
||||
kLegacyNarrowRawRangeProfile,
|
||||
|
|
@ -300,7 +300,7 @@ void test_legacy_profile_migration() {
|
|||
static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kCapture) &&
|
||||
migrated.motion_toggle_chord == 0,
|
||||
"v2 profile controls did not migrate to v6");
|
||||
"v2 profile controls did not migrate to the current schema");
|
||||
previous_encoded[80] = 2;
|
||||
previous_encoded[100] = 0;
|
||||
previous_encoded[101] = kControllerProfileOverrideButtons;
|
||||
|
|
@ -508,6 +508,101 @@ void test_legacy_database_strides() {
|
|||
}
|
||||
}
|
||||
|
||||
void test_schema6_migration_preserves_every_setting() {
|
||||
ControllerProfile profile{};
|
||||
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_decode(kLegacySchema6Profile,
|
||||
sizeof(kLegacySchema6Profile), &profile) &&
|
||||
controller_profile_encode(profile, upgraded, sizeof(upgraded)),
|
||||
"schema6 profile could not upgrade");
|
||||
require(memcmp(upgraded + 2, kLegacySchema6Profile + 2, 342) == 0,
|
||||
"schema6 migration changed an existing encoded setting");
|
||||
for (uint8_t index = 0; index < CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT; ++index) {
|
||||
require(profile.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.shift.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"schema6 padding became an extra-button mapping");
|
||||
}
|
||||
ControllerProfile reloaded{};
|
||||
uint8_t round_trip[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_decode(upgraded, sizeof(upgraded), &reloaded) &&
|
||||
controller_profile_encode(reloaded, round_trip, sizeof(round_trip)) &&
|
||||
memcmp(upgraded, round_trip, sizeof(upgraded)) == 0,
|
||||
"upgraded schema6 profile was not stable after reload");
|
||||
|
||||
const uint16_t extra_field_offsets[] = {344, 351, 358, 362, 363};
|
||||
for (uint16_t offset : extra_field_offsets) {
|
||||
uint8_t invalid[sizeof(kLegacySchema6Profile)]{};
|
||||
memcpy(invalid, kLegacySchema6Profile, sizeof(invalid));
|
||||
invalid[offset] = 1;
|
||||
require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded),
|
||||
"schema6 interpreted an extra-control field in reserved padding");
|
||||
}
|
||||
const uint16_t source_offsets[] = {256, 266};
|
||||
for (uint16_t offset : source_offsets) {
|
||||
uint8_t invalid[sizeof(kLegacySchema6Profile)]{};
|
||||
memcpy(invalid, kLegacySchema6Profile, sizeof(invalid));
|
||||
invalid[offset] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
|
||||
require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded),
|
||||
"schema6 admitted an extra-control modifier");
|
||||
}
|
||||
uint8_t invalid[sizeof(kLegacySchema6Profile)]{};
|
||||
memcpy(invalid, kLegacySchema6Profile, sizeof(invalid));
|
||||
invalid[98] = (CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL << 2) | 1;
|
||||
require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded),
|
||||
"schema6 admitted an extra-control macro cancellation");
|
||||
}
|
||||
|
||||
void test_extra_control_schema_round_trip_and_output_limits() {
|
||||
ControllerProfile profile{};
|
||||
require(controller_profile_decode(kLegacySchema6Profile,
|
||||
sizeof(kLegacySchema6Profile), &profile),
|
||||
"extra-control fixture did not decode");
|
||||
const uint8_t extra_map[] = {0, 16, 17, 12, 13, 14, 15};
|
||||
const uint8_t shifted_map[] = {15, 14, 13, 12, 3, 2, 0xff};
|
||||
memcpy(profile.extra_button_map, extra_map, sizeof(extra_map));
|
||||
memcpy(profile.shift.extra_button_map, shifted_map, sizeof(shifted_map));
|
||||
profile.shortcuts.modifier = 18;
|
||||
profile.shift.modifier = 24;
|
||||
profile.switching_chord |= 0x55u << 18;
|
||||
profile.motion_toggle_chord |= 0x2au << 18;
|
||||
for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
|
||||
profile.macros[index].trigger_mask |= (1u << index) << 18;
|
||||
profile.macros[index].cancel_control = 24 - index;
|
||||
}
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
ControllerProfile decoded{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"extra-control profile did not round-trip");
|
||||
require(memcmp(encoded + 344, extra_map, sizeof(extra_map)) == 0 &&
|
||||
memcmp(encoded + 351, shifted_map, sizeof(shifted_map)) == 0 &&
|
||||
encoded[358] == 1 && encoded[359] == 2 &&
|
||||
encoded[360] == 4 && encoded[361] == 8 &&
|
||||
encoded[362] == 0x55 && encoded[363] == 0x2a,
|
||||
"extra-control fields do not use the schema7 extension layout");
|
||||
uint8_t round_trip[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(decoded, round_trip, sizeof(round_trip)) &&
|
||||
memcmp(encoded, round_trip, sizeof(encoded)) == 0,
|
||||
"extra controls lost masks, mappings, or modifiers on reload");
|
||||
for (uint16_t offset = 358; offset < 364; ++offset) {
|
||||
encoded[offset] |= 0x80;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"out-of-range extra-control mask was accepted");
|
||||
encoded[offset] &= 0x7f;
|
||||
}
|
||||
profile.extra_button_map[0] = 18;
|
||||
require(!controller_profile_validate(profile),
|
||||
"extra input was accepted as a console output destination");
|
||||
profile.extra_button_map[0] = 0;
|
||||
profile.triggers[0].output = 18;
|
||||
require(!controller_profile_validate(profile),
|
||||
"analog trigger was allowed to route into a source-only control");
|
||||
profile.triggers[0].output = 0;
|
||||
profile.shift.extra_button_map[0] = 16;
|
||||
require(!controller_profile_validate(profile),
|
||||
"Shift extra mapping admitted an analog destination");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
int main() {
|
||||
test_profile_wire_schema();
|
||||
|
|
@ -515,5 +610,7 @@ int main() {
|
|||
test_database_round_trip_and_capacity();
|
||||
test_set_b_sparse_extension_and_migration();
|
||||
test_legacy_database_strides();
|
||||
test_schema6_migration_preserves_every_setting();
|
||||
test_extra_control_schema_round_trip_and_output_limits();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ bool states_equal(const ControllerState& left, const ControllerState& right) {
|
|||
left.button_capture == right.button_capture &&
|
||||
left.button_left_stick == right.button_left_stick &&
|
||||
left.button_right_stick == right.button_right_stick &&
|
||||
left.extra_buttons == right.extra_buttons &&
|
||||
left.left_trigger == right.left_trigger &&
|
||||
left.right_trigger == right.right_trigger &&
|
||||
left.left_stick_x == right.left_stick_x &&
|
||||
|
|
@ -109,7 +110,7 @@ void test_button_masks_and_direct_mapping() {
|
|||
"disabled button mapping still produced output");
|
||||
|
||||
ControllerProfile invalid = default_profile();
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"logical output 18 was accepted");
|
||||
invalid.button_map[0] = 0xfe;
|
||||
|
|
@ -432,6 +433,48 @@ void test_rumble_scaling_and_confirmation_policy() {
|
|||
"confirmation policy was not exposed unchanged");
|
||||
}
|
||||
|
||||
void test_extra_sources_route_without_creating_output_channels() {
|
||||
ControllerProfile profile = default_profile();
|
||||
ControllerState input{};
|
||||
input.extra_buttons = 0x7f;
|
||||
require(states_equal(
|
||||
controller_profile_transform(input, profile).state,
|
||||
controller_neutral_state()),
|
||||
"unmapped extra inputs leaked into console output");
|
||||
require(controller_profile_extract_control_mask(input, profile) ==
|
||||
(0x7fu << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL),
|
||||
"extra inputs were omitted or overlapped the trigger controls");
|
||||
profile.extra_button_map[0] = 0;
|
||||
profile.extra_button_map[1] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.extra_button_map[2] = CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
|
||||
profile.extra_button_map[3] = 12;
|
||||
profile.extra_button_map[4] = 13;
|
||||
profile.extra_button_map[5] = 14;
|
||||
profile.extra_button_map[6] = 15;
|
||||
input.button_south = true;
|
||||
input.left_trigger = 12345;
|
||||
auto output = controller_profile_transform(input, profile);
|
||||
require(controller_profile_extract_button_mask(output.state) == 0xf001 &&
|
||||
output.state.left_trigger == UINT16_MAX &&
|
||||
output.state.right_trigger == UINT16_MAX &&
|
||||
output.state.extra_buttons == 0,
|
||||
"extra mappings lost rail buttons, trigger output, or shared contributors");
|
||||
controller_profile_remove_control_mask(
|
||||
(1u << 18) | (1u << 20) | (1u << 24) | (1u << 16), &input);
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(controller_profile_extract_button_mask(output.state) == 0x7001 &&
|
||||
output.state.left_trigger == UINT16_MAX &&
|
||||
output.state.right_trigger == 0 &&
|
||||
input.extra_buttons == 0x3a && input.left_trigger == 0,
|
||||
"consumed extra sources leaked, removed a shared button, or lost another trigger source");
|
||||
input.extra_buttons = 0x80;
|
||||
input.button_south = false;
|
||||
require(controller_profile_extract_control_mask(input, profile) == 0 &&
|
||||
states_equal(controller_profile_transform(input, profile).state,
|
||||
controller_neutral_state()),
|
||||
"reserved extra bit became a control or output");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -440,6 +483,7 @@ int main() {
|
|||
test_stick_curves_and_monotonicity();
|
||||
test_trigger_boundaries_curves_and_thresholds();
|
||||
test_trigger_and_button_cross_mapping();
|
||||
test_extra_sources_route_without_creating_output_channels();
|
||||
test_default_whole_state_equivalence();
|
||||
test_rumble_scaling_and_confirmation_policy();
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -738,6 +738,81 @@ void test_macro_playback_modes_and_bounded_cycle_skips() {
|
|||
"configured cancel did not kill looping macro output");
|
||||
}
|
||||
|
||||
void test_extra_shift_and_macro_sources_are_consumed() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.shift.mode = ControllerProfileShiftMode::kHold;
|
||||
profile.shift.modifier = 18;
|
||||
profile.extra_button_map[0] = 3;
|
||||
profile.shift.extra_button_map[0] = 3;
|
||||
profile.extra_button_map[1] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.shift.extra_button_map[1] = 1;
|
||||
ControllerSyntheticInputContext context{};
|
||||
ControllerState input{};
|
||||
input.extra_buttons = 3;
|
||||
auto output = controller_synthetic_input_apply(&context, input, profile, 0);
|
||||
require(output.state.button_east && !output.state.button_north &&
|
||||
output.state.left_trigger == 0 && output.state.extra_buttons == 0,
|
||||
"extra Shift modifier leaked or failed to select the extra mapping layer");
|
||||
input.extra_buttons = 2;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 1);
|
||||
require(!output.state.button_east && output.state.left_trigger == UINT16_MAX,
|
||||
"extra hold-Shift release did not restore the base mapping");
|
||||
profile.shift.mode = ControllerProfileShiftMode::kToggle;
|
||||
context = {};
|
||||
input.extra_buttons = 3;
|
||||
(void)controller_synthetic_input_apply(&context, input, profile, 2);
|
||||
input.extra_buttons = 2;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 3);
|
||||
require(output.state.button_east && output.state.left_trigger == 0,
|
||||
"extra toggle-Shift did not latch after modifier release");
|
||||
input.extra_buttons = 3;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 4);
|
||||
require(!output.state.button_east && !output.state.button_north &&
|
||||
output.state.left_trigger == UINT16_MAX,
|
||||
"inactive toggle layer leaked its extra modifier");
|
||||
|
||||
profile.shift.mode = ControllerProfileShiftMode::kOff;
|
||||
profile.macros[0] = {(1u << 19) | (1u << 24), 20, 0, 1,
|
||||
ControllerProfileMacroMode::kWhileHeld, 1};
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0] = {kControllerProfileOverrideRightTrigger, 100,
|
||||
0, 0, 0, 0, 0, 0, 45000};
|
||||
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
|
||||
profile.macros[index].first_step = 1;
|
||||
}
|
||||
profile.extra_button_map[2] = 3;
|
||||
profile.extra_button_map[6] = 0;
|
||||
context = {};
|
||||
input.extra_buttons = 2;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 5);
|
||||
require(output.state.left_trigger == UINT16_MAX && output.state.right_trigger == 0,
|
||||
"partial extra macro chord activated");
|
||||
input.extra_buttons = 0x42;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 6);
|
||||
require(output.state.left_trigger == 0 && output.state.right_trigger == 45000 &&
|
||||
!output.state.button_south && output.state.extra_buttons == 0,
|
||||
"extra macro trigger chord leaked its mapped outputs");
|
||||
input.extra_buttons = 0x46;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 7);
|
||||
require(output.state.right_trigger == 0 && !output.state.button_north,
|
||||
"extra macro cancellation did not win or leaked its mapped output");
|
||||
input.extra_buttons = 0x42;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 8);
|
||||
require(output.state.right_trigger == 0,
|
||||
"cancel release retriggered an already-held extra macro chord");
|
||||
input.extra_buttons = 0;
|
||||
(void)controller_synthetic_input_apply(&context, input, profile, 9);
|
||||
input.extra_buttons = 0x42;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 10);
|
||||
require(output.state.right_trigger == 45000,
|
||||
"extra macro trigger did not rearm after release");
|
||||
input.extra_buttons = 2;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 11);
|
||||
require(output.state.right_trigger == 0,
|
||||
"while-held macro survived release of an extra trigger source");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -753,5 +828,6 @@ int main() {
|
|||
test_parameterized_turbo_and_finite_burst();
|
||||
test_shift_maps_consumption_and_physical_bindings();
|
||||
test_macro_playback_modes_and_bounded_cycle_skips();
|
||||
test_extra_shift_and_macro_sources_are_consumed();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,3 +63,53 @@ assert.ok(triggerPercent(65000) > 99,
|
|||
"nonzero trigger input must produce a visible bar");
|
||||
assert.equal(triggerPercent(0), 0);
|
||||
assert.equal(triggerPercent(65535), 100);
|
||||
|
||||
const { transformMappings } = globalThis.ProfilePlaytestMath;
|
||||
const buttons = [
|
||||
"south", "east", "west", "north", "left_shoulder", "right_shoulder",
|
||||
"select", "start", "system", "capture", "left_stick", "right_stick",
|
||||
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
|
||||
];
|
||||
const extras = ["c", "gl", "gr", "left_sl", "left_sr", "right_sl", "right_sr"];
|
||||
const profile = {
|
||||
button_map: Object.fromEntries(buttons.map((button) => [button, button])),
|
||||
extra_button_map: Object.fromEntries(extras.map((button) => [button, null])),
|
||||
shift: {
|
||||
mode: "off", modifier: null,
|
||||
button_map: Object.fromEntries(buttons.map((button) => [button, button])),
|
||||
extra_button_map: Object.fromEntries(extras.map((button) => [button, null])),
|
||||
},
|
||||
triggers: {
|
||||
left: { ...defaultTrigger, output: "left_trigger", digital_threshold: 22934 },
|
||||
right: { ...defaultTrigger, output: "right_trigger", digital_threshold: 22934 },
|
||||
},
|
||||
};
|
||||
const sample = { buttons: ["south"], extra_buttons: extras, triggers: { left: 123, right: 50000 } };
|
||||
assert.deepEqual(transformMappings(sample, profile), {
|
||||
buttons: ["south"], triggers: { left: 123, right: 50000 },
|
||||
});
|
||||
for (const [index, extra] of extras.entries()) {
|
||||
profile.extra_button_map[extra] = buttons[index + 1];
|
||||
}
|
||||
assert.deepEqual(transformMappings(sample, profile).buttons, buttons.slice(0, 8));
|
||||
profile.extra_button_map.c = "left_trigger";
|
||||
profile.extra_button_map.gl = "south";
|
||||
assert.deepEqual(transformMappings(sample, profile), {
|
||||
buttons: ["south", ...buttons.slice(3, 8)],
|
||||
triggers: { left: 65535, right: 50000 },
|
||||
});
|
||||
profile.shift.mode = "hold";
|
||||
profile.shift.modifier = "c";
|
||||
profile.shift.extra_button_map.gr = "system";
|
||||
assert.deepEqual(transformMappings(sample, profile, true), {
|
||||
buttons: ["south", "system"], triggers: { left: 123, right: 50000 },
|
||||
});
|
||||
assert.equal(transformMappings(sample, profile, false).triggers.left, 123,
|
||||
"the Shift modifier is consumed even on the base layer");
|
||||
assert.deepEqual(transformMappings({ ...sample, extra_buttons: [] }, profile), {
|
||||
buttons: ["south"], triggers: { left: 123, right: 50000 },
|
||||
});
|
||||
profile.shift.mode = "off";
|
||||
profile.triggers.right.output = "north";
|
||||
assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22933 } }, profile).buttons, []);
|
||||
assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22934 } }, profile).buttons, ["north"]);
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ void test_profile_bounds_and_transaction_namespace() {
|
|||
"transaction namespaces were not enforced");
|
||||
}
|
||||
|
||||
void test_schema6_validation_and_atomic_selection() {
|
||||
void test_schema7_validation_and_atomic_selection() {
|
||||
const ControllerIdentity id = stable_identity();
|
||||
require(profile_service_select(id, 6) ==
|
||||
ConfigurationTransactionStatus::kCommitted,
|
||||
|
|
@ -285,10 +285,13 @@ void test_schema6_validation_and_atomic_selection() {
|
|||
ProfileServiceSelectedSnapshot old_selection{};
|
||||
profile_service_selected_snapshot(&old_selection);
|
||||
ControllerProfile updated = old_selection.profile;
|
||||
updated.shortcuts.modifier = 5;
|
||||
updated.shortcuts.modifier = 18;
|
||||
updated.shortcuts.selectors[6] = 14;
|
||||
updated.turbo_defaults = {30, 1, 255};
|
||||
updated.macros[0].trigger_mask = 1u << 10;
|
||||
updated.extra_button_map[6] = 16;
|
||||
updated.shift.extra_button_map[0] = 3;
|
||||
updated.macros[0].trigger_mask = 1u << 24;
|
||||
updated.macros[0].cancel_control = 19;
|
||||
updated.macros[0].step_count = 1;
|
||||
updated.macros[0].mode = ControllerProfileMacroMode::kRepeat;
|
||||
updated.macros[0].repeat_count = 255;
|
||||
|
|
@ -299,25 +302,25 @@ void test_schema6_validation_and_atomic_selection() {
|
|||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
|
||||
"extended service profile did not encode");
|
||||
require(profile_service_begin(20, id, 6, 5, 256, 0) ==
|
||||
require(profile_service_begin(20, id, 6, 6, 384, 0) ==
|
||||
ConfigurationTransactionStatus::kUnsupportedSchema &&
|
||||
profile_service_begin(21, id, 6, 6, 256, 0) ==
|
||||
profile_service_begin(21, id, 6, 7, 256, 0) ==
|
||||
ConfigurationTransactionStatus::kMalformed &&
|
||||
profile_service_begin(22, id, 6, 6, 385, 0) ==
|
||||
profile_service_begin(22, id, 6, 7, 385, 0) ==
|
||||
ConfigurationTransactionStatus::kTooLarge,
|
||||
"service admitted old-schema or incorrectly-sized writes");
|
||||
|
||||
// A valid transport CRC cannot authorize an invalid extension.
|
||||
encoded[283] = 0;
|
||||
require(profile_service_begin(
|
||||
23, id, 6, 6, sizeof(encoded),
|
||||
23, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
|
||||
profile_storage_crc32(encoded, sizeof(encoded))) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_append(23, 0, encoded, sizeof(encoded)) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_commit(23) ==
|
||||
ConfigurationTransactionStatus::kMalformed,
|
||||
"service admitted invalid schema6 turbo settings");
|
||||
"service admitted invalid turbo settings");
|
||||
ProfileServiceSelectedSnapshot selected{};
|
||||
profile_service_selected_snapshot(&selected);
|
||||
require(selected.valid &&
|
||||
|
|
@ -329,7 +332,7 @@ void test_schema6_validation_and_atomic_selection() {
|
|||
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
|
||||
"valid replacement did not encode");
|
||||
require(profile_service_begin(
|
||||
24, id, 6, 6, sizeof(encoded),
|
||||
24, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
|
||||
profile_storage_crc32(encoded, sizeof(encoded))) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_append(24, 0, encoded, 256) ==
|
||||
|
|
@ -338,7 +341,7 @@ void test_schema6_validation_and_atomic_selection() {
|
|||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_commit(24) ==
|
||||
ConfigurationTransactionStatus::kPending,
|
||||
"service did not receive both parts of the schema6 payload");
|
||||
"service did not receive both parts of the profile payload");
|
||||
profile_service_selected_snapshot(&selected);
|
||||
require(selected.metadata.generation == old_selection.metadata.generation &&
|
||||
active_snapshot(id).profile.turbo_defaults.rate_hz ==
|
||||
|
|
@ -350,8 +353,12 @@ void test_schema6_validation_and_atomic_selection() {
|
|||
ConfigurationTransactionStatus::kCommitted &&
|
||||
selected.valid && selected.profile.shortcuts.selectors[6] == 14 &&
|
||||
selected.profile.macros[0].repeat_count == 255 &&
|
||||
selected.profile.extra_button_map[6] == 16 &&
|
||||
selected.profile.shift.extra_button_map[0] == 3 &&
|
||||
active_snapshot(id).profile.macros[0].trigger_mask == (1u << 24) &&
|
||||
active_snapshot(id).profile.macros[0].cancel_control == 19 &&
|
||||
active_snapshot(id).profile.turbo_defaults.rate_hz == 30,
|
||||
"committed schema6 profile did not atomically refresh snapshots");
|
||||
"committed schema7 profile did not atomically refresh snapshots");
|
||||
}
|
||||
|
||||
void test_catalog1_selected_and_active_snapshots_migrate() {
|
||||
|
|
@ -426,7 +433,7 @@ ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
|
|||
int main() {
|
||||
test_eight_profile_transactions_and_active_cache();
|
||||
test_profile_bounds_and_transaction_namespace();
|
||||
test_schema6_validation_and_atomic_selection();
|
||||
test_schema7_validation_and_atomic_selection();
|
||||
test_catalog1_selected_and_active_snapshots_migrate();
|
||||
std::cout << "profile service tests passed\n";
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -259,7 +259,9 @@ void install_catalog_record(uint16_t version, size_t offset, uint8_t type,
|
|||
record[7] = slot;
|
||||
write_u32(record + 8, generation);
|
||||
write_u16(record + 12, static_cast<uint16_t>(size));
|
||||
write_u16(record + 14, type == 1 ? (version == 1 ? 5 : 6) : 0);
|
||||
write_u16(record + 14, type == 1
|
||||
? static_cast<uint16_t>(payload[0] | (payload[1] << 8))
|
||||
: 0);
|
||||
write_u32(record + 16, profile_storage_crc32(payload, size));
|
||||
require(controller_identity_encode(identity_value, record + 20,
|
||||
CONTROLLER_IDENTITY_ENCODED_SIZE),
|
||||
|
|
@ -284,6 +286,9 @@ void install_populated_catalog(uint16_t version, bool fill_arena = false) {
|
|||
} else {
|
||||
require(controller_profile_encode(profile, payload, sizeof(payload)),
|
||||
"catalog2 fixture profile did not encode");
|
||||
// Keep catalog2 fixtures on the deployed schema6 wire format.
|
||||
write_u16(payload, CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION);
|
||||
memset(payload + 344, 0, sizeof(payload) - 344);
|
||||
}
|
||||
install_catalog_record(
|
||||
version, offset, 1, id, slot, ++generation, payload,
|
||||
|
|
@ -494,6 +499,8 @@ void test_compaction_preserves_latest_records() {
|
|||
storage.set_profile_name(global, 0, "Compacted", 9) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"compaction metadata did not append");
|
||||
profile.extra_button_map[0] = 16;
|
||||
profile.shift.extra_button_map[6] = 15;
|
||||
for (uint16_t write = 1; write <= 260; ++write) {
|
||||
profile.weak_rumble_scale = static_cast<uint8_t>(write);
|
||||
require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
|
||||
|
|
@ -508,6 +515,8 @@ void test_compaction_preserves_latest_records() {
|
|||
reloaded.get(global, 0, &recovered) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
recovered.weak_rumble_scale == static_cast<uint8_t>(260) &&
|
||||
recovered.extra_button_map[0] == 16 &&
|
||||
recovered.shift.extra_button_map[6] == 15 &&
|
||||
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "Fallback") == 0 &&
|
||||
|
|
@ -727,6 +736,49 @@ void test_legacy_high_generation_remains_mutable() {
|
|||
"high-generation update was lost after reload");
|
||||
}
|
||||
|
||||
void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
|
||||
erase_all();
|
||||
install_populated_catalog(2);
|
||||
ProfileStorage storage;
|
||||
require(storage.initialize(fake_io()), "schema6 catalog did not load");
|
||||
require_populated_catalog(storage, true);
|
||||
require(flash.programs == 0 && flash.erases == 0,
|
||||
"reading schema6 profiles rewrote the published flash arena");
|
||||
const auto id = catalog_identity(7);
|
||||
ControllerProfile profile{};
|
||||
uint8_t before[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(storage.get(id, 7, &profile) == ProfileStorageResult::kOk &&
|
||||
controller_profile_encode(profile, before, sizeof(before)) &&
|
||||
storage.set(id, 7, profile) == ProfileStorageResult::kUnchanged &&
|
||||
flash.programs == 0 && flash.erases == 0,
|
||||
"unchanged migrated profile caused an unnecessary flash write");
|
||||
profile.extra_button_map[0] = 16;
|
||||
profile.extra_button_map[6] = 15;
|
||||
profile.shift.extra_button_map[1] = 2;
|
||||
require(storage.set(id, 7, profile) == ProfileStorageResult::kOk,
|
||||
"schema6 profile could not add extra-control mappings");
|
||||
ProfileStorage reloaded;
|
||||
ControllerProfile recovered{};
|
||||
uint8_t after[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(reloaded.initialize(fake_io()) &&
|
||||
reloaded.get(id, 7, &recovered) == ProfileStorageResult::kOk &&
|
||||
controller_profile_encode(recovered, after, sizeof(after)) &&
|
||||
memcmp(before + 2, after + 2, 342) == 0 &&
|
||||
recovered.extra_button_map[0] == 16 &&
|
||||
recovered.extra_button_map[6] == 15 &&
|
||||
recovered.shift.extra_button_map[1] == 2,
|
||||
"editing migrated extras lost existing settings or the new mappings");
|
||||
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(reloaded.find(id)->active_profile == 7 &&
|
||||
reloaded.get_alias(id, metadata, sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "H") == 0 &&
|
||||
reloaded.get_profile_name(id, 7, metadata, sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "H7") == 0,
|
||||
"editing a schema6 profile lost its active index, alias, or name");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -742,6 +794,7 @@ int main() {
|
|||
test_retired_bank_migration_power_loss();
|
||||
test_late_second_page_program_is_not_reused();
|
||||
test_unreadable_legacy_data_is_not_erased();
|
||||
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
|
||||
std::cout << "profile storage tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
8
tests/switch2_pairing_native_stubs/btstack_tlv.h
Normal file
8
tests/switch2_pairing_native_stubs/btstack_tlv.h
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
#include <stdint.h>
|
||||
typedef struct {
|
||||
int (*get_tag)(void*, uint32_t, uint8_t*, uint32_t);
|
||||
int (*store_tag)(void*, uint32_t, const uint8_t*, uint32_t);
|
||||
void (*delete_tag)(void*, uint32_t);
|
||||
} btstack_tlv_t;
|
||||
void btstack_tlv_get_instance(const btstack_tlv_t**, void**);
|
||||
90
tests/switch2_pairing_test.c
Normal file
90
tests/switch2_pairing_test.c
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <btstack_tlv.h>
|
||||
#include "parser/uni_switch2_pairing.h"
|
||||
|
||||
static struct {
|
||||
uint32_t tag;
|
||||
uint8_t data[256];
|
||||
uint32_t size;
|
||||
bool fail_write;
|
||||
} storage;
|
||||
|
||||
static int get_tag(void* context, uint32_t tag, uint8_t* data, uint32_t capacity) {
|
||||
(void)context;
|
||||
if (tag != storage.tag) return 0;
|
||||
uint32_t copied = storage.size < capacity ? storage.size : capacity;
|
||||
memcpy(data, storage.data, copied);
|
||||
return (int)storage.size;
|
||||
}
|
||||
static int store_tag(void* context, uint32_t tag, const uint8_t* data, uint32_t size) {
|
||||
(void)context;
|
||||
if (storage.fail_write) return -1;
|
||||
assert(size <= sizeof(storage.data));
|
||||
storage.tag = tag;
|
||||
memcpy(storage.data, data, size);
|
||||
storage.size = size;
|
||||
return 0;
|
||||
}
|
||||
static const btstack_tlv_t tlv = {get_tag, store_tag, NULL};
|
||||
void btstack_tlv_get_instance(const btstack_tlv_t** implementation, void** context) {
|
||||
*implementation = &tlv;
|
||||
*context = &storage;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
uint8_t public_address[6] = {0x10, 2, 3, 4, 5, 6};
|
||||
uint8_t static_address[6] = {0xc1, 2, 3, 4, 5, 6};
|
||||
uint8_t private_address[6] = {0x41, 2, 3, 4, 5, 6};
|
||||
assert(!uni_switch2_pairing_known(0, public_address));
|
||||
assert(!uni_switch2_pairing_remember(1, private_address));
|
||||
assert(!uni_switch2_pairing_remember(2, public_address));
|
||||
assert(uni_switch2_pairing_remember(1, static_address));
|
||||
assert(uni_switch2_pairing_remember(0, public_address));
|
||||
assert(uni_switch2_pairing_known(0, public_address));
|
||||
assert(!uni_switch2_pairing_known(1, public_address));
|
||||
assert(uni_switch2_pairing_known(1, static_address));
|
||||
|
||||
// Duplicate authorization remains valid without requiring another flash write.
|
||||
storage.fail_write = true;
|
||||
assert(uni_switch2_pairing_remember(0, public_address));
|
||||
assert(!uni_switch2_pairing_clear());
|
||||
assert(uni_switch2_pairing_known(0, public_address));
|
||||
public_address[5]++;
|
||||
assert(!uni_switch2_pairing_remember(0, public_address));
|
||||
assert(!uni_switch2_pairing_known(0, public_address));
|
||||
public_address[5]--;
|
||||
storage.fail_write = false;
|
||||
|
||||
uint8_t type, observed[6];
|
||||
assert(uni_switch2_pairing_get(0, &type, observed));
|
||||
assert(type == 0 && memcmp(observed, public_address, 6) == 0);
|
||||
assert(uni_switch2_pairing_get(1, &type, observed));
|
||||
assert(type == 1 && memcmp(observed, static_address, 6) == 0);
|
||||
assert(!uni_switch2_pairing_get(2, &type, observed));
|
||||
assert(uni_switch2_pairing_clear());
|
||||
assert(!uni_switch2_pairing_known(0, public_address));
|
||||
assert(!uni_switch2_pairing_known(1, static_address));
|
||||
|
||||
// Filling the bounded store never silently forgets an earlier controller.
|
||||
for (uint8_t i = 0; i < UNI_SWITCH2_PAIRING_CAPACITY; ++i) {
|
||||
public_address[5] = i;
|
||||
assert(uni_switch2_pairing_remember(0, public_address));
|
||||
}
|
||||
public_address[5] = UNI_SWITCH2_PAIRING_CAPACITY;
|
||||
assert(!uni_switch2_pairing_remember(0, public_address));
|
||||
public_address[5] = 0;
|
||||
assert(uni_switch2_pairing_known(0, public_address));
|
||||
|
||||
// Corrupt persisted data is not an authorization and can be explicitly cleared.
|
||||
storage.data[1] = 255;
|
||||
assert(!uni_switch2_pairing_known(0, public_address));
|
||||
assert(!uni_switch2_pairing_remember(0, public_address));
|
||||
assert(uni_switch2_pairing_clear());
|
||||
assert(uni_switch2_pairing_remember(0, public_address));
|
||||
storage.size++;
|
||||
assert(!uni_switch2_pairing_known(0, public_address));
|
||||
return 0;
|
||||
}
|
||||
22
tests/switch2_parser_native_stubs/protocol_fixture.h
Normal file
22
tests/switch2_parser_native_stubs/protocol_fixture.h
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#pragma once
|
||||
|
||||
#include <btstack.h>
|
||||
#include "uni_hid_device.h"
|
||||
|
||||
// The parser uses the SDK's actual GATT types/accessors. Only the asynchronous
|
||||
// radio, run loop, platform admission and persistence endpoints are simulated.
|
||||
enum fixture_query { QUERY_NONE, QUERY_SERVICE, QUERY_CHARACTERISTICS, QUERY_DESCRIPTORS, QUERY_CCCD, QUERY_WRITE };
|
||||
struct fixture_peer {
|
||||
uni_hid_device_t device;
|
||||
bool used, link_alive;
|
||||
enum fixture_query query;
|
||||
btstack_packet_handler_t callback;
|
||||
uint16_t descriptor_value, cccd_handle;
|
||||
uint8_t command[32];
|
||||
uint16_t command_length;
|
||||
const uint8_t* pending_write;
|
||||
uint16_t pending_length;
|
||||
uint8_t rumble[33];
|
||||
uint16_t rumble_length;
|
||||
unsigned commands, rumbles;
|
||||
};
|
||||
678
tests/switch2_parser_native_test.c
Normal file
678
tests/switch2_parser_native_test.c
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "protocol_fixture.h"
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "parser/uni_switch2_pairing.h"
|
||||
|
||||
#define PEERS 4
|
||||
#define SERVICE_START 0x100
|
||||
#define INPUT_HANDLE 0x104
|
||||
#define RESPONSE_HANDLE 0x114
|
||||
#define COMMAND_HANDLE 0x124
|
||||
#define RUMBLE_HANDLE 0x134
|
||||
|
||||
static struct fixture_peer peers[PEERS];
|
||||
static btstack_timer_source_t* timers[16];
|
||||
static unsigned timer_count, connected, ready, disconnected, emitted, remembered, listeners;
|
||||
static unsigned connected_events, disconnected_events;
|
||||
static uint32_t now_ms;
|
||||
static bool pairing_allowed, trusted, storage_ok, request_writes, admit, reject_connected;
|
||||
static uint8_t next_write_error;
|
||||
static const bd_addr_t host_address = {0x10, 0x21, 0x32, 0x43, 0x54, 0x65};
|
||||
static const bd_addr_t controller_address = {0xc0, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
static const uint8_t service_uuid[16] = {0xab,0x7d,0xe9,0xbe,0x89,0xfe,0x49,0xad,0x82,0x8f,0x11,0x8f,0x09,0xdf,0x7f,0xd0};
|
||||
static const uint8_t input_uuid[16] = {0xab,0x7d,0xe9,0xbe,0x89,0xfe,0x49,0xad,0x82,0x8f,0x11,0x8f,0x09,0xdf,0x7f,0xd2};
|
||||
static const uint8_t response_uuid[16] = {0xc7,0x65,0xa9,0x61,0xd9,0xd8,0x4d,0x36,0xa2,0x0a,0x53,0x15,0xb1,0x11,0x83,0x6a};
|
||||
static const uint8_t command_uuid[16] = {0x64,0x9d,0x4a,0xc9,0x8e,0xb7,0x4e,0x6c,0xaf,0x44,0x1e,0xa5,0x4f,0xe5,0xf0,0x05};
|
||||
static const uint8_t rumble_uuids[3][16] = {
|
||||
{0xcc,0x48,0x3f,0x51,0x92,0x58,0x42,0x7d,0xa9,0x39,0x63,0x0c,0x31,0xf7,0x2b,0x05},
|
||||
{0x28,0x93,0x26,0xcb,0xa4,0x71,0x48,0x5d,0xa8,0xf4,0x24,0x0c,0x14,0xf1,0x82,0x41},
|
||||
{0xfa,0x19,0xb0,0xfb,0xcd,0x1f,0x46,0xa7,0x84,0xa1,0xbb,0xb0,0x9e,0x00,0xc1,0x49},
|
||||
};
|
||||
|
||||
static struct fixture_peer* peer_for_handle(hci_con_handle_t handle) {
|
||||
for (unsigned i = 0; i < PEERS; ++i)
|
||||
if (peers[i].used && peers[i].device.conn.handle == handle)
|
||||
return &peers[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void advance(uint32_t milliseconds) {
|
||||
uint32_t target = now_ms + milliseconds;
|
||||
for (;;) {
|
||||
int earliest = -1;
|
||||
for (unsigned i = 0; i < timer_count; ++i) {
|
||||
if ((int32_t)(timers[i]->timeout - target) <= 0 &&
|
||||
(earliest < 0 || (int32_t)(timers[i]->timeout - timers[earliest]->timeout) < 0))
|
||||
earliest = (int)i;
|
||||
}
|
||||
if (earliest < 0)
|
||||
break;
|
||||
btstack_timer_source_t* timer = timers[earliest];
|
||||
now_ms = timer->timeout;
|
||||
btstack_run_loop_remove_timer(timer);
|
||||
timer->process(timer);
|
||||
}
|
||||
now_ms = target;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms) { timer->timeout = now_ms + ms; }
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
|
||||
for (unsigned i = 0; i < timer_count; ++i)
|
||||
assert(timers[i] != timer);
|
||||
assert(timer_count < 16);
|
||||
timers[timer_count++] = timer;
|
||||
}
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
|
||||
for (unsigned i = 0; i < timer_count; ++i) {
|
||||
if (timers[i] == timer) {
|
||||
timers[i] = timers[--timer_count];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context) { timer->context = context; }
|
||||
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { timer->process = handler; }
|
||||
void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer) { return timer->context; }
|
||||
uint32_t btstack_run_loop_get_time_ms(void) { return now_ms; }
|
||||
void uni_log(const char* format, ...) { (void)format; }
|
||||
bool switch_pico_switch2_pairing_allowed(void) { return pairing_allowed; }
|
||||
bool uni_switch2_pairing_known(uint8_t type, const uint8_t address[6]) { (void)type; (void)address; return trusted; }
|
||||
bool uni_switch2_pairing_remember(uint8_t type, const uint8_t address[6]) {
|
||||
assert(type <= 1 && memcmp(address, controller_address, 6) == 0);
|
||||
++remembered;
|
||||
return storage_ok;
|
||||
}
|
||||
void gap_local_bd_addr(bd_addr_t address) { memcpy(address, host_address, 6); }
|
||||
void gap_stop_scan(void) {}
|
||||
uint8_t gap_connect(const bd_addr_t address, bd_addr_type_t type) { (void)address; (void)type; ++connected; return 0; }
|
||||
int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t min, uint16_t max, uint16_t latency, uint16_t timeout) {
|
||||
(void)handle; (void)min; (void)max; (void)latency; (void)timeout; return 0;
|
||||
}
|
||||
gap_connection_type_t gap_get_connection_type(hci_con_handle_t handle) {
|
||||
for (unsigned i = 0; i < PEERS; ++i)
|
||||
if (peers[i].link_alive && peers[i].device.conn.handle == handle)
|
||||
return GAP_CONNECTION_LE;
|
||||
return GAP_CONNECTION_INVALID;
|
||||
}
|
||||
uni_hid_device_t* uni_hid_device_create(bd_addr_t address) {
|
||||
for (unsigned i = 0; i < PEERS; ++i) {
|
||||
if (!peers[i].used) {
|
||||
memset(&peers[i], 0, sizeof(peers[i]));
|
||||
peers[i].used = true;
|
||||
peers[i].device.conn.handle = UNI_BT_CONN_HANDLE_INVALID;
|
||||
memcpy(peers[i].device.conn.btaddr, address, 6);
|
||||
return &peers[i].device;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
uni_hid_device_t* uni_hid_device_get_instance_for_address(bd_addr_t address) {
|
||||
for (unsigned i = 0; i < PEERS; ++i)
|
||||
if (peers[i].used && memcmp(peers[i].device.conn.btaddr, address, 6) == 0)
|
||||
return &peers[i].device;
|
||||
return NULL;
|
||||
}
|
||||
uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(hci_con_handle_t handle) {
|
||||
struct fixture_peer* peer = peer_for_handle(handle);
|
||||
return peer ? &peer->device : NULL;
|
||||
}
|
||||
uni_error_t uni_hid_device_on_device_discovered(bd_addr_t address, const char* name, uint16_t cod, uint8_t rssi) {
|
||||
(void)address; (void)name; (void)cod; (void)rssi;
|
||||
return admit ? UNI_ERROR_SUCCESS : (uni_error_t)1;
|
||||
}
|
||||
void uni_hid_device_set_vendor_id(uni_hid_device_t* d, uint16_t value) { d->vendor_id = value; }
|
||||
void uni_hid_device_set_product_id(uni_hid_device_t* d, uint16_t value) { d->product_id = value; }
|
||||
void uni_hid_device_set_cod(uni_hid_device_t* d, uint32_t value) { d->cod = value; }
|
||||
void uni_hid_device_set_name(uni_hid_device_t* d, const char* value) { (void)d; (void)value; }
|
||||
void uni_hid_device_guess_controller_type_from_pid_vid(uni_hid_device_t* d) { (void)d; }
|
||||
void uni_bt_conn_set_protocol(uni_bt_conn_t* conn, uni_bt_conn_protocol_t value) { conn->protocol = value; }
|
||||
void uni_bt_conn_set_state(uni_bt_conn_t* conn, uni_bt_conn_state_t value) { conn->state = value; }
|
||||
void uni_hid_device_connect(uni_hid_device_t* d) {
|
||||
assert(!d->conn.connected);
|
||||
d->conn.connected = true;
|
||||
++connected_events;
|
||||
if (reject_connected) {
|
||||
uni_hid_device_disconnect(d);
|
||||
uni_hid_device_delete(d);
|
||||
}
|
||||
}
|
||||
void uni_hid_device_set_ready(uni_hid_device_t* d) {
|
||||
d->conn.state = UNI_BT_CONN_STATE_DEVICE_PENDING_READY;
|
||||
uni_hid_parser_switch2_setup(d);
|
||||
}
|
||||
bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) {
|
||||
assert(d->conn.connected && connected_events == 1);
|
||||
++ready;
|
||||
d->conn.state = UNI_BT_CONN_STATE_DEVICE_READY;
|
||||
return true;
|
||||
}
|
||||
void uni_hid_device_disconnect(uni_hid_device_t* d) {
|
||||
++disconnected;
|
||||
if (d->conn.connected)
|
||||
++disconnected_events;
|
||||
d->conn.connected = false;
|
||||
uni_hid_parser_switch2_teardown(d);
|
||||
struct fixture_peer* peer = peer_for_handle(d->conn.handle);
|
||||
if (peer)
|
||||
peer->link_alive = false;
|
||||
}
|
||||
void uni_hid_device_delete(uni_hid_device_t* d) {
|
||||
uni_hid_parser_switch2_teardown(d);
|
||||
for (unsigned i = 0; i < PEERS; ++i)
|
||||
if (&peers[i].device == d)
|
||||
peers[i].used = false;
|
||||
}
|
||||
void uni_hid_device_process_controller(uni_hid_device_t* d) { (void)d; ++emitted; }
|
||||
|
||||
static uint8_t begin_query(btstack_packet_handler_t callback, hci_con_handle_t handle, enum fixture_query query) {
|
||||
struct fixture_peer* peer = peer_for_handle(handle);
|
||||
assert(peer && peer->query == QUERY_NONE);
|
||||
peer->callback = callback;
|
||||
peer->query = query;
|
||||
return 0;
|
||||
}
|
||||
uint8_t gatt_client_discover_primary_services_by_uuid128(btstack_packet_handler_t callback, hci_con_handle_t handle, const uint8_t* uuid) {
|
||||
assert(memcmp(uuid, service_uuid, 16) == 0);
|
||||
return begin_query(callback, handle, QUERY_SERVICE);
|
||||
}
|
||||
uint8_t gatt_client_discover_characteristics_for_service(btstack_packet_handler_t callback, hci_con_handle_t handle, gatt_client_service_t* service) {
|
||||
assert(service->start_group_handle == SERVICE_START);
|
||||
return begin_query(callback, handle, QUERY_CHARACTERISTICS);
|
||||
}
|
||||
uint8_t gatt_client_discover_characteristic_descriptors(btstack_packet_handler_t callback, hci_con_handle_t handle, gatt_client_characteristic_t* ch) {
|
||||
peer_for_handle(handle)->descriptor_value = ch->value_handle;
|
||||
return begin_query(callback, handle, QUERY_DESCRIPTORS);
|
||||
}
|
||||
uint8_t gatt_client_write_characteristic_descriptor_using_descriptor_handle(btstack_packet_handler_t callback, hci_con_handle_t handle,
|
||||
uint16_t descriptor, uint16_t length, uint8_t* value) {
|
||||
struct fixture_peer* peer = peer_for_handle(handle);
|
||||
peer->cccd_handle = descriptor;
|
||||
peer->pending_write = value;
|
||||
peer->pending_length = length;
|
||||
return begin_query(callback, handle, QUERY_CCCD);
|
||||
}
|
||||
void gatt_client_listen_for_characteristic_value_updates(gatt_client_notification_t* registration, btstack_packet_handler_t callback,
|
||||
hci_con_handle_t handle, gatt_client_characteristic_t* ch) {
|
||||
registration->callback = callback;
|
||||
registration->con_handle = handle;
|
||||
registration->attribute_handle = ch->value_handle;
|
||||
++listeners;
|
||||
}
|
||||
void gatt_client_stop_listening_for_characteristic_value_updates(gatt_client_notification_t* registration) { (void)registration; assert(listeners); --listeners; }
|
||||
static uint8_t capture_write(hci_con_handle_t handle, uint16_t value_handle, uint16_t length, uint8_t* value) {
|
||||
if (next_write_error) {
|
||||
uint8_t error = next_write_error;
|
||||
next_write_error = 0;
|
||||
return error;
|
||||
}
|
||||
struct fixture_peer* peer = peer_for_handle(handle);
|
||||
assert(peer);
|
||||
if (value_handle == COMMAND_HANDLE) {
|
||||
assert(length <= sizeof(peer->command));
|
||||
memcpy(peer->command, value, length);
|
||||
peer->command_length = length;
|
||||
++peer->commands;
|
||||
} else {
|
||||
assert(value_handle == RUMBLE_HANDLE && length <= sizeof(peer->rumble));
|
||||
memcpy(peer->rumble, value, length);
|
||||
peer->rumble_length = length;
|
||||
++peer->rumbles;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
uint8_t gatt_client_write_value_of_characteristic_without_response(hci_con_handle_t handle, uint16_t value_handle, uint16_t length, uint8_t* value) {
|
||||
return capture_write(handle, value_handle, length, value);
|
||||
}
|
||||
uint8_t gatt_client_write_value_of_characteristic(btstack_packet_handler_t callback, hci_con_handle_t handle,
|
||||
uint16_t value_handle, uint16_t length, uint8_t* value) {
|
||||
uint8_t status = capture_write(handle, value_handle, length, value);
|
||||
if (status)
|
||||
return status;
|
||||
struct fixture_peer* peer = peer_for_handle(handle);
|
||||
peer->pending_write = value;
|
||||
peer->pending_length = length;
|
||||
return begin_query(callback, handle, QUERY_WRITE);
|
||||
}
|
||||
|
||||
// Serialization here models BTstack-generated events; production accessors and
|
||||
// types come directly from the SDK rather than a parallel mock btstack.h ABI.
|
||||
void gatt_client_deserialize_service(const uint8_t* data, int offset, gatt_client_service_t* service) {
|
||||
service->start_group_handle = little_endian_read_16(data, offset);
|
||||
service->end_group_handle = little_endian_read_16(data, offset + 2);
|
||||
reverse_128(data + offset + 4, service->uuid128);
|
||||
service->uuid16 = 0;
|
||||
}
|
||||
void gatt_client_deserialize_characteristic(const uint8_t* data, int offset, gatt_client_characteristic_t* ch) {
|
||||
ch->start_handle = little_endian_read_16(data, offset);
|
||||
ch->value_handle = little_endian_read_16(data, offset + 2);
|
||||
ch->end_handle = little_endian_read_16(data, offset + 4);
|
||||
ch->properties = little_endian_read_16(data, offset + 6);
|
||||
reverse_128(data + offset + 8, ch->uuid128);
|
||||
ch->uuid16 = 0;
|
||||
}
|
||||
void gatt_client_deserialize_characteristic_descriptor(const uint8_t* data, int offset, gatt_client_characteristic_descriptor_t* descriptor) {
|
||||
descriptor->handle = little_endian_read_16(data, offset);
|
||||
reverse_128(data + offset + 2, descriptor->uuid128);
|
||||
descriptor->uuid16 = (uint16_t)big_endian_read_32(descriptor->uuid128, 0);
|
||||
}
|
||||
|
||||
static void event(struct fixture_peer* peer, uint8_t* data, uint16_t size) {
|
||||
data[1] = (uint8_t)(size - 2);
|
||||
little_endian_store_16(data, 2, peer->device.conn.handle);
|
||||
peer->callback(HCI_EVENT_PACKET, 0, data, size);
|
||||
}
|
||||
static void query_done(struct fixture_peer* peer, uint8_t status) {
|
||||
uint8_t data[9] = {GATT_EVENT_QUERY_COMPLETE};
|
||||
if (peer->query == QUERY_CCCD)
|
||||
assert(peer->pending_length == 2 && peer->pending_write[0] == 1 && peer->pending_write[1] == 0);
|
||||
peer->query = QUERY_NONE;
|
||||
data[8] = status;
|
||||
event(peer, data, sizeof(data));
|
||||
}
|
||||
static void characteristic(struct fixture_peer* peer, uint16_t handle, const uint8_t* uuid, uint16_t properties) {
|
||||
uint8_t data[32] = {GATT_EVENT_CHARACTERISTIC_QUERY_RESULT};
|
||||
little_endian_store_16(data, 8, handle - 1);
|
||||
little_endian_store_16(data, 10, handle);
|
||||
little_endian_store_16(data, 12, handle + 3);
|
||||
little_endian_store_16(data, 14, properties);
|
||||
reverse_128(uuid, data + 16);
|
||||
event(peer, data, sizeof(data));
|
||||
}
|
||||
static void descriptor(struct fixture_peer* peer, uint16_t handle) {
|
||||
const uint8_t cccd_uuid[16] = {0,0,0x29,0x02,0,0,0x10,0,0x80,0,0,0x80,0x5f,0x9b,0x34,0xfb};
|
||||
uint8_t data[26] = {GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT};
|
||||
little_endian_store_16(data, 8, handle);
|
||||
reverse_128(cccd_uuid, data + 10);
|
||||
event(peer, data, sizeof(data));
|
||||
}
|
||||
static void discover(struct fixture_peer* peer) {
|
||||
uint8_t service[28] = {GATT_EVENT_SERVICE_QUERY_RESULT};
|
||||
little_endian_store_16(service, 8, SERVICE_START);
|
||||
little_endian_store_16(service, 10, SERVICE_START + 0x60);
|
||||
reverse_128(service_uuid, service + 12);
|
||||
event(peer, service, sizeof(service));
|
||||
query_done(peer, 0);
|
||||
characteristic(peer, INPUT_HANDLE, input_uuid, ATT_PROPERTY_NOTIFY);
|
||||
characteristic(peer, RESPONSE_HANDLE, response_uuid, ATT_PROPERTY_NOTIFY);
|
||||
characteristic(peer, COMMAND_HANDLE, command_uuid, request_writes ? ATT_PROPERTY_WRITE : ATT_PROPERTY_WRITE_WITHOUT_RESPONSE);
|
||||
unsigned kind = peer->device.product_id == UNI_SW2_PRO_PID ? 0 : peer->device.product_id == UNI_SW2_JOYCON_L_PID ? 1 : 2;
|
||||
characteristic(peer, RUMBLE_HANDLE, rumble_uuids[kind], request_writes ? ATT_PROPERTY_WRITE : ATT_PROPERTY_WRITE_WITHOUT_RESPONSE);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
static void subscribe_response(struct fixture_peer* peer) {
|
||||
// Deliberately not value_handle+1: parser must discover, not guess CCCDs.
|
||||
descriptor(peer, RESPONSE_HANDLE + 2);
|
||||
query_done(peer, 0);
|
||||
descriptor(peer, INPUT_HANDLE + 2);
|
||||
query_done(peer, 0);
|
||||
assert(peer->cccd_handle == RESPONSE_HANDLE + 2);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
static void notify(struct fixture_peer* peer, uint16_t handle, const uint8_t* value, uint16_t length) {
|
||||
uint8_t data[112] = {GATT_EVENT_NOTIFICATION};
|
||||
assert(length <= sizeof(data) - 12);
|
||||
little_endian_store_16(data, 8, handle);
|
||||
little_endian_store_16(data, 10, length);
|
||||
memcpy(data + 12, value, length);
|
||||
event(peer, data, 12 + length);
|
||||
}
|
||||
static void packed(uint8_t* data, uint16_t x, uint16_t y) {
|
||||
data[0] = (uint8_t)x;
|
||||
data[1] = (uint8_t)((x >> 8) | (y << 4));
|
||||
data[2] = (uint8_t)(y >> 4);
|
||||
}
|
||||
static unsigned response_data(struct fixture_peer* peer, uint8_t* out, bool erased) {
|
||||
memset(out, 0, 96);
|
||||
out[0] = peer->command[0];
|
||||
out[1] = out[2] = 1;
|
||||
out[3] = peer->command[3];
|
||||
out[5] = 0x78;
|
||||
if (out[0] == 2) {
|
||||
uint8_t length = peer->command[8];
|
||||
uint32_t address = little_endian_read_32(peer->command, 12);
|
||||
out[8] = length;
|
||||
little_endian_store_32(out, 12, address);
|
||||
if (address == 0x13000) {
|
||||
little_endian_store_16(out, 16 + 18, UNI_SW2_NINTENDO_VID);
|
||||
little_endian_store_16(out, 16 + 20, peer->device.product_id);
|
||||
} else if (address != 0x13044) {
|
||||
if (erased)
|
||||
memset(out + 16, 0xff, length);
|
||||
else {
|
||||
packed(out + 16, 1900, 2100);
|
||||
packed(out + 19, 1200, 1400);
|
||||
packed(out + 22, 1500, 1700);
|
||||
}
|
||||
}
|
||||
return 16 + length;
|
||||
}
|
||||
if (out[0] == 0x15) {
|
||||
out[8] = 1;
|
||||
return out[3] == 1 ? 17 : out[3] == 3 ? 9 : 25;
|
||||
}
|
||||
return 8;
|
||||
}
|
||||
static void acknowledge(struct fixture_peer* peer, bool erased) {
|
||||
uint8_t response[96];
|
||||
unsigned length = response_data(peer, response, erased);
|
||||
if (peer->query == QUERY_WRITE)
|
||||
query_done(peer, 0);
|
||||
notify(peer, RESPONSE_HANDLE, response, length);
|
||||
}
|
||||
static void finish_setup(struct fixture_peer* peer) {
|
||||
for (unsigned limit = 0; limit < 24 && !ready && !disconnected; ++limit) {
|
||||
if (peer->query == QUERY_CCCD) {
|
||||
assert(peer->cccd_handle == INPUT_HANDLE + 2);
|
||||
query_done(peer, 0);
|
||||
} else {
|
||||
acknowledge(peer, false);
|
||||
}
|
||||
}
|
||||
assert(ready == 1 && disconnected == 0);
|
||||
}
|
||||
static size_t advertisement(uint8_t* packet, uint16_t pid, bool fresh) {
|
||||
memset(packet, 0, 64);
|
||||
packet[0] = GAP_EVENT_ADVERTISING_REPORT;
|
||||
packet[2] = 0;
|
||||
packet[3] = BD_ADDR_TYPE_LE_PUBLIC;
|
||||
reverse_bytes(controller_address, packet + 4, 6);
|
||||
packet[10] = (uint8_t)-35;
|
||||
packet[11] = 20;
|
||||
packet[12] = 19;
|
||||
packet[13] = 0xff;
|
||||
uint8_t* mfg = packet + 14;
|
||||
little_endian_store_16(mfg, 0, 0x0553);
|
||||
little_endian_store_16(mfg, 5, UNI_SW2_NINTENDO_VID);
|
||||
little_endian_store_16(mfg, 7, pid);
|
||||
if (!fresh)
|
||||
reverse_bytes(host_address, mfg + 12, 6);
|
||||
packet[1] = 30;
|
||||
return 32;
|
||||
}
|
||||
static struct fixture_peer* connect_peer(uint16_t pid, bool fresh) {
|
||||
uint8_t packet[64];
|
||||
size_t size = advertisement(packet, pid, fresh);
|
||||
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
|
||||
assert(connected == 1);
|
||||
struct fixture_peer* peer = &peers[0];
|
||||
peer->device.conn.handle = 0; // Handle zero is valid, including on retirement.
|
||||
peer->link_alive = true;
|
||||
uni_hid_parser_switch2_on_le_connected(&peer->device);
|
||||
return peer;
|
||||
}
|
||||
static void reset(void) {
|
||||
for (unsigned i = 0; i < PEERS; ++i) {
|
||||
if (peers[i].used)
|
||||
uni_hid_parser_switch2_teardown(&peers[i].device);
|
||||
}
|
||||
assert(timer_count == 0 && listeners == 0);
|
||||
memset(peers, 0, sizeof(peers));
|
||||
connected = ready = disconnected = emitted = remembered = 0;
|
||||
connected_events = disconnected_events = 0;
|
||||
now_ms = 0;
|
||||
next_write_error = 0;
|
||||
pairing_allowed = trusted = storage_ok = admit = true;
|
||||
request_writes = false;
|
||||
reject_connected = false;
|
||||
}
|
||||
|
||||
static void test_connected_callback_rejection(void) {
|
||||
reset();
|
||||
reject_connected = true;
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, false);
|
||||
assert(connected_events == 1 && disconnected_events == 1 && ready == 0);
|
||||
assert(peer->query == QUERY_NONE && timer_count == 0 && listeners == 0);
|
||||
advance(3000);
|
||||
assert(ready == 0 && peer->commands == 0);
|
||||
}
|
||||
|
||||
static void test_advertisement_bounds_and_admission(void) {
|
||||
reset();
|
||||
uint8_t packet[64];
|
||||
size_t size = advertisement(packet, UNI_SW2_PRO_PID, true);
|
||||
for (size_t n = 0; n < size; ++n)
|
||||
assert(!uni_bt_le_switch2_handle_advertisement(packet, (uint16_t)n));
|
||||
packet[12] = 20;
|
||||
assert(!uni_bt_le_switch2_handle_advertisement(packet, size));
|
||||
packet[12] = 19;
|
||||
pairing_allowed = false;
|
||||
assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0);
|
||||
advertisement(packet, UNI_SW2_PRO_PID, false);
|
||||
trusted = false;
|
||||
assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0);
|
||||
trusted = true;
|
||||
memcpy(packet + 26, host_address, 6); // Wrong byte order must not reconnect.
|
||||
assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0);
|
||||
advertisement(packet, UNI_SW2_PRO_PID, false);
|
||||
packet[3] = BD_ADDR_TYPE_LE_RANDOM;
|
||||
packet[9] = 0x40; // Resolving private address, not static identity.
|
||||
assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0);
|
||||
advertisement(packet, UNI_SW2_PRO_PID, false);
|
||||
admit = false;
|
||||
assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0);
|
||||
admit = true;
|
||||
assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 1);
|
||||
uint8_t type = 0xff;
|
||||
assert(uni_hid_parser_switch2_identity_address_type(&peers[0].device, &type) && type == BD_ADDR_TYPE_LE_PUBLIC);
|
||||
}
|
||||
|
||||
static void test_missing_descriptor_and_setup_timeout(void) {
|
||||
reset();
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, false);
|
||||
discover(peer);
|
||||
descriptor(peer, RESPONSE_HANDLE); // CCCD outside characteristic's descriptor range.
|
||||
assert(disconnected == 1 && ready == 0 && timer_count == 0 && listeners == 0);
|
||||
reset();
|
||||
peer = connect_peer(UNI_SW2_PRO_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
uint8_t response[96];
|
||||
unsigned length = response_data(peer, response, false);
|
||||
response[3] ^= 1; // Matching command but wrong subcommand.
|
||||
notify(peer, RESPONSE_HANDLE, response, length);
|
||||
response[3] ^= 1;
|
||||
response[12] ^= 1; // Matching command/subcommand but stale flash address.
|
||||
notify(peer, RESPONSE_HANDLE, response, length);
|
||||
uint8_t truncated[12] = {GATT_EVENT_NOTIFICATION};
|
||||
little_endian_store_16(truncated, 8, RESPONSE_HANDLE);
|
||||
little_endian_store_16(truncated, 10, 0xffff);
|
||||
event(peer, truncated, sizeof(truncated));
|
||||
assert(peer->commands == 1 && ready == 0);
|
||||
advance(2000);
|
||||
assert(disconnected == 1 && ready == 0 && timer_count == 0 && listeners == 0);
|
||||
advance(5000);
|
||||
assert(peer->commands == 1);
|
||||
}
|
||||
|
||||
static void test_pairing_gate_and_write_ack_order(void) {
|
||||
reset();
|
||||
request_writes = true;
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, true);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
uint8_t response[96];
|
||||
unsigned length = response_data(peer, response, false);
|
||||
notify(peer, RESPONSE_HANDLE, response, length); // App ACK arrives before ATT write result.
|
||||
assert(peer->commands == 1 && !remembered && !ready);
|
||||
query_done(peer, 0);
|
||||
assert(peer->command[0] == 0x15 && peer->command[3] == 1);
|
||||
for (unsigned i = 0; i < 6; ++i)
|
||||
assert(peer->command[10 + i] == host_address[5 - i] && peer->command[16 + i] == host_address[5 - i]);
|
||||
pairing_allowed = false;
|
||||
acknowledge(peer, false);
|
||||
assert(disconnected == 1 && remembered == 0 && ready == 0);
|
||||
assert(connected_events == 1 && disconnected_events == 1);
|
||||
reset();
|
||||
peer = connect_peer(UNI_SW2_PRO_PID, true);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
storage_ok = false;
|
||||
for (unsigned i = 0; i < 5; ++i)
|
||||
acknowledge(peer, false); // Info and four app pairing commands.
|
||||
assert(remembered == 1 && disconnected == 1 && ready == 0 && timer_count == 0);
|
||||
reset();
|
||||
peer = connect_peer(UNI_SW2_PRO_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
length = response_data(peer, response, false);
|
||||
response[5] = 0x81;
|
||||
notify(peer, RESPONSE_HANDLE, response, length);
|
||||
assert(disconnected == 1 && ready == 0);
|
||||
reset();
|
||||
request_writes = true;
|
||||
peer = connect_peer(UNI_SW2_PRO_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
length = response_data(peer, response, false);
|
||||
notify(peer, RESPONSE_HANDLE, response, length);
|
||||
query_done(peer, ATT_ERROR_INSUFFICIENT_AUTHENTICATION);
|
||||
assert(disconnected == 1 && ready == 0);
|
||||
}
|
||||
|
||||
static void test_calibration_physical_inputs_and_sensor_units(void) {
|
||||
reset();
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_R_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
acknowledge(peer, false); // Info.
|
||||
acknowledge(peer, true); // Missing user calibration falls back to factory.
|
||||
assert(little_endian_read_32(peer->command, 12) == 0x130a8);
|
||||
finish_setup(peer);
|
||||
uint8_t report[63] = {0};
|
||||
little_endian_store_32(report, 4, 0x4000 | 0x20 | 0x10 | 0x04);
|
||||
packed(report + 10, 4095, 4095); // Nonexistent left stick must stay neutral.
|
||||
packed(report + 13, 3100, 400); // Calibrated +X/-Y full travel.
|
||||
notify(peer, INPUT_HANDLE, report, sizeof(report));
|
||||
uni_gamepad_t* gp = &peer->device.controller.gamepad;
|
||||
assert(gp->axis_x == 0 && gp->axis_y == 0 && gp->axis_rx == 511 && gp->axis_ry == 511);
|
||||
assert(gp->buttons == BUTTON_A && gp->misc_buttons == 0);
|
||||
assert(uni_hid_parser_switch2_extra_buttons(&peer->device) == (UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_RIGHT_SL | UNI_SW2_BUTTON_RIGHT_SR));
|
||||
unsigned previous = emitted;
|
||||
notify(peer, INPUT_HANDLE, report, 62);
|
||||
assert(emitted == previous && gp->axis_rx == 511);
|
||||
little_endian_store_16(report, 48, 4096);
|
||||
little_endian_store_16(report, 50, 8192);
|
||||
little_endian_store_16(report, 52, (uint16_t)-4096);
|
||||
little_endian_store_16(report, 54, 32767);
|
||||
little_endian_store_16(report, 56, 0);
|
||||
little_endian_store_16(report, 58, 0);
|
||||
for (unsigned sample = 0; sample < 46; ++sample) {
|
||||
little_endian_store_32(report, 42, 0xffff0000u + sample * 10000u);
|
||||
notify(peer, INPUT_HANDLE, report, sizeof(report));
|
||||
advance(10);
|
||||
}
|
||||
assert(gp->accel[0] == 8192 && gp->accel[1] == -8192 && gp->accel[2] == -16384);
|
||||
assert(gp->gyro[0] >= 2041740 && gp->gyro[0] <= 2041750);
|
||||
notify(peer, INPUT_HANDLE, report, sizeof(report)); // Repeated sensor sample must not become zero.
|
||||
assert(gp->gyro[0] >= 2041740);
|
||||
reset();
|
||||
peer = connect_peer(UNI_SW2_JOYCON_L_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
finish_setup(peer);
|
||||
memset(report, 0, sizeof(report));
|
||||
little_endian_store_32(report, 4, 0x300000);
|
||||
packed(report + 10, 1900, 2100);
|
||||
notify(peer, INPUT_HANDLE, report, sizeof(report));
|
||||
assert(peer->device.controller.gamepad.misc_buttons == 0); // Rail buttons are NOT Home/Capture.
|
||||
assert(uni_hid_parser_switch2_extra_buttons(&peer->device) == (UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR));
|
||||
}
|
||||
|
||||
static uint64_t rumble_frame(const struct fixture_peer* peer) {
|
||||
uint64_t value = 0;
|
||||
for (unsigned i = 0; i < 5; ++i)
|
||||
value |= (uint64_t)peer->rumble[2 + i] << (8 * i);
|
||||
return value;
|
||||
}
|
||||
static bool rumble_active(const struct fixture_peer* peer) {
|
||||
uint64_t frame = rumble_frame(peer);
|
||||
return ((frame >> 10) & 1023) != 0 || ((frame >> 30) & 1023) != 0;
|
||||
}
|
||||
static void test_rumble_delay_expiry_retry_and_teardown(void) {
|
||||
reset();
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, true);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
finish_setup(peer);
|
||||
assert(remembered == 1);
|
||||
uni_hid_parser_switch2_set_player_leds(&peer->device, 0x0a);
|
||||
assert(peer->command[8] == 0x0a);
|
||||
acknowledge(peer, false);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 20, 50, 40, 80);
|
||||
advance(19);
|
||||
assert(peer->rumble_length == 33 && !rumble_active(peer));
|
||||
advance(1);
|
||||
assert(rumble_active(peer));
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 320 && ((rumble_frame(peer) >> 30) & 1023) == 160);
|
||||
uint8_t successful_id = peer->rumble[1];
|
||||
next_write_error = BTSTACK_ACL_BUFFERS_FULL;
|
||||
advance(13);
|
||||
assert(peer->rumble[1] == successful_id);
|
||||
advance(13);
|
||||
assert(peer->rumble[1] == (uint8_t)(0x50 | ((successful_id + 1) & 15)) && rumble_active(peer));
|
||||
advance(24);
|
||||
assert(!rumble_active(peer));
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 12, 34);
|
||||
advance(70000); // Stateful host rumble does not expire at 65.535 seconds.
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 136 && ((rumble_frame(peer) >> 30) & 1023) == 48);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 255, 255);
|
||||
advance(1);
|
||||
assert(rumble_active(peer));
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0);
|
||||
advance(1);
|
||||
assert(!rumble_active(peer));
|
||||
unsigned writes = peer->rumbles;
|
||||
uni_hid_device_disconnect(&peer->device);
|
||||
assert(disconnected_events == 1);
|
||||
uni_hid_parser_switch2_teardown(&peer->device);
|
||||
assert(timer_count == 0 && listeners == 0);
|
||||
advance(5000);
|
||||
assert(peer->rumbles == writes);
|
||||
assert(!uni_hid_parser_switch2_identity_address_type(&peer->device, &(uint8_t){0}));
|
||||
}
|
||||
|
||||
static void test_write_request_buffers_and_failed_completion(void) {
|
||||
reset();
|
||||
request_writes = true;
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_L_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
finish_setup(peer);
|
||||
advance(1);
|
||||
assert(peer->rumble_length == 17 && peer->query == QUERY_WRITE);
|
||||
uint8_t pending[33];
|
||||
uint16_t length = peer->pending_length;
|
||||
const uint8_t* pending_buffer = peer->pending_write;
|
||||
memcpy(pending, pending_buffer, length);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 20, 30);
|
||||
uni_hid_parser_switch2_set_player_leds(&peer->device, 0x05);
|
||||
advance(13);
|
||||
assert(memcmp(pending, pending_buffer, length) == 0); // ATT still borrows this buffer.
|
||||
query_done(peer, 0);
|
||||
assert(peer->command[0] == 9 && peer->command[8] == 0x05);
|
||||
acknowledge(peer, false);
|
||||
advance(13);
|
||||
assert(rumble_active(peer) && peer->rumble[1] == 0x51);
|
||||
query_done(peer, ATT_ERROR_UNLIKELY_ERROR);
|
||||
assert(disconnected_events == 1 && timer_count == 0 && listeners == 0);
|
||||
unsigned writes = peer->rumbles;
|
||||
advance(3000);
|
||||
assert(peer->rumbles == writes);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_connected_callback_rejection();
|
||||
test_advertisement_bounds_and_admission();
|
||||
test_missing_descriptor_and_setup_timeout();
|
||||
test_pairing_gate_and_write_ack_order();
|
||||
test_calibration_physical_inputs_and_sensor_units();
|
||||
test_rumble_delay_expiry_retry_and_teardown();
|
||||
test_write_request_buffers_and_failed_completion();
|
||||
reset();
|
||||
puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
#include "bt/uni_bt_service.h"
|
||||
#include "parser/uni_hid_parser_switch.h"
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "platform/uni_platform.h"
|
||||
#include "uni_hid_device.h"
|
||||
|
||||
|
|
@ -22,6 +23,15 @@ static struct { uint16_t cid, len; uint8_t bytes[128]; } sent[256];
|
|||
static uint32_t now_ms;
|
||||
static struct { btstack_timer_source_t* timer; uint32_t deadline; bool active; } timers[32];
|
||||
|
||||
bool uni_hid_parser_switch2_is_ble_device(const uni_hid_device_t* d) {
|
||||
(void)d;
|
||||
return false; // This fixture exercises only Classic Switch devices.
|
||||
}
|
||||
void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) {
|
||||
(void)d;
|
||||
assert(!"Switch 2 teardown reached a Classic Switch fixture");
|
||||
}
|
||||
|
||||
static unsigned timer_index(btstack_timer_source_t* timer) {
|
||||
for (unsigned i = 0; i < 32; ++i) {
|
||||
if (timers[i].timer == timer) return i;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
[
|
||||
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
|
||||
str(
|
||||
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"
|
||||
|
|
@ -91,6 +92,14 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
)
|
||||
subprocess.run(command, check=True, cwd=root)
|
||||
subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root)
|
||||
for scenario in (
|
||||
"switch2-forward",
|
||||
"switch2-reverse",
|
||||
"switch2-multiple-pairs",
|
||||
"switch2-admission",
|
||||
"switch2-pairing-inventory",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
if native:
|
||||
subprocess.run([str(executable), "native-stateful"], check=True, cwd=root)
|
||||
subprocess.run(
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ class FakeDevice:
|
|||
self.playtest_connection_generation = 17
|
||||
self.playtest_state_generation = 93
|
||||
self.playtest_button_mask = 0x9001
|
||||
self.playtest_extra_buttons = 0
|
||||
self.playtest_sticks = (-1234, 2345, -30000, 30000)
|
||||
self.playtest_triggers = (123, 65000)
|
||||
self.playtest_motion = (1, -2, 3, -4, 5, -6)
|
||||
|
|
@ -232,6 +233,7 @@ class FakeDevice:
|
|||
payload[38] = 1 if self.playtest_motion is not None else 0
|
||||
payload[39] = self.playtest_battery
|
||||
payload[40] = self.playtest_capabilities
|
||||
payload[54] = self.playtest_extra_buttons
|
||||
if self.playtest_motion is not None:
|
||||
struct.pack_into("<hhhhhh", payload, 42, *self.playtest_motion)
|
||||
return bytes(payload), flags
|
||||
|
|
@ -370,7 +372,7 @@ class FakeDevice:
|
|||
make_response(
|
||||
request,
|
||||
self.profiles[self.selected_profile],
|
||||
schema=config_manager.PROFILE_SCHEMA_VERSION,
|
||||
schema=struct.unpack_from("<H", self.profiles[self.selected_profile])[0],
|
||||
generation=self.profile_generation,
|
||||
)
|
||||
)
|
||||
|
|
@ -1576,7 +1578,7 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
|||
legacy_json_object = default_profile.to_json_object()
|
||||
legacy_json_object["schema_version"] = config_manager.PROFILE_LEGACY_SCHEMA_VERSION
|
||||
legacy_json_object["size"] = config_manager.PROFILE_LEGACY_SIZE
|
||||
for field in ("shortcuts", "shift", "turbo_settings"):
|
||||
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map"):
|
||||
del legacy_json_object[field]
|
||||
del legacy_json_object["motion_toggle_chord"]
|
||||
del legacy_json_object["triggers"]["left"]["output"]
|
||||
|
|
@ -1639,7 +1641,7 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None:
|
|||
|
||||
obj["schema_version"] = 5
|
||||
obj["size"] = 256
|
||||
for field in ("shortcuts", "shift", "turbo_settings"):
|
||||
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map"):
|
||||
del obj[field]
|
||||
for macro in obj["macros"]:
|
||||
del macro["playback"]
|
||||
|
|
@ -1678,10 +1680,133 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
|
|||
assert encoded[283:294] == bytes((30, 99, 255, 4, 128, 1, 1, 1, 23, 37, 17))
|
||||
assert encoded[294:336] == bytes(42)
|
||||
assert encoded[336:344] == bytes((3, 255, 0, 1, 1, 1, 2, 1))
|
||||
assert encoded[344:] == bytes(40)
|
||||
assert encoded[344:358] == bytes([255]) * 14
|
||||
assert encoded[358:] == bytes(26)
|
||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
|
||||
|
||||
legacy_wire = bytearray(encoded)
|
||||
struct.pack_into("<H", legacy_wire, 0, 6)
|
||||
legacy_wire[344:] = bytes(40)
|
||||
legacy_json = json.loads(profile.to_json())
|
||||
legacy_json["schema_version"] = 6
|
||||
del legacy_json["extra_button_map"]
|
||||
del legacy_json["shift"]["extra_button_map"]
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
|
||||
device = FakeDevice()
|
||||
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
|
||||
assert config_manager.read_profile(device, device.stable_identity, 1) == profile
|
||||
device.profile_aliases[device.stable_identity.to_bytes()] = "Custom controller"
|
||||
old_listing = config_manager.parse_profile_list(config_manager.parse_response(
|
||||
make_response(config_manager.OP_PROFILE_LIST, device._profile_list_payload(), schema=6),
|
||||
config_manager.OP_PROFILE_LIST,
|
||||
))
|
||||
assert old_listing[1] == config_manager.ProfileListEntry(device.stable_identity, 1, "Custom controller")
|
||||
|
||||
|
||||
def test_schema7_extra_controls_keep_output_channels_and_wire_layout() -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
obj["extra_button_map"] = dict(zip(
|
||||
config_manager.EXTRA_BUTTONS,
|
||||
("south", "right_trigger", "left_trigger", None, "dpad_left", "start", "capture"),
|
||||
))
|
||||
obj["shift"]["mode"] = "hold"
|
||||
obj["shift"]["modifier"] = "gl"
|
||||
obj["shift"]["extra_button_map"] = dict(zip(
|
||||
config_manager.EXTRA_BUTTONS,
|
||||
("east", None, "west", "north", "system", "dpad_up", "dpad_right"),
|
||||
))
|
||||
obj["shortcuts"]["modifier"] = "left_sr"
|
||||
obj["shortcuts"]["profiles"][0] = "south"
|
||||
obj["switching_chord"] = ["left_trigger", "c", "right_sr"]
|
||||
obj["motion_toggle_chord"] = ["right_trigger", "gl", "left_sl"]
|
||||
for index, names in enumerate((["c", "gl"], ["gr"], ["left_sl", "left_sr"], ["right_sl", "right_sr"])):
|
||||
obj["macros"][index]["trigger"] = names
|
||||
obj["macros"][index]["cancel"] = config_manager.EXTRA_BUTTONS[index + 3]
|
||||
profile = config_manager.ControllerProfile.from_json_object(obj)
|
||||
encoded = profile.to_bytes()
|
||||
assert encoded[:4] == struct.pack("<HH", 7, 384)
|
||||
assert encoded[344:351] == bytes((0, 17, 16, 255, 14, 7, 9))
|
||||
assert encoded[351:358] == bytes((1, 255, 2, 3, 8, 12, 15))
|
||||
assert encoded[358:364] == bytes((3, 4, 24, 96, 65, 10))
|
||||
assert encoded[364:] == bytes(20)
|
||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
|
||||
assert len(profile.button_map) == len(profile.shift.button_map) == len(profile.turbo_modes) == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
("button_map", "south"),
|
||||
("extra_button_map", "c"),
|
||||
("triggers", "left", "output"),
|
||||
("shift", "extra_button_map", "c"),
|
||||
])
|
||||
def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) -> None:
|
||||
obj = config_manager.ControllerProfile.default().to_json_object()
|
||||
target = obj
|
||||
for key in path[:-1]:
|
||||
target = target[key]
|
||||
target[path[-1]] = "right_sr"
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offset,value", [
|
||||
(98, 18 << 2), (256, 18), (266, 24), (344, 1), (358, 1), (363, 1),
|
||||
])
|
||||
def test_schema6_rejects_schema7_controls_in_old_fields(offset: int, value: int) -> None:
|
||||
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||
struct.pack_into("<H", payload, 0, 6)
|
||||
payload[344:] = bytes(40)
|
||||
payload[offset] = value
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offset,value", [
|
||||
(344, 18), (351, 16), (358, 128), (362, 128), (363, 128), (364, 1),
|
||||
])
|
||||
def test_schema7_rejects_extra_map_and_mask_overflow(offset: int, value: int) -> None:
|
||||
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||
payload[offset] = value
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [3, 4])
|
||||
def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None:
|
||||
payload = legacy_profile_wire(version, macro_trigger=3, macro_cancel=2)
|
||||
payload[4] = 17
|
||||
payload[72:75] = bytes((71, 202, 1))
|
||||
struct.pack_into("<H", payload, 76, 1 << 6)
|
||||
struct.pack_into("<H", payload, 98, 1 << 5)
|
||||
if version == 4:
|
||||
payload[75] = 0x21
|
||||
payload[81] = 17
|
||||
profile = config_manager.ControllerProfile.from_bytes(payload)
|
||||
assert profile.button_map[0] == 17
|
||||
assert profile.weak_rumble_scale == 71
|
||||
assert profile.strong_rumble_scale == 202
|
||||
assert profile.confirmation_policy == 1
|
||||
assert profile.switching_chord == (1 << 6) | ((1 << 16) if version == 4 else 0)
|
||||
assert profile.motion_toggle_chord == (1 << 5) | ((1 << 17) if version == 4 else 0)
|
||||
assert profile.macros[0].trigger_mask == 3
|
||||
assert profile.macros[0].cancel_control == (17 if version == 4 else 2)
|
||||
assert profile.extra_button_map == (255,) * 7
|
||||
obj = profile.to_json_object()
|
||||
obj["schema_version"] = version
|
||||
obj["size"] = 256
|
||||
macro = obj.pop("macros")[0]
|
||||
macro.pop("playback")
|
||||
macro.pop("repeat_count")
|
||||
macro["steps"].append(config_manager.MacroStep.end().to_json_object())
|
||||
obj["macro"] = macro
|
||||
for key in ("shortcuts", "shift", "turbo_settings", "extra_button_map"):
|
||||
del obj[key]
|
||||
assert config_manager.ControllerProfile.from_json_object(obj) == profile
|
||||
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "value"),
|
||||
|
|
@ -1963,6 +2088,27 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
|
|||
config_manager.parse_profile_playtest(envelope)
|
||||
|
||||
|
||||
def test_playtest_extra_inputs_and_legacy_firmware_are_distinct() -> None:
|
||||
device = FakeDevice()
|
||||
device.playtest_extra_buttons = 0x55
|
||||
payload, flags = device._profile_playtest_payload()
|
||||
def parse(data: bytes, schema: int) -> config_manager.ProfilePlaytest:
|
||||
return config_manager.parse_profile_playtest(config_manager.parse_response(
|
||||
make_response(config_manager.OP_PROFILE_PLAYTEST, data, flags=flags, schema=schema),
|
||||
config_manager.OP_PROFILE_PLAYTEST,
|
||||
))
|
||||
current = parse(payload, 3)
|
||||
assert current.to_json_object()["extra_buttons"] == ["c", "gr", "left_sr", "right_sr"]
|
||||
assert current.to_json_object()["buttons"] == ["south", "dpad_up", "dpad_right"]
|
||||
legacy = parse(payload[:54], 2)
|
||||
assert legacy == replace(current, extra_buttons=0)
|
||||
assert legacy.to_json_object()["extra_buttons"] == []
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
parse(payload[:54] + b"\x80", 3)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
parse(payload, 2)
|
||||
|
||||
|
||||
def test_profile_reset_and_activate_wait_for_correlated_transactions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -63,35 +63,26 @@ def test_editor_serves_assets_and_complete_schema(
|
|||
) -> None:
|
||||
with running_server(monkeypatch, FakeDevice()) as (base_url, _):
|
||||
with urllib.request.urlopen(f"{base_url}/", timeout=2) as response:
|
||||
page = response.read().decode("utf-8")
|
||||
response.read()
|
||||
assert response.headers["Content-Security-Policy"]
|
||||
with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response:
|
||||
script_size = len(response.read())
|
||||
response.read()
|
||||
assert response.headers["Content-Type"].startswith("text/javascript")
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/assets/controller-switch-pro.svg", timeout=2
|
||||
) as response:
|
||||
artwork_size = len(response.read())
|
||||
response.read()
|
||||
assert response.headers["Content-Type"] == "image/svg+xml"
|
||||
|
||||
status, schema = request_json(f"{base_url}/api/schema")
|
||||
|
||||
assert status == 200
|
||||
assert "Profile Studio" in page
|
||||
assert script_size > 1000
|
||||
assert artwork_size > 10000
|
||||
assert schema["buttons"] == list(config_manager.LOGICAL_BUTTONS)
|
||||
assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS)
|
||||
assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES)
|
||||
assert schema["turbo_modes"] == list(config_manager.TURBO_MODES)
|
||||
assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES)
|
||||
assert schema["profile_capacity"] == 8
|
||||
assert schema["control_labels"]["generic"]["south"] == "A"
|
||||
assert schema["control_labels"]["xbox"]["left_shoulder"] == "LB"
|
||||
assert schema["control_labels"]["switch"]["east"] == "A"
|
||||
assert schema["control_labels"]["switch"]["left_trigger"] == "ZL"
|
||||
assert schema["control_labels"]["playstation"]["south"] == "Cross"
|
||||
assert schema["control_labels"]["playstation"]["select"] == "Create"
|
||||
assert (
|
||||
config_manager.ControllerProfile.from_json(
|
||||
json.dumps(schema["default_profile"])
|
||||
|
|
@ -100,6 +91,75 @@ def test_editor_serves_assets_and_complete_schema(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("product_id", [0x2069, 0x2067, 0x2066])
|
||||
def test_switch2_input_choices_are_never_output_targets(
|
||||
monkeypatch: pytest.MonkeyPatch, product_id: int,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
identity = replace(device.stable_identity, vendor_id=0x057E, product_id=product_id)
|
||||
device.profile_identities = [device.global_identity, identity]
|
||||
device.active_profiles[identity.to_bytes()] = 0
|
||||
with running_server(monkeypatch, device) as (base_url, _):
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
status, schema = request_json(f"{base_url}/api/schema")
|
||||
assert status == 200
|
||||
owner = listing["identities"][1]
|
||||
assert owner["controller"]["style"] == "switch"
|
||||
assert set(config_manager.EXTRA_BUTTONS) <= set(owner["modifier_controls"])
|
||||
assert set(schema["extra_buttons"]).isdisjoint(schema["output_controls"])
|
||||
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
|
||||
|
||||
|
||||
def test_editor_migrates_schema6_and_saves_extra_mappings_without_metadata_loss(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
key = (device.stable_identity.to_bytes(), 1)
|
||||
profile = custom_profile()
|
||||
legacy_wire = bytearray(profile.to_bytes())
|
||||
legacy_wire[:2] = b"\x06\x00"
|
||||
legacy_wire[344:] = bytes(40)
|
||||
device.profiles[key] = bytes(legacy_wire)
|
||||
device.profile_aliases[key[0]] = "Living room"
|
||||
device.profile_names[key] = "Racing"
|
||||
device.playtest_extra_buttons = 0x7F
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, migrated = request_json(f"{base_url}/api/profiles/1/2")
|
||||
assert status == 200
|
||||
assert config_manager.ControllerProfile.from_json_object(migrated["profile"]) == profile
|
||||
draft = migrated["profile"]
|
||||
draft["extra_button_map"] = dict(zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7]))
|
||||
draft["shift"]["mode"] = "hold"
|
||||
draft["shift"]["modifier"] = "c"
|
||||
draft["shift"]["extra_button_map"]["right_sr"] = "dpad_right"
|
||||
draft["macros"][0]["trigger"] = ["gl", "right_sr"]
|
||||
draft["macros"][0]["cancel"] = "gr"
|
||||
draft["switching_chord"] = ["left_sl", "left_sr"]
|
||||
draft["motion_toggle_chord"] = ["right_sl", "c"]
|
||||
status, validated = request_json(
|
||||
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token,
|
||||
)
|
||||
assert status == 200
|
||||
expected_profile = config_manager.ControllerProfile.from_json_object(draft)
|
||||
assert config_manager.ControllerProfile.from_json_object(validated["profile"]) == expected_profile
|
||||
assert device.profiles[key] == bytes(legacy_wire)
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/profiles/1/2", method="PUT", value=draft, token=token,
|
||||
)
|
||||
assert status == 200
|
||||
status, stored = request_json(f"{base_url}/api/profiles/1/2")
|
||||
assert status == 200
|
||||
assert config_manager.ControllerProfile.from_json_object(stored["profile"]) == expected_profile
|
||||
assert stored["alias"] == "Living room"
|
||||
assert stored["name"] == "Racing"
|
||||
assert stored["active"] is True
|
||||
status, sample = request_json(f"{base_url}/api/profiles/1/2/playtest")
|
||||
assert status == 200
|
||||
assert sample["extra_buttons"] == list(config_manager.EXTRA_BUTTONS)
|
||||
assert sample["buttons"] == ["south", "dpad_up", "dpad_right"]
|
||||
|
||||
|
||||
def test_editor_identifies_connected_controller_artwork(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
31
tests/test_switch2_pairing_native.py
Normal file
31
tests/test_switch2_pairing_native.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_switch2_pairing_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("cc") or shutil.which("gcc")
|
||||
assert compiler is not None, "a host C compiler is required"
|
||||
executable = tmp_path / "switch2_pairing"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'tests' / 'switch2_pairing_native_stubs'}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_pairing.c"),
|
||||
str(root / "tests" / "switch2_pairing_test.c"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
57
tests/test_switch2_parser_native.py
Normal file
57
tests/test_switch2_parser_native.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("cc") or shutil.which("gcc")
|
||||
assert compiler is not None, "a host C compiler is required"
|
||||
sdk_candidates = [
|
||||
root / "build" / "_deps" / "pico_sdk-src",
|
||||
root / "external" / "pico-sdk",
|
||||
]
|
||||
if sdk_path := os.environ.get("PICO_SDK_PATH"):
|
||||
sdk_candidates.insert(0, Path(sdk_path))
|
||||
btstack = next(
|
||||
(sdk / "lib" / "btstack" / "src" for sdk in sdk_candidates
|
||||
if (sdk / "lib" / "btstack" / "src" / "ble" / "gatt_client.h").is_file()),
|
||||
None,
|
||||
)
|
||||
if btstack is None:
|
||||
pytest.skip("Pico SDK BTstack headers required; configure firmware or set PICO_SDK_PATH")
|
||||
executable = tmp_path / "switch2_parser_native_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=gnu11",
|
||||
"-O1",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-DENABLE_BLE",
|
||||
"-DENABLE_CLASSIC",
|
||||
f"-I{root / 'tests' / 'switch2_parser_native_stubs'}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
f"-I{root / 'external' / 'bluepad32' / 'src' / 'components' / 'bluepad32' / 'include'}",
|
||||
f"-I{btstack}",
|
||||
f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'encoder' / 'include'}",
|
||||
f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'decoder' / 'include'}",
|
||||
f"-I{btstack.parent / '3rd-party' / 'yxml'}",
|
||||
str(root / "tests" / "switch2_parser_native_test.c"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c"),
|
||||
str(btstack / "btstack_util.c"),
|
||||
"-Wl,--gc-sections",
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -188,6 +188,10 @@ void test_pairing_encoding() {
|
|||
memcmp(&payload[kResponseHeaderSize + 6],
|
||||
classic_address, 6) == 0,
|
||||
"pairings were not migrated into the versioned envelope");
|
||||
snapshot.status = Bluepad32PairingSnapshotStatus::kFailed;
|
||||
require(encode_pairing_snapshot(snapshot, payload, sizeof(payload)) == size &&
|
||||
payload[6] == static_cast<uint8_t>(Status::kStorageError),
|
||||
"failed persistent pairing clear must report storage failure, not success or pending");
|
||||
}
|
||||
|
||||
void perform_out(UsbConfigurationManagement::Operation operation,
|
||||
|
|
@ -511,6 +515,7 @@ void test_profile_vendor_requests() {
|
|||
current_playtest[2].state_generation = 0x55667788;
|
||||
current_playtest[2].identity = expected_identity;
|
||||
current_playtest[2].physical_button_mask = 0x8001;
|
||||
current_playtest[2].state.extra_buttons = 0x7f;
|
||||
current_playtest[2].state.left_stick_x = -1234;
|
||||
current_playtest[2].state.left_stick_y = 2345;
|
||||
current_playtest[2].state.right_stick_x = INT16_MIN;
|
||||
|
|
@ -550,13 +555,15 @@ void test_profile_vendor_requests() {
|
|||
static_cast<int16_t>(read_u16(
|
||||
control_payload, kResponseHeaderSize + 52)) == -6 &&
|
||||
control_payload[kResponseHeaderSize + 39] == 201 &&
|
||||
control_payload[kResponseHeaderSize + 40] == 0x0f,
|
||||
control_payload[kResponseHeaderSize + 40] == 0x0f &&
|
||||
control_payload[kResponseHeaderSize + 54] == 0x7f,
|
||||
"profile playtest response lost live controller state");
|
||||
current_playtest[2].active = false;
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
control_payload[kResponseHeaderSize] == 0 &&
|
||||
control_payload[kResponseHeaderSize + 1] == 0xff,
|
||||
control_payload[kResponseHeaderSize + 1] == 0xff &&
|
||||
control_payload[kResponseHeaderSize + 54] == 0,
|
||||
"disconnected profile playtest was not encoded");
|
||||
current_profile_metadata = {};
|
||||
current_profile_metadata.metadata.state = ProfileServiceState::kReady;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue