Keep wake identity stable during play
This commit is contained in:
parent
6f58d345e0
commit
1f9d7f6824
9 changed files with 147 additions and 87 deletions
37
README.md
37
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
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue