feat: support Switch 2 controllers and expanded profiles

This commit is contained in:
Joey Yakimowich-Payne 2026-09-06 23:17:26 -06:00
commit a87917bf0b
51 changed files with 4371 additions and 365 deletions

View file

@ -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);

View file

@ -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(

View file

@ -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,
};

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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"]);

View file

@ -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;

View file

@ -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;
}

View 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**);

View 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;
}

View 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;
};

View 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;
}

View file

@ -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;

View file

@ -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(

View file

@ -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:

View file

@ -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:

View 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)

View 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)

View file

@ -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;