diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index f8aa9ee..18e4f06 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -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 diff --git a/README.md b/README.md index edfa369..6215526 100644 --- a/README.md +++ b/README.md @@ -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 don’t 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. diff --git a/adapter_mode_controller.cpp b/adapter_mode_controller.cpp index b74ad61..2c5c6c6 100644 --- a/adapter_mode_controller.cpp +++ b/adapter_mode_controller.cpp @@ -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( @@ -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; +} diff --git a/adapter_reboot.h b/adapter_reboot.h index 82fbfad..a1c7143 100644 --- a/adapter_reboot.h +++ b/adapter_reboot.h @@ -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(); diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 2ef08f6..01de644 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index fed55c0..55071c4 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 6bc3397..8944e63 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -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(" 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) diff --git a/tests/adapter_mode_controller_test.cpp b/tests/adapter_mode_controller_test.cpp index 7e2aeae..99bd61b 100644 --- a/tests/adapter_mode_controller_test.cpp +++ b/tests/adapter_mode_controller_test.cpp @@ -29,6 +29,7 @@ enum class Call : uint8_t { kClearPairings, kPairingSnapshot, kWatchdogReboot, + kBootselReboot, }; std::vector 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; } diff --git a/tests/mode_native_stubs/pico/bootrom.h b/tests/mode_native_stubs/pico/bootrom.h new file mode 100644 index 0000000..ed8f314 --- /dev/null +++ b/tests/mode_native_stubs/pico/bootrom.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +void reset_usb_boot(uint32_t usb_activity_gpio_pin_mask, + uint32_t disable_interface_mask); diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index ed3bfe7..72588bd 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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]) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 2c23479..9a6601a 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -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; diff --git a/usb_configuration_management.cpp b/usb_configuration_management.cpp index 661173e..40c5d72 100644 --- a/usb_configuration_management.cpp +++ b/usb_configuration_management.cpp @@ -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(payload[0]) | diff --git a/usb_configuration_management.h b/usb_configuration_management.h index 15c7562..2671ed4 100644 --- a/usb_configuration_management.h +++ b/usb_configuration_management.h @@ -32,6 +32,7 @@ enum class Operation : uint8_t { kInfo = 0x01, kModeSet = 0x02, kReboot = 0x03, + kBootselReboot = 0x04, kConfigurationRead = 0x10, kConfigurationBegin = 0x11, kConfigurationChunk = 0x12,