From 595518fe3d865d85c8f5101fc23942edcf741e71 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 9 Sep 2026 11:40:51 -0600 Subject: [PATCH] Add selectable BLE-only and Classic-only radio modes --- CMakeLists.txt | 23 ++ README.md | 47 +++ bluepad32_config/bluetooth_transport_config.h | 17 ++ bluepad32_config/sdkconfig.h | 4 +- build.py | 25 ++ patches/bluepad32-sdl3-imu.patch | 186 +++++++++++- .../input/bluepad32_input_backend.cpp | 124 ++++++-- src/firmware/input/switch2_wake.cpp | 7 +- tests/bluepad32_backend_lifecycle_test.cpp | 279 +++++++++++++++++- tests/switch2_wake_test.cpp | 39 ++- ...test_bluepad32_backend_lifecycle_native.py | 51 +++- tests/test_build.py | 202 ++++++++++++- tests/test_switch2_wake_native.py | 42 +-- 13 files changed, 951 insertions(+), 95 deletions(-) create mode 100644 bluepad32_config/bluetooth_transport_config.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f43abc4..0dfbb34 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,6 +90,29 @@ if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART" "Unknown SWITCH_PICO_INPUT_BACKEND='${SWITCH_PICO_INPUT_BACKEND}'. " "Expected UART or BLUEPAD32.") endif() +set(SWITCH_PICO_BLUETOOTH_MODE "MIXED" CACHE STRING "Bluetooth radio mode: MIXED, BLE, or CLASSIC") +set_property(CACHE SWITCH_PICO_BLUETOOTH_MODE PROPERTY STRINGS MIXED BLE CLASSIC) +if(NOT SWITCH_PICO_BLUETOOTH_MODE MATCHES "^(MIXED|BLE|CLASSIC)$") + message(FATAL_ERROR "SWITCH_PICO_BLUETOOTH_MODE must be MIXED, BLE, or CLASSIC") +endif() +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + if(SWITCH_PICO_BLUETOOTH_MODE STREQUAL "CLASSIC") + set(SWITCH_PICO_ENABLE_BLE 0) + else() + set(SWITCH_PICO_ENABLE_BLE 1) + endif() + if(SWITCH_PICO_BLUETOOTH_MODE STREQUAL "BLE") + set(SWITCH_PICO_ENABLE_CLASSIC 0) + else() + set(SWITCH_PICO_ENABLE_CLASSIC 1) + endif() + # Directory scope also covers Bluepad32 and its build-local SDK sources. + add_compile_definitions( + SWITCH_PICO_ENABLE_BLE=${SWITCH_PICO_ENABLE_BLE} + SWITCH_PICO_ENABLE_CLASSIC=${SWITCH_PICO_ENABLE_CLASSIC}) +elseif(NOT SWITCH_PICO_BLUETOOTH_MODE STREQUAL "MIXED") + message(FATAL_ERROR "Single-transport Bluetooth modes require the BLUEPAD32 backend") +endif() set(PICO_BOARD pico CACHE STRING "Board type") if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" AND NOT PICO_BOARD STREQUAL "pico2_w") diff --git a/README.md b/README.md index cb19431..5a0eb98 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,53 @@ The default `python3 build.py` command and `firmware/switch-pico.*` artifacts re Both `build.py --aio` and direct AIO CMake configuration copy the pinned Bluepad32 source into the active build directory and apply `patches/bluepad32-sdl3-imu.patch` there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The `external/bluepad32` submodule remains pristine; patch or source-revision drift fails configuration. +### Bluetooth transport selection + +`SWITCH_PICO_BLUETOOTH_MODE` selects the active radio transports at build time: + +| CMake value | Active radio behavior | +| --- | --- | +| `MIXED` (default) | Bluetooth Classic and BLE | +| `BLE` | BLE only; no Classic inquiry, page scanning, or controller admission | +| `CLASSIC` | Classic only; no BLE scanning, controller admission, or wake advertising | + +To **build without flashing**, use a separate directory for each mode: + +```sh +cmake -S . -B build-aio-ble \ + -DPICO_BOARD=pico2_w \ + -DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 \ + -DSWITCH_PICO_BLUETOOTH_MODE=BLE +cmake --build build-aio-ble +``` + +Use `CLASSIC` or `MIXED` and a matching build directory for the other modes. +Add `-DSWITCH_PICO_ADAPTER_FEASIBILITY=ON` for automatic Switch/XInput output. +Invalid modes and single-transport selections with the UART backend are rejected. + +The build helper also supports the selector; these commands **build and flash**: + +```sh +python3 build.py --aio --bluetooth-mode ble +python3 build.py --aio --bluetooth-mode classic +python3 build.py --adapter-feasibility --bluetooth-mode ble +``` + +The helper defaults explicitly to `mixed`. Single-transport build directories and +published `.elf`/`.uf2` names get `-ble` or `-classic` suffixes, so they do not +overwrite mixed artifacts—for example, `build-aio-ble/` and +`firmware/switch-pico-aio-ble.uf2`. + +These flags select radio activity, not complete removal of the unused host stack: +shared BTstack code and both pairing databases remain available. Switching modes +does not erase bonds or profiles; pairing lists still include inactive-transport +bonds and explicit clear-all still clears both stores. A configured stable public +Bluetooth address is retained in every mode, including Classic-only, so changing +modes does not silently change the host identity used by existing bonds. +Classic-only disables Switch 2 wake advertising regardless of the saved wake +configuration. Each build remains a standalone USB adapter; no inter-Pico link +is introduced. + ### Switch 2 wake from L + R + Home, PS, or Xbox The AIO firmware can wake a sleeping Switch 2 when a connected controller's diff --git a/bluepad32_config/bluetooth_transport_config.h b/bluepad32_config/bluetooth_transport_config.h new file mode 100644 index 0000000..a5beeb7 --- /dev/null +++ b/bluepad32_config/bluetooth_transport_config.h @@ -0,0 +1,17 @@ +#pragma once + +// Radio policy, independent of the BTstack libraries linked for shared services. +#ifndef SWITCH_PICO_ENABLE_BLE +#define SWITCH_PICO_ENABLE_BLE 1 +#endif +#ifndef SWITCH_PICO_ENABLE_CLASSIC +#define SWITCH_PICO_ENABLE_CLASSIC 1 +#endif + +#if (SWITCH_PICO_ENABLE_BLE != 0 && SWITCH_PICO_ENABLE_BLE != 1) || \ + (SWITCH_PICO_ENABLE_CLASSIC != 0 && SWITCH_PICO_ENABLE_CLASSIC != 1) +#error "Bluetooth transport selections must be 0 or 1" +#endif +#if !SWITCH_PICO_ENABLE_BLE && !SWITCH_PICO_ENABLE_CLASSIC +#error "At least one Bluetooth transport must be enabled" +#endif diff --git a/bluepad32_config/sdkconfig.h b/bluepad32_config/sdkconfig.h index 90866ec..61d30dc 100644 --- a/bluepad32_config/sdkconfig.h +++ b/bluepad32_config/sdkconfig.h @@ -1,10 +1,12 @@ #pragma once +#include "bluetooth_transport_config.h" + // The AIO firmware exposes one fixed Bluepad32 device slot per USB interface. #define CONFIG_BLUEPAD32_MAX_DEVICES 4 #define CONFIG_BLUEPAD32_MAX_ALLOWLIST 4 #define CONFIG_BLUEPAD32_GAP_SECURITY 1 -#define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT 1 +#define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT SWITCH_PICO_ENABLE_BLE #define CONFIG_BLUEPAD32_PLATFORM_CUSTOM #define CONFIG_TARGET_PICO_W diff --git a/build.py b/build.py index 82a363e..bfdee1e 100755 --- a/build.py +++ b/build.py @@ -44,6 +44,11 @@ CMAKE_CACHE_PATHS = tuple( AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR, WAKE_CAPTURE_BUILD_DIR, + *( + base.with_name(f"{base.name}-{mode}") + for base in (AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR) + for mode in ("ble", "classic") + ), ) ) TOOLCHAIN_COMPILER = "arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc" @@ -287,6 +292,12 @@ def parse_args(): action="store_true", help="Build and flash the automatic Switch 2 wake capture firmware.", ) + parser.add_argument( + "--bluetooth-mode", + choices=("mixed", "ble", "classic"), + default="mixed", + help="Select active Bluetooth transports for --aio or --adapter-feasibility.", + ) group = parser.add_mutually_exclusive_group() group.add_argument( "--random-grip-color", @@ -301,6 +312,8 @@ def parse_args(): args = parser.parse_args() if args.wake_capture and (args.random_grip_color or args.grip_color): parser.error("wake capture firmware does not use grip-color options") + if args.bluetooth_mode != "mixed" and not (args.aio or args.adapter_feasibility): + parser.error("--bluetooth-mode requires --aio or --adapter-feasibility") return args @@ -385,6 +398,8 @@ def build( uf2_path, firmware_elf_path, firmware_uf2_path, + *, + bluetooth_mode="mixed", ): if aio or adapter_feasibility: definitions = [ @@ -405,6 +420,7 @@ def build( native = "ON" if aio or adapter_feasibility else "OFF" definitions.extend( [ + f"-DSWITCH_PICO_BLUETOOTH_MODE={bluetooth_mode.upper()}", f"-DSWITCH_PICO_HAPTICS_EXPERIMENT={native}", f"-DSWITCH_PICO_HD_RUMBLE={native}", f"-DSWITCH_PICO_SYS_CLOCK_MHZ={300 if native == 'ON' else 150}", @@ -532,6 +548,14 @@ def main(): firmware_elf_path = FIRMWARE_ELF_PATH firmware_uf2_path = FIRMWARE_UF2_PATH + if args.bluetooth_mode != "mixed": + suffix = f"-{args.bluetooth_mode}" + build_dir = build_dir.with_name(build_dir.name + suffix) + elf_path = build_dir / elf_path.name + uf2_path = build_dir / uf2_path.name + firmware_elf_path = firmware_elf_path.with_stem(firmware_elf_path.stem + suffix) + firmware_uf2_path = firmware_uf2_path.with_stem(firmware_uf2_path.stem + suffix) + build( args.aio, args.adapter_feasibility, @@ -540,6 +564,7 @@ def main(): uf2_path, firmware_elf_path, firmware_uf2_path, + bluetooth_mode=args.bluetooth_mode, ) flash( elf_path, diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index cc60367..2763e5d 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -14,7 +14,49 @@ diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/ // globals bd_addr_t uni_local_bd_addr; -@@ -473,6 +479,10 @@ +@@ -100,17 +106,21 @@ + }; + + static void bluetooth_del_keys(void) { +- if (IS_ENABLED(UNI_ENABLE_BREDR)) +- uni_bt_bredr_delete_bonded_keys(); +- if (IS_ENABLED(UNI_ENABLE_BLE)) +- uni_bt_le_delete_bonded_keys(); ++#ifdef ENABLE_CLASSIC ++ uni_bt_bredr_delete_bonded_keys(); ++#endif ++#ifdef ENABLE_BLE ++ uni_bt_le_delete_bonded_keys(); ++#endif + } + + static void bluetooth_list_keys(void) { +- if (IS_ENABLED(UNI_ENABLE_BREDR)) +- uni_bt_bredr_list_bonded_keys(); +- if (IS_ENABLED(UNI_ENABLE_BLE)) +- uni_bt_le_list_bonded_keys(); ++#ifdef ENABLE_CLASSIC ++ uni_bt_bredr_list_bonded_keys(); ++#endif ++#ifdef ENABLE_BLE ++ uni_bt_le_list_bonded_keys(); ++#endif + } + + static void start_scan(void) { +@@ -172,9 +182,9 @@ + // Device cannot be used after delete. + d = NULL; + +- if (IS_ENABLED(UNI_ENABLE_BLE) && type == GAP_CONNECTION_LE) ++ if (IS_ENABLED(ENABLE_BLE) && type == GAP_CONNECTION_LE) + uni_bt_le_on_hci_disconnection_complete(channel, packet, size); +- else if (IS_ENABLED(UNI_ENABLE_BREDR) && type == GAP_CONNECTION_ACL) ++ else if (IS_ENABLED(ENABLE_CLASSIC) && type == GAP_CONNECTION_ACL) + uni_bt_bredr_on_hci_disconnection_complete(channel, packet, size); + else + loge("on_hci_disconnection_complete: Unknown GAP connection type: %d\n", type); +@@ -473,6 +483,10 @@ if (device == NULL) { loge("--->>> CANNOT FIND DEVICE"); } else { @@ -28,7 +70,43 @@ diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/ diff --git a/src/components/bluepad32/bt/uni_bt_bredr.c b/src/components/bluepad32/bt/uni_bt_bredr.c --- a/src/components/bluepad32/bt/uni_bt_bredr.c +++ b/src/components/bluepad32/bt/uni_bt_bredr.c -@@ -423,13 +423,14 @@ +@@ -28,7 +28,7 @@ + #define INQUIRY_REMOTE_NAME_TIMEOUT_MS 4500 + _Static_assert(INQUIRY_REMOTE_NAME_TIMEOUT_MS < HID_DEVICE_CONNECTION_TIMEOUT_MS, "Timeout too big"); + +-static bool bt_bredr_enabled = true; ++static bool bt_bredr_enabled = IS_ENABLED(UNI_ENABLE_BREDR); + + static void l2cap_create_control_connection(uni_hid_device_t* d) { + uint8_t status; +@@ -62,6 +62,8 @@ + } + + void uni_bt_bredr_scan_start(void) { ++ if (!bt_bredr_enabled) ++ return; + uint8_t status; + + status = gap_inquiry_periodic_start(uni_bt_get_gap_inquiry_length(), uni_bt_get_gap_max_periodic_length(), +@@ -147,6 +149,8 @@ + } + + void uni_bt_bredr_setup(void) { ++ if (!bt_bredr_enabled) ++ return; + int security_level = uni_bt_get_gap_security_level(); + gap_set_security_level(security_level); + +@@ -183,7 +187,7 @@ + } + + void uni_bt_bredr_set_enabled(bool enabled) { +- bt_bredr_enabled = enabled; ++ bt_bredr_enabled = enabled && IS_ENABLED(UNI_ENABLE_BREDR); + } + + bool uni_bt_bredr_is_enabled(void) { +@@ -423,13 +427,14 @@ status = l2cap_event_channel_opened_get_status(packet); if (status) { logi("L2CAP Connection failed: 0x%02x.\n", status); @@ -191,19 +269,22 @@ diff --git a/src/components/bluepad32/bt/uni_bt_le.c b/src/components/bluepad32/ } void uni_bt_le_list_bonded_keys(void) { -@@ -872,6 +912,11 @@ - gap_delete_bonding((bd_addr_type_t)entry_address_type, entry_address); - } +@@ -874,7 +914,14 @@ logi(".\n"); -+} -+ + } + +void uni_bt_le_set_background_scan(bool enabled) { + gap_set_scan_parameters(0 /* type: passive */, enabled ? 160 : 48 /* interval */, + enabled ? 16 : 48 /* window */); - } - ++} ++ void uni_bt_le_setup(void) { -@@ -921,7 +966,7 @@ ++ if (!IS_ENABLED(UNI_ENABLE_BLE)) ++ return; + // register for events from Security Manager + sm_event_callback_registration.callback = &uni_sm_packet_handler; + sm_add_event_handler(&sm_event_callback_registration); +@@ -921,11 +968,11 @@ // scan_parameters_service_client_init(); device_information_service_client_init(); @@ -212,6 +293,58 @@ diff --git a/src/components/bluepad32/bt/uni_bt_le.c b/src/components/bluepad32/ } void uni_bt_le_scan_start(void) { +- if (!ble_enabled) ++ if (!IS_ENABLED(UNI_ENABLE_BLE) || !ble_enabled) + return; + + gap_start_scan(); +@@ -950,6 +997,9 @@ + + void uni_bt_le_set_enabled(bool enabled) { + // Called from different Task. Don't call BTstack functions. ++ // A build-time radio exclusion must not rewrite the saved mixed-mode preference. ++ if (!IS_ENABLED(UNI_ENABLE_BLE)) ++ return; + uni_property_value_t val; + + val.u8 = enabled; +@@ -959,6 +1009,8 @@ + } + + bool uni_bt_le_is_enabled() { ++ if (!IS_ENABLED(UNI_ENABLE_BLE)) ++ return false; + // Expensive call. Avoid calling it from this same file. + // Called from "uni_bt_setup" + uni_property_value_t val; +diff --git a/src/components/bluepad32/bt/uni_bt_service.c b/src/components/bluepad32/bt/uni_bt_service.c +--- a/src/components/bluepad32/bt/uni_bt_service.c ++++ b/src/components/bluepad32/bt/uni_bt_service.c +@@ -12,6 +12,7 @@ + #include "bt/uni_bt_service.gatt.h" + #include "controller/uni_gamepad.h" + #include "uni_common.h" ++#include "uni_config.h" + #include "uni_log.h" + #include "uni_system.h" + #include "uni_version.h" +@@ -433,6 +434,8 @@ + * Finally, it configures the advertisements. + */ + void uni_bt_service_init(void) { ++ if (!IS_ENABLED(UNI_ENABLE_BLE)) ++ return; + logi("Starting Bluepad32 BLE service UUID: 4627C4A4-AC00-46B9-B688-AFC5C1BF7F63\n"); + + // Setup ATT server. +@@ -464,6 +467,7 @@ + } + + void uni_bt_service_set_enabled(bool enabled) { ++ enabled = enabled && IS_ENABLED(UNI_ENABLE_BLE); + if (enabled == service_enabled) + return; + diff --git a/src/components/bluepad32/controller/uni_gamepad.c b/src/components/bluepad32/controller/uni_gamepad.c --- a/src/components/bluepad32/controller/uni_gamepad.c +++ b/src/components/bluepad32/controller/uni_gamepad.c @@ -322,6 +455,27 @@ diff --git a/src/components/bluepad32/include/uni.h b/src/components/bluepad32/i #include "parser/uni_hid_parser_xboxone.h" #include "platform/uni_platform.h" #include "uni_circular_buffer.h" +diff --git a/src/components/bluepad32/include/uni_config.h b/src/components/bluepad32/include/uni_config.h +--- a/src/components/bluepad32/include/uni_config.h ++++ b/src/components/bluepad32/include/uni_config.h +@@ -7,8 +7,15 @@ + + #include "sdkconfig.h" + +-#if defined(CONFIG_TARGET_POSIX) || defined(CONFIG_TARGET_PICO_W) || defined(CONFIG_IDF_TARGET_ESP32) +-// Pico W, original ESP32 and Posix all support both BR/EDR and BLE ++#if defined(CONFIG_TARGET_PICO_W) ++#if SWITCH_PICO_ENABLE_CLASSIC ++#define UNI_ENABLE_BREDR 1 ++#endif ++#if SWITCH_PICO_ENABLE_BLE ++#define UNI_ENABLE_BLE 1 ++#endif ++#elif defined(CONFIG_TARGET_POSIX) || defined(CONFIG_IDF_TARGET_ESP32) ++// Original ESP32 and Posix support both BR/EDR and BLE. + #define UNI_ENABLE_BREDR 1 + #define UNI_ENABLE_BLE 1 + #elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C3) || \ diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/components/bluepad32/parser/uni_hid_parser_ds4.c --- a/src/components/bluepad32/parser/uni_hid_parser_ds4.c +++ b/src/components/bluepad32/parser/uni_hid_parser_ds4.c @@ -2341,6 +2495,18 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 // Disconnect child first if (d->child) uni_hid_device_disconnect(d->child); +@@ -465,9 +471,9 @@ + // Cleanup + if (!uni_hid_device_is_virtual_device(d)) { + type = gap_get_connection_type(d->conn.handle); +- if (IS_ENABLED(UNI_ENABLE_BLE) && type == GAP_CONNECTION_LE) ++ if (IS_ENABLED(ENABLE_BLE) && type == GAP_CONNECTION_LE) + uni_bt_le_disconnect(d); +- else if (IS_ENABLED(UNI_ENABLE_BREDR) && type == GAP_CONNECTION_ACL) ++ else if (IS_ENABLED(ENABLE_CLASSIC) && type == GAP_CONNECTION_ACL) + uni_bt_bredr_disconnect(d); + else + loge("uni_hid_device_disconnect: Unknown GAP connection type: %d\n", type); @@ -490,6 +496,11 @@ loge("uni_hid_device_delete: invalid hid device: NULL\n"); return; diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 41d0a61..8563dbd 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -1,4 +1,5 @@ #include "input/bluepad32_input_backend.h" +#include "bluetooth_transport_config.h" #include "input/controller_hotkey_config.h" #include "input/switch2_wake.h" #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE @@ -595,6 +596,9 @@ bool waiting_for_joycon_mate(int side = 0) { } void stop_background_scan() { + if (!SWITCH_PICO_ENABLE_BLE) { + return; + } if (g_background_scan_active) { // Direct LE scans do not update Bluepad32's aggregate scanning flag. uni_bt_le_scan_stop(); @@ -604,6 +608,9 @@ void stop_background_scan() { // Core 1 only. Reconcile every ready physical Switch 2 link to the fast interval, // independently of player grouping, controller count, or Classic connections. void apply_radio_connection_policy() { + if (!SWITCH_PICO_ENABLE_BLE) { + return; + } uni_hid_device_t* ready[kSlotCount]{}; for (const BackendSlot& slot : g_slots) { uni_hid_device_t* targets[] = {slot.device, slot.companion}; @@ -1586,19 +1593,19 @@ void handle_btstack_event(uint8_t packet_type, uint16_t channel, pairing_window_active_at(btstack_run_loop_get_time_ms()); switch (hci_event_packet_get_type(packet)) { case SM_EVENT_IDENTITY_RESOLVING_STARTED: - if (size >= 11) { + if (SWITCH_PICO_ENABLE_BLE && size >= 11) { clear_ble_identity_for_handle( sm_event_identity_resolving_started_get_handle(packet)); } break; case SM_EVENT_IDENTITY_RESOLVING_FAILED: - if (size >= 11) { + if (SWITCH_PICO_ENABLE_BLE && size >= 11) { clear_ble_identity_for_handle( sm_event_identity_resolving_failed_get_handle(packet)); } break; case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED: - if (size >= 20) { + if (SWITCH_PICO_ENABLE_BLE && size >= 20) { connection_handle = sm_event_identity_resolving_succeeded_get_handle(packet); sm_event_identity_resolving_succeeded_get_address( @@ -1613,7 +1620,7 @@ void handle_btstack_event(uint8_t packet_type, uint16_t channel, } break; case SM_EVENT_IDENTITY_CREATED: - if (size >= 20) { + if (SWITCH_PICO_ENABLE_BLE && size >= 20) { connection_handle = sm_event_identity_created_get_handle(packet); sm_event_identity_created_get_address(packet, address); @@ -1628,7 +1635,7 @@ void handle_btstack_event(uint8_t packet_type, uint16_t channel, } break; case SM_EVENT_REENCRYPTION_STARTED: - if (size >= 11) { + if (SWITCH_PICO_ENABLE_BLE && size >= 11) { connection_handle = sm_event_reencryption_started_get_handle(packet); sm_event_reencryption_started_get_address( @@ -1643,7 +1650,7 @@ void handle_btstack_event(uint8_t packet_type, uint16_t channel, } break; case SM_EVENT_REENCRYPTION_COMPLETE: - if (size >= 12) { + if (SWITCH_PICO_ENABLE_BLE && size >= 12) { connection_handle = sm_event_reencryption_complete_get_handle(packet); if (sm_event_reencryption_complete_get_status(packet) == @@ -1663,7 +1670,7 @@ void handle_btstack_event(uint8_t packet_type, uint16_t channel, } break; case HCI_EVENT_USER_CONFIRMATION_REQUEST: - if (size < 8) { + if (!SWITCH_PICO_ENABLE_CLASSIC || size < 8) { break; } hci_event_user_confirmation_request_get_bd_addr(packet, address); @@ -1674,7 +1681,7 @@ void handle_btstack_event(uint8_t packet_type, uint16_t channel, } break; case HCI_EVENT_USER_PASSKEY_REQUEST: - if (size < 8) { + if (!SWITCH_PICO_ENABLE_CLASSIC || size < 8) { break; } hci_event_user_passkey_request_get_bd_addr(packet, address); @@ -1709,13 +1716,17 @@ bool update_pairing_window(uint32_t now_ms) { g_pairing_window_deadline_ms = now_ms + g_pairing_window_duration_ms; gap_set_bondable_mode(true); - sm_set_accepted_stk_generation_methods(kAllBlePairingMethods); + if (SWITCH_PICO_ENABLE_BLE) { + sm_set_accepted_stk_generation_methods(kAllBlePairingMethods); + } g_status_led_tick = 0; return true; } if (g_pairing_window_open && !pairing_window_active_at(now_ms)) { g_pairing_window_open = false; - sm_set_accepted_stk_generation_methods(0); + if (SWITCH_PICO_ENABLE_BLE) { + sm_set_accepted_stk_generation_methods(0); + } g_status_led_tick = 0; gap_set_bondable_mode(false); return true; @@ -1839,7 +1850,9 @@ void process_clear_pairings(uint32_t now_ms) { g_pairing_window_open = false; gap_set_bondable_mode(false); - sm_set_accepted_stk_generation_methods(0); + if (SWITCH_PICO_ENABLE_BLE) { + sm_set_accepted_stk_generation_methods(0); + } const bool proprietary_cleared = uni_switch2_pairing_clear(); uni_bt_del_keys_unsafe(); for (uni_hid_device_t* device : devices) { @@ -1895,8 +1908,8 @@ void apply_connection_policy() { pairing_window_active_at(btstack_run_loop_get_time_ms()); const bool active_scan = free_slot && (!active_controller || pairing_open); - const bool background_scan = free_slot && !active_scan && - waiting_for_joycon_mate(); + const bool background_scan = SWITCH_PICO_ENABLE_BLE && + free_slot && !active_scan && waiting_for_joycon_mate(); const ConnectionPolicyState desired_state = !free_slot ? ConnectionPolicyState::Paused @@ -1920,9 +1933,11 @@ void apply_connection_policy() { // Passive mode permits incoming Classic reconnects, with LE discovery // limited to a remembered opposite half for a ready solo Joy-Con2. - uni_bt_allow_incoming_connections(true); + uni_bt_allow_incoming_connections(SWITCH_PICO_ENABLE_CLASSIC != 0); if (active_scan) { - uni_bt_le_set_background_scan(false); + if (SWITCH_PICO_ENABLE_BLE) { + uni_bt_le_set_background_scan(false); + } uni_bt_start_scanning_and_autoconnect_unsafe(); g_connection_policy_state = ConnectionPolicyState::Open; } else { @@ -2796,14 +2811,19 @@ void platform_init(int argc, const char** argv) { } void platform_on_init_complete() { - gap_set_link_supervision_timeout(kClassicLinkSupervisionTimeout); + if (SWITCH_PICO_ENABLE_CLASSIC) { + gap_set_link_supervision_timeout(kClassicLinkSupervisionTimeout); + gap_ssp_set_auto_accept(false); + g_pairing_event_callback.callback = handle_btstack_event; + hci_add_event_handler(&g_pairing_event_callback); + } gap_set_bondable_mode(false); - sm_set_accepted_stk_generation_methods(0); - gap_ssp_set_auto_accept(false); - g_pairing_event_callback.callback = handle_btstack_event; - g_identity_event_callback.callback = handle_btstack_event; - sm_add_event_handler(&g_identity_event_callback); - hci_add_event_handler(&g_pairing_event_callback); + if (SWITCH_PICO_ENABLE_BLE) { + // Bluepad32 does not initialize SM in Classic-only mode. + sm_set_accepted_stk_generation_methods(0); + g_identity_event_callback.callback = handle_btstack_event; + sm_add_event_handler(&g_identity_event_callback); + } switch2_wake_initialize(); refresh_pairing_snapshot(); btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); @@ -2823,6 +2843,35 @@ void platform_on_init_complete() { } } +bool device_transport_enabled(const uni_hid_device_t* device) { + if (device == nullptr) { + return false; + } + if (SWITCH_PICO_ENABLE_BLE && SWITCH_PICO_ENABLE_CLASSIC) { + return true; + } + // GAP describes the actual live link. The protocol hint is also available + // before an outgoing connection has an HCI handle. + switch (gap_get_connection_type(device->conn.handle)) { + case GAP_CONNECTION_ACL: + return SWITCH_PICO_ENABLE_CLASSIC != 0; + case GAP_CONNECTION_LE: + return SWITCH_PICO_ENABLE_BLE != 0; + default: + break; + } + switch (device->conn.protocol) { + case UNI_BT_CONN_PROTOCOL_BR_EDR: + return SWITCH_PICO_ENABLE_CLASSIC != 0; + case UNI_BT_CONN_PROTOCOL_BLE: + return SWITCH_PICO_ENABLE_BLE != 0; + default: + // Preserve mixed-mode admission; single-transport builds cannot + // safely admit a connection whose transport is still unknown. + return SWITCH_PICO_ENABLE_BLE && SWITCH_PICO_ENABLE_CLASSIC; + } +} + uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) { (void)name; @@ -2831,14 +2880,23 @@ uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, if (!has_free_slot()) { return UNI_ERROR_IGNORE_DEVICE; } + const uni_hid_device_t* candidate = + (g_connection_policy_state == ConnectionPolicyState::Passive || + !SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_ENABLE_CLASSIC) + ? uni_hid_device_get_instance_for_address(addr) + : nullptr; + if (candidate != nullptr && !device_transport_enabled(candidate)) { + return UNI_ERROR_IGNORE_DEVICE; + } + // First discovery can precede device creation, so the transport-specific + // Bluepad32 discovery handlers must enforce the mode before this callback. if (g_connection_policy_state == ConnectionPolicyState::Open) { return UNI_ERROR_SUCCESS; } - if (g_connection_policy_state != ConnectionPolicyState::Passive) { + if (!SWITCH_PICO_ENABLE_BLE || + g_connection_policy_state != ConnectionPolicyState::Passive) { return UNI_ERROR_IGNORE_DEVICE; } - const uni_hid_device_t* candidate = - uni_hid_device_get_instance_for_address(addr); const int side = joycon_side(candidate); uint8_t address_type = BD_ADDR_TYPE_UNKNOWN; return side != 0 && waiting_for_joycon_mate(side) && @@ -2853,8 +2911,9 @@ void platform_on_device_connected(uni_hid_device_t* device) { if (device == nullptr) { return; } - if (g_connection_policy_state != ConnectionPolicyState::Open && - g_connection_policy_state != ConnectionPolicyState::Passive) { + if (!device_transport_enabled(device) || + (g_connection_policy_state != ConnectionPolicyState::Open && + g_connection_policy_state != ConnectionPolicyState::Passive)) { uni_hid_device_disconnect(device); return; } @@ -2937,7 +2996,8 @@ void platform_on_device_disconnected(uni_hid_device_t* device) { } uni_error_t platform_on_device_ready(uni_hid_device_t* device) { - if (device == nullptr || !uni_hid_device_is_gamepad(device)) { + if (!device_transport_enabled(device) || + !uni_hid_device_is_gamepad(device)) { return UNI_ERROR_INVALID_CONTROLLER; } if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { @@ -3145,7 +3205,7 @@ uni_platform* get_platform() { } // namespace extern "C" bool switch_pico_switch2_pairing_allowed(void) { - return g_initialized && + return SWITCH_PICO_ENABLE_BLE && g_initialized && pairing_window_active_at(btstack_run_loop_get_time_ms()); } @@ -3153,6 +3213,12 @@ extern "C" void __real_sm_request_pairing(hci_con_handle_t handle); extern "C" void __wrap_sm_request_pairing(hci_con_handle_t handle) { uni_hid_device_t* device = uni_hid_device_get_instance_for_connection_handle(handle); + if (!SWITCH_PICO_ENABLE_BLE) { + if (device != nullptr) { + uni_hid_device_disconnect(device); + } + return; + } if (uni_hid_parser_switch2_is_ble_device(device)) { // GATT's implicit authentication retry must not enter standard SMP for // this proprietary protocol. Retain storage until HCI teardown. diff --git a/src/firmware/input/switch2_wake.cpp b/src/firmware/input/switch2_wake.cpp index 4304fd8..f53993b 100644 --- a/src/firmware/input/switch2_wake.cpp +++ b/src/firmware/input/switch2_wake.cpp @@ -1,4 +1,5 @@ #include "input/switch2_wake.h" +#include "bluetooth_transport_config.h" #include @@ -235,7 +236,7 @@ void handle_command_complete(uint8_t* packet, uint16_t size) { } #endif g_identity_ready = true; - g_phase = Phase::kIdle; + g_phase = SWITCH_PICO_ENABLE_BLE ? Phase::kIdle : Phase::kDisabled; break; case Phase::kSetParameters: g_phase = Phase::kSetData; @@ -298,7 +299,9 @@ void switch2_wake_initialize() { ++g_failures; return; } - g_configured = true; + // Classic bonds also depend on the public address: preserve it across modes, + // even when BLE wake advertising is unavailable. + g_configured = SWITCH_PICO_ENABLE_BLE; g_event_registration.callback = handle_hci_event; hci_add_event_handler(&g_event_registration); btstack_run_loop_set_timer_handler(&g_timer, task); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index b7f709b..b59321b 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -9,6 +9,7 @@ #endif #include +#include "bluetooth_transport_config.h" #include "parser/uni_hid_parser_switch2.h" #include "parser/uni_switch2_haptics.h" #include "parser/uni_switch2_pairing.h" @@ -192,6 +193,7 @@ void uni_hid_parser_xboxone_play_dual_rumble( extern "C" void __real_sm_request_pairing(hci_con_handle_t) { + require(SWITCH_PICO_ENABLE_BLE, "disabled BLE transport entered SMP"); ++ordinary_smp_requests; } @@ -322,27 +324,34 @@ void uni_hid_device_disconnect(uni_hid_device_t* device) { } void uni_bt_allow_incoming_connections(bool enabled) { + require(!enabled || SWITCH_PICO_ENABLE_CLASSIC, + "disabled Classic transport enabled incoming page scanning"); incoming_connections = enabled; } void uni_bt_bredr_scan_start() { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic inquiry started"); classic_scanning_enabled = true; } void uni_bt_bredr_scan_stop() { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic inquiry was accessed"); classic_scanning_enabled = false; } void uni_bt_le_scan_start() { + require(SWITCH_PICO_ENABLE_BLE, "disabled BLE scanning started"); scanning_enabled = true; } void uni_bt_le_scan_stop() { + require(SWITCH_PICO_ENABLE_BLE, "disabled BLE scanning was accessed"); scanning_enabled = false; } void uni_bt_le_set_background_scan(bool enabled) { + require(SWITCH_PICO_ENABLE_BLE, "disabled BLE scan parameters were accessed"); require(!scanning_enabled, "LE scan timing must change only while scanning is stopped"); background_scan_parameters = enabled; } @@ -354,8 +363,8 @@ void uni_bt_start_scanning_and_autoconnect_unsafe() { return; } aggregate_scanning_enabled = true; - uni_bt_bredr_scan_start(); - uni_bt_le_scan_start(); + if (SWITCH_PICO_ENABLE_CLASSIC) uni_bt_bredr_scan_start(); + if (SWITCH_PICO_ENABLE_BLE) uni_bt_le_scan_start(); } void uni_bt_stop_scanning_unsafe() { @@ -363,8 +372,8 @@ void uni_bt_stop_scanning_unsafe() { return; } aggregate_scanning_enabled = false; - uni_bt_bredr_scan_stop(); - uni_bt_le_scan_stop(); + if (SWITCH_PICO_ENABLE_CLASSIC) uni_bt_bredr_scan_stop(); + if (SWITCH_PICO_ENABLE_BLE) uni_bt_le_scan_stop(); } void uni_bt_del_keys_unsafe() { require_clear_completion_pending(); @@ -444,33 +453,40 @@ void gap_set_bondable_mode(int enabled) { } void gap_set_link_supervision_timeout(uint16_t timeout) { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic link policy was accessed"); link_supervision_timeout = timeout; } void gap_ssp_set_auto_accept(int auto_accept) { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic SSP was accessed"); ssp_auto_accept = auto_accept != 0; } void sm_set_accepted_stk_generation_methods(uint8_t methods) { + require(SWITCH_PICO_ENABLE_BLE, "uninitialized SM pairing policy was accessed"); accepted_stk_methods = methods; } int gap_ssp_confirmation_response(const bd_addr_t) { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic confirmation was accepted"); ++confirmation_accepts; return 0; } int gap_ssp_confirmation_negative(const bd_addr_t) { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic confirmation was accessed"); ++confirmation_rejections; return 0; } int gap_ssp_passkey_response(const bd_addr_t, uint32_t) { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic passkey was accepted"); ++passkey_accepts; return 0; } int gap_ssp_passkey_negative(const bd_addr_t) { + require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic passkey was accessed"); ++passkey_rejections; return 0; } @@ -482,6 +498,7 @@ void hci_add_event_handler( void sm_add_event_handler( btstack_packet_callback_registration_t* callback_handler) { + require(SWITCH_PICO_ENABLE_BLE, "event handler added to uninitialized SM"); identity_event_handler = callback_handler->callback; } @@ -654,6 +671,7 @@ uint32_t time_us_32() { void switch2_wake_initialize() { ++switch2_wake_initializations; + if (!SWITCH_PICO_ENABLE_BLE) switch2_connections_ready = true; } bool switch2_wake_ready_for_connections() { return switch2_connections_ready; @@ -950,23 +968,35 @@ bool read_controller_state(uint8_t slot, ControllerState* output) { void start_backend() { bluepad32_input_backend_init(); platform_on_init_complete(); - require(incoming_connections && scanning_enabled && - classic_scanning_enabled && - link_supervision_timeout == - kClassicLinkSupervisionTimeout && - !bondable && accepted_stk_methods == 0 && - !ssp_auto_accept && pairing_event_handler != nullptr && - identity_event_handler != nullptr && - switch2_wake_initializations == 1, - "initialization must register Classic and BLE identity policy"); + require(incoming_connections == (SWITCH_PICO_ENABLE_CLASSIC != 0) && + scanning_enabled == (SWITCH_PICO_ENABLE_BLE != 0) && + classic_scanning_enabled == (SWITCH_PICO_ENABLE_CLASSIC != 0) && + !bondable && switch2_wake_initializations == 1, + "initialization must scan and admit only the selected transports"); + if (SWITCH_PICO_ENABLE_CLASSIC) { + require(link_supervision_timeout == kClassicLinkSupervisionTimeout && + !ssp_auto_accept && pairing_event_handler != nullptr, + "Classic initialization must retain its reconnect and SSP policy"); + } else { + require(pairing_event_handler == nullptr, + "BLE-only initialization registered a Classic pairing handler"); + } + if (SWITCH_PICO_ENABLE_BLE) { + require(accepted_stk_methods == 0 && identity_event_handler != nullptr, + "BLE initialization must reject pairing until its window opens"); + } else { + require(identity_event_handler == nullptr, + "Classic-only initialization registered an uninitialized SM handler"); + } } void start_pairing_backend() { start_backend(); bluepad32_input_backend_open_pairing_window(); process_rumble_timer(&g_rumble_timer); require(g_connection_policy_state == ConnectionPolicyState::Open && - scanning_enabled && classic_scanning_enabled && - incoming_connections, + scanning_enabled == (SWITCH_PICO_ENABLE_BLE != 0) && + classic_scanning_enabled == (SWITCH_PICO_ENABLE_CLASSIC != 0) && + incoming_connections == (SWITCH_PICO_ENABLE_CLASSIC != 0), "test connection setup requires an open pairing window"); } void dispatch_pairing_event(uint8_t event_type) { @@ -5673,11 +5703,230 @@ void test_wii_orientation_races() { "coalescing back to the current mapping must not apply an obsolete intermediate choice"); } +void require_selected_radio(bool active_scan, bool free_slot) { + require(scanning_enabled == (active_scan && SWITCH_PICO_ENABLE_BLE) && + classic_scanning_enabled == + (active_scan && SWITCH_PICO_ENABLE_CLASSIC) && + incoming_connections == + (free_slot && SWITCH_PICO_ENABLE_CLASSIC), + "radio policy activated a disabled transport or lost an enabled one"); +} + +void test_transport_mode_policy() { + classic_bond_count = 1; + classic_bonds[0][5] = 0xa1; + ble_bond_count = 1; + ble_bond_types[0] = BD_ADDR_TYPE_LE_RANDOM; + ble_bonds[0][5] = 0xb1; + auto remembered_switch2 = switch2_device(3, UNI_SW2_PRO_PID); + remember_switch2(remembered_switch2); + initialize_runtime_profile_storage(); + ControllerIdentity saved_identities[2]{}; + for (unsigned index = 0; index < 2; ++index) { + auto& identity = saved_identities[index]; + identity.stable = true; + identity.transport = index == 0 ? ControllerTransport::kClassic + : ControllerTransport::kBle; + identity.address_type = index == 0 ? BD_ADDR_TYPE_UNKNOWN + : BD_ADDR_TYPE_LE_RANDOM; + memcpy(identity.address, index == 0 ? classic_bonds[0] : ble_bonds[0], + sizeof(bd_addr_t)); + identity.vendor_id = 0x1234; + identity.product_id = 0x5678; + auto profile = controller_profile_default(identity, 2 + index); + profile.weak_rumble_scale = 37 + index; + require(runtime_profile_storage.set(identity, 2 + index, profile) == + ProfileStorageResult::kOk && + runtime_profile_storage.activate(identity, 2 + index) == + ProfileStorageResult::kOk, + "mode fixture must persist distinct Classic and BLE profiles"); + } + if (!SWITCH_PICO_ENABLE_BLE) switch2_connections_ready = false; + start_backend(); + Bluepad32PairingSnapshot bonds{}; + bluepad32_input_backend_pairing_snapshot(&bonds); + require(bonds.record_count == 3 && + bonds.records[0].transport == Bluepad32PairingTransport::kClassic && + bonds.records[1].transport == Bluepad32PairingTransport::kBle && + bonds.records[2].transport == Bluepad32PairingTransport::kBle && + memcmp(bonds.records[0].address, classic_bonds[0], sizeof(bd_addr_t)) == 0 && + memcmp(bonds.records[1].address, ble_bonds[0], sizeof(bd_addr_t)) == 0 && + memcmp(bonds.records[2].address, remembered_switch2.conn.btaddr, + sizeof(bd_addr_t)) == 0 && delete_key_calls == 0, + "boot must expose remembered keys from both transports without deleting them"); + + for (bool ble : {false, true}) { + const bool enabled = ble ? SWITCH_PICO_ENABLE_BLE + : SWITCH_PICO_ENABLE_CLASSIC; + auto candidate = device(0, true, ble ? UNI_BT_CONN_PROTOCOL_BLE + : UNI_BT_CONN_PROTOCOL_BR_EDR); + lookup_devices[0] = &candidate; + lookup_device_count = 1; + // A discovery candidate need not have a live GAP handle yet. + const auto handle = candidate.conn.handle; + candidate.conn.handle = HCI_CON_HANDLE_INVALID; + require(platform_on_device_discovered(candidate.conn.btaddr, "candidate", 0, 0) == + (enabled ? UNI_ERROR_SUCCESS : UNI_ERROR_IGNORE_DEVICE), + "discovery admitted a known disabled-transport candidate"); + candidate.conn.handle = handle; + // A stale protocol hint must never overrule the actual live GAP link. + candidate.conn.protocol = ble ? UNI_BT_CONN_PROTOCOL_BR_EDR + : UNI_BT_CONN_PROTOCOL_BLE; + const int disconnected_before = device_disconnect_calls; + platform_on_device_connected(&candidate); + require(device_disconnect_calls == disconnected_before + !enabled, + "connected admission failed to tear down a disabled live transport"); + require(platform_on_device_ready(&candidate) == + (enabled ? UNI_ERROR_SUCCESS : UNI_ERROR_INVALID_CONTROLLER) && + slot_snapshot(0).active == enabled, + "ready callback bypassed transport admission"); + uni_controller_t report{}; + report.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + report.gamepad.buttons = BUTTON_A; + platform_on_controller_data(&candidate, &report); + require(slot_snapshot(0).state.button_south == enabled, + "rejected transport published controller input"); + if (enabled) platform_on_device_disconnected(&candidate); + require_selected_radio(true, true); + } + auto ordinary_ble = device(0, true, UNI_BT_CONN_PROTOCOL_BLE); + lookup_devices[0] = &ordinary_ble; + __wrap_sm_request_pairing(ordinary_ble.conn.handle); + __wrap_sm_request_pairing(HCI_CON_HANDLE_INVALID); + require(ordinary_smp_requests == (SWITCH_PICO_ENABLE_BLE ? 2u : 0u), + "Classic-only mode forwarded an SMP authentication request"); + lookup_device_count = 0; + if (!SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_ENABLE_CLASSIC) { + auto unknown = device(0); + const int disconnected_before = device_disconnect_calls; + platform_on_device_connected(&unknown); + require(device_disconnect_calls == disconnected_before + 1 && + platform_on_device_ready(&unknown) == UNI_ERROR_INVALID_CONTROLLER && + !slot_snapshot(0).active, + "single-transport mode admitted an unidentified connection"); + } + + uint8_t confirmation[] = {HCI_EVENT_USER_CONFIRMATION_REQUEST, 6, 1, 2, 3, 4, 5, 6}; + uint8_t passkey[] = {HCI_EVENT_USER_PASSKEY_REQUEST, 6, 1, 2, 3, 4, 5, 6}; + handle_btstack_event(HCI_EVENT_PACKET, 0, confirmation, sizeof(confirmation)); + handle_btstack_event(HCI_EVENT_PACKET, 0, passkey, sizeof(passkey)); + require(confirmation_accepts == 0 && passkey_accepts == 0 && + confirmation_rejections == SWITCH_PICO_ENABLE_CLASSIC && + passkey_rejections == SWITCH_PICO_ENABLE_CLASSIC, + "closed pairing policy must reject only the enabled Classic authentication"); + bluepad32_input_backend_open_pairing_window(); + process_rumble_timer(&g_rumble_timer); + require(bondable && + switch_pico_switch2_pairing_allowed() == (SWITCH_PICO_ENABLE_BLE != 0) && + (!SWITCH_PICO_ENABLE_BLE || accepted_stk_methods == kAllBlePairingMethods), + "pairing window did not enable only the selected authentication policy"); + handle_btstack_event(HCI_EVENT_PACKET, 0, confirmation, sizeof(confirmation)); + handle_btstack_event(HCI_EVENT_PACKET, 0, passkey, sizeof(passkey)); + require(confirmation_accepts == SWITCH_PICO_ENABLE_CLASSIC && + passkey_accepts == SWITCH_PICO_ENABLE_CLASSIC, + "pairing window accepted disabled Classic authentication"); + + uni_hid_device_t controllers[kSlotCount]{}; + for (uint8_t index = 0; index < kSlotCount; ++index) { + const bool ble = SWITCH_PICO_ENABLE_BLE && + (!SWITCH_PICO_ENABLE_CLASSIC || (index & 1u)); + controllers[index] = device(index, true, ble ? UNI_BT_CONN_PROTOCOL_BLE + : UNI_BT_CONN_PROTOCOL_BR_EDR); + platform_on_device_connected(&controllers[index]); + require(platform_on_device_ready(&controllers[index]) == UNI_ERROR_SUCCESS, + "enabled transport failed to fill the available slots"); + } + require_selected_radio(false, false); + require(platform_on_device_discovered(controllers[0].conn.btaddr, "full", 0, 0) == + UNI_ERROR_IGNORE_DEVICE, + "full slots admitted another discovery"); + now_ms = g_pairing_window_deadline_ms; + process_rumble_timer(&g_rumble_timer); + require(!bondable && !switch_pico_switch2_pairing_allowed() && + (!SWITCH_PICO_ENABLE_BLE || accepted_stk_methods == 0), + "pairing authentication remained enabled after the deadline"); + platform_on_device_disconnected(&controllers[3]); + require_selected_radio(false, true); + bluepad32_input_backend_open_pairing_window(); + process_rumble_timer(&g_rumble_timer); + require_selected_radio(true, true); + for (uint8_t index = 0; index < 3; ++index) { + platform_on_device_disconnected(&controllers[index]); + } + require_selected_radio(true, true); + require(classic_bond_count == 1 && ble_bond_count == 1 && + switch2_pairing_count == 1 && delete_key_calls == 0, + "pairing windows or disconnects erased inactive-mode bonds"); + for (unsigned index = 0; index < 2; ++index) { + require_active_profile(saved_identities[index], 2 + index, 37 + index); + } + const uint32_t token = bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&bonds); + require(bluepad32_input_backend_clear_pairings_completed(bonds, token) && + bonds.record_count == 0 && classic_bond_count == 0 && + ble_bond_count == 0 && switch2_pairing_count == 0, + "explicit clearing must still cover all stored transports"); + for (unsigned index = 0; index < 2; ++index) { + require_active_profile(saved_identities[index], 2 + index, 37 + index); + } + require_selected_radio(true, true); +} + +void test_transport_background_scan() { + start_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + remember_switch2(right); + register_lookup_device(&right); + negotiated_intervals[left.conn.handle] = 24; + ready_switch2(left); + require(scanning_enabled && background_scan_parameters && + !classic_scanning_enabled && + incoming_connections == (SWITCH_PICO_ENABLE_CLASSIC != 0) && + negotiated_intervals[left.conn.handle] == 6 && + platform_on_device_discovered(right.conn.btaddr, "mate", 0, 0) == + UNI_ERROR_SUCCESS, + "ready solo must find its remembered mate using only low-duty BLE"); + platform_on_device_connected(&right); + require(!scanning_enabled && !classic_scanning_enabled, + "pending mate setup must stop discovery"); + require(platform_on_device_ready(&right) == UNI_ERROR_SUCCESS && + !scanning_enabled && !classic_scanning_enabled, + "a complete pair must not continue background discovery"); + platform_on_device_disconnected(&right); + require(scanning_enabled && background_scan_parameters && + !classic_scanning_enabled, + "departed mate must restore low-duty BLE discovery"); + bluepad32_input_backend_open_pairing_window(); + process_rumble_timer(&g_rumble_timer); + require_selected_radio(true, true); + require(!background_scan_parameters, + "explicit pairing must replace background scan timing"); + now_ms = g_pairing_window_deadline_ms; + process_rumble_timer(&g_rumble_timer); + require(scanning_enabled && background_scan_parameters && + !classic_scanning_enabled, + "pairing expiry must restore only remembered-mate BLE discovery"); + platform_on_device_disconnected(&left); + require_selected_radio(true, true); + require(!background_scan_parameters, + "last disconnect must restore foreground scan timing"); +} + } // namespace int main(int argc, char** argv) { require(argc == 2, "scenario argument required"); const std::string scenario = argv[1]; + if (scenario == "transport-policy") { + test_transport_mode_policy(); + return 0; + } + if (scenario == "transport-background") { + test_transport_background_scan(); + return 0; + } #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES) if (scenario == "native-stateful") { test_native_stateful_routing(); diff --git a/tests/switch2_wake_test.cpp b/tests/switch2_wake_test.cpp index 22ddb0e..2b4ccb3 100644 --- a/tests/switch2_wake_test.cpp +++ b/tests/switch2_wake_test.cpp @@ -4,6 +4,7 @@ #include #include +#include "bluetooth_transport_config.h" namespace { @@ -240,10 +241,44 @@ void test_failed_wake_keeps_stable_identity() { "wake failure disrupted the stable controller identity"); } +void test_disabled_transport_wake() { + switch2_wake_initialize(); + switch2_wake_initialize(); + Switch2WakeDiagnostics diagnostics{}; + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.configured && !switch2_wake_ready_for_connections() && + !switch2_wake_request(), + "Classic-only wake must be unavailable while preserving the paired host identity"); + run_task(); + require_opcode(0, 0xfc01); + const uint8_t paired_address[] = SWITCH2_WAKE_SOURCE_ADDRESS_BYTES; + require(memcmp(submitted[0].address, paired_address, sizeof(paired_address)) == 0, + "Classic-only startup changed the address used by existing bonds"); + complete(0xfc01); + run_task(); + require_opcode(1, 0x1009); + complete(0x1009, 0, paired_address); + require(switch2_wake_ready_for_connections() && !switch2_wake_request(), + "Classic controller startup did not resume after identity verification"); + now_ms = 10000; + run_task(); + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.configured && !diagnostics.busy && + submitted_count == 2 && command_available && + diagnostics.accepted_requests == 0 && + diagnostics.completed_bursts == 0 && diagnostics.failures == 0 && + !switch2_wake_request(), + "Classic-only wake submitted BLE advertising or remained busy"); +} + } // namespace int main() { - test_stable_identity_wake(); - test_failed_wake_keeps_stable_identity(); + if (SWITCH_PICO_ENABLE_BLE) { + test_stable_identity_wake(); + test_failed_wake_keeps_stable_identity(); + } else { + test_disabled_transport_wake(); + } return 0; } diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 7a48ab1..1b6cb41 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -10,11 +10,13 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: compiler = shutil.which("c++") or shutil.which("g++") assert compiler is not None, "a host C++ compiler is required" - for adapter_feasibility, native, short_packets in ( - (False, False, False), - (True, False, False), - (True, True, False), - (True, True, True), + for bluetooth_mode, adapter_feasibility, native, short_packets in ( + ("mixed", False, False, False), + ("mixed", True, False, False), + ("mixed", True, True, False), + ("mixed", True, True, True), + ("ble", False, False, False), + ("classic", False, False, False), ): suffix = ( "_native32" @@ -25,7 +27,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: if adapter_feasibility else "" ) - executable = tmp_path / f"bluepad32_backend_lifecycle_test{suffix}" + executable = ( + tmp_path / f"bluepad32_backend_lifecycle_test_{bluetooth_mode}{suffix}" + ) command = [ compiler, "-std=c++17", @@ -34,6 +38,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "-Werror", "-pedantic", "-DSWITCH_PICO_HID_INSTANCE_COUNT=4", + f"-DSWITCH_PICO_ENABLE_BLE={int(bluetooth_mode != 'classic')}", + f"-DSWITCH_PICO_ENABLE_CLASSIC={int(bluetooth_mode != 'ble')}", ] if adapter_feasibility: command.append("-DSWITCH_PICO_USB_OUTPUT_MODES=1") @@ -84,9 +90,27 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: f"-I{root / 'bluepad32_config'}", str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"), str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"), - str(root / "src" / "firmware" / "profile" / "controller_profile_transform.cpp"), - str(root / "src" / "firmware" / "profile" / "controller_synthetic_input.cpp"), - str(root / "src" / "firmware" / "profile" / "controller_profile_runtime.cpp"), + str( + root + / "src" + / "firmware" + / "profile" + / "controller_profile_transform.cpp" + ), + str( + root + / "src" + / "firmware" + / "profile" + / "controller_synthetic_input.cpp" + ), + str( + root + / "src" + / "firmware" + / "profile" + / "controller_profile_runtime.cpp" + ), str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"), str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"), str( @@ -97,6 +121,15 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: ] ) subprocess.run(command, check=True, cwd=root) + subprocess.run([str(executable), "transport-policy"], check=True, cwd=root) + if bluetooth_mode != "classic": + subprocess.run( + [str(executable), "transport-background"], check=True, cwd=root + ) + # The existing lifecycle matrix intentionally exercises both transports. + # Single-radio builds exercise their admission/radio policy above. + if bluetooth_mode != "mixed": + continue subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root) for scenario in ( "wii-orientation", diff --git a/tests/test_build.py b/tests/test_build.py index 48ef299..4595d69 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest - ROOT = Path(__file__).resolve().parent.parent SPEC = importlib.util.spec_from_file_location("switch_pico_build", ROOT / "build.py") build_script = importlib.util.module_from_spec(SPEC) @@ -88,9 +87,7 @@ def test_explicit_environment_wins_over_cache_and_fallback(tmp_path): ), ], ) -def test_stale_explicit_environment_is_an_error( - tmp_path, variable, invalid, expected -): +def test_stale_explicit_environment_is_an_error(tmp_path, variable, invalid, expected): sdk = make_sdk(tmp_path / "sdk") toolchain = make_toolchain(tmp_path / "toolchain") environ = { @@ -201,8 +198,197 @@ def test_missing_dependencies_name_only_actionable_overrides(): which=no_compiler, ) - assert str(error.value) == ( - "Missing build prerequisite(s): Pico SDK (set PICO_SDK_PATH); " - "Arm GNU toolchain (set PICO_TOOLCHAIN_PATH or add " - "arm-none-eabi-gcc to PATH)." + assert "PICO_SDK_PATH" in str(error.value) + assert "PICO_TOOLCHAIN_PATH" in str(error.value) + + +@pytest.fixture +def build_cli(tmp_path, monkeypatch): + for name in ( + "SCRIPT_DIR", + "CONFIG_FILE", + "BUILD_DIR", + "AIO_BUILD_DIR", + "FEASIBILITY_BUILD_DIR", + "FIRMWARE_DIR", + "FIRMWARE_ELF_PATH", + "FIRMWARE_UF2_PATH", + "AIO_FIRMWARE_ELF_PATH", + "AIO_FIRMWARE_UF2_PATH", + "FEASIBILITY_FIRMWARE_ELF_PATH", + "FEASIBILITY_FIRMWARE_UF2_PATH", + ): + original = getattr(build_script, name) + monkeypatch.setattr(build_script, name, tmp_path / original.relative_to(ROOT)) + monkeypatch.setattr( + build_script, "ELF_PATH", tmp_path / "build" / "switch-pico.elf" + ) + monkeypatch.setattr( + build_script, "UF2_PATH", tmp_path / "build" / "switch-pico.uf2" + ) + sdk = make_sdk(tmp_path / "sdk") + toolchain = make_toolchain(tmp_path / "toolchain") + monkeypatch.setenv("PICO_SDK_PATH", str(sdk)) + monkeypatch.setenv("PICO_TOOLCHAIN_PATH", str(toolchain)) + monkeypatch.setattr(build_script, "resolve_picotool", lambda: Path("picotool")) + commands = [] + + def run_cmd(command): + commands.append(command) + if command[:2] == ["cmake", "--build"]: + build_dir = Path(command[2]) + build_dir.mkdir(parents=True, exist_ok=True) + for extension in ("elf", "uf2"): + (build_dir / f"switch-pico.{extension}").write_bytes( + f"{build_dir.name}:{extension}".encode() + ) + + monkeypatch.setattr(build_script, "run_cmd", run_cmd) + return commands + + +@pytest.mark.parametrize( + ("variant", "directory", "artifact"), + [ + ("--aio", "build-aio", "switch-pico-aio"), + ( + "--adapter-feasibility", + "build-feasibility", + "switch-pico-adapter-feasibility", + ), + ], +) +def test_bluetooth_modes_configure_and_publish_isolated_artifacts( + tmp_path, monkeypatch, build_cli, variant, directory, artifact +): + # Switch away from mixed and back, leaving every other image untouched. + published = {} + for mode in (None, "ble", "classic", "mixed"): + arguments = ["build.py", variant] + if mode is not None: + arguments.extend(["--bluetooth-mode", mode]) + monkeypatch.setattr(build_script.sys, "argv", arguments) + build_script.main() + + selected = mode or "mixed" + suffix = "" if selected == "mixed" else f"-{selected}" + build_dir = tmp_path / f"{directory}{suffix}" + configure, compile_command, flash_command = build_cli[-3:] + assert configure[:5] == ["cmake", "-S", str(tmp_path), "-B", str(build_dir)] + assert f"-DSWITCH_PICO_BLUETOOTH_MODE={selected.upper()}" in configure + assert "-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32" in configure + assert ("-DSWITCH_PICO_ADAPTER_FEASIBILITY=ON" in configure) == ( + variant == "--adapter-feasibility" + ) + assert compile_command == ["cmake", "--build", str(build_dir)] + assert flash_command == [ + "picotool", + "load", + str(build_dir / "switch-pico.elf"), + "-fx", + ] + for extension in ("elf", "uf2"): + destination = tmp_path / "firmware" / f"{artifact}{suffix}.{extension}" + published[destination] = f"{directory}{suffix}:{extension}".encode() + for destination, expected in published.items(): + assert destination.read_bytes() == expected + + +def test_uart_default_preserves_paths_and_explicit_mixed_configuration( + tmp_path, monkeypatch, build_cli +): + monkeypatch.setattr(build_script.sys, "argv", ["build.py"]) + build_script.main() + + configure, compile_command, flash_command = build_cli + assert configure[:5] == [ + "cmake", + "-S", + str(tmp_path), + "-B", + str(tmp_path / "build"), + ] + assert "-DSWITCH_PICO_INPUT_BACKEND=UART" in configure + assert "-DSWITCH_PICO_BLUETOOTH_MODE=MIXED" in configure + assert compile_command == ["cmake", "--build", str(tmp_path / "build")] + assert flash_command == [ + "picotool", + "load", + str(tmp_path / "build" / "switch-pico.elf"), + "-fx", + ] + for extension in ("elf", "uf2"): + assert (tmp_path / "firmware" / f"switch-pico.{extension}").read_bytes() == ( + f"build:{extension}".encode() + ) + + +@pytest.mark.parametrize( + "arguments", + [ + ["--bluetooth-mode", "ble"], + ["--bluetooth-mode", "classic"], + ["--wake-capture", "--bluetooth-mode", "ble"], + ["--wake-capture", "--bluetooth-mode", "classic"], + ["--aio", "--bluetooth-mode", "invalid"], + ], +) +def test_invalid_bluetooth_selection_exits_before_build_setup(monkeypatch, arguments): + monkeypatch.setattr(build_script.sys, "argv", ["build.py", *arguments]) + monkeypatch.setattr( + build_script, + "configure_pico_environment", + lambda: pytest.fail("invalid selection reached build setup"), + ) + with pytest.raises(SystemExit) as error: + build_script.main() + assert error.value.code == 2 + + +def test_uart_artifact_overrides_remain_effective(tmp_path, monkeypatch, build_cli): + elf_path = tmp_path / "custom.elf" + uf2_path = tmp_path / "custom.uf2" + elf_path.write_bytes(b"custom ELF") + uf2_path.write_bytes(b"custom UF2") + monkeypatch.setenv("ELF_PATH", str(elf_path)) + monkeypatch.setenv("UF2_PATH", str(uf2_path)) + overridden = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(overridden) + monkeypatch.setattr(build_script, "ELF_PATH", overridden.ELF_PATH) + monkeypatch.setattr(build_script, "UF2_PATH", overridden.UF2_PATH) + monkeypatch.setattr(build_script.sys, "argv", ["build.py"]) + + build_script.main() + + assert (tmp_path / "firmware" / "switch-pico.elf").read_bytes() == b"custom ELF" + assert (tmp_path / "firmware" / "switch-pico.uf2").read_bytes() == b"custom UF2" + assert build_cli[-1] == ["picotool", "load", str(elf_path), "-fx"] + + +@pytest.mark.parametrize("color_option", ["--grip-color", "--random-grip-color"]) +def test_ble_build_preserves_grip_color_options( + tmp_path, monkeypatch, build_cli, color_option +): + config = build_script.CONFIG_FILE + config.parent.mkdir(parents=True) + config.write_text( + "".join(f"#define {macro} 0x00\n" for macro in build_script.MACROS), + encoding="utf-8", + ) + arguments = ["build.py", "--aio", "--bluetooth-mode", "ble", color_option] + if color_option == "--grip-color": + arguments.append("A1B2C3") + else: + monkeypatch.setattr(build_script, "random_hex_color", lambda: "A1B2C3") + monkeypatch.setattr(build_script.sys, "argv", arguments) + + build_script.main() + + assert config.read_text(encoding="utf-8") == "".join( + f"#define SWITCH_COLOR_SLOT_{slot}_{component} 0x{value}\n" + for slot in range(1, 5) + for component, value in zip(("R", "G", "B"), ("A1", "B2", "C3")) + ) + assert (tmp_path / "firmware" / "switch-pico-aio-ble.uf2").read_bytes() == ( + b"build-aio-ble:uf2" ) diff --git a/tests/test_switch2_wake_native.py b/tests/test_switch2_wake_native.py index aacf8ad..699cab8 100644 --- a/tests/test_switch2_wake_native.py +++ b/tests/test_switch2_wake_native.py @@ -7,22 +7,26 @@ def test_switch2_wake_native(tmp_path: Path) -> None: root = Path(__file__).resolve().parents[1] compiler = shutil.which("c++") or shutil.which("g++") assert compiler is not None, "a host C++ compiler is required" - executable = tmp_path / "switch2_wake_test" - subprocess.run( - [ - compiler, - "-std=c++17", - "-Wall", - "-Wextra", - "-Werror", - "-pedantic", - f"-I{root / 'tests' / 'switch2_wake_native_stubs'}", - f"-I{root / 'src' / 'firmware'}", - str(root / "tests" / "switch2_wake_test.cpp"), - "-o", - str(executable), - ], - check=True, - cwd=root, - ) - subprocess.run([str(executable)], check=True, cwd=root) + for mode in ("mixed", "ble", "classic"): + executable = tmp_path / f"switch2_wake_test_{mode}" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-DSWITCH_PICO_ENABLE_BLE={int(mode != 'classic')}", + f"-DSWITCH_PICO_ENABLE_CLASSIC={int(mode != 'ble')}", + f"-I{root / 'tests' / 'switch2_wake_native_stubs'}", + f"-I{root / 'src' / 'firmware'}", + f"-I{root / 'bluepad32_config'}", + str(root / "tests" / "switch2_wake_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root)