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

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

View 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

View 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

View 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

View 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

View 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);
}
}