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
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