diff --git a/README.md b/README.md index bef78d4..bb7e991 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,11 @@ 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 +advertisement for two seconds at a 20 ms base interval. The configured AIO +firmware adopts the captured Joy-Con's public Bluetooth address once during +startup, before Bluepad32 admits controller connections. Wake bursts then +require no identity change and do not disconnect the input controller. 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) @@ -131,6 +130,10 @@ Then: python3 build.py --aio ``` +6. The Pico now has a new stable Bluetooth host address. Clear its old + Bluepad32 bonds by holding BOOTSEL for ten seconds, then open a pairing + window and pair each input controller again. This is a one-time re-pair. + The capture tool also accepts a saved serial log: @@ -143,24 +146,20 @@ 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. +not disturb the radio. Because the wake identity is stable from startup, the +input controller stays connected through the advertising burst. 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. +Pico-to-Switch data connection. The configured Pico continuously owns the +captured Joy-Con's public Bluetooth address, so keep that Joy-Con inactive +while the AIO firmware is running 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. +disable wake, delete the generated `switch2_wake_config.h`, rebuild the AIO +firmware, clear the Pico's bonds, and pair the input controllers to its restored +factory address. Restore the full-flash backup only if you need to recover the +exact pre-capture firmware and persistent state. ### Pairing up to four controllers diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 9650cbc..4e2e8fc 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 371f7ce..9c769a6 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 535b214..53227bb 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -1076,6 +1076,7 @@ void process_pairing_snapshot_request() { } void apply_connection_policy(); +void recompute_connection_status(); void process_clear_pairings(uint32_t now_ms) { uni_hid_device_t* devices[kSlotCount]{}; @@ -1258,7 +1259,14 @@ void process_rumble_timer(btstack_timer_source_t* timer) { process_clear_pairings(now_ms); process_pairing_snapshot_request(); - if (update_pairing_window(now_ms)) { + const bool wake_identity_ready = + switch2_wake_ready_for_connections(); + if (g_connection_policy_state == + ConnectionPolicyState::Uninitialized && + wake_identity_ready) { + recompute_connection_status(); + } + if (update_pairing_window(now_ms) && wake_identity_ready) { apply_connection_policy(); } const bool xinput_host_mode = @@ -1492,7 +1500,9 @@ void platform_on_init_complete() { &g_configuration_timer, kConfigurationPollIntervalMs); btstack_run_loop_add_timer(&g_configuration_timer); __atomic_store_n(&g_initialization_stage, 6, __ATOMIC_RELEASE); - recompute_connection_status(); + if (switch2_wake_ready_for_connections()) { + recompute_connection_status(); + } } uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, diff --git a/src/firmware/input/switch2_wake.cpp b/src/firmware/input/switch2_wake.cpp index 6090318..5438734 100644 --- a/src/firmware/input/switch2_wake.cpp +++ b/src/firmware/input/switch2_wake.cpp @@ -31,19 +31,18 @@ const bd_addr_t kUnusedPeerAddress{}; enum class Phase : uint8_t { kDisabled, + kSetStableAddress, + kVerifyStableAddress, 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; @@ -54,8 +53,8 @@ uint32_t g_completed_bursts = 0; uint32_t g_failures = 0; bool g_initialized = false; bool g_configured = false; +bool g_identity_ready = false; bool g_timer_armed = false; -bool g_address_changed = false; bool g_advertising = false; bool deadline_reached(uint32_t now, uint32_t deadline) { @@ -129,10 +128,13 @@ bool configured_packet_valid() { void recover_from_failure() { ++g_failures; g_pending_opcode = 0; - if (g_advertising) { + if (g_phase == Phase::kSetStableAddress || + g_phase == Phase::kVerifyStableAddress) { + // Wake stays disabled, but controller input must remain available. + g_identity_ready = true; + g_phase = Phase::kFailed; + } else if (g_advertising) { g_phase = Phase::kDisableAdvertising; - } else if (g_address_changed) { - g_phase = Phase::kRestoreAddress; } else { g_phase = Phase::kIdle; } @@ -150,12 +152,16 @@ void submit_phase_command(uint32_t now_ms) { uint8_t result = ERROR_CODE_SUCCESS; switch (g_phase) { - case Phase::kSetWakeAddress: + case Phase::kSetStableAddress: begin_command(kWritePublicAddress.opcode, now_ms); #if SWITCH2_WAKE_CONFIGURED result = hci_send_cmd(&kWritePublicAddress, kWakeAddress); #endif break; + case Phase::kVerifyStableAddress: + begin_command(hci_read_bd_addr.opcode, now_ms); + result = hci_send_cmd(&hci_read_bd_addr); + break; case Phase::kSetParameters: begin_command(hci_le_set_advertising_parameters.opcode, now_ms); result = hci_send_cmd( @@ -181,10 +187,6 @@ void submit_phase_command(uint32_t now_ms) { &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; } @@ -213,9 +215,27 @@ void handle_command_complete(uint8_t* packet, uint16_t size) { } switch (g_phase) { - case Phase::kSetWakeAddress: - g_address_changed = true; - g_phase = Phase::kSetParameters; + case Phase::kSetStableAddress: + g_phase = Phase::kVerifyStableAddress; + break; + case Phase::kVerifyStableAddress: +#if SWITCH2_WAKE_CONFIGURED + if (size < 12) { + recover_from_failure(); + schedule_for_phase(btstack_run_loop_get_time_ms()); + return; + } + for (size_t index = 0; index < sizeof(kWakeAddress); ++index) { + if (packet[6 + index] != + kWakeAddress[sizeof(kWakeAddress) - 1 - index]) { + recover_from_failure(); + schedule_for_phase(btstack_run_loop_get_time_ms()); + return; + } + } +#endif + g_identity_ready = true; + g_phase = Phase::kIdle; break; case Phase::kSetParameters: g_phase = Phase::kSetData; @@ -231,10 +251,6 @@ void handle_command_complete(uint8_t* packet, uint16_t size) { 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; @@ -277,26 +293,32 @@ void switch2_wake_initialize() { g_initialized = true; #if SWITCH2_WAKE_CONFIGURED if (!configured_packet_valid()) { + g_identity_ready = true; 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; + g_phase = Phase::kSetStableAddress; + schedule_task(0); +#else + g_identity_ready = true; #endif } +bool switch2_wake_ready_for_connections() { + return g_identity_ready; +} bool switch2_wake_request() { if (g_phase != Phase::kIdle) { return false; } ++g_accepted_requests; - g_phase = Phase::kSetWakeAddress; + g_phase = Phase::kSetParameters; schedule_task(0); return true; } diff --git a/src/firmware/input/switch2_wake.h b/src/firmware/input/switch2_wake.h index 318136b..2f32020 100644 --- a/src/firmware/input/switch2_wake.h +++ b/src/firmware/input/switch2_wake.h @@ -10,9 +10,10 @@ struct Switch2WakeDiagnostics { uint32_t failures; }; -// Installs BTstack callbacks and remembers the Pico's normal public identity. +// Installs BTstack callbacks and applies the configured stable public identity. +// Call once from the BTstack core before admitting controller connections. void switch2_wake_initialize(); - +bool switch2_wake_ready_for_connections(); // Starts one wake burst when configured and idle. Calls while busy coalesce. bool switch2_wake_request(); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index bb7f640..4d050f7 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -47,6 +47,7 @@ int cyw43_init_calls = 0; int uni_init_calls = 0; int switch2_wake_initializations = 0; int switch2_wake_requests = 0; +bool switch2_connections_ready = true; int device_disconnect_calls = 0; uni_hid_device_t* last_disconnected_device = nullptr; uni_hid_device_t* lookup_devices[8]{}; @@ -448,6 +449,10 @@ uint32_t btstack_run_loop_get_time_ms() { void switch2_wake_initialize() { ++switch2_wake_initializations; } +bool switch2_wake_ready_for_connections() { + return switch2_connections_ready; +} + bool switch2_wake_request() { @@ -2392,6 +2397,26 @@ void test_flash_core_start_contract() { +void test_wake_identity_gates_connections() { + switch2_connections_ready = false; + bluepad32_input_backend_init(); + platform_on_init_complete(); + require(switch2_wake_initializations == 1 && + g_connection_policy_state == + ConnectionPolicyState::Uninitialized && + scan_starts == 0 && classic_scan_starts == 0 && + !incoming_connections, + "controller discovery started before wake identity was ready"); + + switch2_connections_ready = true; + process_rumble_timer(&g_rumble_timer); + require(g_connection_policy_state == ConnectionPolicyState::Open && + scan_starts == 1 && classic_scan_starts == 1 && + incoming_connections, + "controller discovery did not start after wake identity setup"); +} + + void test_system_button_wake_trigger() { start_pairing_backend(); uni_hid_device_t controller = device(0); @@ -2486,6 +2511,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 == "wake-identity-gate") { + test_wake_identity_gates_connections(); } else if (scenario == "system-wake") { test_system_button_wake_trigger(); } else if (scenario == "flash-core-failure") { diff --git a/tests/switch2_wake_test.cpp b/tests/switch2_wake_test.cpp index ecff807..22ddb0e 100644 --- a/tests/switch2_wake_test.cpp +++ b/tests/switch2_wake_test.cpp @@ -153,97 +153,97 @@ void require_opcode(size_t index, uint16_t opcode) { "unexpected HCI command sequence"); } -void test_temporary_identity_wake_and_restore() { +void test_stable_identity_wake() { 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"); + require(diagnostics.configured && diagnostics.busy && + !switch2_wake_ready_for_connections() && + !switch2_wake_request(), + "controller connections or wake escaped startup identity setup"); 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"); + "stable 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, + require_opcode(1, 0x1009); + complete(0x1009, 0, wake_address); + require(switch2_wake_ready_for_connections() && !g_timer_armed, + "verified stable identity did not admit connections and go idle"); + + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.busy && switch2_wake_request() && + !switch2_wake_request(), + "wake requests were not bounded while busy"); + run_task(); + require_opcode(2, 0x2006); + require(submitted[2].interval_min == 0x20 && + submitted[2].interval_max == 0x20 && + submitted[2].advertising_type == 3 && + submitted[2].own_address_type == 0 && + submitted[2].channel_map == 7 && + submitted[2].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, + require_opcode(3, 0x2008); + require(submitted[3].data_length == 31 && + submitted[3].data[16] == 0x81, "captured wake payload was not submitted intact"); complete(0x2008); run_task(); - require_opcode(3, 0x200a); - require(submitted[3].enabled == 1, + require_opcode(4, 0x200a); + require(submitted[4].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, + require(submitted_count == 5, "wake burst stopped before two seconds"); now_ms = 2000; run_task(); - require_opcode(4, 0x200a); - require(submitted[4].enabled == 0, + require_opcode(5, 0x200a); + require(submitted[5].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"); + "completed wake did not return to a dormant idle state"); + require(submitted_count == 6, + "wake burst changed the public identity after startup"); } -void test_failed_setup_restores_gameplay_identity() { +void test_failed_wake_keeps_stable_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); + require_opcode(6, 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); + require(submitted_count == 7, + "wake setup failure issued an address reset"); Switch2WakeDiagnostics diagnostics{}; switch2_wake_diagnostics(&diagnostics); require(!diagnostics.busy && diagnostics.failures == 1 && - !g_timer_armed, - "wake failure did not recover to dormant gameplay"); + switch2_wake_ready_for_connections(), + "wake failure disrupted the stable controller identity"); } } // namespace int main() { - test_temporary_identity_wake_and_restore(); - test_failed_setup_restores_gameplay_identity(); + test_stable_identity_wake(); + test_failed_wake_keeps_stable_identity(); return 0; } diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index d424a54..6b0d75d 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", + "wake-identity-gate", "system-wake", "flash-core-failure", ):