diff --git a/README.md b/README.md index 709f298..f84d942 100644 --- a/README.md +++ b/README.md @@ -2649,6 +2649,25 @@ 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. +The regular UART firmware has the same shortcut over its serial link. The +host sends the command frame `0xAA 0xFE 0x08 0x01 "BOOTSEL" checksum`; the +firmware verifies the checksum and magic, flushes UART TX, and calls +`reset_usb_boot()`. Line noise or a misframed report cannot trigger it. The +Pico can stay plugged into the Switch or a PC; only the UART adapter needs to +be connected to the host: + +```sh +# One-off reboot, then flash the published image +uv run controller-uart-bridge --reboot-bootsel /dev/ttyUSB0 +picotool load -v -x firmware/switch-pico.uf2 + +# Build, publish, reboot the running Pico and flash in one step +uv run python build.py --uart-port /dev/ttyUSB0 +``` + +Firmware from before this command ignores the frame; hold BOOTSEL while +replugging once to install a build that has it. + Flash alternatives: bootsel + drag-drop or `picotool load`. Flags: - `SWITCH_PICO_LOG`: enable/disable UART logging on the Pico. diff --git a/build.py b/build.py index e587733..da14fe9 100755 --- a/build.py +++ b/build.py @@ -8,6 +8,7 @@ import re import shutil import subprocess import sys +import time from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent @@ -309,9 +310,19 @@ def parse_args(): metavar="RRGGBB", help="Set every emulated controller slot to the provided hex color.", ) + parser.add_argument( + "--uart-port", + metavar="PORT", + help=( + "Serial port of a running regular (UART) Pico. Sends the BOOTSEL reboot " + "command before flashing so the button does not need to be held." + ), + ) args = parser.parse_args() if args.wake_capture and (args.random_grip_color or args.grip_color): parser.error("wake capture firmware does not use grip-color options") + if args.uart_port and (args.aio or args.wake_capture or args.wake_only): + parser.error("--uart-port only applies to the regular UART firmware") if args.wake_only: if args.random_grip_color or args.grip_color is not None: parser.error("wake-only firmware does not use grip-color options") @@ -522,7 +533,41 @@ def build_wake_only(): return elf_path -def flash(elf_path, allow_elf_override): +BOOTSEL_ENUMERATION_TIMEOUT_S = 10.0 + + +def reboot_uart_pico_to_bootsel(port): + """Ask the running UART firmware to reboot into BOOTSEL and wait for the loader.""" + from switch_pico_bridge.switch_pico_uart import PicoUART + + try: + uart = PicoUART(port) + except Exception as exc: + sys.stderr.write(f"Error: Cannot open UART {port}: {exc}\n") + sys.exit(1) + try: + uart.reboot_bootsel() + finally: + uart.close() + print(f"Sent BOOTSEL reboot to {port}; waiting for the ROM loader to enumerate...") + picotool = resolve_picotool() + deadline = time.monotonic() + BOOTSEL_ENUMERATION_TIMEOUT_S + while True: + result = subprocess.run( + [str(picotool), "info"], cwd=SCRIPT_DIR, capture_output=True, text=True + ) + if result.returncode == 0: + return + if time.monotonic() >= deadline: + sys.stderr.write( + "Error: No BOOTSEL device appeared after the reboot command. " + "Check the UART wiring/port or hold BOOTSEL while replugging.\n" + ) + sys.exit(1) + time.sleep(0.5) + + +def flash(elf_path, allow_elf_override, uart_port=None): picotool = resolve_picotool() if not elf_path.exists(): if allow_elf_override: @@ -532,6 +577,8 @@ def flash(elf_path, allow_elf_override): else: sys.stderr.write(f"Error: Cannot find ELF at {elf_path}.\n") sys.exit(1) + if uart_port: + reboot_uart_pico_to_bootsel(uart_port) run_cmd([str(picotool), "load", str(elf_path), "-fx"]) @@ -599,6 +646,7 @@ def main(): flash( elf_path, allow_elf_override=not args.aio, + uart_port=args.uart_port, ) diff --git a/firmware/switch-pico.elf b/firmware/switch-pico.elf index 05d3caf..6b6b9d9 100755 Binary files a/firmware/switch-pico.elf and b/firmware/switch-pico.elf differ diff --git a/firmware/switch-pico.uf2 b/firmware/switch-pico.uf2 index a28754b..a27f842 100644 Binary files a/firmware/switch-pico.uf2 and b/firmware/switch-pico.uf2 differ diff --git a/src/firmware/main.cpp b/src/firmware/main.cpp index 7384974..a21858c 100644 --- a/src/firmware/main.cpp +++ b/src/firmware/main.cpp @@ -1,4 +1,5 @@ #include +#include #include "bsp/board.h" #include "pico/stdlib.h" #include "tusb.h" @@ -6,6 +7,7 @@ #include "usb/usb_output_driver.h" #ifndef SWITCH_PICO_BLUEPAD32 #include "hardware/uart.h" +#include "pico/bootrom.h" #else #include "adapter/adapter_mode_controller.h" #include "input/bluepad32_input_backend.h" @@ -34,6 +36,9 @@ #define UART_RX_PIN 5 #define UART_RUMBLE_HEADER 0xBB #define UART_RUMBLE_TYPE_SLOT 0x03 +// Host -> Pico command frame: 0xAA 0xFE payload_len payload... checksum. +#define UART_COMMAND_VERSION 0xFE +#define UART_COMMAND_REBOOT_BOOTSEL 0x01 #endif #ifdef SWITCH_PICO_BLUEPAD32 @@ -106,6 +111,29 @@ static void on_rumble_from_usb(uint8_t instance, } #ifndef SWITCH_PICO_BLUEPAD32 +// Command frames share the report framing. The only command reboots into the +// ROM BOOTSEL loader; it must carry the "BOOTSEL" magic so line noise or a +// mis-framed report can never trigger it. +static void handle_uart_command(const uint8_t* frame, uint8_t length) { + static const uint8_t kBootselMagic[7] = {'B', 'O', 'O', 'T', 'S', 'E', 'L'}; + uint8_t sum = 0; + for (uint8_t i = 0; i + 1 < length; ++i) { + sum = static_cast(sum + frame[i]); + } + if (sum != frame[length - 1]) { + return; + } + const uint8_t payload_len = frame[2]; + const uint8_t* payload = frame + 3; + if (payload_len == 1 + sizeof(kBootselMagic) && + payload[0] == UART_COMMAND_REBOOT_BOOTSEL && + memcmp(payload + 1, kBootselMagic, sizeof(kBootselMagic)) == 0) { + LOG_PRINTF("[UART] reboot to BOOTSEL requested\n"); + uart_tx_wait_blocking(UART_ID); + reset_usb_boot(0, 0); + } +} + // Consume UART bytes and forward complete frames to the Switch Pro driver. static bool poll_uart_frames() { static uint8_t buffer[64]; @@ -150,6 +178,12 @@ static bool poll_uart_frames() { } if (expected_len > 0 && index >= expected_len) { + if (buffer[1] == UART_COMMAND_VERSION) { + handle_uart_command(buffer, expected_len); + index = 0; + expected_len = 0; + continue; + } ControllerState parsed{}; uint8_t slot = 0; if (switch_pro_apply_uart_packet(buffer, expected_len, parsed, slot)) { diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index 6cb8ec9..287b1d6 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -721,6 +721,15 @@ def build_arg_parser() -> argparse.ArgumentParser: action="store_true", help="List serial ports, descriptions, and manufacturers, then exit. Uses port filters and --all-ports.", ) + parser.add_argument( + "--reboot-bootsel", + metavar="PORT", + help=( + "Send the BOOTSEL reboot command to the Pico on PORT and exit, so it can be " + "flashed with picotool without holding the button. Requires UART firmware " + "with command support." + ), + ) parser.add_argument( "--frequency", type=float, @@ -1147,6 +1156,23 @@ def list_serial_ports(console: Console, args: argparse.Namespace) -> None: console.print(table) +def reboot_pico_bootsel(port: str, baud: int, console: Console) -> None: + """Send the BOOTSEL reboot command over UART; the port vanishes on success.""" + try: + uart = PicoUART(port, baud) + except Exception as exc: + console.print(f"[red]Failed to open UART {port}: {exc}[/red]") + raise SystemExit(1) from exc + try: + uart.reboot_bootsel() + finally: + uart.close() + console.print( + f"[green]Sent BOOTSEL reboot to {port}; wait for the RPI-RP2 device, then " + "run picotool load -v -x .[/green]" + ) + + def resolve_mapping_slots( mappings: List[Tuple[int, str, Optional[int]]], slots_per_port: int, @@ -1866,6 +1892,9 @@ def main() -> None: if args.list_ports: list_serial_ports(console, args) return + if args.reboot_bootsel: + reboot_pico_bootsel(args.reboot_bootsel, args.baud, console) + return config = build_bridge_config(console, args) initialize_sdl(parser) contexts: Dict[int, ControllerContext] = {} diff --git a/src/switch_pico_bridge/switch_pico_uart.py b/src/switch_pico_bridge/switch_pico_uart.py index 3de62f2..1da6633 100644 --- a/src/switch_pico_bridge/switch_pico_uart.py +++ b/src/switch_pico_bridge/switch_pico_uart.py @@ -28,6 +28,10 @@ from serial.tools import list_ports, list_ports_common UART_HEADER = 0xAA UART_PROTOCOL_VERSION = 0x03 +# Command frames reuse the report framing with this version byte. +UART_COMMAND_VERSION = 0xFE +UART_COMMAND_REBOOT_BOOTSEL = 0x01 +UART_BOOTSEL_MAGIC = b"BOOTSEL" RUMBLE_HEADER = 0xBB # Legacy 5-byte frame (no slot) from firmware before multi-controller support. RUMBLE_TYPE_DECODED = 0x02 @@ -301,6 +305,22 @@ class PicoUART: """Send a controller report to one of the Pico's controller slots.""" self.serial.write(report.to_bytes(slot)) + @staticmethod + def reboot_bootsel_frame() -> bytes: + """Command frame that makes the UART firmware reboot into ROM BOOTSEL.""" + payload = bytes([UART_COMMAND_REBOOT_BOOTSEL]) + UART_BOOTSEL_MAGIC + frame = bytes([UART_HEADER, UART_COMMAND_VERSION, len(payload)]) + payload + return frame + bytes([compute_checksum(frame)]) + + def reboot_bootsel(self) -> None: + """Ask the Pico to reboot into BOOTSEL so picotool can flash it. + + The serial device disappears once the Pico reboots; callers should + close this object afterwards and wait for the BOOTSEL USB device. + """ + self.serial.write(self.reboot_bootsel_frame()) + self.serial.flush() + def read_rumble(self) -> Optional[Tuple[int, float, float]]: """ Extract one decoded rumble frame as (slot, low, high) with magnitudes diff --git a/tests/test_uart_protocol.py b/tests/test_uart_protocol.py index b828202..b23714f 100644 --- a/tests/test_uart_protocol.py +++ b/tests/test_uart_protocol.py @@ -217,3 +217,13 @@ def test_rumble_frame_with_out_of_range_slot_is_skipped(): uart, _ = make_uart(make_rumble_frame(1, 2, slot=UART_SLOT_COUNT) + make_rumble_frame(3, 4)) assert uart.read_rumble() == pytest.approx((0, 3 / 255.0, 4 / 255.0)) + + +def test_reboot_bootsel_frame_matches_firmware_contract(): + """0xAA 0xFE len(8) cmd(1) 'BOOTSEL' checksum: 12 bytes, the parser's minimum frame.""" + frame = PicoUART.reboot_bootsel_frame() + assert frame[:3] == bytes([UART_HEADER, 0xFE, 8]) + assert frame[3] == 0x01 + assert frame[4:11] == b"BOOTSEL" + assert len(frame) == 12 + assert frame[-1] == compute_checksum(frame[:-1])