switch-pico/tests/wake_beacon_protocol_test.cpp
Joey Yakimowich-Payne bed71f77f1 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.
2026-09-19 22:03:17 -06:00

257 lines
9.4 KiB
C++

#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;
}