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

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