Compare commits

...

2 commits

20 changed files with 1330 additions and 28 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
Switch-Fightstick
GP2040-CE
build
build-aio
debug
.pycache
*.egg-info

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "external/bluepad32"]
path = external/bluepad32
url = https://github.com/ricardoquesada/bluepad32.git

View file

@ -24,7 +24,20 @@ if (EXISTS ${picoVscode})
endif()
# ====================================================================================
option(SWITCH_PICO_LOG "Enable UART debug logging" OFF)
set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend")
set_property(CACHE SWITCH_PICO_INPUT_BACKEND PROPERTY STRINGS UART BLUEPAD32)
if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART"
AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
message(FATAL_ERROR
"Unknown SWITCH_PICO_INPUT_BACKEND='${SWITCH_PICO_INPUT_BACKEND}'. "
"Expected UART or BLUEPAD32.")
endif()
set(PICO_BOARD pico CACHE STRING "Board type")
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32"
AND NOT PICO_BOARD STREQUAL "pico2_w")
message(FATAL_ERROR
"SWITCH_PICO_INPUT_BACKEND=BLUEPAD32 requires PICO_BOARD=pico2_w")
endif()
# Pull in Raspberry Pi Pico SDK (must be before project)
include(pico_sdk_import.cmake)
@ -34,12 +47,51 @@ project(switch-pico C CXX ASM)
# Initialise the Raspberry Pi Pico SDK
pico_sdk_init()
# Configure BLUEPAD32 input backend if selected
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
# Ensure Python3 is available and execute patch preparation
find_package(Python3 REQUIRED COMPONENTS Interpreter)
set(BLUEPAD32_PREP_SCRIPT ${CMAKE_CURRENT_LIST_DIR}/tools/prepare_bluepad32.py)
execute_process(
COMMAND ${Python3_EXECUTABLE} ${BLUEPAD32_PREP_SCRIPT}
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
RESULT_VARIABLE BLUEPAD32_PREP_RESULT
OUTPUT_VARIABLE BLUEPAD32_PREP_OUTPUT
ERROR_VARIABLE BLUEPAD32_PREP_ERROR
)
if(NOT BLUEPAD32_PREP_RESULT EQUAL 0)
message(FATAL_ERROR
"Failed to prepare Bluepad32: Patch application or validation failed. "
"Details: ${BLUEPAD32_PREP_ERROR}")
endif()
# Configure Bluepad32 include paths and subdirectory
set(BLUEPAD32_ROOT ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32)
set(BTSTACK_ROOT ${PICO_SDK_PATH}/lib/btstack)
include_directories(
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config
${BTSTACK_ROOT}/3rd-party/bluedroid/encoder/include
${BTSTACK_ROOT}/3rd-party/bluedroid/decoder/include
${BTSTACK_ROOT}/src
)
add_subdirectory(
${BLUEPAD32_ROOT}/src/components/bluepad32
${CMAKE_CURRENT_BINARY_DIR}/libbluepad32
)
endif()
# Add executable. Default name is the project name, version 0.1
add_executable(switch-pico
switch-pico.cpp
switch_pro_driver.cpp
)
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)
endif()
pico_set_program_name(switch-pico "switch-pico")
pico_set_program_version(switch-pico "0.1")
@ -57,6 +109,16 @@ target_link_libraries(switch-pico
hardware_uart
pico_rand
)
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
target_link_libraries(switch-pico
bluepad32
pico_cyw43_arch_none
pico_btstack_ble
pico_btstack_classic
pico_btstack_cyw43
pico_multicore
)
endif()
if (SWITCH_PICO_LOG)
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_LOG=1)

View file

@ -1,11 +1,12 @@
# Switch Pico Controller Bridge
Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB and a host bridge that forwards real gamepad input over UART (with rumble round-trip).
Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from a Bluetooth controller through Bluepad32.
## What you get
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a wired Switch Pro. Takes controller reports over UART1 and passes rumble from the Switch back over UART.
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a wired Switch Pro, accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend.
- **Python bridge** (`switch_pico_bridge.controller_uart_bridge` / CLI `controller-uart-bridge`): reads SDL3 controllers on the host, sends reports over UART, and applies rumble locally. Hot‑plug friendly and cross‑platform (macOS/Windows/Linux).
- **Colour override** (`controller_color_config.h`): compile‑time RGB overrides for body/buttons/grips as seen by the Switch.
- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts one Bluetooth controller and sends its controls, calibrated motion, and rumble through the same Switch Pro USB device without a computer.
## Quick start
1. Flash the Pico with `firmware/switch-pico.uf2` (or build your own) using BOOTSEL drag-and-drop (see “Manual UF2 flashing” below).
@ -14,12 +15,66 @@ Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB and a
4. Install the Python bridge (see “Python bridge”) and run `controller-uart-bridge --interactive`.
5. Connect the Pico to the Switch (dock USB-A or USB-C OTG); the Switch should see it as a wired Pro Controller.
## Pico 2 W all-in-one Bluetooth option
The AIO build runs TinyUSB and Switch report generation on Core 0 while Bluepad32, BTstack, and the CYW43439 radio run on Core 1. A fixed state snapshot and bounded rumble queue are the only cross-core interfaces.
### Build and flash
Initialize the pinned Bluepad32 dependency once:
```sh
git submodule update --init external/bluepad32
```
Build and flash a Pico 2 W in BOOTSEL mode:
```sh
python3 build.py --aio
```
This uses an isolated `build-aio/` CMake cache and publishes:
- `firmware/switch-pico-aio.elf`
- `firmware/switch-pico-aio.uf2`
The default `python3 build.py` command and `firmware/switch-pico.*` artifacts remain the UART/Pico build. The AIO build requires `PICO_BOARD=pico2_w`; it is not interchangeable with the original non-wireless Pico firmware.
Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0.
### Pair a controller
1. Flash and connect the Pico 2 W to the Switch.
2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`.
3. Put one controller into Bluetooth pairing mode:
- DualSense: hold Create + PS.
- DualShock 4: hold Share + PS.
- Switch Pro: press its sync button.
- Xbox Bluetooth controller: hold its pair button.
- 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required.
4. Wait for the controller to connect. Pairing keys persist across Pico reboots.
Only one wireless controller owns the emulated Pro Controller. Turn off or disconnect it before pairing another; scanning resumes automatically after disconnect. A disconnect immediately publishes neutral buttons, sticks, and motion.
### Controller capabilities
| Controller | Buttons/sticks | Rumble | Motion |
|---|---:|---:|---:|
| DualSense / DualShock 4 | Yes | Yes | Yes |
| Switch Pro | Yes | Yes | Yes |
| 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU |
| Xbox Bluetooth controller | Yes | Yes | No hardware IMU |
Motion is normalized to 1024 units per degree/second and 8192 units per g in SDL3 axes, then converted to Nintendo axes and raw counts. The latest normalized sample is duplicated across the report's three nominal 5 ms slots; it remains pending until a regular `0x30` USB report successfully consumes it.
Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license.
## Planned features
## Limitations
- No NFC/amiibo/IR support.
- Rumble is best-effort: it depends on the Switch sending rumble and SDL3 being able to drive haptics on your specific controller.
- Requires a host computer running the bridge; the Pico is not a Bluetooth/USB host for controllers.
- Rumble is best-effort: the UART build depends on SDL3 haptics; the AIO build depends on the connected controller's Bluepad32 rumble implementation.
- The UART firmware requires a host computer running the bridge. The Pico 2 W AIO firmware does not; it hosts controllers over Bluetooth, not USB.
## Uses
- **Remote couch co-op**: friends connect via Parsec while the host streams the Switch via a low-latency capture device (e.g., Magewell Pro Capture) and runs the bridge (see setup below).

View file

@ -0,0 +1,82 @@
#ifndef _PICO_BTSTACK_BTSTACK_CONFIG_H
#define _PICO_BTSTACK_BTSTACK_CONFIG_H
// Based on Bluepad32's official Pico W configuration. ENABLE_BLE and
// ENABLE_CLASSIC are supplied by the corresponding Pico SDK BTstack targets.
#define ENABLE_LOG_INFO
#define ENABLE_LOG_ERROR
#define ENABLE_PRINTF_HEXDUMP
#define ENABLE_SCO_OVER_HCI
#ifdef ENABLE_BLE
#define ENABLE_GATT_CLIENT_PAIRING
#define ENABLE_L2CAP_LE_CREDIT_BASED_FLOW_CONTROL_MODE
#define ENABLE_LE_CENTRAL
#define ENABLE_LE_DATA_LENGTH_EXTENSION
#define ENABLE_LE_PERIPHERAL
#define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION
#define ENABLE_LE_SECURE_CONNECTIONS
#else
#error "BP32: ENABLE_BLE should be defined"
#endif
#ifdef ENABLE_CLASSIC
#define ENABLE_L2CAP_ENHANCED_RETRANSMISSION_MODE
#define ENABLE_GOEP_L2CAP
#else
#error "BP32: ENABLE_CLASSIC should be defined"
#endif
#if defined(ENABLE_CLASSIC) && defined(ENABLE_BLE)
#define ENABLE_CROSS_TRANSPORT_KEY_DERIVATION
#endif
#define HCI_OUTGOING_PRE_BUFFER_SIZE 4
#define HCI_ACL_PAYLOAD_SIZE (1691 + 4)
#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4
#define MAX_NR_AVDTP_CONNECTIONS 1
#define MAX_NR_AVDTP_STREAM_ENDPOINTS 1
#define MAX_NR_AVRCP_CONNECTIONS 2
#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_HCI_CONNECTIONS 4
#define MAX_NR_HID_HOST_CONNECTIONS 1
#define MAX_NR_HIDS_CLIENTS 1
#define MAX_NR_HFP_CONNECTIONS 1
#define MAX_NR_L2CAP_CHANNELS 6
#define MAX_NR_L2CAP_SERVICES 5
#define MAX_NR_RFCOMM_CHANNELS 1
#define MAX_NR_RFCOMM_MULTIPLEXERS 1
#define MAX_NR_RFCOMM_SERVICES 1
#define MAX_NR_SERVICE_RECORD_ITEMS 4
#define MAX_NR_SM_LOOKUP_ENTRIES 3
#define MAX_NR_WHITELIST_ENTRIES 16
#define MAX_NR_LE_DEVICE_DB_ENTRIES 16
// Keep controller buffers and controller-to-host flow control enabled to avoid
// overrunning the shared CYW43 bus.
#define MAX_NR_CONTROLLER_ACL_BUFFERS 3
#define MAX_NR_CONTROLLER_SCO_PACKETS 3
#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
#define HCI_HOST_ACL_PACKET_LEN 1024
#define HCI_HOST_ACL_PACKET_NUM 3
#define HCI_HOST_SCO_PACKET_LEN 120
#define HCI_HOST_SCO_PACKET_NUM 3
// Persistent Classic and BLE pairing databases use Pico flash-backed TLV.
#define NVM_NUM_DEVICE_DB_ENTRIES 16
#define NVM_NUM_LINK_KEYS 16
// Bluepad32 does not provide malloc to BTstack.
#define MAX_ATT_DB_SIZE 512
#define HAVE_EMBEDDED_TIME_MS
#define HAVE_ASSERT
#define HCI_RESET_RESEND_TIMEOUT_MS 1000
#define ENABLE_SOFTWARE_AES128
#define ENABLE_MICRO_ECC_FOR_LE_SECURE_CONNECTIONS
#define HAVE_BTSTACK_STDIN
#endif // _PICO_BTSTACK_BTSTACK_CONFIG_H

View file

@ -0,0 +1,13 @@
#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
#define CONFIG_BLUEPAD32_GAP_SECURITY 1
#define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT 1
#define CONFIG_BLUEPAD32_PLATFORM_CUSTOM
#define CONFIG_TARGET_PICO_W
// 2 == Info
#define CONFIG_BLUEPAD32_LOG_LEVEL 2

402
bluepad32_input_backend.cpp Normal file
View file

@ -0,0 +1,402 @@
#include "bluepad32_input_backend.h"
#include <limits.h>
#include <stddef.h>
#include <string.h>
#include <btstack_run_loop.h>
#include <pico/critical_section.h>
#include <pico/cyw43_arch.h>
#include <pico/multicore.h>
#include <pico/stdlib.h>
#include <pico/util/queue.h>
#include <uni.h>
namespace {
constexpr uint16_t kStickMidpoint = 32768;
constexpr int32_t kAxisMinimum = -512;
constexpr int32_t kAxisMaximum = 511;
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;
struct RumblePacket {
uint8_t bytes[8];
};
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;
// These generations are only read or written by Core 0.
uint32_t g_consumed_generation = 0;
uint32_t g_last_snapshot_generation = 0;
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;
btstack_timer_source_t g_rumble_timer{};
SwitchInputState make_neutral_state() {
SwitchInputState state{};
state.lx = kStickMidpoint;
state.ly = kStickMidpoint;
state.rx = kStickMidpoint;
state.ry = kStickMidpoint;
return state;
}
void publish_state(const SwitchInputState& state, bool controller_active) {
critical_section_enter_blocking(&g_state_lock);
g_shared_state = state;
g_shared_controller_active = controller_active;
++g_shared_generation;
critical_section_exit(&g_state_lock);
}
constexpr int32_t clamp_axis(int32_t value) {
if (value < kAxisMinimum) {
return kAxisMinimum;
}
if (value > kAxisMaximum) {
return kAxisMaximum;
}
return value;
}
constexpr uint16_t scale_stick(int32_t value) {
value = clamp_axis(value);
if (value <= 0) {
return static_cast<uint16_t>(
(static_cast<int64_t>(value - kAxisMinimum) * kStickMidpoint) / -kAxisMinimum);
}
return static_cast<uint16_t>(
kStickMidpoint + (static_cast<int64_t>(value) * (UINT16_MAX - kStickMidpoint)) / kAxisMaximum);
}
constexpr int16_t clamp_int16(int64_t value) {
if (value < INT16_MIN) {
return INT16_MIN;
}
if (value > INT16_MAX) {
return INT16_MAX;
}
return static_cast<int16_t>(value);
}
constexpr int64_t divide_round_nearest(int64_t numerator, int64_t denominator) {
if (numerator >= 0) {
return (numerator + denominator / 2) / denominator;
}
return -((-numerator + denominator / 2) / denominator);
}
constexpr int16_t convert_accel(int64_t q13_value) {
return clamp_int16(q13_value / 2);
}
constexpr int16_t convert_gyro(int64_t q10_value) {
constexpr int64_t kNumeratorScale = 13371;
constexpr int64_t kDenominator = 1024 * 936;
return clamp_int16(divide_round_nearest(q10_value * kNumeratorScale, kDenominator));
}
static_assert(scale_stick(-512) == 0);
static_assert(scale_stick(0) == 32768);
static_assert(scale_stick(511) == UINT16_MAX);
static_assert(convert_accel(8192) == 4096);
static_assert(convert_accel(-8192) == -4096);
static_assert(convert_gyro(1024) == 14);
static_assert(convert_gyro(-1024) == -14);
bool has_motion(const uni_gamepad_t& gamepad) {
for (size_t i = 0; i < 3; ++i) {
if (gamepad.accel[i] != 0 || gamepad.gyro[i] != 0) {
return true;
}
}
return false;
}
SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
SwitchInputState state = make_neutral_state();
state.dpad_up = (gamepad.dpad & DPAD_UP) != 0;
state.dpad_down = (gamepad.dpad & DPAD_DOWN) != 0;
state.dpad_left = (gamepad.dpad & DPAD_LEFT) != 0;
state.dpad_right = (gamepad.dpad & DPAD_RIGHT) != 0;
// Bluepad32's A/B/X/Y are positional: south/east/west/north.
state.button_b = (gamepad.buttons & BUTTON_A) != 0;
state.button_a = (gamepad.buttons & BUTTON_B) != 0;
state.button_y = (gamepad.buttons & BUTTON_X) != 0;
state.button_x = (gamepad.buttons & BUTTON_Y) != 0;
state.button_l = (gamepad.buttons & BUTTON_SHOULDER_L) != 0;
state.button_r = (gamepad.buttons & BUTTON_SHOULDER_R) != 0;
state.button_zl = (gamepad.buttons & BUTTON_TRIGGER_L) != 0 || gamepad.brake >= kTriggerThreshold;
state.button_zr = (gamepad.buttons & BUTTON_TRIGGER_R) != 0 || gamepad.throttle >= kTriggerThreshold;
state.button_l3 = (gamepad.buttons & BUTTON_THUMB_L) != 0;
state.button_r3 = (gamepad.buttons & BUTTON_THUMB_R) != 0;
state.button_minus = (gamepad.misc_buttons & MISC_BUTTON_SELECT) != 0;
state.button_plus = (gamepad.misc_buttons & MISC_BUTTON_START) != 0;
state.button_home = (gamepad.misc_buttons & MISC_BUTTON_SYSTEM) != 0;
state.button_capture = (gamepad.misc_buttons & MISC_BUTTON_CAPTURE) != 0;
state.lx = scale_stick(gamepad.axis_x);
state.ly = scale_stick(gamepad.axis_y);
state.rx = scale_stick(gamepad.axis_rx);
state.ry = scale_stick(gamepad.axis_ry);
if (has_motion(gamepad)) {
// Dependency patches normalize both arrays to SDL3 PlayStation axes.
SwitchImuSample sample{};
sample.accel_x = convert_accel(-static_cast<int64_t>(gamepad.accel[2]));
sample.accel_y = convert_accel(-static_cast<int64_t>(gamepad.accel[0]));
sample.accel_z = convert_accel(gamepad.accel[1]);
sample.gyro_x = convert_gyro(-static_cast<int64_t>(gamepad.gyro[2]));
sample.gyro_y = convert_gyro(-static_cast<int64_t>(gamepad.gyro[0]));
sample.gyro_z = convert_gyro(gamepad.gyro[1]);
state.imu_sample_count = 3;
for (SwitchImuSample& destination : state.imu_samples) {
destination = sample;
}
}
return state;
}
void decode_rumble(const uint8_t bytes[8], uint8_t* left_magnitude, uint8_t* right_magnitude) {
static constexpr uint8_t kNeutralPacket[8] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
if (memcmp(bytes, kNeutralPacket, sizeof(kNeutralPacket)) == 0) {
*left_magnitude = 0;
*right_magnitude = 0;
return;
}
uint16_t right_raw = static_cast<uint16_t>(((bytes[1] & 0x03) << 8) | bytes[0]);
uint16_t left_raw = static_cast<uint16_t>(((bytes[5] & 0x03) << 8) | bytes[4]);
if (left_raw < 8 && right_raw < 8) {
left_raw = 0;
right_raw = 0;
}
*left_magnitude = static_cast<uint8_t>((left_raw * UINT8_MAX + 511) / 1023);
*right_magnitude = static_cast<uint8_t>((right_raw * UINT8_MAX + 511) / 1023);
}
void process_rumble_timer(btstack_timer_source_t* timer) {
RumblePacket packet{};
RumblePacket latest{};
bool have_packet = false;
while (queue_try_remove(&g_rumble_queue, &packet)) {
latest = packet;
have_packet = true;
}
if (have_packet && g_active_device != nullptr &&
g_active_device->report_parser.play_dual_rumble != nullptr) {
uint8_t left_magnitude = 0;
uint8_t right_magnitude = 0;
decode_rumble(latest.bytes, &left_magnitude, &right_magnitude);
// Bluepad orders the weak (high-frequency) motor before the strong
// (low-frequency) motor; the project decoder names those right/left.
g_active_device->report_parser.play_dual_rumble(
g_active_device, 0, kRumbleDurationMs, right_magnitude, left_magnitude);
}
btstack_run_loop_set_timer(timer, kRumblePollIntervalMs);
btstack_run_loop_add_timer(timer);
}
void platform_init(int argc, const char** argv) {
(void)argc;
(void)argv;
}
void platform_on_init_complete() {
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs);
btstack_run_loop_add_timer(&g_rumble_timer);
uni_bt_allow_incoming_connections(true);
uni_bt_start_scanning_and_autoconnect_unsafe();
}
uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) {
(void)addr;
(void)name;
(void)cod;
(void)rssi;
return g_active_device == nullptr ? UNI_ERROR_SUCCESS : UNI_ERROR_IGNORE_DEVICE;
}
void platform_on_device_connected(uni_hid_device_t* device) {
(void)device;
}
void resume_connections() {
uni_bt_allow_incoming_connections(true);
uni_bt_start_scanning_and_autoconnect_unsafe();
}
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);
resume_connections();
} else if (g_active_device == nullptr) {
resume_connections();
}
}
uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
if (!uni_hid_device_is_gamepad(device)) {
return UNI_ERROR_INVALID_CONTROLLER;
}
if (g_active_device != nullptr && g_active_device != device) {
return UNI_ERROR_NO_SLOTS;
}
g_active_device = device;
publish_state(make_neutral_state(), true);
uni_bt_stop_scanning_unsafe();
uni_bt_allow_incoming_connections(false);
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) {
return;
}
publish_state(map_gamepad(controller->gamepad), true);
}
const uni_property_t* platform_get_property(uni_property_idx_t index) {
(void)index;
return nullptr;
}
void platform_on_oob_event(uni_platform_oob_event_t event, void* data) {
(void)event;
(void)data;
}
uni_platform* get_platform() {
static uni_platform platform = {
"Switch Pico",
platform_init,
platform_on_init_complete,
platform_on_device_discovered,
platform_on_device_connected,
platform_on_device_disconnected,
platform_on_device_ready,
nullptr,
platform_on_controller_data,
platform_get_property,
platform_on_oob_event,
nullptr,
nullptr,
};
return &platform;
}
[[noreturn]] void core1_main() {
if (cyw43_arch_init() != 0) {
publish_state(make_neutral_state(), false);
while (true) {
tight_loop_contents();
}
}
uni_platform_set_custom(get_platform());
if (uni_init(0, nullptr) != 0) {
publish_state(make_neutral_state(), false);
while (true) {
tight_loop_contents();
}
}
btstack_run_loop_execute();
while (true) {
tight_loop_contents();
}
}
} // namespace
void bluepad32_input_backend_init() {
if (g_initialized) {
return;
}
critical_section_init(&g_state_lock);
queue_init(&g_rumble_queue, sizeof(RumblePacket), 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;
g_initialized = true;
}
void bluepad32_input_backend_start() {
if (!g_initialized) {
bluepad32_input_backend_init();
}
if (g_started) {
return;
}
g_started = true;
multicore_launch_core1(core1_main);
}
bool bluepad32_input_backend_snapshot(SwitchInputState* out) {
if (out == nullptr) {
return false;
}
if (!g_initialized) {
*out = make_neutral_state();
return false;
}
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;
critical_section_exit(&g_state_lock);
if (generation == g_consumed_generation) {
out->imu_sample_count = 0;
}
g_last_snapshot_generation = generation;
return controller_active;
}
void bluepad32_input_backend_report_sent() {
if (!g_initialized) {
return;
}
g_consumed_generation = g_last_snapshot_generation;
}
void bluepad32_input_backend_queue_rumble(const uint8_t rumble[8]) {
if (!g_initialized || rumble == nullptr) {
return;
}
RumblePacket packet{};
memcpy(packet.bytes, rumble, sizeof(packet.bytes));
if (!queue_try_add(&g_rumble_queue, &packet)) {
RumblePacket discarded{};
(void)queue_try_remove(&g_rumble_queue, &discarded);
(void)queue_try_add(&g_rumble_queue, &packet);
}
}

11
bluepad32_input_backend.h Normal file
View file

@ -0,0 +1,11 @@
#pragma once
#include <stdint.h>
#include "switch_pro_driver.h"
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 uint8_t rumble[8]);

View file

@ -12,9 +12,12 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "controller_color_config.h"
BUILD_DIR = SCRIPT_DIR / "build"
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf"
AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.uf2"
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser()
@ -34,6 +37,11 @@ def parse_args():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Default behavior leaves controller_color_config.h unchanged.",
)
parser.add_argument(
"--aio",
action="store_true",
help="Build and flash the Pico 2 W Bluepad32 all-in-one firmware.",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--random-grip-color",
@ -111,41 +119,68 @@ def resolve_picotool():
sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n")
sys.exit(1)
def build():
def build(
aio,
build_dir,
elf_path,
uf2_path,
firmware_elf_path,
firmware_uf2_path,
):
if aio:
run_cmd([sys.executable, str(SCRIPT_DIR / "tools" / "prepare_bluepad32.py")])
definitions = [
"-DSWITCH_PICO_LOG=OFF",
"-DPICO_BOARD=pico2_w",
"-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32",
]
else:
definitions = [
"-DSWITCH_PICO_LOG=OFF",
"-DPICO_BOARD=pico",
"-DSWITCH_PICO_INPUT_BACKEND=UART",
]
run_cmd(
[
"cmake",
"-S",
str(SCRIPT_DIR),
"-B",
str(BUILD_DIR),
"-DSWITCH_PICO_LOG=OFF",
str(build_dir),
*definitions,
]
)
run_cmd(["cmake", "--build", str(BUILD_DIR)])
run_cmd(["cmake", "--build", str(build_dir)])
missing_artifacts = [path for path in (ELF_PATH, UF2_PATH) if not path.is_file()]
missing_artifacts = [
path for path in (elf_path, uf2_path) if not path.is_file()
]
if missing_artifacts:
missing = ", ".join(str(path) for path in missing_artifacts)
sys.stderr.write(f"Error: Build did not produce required artifact(s): {missing}\n")
sys.exit(1)
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(ELF_PATH, FIRMWARE_ELF_PATH)
shutil.copy2(UF2_PATH, FIRMWARE_UF2_PATH)
shutil.copy2(elf_path, firmware_elf_path)
shutil.copy2(uf2_path, firmware_uf2_path)
print(f"Built ELF: {ELF_PATH}")
print(f"Built UF2: {UF2_PATH}")
print(f"Copied ELF: {FIRMWARE_ELF_PATH}")
print(f"Copied UF2: {FIRMWARE_UF2_PATH}")
print(f"Built ELF: {elf_path}")
print(f"Built UF2: {uf2_path}")
print(f"Copied ELF: {firmware_elf_path}")
print(f"Copied UF2: {firmware_uf2_path}")
def flash():
def flash(elf_path, allow_elf_override):
picotool = resolve_picotool()
if not ELF_PATH.exists():
sys.stderr.write(
f"Error: Cannot find ELF at {ELF_PATH}. Set ELF_PATH to override.\n"
)
if not elf_path.exists():
if allow_elf_override:
sys.stderr.write(
f"Error: Cannot find ELF at {elf_path}. Set ELF_PATH to override.\n"
)
else:
sys.stderr.write(f"Error: Cannot find ELF at {elf_path}.\n")
sys.exit(1)
run_cmd([str(picotool), "load", str(ELF_PATH), "-fx"])
run_cmd([str(picotool), "load", str(elf_path), "-fx"])
def main():
args = parse_args()
@ -164,8 +199,28 @@ def main():
update_grip_colors(color)
print(f"Grip color set to #{color} in {CONFIG_FILE.name}")
build()
flash()
if args.aio:
build_dir = AIO_BUILD_DIR
elf_path = AIO_BUILD_DIR / "switch-pico.elf"
uf2_path = AIO_BUILD_DIR / "switch-pico.uf2"
firmware_elf_path = AIO_FIRMWARE_ELF_PATH
firmware_uf2_path = AIO_FIRMWARE_UF2_PATH
else:
build_dir = BUILD_DIR
elf_path = ELF_PATH
uf2_path = UF2_PATH
firmware_elf_path = FIRMWARE_ELF_PATH
firmware_uf2_path = FIRMWARE_UF2_PATH
build(
args.aio,
build_dir,
elf_path,
uf2_path,
firmware_elf_path,
firmware_uf2_path,
)
flash(elf_path, allow_elf_override=not args.aio)
if __name__ == "__main__":
main()

1
external/bluepad32 vendored Submodule

@ -0,0 +1 @@
Subproject commit 6efa7123fe8badf5a40ad1205743a80b31c00ea4

BIN
firmware/switch-pico-aio.elf Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,154 @@
diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/components/bluepad32/parser/uni_hid_parser_ds4.c
index ea063b8..7670caf 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_ds4.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_ds4.c
@@ -297,17 +297,17 @@ void uni_hid_parser_ds4_parse_feature_report(uni_hid_device_t* d, const uint8_t*
// Set gyroscope calibration and normalization parameters.
// Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s.
speed_2x = r->gyro_speed_plus + r->gyro_speed_minus;
- ins->gyro_calib_data[0].bias = 0;
+ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias;
ins->gyro_calib_data[0].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[0].sens_denom =
abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias);
- ins->gyro_calib_data[1].bias = 0;
+ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias;
ins->gyro_calib_data[1].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[1].sens_denom =
abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias);
- ins->gyro_calib_data[2].bias = 0;
+ ins->gyro_calib_data[2].bias = r->gyro_roll_bias;
ins->gyro_calib_data[2].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[2].sens_denom =
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
@@ -476,7 +476,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor
// Gyro
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {
- int32_t raw_data = (int16_t)r->gyro[i];
+ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom);
ctl->gamepad.gyro[i] = calib_data;
@@ -484,7 +484,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor
// Accel
for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) {
- int32_t raw_data = (int16_t)r->accel[i];
+ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom);
ctl->gamepad.accel[i] = calib_data;
diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/components/bluepad32/parser/uni_hid_parser_ds5.c
index a22ef26..3d5ecef 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_ds5.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_ds5.c
@@ -487,17 +487,17 @@ void uni_hid_parser_ds5_parse_feature_report(uni_hid_device_t* d, const uint8_t*
// Set gyroscope calibration and normalization parameters.
// Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s.
speed_2x = r->gyro_speed_plus + r->gyro_speed_minus;
- ins->gyro_calib_data[0].bias = 0;
+ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias;
ins->gyro_calib_data[0].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[0].sens_denom =
abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias);
- ins->gyro_calib_data[1].bias = 0;
+ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias;
ins->gyro_calib_data[1].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[1].sens_denom =
abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias);
- ins->gyro_calib_data[2].bias = 0;
+ ins->gyro_calib_data[2].bias = r->gyro_roll_bias;
ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[2].sens_denom =
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
@@ -622,7 +622,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
// Gyro
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {
- int32_t raw_data = (int16_t)r->gyro[i];
+ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom);
ctl->gamepad.gyro[i] = calib_data;
@@ -630,7 +630,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
// Accel
for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) {
- int32_t raw_data = (int16_t)r->accel[i];
+ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias;
int32_t calib_data =
mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom);
ctl->gamepad.accel[i] = calib_data;
diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/components/bluepad32/parser/uni_hid_parser_switch.c
index 599fc35..c72f056 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_switch.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_switch.c
@@ -51,7 +51,8 @@ static const int16_t DEFAULT_ACCEL_OFFSET = 0;
static const int16_t DEFAULT_ACCEL_SCALE = 16384;
static const int16_t DEFAULT_GYRO_OFFSET = 0;
static const int16_t DEFAULT_GYRO_SCALE = 13371;
-#define SWITCH_IMU_PREC_RANGE_SCALE 1000
+#define SWITCH_IMU_GYRO_RES_PER_DEG_S 1024
+#define SWITCH_IMU_ACCEL_RES_PER_G 8192
#define SWITCH_FACTORY_IMU_CAL_DATA_SIZE 24
static const uint16_t SWITCH_FACTORY_IMU_CAL_DATA_ADDR = 0x6020;
@@ -823,19 +824,26 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
switch_instance_t* ins = get_switch_instance(d);
uni_controller_t* ctl = &d->controller;
- int accel[3];
- int gyro[3];
+ int32_t accel[3];
+ int32_t gyro[3];
for (int i = 0; i < 3; i++) {
- if (ins->imu_cal_accel_divisor[i] == 0)
- accel[i] = r->accel[i];
- else
- accel[i] = (r->accel[i] * ins->cal_accel.scale[i]) / ins->imu_cal_accel_divisor[i];
- gyro[i] = mult_frac((SWITCH_IMU_PREC_RANGE_SCALE * (r->gyro[i] - ins->cal_gyro.offset[i])),
- ins->cal_gyro.scale[i], ins->imu_cal_gyro_divisor[i]);
+ if (ins->imu_cal_accel_divisor[i] == 0) {
+ accel[i] = r->accel[i] * 2;
+ } else {
+ accel[i] = mult_frac(r->accel[i], 4 * SWITCH_IMU_ACCEL_RES_PER_G, ins->imu_cal_accel_divisor[i]);
+ }
+
+ if (ins->imu_cal_gyro_divisor[i] == 0) {
+ gyro[i] = mult_frac(r->gyro[i], 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S, DEFAULT_GYRO_SCALE);
+ } else {
+ gyro[i] = mult_frac(r->gyro[i] - ins->cal_gyro.offset[i],
+ 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S,
+ ins->imu_cal_gyro_divisor[i]);
+ }
}
- // Right joycon has Y and Z axes negated.
+ // Right Joy-Con has native Y and Z axes negated.
if (ins->controller_type == SWITCH_CONTROLLER_TYPE_JCR) {
accel[1] = -accel[1];
accel[2] = -accel[2];
@@ -843,10 +851,13 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
gyro[2] = -gyro[2];
}
- for (int i = 0; i < 3; i++) {
- ctl->gamepad.accel[i] = accel[i];
- ctl->gamepad.gyro[i] = gyro[i];
- }
+ // Match SDL3's PlayStation-oriented sensor coordinate convention.
+ ctl->gamepad.accel[0] = -accel[1];
+ ctl->gamepad.accel[1] = accel[2];
+ ctl->gamepad.accel[2] = -accel[0];
+ ctl->gamepad.gyro[0] = -gyro[1];
+ ctl->gamepad.gyro[1] = gyro[2];
+ ctl->gamepad.gyro[2] = -gyro[0];
}
// Process 0x30 input report: SWITCH_INPUT_IMU_DATA

View file

@ -1,10 +1,14 @@
#include <stdio.h>
#include <string.h>
#include "bsp/board.h"
#include "hardware/uart.h"
#include "pico/stdlib.h"
#include "tusb.h"
#include "switch_pro_driver.h"
#ifndef SWITCH_PICO_BLUEPAD32
#include "hardware/uart.h"
#else
#include "bluepad32_input_backend.h"
#endif
#ifdef SWITCH_PICO_LOG
#define LOG_PRINTF(...) printf(__VA_ARGS__)
@ -12,6 +16,7 @@
#define LOG_PRINTF(...) ((void)0)
#endif
#ifndef SWITCH_PICO_BLUEPAD32
// UART1 is reserved for external input frames from the host PC.
#define UART_ID uart1
#define BAUD_RATE 921600
@ -19,6 +24,7 @@
#define UART_RX_PIN 5
#define UART_RUMBLE_HEADER 0xBB
#define UART_RUMBLE_RUMBLE_TYPE 0x01
#endif
static bool g_last_mounted = false;
static bool g_last_ready = false;
@ -26,12 +32,14 @@ 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() {
uart_init(UART_ID, BAUD_RATE);
gpio_set_function(UART_TX_PIN, GPIO_FUNC_UART);
gpio_set_function(UART_RX_PIN, GPIO_FUNC_UART);
uart_set_format(UART_ID, 8, 1, UART_PARITY_NONE);
}
#endif
static SwitchInputState neutral_input() {
SwitchInputState state{};
@ -42,6 +50,7 @@ static SwitchInputState neutral_input() {
return state;
}
#ifndef SWITCH_PICO_BLUEPAD32
static void send_rumble_uart_frame(const uint8_t rumble[8]) {
uint8_t frame[11];
frame[0] = UART_RUMBLE_HEADER;
@ -55,11 +64,17 @@ static void send_rumble_uart_frame(const uint8_t rumble[8]) {
frame[10] = checksum;
uart_write_blocking(UART_ID, frame, sizeof(frame));
}
#endif
static void on_rumble_from_switch(const uint8_t rumble[8]) {
#ifdef SWITCH_PICO_BLUEPAD32
bluepad32_input_backend_queue_rumble(rumble);
#else
send_rumble_uart_frame(rumble);
#endif
}
#ifndef SWITCH_PICO_BLUEPAD32
// Consume UART bytes and forward complete frames to the Switch Pro driver.
static bool poll_uart_frames() {
static uint8_t buffer[64];
@ -134,6 +149,7 @@ static bool poll_uart_frames() {
return new_data;
}
#endif
static void log_usb_state() {
bool mounted = tud_mounted();
@ -153,7 +169,11 @@ int main() {
board_init();
stdio_init_all();
#ifdef SWITCH_PICO_BLUEPAD32
bluepad32_input_backend_init();
#else
init_uart_input();
#endif
tusb_init();
switch_pro_init();
@ -161,17 +181,32 @@ int main() {
g_user_state = neutral_input();
switch_pro_set_input(g_user_state);
#ifdef SWITCH_PICO_BLUEPAD32
bluepad32_input_backend_start();
LOG_PRINTF("[BOOT] switch-pico starting (Bluepad32 wireless @ 115200)\n");
#else
LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n");
LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n",
UART_TX_PIN, UART_RX_PIN, BAUD_RATE);
#endif
while (true) {
tud_task(); // USB device tasks
#ifdef SWITCH_PICO_BLUEPAD32
bluepad32_input_backend_snapshot(&g_user_state);
#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(state);
switch_pro_task(); // Push state to the Switch host
#ifdef SWITCH_PICO_BLUEPAD32
if (switch_pro_task()) {
bluepad32_input_backend_report_sent();
}
#else
(void)switch_pro_task();
#endif
log_usb_state();
}
}

View file

@ -753,9 +753,10 @@ void switch_pro_set_input(const SwitchInputState& state) {
g_input_state = state;
}
void switch_pro_task() {
bool switch_pro_task() {
uint32_t now = to_ms_since_boot(get_absolute_time());
report_sent = false;
bool regular_report_sent = false;
update_switch_report_from_state();
@ -784,6 +785,7 @@ void switch_pro_task() {
memcpy(last_report, inputReport, report_size);
g_input_state.imu_sample_count = 0;
report_sent = true;
regular_report_sent = true;
}
last_report_timer = now;
@ -799,6 +801,7 @@ void switch_pro_task() {
last_report_timer = now;
}
}
return regular_report_sent;
}
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length, SwitchInputState* out_state) {

View file

@ -55,8 +55,9 @@ void switch_pro_init();
// Update the desired controller state for the next USB report.
void switch_pro_set_input(const SwitchInputState& state);
// Drive the Switch Pro USB state machine; call this frequently in the main loop.
void switch_pro_task();
// Drive the Switch Pro USB state machine; returns true only when a regular
// 0x30 input report was successfully queued.
bool switch_pro_task();
// Convert a packed UART message into controller state (returns true if parsed).
// If out_state is null the parsed state is written directly to the driver.

View file

@ -0,0 +1,339 @@
"""
Tests for prepare_bluepad32.py patch preparation tool.
Tests cover:
- Fresh patch application
- Idempotence (second invocation succeeds without changing content)
- Missing paths validation
- Diverged/ambiguous repository states
"""
import subprocess
import tempfile
import pytest
from pathlib import Path
from unittest.mock import patch as mock_patch
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "tools"))
from prepare_bluepad32 import (
PatchError,
resolve_paths,
check_paths,
is_patch_applied,
apply_patch,
prepare_bluepad32,
)
@pytest.fixture
def temp_repo_structure():
"""Create a temporary directory structure with git repositories."""
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
# Create bluepad32 repo
bp_dir = root / "external" / "bluepad32"
bp_dir.mkdir(parents=True)
subprocess.run(["git", "init"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=bp_dir, check=True, capture_output=True)
# Create a file to patch
test_file = bp_dir / "test.txt"
test_file.write_text("line 1\n")
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "initial"], cwd=bp_dir, check=True, capture_output=True)
# Create patches dir
patches_dir = root / "patches"
patches_dir.mkdir()
yield root, bp_dir, patches_dir
def create_simple_patch(repo_path: Path, patch_path: Path, file_to_patch: str = "test.txt") -> str:
"""
Create a simple patch file that modifies a file in the repository.
Returns the patch content as a string.
"""
# Create the modification
test_file = repo_path / file_to_patch
original_content = test_file.read_text()
modified_content = original_content + "line 2\n"
# Generate patch using git diff
test_file.write_text(modified_content)
result = subprocess.run(
["git", "diff", file_to_patch],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
patch_content = result.stdout
# Reset the file to original state
test_file.write_text(original_content)
# Write patch to file
patch_path.write_text(patch_content)
return patch_content
def test_resolve_paths_with_defaults():
"""Test that resolve_paths returns expected default paths."""
with tempfile.TemporaryDirectory() as tmpdir:
bp_path, patch_path = resolve_paths(Path(tmpdir))
assert bp_path == Path(tmpdir) / "external" / "bluepad32"
assert patch_path == Path(tmpdir) / "patches" / "bluepad32-sdl3-imu.patch"
def test_resolve_paths_no_root():
"""Test resolve_paths with no root uses current directory."""
bp_path, patch_path = resolve_paths()
assert bp_path.is_absolute()
assert patch_path.is_absolute()
def test_check_paths_missing_bluepad32(temp_repo_structure):
"""Test that check_paths fails if bluepad32 dir is missing."""
root, bp_dir, patches_dir = temp_repo_structure
# Remove bluepad32
import shutil
shutil.rmtree(bp_dir)
patch_file = patches_dir / "test.patch"
patch_file.write_text("dummy")
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
check_paths(bp_dir, patch_file)
def test_check_paths_missing_patch(temp_repo_structure):
"""Test that check_paths fails if patch file is missing."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "nonexistent.patch"
with pytest.raises(PatchError, match="patch file does not exist"):
check_paths(bp_dir, patch_file)
def test_check_paths_bluepad32_not_git_repo(temp_repo_structure):
"""Test that check_paths fails if bluepad32 is not a git repo."""
root, bp_dir, patches_dir = temp_repo_structure
# Remove .git to make it not a git repo
import shutil
shutil.rmtree(bp_dir / ".git")
patch_file = patches_dir / "test.patch"
patch_file.write_text("dummy")
with pytest.raises(PatchError, match="not a git repository"):
check_paths(bp_dir, patch_file)
def test_fresh_patch_application(temp_repo_structure):
"""Test applying a fresh patch to a clean repository."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Verify test.txt before patch
test_file = bp_dir / "test.txt"
original = test_file.read_text()
assert "line 2" not in original
# Apply patch
apply_patch(bp_dir, patch_file)
# Verify test.txt after patch
patched = test_file.read_text()
assert "line 2" in patched
def test_idempotent_patch_application(temp_repo_structure):
"""Test that applying the same patch twice succeeds (idempotence)."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# First application
apply_patch(bp_dir, patch_file)
test_file = bp_dir / "test.txt"
after_first = test_file.read_text()
# Second application should succeed without changing content
apply_patch(bp_dir, patch_file)
after_second = test_file.read_text()
assert after_first == after_second
def test_is_patch_applied_not_applied(temp_repo_structure):
"""Test is_patch_applied returns False for unapplied patch."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Patch not applied yet
assert is_patch_applied(bp_dir, patch_file) is False
def test_is_patch_applied_already_applied(temp_repo_structure):
"""Test is_patch_applied returns True for already applied patch."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Apply patch first
apply_patch(bp_dir, patch_file)
# Now check should detect it's applied
assert is_patch_applied(bp_dir, patch_file) is True
def test_diverged_repository_state(temp_repo_structure):
"""Test that diverged repository (patch doesn't apply cleanly) is rejected."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Diverge the repository by modifying the file such that the patch conflicts
test_file = bp_dir / "test.txt"
test_file.write_text("completely different line 1\n")
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "divergence"], cwd=bp_dir, check=True, capture_output=True)
# Try to apply patch - should fail because file content doesn't match
with pytest.raises(PatchError, match="Patch validation failed"):
apply_patch(bp_dir, patch_file)
def test_missing_bluepad32_path(temp_repo_structure):
"""Test prepare_bluepad32 fails gracefully with missing bluepad32."""
root, bp_dir, patches_dir = temp_repo_structure
import shutil
shutil.rmtree(bp_dir)
patch_file = patches_dir / "test.patch"
patch_file.write_text("dummy")
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
prepare_bluepad32(bp_dir, patch_file)
def test_missing_patch_file(temp_repo_structure):
"""Test prepare_bluepad32 fails gracefully with missing patch file."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "nonexistent.patch"
with pytest.raises(PatchError, match="patch file does not exist"):
prepare_bluepad32(bp_dir, patch_file)
def test_prepare_bluepad32_full_workflow(temp_repo_structure):
"""Test complete prepare_bluepad32 workflow: apply then idempotent re-apply."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
test_file = bp_dir / "test.txt"
original = test_file.read_text()
# First prepare (should apply patch)
prepare_bluepad32(bp_dir, patch_file)
after_first = test_file.read_text()
assert after_first != original
assert "line 2" in after_first
# Second prepare (should be idempotent)
prepare_bluepad32(bp_dir, patch_file)
after_second = test_file.read_text()
assert after_first == after_second
def test_prepare_bluepad32_with_defaults(temp_repo_structure):
"""Test prepare_bluepad32 uses correct defaults when paths not provided."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "bluepad32-sdl3-imu.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Change to root directory and call with defaults
import os
original_cwd = os.getcwd()
try:
os.chdir(root)
prepare_bluepad32() # Use defaults
finally:
os.chdir(original_cwd)
# Verify patch was applied
test_file = bp_dir / "test.txt"
assert "line 2" in test_file.read_text()
def test_patch_application_with_conflicting_content(temp_repo_structure):
"""Test that patch with conflicting content is rejected."""
root, bp_dir, patches_dir = temp_repo_structure
# Create a patch that adds a specific change
patch_content = """--- a/test.txt
+++ b/test.txt
@@ -1 +1,3 @@
line 1
+line 2
+line 3
"""
patch_file = patches_dir / "conflict.patch"
patch_file.write_text(patch_content)
# Modify the file to have different content that won't match the patch context
test_file = bp_dir / "test.txt"
test_file.write_text("modified line 1\n")
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "modify"], cwd=bp_dir, check=True, capture_output=True)
# Try to apply patch - should fail due to context mismatch
with pytest.raises(PatchError):
apply_patch(bp_dir, patch_file)
def test_cli_with_explicit_paths(temp_repo_structure):
"""Test CLI argument parsing with explicit paths."""
root, bp_dir, patches_dir = temp_repo_structure
patch_file = patches_dir / "test.patch"
create_simple_patch(bp_dir, patch_file, "test.txt")
# Simulate CLI call
sys.argv = [
"prepare_bluepad32.py",
"--bluepad32", str(bp_dir),
"--patch", str(patch_file),
]
from prepare_bluepad32 import main
# Should not raise
try:
main()
except SystemExit as e:
# main() calls sys.exit on success, which we need to catch
if e.code != 0:
raise

85
tools/prepare_bluepad32.py Executable file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Apply the project Bluepad32 patch exactly once."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
class PatchError(RuntimeError):
pass
def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path]:
root = Path.cwd() if repo_root is None else Path(repo_root)
return (
root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
)
def check_paths(bluepad32_path: Path, patch_path: Path) -> None:
if not bluepad32_path.is_dir():
raise PatchError(f"bluepad32 directory does not exist: {bluepad32_path}")
if not (bluepad32_path / ".git").exists():
raise PatchError(f"Bluepad32 is not a git repository: {bluepad32_path}")
if not patch_path.is_file():
raise PatchError(f"patch file does not exist: {patch_path}")
def git_apply(bluepad32_path: Path, patch_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(bluepad32_path), "apply", *args, str(patch_path)],
capture_output=True,
text=True,
check=False,
)
def is_patch_applied(bluepad32_path: Path, patch_path: Path) -> bool:
return git_apply(bluepad32_path, patch_path, "--reverse", "--check").returncode == 0
def apply_patch(bluepad32_path: Path, patch_path: Path) -> None:
if is_patch_applied(bluepad32_path, patch_path):
return
check = git_apply(bluepad32_path, patch_path, "--check")
if check.returncode != 0:
detail = check.stderr.strip() or "patch does not apply"
raise PatchError(f"Patch validation failed (repository may be diverged):\n{detail}")
result = git_apply(bluepad32_path, patch_path)
if result.returncode != 0:
detail = result.stderr.strip() or "git apply failed"
raise PatchError(f"Could not patch Bluepad32: {detail}")
def prepare_bluepad32(
bluepad32_path: Path | None = None,
patch_path: Path | None = None,
) -> None:
default_bluepad32, default_patch = resolve_paths()
dependency = Path(bluepad32_path or default_bluepad32)
patch = Path(patch_path or default_patch)
check_paths(dependency, patch)
apply_patch(dependency, patch)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bluepad32", type=Path)
parser.add_argument("--patch", type=Path)
args = parser.parse_args()
try:
prepare_bluepad32(args.bluepad32, args.patch)
except PatchError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())