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
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue