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

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

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

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

View file

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

432
tests/test_wake_beacon.py Normal file
View file

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

View file

@ -0,0 +1,29 @@
import shutil
import subprocess
from pathlib import Path
def test_wake_beacon_protocol_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
executable = tmp_path / "wake_beacon_protocol_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'tools' / 'switch2_wake_beacon'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "wake_beacon_protocol_test.cpp"),
str(root / "tools" / "switch2_wake_beacon" / "beacon_protocol.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

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