From fac919a7981864d68ac10c28bc7570d0078daead Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 30 Aug 2026 23:23:29 -0600 Subject: [PATCH] Scale AIO transport to four controllers --- CMakeLists.txt | 2 +- bluepad32_config/btstack_config.h | 8 +- bluepad32_config/sdkconfig.h | 4 +- bluepad32_input_backend.cpp | 2 +- bluepad32_input_backend.h | 2 +- switch_pro_descriptors.h | 82 ++- tests/bluepad32_backend_lifecycle_test.cpp | 495 +++++++++++------- tests/switch_pro_descriptors_test.cpp | 15 + tests/switch_pro_driver_context_test.cpp | 300 ++++++----- ...test_bluepad32_backend_lifecycle_native.py | 9 +- tests/test_switch_pro_descriptors_native.py | 30 +- .../test_switch_pro_driver_context_native.py | 4 +- tusb_config.h | 6 +- 13 files changed, 617 insertions(+), 342 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3580c8f..7ef93ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,7 +93,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") target_sources(switch-pico PRIVATE bluepad32_input_backend.cpp) target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_BLUEPAD32=1 - SWITCH_PICO_HID_INSTANCE_COUNT=2 + SWITCH_PICO_HID_INSTANCE_COUNT=4 ) else() target_compile_definitions(switch-pico PRIVATE diff --git a/bluepad32_config/btstack_config.h b/bluepad32_config/btstack_config.h index db9f2eb..c2880e2 100644 --- a/bluepad32_config/btstack_config.h +++ b/bluepad32_config/btstack_config.h @@ -40,12 +40,12 @@ #define MAX_NR_BNEP_CHANNELS 1 #define MAX_NR_BNEP_SERVICES 1 #define MAX_NR_BTSTACK_LINK_KEY_DB_MEMORY_ENTRIES 2 -#define MAX_NR_GATT_CLIENTS 2 +#define MAX_NR_GATT_CLIENTS 4 #define MAX_NR_HCI_CONNECTIONS 4 -#define MAX_NR_HID_HOST_CONNECTIONS 2 -#define MAX_NR_HIDS_CLIENTS 2 +#define MAX_NR_HID_HOST_CONNECTIONS 4 +#define MAX_NR_HIDS_CLIENTS 4 #define MAX_NR_HFP_CONNECTIONS 1 -#define MAX_NR_L2CAP_CHANNELS 6 +#define MAX_NR_L2CAP_CHANNELS 10 #define MAX_NR_L2CAP_SERVICES 5 #define MAX_NR_RFCOMM_CHANNELS 1 #define MAX_NR_RFCOMM_MULTIPLEXERS 1 diff --git a/bluepad32_config/sdkconfig.h b/bluepad32_config/sdkconfig.h index 1269667..90866ec 100644 --- a/bluepad32_config/sdkconfig.h +++ b/bluepad32_config/sdkconfig.h @@ -1,8 +1,8 @@ #pragma once // The AIO firmware exposes one fixed Bluepad32 device slot per USB interface. -#define CONFIG_BLUEPAD32_MAX_DEVICES 2 -#define CONFIG_BLUEPAD32_MAX_ALLOWLIST 2 +#define CONFIG_BLUEPAD32_MAX_DEVICES 4 +#define CONFIG_BLUEPAD32_MAX_ALLOWLIST 4 #define CONFIG_BLUEPAD32_GAP_SECURITY 1 #define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT 1 diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index e641264..e515da2 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -21,7 +21,7 @@ constexpr uint16_t kRumbleDurationMs = 50; constexpr uint32_t kRumblePollIntervalMs = 5; constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; -static_assert(kSlotCount == 2); +static_assert(kSlotCount == 4); static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount); enum class ConnectionStatus { diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index 16654d1..16b7e00 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -5,7 +5,7 @@ #include "switch_haptics.h" #include "switch_pro_driver.h" -constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 2; +constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4; void bluepad32_input_backend_init(); void bluepad32_input_backend_start(); diff --git a/switch_pro_descriptors.h b/switch_pro_descriptors.h index 8033ed7..fd0d1a2 100644 --- a/switch_pro_descriptors.h +++ b/switch_pro_descriptors.h @@ -12,8 +12,8 @@ #define SWITCH_PICO_HID_INSTANCE_COUNT 1 #endif -#if SWITCH_PICO_HID_INSTANCE_COUNT != 1 && SWITCH_PICO_HID_INSTANCE_COUNT != 2 -#error "SWITCH_PICO_HID_INSTANCE_COUNT must be 1 or 2" +#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4 +#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4" #endif @@ -380,9 +380,15 @@ static const uint8_t switch_pro_configuration_descriptor[] = #if SWITCH_PICO_HID_INSTANCE_COUNT == 1 0x29, 0x00, // wTotalLength 41 0x01, // bNumInterfaces 1 -#else +#elif SWITCH_PICO_HID_INSTANCE_COUNT == 2 0x49, 0x00, // wTotalLength 73 0x02, // bNumInterfaces 2 +#elif SWITCH_PICO_HID_INSTANCE_COUNT == 3 + 0x69, 0x00, // wTotalLength 105 + 0x03, // bNumInterfaces 3 +#else + 0x89, 0x00, // wTotalLength 137 + 0x04, // bNumInterfaces 4 #endif 0x01, // bConfigurationValue 0x00, // iConfiguration (String Index) @@ -421,7 +427,7 @@ static const uint8_t switch_pro_configuration_descriptor[] = 0x40, 0x00, // wMaxPacketSize 64 0x08, // bInterval 8 (unit depends on device speed) -#if SWITCH_PICO_HID_INSTANCE_COUNT == 2 +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 2 0x09, // bLength 0x04, // bDescriptorType (Interface) 0x01, // bInterfaceNumber 1 @@ -454,6 +460,74 @@ static const uint8_t switch_pro_configuration_descriptor[] = 0x40, 0x00, // wMaxPacketSize 64 0x08, // bInterval 8 (unit depends on device speed) #endif + +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 3 + 0x09, // bLength + 0x04, // bDescriptorType (Interface) + 0x02, // bInterfaceNumber 2 + 0x00, // bAlternateSetting + 0x02, // bNumEndpoints 2 + 0x03, // bInterfaceClass + 0x00, // bInterfaceSubClass + 0x00, // bInterfaceProtocol + 0x00, // iInterface (String Index) + + 0x09, // bLength + 0x21, // bDescriptorType (HID) + 0x11, 0x01, // bcdHID 1.11 + 0x00, // bCountryCode + 0x01, // bNumDescriptors + 0x22, // bDescriptorType[0] (HID) + 0xCB, 0x00, // wDescriptorLength[0] 203 + + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x83, // bEndpointAddress (IN/D2H) + 0x03, // bmAttributes (Interrupt) + 0x40, 0x00, // wMaxPacketSize 64 + 0x08, // bInterval 8 (unit depends on device speed) + + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x03, // bEndpointAddress (OUT/H2D) + 0x03, // bmAttributes (Interrupt) + 0x40, 0x00, // wMaxPacketSize 64 + 0x08, // bInterval 8 (unit depends on device speed) +#endif + +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 4 + 0x09, // bLength + 0x04, // bDescriptorType (Interface) + 0x03, // bInterfaceNumber 3 + 0x00, // bAlternateSetting + 0x02, // bNumEndpoints 2 + 0x03, // bInterfaceClass + 0x00, // bInterfaceSubClass + 0x00, // bInterfaceProtocol + 0x00, // iInterface (String Index) + + 0x09, // bLength + 0x21, // bDescriptorType (HID) + 0x11, 0x01, // bcdHID 1.11 + 0x00, // bCountryCode + 0x01, // bNumDescriptors + 0x22, // bDescriptorType[0] (HID) + 0xCB, 0x00, // wDescriptorLength[0] 203 + + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x84, // bEndpointAddress (IN/D2H) + 0x03, // bmAttributes (Interrupt) + 0x40, 0x00, // wMaxPacketSize 64 + 0x08, // bInterval 8 (unit depends on device speed) + + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x04, // bEndpointAddress (OUT/H2D) + 0x03, // bmAttributes (Interrupt) + 0x40, 0x00, // wMaxPacketSize 64 + 0x08, // bInterval 8 (unit depends on device speed) +#endif }; static const uint8_t switch_pro_report_descriptor[] = diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index eee98b7..d28cc88 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -97,10 +97,16 @@ void tick_backend_timer(int ticks) { } } -void test_ready_order(int first_slot) { +void test_ready_order(bool reverse) { start_backend(); - uni_hid_device_t devices[2] = {device(0), device(1)}; - const int second_slot = 1 - first_slot; + uni_hid_device_t devices[kSlotCount] = { + device(0), device(1), device(2), device(3)}; + uni_hid_device_t replacements[kSlotCount] = { + device(0), device(1), device(2), device(3)}; + const int forward[kSlotCount] = {0, 1, 2, 3}; + const int backward[kSlotCount] = {3, 2, 1, 0}; + const int* order = reverse ? backward : forward; + tick_backend_timer(99); require(observed_status_led_on, "scanning LED must stay on for the first slow-blink half-cycle"); @@ -108,7 +114,7 @@ void test_ready_order(int first_slot) { require(!observed_status_led_on, "scanning LED must turn off at the slow-blink half-cycle"); - platform_on_device_connected(&devices[first_slot]); + platform_on_device_connected(&devices[order[0]]); tick_backend_timer(19); require(observed_status_led_on, "connecting LED must stay on for the first fast-blink half-cycle"); @@ -119,53 +125,83 @@ void test_ready_order(int first_slot) { require(observed_status_led_on, "connecting LED must turn on for the next fast-blink cycle"); - require(platform_on_device_ready(&devices[first_slot]) == - UNI_ERROR_SUCCESS, - "first ready device must bind to its Bluepad index"); - require(scan_stops == 0, - "scanning must continue while one slot remains free"); - require(incoming_connections, - "incoming connections must remain enabled with one ready slot"); - tick_backend_timer(99); - require(observed_status_led_on, - "one ready slot must leave the LED in the slow scanning cycle"); - tick_backend_timer(1); - require(!observed_status_led_on, - "one open slot must produce the scanning LED off transition"); + for (int position = 0; position < kSlotCount; ++position) { + const int slot = order[position]; + require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS, + "ready device must bind to its Bluepad index"); - SwitchInputState first{}; - SwitchInputState second{}; - require(bluepad32_input_backend_snapshot(first_slot, &first), - "first ready slot must be active"); - require(!bluepad32_input_backend_snapshot(second_slot, &second), - "other slot must remain independently inactive"); + for (int candidate = 0; candidate < kSlotCount; ++candidate) { + SwitchInputState snapshot{}; + bool expected_active = false; + for (int ready = 0; ready <= position; ++ready) { + expected_active = expected_active || order[ready] == candidate; + } + require(bluepad32_input_backend_snapshot(candidate, &snapshot) == + expected_active, + "only ready indexed slots may become active"); + } + + if (position + 1 < kSlotCount) { + require(scan_stops == 0, + "scanning must continue while any slot remains free"); + require(incoming_connections, + "incoming connections must remain enabled before all slots are ready"); + if (position == 0) { + tick_backend_timer(99); + require(observed_status_led_on, + "a partially full backend must use the scanning LED on half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "a partially full backend must use the scanning LED off half-cycle"); + } + } + } - require(platform_on_device_ready(&devices[second_slot]) == - UNI_ERROR_SUCCESS, - "second ready device must bind to its Bluepad index"); require(scan_stops == 1, - "scanning must stop exactly when both slots are ready"); + "scanning must stop exactly when all four slots are ready"); require(!incoming_connections, - "incoming connections must be disabled only when full"); + "incoming connections must be disabled only when all slots are full"); tick_backend_timer(1); require(observed_status_led_on, - "both ready slots must turn the status LED on"); + "four ready slots must turn the status LED on"); const int ready_led_writes = observed_status_led_writes; tick_backend_timer(200); require(observed_status_led_on && observed_status_led_writes == ready_led_writes, - "both ready slots must keep the status LED solid"); + "four ready slots must keep the status LED solid"); + + 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, + "disconnecting any slot must resume connection policy"); + + for (int candidate = 0; candidate < kSlotCount; ++candidate) { + SwitchInputState snapshot{}; + require(bluepad32_input_backend_snapshot(candidate, &snapshot) == + (candidate != slot), + "disconnect must preserve every surviving slot"); + } + + require(platform_on_device_ready(&replacements[slot]) == + UNI_ERROR_SUCCESS, + "replacement must bind to each freed indexed slot"); + require(!scanning_enabled && !incoming_connections, + "restoring four ready slots must stop connection policy"); + devices[slot] = replacements[slot]; + } bd_addr_t address{}; require(platform_on_device_discovered(address, "extra", 0, 0) == UNI_ERROR_IGNORE_DEVICE, - "discovery must reject devices while both slots are occupied"); + "discovery must reject devices while all four slots are occupied"); } void test_rejections() { start_backend(); uni_hid_device_t non_gamepad = device(0, false); - uni_hid_device_t out_of_range = device(2); + uni_hid_device_t out_of_range = device(4); uni_hid_device_t slot_zero = device(0); uni_hid_device_t collision = device(0); @@ -173,21 +209,39 @@ void test_rejections() { UNI_ERROR_INVALID_CONTROLLER, "non-gamepad must be rejected"); require(platform_on_device_ready(&out_of_range) == UNI_ERROR_NO_SLOTS, - "out-of-range Bluepad index must be rejected"); + "Bluepad index 4 must be rejected"); require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS, "valid device must occupy its indexed slot"); require(platform_on_device_ready(&collision) == UNI_ERROR_NO_SLOTS, "different device cannot replace an occupied slot"); - uni_controller_t data{}; - data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - data.gamepad.buttons = BUTTON_B; - platform_on_controller_data(&collision, &data); + uni_controller_t collision_data{}; + collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + collision_data.gamepad.buttons = BUTTON_B; + platform_on_controller_data(&collision, &collision_data); SwitchInputState snapshot{}; require(bluepad32_input_backend_snapshot(0, &snapshot), "occupied slot must stay active"); require(!snapshot.button_a, "mismatched device input must not enter the occupied slot"); + + uni_controller_t slot_zero_data{}; + slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + slot_zero_data.gamepad.accel[0] = 8192; + platform_on_controller_data(&slot_zero, &slot_zero_data); + require(bluepad32_input_backend_snapshot(0, &snapshot) && + snapshot.imu_sample_count == 3, + "valid slot input must remain observable"); + require(!bluepad32_input_backend_snapshot(4, &snapshot), + "public snapshot must reject slot 4"); + bluepad32_input_backend_report_sent(4); + require(bluepad32_input_backend_snapshot(0, &snapshot) && + snapshot.imu_sample_count == 3, + "slot 4 acknowledgement must not consume slot 0 IMU"); + bluepad32_input_backend_queue_rumble(4, SwitchRumbleOutput{1, 2}); + process_rumble_timer(&g_rumble_timer); + require(slot_zero.rumble_calls == 0, + "slot 4 rumble must not reach a valid controller"); } void test_independent_lifecycle() { @@ -222,13 +276,14 @@ void test_independent_lifecycle() { require(!observed_status_led_on, "pre-ready disconnect must restore the slow LED off half-cycle"); - uni_hid_device_t first = device(0); - uni_hid_device_t survivor = device(1); - platform_on_device_connected(&first); - platform_on_device_connected(&survivor); - require(g_slots[0].device == &first && !g_slots[0].active && - g_slots[1].device == &survivor && !g_slots[1].active, - "concurrent pending devices must retain independent identities"); + uni_hid_device_t devices[kSlotCount] = { + device(0), device(1), device(2), device(3)}; + for (int slot = 0; slot < kSlotCount; ++slot) { + platform_on_device_connected(&devices[slot]); + require(g_slots[slot].device == &devices[slot] && + !g_slots[slot].active, + "each pending device must retain its indexed identity"); + } tick_backend_timer(19); require(observed_status_led_on, "concurrent pending devices must use the fast LED on half-cycle"); @@ -239,182 +294,246 @@ 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(&first); - require(g_slots[0].device == nullptr && !g_slots[0].active && - g_slots[1].device == &survivor && !g_slots[1].active, - "pre-ready disconnect must preserve the other pending identity"); + 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"); + for (int slot = 1; slot < kSlotCount; ++slot) { + require(g_slots[slot].device == &devices[slot] && + !g_slots[slot].active, + "pre-ready disconnect must preserve all pending survivors"); + } require(g_slots[0].connection_generation == first_pending_generation + 1, - "pending disconnect beside a peer must invalidate its generation"); + "pending disconnect beside peers must invalidate its generation"); require(g_connection_status == ConnectionStatus::Connecting && scanning_enabled && incoming_connections && scan_starts == starts_before_first_pending_disconnect + 1, - "open slot must scan while another slot remains connecting"); + "open slot must scan while other slots remain connecting"); tick_backend_timer(19); require(observed_status_led_on, - "surviving pending device must retain the fast LED on half-cycle"); + "surviving pending devices must retain the fast LED on half-cycle"); tick_backend_timer(1); require(!observed_status_led_on, - "surviving pending device must retain the fast LED off half-cycle"); + "surviving pending devices must retain the fast LED off half-cycle"); - require(platform_on_device_ready(&survivor) == UNI_ERROR_SUCCESS, - "surviving pending device must still become ready"); - platform_on_device_connected(&first); - require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, - "reconnected slot 0 device must complete the pair"); + for (int slot = 1; slot < kSlotCount; ++slot) { + require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS, + "each surviving pending device must still become ready"); + } + platform_on_device_connected(&devices[0]); + require(platform_on_device_ready(&devices[0]) == UNI_ERROR_SUCCESS, + "reconnected slot 0 device must complete all four slots"); + require(g_connection_status == ConnectionStatus::Ready && + !scanning_enabled && !incoming_connections, + "four ready lifecycle devices must stop connection policy"); - uni_controller_t data0{}; - data0.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - data0.gamepad.buttons = BUTTON_B; - data0.gamepad.accel[0] = 8192; - uni_controller_t data1{}; - data1.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - data1.gamepad.buttons = BUTTON_A; - data1.gamepad.gyro[1] = 1024; - platform_on_controller_data(&first, &data0); - platform_on_controller_data(&survivor, &data1); + const uint32_t buttons[kSlotCount] = { + BUTTON_B, BUTTON_A, BUTTON_X, BUTTON_Y}; + uni_controller_t data[kSlotCount]{}; + for (int slot = 0; slot < kSlotCount; ++slot) { + data[slot].klass = UNI_CONTROLLER_CLASS_GAMEPAD; + data[slot].gamepad.buttons = buttons[slot]; + data[slot].gamepad.accel[slot % 3] = 8192 + slot; + data[slot].gamepad.gyro[(slot + 1) % 3] = 1024 + slot; + platform_on_controller_data(&devices[slot], &data[slot]); + } - SwitchInputState state0{}; - SwitchInputState state1{}; - require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_a && - state0.imu_sample_count == 3, - "slot 0 input and IMU must map only to slot 0"); - require(bluepad32_input_backend_snapshot(1, &state1) && state1.button_b && - state1.imu_sample_count == 3, - "slot 1 input and IMU must map only to slot 1"); + SwitchInputState states[kSlotCount]{}; + for (int slot = 0; slot < kSlotCount; ++slot) { + require(bluepad32_input_backend_snapshot(slot, &states[slot]) && + states[slot].imu_sample_count == 3, + "every slot must expose independent input and IMU"); + } + require(states[0].button_a && !states[0].button_b && + !states[0].button_y && !states[0].button_x, + "slot 0 must contain only slot 0 input"); + require(states[1].button_b && !states[1].button_a && + !states[1].button_y && !states[1].button_x, + "slot 1 must contain only slot 1 input"); + require(states[2].button_y && !states[2].button_a && + !states[2].button_b && !states[2].button_x, + "slot 2 must contain only slot 2 input"); + require(states[3].button_x && !states[3].button_a && + !states[3].button_b && !states[3].button_y, + "slot 3 must contain only slot 3 input"); - bluepad32_input_backend_report_sent(0); - require(bluepad32_input_backend_snapshot(0, &state0) && - state0.imu_sample_count == 0, - "slot 0 report acknowledgement must consume only slot 0 IMU"); - require(bluepad32_input_backend_snapshot(1, &state1) && - state1.imu_sample_count == 3, - "slot 0 acknowledgement must not consume slot 1 IMU"); + bluepad32_input_backend_report_sent(3); + for (int slot = 0; slot < kSlotCount; ++slot) { + require(bluepad32_input_backend_snapshot(slot, &states[slot]) && + states[slot].imu_sample_count == (slot == 3 ? 0 : 3), + "slot 3 acknowledgement must not consume slots 0-2 IMU"); + } + for (int slot = 0; slot < 3; ++slot) { + bluepad32_input_backend_report_sent(slot); + require(bluepad32_input_backend_snapshot(slot, &states[slot]) && + states[slot].imu_sample_count == 0, + "each slot acknowledgement must consume only its own IMU"); + } - const SwitchRumbleOutput rumble0{11, 22}; - const SwitchRumbleOutput rumble1{33, 44}; - bluepad32_input_backend_queue_rumble(0, rumble0); - bluepad32_input_backend_queue_rumble(1, rumble1); + const SwitchRumbleOutput initial_rumble[kSlotCount] = { + {11, 21}, {12, 22}, {13, 23}, {14, 24}}; + for (int slot = 0; slot < kSlotCount; ++slot) { + bluepad32_input_backend_queue_rumble(slot, initial_rumble[slot]); + } process_rumble_timer(&g_rumble_timer); - require(first.rumble_calls == 1 && first.last_low == 11 && - first.last_high == 22, - "slot 0 rumble must reach only controller 0"); - require(survivor.rumble_calls == 1 && survivor.last_low == 33 && - survivor.last_high == 44, - "slot 1 rumble must reach only controller 1"); + for (int slot = 0; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == 1 && + devices[slot].last_low == 11 + slot && + devices[slot].last_high == 21 + slot, + "each slot rumble must reach only its indexed controller"); + } - bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{55, 66}); + bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{55, 66}); const uint32_t disconnected_generation = - g_slots[0].connection_generation; - const int starts_before_disconnect = scan_starts; - platform_on_device_disconnected(&first); - require(scan_starts == starts_before_disconnect + 1 && - incoming_connections, - "disconnect must resume scanning and incoming connections"); - require(!bluepad32_input_backend_snapshot(0, &state0) && - !state0.button_a && state0.lx == 32768, - "disconnect must neutralize only its own slot"); - require(bluepad32_input_backend_snapshot(1, &state1) && state1.button_b, - "disconnect must preserve survivor state and activity"); - - uni_hid_device_t replacement = device(0); - require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, - "replacement must bind to the freed indexed slot"); - process_rumble_timer(&g_rumble_timer); - require(replacement.rumble_calls == 0, - "replacement must not receive disconnected device rumble"); - - g_slots[0].pending_rumble = { - 0, disconnected_generation, SwitchRumbleOutput{77, 88}}; - g_slots[0].rumble_pending = true; - process_rumble_timer(&g_rumble_timer); - require(replacement.rumble_calls == 0, - "stale connection generation must be rejected at dispatch"); - - uni_controller_t replacement_data{}; - replacement_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - replacement_data.gamepad.buttons = BUTTON_Y; - platform_on_controller_data(&replacement, &replacement_data); - require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_x, - "replacement input must populate only the freed slot"); - require(bluepad32_input_backend_snapshot(1, &state1) && state1.button_b, - "replacement must not disturb survivor input"); - - bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{90, 91}); - bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{92, 93}); - process_rumble_timer(&g_rumble_timer); - require(replacement.rumble_calls == 1 && replacement.last_low == 90 && - replacement.last_high == 91, - "replacement must receive only new-generation slot 0 rumble"); - require(survivor.rumble_calls == 2 && survivor.last_low == 92 && - survivor.last_high == 93, - "survivor rumble must continue after peer replacement"); - - const int starts_before_slot_one_disconnect = scan_starts; - platform_on_device_disconnected(&survivor); - require(scan_starts == starts_before_slot_one_disconnect + 1 && - incoming_connections, - "slot 1 disconnect must resume scanning for its open slot"); - require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_x, - "slot 1 disconnect must preserve slot 0 state and activity"); - require(!bluepad32_input_backend_snapshot(1, &state1) && - !state1.button_b && state1.lx == 32768, - "slot 1 disconnect must neutralize only slot 1"); - - uni_controller_t continuing_slot_zero_data{}; - continuing_slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - continuing_slot_zero_data.gamepad.buttons = BUTTON_B; - platform_on_controller_data(&replacement, &continuing_slot_zero_data); - require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_a, - "slot 0 input must continue while slot 1 is disconnected"); - - const int slot_zero_calls_while_scanning = replacement.rumble_calls; + g_slots[3].connection_generation; + const int starts_before_slot_three_disconnect = scan_starts; + platform_on_device_disconnected(&devices[3]); + require(scan_starts == starts_before_slot_three_disconnect + 1 && + scanning_enabled && incoming_connections, + "slot 3 disconnect must resume scanning and incoming connections"); + require(!bluepad32_input_backend_snapshot(3, &states[3]) && + !states[3].button_x && states[3].lx == 32768, + "slot 3 disconnect must neutralize only slot 3"); + require(bluepad32_input_backend_snapshot(0, &states[0]) && + states[0].button_a && + bluepad32_input_backend_snapshot(1, &states[1]) && + states[1].button_b && + bluepad32_input_backend_snapshot(2, &states[2]) && + states[2].button_y, + "slot 3 disconnect must preserve slots 0-2"); + platform_on_controller_data(&devices[0], &data[0]); + require(bluepad32_input_backend_snapshot(0, &states[0]) && + states[0].button_a, + "slot 0 input must continue while slot 3 is disconnected"); + const int slot_zero_calls_while_scanning = devices[0].rumble_calls; bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{115, 116}); tick_backend_timer(99); - require(replacement.rumble_calls == slot_zero_calls_while_scanning + 1 && - replacement.last_low == 115 && replacement.last_high == 116, - "slot 0 rumble must continue while slot 1 is disconnected"); + require(devices[0].rumble_calls == slot_zero_calls_while_scanning + 1 && + devices[0].last_low == 115 && + devices[0].last_high == 116, + "slot 0 rumble must continue while slot 3 is disconnected"); require(observed_status_led_on, "disconnect scanning must use the slow LED on half-cycle"); tick_backend_timer(1); require(!observed_status_led_on, "disconnect scanning must reach the slow LED off half-cycle"); - uni_hid_device_t first_slot_one_replacement = device(1); - require(platform_on_device_ready(&first_slot_one_replacement) == + uni_hid_device_t slot_three_replacement = device(3); + require(platform_on_device_ready(&slot_three_replacement) == UNI_ERROR_SUCCESS, - "slot 1 replacement must bind without disturbing slot 0"); - tick_backend_timer(1); + "slot 3 replacement must bind to the freed indexed slot"); + process_rumble_timer(&g_rumble_timer); + require(slot_three_replacement.rumble_calls == 0, + "slot 3 replacement must not receive disconnected device rumble"); require(observed_status_led_on, - "replacing the open slot must return the LED to solid ready"); + "slot 3 replacement must restore the solid ready LED"); const int replacement_ready_led_writes = observed_status_led_writes; tick_backend_timer(100); require(observed_status_led_on && observed_status_led_writes == replacement_ready_led_writes, - "replacement pair must keep the ready LED solid"); + "replacement quartet must keep the ready LED solid"); - bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{117, 118}); - platform_on_device_disconnected(&first_slot_one_replacement); - uni_hid_device_t second_slot_one_replacement = device(1); - require(platform_on_device_ready(&second_slot_one_replacement) == - UNI_ERROR_SUCCESS, - "a subsequent slot 1 replacement must bind to the freed slot"); + g_slots[3].pending_rumble = { + 3, disconnected_generation, SwitchRumbleOutput{77, 88}}; + g_slots[3].rumble_pending = true; process_rumble_timer(&g_rumble_timer); - require(second_slot_one_replacement.rumble_calls == 0, - "slot 1 replacement must not receive prior-generation rumble"); + require(slot_three_replacement.rumble_calls == 0, + "stale slot 3 connection generation must be rejected"); - const int slot_zero_calls_before_mailboxes = replacement.rumble_calls; - bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{119, 120}); - bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{121, 122}); + uni_controller_t replacement_data{}; + replacement_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + replacement_data.gamepad.buttons = BUTTON_Y; + replacement_data.gamepad.accel[0] = 9000; + platform_on_controller_data(&slot_three_replacement, &replacement_data); + require(bluepad32_input_backend_snapshot(3, &states[3]) && + states[3].button_x && states[3].imu_sample_count == 3, + "replacement input and IMU must populate only slot 3"); + require(bluepad32_input_backend_snapshot(0, &states[0]) && + states[0].button_a && + bluepad32_input_backend_snapshot(1, &states[1]) && + states[1].button_b && + bluepad32_input_backend_snapshot(2, &states[2]) && + states[2].button_y, + "slot 3 replacement must not disturb slots 0-2"); + + const int survivor_calls[kSlotCount - 1] = { + devices[0].rumble_calls, + devices[1].rumble_calls, + devices[2].rumble_calls}; + bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{90, 91}); + process_rumble_timer(&g_rumble_timer); + require(slot_three_replacement.rumble_calls == 1 && + slot_three_replacement.last_low == 90 && + slot_three_replacement.last_high == 91, + "new-generation slot 3 rumble must reach its replacement"); + for (int slot = 0; slot < 3; ++slot) { + require(devices[slot].rumble_calls == survivor_calls[slot], + "slot 3 rumble must not affect slots 0-2"); + } + const int all_slot_calls[kSlotCount] = { + devices[0].rumble_calls, + devices[1].rumble_calls, + devices[2].rumble_calls, + slot_three_replacement.rumble_calls}; + for (int slot = 0; slot < kSlotCount; ++slot) { + bluepad32_input_backend_queue_rumble( + slot, SwitchRumbleOutput{static_cast(100 + slot), + static_cast(110 + slot)}); + } + process_rumble_timer(&g_rumble_timer); + for (int slot = 0; slot < kSlotCount; ++slot) { + const uni_hid_device_t& target = + slot == 3 ? slot_three_replacement : devices[slot]; + require(target.rumble_calls == all_slot_calls[slot] + 1 && + target.last_low == 100 + slot && + target.last_high == 110 + slot, + "survivor rumble must continue after slot 3 replacement"); + } + + 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, + "disconnecting slots 0-2 must resume connection policy"); + require(!bluepad32_input_backend_snapshot(slot, &states[slot]) && + states[slot].lx == 32768, + "disconnect must neutralize its indexed slot"); + for (int survivor = 0; survivor < kSlotCount; ++survivor) { + if (survivor == slot) { + continue; + } + require(bluepad32_input_backend_snapshot(survivor, + &states[survivor]), + "disconnect must preserve all three survivors"); + } + require(platform_on_device_ready(&replacements[slot]) == + UNI_ERROR_SUCCESS, + "replacement must bind to each freed slot"); + require(g_connection_status == ConnectionStatus::Ready && + !scanning_enabled && !incoming_connections, + "replacement must restore the full four-slot policy"); + } + + const int slot_zero_calls_before_mailboxes = replacements[0].rumble_calls; + const int slot_three_calls_before_mailboxes = + slot_three_replacement.rumble_calls; + bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{119, 120}); + bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{121, 122}); bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{123, 124}); process_rumble_timer(&g_rumble_timer); - require(second_slot_one_replacement.rumble_calls == 1 && - second_slot_one_replacement.last_low == 121 && - second_slot_one_replacement.last_high == 122, - "slot 1 mailbox must dispatch only its latest queued value"); - require(replacement.rumble_calls == slot_zero_calls_before_mailboxes + 1 && - replacement.last_low == 123 && replacement.last_high == 124, - "slot 0 activity must not evict the slot 1 mailbox"); + require(slot_three_replacement.rumble_calls == + slot_three_calls_before_mailboxes + 1 && + slot_three_replacement.last_low == 121 && + slot_three_replacement.last_high == 122, + "slot 3 mailbox must dispatch only its latest queued value"); + require(replacements[0].rumble_calls == + slot_zero_calls_before_mailboxes + 1 && + replacements[0].last_low == 123 && + replacements[0].last_high == 124, + "slot 0 activity must not evict the slot 3 mailbox"); } } // namespace @@ -422,10 +541,10 @@ void test_independent_lifecycle() { int main(int argc, char** argv) { require(argc == 2, "scenario argument required"); const std::string scenario = argv[1]; - if (scenario == "ready-0-1") { - test_ready_order(0); - } else if (scenario == "ready-1-0") { - test_ready_order(1); + if (scenario == "ready-forward") { + test_ready_order(false); + } else if (scenario == "ready-reverse") { + test_ready_order(true); } else if (scenario == "rejections") { test_rejections(); } else if (scenario == "lifecycle") { diff --git a/tests/switch_pro_descriptors_test.cpp b/tests/switch_pro_descriptors_test.cpp index 9ce2130..edcf392 100644 --- a/tests/switch_pro_descriptors_test.cpp +++ b/tests/switch_pro_descriptors_test.cpp @@ -26,6 +26,16 @@ constexpr uint8_t kInterfaceDescriptor = 0x04; constexpr uint8_t kEndpointDescriptor = 0x05; constexpr uint8_t kHidDescriptor = 0x21; +#if EXPECTED_HID_INSTANCE_COUNT == 1 +constexpr std::array kUartConfigurationDescriptor = { + 0x09, 0x02, 0x29, 0x00, 0x01, 0x01, 0x00, 0xA0, 0xFA, + 0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00, + 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0xCB, 0x00, + 0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x08, + 0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x08, +}; +#endif + int failures = 0; void expect(bool condition, const char* message) { @@ -52,6 +62,11 @@ void inspect_configuration_descriptor() { const auto* descriptor = switch_pro_configuration_descriptor; constexpr size_t descriptor_size = sizeof(switch_pro_configuration_descriptor); +#if EXPECTED_HID_INSTANCE_COUNT == 1 + expect(std::memcmp(descriptor, kUartConfigurationDescriptor.data(), + descriptor_size) == 0, + "UART configuration descriptor bytes changed"); +#endif expect(descriptor[0] == 9 && descriptor[1] == kConfigurationDescriptor, "configuration header is malformed"); diff --git a/tests/switch_pro_driver_context_test.cpp b/tests/switch_pro_driver_context_test.cpp index 611501f..576f645 100644 --- a/tests/switch_pro_driver_context_test.cpp +++ b/tests/switch_pro_driver_context_test.cpp @@ -8,6 +8,11 @@ #include namespace { +constexpr uint8_t kInstanceCount = SWITCH_PICO_HID_INSTANCE_COUNT; +constexpr uint8_t kInvalidInstance = kInstanceCount; +static_assert(kInstanceCount == 4, + "the native driver harness must exercise four HID instances"); + struct SentReport { uint8_t instance = 0; @@ -24,13 +29,12 @@ struct RumbleEvent { uint64_t now_ms = 0; uint32_t random_value = 1; -bool hid_ready[SWITCH_PICO_HID_INSTANCE_COUNT] = {true, true}; -bool hid_report_succeeds[SWITCH_PICO_HID_INSTANCE_COUNT] = {true, true}; -std::array hid_report_attempts{}; +std::array hid_ready{}; +std::array hid_report_succeeds{}; +std::array hid_report_attempts{}; std::array sent_reports{}; unsigned sent_report_count = 0; -RumbleEvent rumble_zero{}; -RumbleEvent rumble_one{}; +std::array rumble_events{}; int failures = 0; void expect(bool condition, const char* message) { @@ -47,13 +51,12 @@ void clear_sent_reports() { void initialize_contexts() { now_ms = 0; - hid_ready[0] = true; - hid_ready[1] = true; - hid_report_succeeds[0] = true; - hid_report_succeeds[1] = true; hid_report_attempts = {}; - switch_pro_init(0); - switch_pro_init(1); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + hid_ready[instance] = true; + hid_report_succeeds[instance] = true; + switch_pro_init(instance); + } clear_sent_reports(); } @@ -178,40 +181,34 @@ std::array complete_rumble_report( return report; } -void rumble_callback_zero(uint8_t instance, - const SwitchRumbleOutput& output) { - ++rumble_zero.count; - rumble_zero.instance = instance; - rumble_zero.output = output; -} - -void rumble_callback_one(uint8_t instance, - const SwitchRumbleOutput& output) { - ++rumble_one.count; - rumble_one.instance = instance; - rumble_one.output = output; +void rumble_callback(uint8_t instance, const SwitchRumbleOutput& output) { + expect(instance < rumble_events.size(), + "rumble callback received an invalid instance"); + if (instance >= rumble_events.size()) { + return; + } + RumbleEvent& event = rumble_events[instance]; + ++event.count; + event.instance = instance; + event.output = output; } void test_reset_materializes_neutral_sticks() { initialize_contexts(); - SwitchProReport init_zero = - get_current_report(0, "GET_REPORT failed immediately after init"); - SwitchProReport init_one = - get_current_report(1, "GET_REPORT failed for second initialized context"); - expect_neutral_sticks( - init_zero, "instance 0 sticks were not neutral immediately after init"); - expect_neutral_sticks( - init_one, "instance 1 sticks were not neutral immediately after init"); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + SwitchProReport initialized = get_current_report( + instance, "GET_REPORT failed immediately after init"); + expect_neutral_sticks( + initialized, "instance sticks were not neutral after init"); + } tud_mount_cb(); - SwitchProReport mount_zero = - get_current_report(0, "GET_REPORT failed immediately after mount"); - SwitchProReport mount_one = - get_current_report(1, "GET_REPORT failed for second mounted context"); - expect_neutral_sticks( - mount_zero, "instance 0 sticks were not neutral immediately after mount"); - expect_neutral_sticks( - mount_one, "instance 1 sticks were not neutral immediately after mount"); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + SwitchProReport mounted = get_current_report( + instance, "GET_REPORT failed immediately after mount"); + expect_neutral_sticks( + mounted, "instance sticks were not neutral after mount"); + } } void test_startup_identify_preserves_first_reply_counter() { @@ -259,51 +256,81 @@ void test_failed_startup_identify_retries_preserve_counter() { void test_input_reports_and_timers_are_isolated() { initialize_contexts(); - SwitchInputState zero{}; - zero.lx = 0x1111; - zero.ly = 0x2222; - zero.rx = 0x3333; - zero.ry = 0x4444; - zero.button_a = true; - SwitchInputState one{}; - one.lx = 0xaaaa; - one.ly = 0xbbbb; - one.rx = 0xcccc; - one.ry = 0xdddd; - one.button_b = true; - one.dpad_right = true; - switch_pro_set_input(0, zero); - switch_pro_set_input(1, one); + std::array states{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + SwitchInputState& state = states[instance]; + state.lx = static_cast(0x1111u * (instance + 1u)); + state.ly = static_cast(0x2222u + 0x1111u * instance); + state.rx = static_cast(0x5555u + 0x1111u * instance); + state.ry = static_cast(0x8888u + 0x1111u * instance); + } + states[0].button_a = true; + states[1].button_b = true; + states[2].button_x = true; + states[3].button_y = true; + + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + switch_pro_set_input(instance, states[instance]); + } now_ms = 15; - expect(switch_pro_task(0), "instance 0 did not send its timed report"); - expect(switch_pro_task(1), "instance 1 timer was changed by instance 0"); - const SentReport* sent_zero = latest_regular_report(0); - const SentReport* sent_one = latest_regular_report(1); - expect(sent_zero != nullptr, "instance 0 report used the wrong HID route"); - expect(sent_one != nullptr, "instance 1 report used the wrong HID route"); - SwitchProReport report_zero = copy_switch_report(sent_zero); - SwitchProReport report_one = copy_switch_report(sent_one); - expect(report_zero.inputs.buttonA && !report_zero.inputs.buttonB, - "instance 0 button state crossed with instance 1"); - expect(report_one.inputs.buttonB && !report_one.inputs.buttonA, - "instance 1 button state crossed with instance 0"); - expect(report_zero.inputs.leftStick.getX() != - report_one.inputs.leftStick.getX(), - "instance stick reports were merged"); + std::array sent{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(switch_pro_task(instance), + "configured instance did not send its timed report"); + const SentReport* routed = latest_regular_report(instance); + expect(routed != nullptr, "input report used the wrong HID route"); + sent[instance] = copy_switch_report(routed); + expect(sent[instance].inputs.buttonA == (instance == 0) && + sent[instance].inputs.buttonB == (instance == 1) && + sent[instance].inputs.buttonX == (instance == 2) && + sent[instance].inputs.buttonY == (instance == 3), + "button state crossed HID instances"); + } - std::array get_zero{}; - std::array get_one{}; - expect(tud_hid_get_report_cb(0, 0, HID_REPORT_TYPE_INPUT, - get_zero.data(), get_zero.size()) == - sizeof(SwitchProReport), - "GET_REPORT rejected valid instance 0"); - expect(tud_hid_get_report_cb(1, 0, HID_REPORT_TYPE_INPUT, - get_one.data(), get_one.size()) == - sizeof(SwitchProReport), - "GET_REPORT rejected valid instance 1"); - expect(std::memcmp(get_zero.data(), get_one.data(), get_zero.size()) != 0, - "GET_REPORT returned a shared report for both instances"); + std::array, kInstanceCount> + current{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(tud_hid_get_report_cb(instance, 0, HID_REPORT_TYPE_INPUT, + current[instance].data(), + current[instance].size()) == + sizeof(SwitchProReport), + "GET_REPORT rejected a configured instance"); + } + for (uint8_t left = 0; left < kInstanceCount; ++left) { + for (uint8_t right = static_cast(left + 1u); + right < kInstanceCount; ++right) { + expect(std::memcmp(current[left].data(), current[right].data(), + current[left].size()) != 0, + "GET_REPORT returned shared state across HID instances"); + } + } + + SwitchInputState changed_zero = states[0]; + changed_zero.button_a = false; + changed_zero.button_home = true; + switch_pro_set_input(0, changed_zero); + now_ms = 30; + expect(switch_pro_task(0), + "instance 0 did not apply its changed input state"); + SwitchProReport unchanged_three = get_current_report( + 3, "GET_REPORT failed for instance 3 after instance 0 changed"); + expect(unchanged_three.inputs.buttonY && + !unchanged_three.inputs.buttonHome, + "instance 0 input change leaked into instance 3"); + + SwitchInputState changed_three = states[3]; + changed_three.button_y = false; + changed_three.button_capture = true; + switch_pro_set_input(3, changed_three); + now_ms = 45; + expect(switch_pro_task(3), + "instance 3 did not apply its changed input state"); + SwitchProReport unchanged_zero = get_current_report( + 0, "GET_REPORT failed for instance 0 after instance 3 changed"); + expect(unchanged_zero.inputs.buttonHome && + !unchanged_zero.inputs.buttonCapture, + "instance 3 input change leaked into instance 0"); } void test_callback_send_and_imu_modes_are_isolated() { @@ -381,75 +408,108 @@ void test_callback_send_and_imu_modes_are_isolated() { void test_rumble_callbacks_and_decoders_are_isolated() { initialize_contexts(); - rumble_zero = {}; - rumble_one = {}; - switch_pro_set_rumble_callback(0, rumble_callback_zero); - switch_pro_set_rumble_callback(1, rumble_callback_one); + rumble_events = {}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + switch_pro_set_rumble_callback(instance, rumble_callback); + } constexpr uint32_t neutral = 0x40400100u; auto full_payload = rumble_payload(type_2(64, 16, 64, 16), neutral); auto full_report = complete_rumble_report(full_payload); - tud_hid_report_received_cb(2, 0, full_report.data(), full_report.size()); - expect(rumble_zero.count == 0 && rumble_one.count == 0, - "invalid output instance reached a rumble callback"); + tud_hid_report_received_cb(kInvalidInstance, 0, full_report.data(), + full_report.size()); + for (const auto& event : rumble_events) { + expect(event.count == 0, + "invalid output instance reached a rumble callback"); + } std::array stripped{}; std::memcpy(stripped.data() + 1, full_payload.data(), full_payload.size()); tud_hid_set_report_cb(0, REPORT_OUTPUT_10, HID_REPORT_TYPE_OUTPUT, stripped.data(), stripped.size()); - expect(rumble_zero.count == 1 && rumble_zero.instance == 0, + expect(rumble_events[0].count == 1 && rumble_events[0].instance == 0, "control output did not route to instance 0 callback"); - expect(rumble_zero.output.low_frequency_magnitude == 16 && - rumble_zero.output.high_frequency_magnitude == 16, + expect(rumble_events[0].output.low_frequency_magnitude == 16 && + rumble_events[0].output.high_frequency_magnitude == 16, "instance 0 full rumble state decoded incorrectly"); - expect(rumble_one.count == 0, - "instance 0 rumble invoked instance 1 callback"); + for (uint8_t instance = 1; instance < kInstanceCount; ++instance) { + expect(rumble_events[instance].count == 0, + "instance 0 rumble invoked another instance callback"); + } auto delta_payload = rumble_payload(type_1_one_sample(17, 20), neutral); auto delta_report = complete_rumble_report(delta_payload); tud_hid_report_received_cb(1, 0, delta_report.data(), delta_report.size()); - expect(rumble_one.count == 1 && rumble_one.instance == 1, + expect(rumble_events[1].count == 1 && rumble_events[1].instance == 1, "interrupt output did not route to instance 1 callback"); - expect(rumble_one.output.low_frequency_magnitude == 0 && - rumble_one.output.high_frequency_magnitude == 1, + expect(rumble_events[1].output.low_frequency_magnitude == 0 && + rumble_events[1].output.high_frequency_magnitude == 1, "instance 1 decoder inherited instance 0 rumble state"); tud_hid_report_received_cb(0, 0, delta_report.data(), delta_report.size()); - expect(rumble_zero.count == 2 && - rumble_zero.output.low_frequency_magnitude == 17 && - rumble_zero.output.high_frequency_magnitude == 18, + expect(rumble_events[0].count == 2 && + rumble_events[0].output.low_frequency_magnitude == 17 && + rumble_events[0].output.high_frequency_magnitude == 18, "instance 0 decoder lost its own prior rumble state"); - expect(rumble_one.count == 1, - "instance 0 delta reached instance 1 callback"); + + for (uint8_t instance = 2; instance < kInstanceCount; ++instance) { + const uint8_t magnitude = instance == 2 ? 16 : 32; + auto payload = + rumble_payload(type_2(64, magnitude, 64, magnitude), neutral); + auto report = complete_rumble_report(payload); + tud_hid_report_received_cb(instance, 0, report.data(), report.size()); + expect(rumble_events[instance].count == 1 && + rumble_events[instance].instance == instance, + "rumble output did not route to its configured instance"); + expect(rumble_events[instance].output.low_frequency_magnitude == + magnitude && + rumble_events[instance].output.high_frequency_magnitude == + magnitude, + "configured instance decoded another rumble context"); + } + expect(rumble_events[1].count == 1, + "another instance's rumble reached instance 1 callback"); } void test_lifecycle_and_invalid_instances() { initialize_contexts(); - expect(switch_pro_is_ready(0) && switch_pro_is_ready(1), - "initialized contexts were not ready"); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(switch_pro_is_ready(instance), + "initialized context was not ready"); + } + tud_mount_cb(); - expect(!switch_pro_is_ready(0) && !switch_pro_is_ready(1), - "mount did not reset every configured context"); - send_config(0, DISABLE_USB_TIMEOUT); - expect(switch_pro_is_ready(0) && !switch_pro_is_ready(1), - "instance 0 handshake changed instance 1 readiness"); - send_config(1, DISABLE_USB_TIMEOUT); - expect(switch_pro_is_ready(0) && switch_pro_is_ready(1), - "instance 1 handshake did not address its context"); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(!switch_pro_is_ready(instance), + "mount did not reset every configured context"); + } + + for (uint8_t addressed = 0; addressed < kInstanceCount; ++addressed) { + send_config(addressed, DISABLE_USB_TIMEOUT); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(switch_pro_is_ready(instance) == (instance <= addressed), + "handshake readiness crossed configured contexts"); + } + } + tud_umount_cb(); - expect(!switch_pro_is_ready(0) && !switch_pro_is_ready(1), - "unmount did not reset every configured context"); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(!switch_pro_is_ready(instance), + "unmount did not reset every configured context"); + } SwitchInputState ignored{}; ignored.button_home = true; - switch_pro_init(2); - switch_pro_set_input(2, ignored); - switch_pro_set_rumble_callback(2, rumble_callback_zero); - expect(!switch_pro_task(2), "invalid instance ran a driver task"); - expect(!switch_pro_is_ready(2), "invalid instance reported ready"); + switch_pro_init(kInvalidInstance); + switch_pro_set_input(kInvalidInstance, ignored); + switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback); + expect(!switch_pro_task(kInvalidInstance), + "invalid instance ran a driver task"); + expect(!switch_pro_is_ready(kInvalidInstance), + "invalid instance reported ready"); std::array buffer{}; - expect(tud_hid_get_report_cb(2, 0, HID_REPORT_TYPE_INPUT, + expect(tud_hid_get_report_cb(kInvalidInstance, 0, HID_REPORT_TYPE_INPUT, buffer.data(), buffer.size()) == 0, "invalid instance served GET_REPORT data"); - expect(tud_hid_descriptor_report_cb(2) == nullptr, + expect(tud_hid_descriptor_report_cb(kInvalidInstance) == nullptr, "invalid instance served a report descriptor"); } diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 20ebed7..dedb67b 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -19,7 +19,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "-Wextra", "-Werror", "-pedantic", - "-DSWITCH_PICO_HID_INSTANCE_COUNT=2", + "-DSWITCH_PICO_HID_INSTANCE_COUNT=4", f"-I{root / 'tests' / 'bluepad32_native_stubs'}", f"-I{root}", str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"), @@ -30,5 +30,10 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: cwd=root, ) - for scenario in ("ready-0-1", "ready-1-0", "rejections", "lifecycle"): + for scenario in ( + "ready-forward", + "ready-reverse", + "rejections", + "lifecycle", + ): subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_switch_pro_descriptors_native.py b/tests/test_switch_pro_descriptors_native.py index c53f275..a586843 100644 --- a/tests/test_switch_pro_descriptors_native.py +++ b/tests/test_switch_pro_descriptors_native.py @@ -54,26 +54,28 @@ def test_default_descriptor_contract_is_single_hid(tmp_path: Path) -> None: subprocess.run([str(executable)], check=True, cwd=root) -def test_explicit_single_descriptor_contract(tmp_path: Path) -> None: +def test_supported_descriptor_contracts(tmp_path: Path) -> None: root = Path(__file__).resolve().parents[1] - executable = tmp_path / "switch_pro_descriptors_single_test" - result = compile_descriptor_test(root, host_compiler(), executable, 1, 1) - assert result.returncode == 0, result.stderr - subprocess.run([str(executable)], check=True, cwd=root) - - -def test_dual_descriptor_contract(tmp_path: Path) -> None: - root = Path(__file__).resolve().parents[1] - executable = tmp_path / "switch_pro_descriptors_dual_test" - result = compile_descriptor_test(root, host_compiler(), executable, 2, 2) - assert result.returncode == 0, result.stderr - subprocess.run([str(executable)], check=True, cwd=root) + compiler = host_compiler() + for instance_count in range(1, 5): + executable = ( + tmp_path / f"switch_pro_descriptors_{instance_count}_test" + ) + result = compile_descriptor_test( + root, + compiler, + executable, + instance_count, + instance_count, + ) + assert result.returncode == 0, result.stderr + subprocess.run([str(executable)], check=True, cwd=root) def test_unsupported_hid_instance_counts_fail_to_compile(tmp_path: Path) -> None: root = Path(__file__).resolve().parents[1] compiler = host_compiler() - for unsupported_count in (0, 3): + for unsupported_count in (0, 5): executable = tmp_path / f"switch_pro_descriptors_invalid_{unsupported_count}" result = compile_descriptor_test( root, diff --git a/tests/test_switch_pro_driver_context_native.py b/tests/test_switch_pro_driver_context_native.py index d17424a..45102de 100644 --- a/tests/test_switch_pro_driver_context_native.py +++ b/tests/test_switch_pro_driver_context_native.py @@ -5,7 +5,7 @@ import subprocess from pathlib import Path -def test_switch_pro_driver_two_contexts_native(tmp_path: Path) -> None: +def test_switch_pro_driver_four_contexts_native(tmp_path: Path) -> None: root = Path(__file__).resolve().parents[1] compiler = shutil.which("c++") or shutil.which("g++") assert compiler is not None, "a host C++ compiler is required" @@ -19,7 +19,7 @@ def test_switch_pro_driver_two_contexts_native(tmp_path: Path) -> None: "-Wextra", "-Werror", "-pedantic", - "-DSWITCH_PICO_HID_INSTANCE_COUNT=2", + "-DSWITCH_PICO_HID_INSTANCE_COUNT=4", f"-I{root / 'tests' / 'native_stubs'}", f"-I{root}", str(root / "switch_pro_driver.cpp"), diff --git a/tusb_config.h b/tusb_config.h index 30155db..a6f81b6 100644 --- a/tusb_config.h +++ b/tusb_config.h @@ -1,4 +1,4 @@ -// TinyUSB configuration for one or two Switch Pro style HID interfaces. +// TinyUSB configuration for one to four Switch Pro style HID interfaces. // Each interface uses independent 64-byte interrupt IN and OUT endpoints. #ifndef _TUSB_CONFIG_H_ #define _TUSB_CONFIG_H_ @@ -10,8 +10,8 @@ extern "C" { #define SWITCH_PICO_HID_INSTANCE_COUNT 1 #endif -#if SWITCH_PICO_HID_INSTANCE_COUNT != 1 && SWITCH_PICO_HID_INSTANCE_COUNT != 2 -#error "SWITCH_PICO_HID_INSTANCE_COUNT must be 1 or 2" +#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4 +#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4" #endif