diff --git a/.gitignore b/.gitignore index 70bc105..5bc85b8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ __pycache__ pytest-*/* build-*/* .ignore +src/firmware/platform/pico/switch2_wake_config.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a7e7c57..d92cb0b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ${SWITCH_PICO_SOURCE_DIR}/configuration/configuration_transaction.cpp ${SWITCH_PICO_SOURCE_DIR}/core/controller_identity.cpp ${SWITCH_PICO_SOURCE_DIR}/input/bluepad32_input_backend.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/switch2_wake.cpp ${SWITCH_PICO_SOURCE_DIR}/platform/pico/bootsel_pairing_button.cpp ${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_configuration_storage.cpp ${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_profile_storage.cpp diff --git a/README.md b/README.md index e24d8c0..bef78d4 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,102 @@ 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. +### Switch 2 wake from L + R + Home, PS, or Xbox + +The AIO firmware can wake a sleeping Switch 2 when a connected controller's +physical **L + R + System** chord becomes held: L + R + Home on +Nintendo-style controllers, L1 + R1 + PS on PlayStation controllers, or +LB + RB + Xbox on Xbox controllers. Plain Home, PS, or Xbox remains a normal +button and does not start wake advertising. Setup needs one wake advertisement +captured from a Joy-Con 2 already paired with that Switch 2. The generated +configuration is console-specific and is intentionally ignored by Git. + +The implementation replays the captured, unencrypted Switch 2 BLE wake +advertisement for two seconds at a 20 ms base interval. The CYW43439 has one +controller-wide public Bluetooth address, so the firmware temporarily changes +from the Pico's normal identity to the captured Joy-Con identity for the wake +burst and restores it afterward. The explicit chord confines the resulting +input-controller disconnect to an intentional wake attempt. This follows the +packet format documented by +[`ndeadly/switch2_controller_research`](https://github.com/ndeadly/switch2_controller_research/blob/master/bluetooth_interface.md) +and the capture/replay approach demonstrated by +[`alexvnesta/switch2controller`](https://github.com/alexvnesta/switch2controller) +and the MIT-licensed +[`Switch2-Wake-Beacon-ESPHome`](https://github.com/sickyj/Switch2-Wake-Beacon-ESPHome). + +Back up the complete Pico flash before replacing the AIO firmware with the +temporary capture image: + +```sh +picotool save -a -v switch-pico-before-wake-capture.uf2 +``` + +Then: + +1. Connect the Pico 2 W to the computer and build/flash the one-shot capture + firmware: + + ```sh + python3 build.py --wake-capture + ``` + + This also publishes `firmware/switch-pico-wake-capture.elf` and + `firmware/switch-pico-wake-capture.uf2`. + +2. Start the configuration tool. It auto-detects a single Pico USB serial + port; use `--port /dev/ttyACM0` when more than one Pico is attached: + + ```sh + python3 tools/configure_switch2_wake.py + ``` + +3. Detach a Joy-Con 2 that is already paired with the target Switch 2, put the + console to sleep, and press that Joy-Con's Home button. Do not press its + sync button. The capture firmware accepts the first public `ADV_IND` packet + with Nintendo's Switch 2 wake flag and nonzero console address, stops + scanning automatically, lights the onboard LED solid, and repeats the + captured record until the tool receives it. + +4. The tool validates the packet and atomically writes + `src/firmware/platform/pico/switch2_wake_config.h`. + +5. Restore the AIO firmware with the generated wake configuration: + + ```sh + python3 build.py --aio + ``` + + +The capture tool also accepts a saved serial log: + +```sh +python3 tools/configure_switch2_wake.py --input switch2-joycon-capture.log +``` + +With the configured AIO firmware powered while the console sleeps, first turn +on the paired input controller with Home, PS, or Xbox and let it reconnect to +the Pico. Then hold L + R and press its system button to send one wake burst. +Holding the chord does not retrigger it; release at least one chord button +before another attempt. Plain Home, PS, or Xbox is forwarded normally and does +not disturb the radio. + +The input controller disconnects during the intentional wake burst because +the CYW43439 cannot retain its normal public identity while transmitting the +captured controller's public identity. It can reconnect after the Pico restores +its address. Avoiding that disconnect requires a second BLE radio dedicated to +wake transmission; keeping the captured identity throughout gameplay caused +severe Classic Bluetooth latency in hardware testing. + +The Pico must remain powered for wireless wake. If the Switch or dock removes +USB power during sleep, use a powered USB arrangement that preserves the +Pico-to-Switch data connection. Keep the captured Joy-Con inactive during the +two-second wake burst to avoid two radios using one address. + +To target another Switch 2, repeat the capture and configuration steps. To +disable wake, delete the generated `switch2_wake_config.h` and rebuild the AIO +firmware. Restore the full-flash backup only if you need to recover the exact +pre-capture firmware and persistent state. + ### Pairing up to four controllers 1. Flash and connect the Pico 2 W to the Switch. diff --git a/build.py b/build.py index eda4f08..cc524d5 100644 --- a/build.py +++ b/build.py @@ -15,6 +15,8 @@ CONFIG_FILE = FIRMWARE_SOURCE_DIR / "platform" / "pico" / "controller_color_conf BUILD_DIR = SCRIPT_DIR / "build" AIO_BUILD_DIR = SCRIPT_DIR / "build-aio" FEASIBILITY_BUILD_DIR = SCRIPT_DIR / "build-feasibility" +WAKE_CAPTURE_SOURCE_DIR = SCRIPT_DIR / "tools" / "switch2_wake_capture" +WAKE_CAPTURE_BUILD_DIR = SCRIPT_DIR / "build-wake-capture" FIRMWARE_DIR = SCRIPT_DIR / "firmware" FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf" FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2" @@ -26,6 +28,12 @@ FEASIBILITY_FIRMWARE_ELF_PATH = ( FEASIBILITY_FIRMWARE_UF2_PATH = ( FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2" ) +WAKE_CAPTURE_FIRMWARE_ELF_PATH = ( + FIRMWARE_DIR / "switch-pico-wake-capture.elf" +) +WAKE_CAPTURE_FIRMWARE_UF2_PATH = ( + FIRMWARE_DIR / "switch-pico-wake-capture.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() @@ -38,7 +46,12 @@ MACROS = tuple( CMAKE_CACHE_PATHS = tuple( build_dir / "CMakeCache.txt" - for build_dir in (BUILD_DIR, AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR) + for build_dir in ( + BUILD_DIR, + AIO_BUILD_DIR, + FEASIBILITY_BUILD_DIR, + WAKE_CAPTURE_BUILD_DIR, + ) ) TOOLCHAIN_COMPILER = ( "arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc" @@ -279,6 +292,11 @@ def parse_args(): action="store_true", help="Build and flash the Pico 2 W automatic Switch/XInput prototype.", ) + mode_group.add_argument( + "--wake-capture", + action="store_true", + help="Build and flash the automatic Switch 2 wake capture firmware.", + ) group = parser.add_mutually_exclusive_group() group.add_argument( "--random-grip-color", @@ -290,7 +308,10 @@ def parse_args(): metavar="RRGGBB", help="Set every emulated controller slot to the provided hex color.", ) - return parser.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") + return args def random_hex_color(): return "".join(f"{random.randrange(256):02X}" for _ in range(3)) @@ -409,6 +430,39 @@ def build( print(f"Copied UF2: {firmware_uf2_path}") +def build_wake_capture(): + elf_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.elf" + uf2_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.uf2" + run_cmd( + [ + "cmake", + "-S", + str(WAKE_CAPTURE_SOURCE_DIR), + "-B", + str(WAKE_CAPTURE_BUILD_DIR), + "-DPICO_BOARD=pico2_w", + ] + ) + run_cmd(["cmake", "--build", str(WAKE_CAPTURE_BUILD_DIR)]) + 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: Wake capture build did not produce: {missing}\n" + ) + sys.exit(1) + FIRMWARE_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(elf_path, WAKE_CAPTURE_FIRMWARE_ELF_PATH) + shutil.copy2(uf2_path, WAKE_CAPTURE_FIRMWARE_UF2_PATH) + print(f"Built wake capture ELF: {elf_path}") + print(f"Built wake capture UF2: {uf2_path}") + print(f"Copied ELF: {WAKE_CAPTURE_FIRMWARE_ELF_PATH}") + print(f"Copied UF2: {WAKE_CAPTURE_FIRMWARE_UF2_PATH}") + return elf_path + + def flash(elf_path, allow_elf_override): picotool = resolve_picotool() if not elf_path.exists(): @@ -428,6 +482,10 @@ def main(): except BuildEnvironmentError as exc: sys.stderr.write(f"Error: {exc}\n") sys.exit(1) + if args.wake_capture: + wake_capture_elf = build_wake_capture() + flash(wake_capture_elf, allow_elf_override=False) + return color = None diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 01de644..9650cbc 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index 55071c4..371f7ce 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/firmware/switch-pico-wake-capture.elf b/firmware/switch-pico-wake-capture.elf new file mode 100755 index 0000000..d74d007 Binary files /dev/null and b/firmware/switch-pico-wake-capture.elf differ diff --git a/firmware/switch-pico-wake-capture.uf2 b/firmware/switch-pico-wake-capture.uf2 new file mode 100644 index 0000000..9aaf841 Binary files /dev/null and b/firmware/switch-pico-wake-capture.uf2 differ diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 0fcc3ba..535b214 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -1,5 +1,6 @@ #include "input/bluepad32_input_backend.h" #include "input/controller_hotkey_config.h" +#include "input/switch2_wake.h" #include "configuration/configuration_service.h" #include "profile/profile_service.h" #include @@ -500,6 +501,8 @@ ConnectionStatus compute_connection_status() { : ConnectionStatus::Scanning; } + + void publish_device_state(uint8_t slot, uni_hid_device_t* device, uint16_t pre_hotkey_button_mask, const ControllerState& state) { @@ -632,6 +635,21 @@ constexpr uint16_t logical_button_bit( return static_cast( 1u << static_cast(button)); } +bool wake_chord_rising_edge(uint8_t slot, uni_hid_device_t* device, + uint16_t button_mask) { + const uint16_t chord = + logical_button_bit(ControllerProfileLogicalButton::kLeftShoulder) | + logical_button_bit(ControllerProfileLogicalButton::kRightShoulder) | + logical_button_bit(ControllerProfileLogicalButton::kSystem); + critical_section_enter_blocking(&g_state_lock); + const BackendSlot& previous = g_slots[slot]; + const bool rising = + previous.active && previous.device == device && + (button_mask & chord) == chord && + (previous.pre_hotkey_button_mask & chord) != chord; + critical_section_exit(&g_state_lock); + return rising; +} constexpr uint16_t logical_button_mask( uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) { @@ -1462,6 +1480,7 @@ void platform_on_init_complete() { g_identity_event_callback.callback = handle_btstack_event; sm_add_event_handler(&g_identity_event_callback); hci_add_event_handler(&g_pairing_event_callback); + switch2_wake_initialize(); refresh_pairing_snapshot(); // Keep Bluepad32 autoconnect active whenever at least one slot is free. btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); @@ -1624,6 +1643,11 @@ void platform_on_controller_data(uni_hid_device_t* device, uni_gamepad_t gamepad = controller->gamepad; const uint16_t pre_hotkey_button_mask = logical_button_mask(gamepad); + if (wake_chord_rising_edge( + static_cast(slot_index), device, + pre_hotkey_button_mask)) { + switch2_wake_request(); + } const HotkeyDecision hotkeys = update_controller_hotkeys( static_cast(slot_index), device); const uint16_t output_button_mask = pre_hotkey_button_mask; diff --git a/src/firmware/input/switch2_wake.cpp b/src/firmware/input/switch2_wake.cpp new file mode 100644 index 0000000..6090318 --- /dev/null +++ b/src/firmware/input/switch2_wake.cpp @@ -0,0 +1,316 @@ +#include "input/switch2_wake.h" + +#include + +#include + +#if !defined(SWITCH2_WAKE_CONFIGURED) +#if __has_include("platform/pico/switch2_wake_config.h") +#include "platform/pico/switch2_wake_config.h" +#else +#define SWITCH2_WAKE_CONFIGURED 0 +#endif +#endif + +namespace { + +constexpr uint32_t kRetryIntervalMs = 5; +constexpr uint32_t kCommandTimeoutMs = 1000; +constexpr uint32_t kBurstDurationMs = 2000; +constexpr uint16_t kAdvertisingIntervalUnits = 0x0020; // 20 ms + BLE delay. +constexpr uint8_t kAdvertisingTypeNonConnectable = 3; +constexpr uint16_t kWritePublicAddressOpcode = 0xfc01; + +#if SWITCH2_WAKE_CONFIGURED +const uint8_t kWakeAddress[6] = SWITCH2_WAKE_SOURCE_ADDRESS_BYTES; +uint8_t kWakeAdvertisement[31] = SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES; +#endif + +const hci_cmd_t kWritePublicAddress = {kWritePublicAddressOpcode, "B"}; +const bd_addr_t kUnusedPeerAddress{}; + +enum class Phase : uint8_t { + kDisabled, + kIdle, + kSetWakeAddress, + kSetParameters, + kSetData, + kEnableAdvertising, + kAdvertising, + kDisableAdvertising, + kRestoreAddress, + kFailed, +}; + +Phase g_phase = Phase::kDisabled; +bd_addr_t g_original_address{}; +btstack_packet_callback_registration_t g_event_registration{}; +btstack_timer_source_t g_timer{}; +uint16_t g_pending_opcode = 0; +uint32_t g_command_deadline_ms = 0; +uint32_t g_burst_deadline_ms = 0; +uint32_t g_accepted_requests = 0; +uint32_t g_completed_bursts = 0; +uint32_t g_failures = 0; +bool g_initialized = false; +bool g_configured = false; +bool g_timer_armed = false; +bool g_address_changed = false; +bool g_advertising = false; + +bool deadline_reached(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; +} + +bool phase_needs_work() { + return g_phase != Phase::kDisabled && + g_phase != Phase::kIdle && + g_phase != Phase::kFailed; +} + +void cancel_task() { + if (g_timer_armed) { + btstack_run_loop_remove_timer(&g_timer); + g_timer_armed = false; + } +} + +void schedule_task(uint32_t delay_ms) { + cancel_task(); + btstack_run_loop_set_timer(&g_timer, delay_ms); + btstack_run_loop_add_timer(&g_timer); + g_timer_armed = true; +} + +void schedule_for_phase(uint32_t now_ms) { + if (g_pending_opcode != 0) { + schedule_task( + deadline_reached(now_ms, g_command_deadline_ms) + ? 0 + : g_command_deadline_ms - now_ms); + } else if (g_phase == Phase::kAdvertising) { + schedule_task( + deadline_reached(now_ms, g_burst_deadline_ms) + ? 0 + : g_burst_deadline_ms - now_ms); + } else if (phase_needs_work()) { + schedule_task(kRetryIntervalMs); + } else { + cancel_task(); + } +} + +#if SWITCH2_WAKE_CONFIGURED +bool configured_packet_valid() { + static const uint8_t prefix[] = { + 0x02, 0x01, 0x06, 0x1b, 0xff, 0x53, 0x05, + 0x01, 0x00, 0x03, 0x7e, 0x05, + }; + static const uint8_t suffix[] = { + 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + uint8_t address_any = 0; + uint8_t target_any = 0; + for (uint8_t value : kWakeAddress) { + address_any |= value; + } + for (size_t index = 17; index < 23; ++index) { + target_any |= kWakeAdvertisement[index]; + } + return address_any != 0 && target_any != 0 && + memcmp(kWakeAdvertisement, prefix, sizeof(prefix)) == 0 && + kWakeAdvertisement[14] == 0x00 && + kWakeAdvertisement[15] == 0x01 && + kWakeAdvertisement[16] == 0x81 && + memcmp(&kWakeAdvertisement[23], suffix, sizeof(suffix)) == 0; +} +#endif + +void recover_from_failure() { + ++g_failures; + g_pending_opcode = 0; + if (g_advertising) { + g_phase = Phase::kDisableAdvertising; + } else if (g_address_changed) { + g_phase = Phase::kRestoreAddress; + } else { + g_phase = Phase::kIdle; + } +} + +void begin_command(uint16_t opcode, uint32_t now_ms) { + g_pending_opcode = opcode; + g_command_deadline_ms = now_ms + kCommandTimeoutMs; +} + +void submit_phase_command(uint32_t now_ms) { + if (g_pending_opcode != 0 || !hci_can_send_command_packet_now()) { + return; + } + + uint8_t result = ERROR_CODE_SUCCESS; + switch (g_phase) { + case Phase::kSetWakeAddress: + begin_command(kWritePublicAddress.opcode, now_ms); +#if SWITCH2_WAKE_CONFIGURED + result = hci_send_cmd(&kWritePublicAddress, kWakeAddress); +#endif + break; + case Phase::kSetParameters: + begin_command(hci_le_set_advertising_parameters.opcode, now_ms); + result = hci_send_cmd( + &hci_le_set_advertising_parameters, + kAdvertisingIntervalUnits, kAdvertisingIntervalUnits, + kAdvertisingTypeNonConnectable, + BD_ADDR_TYPE_LE_PUBLIC, BD_ADDR_TYPE_LE_PUBLIC, + kUnusedPeerAddress, 7, 0); + break; + case Phase::kSetData: + begin_command(hci_le_set_advertising_data.opcode, now_ms); +#if SWITCH2_WAKE_CONFIGURED + result = hci_send_cmd( + &hci_le_set_advertising_data, + static_cast(sizeof(kWakeAdvertisement)), + kWakeAdvertisement); +#endif + break; + case Phase::kEnableAdvertising: + case Phase::kDisableAdvertising: + begin_command(hci_le_set_advertise_enable.opcode, now_ms); + result = hci_send_cmd( + &hci_le_set_advertise_enable, + g_phase == Phase::kEnableAdvertising ? 1 : 0); + break; + case Phase::kRestoreAddress: + begin_command(kWritePublicAddress.opcode, now_ms); + result = hci_send_cmd(&kWritePublicAddress, g_original_address); + break; + default: + return; + } + + if (result != ERROR_CODE_SUCCESS) { + recover_from_failure(); + } +} + +void handle_command_complete(uint8_t* packet, uint16_t size) { + if (size < 6 || g_pending_opcode == 0) { + return; + } + const uint16_t opcode = + hci_event_command_complete_get_command_opcode(packet); + if (opcode != g_pending_opcode) { + return; + } + const uint8_t status = + hci_event_command_complete_get_return_parameters(packet)[0]; + g_pending_opcode = 0; + if (status != ERROR_CODE_SUCCESS) { + recover_from_failure(); + schedule_for_phase(btstack_run_loop_get_time_ms()); + return; + } + + switch (g_phase) { + case Phase::kSetWakeAddress: + g_address_changed = true; + g_phase = Phase::kSetParameters; + break; + case Phase::kSetParameters: + g_phase = Phase::kSetData; + break; + case Phase::kSetData: + g_phase = Phase::kEnableAdvertising; + break; + case Phase::kEnableAdvertising: + g_advertising = true; + g_burst_deadline_ms = + btstack_run_loop_get_time_ms() + kBurstDurationMs; + g_phase = Phase::kAdvertising; + break; + case Phase::kDisableAdvertising: + g_advertising = false; + g_phase = Phase::kRestoreAddress; + break; + case Phase::kRestoreAddress: + g_address_changed = false; + ++g_completed_bursts; + g_phase = Phase::kIdle; + break; + default: + recover_from_failure(); + break; + } + schedule_for_phase(btstack_run_loop_get_time_ms()); +} + +void handle_hci_event(uint8_t packet_type, uint16_t, + uint8_t* packet, uint16_t size) { + if (packet_type == HCI_EVENT_PACKET && size >= 2 && + hci_event_packet_get_type(packet) == HCI_EVENT_COMMAND_COMPLETE) { + handle_command_complete(packet, size); + } +} + +void task(btstack_timer_source_t*) { + g_timer_armed = false; + const uint32_t now_ms = btstack_run_loop_get_time_ms(); + if (g_pending_opcode != 0 && + deadline_reached(now_ms, g_command_deadline_ms)) { + recover_from_failure(); + } + if (g_phase == Phase::kAdvertising && + deadline_reached(now_ms, g_burst_deadline_ms)) { + g_phase = Phase::kDisableAdvertising; + } + submit_phase_command(now_ms); + schedule_for_phase(now_ms); +} + +} // namespace + +void switch2_wake_initialize() { + if (g_initialized) { + return; + } + g_initialized = true; +#if SWITCH2_WAKE_CONFIGURED + if (!configured_packet_valid()) { + g_phase = Phase::kFailed; + ++g_failures; + return; + } + g_configured = true; + gap_local_bd_addr(g_original_address); + g_event_registration.callback = handle_hci_event; + hci_add_event_handler(&g_event_registration); + btstack_run_loop_set_timer_handler(&g_timer, task); + g_phase = Phase::kIdle; +#endif +} + + +bool switch2_wake_request() { + if (g_phase != Phase::kIdle) { + return false; + } + ++g_accepted_requests; + g_phase = Phase::kSetWakeAddress; + schedule_task(0); + return true; +} + +void switch2_wake_diagnostics(Switch2WakeDiagnostics* out) { + if (out == nullptr) { + return; + } + *out = { + g_configured, + g_phase != Phase::kDisabled && g_phase != Phase::kIdle && + g_phase != Phase::kFailed, + g_accepted_requests, + g_completed_bursts, + g_failures, + }; +} diff --git a/src/firmware/input/switch2_wake.h b/src/firmware/input/switch2_wake.h new file mode 100644 index 0000000..318136b --- /dev/null +++ b/src/firmware/input/switch2_wake.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +struct Switch2WakeDiagnostics { + bool configured; + bool busy; + uint32_t accepted_requests; + uint32_t completed_bursts; + uint32_t failures; +}; + +// Installs BTstack callbacks and remembers the Pico's normal public identity. +void switch2_wake_initialize(); + + +// Starts one wake burst when configured and idle. Calls while busy coalesce. +bool switch2_wake_request(); + +void switch2_wake_diagnostics(Switch2WakeDiagnostics* out); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index f4a1142..bb7f640 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -5,6 +5,7 @@ #include #include "platform/pico/controller_color_config.h" +#include "input/switch2_wake.h" namespace { @@ -44,6 +45,8 @@ bool expect_configuration_timer_prearmed = false; uint32_t expected_configuration_timer_add_count = 0; int cyw43_init_calls = 0; int uni_init_calls = 0; +int switch2_wake_initializations = 0; +int switch2_wake_requests = 0; int device_disconnect_calls = 0; uni_hid_device_t* last_disconnected_device = nullptr; uni_hid_device_t* lookup_devices[8]{}; @@ -442,6 +445,19 @@ uint32_t btstack_run_loop_get_time_ms() { } +void switch2_wake_initialize() { + ++switch2_wake_initializations; +} + + +bool switch2_wake_request() { + ++switch2_wake_requests; + return true; +} + +void switch2_wake_diagnostics(Switch2WakeDiagnostics*) { +} + #include "core/controller_identity.cpp" #include "input/bluepad32_input_backend.cpp" @@ -548,7 +564,8 @@ void start_backend() { kClassicLinkSupervisionTimeout && !bondable && accepted_stk_methods == 0 && !ssp_auto_accept && pairing_event_handler != nullptr && - identity_event_handler != nullptr, + identity_event_handler != nullptr && + switch2_wake_initializations == 1, "initialization must register Classic and BLE identity policy"); } void start_pairing_backend() { @@ -2373,6 +2390,51 @@ void test_flash_core_start_contract() { "backend start must remain idempotent"); } + + +void test_system_button_wake_trigger() { + start_pairing_backend(); + uni_hid_device_t controller = device(0); + require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, + "wake trigger controller did not become ready"); + + uni_controller_t input{}; + input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 0, + "neutral input requested a wake burst"); + + input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 0, + "plain system button requested a wake burst"); + + input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R; + platform_on_controller_data(&controller, &input); + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 1, + "held L+R+System chord did not produce exactly one wake request"); + + input.gamepad.buttons = 0; + input.gamepad.misc_buttons = 0; + platform_on_controller_data(&controller, &input); + input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 1, + "L+R without System requested wake"); + input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 2, + "second L+R+System chord edge did not request wake"); + + input.gamepad.buttons = 0; + input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 2, + "non-system misc button requested wake"); +} + + void test_flash_core_init_fatal() { bluepad32_input_backend_init(); flash_core_init_result = false; @@ -2424,6 +2486,8 @@ int main(int argc, char** argv) { test_configuration_timer_rearms_before_storage_work(); } else if (scenario == "flash-core-start") { test_flash_core_start_contract(); + } else if (scenario == "system-wake") { + test_system_button_wake_trigger(); } else if (scenario == "flash-core-failure") { test_flash_core_init_fatal(); } else { diff --git a/tests/switch2_wake_native_stubs/btstack.h b/tests/switch2_wake_native_stubs/btstack.h new file mode 100644 index 0000000..a65ec2a --- /dev/null +++ b/tests/switch2_wake_native_stubs/btstack.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +using bd_addr_t = uint8_t[6]; +using btstack_packet_handler_t = void (*)(uint8_t, uint16_t, uint8_t*, uint16_t); +struct btstack_packet_callback_registration_t { + void* item; + btstack_packet_handler_t callback; +}; +struct btstack_timer_source_t { + void (*handler)(btstack_timer_source_t*); + uint32_t timeout_ms; + uint32_t add_count; +}; +struct hci_cmd_t { + uint16_t opcode; + const char* format; +}; + +enum { + ERROR_CODE_SUCCESS = 0, + HCI_EVENT_PACKET = 4, + HCI_EVENT_COMMAND_COMPLETE = 0x0e, + BD_ADDR_TYPE_LE_PUBLIC = 0, +}; + +extern const hci_cmd_t hci_read_bd_addr; +extern const hci_cmd_t hci_le_set_advertising_parameters; +extern const hci_cmd_t hci_le_set_advertising_data; +extern const hci_cmd_t hci_le_set_advertise_enable; + +bool hci_can_send_command_packet_now(); +uint8_t hci_send_cmd(const hci_cmd_t* command, ...); +void hci_add_event_handler(btstack_packet_callback_registration_t* registration); +void gap_local_bd_addr(bd_addr_t output); +uint32_t btstack_run_loop_get_time_ms(); +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, + void (*handler)(btstack_timer_source_t*)); +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms); +void btstack_run_loop_add_timer(btstack_timer_source_t* timer); +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer); + +inline uint8_t hci_event_packet_get_type(const uint8_t* event) { + return event[0]; +} +inline uint16_t hci_event_command_complete_get_command_opcode( + const uint8_t* event) { + return static_cast(event[3]) | + static_cast(event[4] << 8); +} +inline const uint8_t* hci_event_command_complete_get_return_parameters( + const uint8_t* event) { + return &event[5]; +} diff --git a/tests/switch2_wake_test.cpp b/tests/switch2_wake_test.cpp new file mode 100644 index 0000000..ecff807 --- /dev/null +++ b/tests/switch2_wake_test.cpp @@ -0,0 +1,249 @@ +#include +#include +#include +#include + +#include + +namespace { + +struct SubmittedCommand { + uint16_t opcode; + uint8_t address[6]; + uint16_t interval_min; + uint16_t interval_max; + uint8_t advertising_type; + uint8_t own_address_type; + uint8_t channel_map; + uint8_t filter_policy; + uint8_t data[31]; + uint8_t data_length; + uint8_t enabled; +}; + +uint32_t now_ms; +bd_addr_t original_address = {0xA8, 0x59, 0x5F, 0x9E, 0x37, 0x65}; +btstack_packet_handler_t event_handler; +btstack_timer_source_t* installed_timer; +SubmittedCommand submitted[16]{}; +size_t submitted_count; +bool command_available = true; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +} // namespace + +const hci_cmd_t hci_read_bd_addr = {0x1009, "read-address"}; +const hci_cmd_t hci_le_set_advertising_parameters = {0x2006, "params"}; +const hci_cmd_t hci_le_set_advertising_data = {0x2008, "data"}; +const hci_cmd_t hci_le_set_advertise_enable = {0x200a, "enable"}; + +bool hci_can_send_command_packet_now() { + return command_available; +} + +uint8_t hci_send_cmd(const hci_cmd_t* command, ...) { + require(command_available, "submitted without an HCI command credit"); + require(submitted_count < 16, "command capture overflow"); + command_available = false; + SubmittedCommand& output = submitted[submitted_count++]; + output.opcode = command->opcode; + va_list arguments; + va_start(arguments, command); + if (command->opcode == 0xfc01) { + const uint8_t* address = va_arg(arguments, const uint8_t*); + memcpy(output.address, address, sizeof(output.address)); + } else if (command->opcode == 0x2006) { + output.interval_min = static_cast(va_arg(arguments, int)); + output.interval_max = static_cast(va_arg(arguments, int)); + output.advertising_type = static_cast(va_arg(arguments, int)); + output.own_address_type = static_cast(va_arg(arguments, int)); + (void)va_arg(arguments, int); + (void)va_arg(arguments, const uint8_t*); + output.channel_map = static_cast(va_arg(arguments, int)); + output.filter_policy = static_cast(va_arg(arguments, int)); + } else if (command->opcode == 0x2008) { + output.data_length = static_cast(va_arg(arguments, int)); + const uint8_t* data = va_arg(arguments, const uint8_t*); + memcpy(output.data, data, output.data_length); + } else if (command->opcode == 0x200a) { + output.enabled = static_cast(va_arg(arguments, int)); + } + va_end(arguments); + return ERROR_CODE_SUCCESS; +} + +void hci_add_event_handler( + btstack_packet_callback_registration_t* registration) { + event_handler = registration->callback; +} + +void gap_local_bd_addr(bd_addr_t output) { + memcpy(output, original_address, sizeof(original_address)); +} + +uint32_t btstack_run_loop_get_time_ms() { + return now_ms; +} + +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, + void (*handler)(btstack_timer_source_t*)) { + timer->handler = handler; + installed_timer = timer; +} + +void btstack_run_loop_set_timer( + btstack_timer_source_t* timer, uint32_t timeout_ms) { + timer->timeout_ms = timeout_ms; +} + +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + ++timer->add_count; +} +int btstack_run_loop_remove_timer(btstack_timer_source_t*) { + return 1; +} + + +#define SWITCH2_WAKE_CONFIGURED 1 +#define SWITCH2_WAKE_SOURCE_ADDRESS_BYTES \ + {0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00} +#define SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES \ + {0x02, 0x01, 0x06, 0x1B, 0xFF, 0x53, 0x05, 0x01, 0x00, 0x03, 0x7E, \ + 0x05, 0x66, 0x20, 0x00, 0x01, 0x81, 0xBD, 0xD6, 0xF7, 0xEB, 0xF1, \ + 0x48, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} +#include "input/switch2_wake.cpp" + +namespace { + +void complete(uint16_t opcode, uint8_t status = 0, + const uint8_t* address = nullptr) { + uint8_t event[12] = { + HCI_EVENT_COMMAND_COMPLETE, + static_cast(address == nullptr ? 4 : 10), + 1, + static_cast(opcode), static_cast(opcode >> 8), + status, + }; + if (address != nullptr) { + for (size_t index = 0; index < 6; ++index) { + event[6 + index] = address[5 - index]; + } + } + command_available = true; + event_handler( + HCI_EVENT_PACKET, 0, event, + static_cast(address == nullptr ? 6 : 12)); +} + +void run_task() { + require(installed_timer != nullptr && installed_timer->handler != nullptr, + "wake timer was not installed"); + installed_timer->handler(installed_timer); +} + +void require_opcode(size_t index, uint16_t opcode) { + require(index < submitted_count && submitted[index].opcode == opcode, + "unexpected HCI command sequence"); +} + +void test_temporary_identity_wake_and_restore() { + switch2_wake_initialize(); + Switch2WakeDiagnostics diagnostics{}; + switch2_wake_diagnostics(&diagnostics); + require(diagnostics.configured && !diagnostics.busy && !g_timer_armed, + "configured wake module did not initialize dormant"); + require(switch2_wake_request() && !switch2_wake_request(), + "wake requests were not bounded while busy"); + + run_task(); + require_opcode(0, 0xfc01); + const uint8_t wake_address[] = {0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00}; + require(memcmp(submitted[0].address, wake_address, 6) == 0, + "wake address did not reach the radio command"); + complete(0xfc01); + + run_task(); + require_opcode(1, 0x2006); + require(submitted[1].interval_min == 0x20 && + submitted[1].interval_max == 0x20 && + submitted[1].advertising_type == 3 && + submitted[1].own_address_type == 0 && + submitted[1].channel_map == 7 && + submitted[1].filter_policy == 0, + "known-working advertising parameters changed"); + complete(0x2006); + + run_task(); + require_opcode(2, 0x2008); + require(submitted[2].data_length == 31 && + submitted[2].data[16] == 0x81, + "captured wake payload was not submitted intact"); + complete(0x2008); + + run_task(); + require_opcode(3, 0x200a); + require(submitted[3].enabled == 1, + "wake advertising was not enabled"); + complete(0x200a); + require(g_timer_armed && installed_timer->timeout_ms == 2000, + "wake burst did not schedule one exact stop deadline"); + now_ms = 1999; + run_task(); + require(submitted_count == 4, + "wake burst stopped before two seconds"); + now_ms = 2000; + run_task(); + require_opcode(4, 0x200a); + require(submitted[4].enabled == 0, + "wake advertising was not disabled"); + complete(0x200a); + + run_task(); + require_opcode(5, 0xfc01); + require(memcmp(submitted[5].address, original_address, 6) == 0, + "Pico gameplay identity was not restored"); + complete(0xfc01); + + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.busy && diagnostics.accepted_requests == 1 && + diagnostics.completed_bursts == 1 && + diagnostics.failures == 0 && !g_timer_armed, + "completed wake did not restore a dormant gameplay state"); +} + +void test_failed_setup_restores_gameplay_identity() { + require(switch2_wake_request(), + "idle module rejected a second wake request"); + run_task(); + require_opcode(6, 0xfc01); + complete(0xfc01); + run_task(); + require_opcode(7, 0x2006); + complete(0x2006, 0x12); + run_task(); + require_opcode(8, 0xfc01); + require(memcmp(submitted[8].address, original_address, 6) == 0, + "wake setup failure did not restore the gameplay identity"); + complete(0xfc01); + + Switch2WakeDiagnostics diagnostics{}; + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.busy && diagnostics.failures == 1 && + !g_timer_armed, + "wake failure did not recover to dormant gameplay"); +} + +} // namespace + +int main() { + test_temporary_identity_wake_and_restore(); + test_failed_setup_restores_gameplay_identity(); + return 0; +} diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 0bfea37..d424a54 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -51,6 +51,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "clear-pairings", "configuration-timer", "flash-core-start", + "system-wake", "flash-core-failure", ): subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_configure_switch2_wake.py b/tests/test_configure_switch2_wake.py new file mode 100644 index 0000000..c4595e8 --- /dev/null +++ b/tests/test_configure_switch2_wake.py @@ -0,0 +1,70 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "configure_switch2_wake", + ROOT / "tools" / "configure_switch2_wake.py", +) +configure = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(configure) + +RAW_HEX = "0201061BFF53050100037E056620000181BDD6F7EBF1480F00000000000000" +RECORD = { + "version": 1, + "advertiser": "98:E2:55:07:DF:00", + "address_type": 0, + "event_type": 0, + "rssi": -67, + "pid": "2066", + "console": "48:F1:EB:F7:D6:BD", + "raw_hex": RAW_HEX, + "esphome_payload_hex": RAW_HEX[14:], +} + + +def test_valid_capture_generates_build_configuration(tmp_path: Path) -> None: + capture_log = tmp_path / "capture.log" + capture_log.write_text( + "scanner startup\nSWITCH2_WAKE_CAPTURE " + json.dumps(RECORD) + "\n", + encoding="utf-8", + ) + output = tmp_path / "switch2_wake_config.h" + + assert configure.main( + ["--input", str(capture_log), "--output", str(output)] + ) == 0 + generated = output.read_text(encoding="utf-8") + assert "#define SWITCH2_WAKE_CONFIGURED 1" in generated + assert "0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00" in generated + assert "0x02, 0x01, 0x06, 0x1B, 0xFF" in generated + assert "Target Switch 2: 48:F1:EB:F7:D6:BD" in generated + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("address_type", 1, "public advertiser"), + ("event_type", 3, "ADV_IND"), + ("console", "48:F1:EB:F7:D6:BE", "console address"), + ("pid", "2067", "PID"), + ], +) +def test_inconsistent_capture_is_rejected(field, value, message) -> None: + record = dict(RECORD) + record[field] = value + with pytest.raises(configure.CaptureError, match=message): + configure.validate_capture(record) + + +def test_non_wake_packet_is_rejected() -> None: + record = dict(RECORD) + raw = bytearray.fromhex(RAW_HEX) + raw[16] = 0 + record["raw_hex"] = raw.hex() + with pytest.raises(configure.CaptureError, match="wake flag"): + configure.validate_capture(record) diff --git a/tests/test_switch2_wake_native.py b/tests/test_switch2_wake_native.py new file mode 100644 index 0000000..aacf8ad --- /dev/null +++ b/tests/test_switch2_wake_native.py @@ -0,0 +1,28 @@ +import shutil +import subprocess +from pathlib import Path + + +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) diff --git a/tools/configure_switch2_wake.py b/tools/configure_switch2_wake.py new file mode 100755 index 0000000..bd37053 --- /dev/null +++ b/tools/configure_switch2_wake.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Capture a Switch 2 wake record and generate the AIO firmware header.""" + +import argparse +import json +import re +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT = ( + ROOT / "src" / "firmware" / "platform" / "pico" / + "switch2_wake_config.h" +) +CAPTURE_PREFIX = "SWITCH2_WAKE_CAPTURE " +ADDRESS_PATTERN = re.compile(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") +EXPECTED_PREFIX = bytes.fromhex("0201061BFF53050100037E05") +EXPECTED_SUFFIX = bytes.fromhex("0F00000000000000") + + +class CaptureError(ValueError): + pass + + +def normalize_address(value, field): + if not isinstance(value, str) or ADDRESS_PATTERN.fullmatch(value) is None: + raise CaptureError(f"{field} must be a six-byte Bluetooth address.") + normalized = value.upper() + if normalized == "00:00:00:00:00:00": + raise CaptureError(f"{field} must not be all zeroes.") + return normalized + + +def parse_capture_line(line): + line = line.strip() + if line.startswith(CAPTURE_PREFIX): + line = line[len(CAPTURE_PREFIX):] + if not line.startswith("{"): + return None + try: + record = json.loads(line) + except json.JSONDecodeError: + return None + if not isinstance(record, dict) or "raw_hex" not in record: + return None + return validate_capture(record) + + +def validate_capture(record): + if record.get("version", 1) != 1: + raise CaptureError("unsupported capture version") + if record.get("address_type") != 0: + raise CaptureError("capture must use a public advertiser address") + if record.get("event_type") != 0: + raise CaptureError("capture must be an ADV_IND wake advertisement") + advertiser = normalize_address(record.get("advertiser"), "advertiser") + console = normalize_address(record.get("console"), "console") + raw_hex = record.get("raw_hex") + if not isinstance(raw_hex, str) or re.fullmatch(r"[0-9A-Fa-f]{62}", raw_hex) is None: + raise CaptureError("raw_hex must contain exactly 31 bytes") + raw = bytes.fromhex(raw_hex) + if raw[:12] != EXPECTED_PREFIX: + raise CaptureError("capture is not a Nintendo Switch 2 advertisement") + if raw[14:16] != b"\x00\x01": + raise CaptureError("capture has an unexpected reconnect marker") + if raw[16] != 0x81: + raise CaptureError("capture does not contain the Switch 2 wake flag") + if raw[23:] != EXPECTED_SUFFIX: + raise CaptureError("capture has an unexpected reserved suffix") + decoded_console = ":".join(f"{byte:02X}" for byte in reversed(raw[17:23])) + if decoded_console != console: + raise CaptureError("console address does not match raw wake data") + pid = int.from_bytes(raw[12:14], "little") + if record.get("pid", f"{pid:04X}").upper() != f"{pid:04X}": + raise CaptureError("PID does not match raw wake data") + payload = raw[7:].hex().upper() + supplied_payload = record.get("esphome_payload_hex", payload) + if supplied_payload.upper() != payload: + raise CaptureError("manufacturer payload does not match raw wake data") + return { + "advertiser": advertiser, + "console": console, + "pid": pid, + "raw": raw, + } + + +def byte_initializer(data): + return ", ".join(f"0x{byte:02X}" for byte in data) + + +def render_header(capture): + source = bytes.fromhex(capture["advertiser"].replace(":", "")) + return ( + "#pragma once\n\n" + "// Generated by tools/configure_switch2_wake.py. Do not commit this file.\n" + f"// Captured source controller: {capture['advertiser']}\n" + f"// Target Switch 2: {capture['console']}; PID: 0x{capture['pid']:04X}\n" + "#define SWITCH2_WAKE_CONFIGURED 1\n" + "#define SWITCH2_WAKE_SOURCE_ADDRESS_BYTES \\\n" + f" {{{byte_initializer(source)}}}\n" + "#define SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES \\\n" + f" {{{byte_initializer(capture['raw'])}}}\n" + ) + + +def capture_from_stream(stream, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + line = stream.readline() + if not line: + continue + if isinstance(line, bytes): + line = line.decode("utf-8", errors="replace") + print(line.rstrip()) + capture = parse_capture_line(line) + if capture is not None: + return capture + raise CaptureError("timed out waiting for a Switch 2 wake capture") + + +def pico_serial_ports(): + try: + from serial.tools import list_ports + except ImportError as exc: + raise CaptureError( + "pyserial is required; install project dependencies first" + ) from exc + return [port.device for port in list_ports.comports() if port.vid == 0x2E8A] + + +def capture_from_pico(port, timeout): + try: + import serial + except ImportError as exc: + raise CaptureError( + "pyserial is required; install project dependencies first" + ) from exc + if port is None: + ports = pico_serial_ports() + if len(ports) != 1: + detail = ", ".join(ports) if ports else "none" + raise CaptureError( + f"expected one Pico USB serial port, found {detail}; use --port" + ) + port = ports[0] + print(f"Waiting for automatic wake capture on {port}...") + with serial.Serial(port, 115200, timeout=0.25, write_timeout=1) as device: + deadline = time.monotonic() + timeout + next_request = 0.0 + while time.monotonic() < deadline: + now = time.monotonic() + if now >= next_request: + device.write(b"p\n") + next_request = now + 1.0 + line = device.readline() + if not line: + continue + text = line.decode("utf-8", errors="replace").strip() + print(text) + capture = parse_capture_line(text) + if capture is not None: + return capture + raise CaptureError("timed out waiting for a Switch 2 wake capture") + + +def write_header(path, capture): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(render_header(capture), encoding="utf-8") + temporary.replace(path) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser( + description=( + "Read the one-shot Pico wake capture and configure the AIO build." + ) + ) + parser.add_argument("--port", help="Pico USB serial port; auto-detected by default") + parser.add_argument( + "--input", + type=Path, + help="read a saved capture log instead of a live Pico", + ) + parser.add_argument("--timeout", type=float, default=90.0) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + try: + if args.timeout <= 0: + raise CaptureError("timeout must be positive") + if args.input is None: + capture = capture_from_pico(args.port, args.timeout) + else: + with args.input.open("r", encoding="utf-8") as stream: + capture = capture_from_stream(stream, args.timeout) + write_header(args.output, capture) + except (CaptureError, OSError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + print(f"Configured Switch 2 wake: {args.output}") + print("Build and flash it with: python3 build.py --aio") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/switch2_wake_capture/CMakeLists.txt b/tools/switch2_wake_capture/CMakeLists.txt new file mode 100644 index 0000000..43175db --- /dev/null +++ b/tools/switch2_wake_capture/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.13) +set(PICO_BOARD pico2_w CACHE STRING "Target board") +include(${CMAKE_CURRENT_LIST_DIR}/../../pico_sdk_import.cmake) +project(switch2_wake_capture C CXX ASM) +set(CMAKE_C_STANDARD 11) +pico_sdk_init() +add_executable(switch2-wake-capture main.c) +target_include_directories(switch2-wake-capture PRIVATE ${CMAKE_CURRENT_LIST_DIR}) +target_link_libraries(switch2-wake-capture PRIVATE + pico_stdlib + pico_cyw43_arch_none + pico_btstack_cyw43 + pico_btstack_ble +) +pico_enable_stdio_usb(switch2-wake-capture 1) +pico_enable_stdio_uart(switch2-wake-capture 0) +pico_set_program_name(switch2-wake-capture "Switch 2 wake capture") +pico_set_program_version(switch2-wake-capture "1.0") +pico_add_extra_outputs(switch2-wake-capture) diff --git a/tools/switch2_wake_capture/btstack_config.h b/tools/switch2_wake_capture/btstack_config.h new file mode 100644 index 0000000..20dec82 --- /dev/null +++ b/tools/switch2_wake_capture/btstack_config.h @@ -0,0 +1,30 @@ +#ifndef SWITCH2_WAKE_CAPTURE_BTSTACK_CONFIG_H +#define SWITCH2_WAKE_CAPTURE_BTSTACK_CONFIG_H + +#define ENABLE_LE_CENTRAL +#define ENABLE_LE_PERIPHERAL +#define ENABLE_LOG_ERROR +#define ENABLE_PRINTF_HEXDUMP +#define HAVE_EMBEDDED_TIME_MS +#define HAVE_ASSERT +#define HCI_OUTGOING_PRE_BUFFER_SIZE 4 +#define HCI_ACL_PAYLOAD_SIZE (1691 + 4) +#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4 +#define MAX_NR_HCI_CONNECTIONS 1 +#define MAX_NR_GATT_CLIENTS 1 +#define MAX_NR_L2CAP_CHANNELS 2 +#define MAX_NR_L2CAP_SERVICES 2 +#define MAX_NR_SM_LOOKUP_ENTRIES 1 +#define MAX_NR_WHITELIST_ENTRIES 1 +#define MAX_NR_LE_DEVICE_DB_ENTRIES 16 +#define NVM_NUM_DEVICE_DB_ENTRIES 16 +#define MAX_ATT_DB_SIZE 128 +#define MAX_NR_CONTROLLER_ACL_BUFFERS 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 +#define HCI_RESET_RESEND_TIMEOUT_MS 1000 + +#endif diff --git a/tools/switch2_wake_capture/main.c b/tools/switch2_wake_capture/main.c new file mode 100644 index 0000000..a75d570 --- /dev/null +++ b/tools/switch2_wake_capture/main.c @@ -0,0 +1,152 @@ +#include +#include + +#include "pico/cyw43_arch.h" +#include "pico/stdio_usb.h" +#include "pico/stdlib.h" +#include "btstack.h" +#include "nintendo_wake.h" + +typedef struct { + bd_addr_t advertiser; + uint8_t address_type; + uint8_t event_type; + int8_t rssi; + uint8_t data[31]; +} wake_capture_t; + +static wake_capture_t capture; +static bool captured; +static bool scan_started; +static bool usb_was_connected; +static uint32_t last_print_ms; +static btstack_packet_callback_registration_t registration; +static btstack_timer_source_t output_timer; + +static void to_hex(const uint8_t* bytes, size_t size, char* output) { + static const char digits[] = "0123456789ABCDEF"; + for (size_t index = 0; index < size; ++index) { + output[index * 2] = digits[bytes[index] >> 4]; + output[index * 2 + 1] = digits[bytes[index] & 0x0f]; + } + output[size * 2] = 0; +} + +static void print_capture(void) { + const uint8_t* manufacturer = &capture.data[5]; + char raw_hex[63]; + char payload_hex[49]; + to_hex(capture.data, sizeof(capture.data), raw_hex); + // ESPHome-style payload excludes the two-byte Nintendo company ID. + to_hex(&manufacturer[2], 24, payload_hex); + printf("SWITCH2_WAKE_CAPTURE {\"version\":1,\"advertiser\":" + "\"%02X:%02X:%02X:%02X:%02X:%02X\",\"address_type\":%u," + "\"event_type\":%u,\"rssi\":%d,\"pid\":\"%04X\"," + "\"console\":\"%02X:%02X:%02X:%02X:%02X:%02X\"," + "\"raw_hex\":\"%s\",\"esphome_payload_hex\":\"%s\"}\n", + capture.advertiser[0], capture.advertiser[1], + capture.advertiser[2], capture.advertiser[3], + capture.advertiser[4], capture.advertiser[5], + capture.address_type, capture.event_type, (int)capture.rssi, + (unsigned)(manufacturer[7] | (manufacturer[8] << 8)), + manufacturer[17], manufacturer[16], manufacturer[15], + manufacturer[14], manufacturer[13], manufacturer[12], + raw_hex, payload_hex); +} + +static void output_task(btstack_timer_source_t* timer) { + const uint32_t now_ms = btstack_run_loop_get_time_ms(); + const bool usb_connected = stdio_usb_connected(); + if (usb_connected && !usb_was_connected) { + puts("Switch 2 wake capture ready. Put the console to sleep, then wake it with a paired Joy-Con 2 HOME button."); + puts("Scanning stops automatically after one valid public ADV_IND wake packet."); + if (captured) { + print_capture(); + last_print_ms = now_ms; + } + } + if (usb_connected) { + int key; + while ((key = getchar_timeout_us(0)) != PICO_ERROR_TIMEOUT) { + if ((key == 'p' || key == 'P') && captured) { + print_capture(); + last_print_ms = now_ms; + } + } + // Repeat for a generator attached after capture; scanning remains stopped. + if (captured && now_ms - last_print_ms >= 1000) { + print_capture(); + last_print_ms = now_ms; + } + } + usb_was_connected = usb_connected; + static bool blink; + blink = !blink; + cyw43_arch_gpio_put( + CYW43_WL_GPIO_LED_PIN, captured || (scan_started && blink)); + btstack_run_loop_set_timer(timer, 250); + btstack_run_loop_add_timer(timer); +} + +static void handle_packet(uint8_t packet_type, uint16_t, + uint8_t* packet, uint16_t size) { + if (packet_type != HCI_EVENT_PACKET || size < 2 || captured) { + return; + } + const uint8_t event_type = hci_event_packet_get_type(packet); + if (event_type == BTSTACK_EVENT_STATE && size >= 3 && + btstack_event_state_get_state(packet) == HCI_STATE_WORKING) { + gap_set_scan_params(0, 96, 96, 0); // Passive, continuous, accept all. + gap_set_scan_duplicate_filter(false); + gap_start_scan(); + scan_started = true; + return; + } + if (event_type != GAP_EVENT_ADVERTISING_REPORT || size < 12) { + return; + } + const uint8_t length = + gap_event_advertising_report_get_data_length(packet); + if (length != sizeof(capture.data) || size < 12u + length || + gap_event_advertising_report_get_address_type(packet) != + BD_ADDR_TYPE_LE_PUBLIC || + gap_event_advertising_report_get_advertising_event_type(packet) != 0) { + return; + } + const uint8_t* data = + gap_event_advertising_report_get_data(packet); + if (switch2_wake_manufacturer(data, length) == NULL) { + return; + } + gap_event_advertising_report_get_address(packet, capture.advertiser); + capture.address_type = BD_ADDR_TYPE_LE_PUBLIC; + capture.event_type = 0; + capture.rssi = + (int8_t)gap_event_advertising_report_get_rssi(packet); + memcpy(capture.data, data, sizeof(capture.data)); + captured = true; + scan_started = false; + gap_stop_scan(); + if (stdio_usb_connected()) { + print_capture(); + last_print_ms = btstack_run_loop_get_time_ms(); + } +} + +int main(void) { + stdio_init_all(); + if (cyw43_arch_init() != 0) { + while (true) { + puts("ERROR: CYW43 initialization failed"); + sleep_ms(1000); + } + } + registration.callback = handle_packet; + hci_add_event_handler(®istration); + btstack_run_loop_set_timer_handler(&output_timer, output_task); + btstack_run_loop_set_timer(&output_timer, 250); + btstack_run_loop_add_timer(&output_timer); + hci_power_control(HCI_POWER_ON); + btstack_run_loop_execute(); + return 0; +} diff --git a/tools/switch2_wake_capture/nintendo_wake.h b/tools/switch2_wake_capture/nintendo_wake.h new file mode 100644 index 0000000..8c8ed91 --- /dev/null +++ b/tools/switch2_wake_capture/nintendo_wake.h @@ -0,0 +1,34 @@ +#ifndef SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H +#define SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H + +#include +#include +#include +#include + +static inline const uint8_t* switch2_wake_manufacturer( + const uint8_t* data, size_t size) { + static const uint8_t prefix[] = { + 0x53, 0x05, 0x01, 0x00, 0x03, 0x7e, 0x05, + }; + static const uint8_t suffix[] = { + 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + if (size != 31 || memcmp(data, "\x02\x01\x06\x1b\xff", 5) != 0) { + return NULL; + } + const uint8_t* manufacturer = &data[5]; + if (memcmp(manufacturer, prefix, sizeof(prefix)) != 0 || + manufacturer[9] != 0x00 || manufacturer[10] != 0x01 || + manufacturer[11] != 0x81 || + memcmp(&manufacturer[18], suffix, sizeof(suffix)) != 0) { + return NULL; + } + uint8_t target_any = 0; + for (size_t index = 12; index < 18; ++index) { + target_any |= manufacturer[index]; + } + return target_any == 0 ? NULL : manufacturer; +} + +#endif