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:
parent
e277d34c20
commit
bed71f77f1
14 changed files with 2042 additions and 3 deletions
58
README.md
58
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
|
||||
|
|
|
|||
52
build.py
52
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
422
src/switch_pico_bridge/wake_beacon.py
Normal file
422
src/switch_pico_bridge/wake_beacon.py
Normal 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())
|
||||
|
|
@ -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
432
tests/test_wake_beacon.py
Normal 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
|
||||
29
tests/test_wake_beacon_protocol_native.py
Normal file
29
tests/test_wake_beacon_protocol_native.py
Normal 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)
|
||||
257
tests/wake_beacon_protocol_test.cpp
Normal file
257
tests/wake_beacon_protocol_test.cpp
Normal 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;
|
||||
}
|
||||
68
tools/switch2_wake_beacon/CMakeLists.txt
Normal file
68
tools/switch2_wake_beacon/CMakeLists.txt
Normal 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)
|
||||
193
tools/switch2_wake_beacon/beacon_protocol.cpp
Normal file
193
tools/switch2_wake_beacon/beacon_protocol.cpp
Normal 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
|
||||
62
tools/switch2_wake_beacon/beacon_protocol.h
Normal file
62
tools/switch2_wake_beacon/beacon_protocol.h
Normal 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
|
||||
5
tools/switch2_wake_beacon/bluetooth_transport_config.h
Normal file
5
tools/switch2_wake_beacon/bluetooth_transport_config.h
Normal 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
|
||||
24
tools/switch2_wake_beacon/btstack_config.h
Normal file
24
tools/switch2_wake_beacon/btstack_config.h
Normal 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
|
||||
281
tools/switch2_wake_beacon/main.cpp
Normal file
281
tools/switch2_wake_beacon/main.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue