Add managed reboot into USB BOOTSEL

This commit is contained in:
Joey Yakimowich-Payne 2026-09-03 14:15:31 -06:00
commit 4626b13bd8
13 changed files with 153 additions and 0 deletions

View file

@ -534,6 +534,16 @@ Evaluate a newer tagged Bluepad32 only when it closes a specific coverage gap. L
The ROM UF2 path remains the trusted update mechanism.
The management foundation is now available:
```text
switch-pico-config reboot bootsel
```
The endpoint-zero request is acknowledged before a guarded 50 ms delayed call
to the Pico ROM `reset_usb_boot()` entry point. Physical BOOTSEL remains the
recovery path when management USB is unavailable.
Add:
```text

View file

@ -92,6 +92,7 @@ Connect the Pico 2 W to the PC while the AIO firmware is running normally; do no
```sh
uv run switch-pico-config status
uv run switch-pico-config diagnostics
uv run switch-pico-config reboot bootsel
uv run switch-pico-config config show
uv run switch-pico-config config set --pairing-window-seconds 90
uv run switch-pico-config config reset --yes
@ -390,6 +391,17 @@ If you already have a built (or use the pre-built one in `firmware/`) `.uf2`, yo
Tip: if you dont see `RPI-RP2`, try a different USB cable (some are charge-only) or a different USB port/hub.
When the AIO firmware is already running on a PC, enter ROM BOOTSEL without
touching the board:
```sh
uv run switch-pico-config reboot bootsel
```
The firmware acknowledges the endpoint-zero request, waits 50 ms, and then
calls the Pico ROM `reset_usb_boot()` entry point. Physical BOOTSEL remains the
fallback if the firmware or USB management path is unavailable.
Flash alternatives: bootsel + drag-drop or `picotool load`.
Flags:
- `SWITCH_PICO_LOG`: enable/disable UART logging on the Pico.

View file

@ -8,6 +8,7 @@
#include "controller_profile.h"
#include "controller_profile_runtime.h"
#include "hardware/watchdog.h"
#include "pico/bootrom.h"
#include "tusb.h"
#include "usb_output_driver.h"
@ -19,6 +20,7 @@ constexpr uint32_t kInternalTransactionValueMask =
~kInternalTransactionBit;
constexpr uint32_t kFeedbackPhaseMs = 75;
constexpr uint32_t kFeedbackGuardMs = 75;
constexpr uint32_t kBootselRebootDelayMs = 50;
static_assert(
ADAPTER_MODE_CHORD_BUTTON_MASK ==
static_cast<uint16_t>(
@ -69,6 +71,9 @@ ModeOperation g_operation{};
uint32_t g_reboot_transaction_id = 0;
bool g_reboot_scheduled = false;
bool g_correlated_reboot_scheduled = false;
bool g_bootsel_reboot_requested = false;
bool g_bootsel_reboot_delay_started = false;
uint32_t g_bootsel_reboot_deadline_ms = 0;
AdapterRequestedMode g_requested_mode = AdapterRequestedMode::kAuto;
uint32_t g_next_internal_transaction_value = 1;
bool g_recovery_requested = false;
@ -298,6 +303,9 @@ void adapter_mode_controller_initialize_usb() {
g_recovery_failed = false;
g_recovery_clear_pairings_token = 0;
g_correlated_reboot_scheduled = false;
g_bootsel_reboot_requested = false;
g_bootsel_reboot_delay_started = false;
g_bootsel_reboot_deadline_ms = 0;
configuration_service_initialize_pre_usb();
ConfigurationServiceSnapshot snapshot{};
@ -383,6 +391,18 @@ void adapter_mode_controller_process_input(
}
void adapter_mode_controller_task(uint32_t now_ms) {
if (g_bootsel_reboot_requested) {
if (!g_bootsel_reboot_delay_started) {
g_bootsel_reboot_delay_started = true;
g_bootsel_reboot_deadline_ms =
now_ms + kBootselRebootDelayMs;
} else if (deadline_reached(
now_ms, g_bootsel_reboot_deadline_ms)) {
g_bootsel_reboot_requested = false;
reset_usb_boot(0, 0);
}
return;
}
if (g_reboot_scheduled) {
return;
}
@ -449,3 +469,13 @@ bool adapter_reboot_for_mode_transaction(uint32_t transaction_id) {
reboot_now();
return true;
}
bool adapter_reboot_to_bootsel() {
if (g_reboot_scheduled) {
return g_bootsel_reboot_requested;
}
g_reboot_scheduled = true;
g_bootsel_reboot_requested = true;
controller_profile_runtime_reset();
return true;
}

View file

@ -5,3 +5,7 @@
// Correlated normal reboot entry point for management opcode 0x03. Only a
// successful host mode transaction can reset profile runtime and reboot.
bool adapter_reboot_for_mode_transaction(uint32_t transaction_id);
// Schedules a direct ROM USB BOOTSEL reboot after the current endpoint-zero
// control transfer has completed.
bool adapter_reboot_to_bootsel();

Binary file not shown.

Binary file not shown.

View file

@ -38,6 +38,7 @@ HOST_TRANSACTION_ID_MASK = 0x7FFFFFFF
OP_INFO = 0x01
OP_MODE_SET = 0x02
OP_REBOOT = 0x03
OP_BOOTSEL_REBOOT = 0x04
OP_CONFIGURATION_READ = 0x10
OP_CONFIGURATION_BEGIN = 0x11
OP_CONFIGURATION_CHUNK = 0x12
@ -1512,6 +1513,9 @@ def request_reboot(device: UsbDevice, transaction_id: int) -> None:
)
_control_out(device, OP_REBOOT, struct.pack("<I", transaction_id))
def request_bootsel_reboot(device: UsbDevice) -> None:
_control_out(device, OP_BOOTSEL_REBOOT)
def _mode_is_active(requested_mode: int, active_mode: int) -> bool:
if requested_mode == REQUESTED_MODE_AUTO:
@ -2169,6 +2173,10 @@ def build_parser() -> argparse.ArgumentParser:
commands.add_parser(
"diagnostics", help="show live Bluetooth and rumble pipeline counters"
)
reboot = commands.add_parser(
"reboot", help="reboot into a firmware or ROM target"
)
reboot.add_argument("target", choices=("bootsel",))
mode = commands.add_parser("mode", help="select the persistent USB mode")
mode.add_argument("mode", choices=SELECTABLE_MODE_NAMES)
@ -2319,6 +2327,9 @@ def main(argv: Sequence[str] | None = None) -> int:
"Rumble-pending slots: "
f"{diagnostics.rumble_pending_slots}"
)
elif args.command == "reboot":
request_bootsel_reboot(device)
print("Rebooting into USB BOOTSEL mode.")
elif args.command == "mode":
requested_mode = REQUESTED_MODE_NAMES.index(args.mode)
_, changed = configure_mode(device, requested_mode, args.timeout)

View file

@ -29,6 +29,7 @@ enum class Call : uint8_t {
kClearPairings,
kPairingSnapshot,
kWatchdogReboot,
kBootselReboot,
};
std::vector<Call> calls;
@ -61,6 +62,9 @@ ControllerProfileConfirmationPolicy feedback_policy =
int reboot_count = 0;
int runtime_reset_count = 0;
int clear_pairings_count = 0;
int bootsel_reboot_count = 0;
uint32_t bootsel_gpio_mask = UINT32_MAX;
uint32_t bootsel_disable_mask = UINT32_MAX;
bool recovery_reserved = false;
bool abandoned_host_receive = false;
bool abandoned_host_receive_canceled = false;
@ -101,6 +105,9 @@ void reset_harness(AdapterRequestedMode requested_mode =
reboot_count = 0;
runtime_reset_count = 0;
clear_pairings_count = 0;
bootsel_reboot_count = 0;
bootsel_gpio_mask = UINT32_MAX;
bootsel_disable_mask = UINT32_MAX;
recovery_reserved = false;
abandoned_host_receive = false;
abandoned_host_receive_canceled = false;
@ -553,6 +560,29 @@ void test_auto_xinput_disconnect_reboots_to_probe() {
"Auto Switch probe rebooted after USB unmount");
}
void test_bootsel_reboot_is_delayed_and_idempotent() {
reset_harness(AdapterRequestedMode::kAuto);
require(adapter_reboot_to_bootsel() &&
adapter_reboot_to_bootsel() &&
runtime_reset_count == 1 &&
bootsel_reboot_count == 0 && reboot_count == 0,
"BOOTSEL request was not accepted exactly once");
adapter_mode_controller_task(UINT32_MAX - 25u);
adapter_mode_controller_task(23);
require(bootsel_reboot_count == 0,
"BOOTSEL reboot occurred before the control-transfer guard");
adapter_mode_controller_task(24);
adapter_mode_controller_task(25);
require(bootsel_reboot_count == 1 &&
bootsel_gpio_mask == 0 &&
bootsel_disable_mask == 0 &&
reboot_count == 0 &&
!adapter_reboot_for_mode_transaction(91),
"BOOTSEL reboot was not delayed, one-shot, or isolated from "
"the correlated watchdog path");
}
} // namespace
void configuration_service_initialize_pre_usb() {
@ -669,6 +699,14 @@ void watchdog_reboot(uint32_t, uint32_t, uint32_t) {
++reboot_count;
}
void reset_usb_boot(uint32_t usb_activity_gpio_pin_mask,
uint32_t disable_interface_mask) {
calls.push_back(Call::kBootselReboot);
++bootsel_reboot_count;
bootsel_gpio_mask = usb_activity_gpio_pin_mask;
bootsel_disable_mask = disable_interface_mask;
}
int main() {
test_pre_tusb_ordering_and_configured_selection();
test_mode_availability_has_one_stable_value();
@ -681,5 +719,6 @@ int main() {
test_recovery_auto_wins_host_mode_interleaving();
test_failed_recovery_never_reboots();
test_auto_xinput_disconnect_reboots_to_probe();
test_bootsel_reboot_is_delayed_and_idempotent();
return 0;
}

View file

@ -0,0 +1,6 @@
#pragma once
#include <stdint.h>
void reset_usb_boot(uint32_t usb_activity_gpio_pin_mask,
uint32_t disable_interface_mask);

View file

@ -58,6 +58,7 @@ class FakeDevice:
self.mode_pending_reads = 0
self.fail_mode_status: int | None = None
self.reboot_transaction_ids: list[int] = []
self.bootsel_reboot_requested = False
self.records = [
(
config_manager.TRANSPORT_CLASSIC,
@ -392,6 +393,9 @@ class FakeDevice:
assert reboot_transaction_id == self.transaction_id
assert self.transaction_status == config_manager.STATUS_OK
self.reboot_transaction_ids.append(reboot_transaction_id)
elif request == config_manager.OP_BOOTSEL_REBOOT:
assert payload == b""
self.bootsel_reboot_requested = True
elif request == config_manager.OP_PAIRING_REFRESH:
self.pairing_generation += 1
elif request == config_manager.OP_PAIRING_CLEAR:
@ -660,6 +664,15 @@ def test_mode_envelopes_and_host_side_validation(
config_manager.OP_REBOOT, reboot_payload
)
config_manager.request_bootsel_reboot(device)
operation, payload, encoded = device.out_requests[-1]
assert operation == config_manager.OP_BOOTSEL_REBOOT
assert payload == b""
assert encoded == config_manager.encode_request(
config_manager.OP_BOOTSEL_REBOOT
)
assert device.bootsel_reboot_requested
for transaction_id in (0, 0x80000000, True):
with pytest.raises(config_manager.ConfigManagerError):
config_manager.request_reboot(device, transaction_id)
@ -1687,6 +1700,20 @@ def test_mode_cli_changes_then_noops(
assert reenumerated.out_requests == []
def test_bootsel_reboot_cli(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
device = FakeDevice()
monkeypatch.setattr(
config_manager, "_candidate_devices", lambda: (device,)
)
assert config_manager.main(["reboot", "bootsel"]) == 0
assert capsys.readouterr().out == "Rebooting into USB BOOTSEL mode.\n"
assert device.bootsel_reboot_requested
def test_mode_parser_accepts_all_implemented_modes() -> None:
for mode in config_manager.REQUESTED_MODE_NAMES:
args = config_manager.build_parser().parse_args(["mode", mode])

View file

@ -30,6 +30,7 @@ uint32_t mode_set_call_count = 0;
uint32_t correlated_reboot_transaction_id = 0;
uint32_t reboot_transaction_id = 0;
uint32_t reboot_call_count = 0;
bool bootsel_reboot_requested = false;
bool refresh_requested = false;
bool clear_requested = false;
Bluepad32BackendDiagnostics current_diagnostics{};
@ -221,6 +222,9 @@ void test_vendor_requests() {
perform_out(Operation::kPairingRefresh, {});
require(refresh_requested,
"pairing refresh was not dispatched");
perform_out(Operation::kBootselReboot, {});
require(bootsel_reboot_requested,
"BOOTSEL reboot request was not dispatched");
perform_out(Operation::kPairingClear, {});
require(clear_requested, "pairing clear was not dispatched");
@ -756,6 +760,11 @@ void bluepad32_input_backend_diagnostics(
*out = current_diagnostics;
}
bool adapter_reboot_to_bootsel() {
bootsel_reboot_requested = true;
return true;
}
bool adapter_host_probe_vendor_control(
uint8_t, uint8_t, const tusb_control_request_t*) {
return false;

View file

@ -79,6 +79,8 @@ bool valid_out_size(Operation operation, size_t size) {
return size == kRequestHeaderSize + 5;
case Operation::kReboot:
return size == kRequestHeaderSize + 4;
case Operation::kBootselReboot:
return size == kRequestHeaderSize;
case Operation::kConfigurationBegin:
return size == kRequestHeaderSize + 12;
case Operation::kProfileBegin:
@ -385,6 +387,8 @@ bool process_out_request() {
}
return adapter_reboot_for_mode_transaction(transaction_id);
}
case Operation::kBootselReboot:
return adapter_reboot_to_bootsel();
case Operation::kConfigurationBegin:
configuration_service_begin(
static_cast<uint32_t>(payload[0]) |

View file

@ -32,6 +32,7 @@ enum class Operation : uint8_t {
kInfo = 0x01,
kModeSet = 0x02,
kReboot = 0x03,
kBootselReboot = 0x04,
kConfigurationRead = 0x10,
kConfigurationBegin = 0x11,
kConfigurationChunk = 0x12,