Add dual-controller AIO USB transport

This commit is contained in:
Joey Yakimowich-Payne 2026-08-30 20:54:59 -06:00
commit edf6ecaae1
19 changed files with 1007 additions and 116 deletions

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@
#include <pico/cyw43_arch.h>
#include <pico/multicore.h>
#include <pico/stdlib.h>
#include <pico/util/queue.h>
#include <uni.h>
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<uint8_t>(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);
}

View file

@ -2,11 +2,14 @@
#include <stdint.h>
#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);

View file

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

View file

@ -8,6 +8,14 @@
#pragma once
#include <stdint.h>
#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[] =

View file

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

View file

@ -0,0 +1,261 @@
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <uni.h>
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;
}

View file

@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
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() {}

View file

@ -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*) {}

View file

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

View file

@ -0,0 +1,3 @@
#pragma once
void multicore_launch_core1(void (*entry)());

View file

@ -0,0 +1,3 @@
#pragma once
inline void tight_loop_contents() {}

View file

@ -0,0 +1,98 @@
#pragma once
#include <stdint.h>
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);

View file

@ -0,0 +1,169 @@
#include "switch_pro_descriptors.h"
#include "tusb_config.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <iostream>
#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<uint16_t>(bytes[0]) |
(static_cast<uint16_t>(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<InterfaceContract, EXPECTED_HID_INSTANCE_COUNT> interfaces{};
std::array<bool, 256> 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<size_t>(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<size_t>(current_interface)];
++interface.endpoint_count;
const uint8_t endpoint_number =
static_cast<uint8_t>(current_interface + 1);
if ((address & 0x80u) != 0) {
expect(address == static_cast<uint8_t>(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;
}

View file

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

View file

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

View file

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