From edf6ecaae1f0450ef0fe4876b806e7247790f00e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 30 Aug 2026 20:54:59 -0600 Subject: [PATCH] Add dual-controller AIO USB transport --- CMakeLists.txt | 9 +- bluepad32_config/btstack_config.h | 6 +- bluepad32_config/sdkconfig.h | 6 +- bluepad32_input_backend.cpp | 269 ++++++++++++------ bluepad32_input_backend.h | 11 +- switch-pico.cpp | 68 +++-- switch_pro_descriptors.h | 47 +++ switch_pro_driver.h | 3 - tests/bluepad32_backend_lifecycle_test.cpp | 261 +++++++++++++++++ .../bluepad32_native_stubs/btstack_run_loop.h | 22 ++ .../pico/critical_section.h | 7 + .../bluepad32_native_stubs/pico/cyw43_arch.h | 6 + tests/bluepad32_native_stubs/pico/multicore.h | 3 + tests/bluepad32_native_stubs/pico/stdlib.h | 3 + tests/bluepad32_native_stubs/uni.h | 98 +++++++ tests/switch_pro_descriptors_test.cpp | 169 +++++++++++ ...test_bluepad32_backend_lifecycle_native.py | 34 +++ tests/test_switch_pro_descriptors_native.py | 87 ++++++ tusb_config.h | 14 +- 19 files changed, 1007 insertions(+), 116 deletions(-) create mode 100644 tests/bluepad32_backend_lifecycle_test.cpp create mode 100644 tests/bluepad32_native_stubs/btstack_run_loop.h create mode 100644 tests/bluepad32_native_stubs/pico/critical_section.h create mode 100644 tests/bluepad32_native_stubs/pico/cyw43_arch.h create mode 100644 tests/bluepad32_native_stubs/pico/multicore.h create mode 100644 tests/bluepad32_native_stubs/pico/stdlib.h create mode 100644 tests/bluepad32_native_stubs/uni.h create mode 100644 tests/switch_pro_descriptors_test.cpp create mode 100644 tests/test_bluepad32_backend_lifecycle_native.py create mode 100644 tests/test_switch_pro_descriptors_native.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 89d43ea..3580c8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,7 +91,14 @@ add_executable(switch-pico ) 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) + target_compile_definitions(switch-pico PRIVATE + SWITCH_PICO_BLUEPAD32=1 + SWITCH_PICO_HID_INSTANCE_COUNT=2 + ) +else() + target_compile_definitions(switch-pico PRIVATE + SWITCH_PICO_HID_INSTANCE_COUNT=1 + ) endif() pico_set_program_name(switch-pico "switch-pico") diff --git a/bluepad32_config/btstack_config.h b/bluepad32_config/btstack_config.h index 95072bf..db9f2eb 100644 --- a/bluepad32_config/btstack_config.h +++ b/bluepad32_config/btstack_config.h @@ -40,10 +40,10 @@ #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 1 +#define MAX_NR_GATT_CLIENTS 2 #define MAX_NR_HCI_CONNECTIONS 4 -#define MAX_NR_HID_HOST_CONNECTIONS 1 -#define MAX_NR_HIDS_CLIENTS 1 +#define MAX_NR_HID_HOST_CONNECTIONS 2 +#define MAX_NR_HIDS_CLIENTS 2 #define MAX_NR_HFP_CONNECTIONS 1 #define MAX_NR_L2CAP_CHANNELS 6 #define MAX_NR_L2CAP_SERVICES 5 diff --git a/bluepad32_config/sdkconfig.h b/bluepad32_config/sdkconfig.h index 83002a9..1269667 100644 --- a/bluepad32_config/sdkconfig.h +++ b/bluepad32_config/sdkconfig.h @@ -1,8 +1,8 @@ #pragma once -// Bluepad32's Pico W example configuration, limited to one live controller. -#define CONFIG_BLUEPAD32_MAX_DEVICES 1 -#define CONFIG_BLUEPAD32_MAX_ALLOWLIST 1 +// 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_GAP_SECURITY 1 #define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT 1 diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index f521bcf..39cbfdc 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include namespace { @@ -20,7 +19,10 @@ constexpr int32_t kTriggerMaximum = 1023; constexpr int32_t kTriggerThreshold = (kTriggerMaximum * 35) / 100; constexpr uint16_t kRumbleDurationMs = 50; constexpr uint32_t kRumblePollIntervalMs = 5; -constexpr uint kRumbleQueueDepth = 8; +constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; + +static_assert(kSlotCount == 2); +static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount); enum class ConnectionStatus { Initializing, @@ -29,20 +31,32 @@ enum class ConnectionStatus { Ready, }; -critical_section_t g_state_lock; -queue_t g_rumble_queue; -SwitchInputState g_shared_state; -bool g_shared_controller_active = false; -uint32_t g_shared_generation = 0; +struct RumbleEnvelope { + uint8_t slot; + uint32_t connection_generation; + SwitchRumbleOutput rumble; +}; -// These generations are only read or written by Core 0. -uint32_t g_consumed_generation = 0; -uint32_t g_last_snapshot_generation = 0; +struct BackendSlot { + SwitchInputState state; + uni_hid_device_t* device; + uint32_t state_generation; + uint32_t connection_generation; + bool active; + bool rumble_pending; + RumbleEnvelope pending_rumble; +}; + +critical_section_t g_state_lock; +BackendSlot g_slots[kSlotCount]; + +// These acknowledgement generations are only read or written by Core 0. +uint32_t g_consumed_generation[kSlotCount]{}; +uint32_t g_last_snapshot_generation[kSlotCount]{}; bool g_initialized = false; bool g_started = false; -// This pointer and the timer are only read or written by Core 1 / BTstack. -uni_hid_device_t* g_active_device = nullptr; +// The timer and connection status are only read or written by Core 1 / BTstack. btstack_timer_source_t g_rumble_timer{}; ConnectionStatus g_connection_status = ConnectionStatus::Initializing; uint16_t g_status_led_tick = 0; @@ -57,11 +71,49 @@ SwitchInputState make_neutral_state() { return state; } -void publish_state(const SwitchInputState& state, bool controller_active) { +bool valid_slot(uint8_t slot) { + return slot < kSlotCount; +} + +int slot_for_device(const uni_hid_device_t* device) { + if (device == nullptr) { + return -1; + } + const int slot = uni_hid_device_get_idx_for_instance(device); + return slot >= 0 && slot < kSlotCount ? slot : -1; +} + +bool all_slots_ready() { critical_section_enter_blocking(&g_state_lock); - g_shared_state = state; - g_shared_controller_active = controller_active; - ++g_shared_generation; + bool ready = true; + for (const BackendSlot& slot : g_slots) { + ready = ready && slot.active && slot.device != nullptr; + } + critical_section_exit(&g_state_lock); + return ready; +} + +void publish_device_state(uint8_t slot, uni_hid_device_t* device, + const SwitchInputState& state) { + critical_section_enter_blocking(&g_state_lock); + BackendSlot& target = g_slots[slot]; + if (target.active && target.device == device) { + target.state = state; + ++target.state_generation; + } + critical_section_exit(&g_state_lock); +} + +void publish_all_neutral() { + critical_section_enter_blocking(&g_state_lock); + for (BackendSlot& slot : g_slots) { + slot.state = make_neutral_state(); + slot.device = nullptr; + slot.active = false; + slot.rumble_pending = false; + ++slot.state_generation; + ++slot.connection_generation; + } critical_section_exit(&g_state_lock); } @@ -182,8 +234,6 @@ void update_status_led() { bool led_on = false; switch (g_connection_status) { case ConnectionStatus::Initializing: - led_on = true; - break; case ConnectionStatus::Ready: led_on = true; break; @@ -201,26 +251,43 @@ void update_status_led() { } void process_rumble_timer(btstack_timer_source_t* timer) { - SwitchRumbleOutput packet{}; - SwitchRumbleOutput latest{}; - bool have_packet = false; - while (queue_try_remove(&g_rumble_queue, &packet)) { - latest = packet; - have_packet = true; + for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { + RumbleEnvelope envelope{}; + uni_hid_device_t* device = nullptr; + bool dispatch = false; + + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (slot.rumble_pending) { + envelope = slot.pending_rumble; + slot.rumble_pending = false; + dispatch = envelope.slot == slot_index && slot.active && + slot.device != nullptr && + envelope.connection_generation == slot.connection_generation; + if (dispatch) { + device = slot.device; + } + } + critical_section_exit(&g_state_lock); + + if (dispatch && device->report_parser.play_dual_rumble != nullptr) { + device->report_parser.play_dual_rumble( + device, 0, kRumbleDurationMs, + envelope.rumble.high_frequency_magnitude, + envelope.rumble.low_frequency_magnitude); + } } - if (have_packet && g_active_device != nullptr && - g_active_device->report_parser.play_dual_rumble != nullptr) { - g_active_device->report_parser.play_dual_rumble( - g_active_device, 0, kRumbleDurationMs, - latest.high_frequency_magnitude, latest.low_frequency_magnitude); - } update_status_led(); - btstack_run_loop_set_timer(timer, kRumblePollIntervalMs); btstack_run_loop_add_timer(timer); } +void resume_connections() { + uni_bt_allow_incoming_connections(true); + uni_bt_start_scanning_and_autoconnect_unsafe(); +} + void platform_init(int argc, const char** argv) { (void)argc; (void)argv; @@ -232,9 +299,7 @@ void platform_on_init_complete() { btstack_run_loop_add_timer(&g_rumble_timer); g_connection_status = ConnectionStatus::Scanning; g_status_led_tick = 0; - - uni_bt_allow_incoming_connections(true); - uni_bt_start_scanning_and_autoconnect_unsafe(); + resume_connections(); } uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) { @@ -242,56 +307,91 @@ uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint (void)name; (void)cod; (void)rssi; - return g_active_device == nullptr ? UNI_ERROR_SUCCESS : UNI_ERROR_IGNORE_DEVICE; + return all_slots_ready() ? UNI_ERROR_IGNORE_DEVICE : UNI_ERROR_SUCCESS; } void platform_on_device_connected(uni_hid_device_t* device) { (void)device; - g_connection_status = ConnectionStatus::Connecting; - g_status_led_tick = 0; -} - -void resume_connections() { - uni_bt_allow_incoming_connections(true); - uni_bt_start_scanning_and_autoconnect_unsafe(); + if (!all_slots_ready()) { + g_connection_status = ConnectionStatus::Connecting; + g_status_led_tick = 0; + } } void platform_on_device_disconnected(uni_hid_device_t* device) { - if (device == g_active_device) { - g_active_device = nullptr; - publish_state(make_neutral_state(), false); + const int slot_index = slot_for_device(device); + if (slot_index < 0) { + return; + } + + bool disconnected_active_slot = false; + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (slot.active && slot.device == device) { + slot.state = make_neutral_state(); + slot.device = nullptr; + slot.active = false; + slot.rumble_pending = false; + ++slot.state_generation; + ++slot.connection_generation; + disconnected_active_slot = true; + } + critical_section_exit(&g_state_lock); + + if (disconnected_active_slot) { g_connection_status = ConnectionStatus::Scanning; g_status_led_tick = 0; resume_connections(); - } else if (g_active_device == nullptr) { - resume_connections(); - g_connection_status = ConnectionStatus::Scanning; - g_status_led_tick = 0; } } uni_error_t platform_on_device_ready(uni_hid_device_t* device) { - if (!uni_hid_device_is_gamepad(device)) { + if (device == nullptr || !uni_hid_device_is_gamepad(device)) { return UNI_ERROR_INVALID_CONTROLLER; } - if (g_active_device != nullptr && g_active_device != device) { + + const int slot_index = slot_for_device(device); + if (slot_index < 0) { + return UNI_ERROR_NO_SLOTS; + } + + bool occupied_mismatch = false; + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + occupied_mismatch = slot.active && slot.device != device; + if (!occupied_mismatch && !slot.active) { + slot.state = make_neutral_state(); + slot.device = device; + slot.active = true; + slot.rumble_pending = false; + ++slot.state_generation; + } + critical_section_exit(&g_state_lock); + + if (occupied_mismatch) { return UNI_ERROR_NO_SLOTS; } - g_active_device = device; - g_connection_status = ConnectionStatus::Ready; g_status_led_tick = 0; - publish_state(make_neutral_state(), true); - uni_bt_stop_scanning_unsafe(); - uni_bt_allow_incoming_connections(false); + if (all_slots_ready()) { + g_connection_status = ConnectionStatus::Ready; + uni_bt_stop_scanning_unsafe(); + uni_bt_allow_incoming_connections(false); + } else { + g_connection_status = ConnectionStatus::Scanning; + resume_connections(); + } return UNI_ERROR_SUCCESS; } void platform_on_controller_data(uni_hid_device_t* device, uni_controller_t* controller) { - if (device != g_active_device || controller == nullptr || controller->klass != UNI_CONTROLLER_CLASS_GAMEPAD) { + const int slot_index = slot_for_device(device); + if (slot_index < 0 || controller == nullptr || + controller->klass != UNI_CONTROLLER_CLASS_GAMEPAD) { return; } - publish_state(map_gamepad(controller->gamepad), true); + publish_device_state(static_cast(slot_index), device, + map_gamepad(controller->gamepad)); } const uni_property_t* platform_get_property(uni_property_idx_t index) { @@ -325,7 +425,7 @@ uni_platform* get_platform() { [[noreturn]] void core1_main() { if (cyw43_arch_init() != 0) { - publish_state(make_neutral_state(), false); + publish_all_neutral(); while (true) { tight_loop_contents(); } @@ -335,7 +435,7 @@ uni_platform* get_platform() { uni_platform_set_custom(get_platform()); if (uni_init(0, nullptr) != 0) { - publish_state(make_neutral_state(), false); + publish_all_neutral(); while (true) { tight_loop_contents(); } @@ -355,12 +455,14 @@ void bluepad32_input_backend_init() { } critical_section_init(&g_state_lock); - queue_init(&g_rumble_queue, sizeof(SwitchRumbleOutput), kRumbleQueueDepth); - g_shared_state = make_neutral_state(); - g_shared_controller_active = false; - g_shared_generation = 0; - g_consumed_generation = 0; - g_last_snapshot_generation = 0; + for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { + BackendSlot& slot = g_slots[slot_index]; + slot = {}; + slot.state = make_neutral_state(); + slot.pending_rumble.slot = slot_index; + g_consumed_generation[slot_index] = 0; + g_last_snapshot_generation[slot_index] = 0; + } g_initialized = true; } @@ -376,8 +478,8 @@ void bluepad32_input_backend_start() { multicore_launch_core1(core1_main); } -bool bluepad32_input_backend_snapshot(SwitchInputState* out) { - if (out == nullptr) { +bool bluepad32_input_backend_snapshot(uint8_t slot_index, SwitchInputState* out) { + if (out == nullptr || !valid_slot(slot_index)) { return false; } if (!g_initialized) { @@ -386,33 +488,36 @@ bool bluepad32_input_backend_snapshot(SwitchInputState* out) { } critical_section_enter_blocking(&g_state_lock); - *out = g_shared_state; - const bool controller_active = g_shared_controller_active; - const uint32_t generation = g_shared_generation; + *out = g_slots[slot_index].state; + const bool controller_active = g_slots[slot_index].active; + const uint32_t generation = g_slots[slot_index].state_generation; critical_section_exit(&g_state_lock); - if (generation == g_consumed_generation) { + if (generation == g_consumed_generation[slot_index]) { out->imu_sample_count = 0; } - g_last_snapshot_generation = generation; + g_last_snapshot_generation[slot_index] = generation; return controller_active; } -void bluepad32_input_backend_report_sent() { - if (!g_initialized) { +void bluepad32_input_backend_report_sent(uint8_t slot_index) { + if (!g_initialized || !valid_slot(slot_index)) { return; } - g_consumed_generation = g_last_snapshot_generation; + g_consumed_generation[slot_index] = g_last_snapshot_generation[slot_index]; } -void bluepad32_input_backend_queue_rumble(const SwitchRumbleOutput& rumble) { - if (!g_initialized) { +void bluepad32_input_backend_queue_rumble(uint8_t slot_index, + const SwitchRumbleOutput& rumble) { + if (!g_initialized || !valid_slot(slot_index)) { return; } - if (!queue_try_add(&g_rumble_queue, &rumble)) { - SwitchRumbleOutput discarded{}; - (void)queue_try_remove(&g_rumble_queue, &discarded); - (void)queue_try_add(&g_rumble_queue, &rumble); + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (slot.active && slot.device != nullptr) { + slot.pending_rumble = {slot_index, slot.connection_generation, rumble}; + slot.rumble_pending = true; } + critical_section_exit(&g_state_lock); } diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index 2360860..16654d1 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -2,11 +2,14 @@ #include -#include "switch_pro_driver.h" #include "switch_haptics.h" +#include "switch_pro_driver.h" + +constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 2; void bluepad32_input_backend_init(); void bluepad32_input_backend_start(); -bool bluepad32_input_backend_snapshot(SwitchInputState* out); -void bluepad32_input_backend_report_sent(); -void bluepad32_input_backend_queue_rumble(const SwitchRumbleOutput& rumble); +bool bluepad32_input_backend_snapshot(uint8_t slot, SwitchInputState* out); +void bluepad32_input_backend_report_sent(uint8_t slot); +void bluepad32_input_backend_queue_rumble(uint8_t slot, + const SwitchRumbleOutput& rumble); diff --git a/switch-pico.cpp b/switch-pico.cpp index 1d244da..ebe3007 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -25,13 +25,19 @@ #define UART_RUMBLE_TYPE 0x02 #endif +#ifdef SWITCH_PICO_BLUEPAD32 +static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == + BLUEPAD32_INPUT_BACKEND_SLOT_COUNT); +static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; +static SwitchInputState + g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; +#else static constexpr uint8_t SWITCH_HID_INSTANCE = 0; +static bool g_last_ready = false; +static SwitchInputState g_user_state; +#endif static bool g_last_mounted = false; -static bool g_last_ready = false; - -// Track the latest state provided by UART or the autopilot. -static SwitchInputState g_user_state; #ifndef SWITCH_PICO_BLUEPAD32 static void init_uart_input() { @@ -70,12 +76,15 @@ static void send_rumble_uart_frame(const SwitchRumbleOutput& rumble) { static void on_rumble_from_switch(uint8_t instance, const SwitchRumbleOutput& rumble) { +#ifdef SWITCH_PICO_BLUEPAD32 + if (instance >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) { + return; + } + bluepad32_input_backend_queue_rumble(instance, rumble); +#else if (instance != SWITCH_HID_INSTANCE) { return; } -#ifdef SWITCH_PICO_BLUEPAD32 - bluepad32_input_backend_queue_rumble(rumble); -#else send_rumble_uart_frame(rumble); #endif } @@ -159,16 +168,29 @@ static bool poll_uart_frames() { static void log_usb_state() { bool mounted = tud_mounted(); - bool ready = switch_pro_is_ready(SWITCH_HID_INSTANCE); - if (mounted != g_last_mounted) { g_last_mounted = mounted; LOG_PRINTF("[USB] %s\n", mounted ? "mounted" : "unmounted"); } + +#ifdef SWITCH_PICO_BLUEPAD32 + for (uint8_t instance = 0; + instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { + const bool ready = switch_pro_is_ready(instance); + if (ready != g_last_ready[instance]) { + g_last_ready[instance] = ready; + LOG_PRINTF("[SWITCH %u] driver %s\n", instance, + ready ? "ready (handshake OK)" : "not ready"); + } + } +#else + const bool ready = switch_pro_is_ready(SWITCH_HID_INSTANCE); if (ready != g_last_ready) { g_last_ready = ready; - LOG_PRINTF("[SWITCH] driver %s\n", ready ? "ready (handshake OK)" : "not ready"); + LOG_PRINTF("[SWITCH] driver %s\n", + ready ? "ready (handshake OK)" : "not ready"); } +#endif } int main() { @@ -182,11 +204,21 @@ int main() { #endif tusb_init(); +#ifdef SWITCH_PICO_BLUEPAD32 + for (uint8_t instance = 0; + instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { + switch_pro_init(instance); + switch_pro_set_rumble_callback(instance, on_rumble_from_switch); + g_user_states[instance] = neutral_input(); + switch_pro_set_input(instance, g_user_states[instance]); + } +#else switch_pro_init(SWITCH_HID_INSTANCE); switch_pro_set_rumble_callback(SWITCH_HID_INSTANCE, on_rumble_from_switch); g_user_state = neutral_input(); switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state); +#endif #ifdef SWITCH_PICO_BLUEPAD32 bluepad32_input_backend_start(); @@ -200,18 +232,20 @@ int main() { while (true) { tud_task(); // USB device tasks #ifdef SWITCH_PICO_BLUEPAD32 - bluepad32_input_backend_snapshot(&g_user_state); + for (uint8_t instance = 0; + instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { + bluepad32_input_backend_snapshot(instance, + &g_user_states[instance]); + switch_pro_set_input(instance, g_user_states[instance]); + if (switch_pro_task(instance)) { + bluepad32_input_backend_report_sent(instance); + } + } #else bool new_data = poll_uart_frames(); // Pull controller state from UART1 (void)new_data; -#endif SwitchInputState state = g_user_state; switch_pro_set_input(SWITCH_HID_INSTANCE, state); -#ifdef SWITCH_PICO_BLUEPAD32 - if (switch_pro_task(SWITCH_HID_INSTANCE)) { - bluepad32_input_backend_report_sent(); - } -#else (void)switch_pro_task(SWITCH_HID_INSTANCE); #endif log_usb_state(); diff --git a/switch_pro_descriptors.h b/switch_pro_descriptors.h index 264b736..8033ed7 100644 --- a/switch_pro_descriptors.h +++ b/switch_pro_descriptors.h @@ -8,6 +8,14 @@ #pragma once #include +#ifndef SWITCH_PICO_HID_INSTANCE_COUNT +#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" +#endif + #define SWITCH_PRO_ENDPOINT_SIZE 64 @@ -369,8 +377,13 @@ static const uint8_t switch_pro_configuration_descriptor[] = { 0x09, // bLength 0x02, // bDescriptorType (Configuration) +#if SWITCH_PICO_HID_INSTANCE_COUNT == 1 0x29, 0x00, // wTotalLength 41 0x01, // bNumInterfaces 1 +#else + 0x49, 0x00, // wTotalLength 73 + 0x02, // bNumInterfaces 2 +#endif 0x01, // bConfigurationValue 0x00, // iConfiguration (String Index) 0xA0, // bmAttributes Remote Wakeup @@ -407,6 +420,40 @@ static const uint8_t switch_pro_configuration_descriptor[] = 0x03, // bmAttributes (Interrupt) 0x40, 0x00, // wMaxPacketSize 64 0x08, // bInterval 8 (unit depends on device speed) + +#if SWITCH_PICO_HID_INSTANCE_COUNT == 2 + 0x09, // bLength + 0x04, // bDescriptorType (Interface) + 0x01, // bInterfaceNumber 1 + 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) + 0x82, // bEndpointAddress (IN/D2H) + 0x03, // bmAttributes (Interrupt) + 0x40, 0x00, // wMaxPacketSize 64 + 0x08, // bInterval 8 (unit depends on device speed) + + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x02, // 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/switch_pro_driver.h b/switch_pro_driver.h index c070cfb..91072f7 100644 --- a/switch_pro_driver.h +++ b/switch_pro_driver.h @@ -11,9 +11,6 @@ #include "switch_haptics.h" #include "switch_pro_descriptors.h" -#ifndef SWITCH_PICO_HID_INSTANCE_COUNT -#define SWITCH_PICO_HID_INSTANCE_COUNT 1 -#endif typedef struct { int16_t accel_x; diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp new file mode 100644 index 0000000..824a3f9 --- /dev/null +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -0,0 +1,261 @@ +#include +#include +#include +#include + +#include + +namespace { + +bool incoming_connections = false; +int scan_starts = 0; +int scan_stops = 0; +uni_platform* installed_platform = nullptr; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +void play_rumble(uni_hid_device_t* device, uint16_t, uint16_t, + uint8_t high, uint8_t low) { + ++device->rumble_calls; + device->last_high = high; + device->last_low = low; +} + +uni_hid_device_t device(int idx, bool gamepad = true) { + uni_hid_device_t result{}; + result.idx = idx; + result.gamepad = gamepad; + result.report_parser.play_dual_rumble = play_rumble; + return result; +} + +} // namespace + +bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) { + return device != nullptr && device->gamepad; +} + +int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device) { + return device == nullptr ? -1 : device->idx; +} + +void uni_bt_allow_incoming_connections(bool enabled) { + incoming_connections = enabled; +} + +void uni_bt_start_scanning_and_autoconnect_unsafe() { + ++scan_starts; +} + +void uni_bt_stop_scanning_unsafe() { + ++scan_stops; +} + +void uni_platform_set_custom(uni_platform* platform) { + installed_platform = platform; +} + +int uni_init(int, const char**) { + return 0; +} + +int cyw43_arch_init() { + return 0; +} + +void cyw43_arch_gpio_put(int, bool) {} +void multicore_launch_core1(void (*)()) {} + +#include "../bluepad32_input_backend.cpp" + +namespace { + +void start_backend() { + bluepad32_input_backend_init(); + platform_on_init_complete(); + require(incoming_connections, "initialization must allow connections"); + require(scan_starts == 1, "initialization must start scanning"); +} + +void test_ready_order(int first_slot) { + start_backend(); + uni_hid_device_t devices[2] = {device(0), device(1)}; + const int second_slot = 1 - first_slot; + + 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"); + + 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"); + + 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"); + require(!incoming_connections, + "incoming connections must be disabled only when full"); + + 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"); +} + +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 slot_zero = device(0); + uni_hid_device_t collision = device(0); + + require(platform_on_device_ready(&non_gamepad) == + 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"); + 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); + 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"); +} + +void test_independent_lifecycle() { + start_backend(); + uni_hid_device_t first = device(0); + uni_hid_device_t survivor = device(1); + require(platform_on_device_ready(&survivor) == UNI_ERROR_SUCCESS, + "slot 1 must be accepted before slot 0"); + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, + "slot 0 must complete the pair"); + + 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); + + 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"); + + 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"); + + const SwitchRumbleOutput rumble0{11, 22}; + const SwitchRumbleOutput rumble1{33, 44}; + bluepad32_input_backend_queue_rumble(0, rumble0); + bluepad32_input_backend_queue_rumble(1, rumble1); + 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"); + + bluepad32_input_backend_queue_rumble(0, 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"); +} + +} // namespace + +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); + } else if (scenario == "rejections") { + test_rejections(); + } else if (scenario == "lifecycle") { + test_independent_lifecycle(); + } else { + require(false, "unknown scenario"); + } + return 0; +} diff --git a/tests/bluepad32_native_stubs/btstack_run_loop.h b/tests/bluepad32_native_stubs/btstack_run_loop.h new file mode 100644 index 0000000..2d422d6 --- /dev/null +++ b/tests/bluepad32_native_stubs/btstack_run_loop.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +struct btstack_timer_source_t { + void (*handler)(btstack_timer_source_t*); + uint32_t timeout_ms; +}; + +inline void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, + void (*handler)(btstack_timer_source_t*)) { + timer->handler = handler; +} + +inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer, + uint32_t timeout_ms) { + timer->timeout_ms = timeout_ms; +} + +inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {} +inline void btstack_run_loop_execute() {} diff --git a/tests/bluepad32_native_stubs/pico/critical_section.h b/tests/bluepad32_native_stubs/pico/critical_section.h new file mode 100644 index 0000000..ee838ed --- /dev/null +++ b/tests/bluepad32_native_stubs/pico/critical_section.h @@ -0,0 +1,7 @@ +#pragma once + +struct critical_section_t {}; + +inline void critical_section_init(critical_section_t*) {} +inline void critical_section_enter_blocking(critical_section_t*) {} +inline void critical_section_exit(critical_section_t*) {} diff --git a/tests/bluepad32_native_stubs/pico/cyw43_arch.h b/tests/bluepad32_native_stubs/pico/cyw43_arch.h new file mode 100644 index 0000000..909a028 --- /dev/null +++ b/tests/bluepad32_native_stubs/pico/cyw43_arch.h @@ -0,0 +1,6 @@ +#pragma once + +#define CYW43_WL_GPIO_LED_PIN 0 + +int cyw43_arch_init(); +void cyw43_arch_gpio_put(int pin, bool value); diff --git a/tests/bluepad32_native_stubs/pico/multicore.h b/tests/bluepad32_native_stubs/pico/multicore.h new file mode 100644 index 0000000..33d06f7 --- /dev/null +++ b/tests/bluepad32_native_stubs/pico/multicore.h @@ -0,0 +1,3 @@ +#pragma once + +void multicore_launch_core1(void (*entry)()); diff --git a/tests/bluepad32_native_stubs/pico/stdlib.h b/tests/bluepad32_native_stubs/pico/stdlib.h new file mode 100644 index 0000000..d5ce0cb --- /dev/null +++ b/tests/bluepad32_native_stubs/pico/stdlib.h @@ -0,0 +1,3 @@ +#pragma once + +inline void tight_loop_contents() {} diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h new file mode 100644 index 0000000..2de0fa7 --- /dev/null +++ b/tests/bluepad32_native_stubs/uni.h @@ -0,0 +1,98 @@ +#pragma once + +#include + +typedef uint8_t bd_addr_t[6]; +typedef int uni_property_idx_t; +typedef int uni_platform_oob_event_t; +struct uni_property_t {}; + +enum uni_error_t { + UNI_ERROR_SUCCESS = 0, + UNI_ERROR_IGNORE_DEVICE = 1, + UNI_ERROR_INVALID_CONTROLLER = 2, + UNI_ERROR_NO_SLOTS = 3, +}; + +enum { + UNI_CONTROLLER_CLASS_GAMEPAD = 1, + DPAD_UP = 1 << 0, + DPAD_DOWN = 1 << 1, + DPAD_LEFT = 1 << 2, + DPAD_RIGHT = 1 << 3, + BUTTON_A = 1 << 0, + BUTTON_B = 1 << 1, + BUTTON_X = 1 << 2, + BUTTON_Y = 1 << 3, + BUTTON_SHOULDER_L = 1 << 4, + BUTTON_SHOULDER_R = 1 << 5, + BUTTON_TRIGGER_L = 1 << 6, + BUTTON_TRIGGER_R = 1 << 7, + BUTTON_THUMB_L = 1 << 8, + BUTTON_THUMB_R = 1 << 9, + MISC_BUTTON_SELECT = 1 << 0, + MISC_BUTTON_START = 1 << 1, + MISC_BUTTON_SYSTEM = 1 << 2, + MISC_BUTTON_CAPTURE = 1 << 3, +}; + +struct uni_gamepad_t { + uint32_t dpad; + uint32_t buttons; + uint32_t misc_buttons; + int32_t axis_x; + int32_t axis_y; + int32_t axis_rx; + int32_t axis_ry; + int32_t brake; + int32_t throttle; + int32_t accel[3]; + int32_t gyro[3]; +}; + +struct uni_controller_t { + int klass; + uni_gamepad_t gamepad; +}; + +struct uni_hid_device_t; +typedef void (*uni_play_dual_rumble_t)(uni_hid_device_t*, uint16_t, + uint16_t, uint8_t, uint8_t); + +struct uni_report_parser_t { + uni_play_dual_rumble_t play_dual_rumble; +}; + +struct uni_hid_device_t { + int idx; + bool gamepad; + uni_report_parser_t report_parser; + int rumble_calls; + uint8_t last_high; + uint8_t last_low; +}; + +struct uni_platform { + const char* name; + void (*init)(int, const char**); + void (*on_init_complete)(); + uni_error_t (*on_device_discovered)(bd_addr_t, const char*, uint16_t, + uint8_t); + void (*on_device_connected)(uni_hid_device_t*); + void (*on_device_disconnected)(uni_hid_device_t*); + uni_error_t (*on_device_ready)(uni_hid_device_t*); + void* on_device_oob_event; + void (*on_controller_data)(uni_hid_device_t*, uni_controller_t*); + const uni_property_t* (*get_property)(uni_property_idx_t); + void (*on_oob_event)(uni_platform_oob_event_t, void*); + void* on_device_dump; + void* on_gamepad_seat; +}; + +bool uni_hid_device_is_gamepad(const uni_hid_device_t* device); +int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device); +void uni_bt_allow_incoming_connections(bool enabled); +void uni_bt_start_scanning_and_autoconnect_unsafe(); +void uni_bt_stop_scanning_unsafe(); +void uni_platform_set_custom(uni_platform* platform); +int uni_init(int argc, const char** argv); diff --git a/tests/switch_pro_descriptors_test.cpp b/tests/switch_pro_descriptors_test.cpp new file mode 100644 index 0000000..9ce2130 --- /dev/null +++ b/tests/switch_pro_descriptors_test.cpp @@ -0,0 +1,169 @@ +#include "switch_pro_descriptors.h" +#include "tusb_config.h" + +#include +#include +#include +#include +#include + +#ifndef EXPECTED_HID_INSTANCE_COUNT +#error "EXPECTED_HID_INSTANCE_COUNT must be defined by the test build" +#endif + +static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == EXPECTED_HID_INSTANCE_COUNT, + "the requested HID instance count did not reach the descriptors"); +static_assert(CFG_TUD_HID == EXPECTED_HID_INSTANCE_COUNT, + "TinyUSB HID count differs from the descriptor count"); +static_assert(sizeof(switch_pro_configuration_descriptor) == + 9u + 32u * EXPECTED_HID_INSTANCE_COUNT, + "configuration descriptor has the wrong total size"); + +namespace { + +constexpr uint8_t kConfigurationDescriptor = 0x02; +constexpr uint8_t kInterfaceDescriptor = 0x04; +constexpr uint8_t kEndpointDescriptor = 0x05; +constexpr uint8_t kHidDescriptor = 0x21; + +int failures = 0; + +void expect(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + ++failures; + } +} + +uint16_t read_u16(const uint8_t* bytes) { + return static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8u); +} + +struct InterfaceContract { + bool present = false; + bool in_endpoint = false; + bool out_endpoint = false; + uint8_t endpoint_count = 0; + uint8_t hid_count = 0; +}; + +void inspect_configuration_descriptor() { + const auto* descriptor = switch_pro_configuration_descriptor; + constexpr size_t descriptor_size = + sizeof(switch_pro_configuration_descriptor); + + expect(descriptor[0] == 9 && descriptor[1] == kConfigurationDescriptor, + "configuration header is malformed"); + expect(read_u16(descriptor + 2) == descriptor_size, + "wTotalLength does not match the emitted descriptor"); + expect(descriptor[4] == EXPECTED_HID_INSTANCE_COUNT, + "bNumInterfaces does not match the HID instance count"); + + std::array interfaces{}; + std::array endpoint_addresses{}; + int current_interface = -1; + size_t offset = descriptor[0]; + + while (offset < descriptor_size) { + const uint8_t length = descriptor[offset]; + expect(length >= 2, "descriptor block has an invalid length"); + if (length < 2) { + break; + } + expect(offset + length <= descriptor_size, + "descriptor block extends beyond wTotalLength"); + if (offset + length > descriptor_size) { + break; + } + + const uint8_t type = descriptor[offset + 1]; + if (type == kInterfaceDescriptor) { + expect(length == 9, "interface descriptor has the wrong length"); + const uint8_t number = descriptor[offset + 2]; + expect(number < interfaces.size(), + "interface number is outside the configured range"); + if (number < interfaces.size()) { + expect(!interfaces[number].present, + "interface number is duplicated"); + interfaces[number].present = true; + current_interface = number; + } else { + current_interface = -1; + } + expect(descriptor[offset + 3] == 0, + "interface uses an unexpected alternate setting"); + expect(descriptor[offset + 4] == 2, + "interface does not declare two endpoints"); + expect(descriptor[offset + 5] == 0x03, + "interface is not HID class"); + } else if (type == kHidDescriptor) { + expect(current_interface >= 0, + "HID descriptor appears before an interface"); + expect(length == sizeof(switch_pro_hid_descriptor), + "HID descriptor has the wrong length"); + expect(std::memcmp(descriptor + offset, switch_pro_hid_descriptor, + sizeof(switch_pro_hid_descriptor)) == 0, + "interfaces do not reuse the shared HID/report contract"); + expect(read_u16(descriptor + offset + 7) == + sizeof(switch_pro_report_descriptor), + "HID descriptor advertises the wrong report descriptor size"); + if (current_interface >= 0) { + ++interfaces[static_cast(current_interface)].hid_count; + } + } else if (type == kEndpointDescriptor) { + expect(current_interface >= 0, + "endpoint descriptor appears before an interface"); + expect(length == 7, "endpoint descriptor has the wrong length"); + const uint8_t address = descriptor[offset + 2]; + expect(!endpoint_addresses[address], + "endpoint address is duplicated across interfaces"); + endpoint_addresses[address] = true; + expect(descriptor[offset + 3] == 0x03, + "endpoint is not interrupt type"); + expect(read_u16(descriptor + offset + 4) == + SWITCH_PRO_ENDPOINT_SIZE, + "endpoint has the wrong maximum packet size"); + expect(descriptor[offset + 6] == 8, + "endpoint has the wrong polling interval"); + + if (current_interface >= 0) { + auto& interface = + interfaces[static_cast(current_interface)]; + ++interface.endpoint_count; + const uint8_t endpoint_number = + static_cast(current_interface + 1); + if ((address & 0x80u) != 0) { + expect(address == static_cast(0x80u | endpoint_number), + "IN endpoint does not belong to its interface"); + interface.in_endpoint = true; + } else { + expect(address == endpoint_number, + "OUT endpoint does not belong to its interface"); + interface.out_endpoint = true; + } + } + } + + offset += length; + } + + expect(offset == descriptor_size, + "descriptor parser did not finish at wTotalLength"); + for (const auto& interface : interfaces) { + expect(interface.present, "configured HID interface is missing"); + expect(interface.hid_count == 1, + "interface does not contain exactly one HID descriptor"); + expect(interface.endpoint_count == 2, + "interface does not contain exactly two endpoints"); + expect(interface.in_endpoint && interface.out_endpoint, + "interface is missing an IN or OUT endpoint"); + } +} + +} // namespace + +int main() { + inspect_configuration_descriptor(); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py new file mode 100644 index 0000000..20ebed7 --- /dev/null +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + + executable = tmp_path / "bluepad32_backend_lifecycle_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-DSWITCH_PICO_HID_INSTANCE_COUNT=2", + f"-I{root / 'tests' / 'bluepad32_native_stubs'}", + f"-I{root}", + str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + + for scenario in ("ready-0-1", "ready-1-0", "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 new file mode 100644 index 0000000..c53f275 --- /dev/null +++ b/tests/test_switch_pro_descriptors_native.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def compile_descriptor_test( + root: Path, + compiler: str, + output: Path, + expected_count: int, + configured_count: int | None, +) -> subprocess.CompletedProcess[str]: + command = [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-DEXPECTED_HID_INSTANCE_COUNT={expected_count}", + ] + if configured_count is not None: + command.append(f"-DSWITCH_PICO_HID_INSTANCE_COUNT={configured_count}") + command.extend( + [ + f"-I{root}", + str(root / "tests" / "switch_pro_descriptors_test.cpp"), + "-o", + str(output), + ] + ) + return subprocess.run( + command, + check=False, + cwd=root, + text=True, + capture_output=True, + ) + + +def host_compiler() -> str: + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + return compiler + + +def test_default_descriptor_contract_is_single_hid(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + executable = tmp_path / "switch_pro_descriptors_default_test" + result = compile_descriptor_test(root, host_compiler(), executable, 1, None) + assert result.returncode == 0, result.stderr + subprocess.run([str(executable)], check=True, cwd=root) + + +def test_explicit_single_descriptor_contract(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) + + +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): + executable = tmp_path / f"switch_pro_descriptors_invalid_{unsupported_count}" + result = compile_descriptor_test( + root, + compiler, + executable, + unsupported_count, + unsupported_count, + ) + assert result.returncode != 0, ( + f"unsupported HID instance count {unsupported_count} compiled successfully" + ) diff --git a/tusb_config.h b/tusb_config.h index 7f291ec..30155db 100644 --- a/tusb_config.h +++ b/tusb_config.h @@ -1,11 +1,19 @@ -// TinyUSB configuration tailored for a single Switch Pro style HID interface. -// Data is derived from TinyUSB examples and tuned for a 64-byte HID endpoint. +// TinyUSB configuration for one or two Switch Pro style HID interfaces. +// Each interface uses independent 64-byte interrupt IN and OUT endpoints. #ifndef _TUSB_CONFIG_H_ #define _TUSB_CONFIG_H_ #ifdef __cplusplus extern "C" { #endif +#ifndef SWITCH_PICO_HID_INSTANCE_COUNT +#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" +#endif + #define CFG_TUSB_RHPORT0_MODE (OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED) #ifndef CFG_TUSB_OS @@ -23,7 +31,7 @@ extern "C" { #define CFG_TUD_ENDPOINT0_SIZE 64 // Device class configuration -#define CFG_TUD_HID 1 +#define CFG_TUD_HID SWITCH_PICO_HID_INSTANCE_COUNT #define CFG_TUD_CDC 0 #define CFG_TUD_MSC 0 #define CFG_TUD_MIDI 0