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

1
.gitignore vendored
View file

@ -14,3 +14,4 @@ __pycache__
pytest-*/*
build-*/*
.ignore
src/firmware/platform/pico/switch2_wake_config.h

View file

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

View file

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

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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 <limits.h>
@ -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<uint16_t>(
1u << static_cast<uint8_t>(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<uint8_t>(slot_index), device,
pre_hotkey_button_mask)) {
switch2_wake_request();
}
const HotkeyDecision hotkeys = update_controller_hotkeys(
static_cast<uint8_t>(slot_index), device);
const uint16_t output_button_mask = pre_hotkey_button_mask;

View file

@ -0,0 +1,316 @@
#include "input/switch2_wake.h"
#include <string.h>
#include <btstack.h>
#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<int32_t>(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<uint8_t>(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,
};
}

View file

@ -0,0 +1,20 @@
#pragma once
#include <stdint.h>
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);

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)

213
tools/configure_switch2_wake.py Executable file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,152 @@
#include <stdio.h>
#include <string.h>
#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(&registration);
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;
}

View file

@ -0,0 +1,34 @@
#ifndef SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H
#define SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
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