Keep wake identity stable during play

This commit is contained in:
Joey Yakimowich-Payne 2026-09-04 23:47:00 -06:00
commit 1f9d7f6824
9 changed files with 147 additions and 87 deletions

View file

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

Binary file not shown.

Binary file not shown.

View file

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

View file

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

View file

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

View file

@ -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") {

View file

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

View file

@ -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",
):