Add Switch 2 wake capture and replay

This commit is contained in:
Joey Yakimowich-Payne 2026-09-04 23:27:02 -06:00
commit 6f58d345e0
22 changed files with 1436 additions and 3 deletions

View file

@ -5,6 +5,7 @@
#include <uni.h>
#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 {

View file

@ -0,0 +1,57 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
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<uint16_t>(event[3]) |
static_cast<uint16_t>(event[4] << 8);
}
inline const uint8_t* hci_event_command_complete_get_return_parameters(
const uint8_t* event) {
return &event[5];
}

249
tests/switch2_wake_test.cpp Normal file
View file

@ -0,0 +1,249 @@
#include <cstdarg>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <btstack.h>
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<uint16_t>(va_arg(arguments, int));
output.interval_max = static_cast<uint16_t>(va_arg(arguments, int));
output.advertising_type = static_cast<uint8_t>(va_arg(arguments, int));
output.own_address_type = static_cast<uint8_t>(va_arg(arguments, int));
(void)va_arg(arguments, int);
(void)va_arg(arguments, const uint8_t*);
output.channel_map = static_cast<uint8_t>(va_arg(arguments, int));
output.filter_policy = static_cast<uint8_t>(va_arg(arguments, int));
} else if (command->opcode == 0x2008) {
output.data_length = static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(address == nullptr ? 4 : 10),
1,
static_cast<uint8_t>(opcode), static_cast<uint8_t>(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<uint16_t>(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;
}

View file

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

View file

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

View file

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