Add Pico 2 W Bluepad32 AIO backend

This commit is contained in:
Joey Yakimowich-Payne 2026-08-29 17:37:48 -06:00
commit f451e27f8c
17 changed files with 1263 additions and 24 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

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

394
bluepad32_input_backend.cpp Normal file
View file

@ -0,0 +1,394 @@
#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);
}
int32_t clamp_axis(int32_t value) {
if (value < kAxisMinimum) {
return kAxisMinimum;
}
if (value > kAxisMaximum) {
return kAxisMaximum;
}
return value;
}
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);
}
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);
}
int64_t divide_round_nearest(int64_t numerator, int64_t denominator) {
if (numerator >= 0) {
return (numerator + denominator / 2) / denominator;
}
return -((-numerator + denominator / 2) / denominator);
}
int16_t convert_accel(int64_t q13_value) {
return clamp_int16(q13_value / 2);
}
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));
}
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.

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