feat(wake): add controller-free USB serial wake beacon

Add a standalone Pico2W image reusing the configured nonconnectable wake
burst without Bluepad32, controller-host profiles, Classic/LE-central
roles, connection pools, pairing or writable TLV storage. Explicitly
disable and verify Classic inquiry/page scan before declaring readiness.
Keep the original controller firmware and captured wake identity unchanged.

Expose bounded SPWB1 CDC commands with exactly-once request IDs, explicit
failures and radio progress independent of serial backpressure. Add the
cross-platform switch-pico-wake script with strict preflight, bounded I/O,
no automatic rebroadcast, status-only mode and preserved uncertain-outcome
metadata. Standard USB serial requires no WinUSB/Zadig binding.

Add isolated --wake-only build/publication selection and document setup,
backup and physical-BOOTSEL recovery. Validate776 tests, focused framing
and lifecycle cases, ELF isolation and actual CDC operation on only the
new board. One burst completed in2.095s; malformed/status/reconnect input
and same-ID replay caused no additional broadcast. Original Pico untouched.
Private firmware images, console configuration and backups are excluded.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-19 22:03:17 -06:00
commit bed71f77f1
14 changed files with 2042 additions and 3 deletions

View file

@ -7,6 +7,7 @@ Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over
- **Python bridge** (`switch_pico_bridge.controller_uart_bridge` / CLI `controller-uart-bridge`): reads SDL3 controllers on the host, sends reports over UART, and applies rumble locally. Hot‑plug friendly and cross‑platform (macOS/Windows/Linux).
- **Color configuration** (`src/firmware/platform/pico/controller_color_config.h`): compile-time RGB colors for emulated controller grips and supported Bluetooth controller LEDs.
- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts four concurrent Bluetooth controllers and sends their controls, calibrated motion, rumble, and slot identity through four separate Switch Pro USB interfaces without a computer.
- **Wake-only beacon** (`tools/switch2_wake_beacon/`): a dedicated USB-serial Pico 2 W appliance that sends the configured Switch 2 BLE wake burst without hosting, discovering or pairing controllers.
## Source layout
@ -204,7 +205,62 @@ before another attempt. Plain Home, PS, or Xbox is forwarded normally and does
not disturb the radio. Because the wake identity is stable from startup,
the wake code itself does not disconnect the input controller.
**Wake from Python over USB:** firmware with the USB wake command (native
**Dedicated wake-only Pico:** use this role for a second Pico that should stay
on the PC without participating in the controller adapter's Bluetooth links.
It reuses the same private wake capture/address, but has no Bluepad32,
controller-host, Classic or LE-central stack, no connection pool, and no
pairing/profile/TLV storage. Startup explicitly disables and reads back Classic
inquiry/page scanning. The only advertising is the existing two-second
non-connectable burst after an explicit valid request; boot, status queries,
malformed input and USB reconnects do not trigger it. The original controller
Pico and its firmware remain unchanged.
The beacon enumerates as **USB CDC serial, `CAFE:4030`**, product
`switch-pico wake beacon`, with the board's unique serial number. It has no HID
controller interfaces. Windows uses its standard USB serial/COM driver—do not
install a WinUSB/Zadig driver for this role. Linux needs normal permission to
open its `/dev/ttyACM*` port. Physical Windows execution has not been tested on
the Linux development machine; CDC descriptors and Windows-style serial
framing are covered by the implementation and tests.
```sh
# Automatically find the single wake-only beacon and request one burst:
uv run switch-pico-wake
# Same script as a Python module:
uv run python -m switch_pico_bridge.wake_beacon
# Explicit Windows port, useful with multiple beacons:
uv run switch-pico-wake --port COM5 --json
# Read-only inspection; never broadcasts:
uv run switch-pico-wake --status --json
```
Python code can use `wake_beacon.request_wake(port=None, timeout=15)` or
`wake_beacon.read_status(...)` from `switch_pico_bridge`; both open and close
the port. Each wake invocation sends one fresh request and polls its retained
outcome without rebroadcasting after a timeout or disconnect. Completion
confirms the firmware's advertising sequence, not the console's power state.
This serial client is separate from `switch-pico-config wake`, which targets
the full controller firmware below.
Build separately, with the Pico SDK/toolchain available and the existing
ignored `src/firmware/platform/pico/switch2_wake_config.h` present:
```sh
cmake -S tools/switch2_wake_beacon -B build-wake-only \
-DPICO_BOARD=pico2_w -DCMAKE_BUILD_TYPE=Release
cmake --build build-wake-only --parallel 4
```
Before replacing controller firmware, record/export any settings/profiles you
need and make a verified full-flash backup in BOOTSEL. Then load
`build-wake-only/switch2-wake-beacon.uf2` with `picotool load -v -x`.
Alternatively, `uv run python build.py --wake-only` explicitly builds, publishes
`firmware/switch-pico-wake-only.{elf,uf2}`, and flashes. The beacon has no
software/baud-rate BOOTSEL shortcut: hold BOOTSEL while reconnecting it for
future firmware changes. It does not expose configuration/profile management
or write their flash region.
**Full controller firmware: wake from Python over USB:** firmware with the USB wake command (native
0.109 and rebuilt AIO images) can send the same burst while the Pico stays
plugged into the PC. The wake signal goes over BLE; the Pico does not need a
USB connection to the Switch or a connected controller. The console-specific

View file

@ -17,6 +17,8 @@ BUILD_DIR = SCRIPT_DIR / "build"
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
WAKE_CAPTURE_SOURCE_DIR = SCRIPT_DIR / "tools" / "switch2_wake_capture"
WAKE_CAPTURE_BUILD_DIR = SCRIPT_DIR / "build-wake-capture"
WAKE_ONLY_SOURCE_DIR = SCRIPT_DIR / "tools" / "switch2_wake_beacon"
WAKE_ONLY_BUILD_DIR = SCRIPT_DIR / "build-wake-only"
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
@ -24,6 +26,8 @@ AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf"
AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.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"
WAKE_ONLY_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-wake-only.elf"
WAKE_ONLY_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-wake-only.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()
@ -40,6 +44,7 @@ CMAKE_CACHE_PATHS = tuple(
BUILD_DIR,
AIO_BUILD_DIR,
WAKE_CAPTURE_BUILD_DIR,
WAKE_ONLY_BUILD_DIR,
*(
AIO_BUILD_DIR.with_name(f"{AIO_BUILD_DIR.name}-{mode}")
for mode in ("ble", "classic")
@ -282,10 +287,15 @@ def parse_args():
action="store_true",
help="Build and flash the automatic Switch 2 wake capture firmware.",
)
mode_group.add_argument(
"--wake-only",
action="store_true",
help="Build and flash the standalone USB serial Switch 2 wake beacon.",
)
parser.add_argument(
"--bluetooth-mode",
choices=("mixed", "ble", "classic"),
default="mixed",
default=None,
help="Select active Bluetooth transports for --aio.",
)
group = parser.add_mutually_exclusive_group()
@ -302,6 +312,13 @@ def 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")
if args.wake_only:
if args.random_grip_color or args.grip_color is not None:
parser.error("wake-only firmware does not use grip-color options")
if args.bluetooth_mode is not None:
parser.error("wake-only firmware does not use --bluetooth-mode")
if args.bluetooth_mode is None:
args.bluetooth_mode = "mixed"
if args.bluetooth_mode != "mixed" and not args.aio:
parser.error("--bluetooth-mode requires --aio")
return args
@ -476,6 +493,35 @@ def build_wake_capture():
return elf_path
def build_wake_only():
elf_path = WAKE_ONLY_BUILD_DIR / "switch2-wake-beacon.elf"
uf2_path = WAKE_ONLY_BUILD_DIR / "switch2-wake-beacon.uf2"
run_cmd(
[
"cmake",
"-S",
str(WAKE_ONLY_SOURCE_DIR),
"-B",
str(WAKE_ONLY_BUILD_DIR),
"-DPICO_BOARD=pico2_w",
]
)
run_cmd(["cmake", "--build", str(WAKE_ONLY_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-only build did not produce: {missing}\n")
sys.exit(1)
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(elf_path, WAKE_ONLY_FIRMWARE_ELF_PATH)
shutil.copy2(uf2_path, WAKE_ONLY_FIRMWARE_UF2_PATH)
print(f"Built wake-only ELF: {elf_path}")
print(f"Built wake-only UF2: {uf2_path}")
print(f"Copied ELF: {WAKE_ONLY_FIRMWARE_ELF_PATH}")
print(f"Copied UF2: {WAKE_ONLY_FIRMWARE_UF2_PATH}")
return elf_path
def flash(elf_path, allow_elf_override):
picotool = resolve_picotool()
if not elf_path.exists():
@ -500,6 +546,10 @@ def main():
wake_capture_elf = build_wake_capture()
flash(wake_capture_elf, allow_elf_override=False)
return
if args.wake_only:
wake_only_elf = build_wake_only()
flash(wake_only_elf, allow_elf_override=False)
return
color = None

View file

@ -22,6 +22,7 @@ dependencies = [
controller-uart-bridge = "switch_pico_bridge.controller_uart_bridge:main"
host-uart-logger = "switch_pico_bridge.host_uart_logger:main"
switch-pico-config = "switch_pico_bridge.config_manager:main"
switch-pico-wake = "switch_pico_bridge.wake_beacon:main"
[tool.setuptools]
package-dir = {"" = "src"}

View file

@ -0,0 +1,422 @@
"""USB CDC client for the standalone wake-only beacon (no controller transport).
A completed request confirms the beacon finished its advertising burst, not RF
reception or the console's power state. An interrupted request is never retried.
"""
from __future__ import annotations
import argparse
import json
import math
import secrets
import sys
import time
from collections.abc import Sequence
from dataclasses import asdict, dataclass, fields
import serial
from serial.tools import list_ports
USB_VID = 0xCAFE
USB_PID = 0x4030
MAX_REQUEST_ID = 0x7FFFFFFF
MAX_RESPONSE_BYTES = 1024
_STATES = {
"idle",
"queued",
"broadcasting",
"complete",
"unconfigured",
"busy",
"failed",
}
_ERRORS = {"", "malformed", "busy", "radio_init_failed"}
@dataclass(frozen=True)
class WakeBeaconStatus:
protocol: int
role: str
firmware: str
radio_ready: bool
controller_hosting: bool
request_id: int
state: str
configured: bool
busy: bool
accepted_requests: int
completed_bursts: int
failures: int
error: str
def to_dict(self) -> dict:
return asdict(self)
class WakeBeaconError(Exception):
"""Failure with the last validated status and whether WAKE may have been sent."""
code = "beacon_error"
def __init__(self, message: str, status: WakeBeaconStatus | None = None):
super().__init__(message)
self.status = status
self.request_id = None
self.wake_sent = False
class ProtocolError(WakeBeaconError):
code = "protocol_error"
class TransportError(WakeBeaconError):
code = "transport_error"
class BeaconTimeout(WakeBeaconError):
code = "timeout"
class CommandRejected(WakeBeaconError):
code = "command_rejected"
class OperationFailed(WakeBeaconError):
code = "operation_failed"
class RequestLost(WakeBeaconError):
code = "request_lost"
def _unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ProtocolError("Duplicate status field")
result[key] = value
return result
def decode_status(line: bytes) -> WakeBeaconStatus:
"""Strictly decode one complete SPWB1 response, including its newline."""
if len(line) > MAX_RESPONSE_BYTES or not line.endswith(b"\n"):
raise ProtocolError("Missing newline or oversized beacon response")
body = line[:-1]
if body.endswith(b"\r"):
body = body[:-1]
if not body.startswith(b"SPWB1 ") or any(c < 32 or c > 126 for c in body):
raise ProtocolError("Expected an ASCII SPWB1 response")
try:
value = json.loads(body[6:].decode("ascii"), object_pairs_hook=_unique_object)
except (ValueError, RecursionError) as exc:
raise ProtocolError("Invalid beacon JSON") from exc
if type(value) is not dict or set(value) != {
field.name for field in fields(WakeBeaconStatus)
}:
raise ProtocolError("Unexpected beacon status fields")
for key in ("radio_ready", "controller_hosting", "configured", "busy"):
if type(value[key]) is not bool:
raise ProtocolError(f"Invalid boolean field: {key}")
for key in (
"protocol",
"request_id",
"accepted_requests",
"completed_bursts",
"failures",
):
limit = MAX_REQUEST_ID if key == "request_id" else 0xFFFFFFFF
if type(value[key]) is not int or not 0 <= value[key] <= limit:
raise ProtocolError(f"Invalid integer field: {key}")
if (
value["protocol"] != 1
or value["role"] != "wake-only"
or value["controller_hosting"]
):
raise ProtocolError("Device is not a supported wake-only beacon")
if (
type(value["firmware"]) is not str
or not value["firmware"].strip()
or len(value["firmware"]) > 64
):
raise ProtocolError("Invalid firmware identifier")
if type(value["state"]) is not str or value["state"] not in _STATES:
raise ProtocolError("Unknown beacon state")
if type(value["error"]) is not str or value["error"] not in _ERRORS:
raise ProtocolError("Unknown beacon command error")
return WakeBeaconStatus(**value)
def discover_beacon_port(port: str | None = None) -> str:
"""Use an explicit path or require exactly one CAFE:4030 serial interface."""
if port is not None:
if not port.strip():
raise TransportError("Serial port must not be empty")
return port
try:
matches = sorted(
{
p.device
for p in list_ports.comports()
if p.vid == USB_VID and p.pid == USB_PID and p.device
}
)
except (OSError, serial.SerialException) as exc:
raise TransportError(f"Cannot enumerate serial ports: {exc}") from exc
if not matches:
raise TransportError(
"No CAFE:4030 wake beacon found; specify --port if necessary"
)
if len(matches) != 1:
raise TransportError(
"Multiple wake beacons found; select --port: " + ", ".join(matches)
)
return matches[0]
def _positive_timeout(value) -> float:
try:
timeout = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError("timeout must be finite and positive") from exc
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be finite and positive")
return timeout
class WakeBeaconClient:
"""Own a pyserial-compatible transport; use as a context manager to close it.
Calls are synchronous and must not run concurrently. Each public operation
has one deadline covering all serial reads, writes and status polls.
"""
def __init__(self, transport, timeout: float = 15.0):
self.timeout = _positive_timeout(timeout)
self.transport = transport
self._last_operation = (None, None, False)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
try:
self.close()
except TransportError:
if exc_type is None:
raise
def close(self) -> None:
try:
self.transport.close()
except (OSError, serial.SerialException) as exc:
error = TransportError(f"Cannot close beacon serial port: {exc}")
error.status, error.request_id, error.wake_sent = self._last_operation
raise error from exc
@staticmethod
def _remaining(deadline: float) -> float:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise BeaconTimeout("Beacon deadline expired; no wake retry was attempted")
return remaining
def _exchange(self, command: bytes, deadline: float) -> WakeBeaconStatus:
try:
self.transport.write_timeout = min(0.25, self._remaining(deadline))
# A partial write may already have reached the device. Never resend.
if self.transport.write(command) != len(command):
raise TransportError("Incomplete serial write; command was not retried")
response = bytearray()
while True:
self.transport.timeout = min(0.1, self._remaining(deadline))
chunk = self.transport.read(1)
if not chunk:
continue
response.extend(chunk)
if len(response) > MAX_RESPONSE_BYTES:
raise ProtocolError("Oversized beacon response")
if chunk.endswith(b"\n"):
self._remaining(deadline)
status = decode_status(bytes(response))
if status.error:
raise CommandRejected(status.error, status)
return status
except (OSError, serial.SerialException) as exc:
raise TransportError(
f"Beacon serial I/O failed: {exc}; no wake retry was attempted"
) from exc
def read_status(self) -> WakeBeaconStatus:
"""Read retained state without actuating, including failed operations."""
status = None
try:
status = self._exchange(b"SPWB1 STATUS\n", time.monotonic() + self.timeout)
return status
except WakeBeaconError as exc:
status = exc.status
raise
finally:
self._last_operation = (status, None, False)
def request_wake(self) -> WakeBeaconStatus:
"""Preflight, send one fresh WAKE, and await this request's completed burst.
Disconnects, resets, command rejection and uncertain outcomes raise a
WakeBeaconError. None causes reconnection or rebroadcast.
"""
deadline = time.monotonic() + self.timeout
status = None
request_id = None
wake_sent = False
try:
while True:
status = self._exchange(b"SPWB1 STATUS\n", deadline)
if status.state in {"queued", "broadcasting", "busy"}:
raise CommandRejected("busy", status)
if status.state == "unconfigured":
raise OperationFailed("Beacon is unconfigured", status)
if not status.radio_ready:
if status.state == "failed" or status.failures:
raise OperationFailed(
"Beacon radio initialization failed", status
)
# Configuration is not inspected until radio startup finishes.
time.sleep(min(0.1, self._remaining(deadline)))
continue
if status.busy:
raise CommandRejected("busy", status)
if not status.configured:
raise OperationFailed("Beacon is unconfigured", status)
break
# Exclude the retained ID without an unbounded random retry loop.
request_id = secrets.randbelow(MAX_REQUEST_ID - 1) + 1
if request_id >= status.request_id > 0:
request_id += 1
previous = status
wake_sent = True # Even a failed write may have reached the device.
status = self._exchange(
f"SPWB1 WAKE {request_id}\n".encode("ascii"), deadline
)
while True:
if status.request_id != request_id:
raise RequestLost(
"Beacon request changed or device reset; completion is unknown",
status,
)
# Cleanup can increment both counters: failure always takes priority.
if status.failures != previous.failures or status.state in {
"failed",
"unconfigured",
}:
raise OperationFailed("Beacon advertising burst failed", status)
if status.state == "complete":
return status
if status.state not in {"queued", "broadcasting"}:
raise RequestLost(
"Beacon no longer reports this request as active", status
)
previous = status
time.sleep(min(0.1, self._remaining(deadline)))
status = self._exchange(b"SPWB1 STATUS\n", deadline)
except WakeBeaconError as exc:
if exc.status is None:
exc.status = status
else:
status = exc.status
exc.request_id = request_id
exc.wake_sent = wake_sent
raise
finally:
self._last_operation = (status, request_id, wake_sent)
def _open_client(port: str | None, timeout: float) -> WakeBeaconClient:
timeout = _positive_timeout(timeout)
selected = discover_beacon_port(port)
try:
transport = serial.Serial(
port=selected,
baudrate=115200,
timeout=min(timeout, 0.1),
write_timeout=min(timeout, 0.25),
xonxoff=False,
rtscts=False,
dsrdtr=False,
)
except (OSError, ValueError, serial.SerialException) as exc:
raise TransportError(f"Cannot open beacon port {selected}: {exc}") from exc
return WakeBeaconClient(transport, timeout)
def read_status(port: str | None = None, timeout: float = 15.0) -> WakeBeaconStatus:
"""Open, read status without waking, and close the selected beacon."""
with _open_client(port, timeout) as client:
return client.read_status()
def request_wake(port: str | None = None, timeout: float = 15.0) -> WakeBeaconStatus:
"""Open, request exactly one completed advertising burst, and close."""
with _open_client(port, timeout) as client:
return client.request_wake()
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Request a wake-only beacon advertising burst over USB CDC."
)
parser.add_argument(
"--port", help="Serial port (COM5 or /dev/ttyACM0); otherwise unique CAFE:4030"
)
parser.add_argument(
"--status", action="store_true", help="Read status only; never send WAKE"
)
parser.add_argument(
"--timeout",
type=_positive_timeout,
default=15.0,
help="Finite positive deadline in seconds (default: 15)",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit one JSON result, without human-readable stdout",
)
args = parser.parse_args(argv)
action = "status" if args.status else "wake"
try:
status = (read_status if args.status else request_wake)(args.port, args.timeout)
except WakeBeaconError as exc:
result = {
"ok": False,
"action": action,
"error": exc.code,
"message": str(exc),
"request_id": exc.request_id,
"wake_sent": exc.wake_sent,
"status": exc.status.to_dict() if exc.status else None,
}
if args.json:
print(json.dumps(result, separators=(",", ":")))
else:
print(f"Wake beacon: {exc}", file=sys.stderr)
return 1
if args.json:
print(
json.dumps(
{"ok": True, "action": action, "status": status.to_dict()},
separators=(",", ":"),
)
)
elif args.status:
print(json.dumps(status.to_dict(), sort_keys=True))
else:
print(
f"Advertising burst complete (request {status.request_id}); console power state is not confirmed."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -209,14 +209,29 @@ def build_cli(tmp_path, monkeypatch):
"CONFIG_FILE",
"BUILD_DIR",
"AIO_BUILD_DIR",
"WAKE_CAPTURE_SOURCE_DIR",
"WAKE_CAPTURE_BUILD_DIR",
"WAKE_ONLY_SOURCE_DIR",
"WAKE_ONLY_BUILD_DIR",
"FIRMWARE_DIR",
"FIRMWARE_ELF_PATH",
"FIRMWARE_UF2_PATH",
"AIO_FIRMWARE_ELF_PATH",
"AIO_FIRMWARE_UF2_PATH",
"WAKE_CAPTURE_FIRMWARE_ELF_PATH",
"WAKE_CAPTURE_FIRMWARE_UF2_PATH",
"WAKE_ONLY_FIRMWARE_ELF_PATH",
"WAKE_ONLY_FIRMWARE_UF2_PATH",
):
original = getattr(build_script, name)
monkeypatch.setattr(build_script, name, tmp_path / original.relative_to(ROOT))
monkeypatch.setattr(
build_script,
"CMAKE_CACHE_PATHS",
tuple(
tmp_path / path.relative_to(ROOT) for path in build_script.CMAKE_CACHE_PATHS
),
)
monkeypatch.setattr(
build_script, "ELF_PATH", tmp_path / "build" / "switch-pico.elf"
)
@ -235,8 +250,12 @@ def build_cli(tmp_path, monkeypatch):
if command[:2] == ["cmake", "--build"]:
build_dir = Path(command[2])
build_dir.mkdir(parents=True, exist_ok=True)
stem = {
"build-wake-capture": "switch2-wake-capture",
"build-wake-only": "switch2-wake-beacon",
}.get(build_dir.name, "switch-pico")
for extension in ("elf", "uf2"):
(build_dir / f"switch-pico.{extension}").write_bytes(
(build_dir / f"{stem}.{extension}").write_bytes(
f"{build_dir.name}:{extension}".encode()
)
@ -375,3 +394,143 @@ def test_ble_build_preserves_grip_color_options(
assert (tmp_path / "firmware" / "switch-pico-aio-ble.uf2").read_bytes() == (
b"build-aio-ble:uf2"
)
def test_wake_only_build_publishes_and_flashes_only_beacon_artifacts(
tmp_path, monkeypatch, build_cli
):
firmware = tmp_path / "firmware"
firmware.mkdir()
unchanged = {}
for stem in ("switch-pico", "switch-pico-aio", "switch-pico-wake-capture"):
for extension in ("elf", "uf2"):
path = firmware / f"{stem}.{extension}"
path.write_bytes(f"existing {path.name}".encode())
unchanged[path] = path.read_bytes()
config = build_script.CONFIG_FILE
config.parent.mkdir(parents=True)
config.write_bytes(b"controller colors must remain untouched")
unchanged[config] = config.read_bytes()
override = tmp_path / "custom.elf"
override.write_bytes(b"not the wake beacon")
monkeypatch.setattr(build_script, "ELF_PATH", override)
monkeypatch.setattr(build_script, "UF2_PATH", tmp_path / "custom.uf2")
monkeypatch.setattr(build_script.sys, "argv", ["build.py", "--wake-only"])
build_script.main()
build_dir = tmp_path / "build-wake-only"
assert build_cli == [
[
"cmake",
"-S",
str(tmp_path / "tools" / "switch2_wake_beacon"),
"-B",
str(build_dir),
"-DPICO_BOARD=pico2_w",
],
["cmake", "--build", str(build_dir)],
["picotool", "load", str(build_dir / "switch2-wake-beacon.elf"), "-fx"],
]
for extension in ("elf", "uf2"):
assert (firmware / f"switch-pico-wake-only.{extension}").read_bytes() == (
f"build-wake-only:{extension}".encode()
)
for path, expected in unchanged.items():
assert path.read_bytes() == expected
def test_wake_only_build_discovers_dependencies_from_its_cache(
tmp_path, monkeypatch, build_cli
):
monkeypatch.delenv("PICO_SDK_PATH")
monkeypatch.delenv("PICO_TOOLCHAIN_PATH")
monkeypatch.setattr(build_script.shutil, "which", no_compiler)
monkeypatch.setattr(build_script, "_sdk_fallback_candidates", lambda: ())
monkeypatch.setattr(build_script, "_toolchain_fallback_candidates", lambda: ())
cache = tmp_path / "build-wake-only" / "CMakeCache.txt"
cache.parent.mkdir()
cache.write_text(
f"PICO_SDK_PATH:PATH={tmp_path / 'sdk'}\n"
f"PICO_TOOLCHAIN_PATH:PATH={tmp_path / 'toolchain'}\n",
encoding="utf-8",
)
commands = build_script.run_cmd
def run_with_dependencies(command):
assert build_script.os.environ["PICO_SDK_PATH"] == str(tmp_path / "sdk")
assert build_script.os.environ["PICO_TOOLCHAIN_PATH"] == str(
tmp_path / "toolchain"
)
commands(command)
monkeypatch.setattr(build_script, "run_cmd", run_with_dependencies)
monkeypatch.setattr(build_script.sys, "argv", ["build.py", "--wake-only"])
build_script.main()
assert (tmp_path / "firmware" / "switch-pico-wake-only.uf2").read_bytes() == (
b"build-wake-only:uf2"
)
@pytest.mark.parametrize("missing_extension", ["elf", "uf2"])
def test_wake_only_missing_artifact_does_not_publish_or_flash(
tmp_path, monkeypatch, build_cli, missing_extension
):
build_dir = tmp_path / "build-wake-only"
build_dir.mkdir()
for extension in ("elf", "uf2"):
(build_dir / f"switch-pico.{extension}").write_bytes(b"wrong target")
if extension != missing_extension:
(build_dir / f"switch2-wake-beacon.{extension}").write_bytes(b"beacon")
firmware = tmp_path / "firmware"
firmware.mkdir()
for extension in ("elf", "uf2"):
(firmware / f"switch-pico-wake-only.{extension}").write_bytes(b"old beacon")
monkeypatch.setattr(build_script, "run_cmd", build_cli.append)
monkeypatch.setattr(build_script.sys, "argv", ["build.py", "--wake-only"])
with pytest.raises(SystemExit) as error:
build_script.main()
assert error.value.code == 1
assert [command[0] for command in build_cli] == ["cmake", "cmake"]
for extension in ("elf", "uf2"):
assert (firmware / f"switch-pico-wake-only.{extension}").read_bytes() == (
b"old beacon"
)
@pytest.mark.parametrize(
"conflict",
[
["--aio"],
["--wake-capture"],
["--grip-color", "A1B2C3"],
["--grip-color", ""],
["--random-grip-color"],
["--bluetooth-mode", "mixed"],
["--bluetooth-mode", "ble"],
["--bluetooth-mode", "classic"],
["--input-backend", "BLUEPAD32"],
["--hd-rumble"],
["--native"],
],
)
def test_wake_only_conflicts_fail_before_dependencies_or_mutations(
monkeypatch, conflict
):
monkeypatch.setattr(
build_script.sys, "argv", ["build.py", "--wake-only", *conflict]
)
monkeypatch.setattr(
build_script,
"configure_pico_environment",
lambda: pytest.fail("invalid wake-only selection reached build setup"),
)
with pytest.raises(SystemExit) as error:
build_script.main()
assert error.value.code == 2

432
tests/test_wake_beacon.py Normal file
View file

@ -0,0 +1,432 @@
"""Exercise host safety against a stateful, deterministic serial beacon."""
import json
from types import SimpleNamespace
import pytest
import serial
from switch_pico_bridge import wake_beacon as wake
class Clock:
def __init__(self):
self.now = 0.0
def monotonic(self):
return self.now
def sleep(self, seconds):
self.now += seconds
class SimulatedBeacon:
"""Serial endpoint whose burst advances on time, not on echoed requests."""
def __init__(self, clock):
self.clock = clock
self.timeout = 0.1
self.write_timeout = 0.25
self.closed = False
self.fail_close = False
self.input = bytearray()
self.output = bytearray()
self.commands = []
self.bursts = 0
self.started = None
self.disconnect_on_wake = False
self.silent = False
self.partial_write = False
self.finish = "complete"
self.preflight_change = None
self.race_busy = False
self.ready_at = None
self.status = {
"protocol": 1,
"role": "wake-only",
"firmware": "1.0.0",
"radio_ready": True,
"controller_hosting": False,
"request_id": 0,
"state": "idle",
"configured": True,
"busy": False,
"accepted_requests": 0,
"completed_bursts": 0,
"failures": 0,
"error": "",
}
def _advance(self):
if self.ready_at is not None and self.clock.now >= self.ready_at:
self.status.update(radio_ready=True, configured=True, busy=False)
self.ready_at = None
if self.started is None:
return
elapsed = self.clock.now - self.started
if elapsed < 0.05:
return
if elapsed < 0.3 or self.finish == "never":
self.status["state"] = "broadcasting"
return
self.started = None
if self.finish == "reset":
self.status.update(
request_id=0,
state="idle",
busy=False,
accepted_requests=0,
completed_bursts=0,
failures=0,
)
elif self.finish == "replaced":
self.status.update(
request_id=self.status["request_id"] + 1,
state="complete",
busy=False,
completed_bursts=1,
)
else:
self.status.update(state="complete", busy=False, completed_bursts=1)
if self.finish == "cleanup_failure":
# A stop-cleanup failure can also increment completed_bursts.
self.status.update(failures=1, state="failed")
elif self.finish == "counter_failure":
self.status["failures"] = 1
def write(self, data):
if self.closed:
raise serial.SerialException("disconnected")
if self.partial_write:
self.input.extend(data[:3])
return 3
self.input.extend(data)
while b"\n" in self.input:
command, _, rest = self.input.partition(b"\n")
self.input = bytearray(rest)
self.commands.append(bytes(command))
self._advance()
response = dict(self.status)
if command.startswith(b"SPWB1 WAKE "):
request_id = int(command.split()[-1])
if self.race_busy:
self.status.update(request_id=77, state="broadcasting", busy=True)
response = dict(self.status, error="busy")
else:
self.status.update(
request_id=request_id,
state="queued",
busy=True,
accepted_requests=self.status["accepted_requests"] + 1,
)
self.started = self.clock.now
self.bursts += 1
response = dict(self.status)
if self.disconnect_on_wake:
self.closed = True
raise serial.SerialException(
"USB disconnected after command delivery"
)
elif command != b"SPWB1 STATUS":
response["error"] = "malformed"
elif self.preflight_change:
response.update(self.preflight_change)
if not self.silent:
self.output.extend(
b"SPWB1 " + json.dumps(response).encode("ascii") + b"\r\n"
)
return len(data)
def read(self, size):
if self.closed:
raise serial.SerialException("USB disconnected")
if self.output:
# Byte-at-a-time delivery proves no reliance on complete read packets.
result = bytes(self.output[:1])
del self.output[:1]
return result
self.clock.sleep(self.timeout)
return b""
def close(self):
self.closed = True
if self.fail_close:
raise serial.SerialException("USB disconnected during close")
@pytest.fixture
def beacon(monkeypatch):
clock = Clock()
monkeypatch.setattr(wake.time, "monotonic", clock.monotonic)
monkeypatch.setattr(wake.time, "sleep", clock.sleep)
monkeypatch.setattr(wake.secrets, "randbelow", lambda maximum: 41)
device = SimulatedBeacon(clock)
monkeypatch.setattr(wake.serial, "Serial", lambda **kwargs: device)
return device
def test_fragmented_burst_completes_once_and_excludes_retained_id(beacon):
beacon.status.update(request_id=42, state="complete")
result = wake.request_wake("COM5", timeout=1)
assert result.state == "complete"
assert result.request_id == 43
assert result.completed_bursts == 1
assert beacon.bursts == 1
assert beacon.commands[0] == b"SPWB1 STATUS"
assert [c for c in beacon.commands if c.startswith(b"SPWB1 WAKE")] == [
b"SPWB1 WAKE 43"
]
assert beacon.closed
@pytest.mark.parametrize(
"change",
[
{"role": "controller"},
{"firmware": 100},
{"protocol": True},
{"radio_ready": 1},
{"controller_hosting": True},
{"request_id": -1},
{"completed_bursts": 0x100000000},
{"busy": None},
{"state": ["idle"]},
{"unexpected": 1},
],
)
def test_unsafe_preflight_never_wakes(beacon, change):
beacon.preflight_change = change
with pytest.raises(wake.ProtocolError):
wake.request_wake("/dev/ttyACM0", timeout=1)
assert beacon.bursts == 0
assert beacon.commands == [b"SPWB1 STATUS"]
assert beacon.closed
@pytest.mark.parametrize(
"raw",
[
b'SPWB1 {"protocol":1,"protocol":1}\n',
b"SPWB1 {}\n",
b"diagnostic output\n",
b"SPWB1 {}",
b"SPWB1 \xff\n",
b"SPWB1 " + b" " * wake.MAX_RESPONSE_BYTES + b"\n",
],
)
def test_invalid_framing_and_ambiguous_json_rejected(raw):
with pytest.raises(wake.ProtocolError):
wake.decode_status(raw)
def test_busy_race_is_command_rejection_not_our_operation(beacon):
beacon.race_busy = True
with pytest.raises(wake.CommandRejected) as error:
wake.request_wake("COM5", timeout=1)
assert error.value.status.error == "busy"
assert error.value.status.request_id == 77
assert error.value.request_id == 42
assert beacon.bursts == 0
assert len(beacon.commands) == 2
assert beacon.closed
@pytest.mark.parametrize("finish", ["cleanup_failure", "counter_failure"])
def test_failure_wins_over_completed_counter(beacon, finish):
beacon.finish = finish
with pytest.raises(wake.OperationFailed) as error:
wake.request_wake("COM5", timeout=1)
assert error.value.status.failures == 1
assert error.value.status.completed_bursts == 1
assert error.value.status.error == ""
assert beacon.bursts == 1
assert beacon.closed
@pytest.mark.parametrize("finish", ["reset", "replaced"])
def test_reset_or_unrelated_completion_never_rebroadcasts(beacon, finish):
beacon.finish = finish
with pytest.raises(wake.RequestLost) as error:
wake.request_wake("COM5", timeout=1)
assert error.value.request_id == 42
assert error.value.status.request_id != 42
assert error.value.wake_sent
assert beacon.bursts == 1
assert beacon.closed
def test_disconnect_after_delivery_has_uncertain_outcome_without_retry(beacon):
beacon.disconnect_on_wake = True
with pytest.raises(wake.TransportError) as error:
wake.request_wake("COM5", timeout=1)
assert error.value.wake_sent
assert error.value.request_id == 42
assert beacon.bursts == 1
assert beacon.commands == [b"SPWB1 STATUS", b"SPWB1 WAKE 42"]
assert beacon.closed
def test_partial_write_never_retries_or_wakes(beacon):
beacon.partial_write = True
with pytest.raises(wake.TransportError):
wake.request_wake("COM5", timeout=1)
assert beacon.input == b"SPW"
assert beacon.bursts == 0
assert beacon.closed
@pytest.mark.parametrize("silent", [False, True])
def test_deadline_bounds_silence_and_endless_broadcast(beacon, silent):
beacon.silent = silent
beacon.finish = "never"
with pytest.raises(wake.BeaconTimeout):
wake.request_wake("COM5", timeout=0.5)
assert beacon.clock.now == pytest.approx(0.5)
assert beacon.bursts == (0 if silent else 1)
assert beacon.closed
def test_unterminated_response_is_bounded_without_wake(beacon):
beacon.silent = True
beacon.output.extend(b"x" * (wake.MAX_RESPONSE_BYTES + 1))
with pytest.raises(wake.ProtocolError):
wake.request_wake("COM5", timeout=1)
assert beacon.bursts == 0
assert beacon.closed
def test_status_reports_retained_failure_without_actuating(beacon, capsys):
beacon.status.update(request_id=12, state="failed", failures=1)
assert wake.main(["--port", "COM5", "--status", "--json"]) == 0
result = json.loads(capsys.readouterr().out)
assert result["status"]["state"] == "failed"
assert result["action"] == "status"
assert beacon.commands == [b"SPWB1 STATUS"]
assert beacon.bursts == 0
assert beacon.closed
def test_json_operation_failure_preserves_device_status(beacon, capsys):
beacon.finish = "cleanup_failure"
assert wake.main(["--port", "COM5", "--json"]) == 1
captured = capsys.readouterr()
result = json.loads(captured.out)
assert captured.err == ""
assert result["ok"] is False
assert result["error"] == "operation_failed"
assert result["status"]["error"] == ""
assert result["status"]["failures"] == 1
assert result["request_id"] == 42
assert result["wake_sent"] is True
def test_discovery_selects_only_unique_beacon_pid(monkeypatch):
ports = [
SimpleNamespace(device="COM3", vid=0xCAFE, pid=0x4000),
SimpleNamespace(device="COM4", vid=0x1234, pid=0x4030),
SimpleNamespace(device="COM5", vid=0xCAFE, pid=0x4030),
]
monkeypatch.setattr(wake.list_ports, "comports", lambda: ports)
assert wake.discover_beacon_port() == "COM5"
ports.pop()
with pytest.raises(wake.TransportError):
wake.discover_beacon_port()
def test_multiple_beacons_fail_before_port_open_or_wake(beacon, monkeypatch):
monkeypatch.setattr(
wake.list_ports,
"comports",
lambda: [
SimpleNamespace(device=path, vid=0xCAFE, pid=0x4030)
for path in ("COM5", "COM6")
],
)
with pytest.raises(wake.TransportError):
wake.request_wake(timeout=1)
assert not beacon.closed
assert beacon.commands == []
assert beacon.bursts == 0
@pytest.mark.parametrize("timeout", [0, -1, float("inf"), float("nan")])
def test_invalid_deadline_never_opens_transport(beacon, timeout):
with pytest.raises(ValueError):
wake.request_wake("COM5", timeout=timeout)
assert not beacon.closed
assert beacon.commands == []
@pytest.mark.parametrize(
"initial, error_type",
[
(
{"busy": True, "state": "broadcasting", "request_id": 9},
wake.CommandRejected,
),
({"configured": False, "state": "unconfigured"}, wake.OperationFailed),
],
)
def test_preflight_busy_or_unconfigured_never_sends_wake(beacon, initial, error_type):
beacon.status.update(initial)
with pytest.raises(error_type) as error:
wake.request_wake("COM5", timeout=1)
assert error.value.status.state == initial["state"]
assert error.value.wake_sent is False
assert beacon.commands == [b"SPWB1 STATUS"]
assert beacon.bursts == 0
assert beacon.closed
@pytest.mark.parametrize("startup_busy", [False, True])
def test_startup_waits_for_configuration_before_one_wake(beacon, startup_busy):
beacon.status.update(radio_ready=False, configured=False, busy=startup_busy)
beacon.ready_at = 0.2
result = wake.request_wake("COM5", timeout=1)
assert result.state == "complete"
assert beacon.commands[:3] == [b"SPWB1 STATUS"] * 3
assert beacon.commands[3] == b"SPWB1 WAKE 42"
assert beacon.bursts == 1
assert beacon.closed
def test_stalled_startup_times_out_without_wake(beacon):
beacon.status.update(radio_ready=False, configured=False)
with pytest.raises(wake.BeaconTimeout) as error:
wake.request_wake("COM5", timeout=0.5)
assert error.value.wake_sent is False
assert beacon.bursts == 0
assert set(beacon.commands) == {b"SPWB1 STATUS"}
assert beacon.clock.now == pytest.approx(0.5)
assert beacon.closed
def test_protocol_compatible_patch_firmware_can_wake(beacon):
beacon.status["firmware"] = "1.0.1"
result = wake.request_wake("COM5", timeout=1)
assert result.state == "complete"
assert result.firmware == "1.0.1"
assert beacon.bursts == 1
@pytest.mark.parametrize("firmware", ["", " " * 3, "v" * 65])
def test_invalid_firmware_identifier_blocks_wake(beacon, firmware):
beacon.status["firmware"] = firmware
with pytest.raises(wake.ProtocolError):
wake.request_wake("COM5", timeout=1)
assert beacon.commands == [b"SPWB1 STATUS"]
assert beacon.bursts == 0
def test_close_failure_preserves_completed_wake_context(beacon, capsys):
beacon.fail_close = True
assert wake.main(["--port", "COM5", "--json"]) == 1
captured = capsys.readouterr()
result = json.loads(captured.out)
assert captured.err == ""
assert result["error"] == "transport_error"
assert result["request_id"] == 42
assert result["wake_sent"] is True
assert result["status"]["state"] == "complete"
assert result["status"]["completed_bursts"] == 1
assert beacon.bursts == 1

View file

@ -0,0 +1,29 @@
import shutil
import subprocess
from pathlib import Path
def test_wake_beacon_protocol_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 / "wake_beacon_protocol_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'tools' / 'switch2_wake_beacon'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "wake_beacon_protocol_test.cpp"),
str(root / "tools" / "switch2_wake_beacon" / "beacon_protocol.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,257 @@
#include <cstdlib>
#include <iostream>
#include <string>
#include "beacon_protocol.h"
namespace {
using wake_beacon::Protocol;
using wake_beacon::RadioStatus;
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
void field(const std::string& response, const std::string& key,
const std::string& value) {
const auto token = "\"" + key + "\":" + value;
require(response.find(token + ",") != std::string::npos ||
response.find(token + "}") != std::string::npos,
("unexpected " + key + " in " + response).c_str());
}
void feed(Protocol& protocol, const std::string& input) {
for (unsigned char byte : input) {
require(protocol.receive(byte), "unexpected receive backpressure");
}
}
std::string drain(Protocol& protocol) {
const std::string response(protocol.output_data(), protocol.output_size());
protocol.consume_output(response.size());
require(response.rfind("SPWB1 {", 0) == 0 && response.back() == '\n',
"missing complete framed JSON response");
require(response.find('\n') == response.size() - 1,
"multiple or unsolicited response lines");
return response;
}
std::string request(Protocol& protocol, const std::string& line) {
feed(protocol, line);
return drain(protocol);
}
RadioStatus ready() {
RadioStatus radio;
radio.ready = true;
radio.initialized = true;
radio.wake.configured = true;
return radio;
}
void malformed_and_framing() {
Protocol protocol;
protocol.observe(ready());
protocol.connected(true);
const std::string invalid[] = {
"\n", "SPWB1 WAKE 0\n", "SPWB1 WAKE -1\n",
"SPWB1 WAKE +1\n", "SPWB1 WAKE 2147483648\n",
"SPWB1 WAKE 999999999999999999999\n", "SPWB1 WAKE 1x\n",
"SPWB1 WAKE 1 \n", "SPWB1 WAKE 1\r\r\n",
"SPWB1 WAKE 1\rSTATUS\n", "SPWB2 WAKE 1\n",
std::string("SPWB1 WAKE 1\0", 13) + "\n",
"SPWB1 WAKE " + std::string(53, '0') + "1\n",
std::string(65, 'x') + "SPWB1 WAKE 1\n",
};
for (const auto& line : invalid) {
const auto response = request(protocol, line);
field(response, "error", "\"malformed\"");
field(response, "accepted_requests", "0");
field(response, "request_id", "0");
require(!protocol.dispatch_pending(), "malformed request queued wake");
}
feed(protocol, "SPWB1 WAKE 2147483647\r");
require(!protocol.dispatch_pending() && protocol.output_size() == 0,
"request acted before newline");
feed(protocol, "\n");
const auto response = drain(protocol);
field(response, "request_id", "2147483647");
field(response, "state", "\"queued\"");
require(protocol.dispatch_pending(), "valid boundary ID not queued");
}
void exact_line_boundary() {
Protocol protocol;
protocol.observe(ready());
protocol.connected(true);
const auto response = request(
protocol, "SPWB1 WAKE " + std::string(52, '0') + "1\n");
field(response, "error", "\"\"");
field(response, "request_id", "1");
require(protocol.dispatch_pending(), "64-byte line rejected");
}
void readonly_and_readiness() {
Protocol protocol;
protocol.connected(true);
require(protocol.output_size() == 0 && !protocol.dispatch_pending(),
"connection caused output or wake");
field(request(protocol, "SPWB1 STATUS\r\n"), "state", "\"idle\"");
require(!protocol.dispatch_pending(), "STATUS caused wake");
auto response = request(protocol, "SPWB1 WAKE 19\n");
field(response, "state", "\"queued\"");
field(response, "radio_ready", "false");
require(!protocol.dispatch_pending(), "wake dispatched before readiness");
protocol.observe(ready());
require(protocol.dispatch_pending(), "queued wake lost during startup");
protocol.dispatched(true);
require(!protocol.dispatch_pending(), "accepted wake dispatched twice");
response = request(protocol, "SPWB1 STATUS\n");
field(response, "state", "\"broadcasting\"");
field(response, "accepted_requests", "1");
}
void replay_and_busy() {
Protocol protocol;
auto radio = ready();
protocol.observe(radio);
protocol.connected(true);
request(protocol, "SPWB1 WAKE 23\n");
auto response = request(protocol, "SPWB1 WAKE 24\n");
field(response, "error", "\"busy\"");
field(response, "request_id", "23");
field(response, "state", "\"queued\"");
response = request(protocol, "SPWB1 WAKE 00023\n");
field(response, "error", "\"\"");
field(response, "accepted_requests", "1");
protocol.dispatched(true);
radio.wake.busy = true;
protocol.observe(radio);
protocol.connected(false);
protocol.connected(true);
response = request(protocol, "SPWB1 WAKE 23\n");
field(response, "state", "\"broadcasting\"");
field(response, "accepted_requests", "1");
require(!protocol.dispatch_pending(), "active replay restarted wake");
radio.wake.busy = false;
radio.wake.completed_bursts = 1;
protocol.observe(radio);
response = request(protocol, "SPWB1 WAKE 23\n");
field(response, "state", "\"complete\"");
field(response, "accepted_requests", "1");
require(!protocol.dispatch_pending(), "terminal replay restarted wake");
field(request(protocol, "SPWB1 WAKE 24\n"), "state", "\"queued\"");
require(protocol.dispatch_pending(), "new ID did not start next wake");
}
void disconnect_and_backpressure() {
Protocol protocol;
protocol.observe(ready());
protocol.connected(true);
feed(protocol, "SPWB1 WAKE ");
protocol.connected(false);
require(!protocol.receive('7'), "disconnected input accepted");
protocol.connected(true);
field(request(protocol, "7\n"), "error", "\"malformed\"");
require(!protocol.dispatch_pending(), "disconnect joined partial request");
feed(protocol, "SPWB1 WAKE 7\n");
const std::string queued(protocol.output_data(), protocol.output_size());
require(!protocol.receive('S'), "backpressured request accepted");
protocol.consume_output(7);
require(!protocol.receive('\n'), "partial TX released backpressure");
protocol.dispatched(true);
auto radio = ready();
radio.wake.completed_bursts = 1;
protocol.observe(radio);
require(std::string(protocol.output_data(), protocol.output_size()) ==
queued.substr(7),
"radio completion corrupted in-flight queued response");
protocol.connected(false);
require(protocol.output_size() == 0, "disconnect retained partial TX");
protocol.connected(true);
auto response = request(protocol, "SPWB1 STATUS\n");
field(response, "state", "\"complete\"");
field(response, "request_id", "7");
require(!protocol.dispatch_pending(), "reconnect caused another wake");
}
void failed_cleanup_is_not_success() {
Protocol protocol;
auto radio = ready();
// Counter wrap is also a legitimate change, not an ordering comparison.
radio.wake.failures = UINT32_MAX;
radio.wake.completed_bursts = UINT32_MAX;
protocol.observe(radio);
protocol.connected(true);
request(protocol, "SPWB1 WAKE 31\n");
protocol.dispatched(true);
radio.wake.failures = 0;
radio.wake.completed_bursts = 0;
radio.wake.busy = true;
protocol.observe(radio);
auto response = request(protocol, "SPWB1 STATUS\n");
field(response, "state", "\"failed\"");
field(response, "error", "\"\"");
field(response, "busy", "true");
field(request(protocol, "SPWB1 WAKE 32\n"), "error", "\"busy\"");
radio.wake.busy = false;
protocol.observe(radio);
response = request(protocol, "SPWB1 WAKE 31\n");
field(response, "state", "\"failed\"");
require(!protocol.dispatch_pending(), "failed replay retried burst");
field(request(protocol, "SPWB1 WAKE 32\n"), "state", "\"queued\"");
protocol.dispatched(false);
field(request(protocol, "SPWB1 STATUS\n"), "state", "\"failed\"");
require(!protocol.dispatch_pending(), "dispatch refusal retried burst");
}
void startup_failure_and_missing_configuration() {
Protocol protocol;
protocol.connected(true);
request(protocol, "SPWB1 WAKE 41\n");
RadioStatus radio;
radio.failed = true;
radio.wake.busy = true; // Software may still report busy after radio shutdown.
protocol.observe(radio);
auto response = request(protocol, "SPWB1 STATUS\n");
field(response, "request_id", "41");
field(response, "state", "\"failed\"");
field(response, "error", "\"\"");
field(response, "busy", "false");
field(response, "failures", "1");
protocol.observe(radio);
response = request(protocol, "SPWB1 WAKE 42\n");
field(response, "error", "\"radio_init_failed\"");
field(response, "failures", "1");
field(response, "request_id", "41");
require(!protocol.dispatch_pending(), "failed radio dispatched wake");
Protocol unconfigured;
unconfigured.connected(true);
request(unconfigured, "SPWB1 WAKE 1\n");
radio = {};
radio.initialized = true;
unconfigured.observe(radio);
response = request(unconfigured, "SPWB1 STATUS\n");
field(response, "state", "\"unconfigured\"");
field(response, "configured", "false");
require(!unconfigured.dispatch_pending(), "unconfigured wake dispatched");
}
} // namespace
int main() {
malformed_and_framing();
exact_line_boundary();
readonly_and_readiness();
replay_and_busy();
disconnect_and_backpressure();
failed_cleanup_is_not_success();
startup_failure_and_missing_configuration();
return 0;
}

View file

@ -0,0 +1,68 @@
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_beacon C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(FIRMWARE_DIR ${CMAKE_CURRENT_LIST_DIR}/../../src/firmware)
if(NOT EXISTS "${FIRMWARE_DIR}/platform/pico/switch2_wake_config.h")
message(FATAL_ERROR
"Wake-only firmware requires the existing private switch2_wake_config.h. Capture/configure it first; no generic wake packet is provided.")
endif()
pico_sdk_init()
if(NOT PICO_CYW43_SUPPORTED)
message(FATAL_ERROR "Wake-only firmware requires a wireless Pico board (default: pico2_w).")
endif()
add_executable(switch2-wake-beacon
main.cpp
beacon_protocol.cpp
${FIRMWARE_DIR}/input/switch2_wake.cpp
)
target_include_directories(switch2-wake-beacon PRIVATE
${CMAKE_CURRENT_LIST_DIR}
${FIRMWARE_DIR}
)
target_compile_definitions(switch2-wake-beacon PRIVATE
SWITCH2_WAKE_CONFIGURED=1
ENABLE_BLE=1
CYW43_LWIP=0
PICO_CYW43_LOGGING_ENABLED=0
PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=4
USBD_VID=0xCAFE
USBD_PID=0x4030
USBD_MANUFACTURER="switch-pico"
USBD_PRODUCT="switch-pico wake beacon"
USBD_DESC_STR_MAX=32
PICO_STDIO_USB_ENABLE_RESET_VIA_BAUD_RATE=0
PICO_STDIO_USB_ENABLE_RESET_VIA_VENDOR_INTERFACE=0
PICO_STDIO_USB_RESET_INTERFACE_SUPPORT_MS_OS_20_DESCRIPTOR=0
PICO_STDIO_USB_ENABLE_IRQ_BACKGROUND_TASK=0
PICO_STDIO_USB_SUPPORT_CHARS_AVAILABLE_CALLBACK=0
PICO_STDIO_USB_CONNECT_WAIT_TIMEOUT_MS=0
PICO_STDIO_USB_STDOUT_TIMEOUT_US=0
PICO_STDIO_USB_DEFAULT_CRLF=0
CFG_TUSB_OS=OPT_OS_PICO
CFG_TUD_TASK_QUEUE_SZ=16
)
# Do not link pico_btstack_cyw43: its startup initializes writable flash TLV.
# HCI-only BLE needs neither pico_btstack_ble's SM/GATT/HID profiles nor Classic.
# The exported low-level SDK driver and transport use our Core-0 polled context.
target_link_libraries(switch2-wake-beacon PRIVATE
pico_stdlib
pico_async_context_poll
pico_cyw43_driver
cyw43_driver_picow
pico_btstack_base
pico_btstack_hci_transport_cyw43
pico_btstack_run_loop_async_context
)
pico_enable_stdio_usb(switch2-wake-beacon 1)
pico_enable_stdio_uart(switch2-wake-beacon 0)
pico_enable_stdio_rtt(switch2-wake-beacon 0)
pico_set_program_name(switch2-wake-beacon "switch-pico wake beacon")
pico_set_program_version(switch2-wake-beacon "1.0.0")
pico_add_extra_outputs(switch2-wake-beacon)

View file

@ -0,0 +1,193 @@
#include "beacon_protocol.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
namespace wake_beacon {
void Protocol::connected(bool connected) {
if (!connected) {
// An interrupted line must never be completed by a later USB session.
line_size_ = 0;
invalid_line_ = false;
response_size_ = 0;
response_offset_ = 0;
}
connected_ = connected;
}
bool Protocol::can_receive() const {
return connected_ && output_size() == 0;
}
bool Protocol::receive(uint8_t byte) {
if (!can_receive()) {
return false;
}
if (byte == '\n') {
if (invalid_line_) {
respond("malformed");
} else {
command();
}
line_size_ = 0;
invalid_line_ = false;
} else if (line_size_ == kMaxLineBytes ||
(byte != '\r' && (byte < 0x20 || byte > 0x7e))) {
// Discard through LF, never parse an overflowing suffix as a command.
invalid_line_ = true;
} else if (!invalid_line_) {
line_[line_size_++] = static_cast<char>(byte);
}
return true;
}
const char* Protocol::output_data() const {
return response_ + response_offset_;
}
size_t Protocol::output_size() const {
return response_size_ - response_offset_;
}
void Protocol::consume_output(size_t count) {
if (count > output_size()) {
count = output_size();
}
response_offset_ += count;
}
bool Protocol::active() const {
return state_ == State::Queued || state_ == State::Broadcasting;
}
bool Protocol::busy() const {
return active() || (!radio_.failed && radio_.wake.busy);
}
const char* Protocol::state_name() const {
switch (state_) {
case State::Idle: return "idle";
case State::Queued: return "queued";
case State::Broadcasting: return "broadcasting";
case State::Complete: return "complete";
case State::Unconfigured: return "unconfigured";
case State::Failed: return "failed";
}
return "failed";
}
void Protocol::observe(const RadioStatus& radio) {
if (radio.failed && !radio_.failed) {
++local_failures_;
}
radio_ = radio;
if (active()) {
if (radio_.failed) {
state_ = State::Failed;
} else if (radio_.initialized && !radio_.wake.configured) {
state_ = State::Unconfigured;
} else if (state_ == State::Broadcasting) {
// A failed stop may still complete during cleanup. Failure wins.
if (radio_.wake.failures != start_failures_) {
state_ = State::Failed;
} else if (radio_.wake.completed_bursts != start_completed_ &&
!radio_.wake.busy) {
state_ = State::Complete;
}
}
} else if (request_id_ == 0) {
if (radio_.failed) {
state_ = State::Failed;
} else if (radio_.initialized && !radio_.wake.configured) {
state_ = State::Unconfigured;
}
}
}
bool Protocol::dispatch_pending() const {
return state_ == State::Queued && radio_.ready && !radio_.failed &&
radio_.wake.configured && !radio_.wake.busy;
}
void Protocol::dispatched(bool accepted) {
if (!dispatch_pending()) {
return;
}
if (accepted) {
start_completed_ = radio_.wake.completed_bursts;
start_failures_ = radio_.wake.failures;
state_ = State::Broadcasting;
} else {
++local_failures_;
state_ = State::Failed;
}
}
void Protocol::command() {
size_t length = line_size_;
if (length != 0 && line_[length - 1] == '\r') {
--length;
}
constexpr char status[] = "SPWB1 STATUS";
constexpr char wake[] = "SPWB1 WAKE ";
if (length == sizeof(status) - 1 &&
memcmp(line_, status, sizeof(status) - 1) == 0) {
respond("");
return;
}
if (length <= sizeof(wake) - 1 ||
memcmp(line_, wake, sizeof(wake) - 1) != 0) {
respond("malformed");
return;
}
uint32_t id = 0;
constexpr uint32_t max_id = 0x7fffffff;
for (size_t index = sizeof(wake) - 1; index < length; ++index) {
const char digit = line_[index];
if (digit < '0' || digit > '9' ||
id > (max_id - static_cast<uint32_t>(digit - '0')) / 10) {
respond("malformed");
return;
}
id = id * 10 + static_cast<uint32_t>(digit - '0');
}
if (id == 0) {
respond("malformed");
} else if (id == request_id_) {
// Retain idempotency across disconnects, failures and completion.
respond("");
} else if (busy()) {
respond("busy");
} else if (radio_.failed) {
respond("radio_init_failed");
} else {
request_id_ = id;
state_ = State::Queued;
++accepted_requests_;
respond("");
}
}
void Protocol::respond(const char* error) {
const int length = snprintf(
response_, sizeof(response_),
"SPWB1 {\"protocol\":1,\"role\":\"wake-only\",\"firmware\":\"1.0.0\","
"\"radio_ready\":%s,\"controller_hosting\":false,"
"\"request_id\":%" PRIu32 ",\"state\":\"%s\",\"configured\":%s,"
"\"busy\":%s,\"accepted_requests\":%" PRIu32 ","
"\"completed_bursts\":%" PRIu32 ",\"failures\":%" PRIu32 ","
"\"error\":\"%s\"}\n",
radio_.ready ? "true" : "false", request_id_, state_name(),
radio_.wake.configured ? "true" : "false", busy() ? "true" : "false",
accepted_requests_, radio_.wake.completed_bursts,
radio_.wake.failures + local_failures_, error);
// All strings are fixed literals and even maximum counters fit in 512 bytes.
response_size_ = length > 0 && static_cast<size_t>(length) < sizeof(response_)
? static_cast<size_t>(length)
: 0;
response_offset_ = 0;
}
} // namespace wake_beacon

View file

@ -0,0 +1,62 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "input/switch2_wake.h"
namespace wake_beacon {
struct RadioStatus {
bool ready = false;
bool failed = false;
bool initialized = false;
Switch2WakeDiagnostics wake{};
};
// Owned entirely by Core 0. Parsing only queues a request; the radio owner calls
// dispatch_pending()/dispatched() separately, outside USB callbacks and IRQs.
class Protocol {
public:
static constexpr size_t kMaxLineBytes = 64; // Excludes LF, includes optional CR.
static constexpr size_t kResponseBytes = 512;
void connected(bool connected);
bool can_receive() const;
bool receive(uint8_t byte);
const char* output_data() const;
size_t output_size() const;
void consume_output(size_t count);
void observe(const RadioStatus& radio);
bool dispatch_pending() const;
void dispatched(bool accepted);
private:
enum class State : uint8_t {
Idle, Queued, Broadcasting, Complete, Unconfigured, Failed,
};
bool active() const;
bool busy() const;
const char* state_name() const;
void command();
void respond(const char* error);
RadioStatus radio_{};
State state_ = State::Idle;
uint32_t request_id_ = 0;
uint32_t accepted_requests_ = 0;
uint32_t local_failures_ = 0;
uint32_t start_completed_ = 0;
uint32_t start_failures_ = 0;
bool connected_ = false;
bool invalid_line_ = false;
char line_[kMaxLineBytes]{};
size_t line_size_ = 0;
char response_[kResponseBytes]{};
size_t response_size_ = 0;
size_t response_offset_ = 0;
};
} // namespace wake_beacon

View file

@ -0,0 +1,5 @@
#pragma once
// Policy for the shared wake engine, not the controller-host firmware config.
#define SWITCH_PICO_ENABLE_BLE 1
#define SWITCH_PICO_ENABLE_CLASSIC 0

View file

@ -0,0 +1,24 @@
#ifndef SWITCH2_WAKE_BEACON_BTSTACK_CONFIG_H
#define SWITCH2_WAKE_BEACON_BTSTACK_CONFIG_H
// BTstack requires its peripheral/advertiser fields for BLE advertising.
// There is no central/Classic role, connection pool, GATT/SM/HID stack or scan.
#if defined(ENABLE_CLASSIC) || defined(ENABLE_LE_CENTRAL)
#error "Wake-only firmware must not enable controller discovery/hosting roles"
#endif
#define ENABLE_LE_PERIPHERAL
#define HAVE_EMBEDDED_TIME_MS
// Required by the SDK's compiled dump helper; no logger/dump is initialized.
#define ENABLE_PRINTF_HEXDUMP
#define HAVE_ASSERT
#define HCI_OUTGOING_PRE_BUFFER_SIZE 4
#define HCI_INCOMING_PRE_BUFFER_SIZE 4
#define HCI_ACL_PAYLOAD_SIZE 251
#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4
#define MAX_NR_HCI_CONNECTIONS 0
#define MAX_NR_L2CAP_CHANNELS 0
#define MAX_NR_L2CAP_SERVICES 0
#define MAX_NR_WHITELIST_ENTRIES 0
#define HCI_RESET_RESEND_TIMEOUT_MS 1000
#endif

View file

@ -0,0 +1,281 @@
#include "beacon_protocol.h"
#include <btstack.h>
#include "device/dcd.h"
#include "hardware/sync.h"
#include "pico/async_context_poll.h"
#include "pico/btstack_hci_transport_cyw43.h"
#include "pico/btstack_run_loop_async_context.h"
#include "pico/cyw43_driver.h"
#include "pico/stdio_usb.h"
#include "pico/stdlib.h"
#include "tusb.h"
namespace {
constexpr uint32_t kStartupTimeoutMs = 10000;
constexpr uint32_t kCommandTimeoutMs = 1000;
constexpr uint32_t kBurstWatchdogMs = 8000;
constexpr size_t kUsbBytesPerTurn = 64;
const hci_cmd_t kReadScanEnable{0x0c19, ""};
enum class RadioPhase : uint8_t {
Starting,
DisableClassicScan,
VerifyClassicScan,
InitializeWake,
Ready,
Unconfigured,
Failed,
};
wake_beacon::Protocol protocol;
async_context_poll_t radio_context;
btstack_packet_callback_registration_t event_registration{};
RadioPhase radio_phase = RadioPhase::Starting;
bool driver_live = false;
bool hci_initialized = false;
bool wake_initialized = false;
bool burst_watchdog_armed = false;
uint16_t pending_opcode = 0;
uint32_t command_deadline = 0;
uint32_t startup_deadline = 0;
uint32_t burst_deadline = 0;
volatile bool usb_session_reset = false;
bool expired(uint32_t now, uint32_t deadline) {
return static_cast<int32_t>(now - deadline) >= 0;
}
void fail_radio() {
// Deinitialization happens in the owner loop, not recursively in HCI events.
radio_phase = RadioPhase::Failed;
pending_opcode = 0;
}
void handle_packet(uint8_t packet_type, uint16_t,
uint8_t* packet, uint16_t size) {
if (packet_type != HCI_EVENT_PACKET || size < 2 ||
radio_phase == RadioPhase::Failed) {
return;
}
const uint8_t event = hci_event_packet_get_type(packet);
if (event == BTSTACK_EVENT_POWERON_FAILED ||
event == HCI_EVENT_HARDWARE_ERROR) {
fail_radio();
return;
}
if (event == BTSTACK_EVENT_STATE && size >= 3) {
const uint8_t state = btstack_event_state_get_state(packet);
if (state == HCI_STATE_WORKING && radio_phase == RadioPhase::Starting) {
radio_phase = RadioPhase::DisableClassicScan;
} else if (state != HCI_STATE_INITIALIZING && state != HCI_STATE_WORKING) {
fail_radio();
}
return;
}
if (event != HCI_EVENT_COMMAND_COMPLETE || size < 5 ||
pending_opcode == 0 ||
hci_event_command_complete_get_command_opcode(packet) != pending_opcode) {
return;
}
pending_opcode = 0;
if (size < 6 ||
hci_event_command_complete_get_return_parameters(packet)[0] !=
ERROR_CODE_SUCCESS) {
fail_radio();
return;
}
if (radio_phase == RadioPhase::DisableClassicScan) {
radio_phase = RadioPhase::VerifyClassicScan;
} else if (radio_phase == RadioPhase::VerifyClassicScan) {
// Both inquiry and page scanning must be off on the dual-mode CYW43.
if (size < 7 || packet[6] != 0) {
fail_radio();
} else {
radio_phase = RadioPhase::InitializeWake;
}
}
}
wake_beacon::RadioStatus radio_status() {
wake_beacon::RadioStatus status;
status.ready = radio_phase == RadioPhase::Ready;
status.failed = radio_phase == RadioPhase::Failed;
status.initialized = wake_initialized;
if (wake_initialized) {
switch2_wake_diagnostics(&status.wake);
}
return status;
}
void radio_owner_task() {
const uint32_t now = to_ms_since_boot(get_absolute_time());
if (radio_phase != RadioPhase::Ready &&
radio_phase != RadioPhase::Unconfigured &&
radio_phase != RadioPhase::Failed && expired(now, startup_deadline)) {
fail_radio();
}
if (pending_opcode != 0 && expired(now, command_deadline)) {
fail_radio();
}
if (radio_phase == RadioPhase::Failed) {
if (driver_live) {
// Stop the physical radio too: a controller fault or failed stop
// must not leave advertising running while USB reports failure.
if (hci_initialized) {
hci_close();
}
cyw43_driver_deinit(&radio_context.core);
driver_live = false;
}
protocol.observe(radio_status());
return;
}
if (pending_opcode == 0 && hci_can_send_command_packet_now()) {
if (radio_phase == RadioPhase::DisableClassicScan) {
pending_opcode = hci_write_scan_enable.opcode;
command_deadline = now + kCommandTimeoutMs;
if (hci_send_cmd(&hci_write_scan_enable, 0) != ERROR_CODE_SUCCESS) {
fail_radio();
}
} else if (radio_phase == RadioPhase::VerifyClassicScan) {
pending_opcode = kReadScanEnable.opcode;
command_deadline = now + kCommandTimeoutMs;
if (hci_send_cmd(&kReadScanEnable) != ERROR_CODE_SUCCESS) {
fail_radio();
}
}
}
if (radio_phase == RadioPhase::InitializeWake) {
if (!wake_initialized) {
switch2_wake_initialize();
wake_initialized = true;
}
const auto status = radio_status();
if (!status.wake.configured) {
radio_phase = RadioPhase::Unconfigured;
} else if (status.wake.failures != 0) {
fail_radio();
} else if (switch2_wake_ready_for_connections() && !status.wake.busy) {
radio_phase = RadioPhase::Ready;
}
}
auto status = radio_status();
if (burst_watchdog_armed) {
if (!status.wake.busy) {
burst_watchdog_armed = false;
} else if (expired(now, burst_deadline)) {
fail_radio();
status = radio_status();
}
}
protocol.observe(status);
if (protocol.dispatch_pending()) {
const bool accepted = switch2_wake_request();
protocol.dispatched(accepted);
if (accepted) {
burst_deadline = now + kBurstWatchdogMs;
burst_watchdog_armed = true;
}
protocol.observe(radio_status());
}
}
void reset_usb_session() {
protocol.connected(false);
tud_cdc_read_flush();
tud_cdc_write_clear();
}
void service_usb() {
// TinyUSB's SDK version drains its event queue. Mask IRQs for this bounded
// queue snapshot so continuous host traffic cannot keep refilling it. The
// PICO OSAL preserves this interrupt mask; callbacks never touch the radio.
const uint32_t saved = save_and_disable_interrupts();
if (usb_session_reset) {
usb_session_reset = false;
reset_usb_session();
}
tud_task_ext(0, false);
restore_interrupts(saved);
const bool connected = stdio_usb_connected();
protocol.connected(connected);
if (!connected) {
tud_cdc_read_flush();
tud_cdc_write_clear();
return;
}
// One retained response applies backpressure before another request can be
// parsed. Never use printf/stdio flush: they may wait for a disconnected PC.
size_t count = protocol.output_size();
const size_t available = tud_cdc_write_available();
if (count > available) count = available;
if (count > kUsbBytesPerTurn) count = kUsbBytesPerTurn;
if (count != 0) {
protocol.consume_output(tud_cdc_write(protocol.output_data(), count));
}
tud_cdc_write_flush();
for (size_t index = 0; index < kUsbBytesPerTurn && protocol.can_receive();
++index) {
const int byte = tud_cdc_read_char();
if (byte < 0) break;
protocol.receive(static_cast<uint8_t>(byte));
}
}
} // namespace
extern "C" void tud_cdc_line_state_cb(uint8_t interface, bool dtr, bool) {
if (interface == 0 && !dtr) {
reset_usb_session();
}
}
extern "C" void tud_umount_cb() {
reset_usb_session();
}
extern "C" void tud_event_hook_cb(uint8_t, uint32_t event, bool) {
// This hook may run in USB IRQ context. Only invalidate the session here;
// TinyUSB and protocol work is deferred to service_usb() on Core 0.
if (event == DCD_EVENT_BUS_RESET || event == DCD_EVENT_UNPLUGGED) {
usb_session_reset = true;
}
}
int main() {
// Keep the SDK's unique-board-ID CDC descriptors, but use its TinyUSB FIFO
// directly for bounded protocol output. No SDK/radio log belongs on CDC.
if (!stdio_usb_init()) {
return 1;
}
stdio_set_driver_enabled(&stdio_usb, false);
startup_deadline = to_ms_since_boot(get_absolute_time()) + kStartupTimeoutMs;
if (!async_context_poll_init_with_defaults(&radio_context) ||
!cyw43_driver_init(&radio_context.core)) {
fail_radio();
} else {
driver_live = true;
// Deliberately bypass btstack_cyw43_init(): it initializes flash TLV.
btstack_memory_init();
btstack_run_loop_init(
btstack_run_loop_async_context_get_instance(&radio_context.core));
hci_init(hci_transport_cyw43_instance(), nullptr);
hci_initialized = true;
event_registration.callback = handle_packet;
hci_add_event_handler(&event_registration);
if (hci_power_control(HCI_POWER_ON) != ERROR_CODE_SUCCESS) {
fail_radio();
}
}
for (;;) {
if (driver_live && radio_phase != RadioPhase::Failed) {
async_context_poll(&radio_context.core);
}
radio_owner_task();
service_usb();
sleep_us(100);
}
}