From bed71f77f1cebc8557b752d48083f7047999efd8 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 19 Sep 2026 22:03:17 -0600 Subject: [PATCH] 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. --- README.md | 58 ++- build.py | 52 ++- pyproject.toml | 1 + src/switch_pico_bridge/wake_beacon.py | 422 +++++++++++++++++ tests/test_build.py | 161 ++++++- tests/test_wake_beacon.py | 432 ++++++++++++++++++ tests/test_wake_beacon_protocol_native.py | 29 ++ tests/wake_beacon_protocol_test.cpp | 257 +++++++++++ tools/switch2_wake_beacon/CMakeLists.txt | 68 +++ tools/switch2_wake_beacon/beacon_protocol.cpp | 193 ++++++++ tools/switch2_wake_beacon/beacon_protocol.h | 62 +++ .../bluetooth_transport_config.h | 5 + tools/switch2_wake_beacon/btstack_config.h | 24 + tools/switch2_wake_beacon/main.cpp | 281 ++++++++++++ 14 files changed, 2042 insertions(+), 3 deletions(-) create mode 100644 src/switch_pico_bridge/wake_beacon.py create mode 100644 tests/test_wake_beacon.py create mode 100644 tests/test_wake_beacon_protocol_native.py create mode 100644 tests/wake_beacon_protocol_test.cpp create mode 100644 tools/switch2_wake_beacon/CMakeLists.txt create mode 100644 tools/switch2_wake_beacon/beacon_protocol.cpp create mode 100644 tools/switch2_wake_beacon/beacon_protocol.h create mode 100644 tools/switch2_wake_beacon/bluetooth_transport_config.h create mode 100644 tools/switch2_wake_beacon/btstack_config.h create mode 100644 tools/switch2_wake_beacon/main.cpp diff --git a/README.md b/README.md index 4a3c38e..7fa6119 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.py b/build.py index 07682b6..e587733 100755 --- a/build.py +++ b/build.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 8cd4c11..ee24ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/switch_pico_bridge/wake_beacon.py b/src/switch_pico_bridge/wake_beacon.py new file mode 100644 index 0000000..bc4477b --- /dev/null +++ b/src/switch_pico_bridge/wake_beacon.py @@ -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()) diff --git a/tests/test_build.py b/tests/test_build.py index 785d355..0e0a3c1 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -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 diff --git a/tests/test_wake_beacon.py b/tests/test_wake_beacon.py new file mode 100644 index 0000000..70c9579 --- /dev/null +++ b/tests/test_wake_beacon.py @@ -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 diff --git a/tests/test_wake_beacon_protocol_native.py b/tests/test_wake_beacon_protocol_native.py new file mode 100644 index 0000000..6e7620b --- /dev/null +++ b/tests/test_wake_beacon_protocol_native.py @@ -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) diff --git a/tests/wake_beacon_protocol_test.cpp b/tests/wake_beacon_protocol_test.cpp new file mode 100644 index 0000000..0e0bf59 --- /dev/null +++ b/tests/wake_beacon_protocol_test.cpp @@ -0,0 +1,257 @@ +#include +#include +#include + +#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; +} diff --git a/tools/switch2_wake_beacon/CMakeLists.txt b/tools/switch2_wake_beacon/CMakeLists.txt new file mode 100644 index 0000000..c9d8024 --- /dev/null +++ b/tools/switch2_wake_beacon/CMakeLists.txt @@ -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) diff --git a/tools/switch2_wake_beacon/beacon_protocol.cpp b/tools/switch2_wake_beacon/beacon_protocol.cpp new file mode 100644 index 0000000..43929a1 --- /dev/null +++ b/tools/switch2_wake_beacon/beacon_protocol.cpp @@ -0,0 +1,193 @@ +#include "beacon_protocol.h" + +#include +#include +#include + +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(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(digit - '0')) / 10) { + respond("malformed"); + return; + } + id = id * 10 + static_cast(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(length) < sizeof(response_) + ? static_cast(length) + : 0; + response_offset_ = 0; +} + +} // namespace wake_beacon diff --git a/tools/switch2_wake_beacon/beacon_protocol.h b/tools/switch2_wake_beacon/beacon_protocol.h new file mode 100644 index 0000000..bd488bd --- /dev/null +++ b/tools/switch2_wake_beacon/beacon_protocol.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#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 diff --git a/tools/switch2_wake_beacon/bluetooth_transport_config.h b/tools/switch2_wake_beacon/bluetooth_transport_config.h new file mode 100644 index 0000000..313a7c8 --- /dev/null +++ b/tools/switch2_wake_beacon/bluetooth_transport_config.h @@ -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 diff --git a/tools/switch2_wake_beacon/btstack_config.h b/tools/switch2_wake_beacon/btstack_config.h new file mode 100644 index 0000000..4f9fdc5 --- /dev/null +++ b/tools/switch2_wake_beacon/btstack_config.h @@ -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 diff --git a/tools/switch2_wake_beacon/main.cpp b/tools/switch2_wake_beacon/main.cpp new file mode 100644 index 0000000..f758a80 --- /dev/null +++ b/tools/switch2_wake_beacon/main.cpp @@ -0,0 +1,281 @@ +#include "beacon_protocol.h" + +#include +#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(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(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); + } +}