213 lines
7.4 KiB
Python
Executable file
213 lines
7.4 KiB
Python
Executable file
#!/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())
|