fix: reconnect Joy-Con mates and balance mixed Bluetooth links

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 12:04:41 -06:00
commit 8dd11c17a6
15 changed files with 780 additions and 75 deletions

View file

@ -19,11 +19,9 @@ namespace {
bool incoming_connections = false;
int scan_starts = 0;
int scan_stops = 0;
bool scanning_enabled = false;
int classic_scan_starts = 0;
int classic_scan_stops = 0;
bool aggregate_scanning_enabled = false;
bool background_scan_parameters = false;
bool classic_scanning_enabled = false;
uni_platform* installed_platform = nullptr;
bool observed_status_led_on = false;
@ -67,6 +65,11 @@ void require_clear_completion_pending();
void require_clear_snapshot_published();
void request_repeated_clear_during_disconnect();
gap_connection_type_t gap_connection_types[256]{};
uint16_t negotiated_intervals[256]{};
uint16_t pending_intervals[256]{};
unsigned interval_requests[256]{};
bool defer_interval_updates = false;
bool reject_interval_updates = false;
uint8_t xbox_left_trigger = 0;
uint8_t xbox_right_trigger = 0;
unsigned xbox_quad_calls = 0;
@ -127,6 +130,8 @@ uni_hid_device_t device(
result.gamepad = gamepad;
result.conn.protocol = protocol;
result.conn.handle = static_cast<hci_con_handle_t>(0x40 + idx);
negotiated_intervals[result.conn.handle] = 6;
pending_intervals[result.conn.handle] = 0;
result.conn.btaddr[5] = static_cast<uint8_t>(idx + 1);
result.vendor_id = static_cast<uint16_t>(0x1000 + idx);
result.product_id = static_cast<uint16_t>(0x2000 + idx);
@ -179,6 +184,17 @@ extern "C" bool uni_switch2_pairing_get(
return true;
}
extern "C" bool uni_switch2_pairing_known(
uint8_t address_type, const uint8_t address[6]) {
for (uint8_t index = 0; index < switch2_pairing_count; ++index) {
if (switch2_pairing_types[index] == address_type &&
memcmp(switch2_pairings[index], address, sizeof(bd_addr_t)) == 0) {
return true;
}
}
return false;
}
extern "C" bool uni_switch2_pairing_clear(void) {
if (!switch2_clear_succeeds) {
return false;
@ -268,6 +284,15 @@ uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(
}
return nullptr;
}
uni_hid_device_t* uni_hid_device_get_instance_for_address(const bd_addr_t address) {
for (size_t index = 0; index < lookup_device_count; ++index) {
if (memcmp(lookup_devices[index]->conn.btaddr, address, sizeof(bd_addr_t)) == 0) {
return lookup_devices[index];
}
}
return nullptr;
}
void uni_hid_device_disconnect(uni_hid_device_t* device) {
require_clear_completion_pending();
request_repeated_clear_during_disconnect();
@ -281,37 +306,42 @@ void uni_bt_allow_incoming_connections(bool enabled) {
void uni_bt_bredr_scan_start() {
++classic_scan_starts;
classic_scanning_enabled = true;
}
void uni_bt_bredr_scan_stop() {
if (classic_scanning_enabled) {
++classic_scan_stops;
}
classic_scanning_enabled = false;
}
void uni_bt_le_scan_start() {
++scan_starts;
scanning_enabled = true;
}
void uni_bt_le_scan_stop() {
if (scanning_enabled) {
++scan_stops;
}
scanning_enabled = false;
}
void uni_bt_le_set_background_scan(bool enabled) {
require(!scanning_enabled, "LE scan timing must change only while scanning is stopped");
background_scan_parameters = enabled;
}
void uni_bt_start_scanning_and_autoconnect_unsafe() {
require_clear_completion_pending();
require_clear_snapshot_published();
if (aggregate_scanning_enabled) {
return;
}
aggregate_scanning_enabled = true;
uni_bt_bredr_scan_start();
uni_bt_le_scan_start();
}
void uni_bt_stop_scanning_unsafe() {
if (!aggregate_scanning_enabled) {
return;
}
aggregate_scanning_enabled = false;
uni_bt_bredr_scan_stop();
uni_bt_le_scan_stop();
}
@ -331,6 +361,25 @@ gap_connection_type_t gap_get_connection_type(
: GAP_CONNECTION_INVALID;
}
uint16_t gap_le_connection_interval(hci_con_handle_t handle) {
require(handle < 256, "interval read must target a valid fake connection");
return negotiated_intervals[handle];
}
int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t minimum,
uint16_t maximum, uint16_t latency,
uint16_t supervision_timeout) {
require(handle < 256 && gap_get_connection_type(handle) == GAP_CONNECTION_LE,
"interval policy must not update a Classic or invalid link");
require(minimum == maximum && latency == 0 && supervision_timeout >= 100,
"interval request must retain valid supervision and peripheral latency");
++interval_requests[handle];
if (reject_interval_updates) return 0x0c;
if (defer_interval_updates) pending_intervals[handle] = minimum;
else negotiated_intervals[handle] = minimum;
return ERROR_CODE_SUCCESS;
}
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator) {
iterator->index = 0;
return 1;
@ -749,7 +798,7 @@ void start_backend() {
bluepad32_input_backend_init();
platform_on_init_complete();
require(incoming_connections && scanning_enabled &&
classic_scanning_enabled && scan_starts == 1 &&
classic_scanning_enabled &&
link_supervision_timeout ==
kClassicLinkSupervisionTimeout &&
!bondable && accepted_stk_methods == 0 &&
@ -905,6 +954,14 @@ void ready_switch2(uni_hid_device_t& controller) {
"Switch2 physical device must become ready");
}
void remember_switch2(const uni_hid_device_t& controller) {
require(switch2_pairing_count < UNI_SWITCH2_PAIRING_CAPACITY,
"test proprietary trust inventory overflow");
switch2_pairing_types[switch2_pairing_count] = controller.switch2_identity_address_type;
memcpy(switch2_pairings[switch2_pairing_count], controller.conn.btaddr, sizeof(bd_addr_t));
++switch2_pairing_count;
}
Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
Bluepad32SlotSnapshot result{};
bluepad32_input_backend_snapshot(index, &result);
@ -1534,6 +1591,256 @@ void test_switch2_admission() {
"Pro2 must preserve vertical normal controls and ingest remappable extras");
}
void test_switch2_radio_policy() {
start_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto other_ble = device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
ready_switch2(left);
ready_switch2(right);
platform_on_device_connected(&other_ble);
require(platform_on_device_ready(&other_ble) == UNI_ERROR_SUCCESS,
"unrelated BLE controller must join");
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"a pair without Classic must retain fast intervals");
platform_on_device_connected(&classic);
require(negotiated_intervals[left.conn.handle] == 24 &&
negotiated_intervals[right.conn.handle] == 24 &&
negotiated_intervals[other_ble.conn.handle] == 6,
"Classic setup must relax both physical halves before native attachment, not unrelated BLE");
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic controller must complete setup alongside the pair");
const auto pair = slot_snapshot(0);
platform_on_device_disconnected(&classic);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6 &&
slot_snapshot(0).connection_generation == pair.connection_generation,
"Classic departure must restore fast intervals without rebinding the pair");
platform_on_device_connected(&classic);
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic reconnect must complete");
platform_on_device_disconnected(&left);
require(negotiated_intervals[right.conn.handle] == 6,
"a single surviving Switch2 link must return to fast scheduling even with Classic");
left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
ready_switch2(left);
require(negotiated_intervals[left.conn.handle] == 24 &&
negotiated_intervals[right.conn.handle] == 24,
"physical index and handle reuse must negotiate mixed intervals on the new connection");
}
void test_switch2_radio_settling() {
start_backend();
auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
platform_on_device_connected(&classic);
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic-first connection must become ready");
ready_switch2(left);
defer_interval_updates = true;
now_ms = UINT32_MAX - 10;
platform_on_device_connected(&right);
require(interval_requests[right.conn.handle] == 0,
"pending Switch2 setup must retain ownership of its initial interval");
require(platform_on_device_ready(&right) == UNI_ERROR_SUCCESS,
"second Switch2 connection must become ready");
platform_on_device_disconnected(&classic);
// The controller completes the old update after the topology reversed.
for (auto* half : {&left, &right}) {
negotiated_intervals[half->conn.handle] = pending_intervals[half->conn.handle];
}
now_ms += 50;
process_configuration_timer(&g_configuration_timer);
for (auto* half : {&left, &right}) {
negotiated_intervals[half->conn.handle] = pending_intervals[half->conn.handle];
require(negotiated_intervals[half->conn.handle] == 6,
"late mixed-mode completion must not strand a pair at slow intervals across clock wrap");
}
process_configuration_timer(&g_configuration_timer);
defer_interval_updates = false;
reject_interval_updates = true;
platform_on_device_connected(&classic);
const unsigned attempts = interval_requests[left.conn.handle];
now_ms += 999;
process_configuration_timer(&g_configuration_timer);
require(interval_requests[left.conn.handle] == attempts,
"rejected negotiation must not flood the HCI command queue");
reject_interval_updates = false;
++now_ms;
process_configuration_timer(&g_configuration_timer);
require(negotiated_intervals[left.conn.handle] == 24 &&
negotiated_intervals[right.conn.handle] == 24,
"transiently rejected negotiation must recover without reconnect or pairing");
}
void test_switch2_mate_reconnect() {
start_backend();
auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID);
auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID);
left.conn.btaddr[0] = 0xc1;
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
remember_switch2(right);
remember_switch2(left);
register_lookup_device(&right);
register_lookup_device(&left);
ready_switch2(right);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled && incoming_connections &&
!bondable && accepted_stk_methods == 0 &&
!switch_pico_switch2_pairing_allowed(),
"first remembered half must seek its mate with low-duty BLE and authentication closed");
dispatch_pairing_event(HCI_EVENT_USER_CONFIRMATION_REQUEST);
dispatch_pairing_event(HCI_EVENT_USER_PASSKEY_REQUEST);
require(confirmation_accepts == 0 && passkey_accepts == 0 &&
confirmation_rejections == 1 && passkey_rejections == 1,
"mate scanning must not authorize new Classic authentication");
bd_addr_t missing = {1, 2, 3, 4, 5, 6};
require(platform_on_device_discovered(missing, "Joy-Con 2 (L)", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"a device name without a prepared parser candidate cannot authorize passive discovery");
const auto reject_left = [&]() {
require(platform_on_device_discovered(left.conn.btaddr, "Joy-Con 2 (L)", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE &&
scanning_enabled && !classic_scanning_enabled &&
!switch_pico_switch2_pairing_allowed(),
"rejected mate candidates must leave the bounded passive policy unchanged");
};
++left.conn.btaddr[5];
reject_left();
--left.conn.btaddr[5];
left.product_id = UNI_SW2_JOYCON_R_PID;
reject_left();
left.product_id = UNI_SW2_PRO_PID;
reject_left();
left.product_id = UNI_SW2_JOYCON_L_PID;
left.conn.protocol = UNI_BT_CONN_PROTOCOL_BR_EDR;
reject_left();
left.conn.protocol = UNI_BT_CONN_PROTOCOL_BLE;
++left.vendor_id;
reject_left();
--left.vendor_id;
left.switch2_identity_valid = false;
reject_left();
left.switch2_identity_valid = true;
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_PUBLIC;
reject_left();
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
require(platform_on_device_discovered(left.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_SUCCESS,
"remembered opposite static-random half must be admitted outside the pairing window");
// The parser stops GAP scan before it has a backend slot to reserve.
uni_bt_le_scan_stop();
platform_on_device_connected(&left);
require(!scanning_enabled && !classic_scanning_enabled,
"remembered mate setup must pause scan even when GAP already stopped it");
platform_on_device_disconnected(&left);
require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled,
"failed setup must resume remembered mate scanning");
uni_bt_le_scan_stop();
platform_on_device_connected(&left);
require(!scanning_enabled && !classic_scanning_enabled && incoming_connections,
"mate setup must pause background scanning while reserving physical capacity");
require(platform_on_device_ready(&left) == UNI_ERROR_SUCCESS &&
slot_snapshot(0).active && !slot_snapshot(1).active &&
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left)) &&
!scanning_enabled && !classic_scanning_enabled,
"ready remembered mate must merge without a pairing window and stop background scanning");
auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&ordinary);
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS &&
!scanning_enabled && !classic_scanning_enabled,
"a complete pair plus incoming Classic controller must not keep LE scanning");
platform_on_device_disconnected(&left);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled && slot_snapshot(2).active,
"losing a half must resume its mate scan without disturbing an ordinary controller");
}
void test_switch2_mate_pending() {
start_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(3, UNI_SW2_JOYCON_R_PID);
remember_switch2(left);
remember_switch2(right);
register_lookup_device(&right);
ready_switch2(left);
auto first = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto second = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto third = device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&first);
platform_on_device_connected(&second);
require(!scanning_enabled && !classic_scanning_enabled && incoming_connections &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"any pending setup must pause scanning and reject otherwise valid mates");
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
!scanning_enabled,
"one resolved setup must not resume mate scanning while another is pending");
platform_on_device_disconnected(&second);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_SUCCESS,
"last pending setup failure must resume passive mate discovery and admission");
platform_on_device_connected(&second);
require(!scanning_enabled &&
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS &&
scanning_enabled && !classic_scanning_enabled,
"last pending setup becoming ready must resume the unmatched half scan");
platform_on_device_connected(&third);
require(platform_on_device_ready(&third) == UNI_ERROR_SUCCESS &&
!scanning_enabled && !classic_scanning_enabled && !incoming_connections &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"four physical controllers must stop mate scanning even with an unmatched half");
platform_on_device_disconnected(&third);
require(scanning_enabled && background_scan_parameters && incoming_connections,
"free physical capacity must restore the waiting half scan");
switch2_clear_succeeds = false;
bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
require(!scanning_enabled && !classic_scanning_enabled && !incoming_connections,
"failed trust deletion must explicitly stop a direct background LE scan");
platform_on_device_disconnected(&right);
platform_on_device_disconnected(&left);
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(!scanning_enabled && !classic_scanning_enabled && !incoming_connections &&
!switch_pico_switch2_pairing_allowed() &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"late disconnects and pairing requests must not clear the failed-closed latch");
}
void test_switch2_mate_pairing_window() {
start_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
ready_switch2(left);
require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled,
"solo half must enter background discovery before opening pairing");
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(scanning_enabled && !background_scan_parameters && classic_scanning_enabled &&
bondable && accepted_stk_methods == kAllBlePairingMethods,
"explicit pairing must restore full discovery timing and the existing authentication window");
now_ms = g_pairing_window_deadline_ms;
process_rumble_timer(&g_rumble_timer);
require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled &&
!bondable && accepted_stk_methods == 0 &&
!switch_pico_switch2_pairing_allowed(),
"pairing expiry must restore low-duty mate discovery without extending authentication");
platform_on_device_disconnected(&left);
require(scanning_enabled && !background_scan_parameters && classic_scanning_enabled &&
!bondable && accepted_stk_methods == 0,
"last controller loss must restore idle discovery defaults without opening pairing");
}
void test_switch2_pairing_inventory() {
start_pairing_backend();
switch2_pairing_count = 2;
@ -1630,23 +1937,21 @@ void test_ready_order(bool reverse) {
}
if (position + 1 < kSlotCount) {
require(scan_stops == 0,
"scanning must continue while any slot remains free");
require(scanning_enabled && classic_scanning_enabled,
"pairing-window discovery must continue while a physical slot remains free");
require(incoming_connections,
"incoming connections must remain enabled before all slots are ready");
}
}
require(scan_stops == 1,
"scanning must stop exactly when all four slots are ready");
require(!scanning_enabled && !classic_scanning_enabled,
"discovery must stop when all four physical slots are full");
require(!incoming_connections,
"incoming connections must be disabled only when all slots are full");
for (int slot = 0; slot < kSlotCount; ++slot) {
const int starts_before_disconnect = scan_starts;
platform_on_device_disconnected(&devices[slot]);
require(scan_starts == starts_before_disconnect + 1 &&
scanning_enabled && incoming_connections,
require(scanning_enabled && classic_scanning_enabled && incoming_connections,
"disconnecting any slot must resume connection policy");
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
@ -1768,12 +2073,6 @@ void test_independent_lifecycle() {
require(g_slots[0].device == &aborted && !g_slots[0].active,
"connected device must remain identifiable while becoming ready");
const int starts_before_aborted_disconnect = scan_starts;
const int classic_starts_before_aborted_disconnect =
classic_scan_starts;
const int stops_before_aborted_disconnect = scan_stops;
const int classic_stops_before_aborted_disconnect =
classic_scan_stops;
platform_on_device_disconnected(&aborted);
require(g_slots[0].device == nullptr && !g_slots[0].active,
"pre-ready disconnect must clear its pending slot identity");
@ -1781,13 +2080,7 @@ void test_independent_lifecycle() {
"pre-ready disconnect must invalidate its connection generation");
require(g_connection_status == ConnectionStatus::Scanning &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections &&
scan_starts == starts_before_aborted_disconnect + 1 &&
classic_scan_starts ==
classic_starts_before_aborted_disconnect + 1 &&
scan_stops == stops_before_aborted_disconnect + 1 &&
classic_scan_stops ==
classic_stops_before_aborted_disconnect + 1,
incoming_connections,
"pre-ready disconnect must restart Classic and BLE scans");
uni_hid_device_t devices[kSlotCount] = {
@ -1850,7 +2143,6 @@ void test_independent_lifecycle() {
const uint32_t first_pending_generation =
g_slots[0].connection_generation;
const int starts_before_first_pending_disconnect = scan_starts;
platform_on_device_disconnected(&devices[0]);
require(g_slots[0].device == nullptr && !g_slots[0].active,
"pre-ready disconnect must clear only its own pending identity");
@ -1864,8 +2156,7 @@ void test_independent_lifecycle() {
"pending disconnect beside peers must invalidate its generation");
require(g_connection_status == ConnectionStatus::Connecting &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections &&
scan_starts == starts_before_first_pending_disconnect + 1,
incoming_connections,
"open pairing slot must preserve pending peers and resume scanning");
for (int slot = 1; slot < kSlotCount; ++slot) {
@ -2025,10 +2316,8 @@ void test_independent_lifecycle() {
bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{55, 66});
const uint32_t disconnected_generation =
baseline_connection_generations[3];
const int starts_before_slot_three_disconnect = scan_starts;
platform_on_device_disconnected(&devices[3]);
require(scan_starts == starts_before_slot_three_disconnect + 1 &&
scanning_enabled && incoming_connections,
require(scanning_enabled && classic_scanning_enabled && incoming_connections,
"slot 3 disconnect must resume scanning and incoming connections");
require(!read_controller_state(3, &states[3]) &&
!states[3].button_north && states[3].left_stick_x == 0,
@ -2162,10 +2451,8 @@ void test_independent_lifecycle() {
uni_hid_device_t replacements[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (int slot = 0; slot < 3; ++slot) {
const int starts_before_disconnect = scan_starts;
platform_on_device_disconnected(&devices[slot]);
require(scan_starts == starts_before_disconnect + 1 &&
scanning_enabled && incoming_connections,
require(scanning_enabled && classic_scanning_enabled && incoming_connections,
"disconnecting slots 0-2 must resume connection policy");
require(!read_controller_state(slot, &states[slot]) &&
states[slot].left_stick_x == 0,
@ -3369,14 +3656,14 @@ void test_wake_identity_gates_connections() {
require(switch2_wake_initializations == 1 &&
g_connection_policy_state ==
ConnectionPolicyState::Uninitialized &&
scan_starts == 0 && classic_scan_starts == 0 &&
!scanning_enabled && !classic_scanning_enabled &&
!incoming_connections,
"controller discovery started before wake identity was ready");
switch2_connections_ready = true;
process_rumble_timer(&g_rumble_timer);
require(g_connection_policy_state == ConnectionPolicyState::Open &&
scan_starts == 1 && classic_scan_starts == 1 &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections,
"controller discovery did not start after wake identity setup");
}
@ -3677,6 +3964,16 @@ int main(int argc, char** argv) {
test_switch2_multiple_pairs();
} else if (scenario == "switch2-admission") {
test_switch2_admission();
} else if (scenario == "switch2-radio-policy") {
test_switch2_radio_policy();
} else if (scenario == "switch2-radio-settling") {
test_switch2_radio_settling();
} else if (scenario == "switch2-mate-reconnect") {
test_switch2_mate_reconnect();
} else if (scenario == "switch2-mate-pending") {
test_switch2_mate_pending();
} else if (scenario == "switch2-mate-pairing-window") {
test_switch2_mate_pairing_window();
} else if (scenario == "switch2-pairing-inventory") {
test_switch2_pairing_inventory();
} else if (scenario == "ready-forward") {

View file

@ -34,6 +34,7 @@ struct btstack_link_key_iterator_t {
enum {
ERROR_CODE_SUCCESS = 0,
HCI_CON_HANDLE_INVALID = 0xffff,
HCI_EVENT_PACKET = 4,
HCI_EVENT_USER_CONFIRMATION_REQUEST = 0x33,
HCI_EVENT_USER_PASSKEY_REQUEST = 0x34,
@ -198,6 +199,7 @@ int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device);
void uni_hid_device_disconnect(uni_hid_device_t* device);
uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(
hci_con_handle_t handle);
uni_hid_device_t* uni_hid_device_get_instance_for_address(const bd_addr_t address);
void uni_bt_allow_incoming_connections(bool enabled);
void uni_bt_start_scanning_and_autoconnect_unsafe();
void uni_bt_stop_scanning_unsafe();
@ -205,9 +207,14 @@ void uni_bt_bredr_scan_start();
void uni_bt_bredr_scan_stop();
void uni_bt_le_scan_start();
void uni_bt_le_scan_stop();
void uni_bt_le_set_background_scan(bool enabled);
void uni_bt_del_keys_unsafe();
gap_connection_type_t gap_get_connection_type(
hci_con_handle_t connection_handle);
uint16_t gap_le_connection_interval(hci_con_handle_t handle);
int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t minimum,
uint16_t maximum, uint16_t latency,
uint16_t supervision_timeout);
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator);
int gap_link_key_iterator_get_next(
btstack_link_key_iterator_t* iterator, bd_addr_t address,

View file

@ -5,8 +5,9 @@
#include "protocol_fixture.h"
#include "parser/uni_hid_parser_switch2.h"
#include "parser/uni_switch2_pairing.h"
#include "sdkconfig.h"
#define PEERS 4
#define PEERS CONFIG_BLUEPAD32_MAX_DEVICES
#define SERVICE_START 0x100
#define INPUT_HANDLE 0x104
#define RESPONSE_HANDLE 0x114
@ -17,9 +18,14 @@ 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 unsigned scan_stops, discovery_calls;
static uint16_t expected_discovery_pid;
static uint8_t expected_discovery_address_type;
static uint32_t now_ms;
static bool pairing_allowed, trusted, storage_ok, request_writes, admit, reject_connected;
static uint8_t next_write_error;
static uint8_t next_connect_error;
static bool scan_enabled, scan_running;
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};
@ -87,8 +93,14 @@ bool uni_switch2_pairing_remember(uint8_t type, const uint8_t address[6]) {
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; }
void gap_stop_scan(void) { ++scan_stops; scan_running = false; }
void uni_bt_le_resume_scanning_if_enabled(void) { if (scan_enabled) scan_running = true; }
uint8_t gap_connect(const bd_addr_t address, bd_addr_type_t type) {
(void)address; (void)type; ++connected;
uint8_t status = next_connect_error;
next_connect_error = 0;
return status;
}
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;
}
@ -121,7 +133,17 @@ uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(hci_con_hand
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;
++discovery_calls;
uni_hid_device_t* d = uni_hid_device_get_instance_for_address(address);
uint8_t type = 0xff;
assert(d && uni_hid_parser_switch2_is_ble_device(d));
assert(uni_hid_parser_switch2_identity_address_type(d, &type));
(void)name;
assert(d->cod == cod && d->conn.rssi == rssi);
if (expected_discovery_pid) {
assert(d->product_id == expected_discovery_pid);
assert(type == expected_discovery_address_type);
}
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; }
@ -419,8 +441,13 @@ static void reset(void) {
memset(peers, 0, sizeof(peers));
connected = ready = disconnected = emitted = remembered = 0;
connected_events = disconnected_events = 0;
scan_stops = discovery_calls = 0;
expected_discovery_pid = 0;
expected_discovery_address_type = BD_ADDR_TYPE_LE_PUBLIC;
now_ms = 0;
next_write_error = 0;
next_connect_error = 0;
scan_enabled = scan_running = true;
pairing_allowed = trusted = storage_ok = admit = true;
request_writes = false;
reject_connected = false;
@ -457,6 +484,7 @@ static void test_advertisement_bounds_and_admission(void) {
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);
assert(discovery_calls == 0 && scan_stops == 0);
advertisement(packet, UNI_SW2_PRO_PID, false);
admit = false;
assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0);
@ -466,6 +494,62 @@ static void test_advertisement_bounds_and_admission(void) {
assert(uni_hid_parser_switch2_identity_address_type(&peers[0].device, &type) && type == BD_ADDR_TYPE_LE_PUBLIC);
}
static void test_discovery_metadata_and_rejection_isolation(void) {
reset();
expected_discovery_pid = UNI_SW2_JOYCON_L_PID;
struct fixture_peer* mate = connect_peer(UNI_SW2_JOYCON_L_PID, false);
discover(mate);
subscribe_response(mate);
finish_setup(mate);
assert(ready == 1 && discovery_calls == 1);
const unsigned mate_timers = timer_count;
const unsigned mate_listeners = listeners;
uint8_t packet[64];
size_t size = advertisement(packet, UNI_SW2_JOYCON_R_PID, false);
packet[3] = BD_ADDR_TYPE_LE_RANDOM;
packet[4] ^= 1; // A different static identity from the active mate.
bd_addr_t candidate_address;
gap_event_advertising_report_get_address(packet, candidate_address);
expected_discovery_pid = UNI_SW2_JOYCON_R_PID;
expected_discovery_address_type = BD_ADDR_TYPE_LE_RANDOM;
admit = false;
// More rejections than slots must not exhaust either device or parser storage.
for (unsigned i = 0; i <= CONFIG_BLUEPAD32_MAX_DEVICES; ++i) {
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
assert(discovery_calls == i + 2);
assert(!uni_hid_device_get_instance_for_address(candidate_address));
for (unsigned slot = 1; slot < PEERS; ++slot) {
uint8_t type;
assert(!peers[slot].used);
assert(!uni_hid_parser_switch2_identity_address_type(&peers[slot].device, &type));
}
assert(connected == 1 && scan_stops == 1 && disconnected == 0);
assert(timer_count == mate_timers && listeners == mate_listeners);
assert(mate->used && mate->link_alive && mate->device.conn.state == UNI_BT_CONN_STATE_DEVICE_READY);
}
// A rejected candidate must remain discoverable once policy permits it.
admit = true;
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
assert(uni_hid_device_get_instance_for_address(candidate_address));
assert(connected == 2 && scan_stops == 2 && disconnected == 0);
unsigned admitted_calls = discovery_calls;
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
assert(discovery_calls == admitted_calls && connected == 2 && scan_stops == 2);
assert(mate->used && mate->link_alive);
reset();
size = advertisement(packet, UNI_SW2_JOYCON_R_PID, false);
admit = false;
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
advance(3000);
assert(discovery_calls == 1 && connected == 0 && scan_stops == 0 && disconnected == 0);
assert(timer_count == 0 && listeners == 0);
for (unsigned slot = 0; slot < PEERS; ++slot)
assert(!peers[slot].used);
}
static void test_missing_descriptor_and_setup_timeout(void) {
reset();
struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, false);
@ -1024,9 +1108,52 @@ static void test_expired_history_does_not_starve_fresh_sequences(void) {
assert(uni_hid_parser_switch2_haptics_dropped() == drops + 1);
}
static void test_gatt_busy_retries_without_disconnect_or_sequence_loss(void) {
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
uni_switch2_haptics_frame_t frame = native_frame(3, 2, 24);
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
next_write_error = GATT_CLIENT_BUSY;
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
advance(1);
assert(!disconnected && peer->rumbles == 0);
advance(13);
assert(!disconnected && peer->rumbles == 1);
assert((peer->rumble[1] & 15) == 0);
assert_block(peer, 0, &frame.sides[0]);
assert_block(peer, 1, &frame.sides[1]);
// The SDK uses the same busy status on command writes such as player LEDs.
next_write_error = GATT_CLIENT_BUSY;
uni_hid_parser_switch2_set_player_leds(&peer->device, 3);
assert(!disconnected);
advance(13);
assert(peer->command[0] == 0x09 && peer->command[8] == 3);
acknowledge(peer, false);
assert(!disconnected && uni_hid_parser_switch2_haptics_dropped() == drops);
}
static void test_immediate_connection_failure_preserves_reconnect_discovery(void) {
reset();
uint8_t packet[64];
size_t size = advertisement(packet, UNI_SW2_JOYCON_R_PID, false);
next_connect_error = ERROR_CODE_COMMAND_DISALLOWED;
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
assert(scan_running && !peers[0].used && !timer_count && !listeners);
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
assert(peers[0].used && connected == 2); // The same remembered peer can retry.
reset();
scan_enabled = scan_running = false;
next_connect_error = ERROR_CODE_COMMAND_DISALLOWED;
assert(uni_bt_le_switch2_handle_advertisement(packet, size));
assert(!scan_running && !peers[0].used); // Explicit stop must not be overridden.
}
int main(void) {
test_connected_callback_rejection();
test_advertisement_bounds_and_admission();
test_discovery_metadata_and_rejection_isolation();
test_immediate_connection_failure_preserves_reconnect_discovery();
test_missing_descriptor_and_setup_timeout();
test_pairing_gate_and_write_ack_order();
test_calibration_physical_inputs_and_sensor_units();
@ -1043,6 +1170,7 @@ int main(void) {
test_identical_hold_coalescing_refreshes_borrowed_head();
test_hold_coalescing_requires_identical_samples_and_side_masks();
test_expired_history_does_not_starve_fresh_sequences();
test_gatt_busy_retries_without_disconnect_or_sequence_loss();
reset();
puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed");
return 0;

View file

@ -98,6 +98,11 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"switch2-reverse",
"switch2-multiple-pairs",
"switch2-admission",
"switch2-radio-policy",
"switch2-radio-settling",
"switch2-mate-reconnect",
"switch2-mate-pending",
"switch2-mate-pairing-window",
"switch2-pairing-inventory",
"switch2-hd-pro",
"switch2-hd-solo-left",

View file

@ -3,10 +3,15 @@ from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from prepare_bluepad32 import prepare_bluepad32
def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
@ -25,6 +30,12 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
)
if btstack is None:
pytest.skip("Pico SDK BTstack headers required; configure firmware or set PICO_SDK_PATH")
prepared = prepare_bluepad32(
root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
tmp_path / "bluepad32-src",
)
component = prepared / "src" / "components" / "bluepad32"
executable = tmp_path / "switch2_parser_native_test"
subprocess.run(
[
@ -39,7 +50,7 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
"-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{component / 'include'}",
f"-I{btstack}",
f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'encoder' / 'include'}",
f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'decoder' / 'include'}",