Add UART BOOTSEL reboot command to the regular firmware

The bridge sends 0xAA 0xFE 0x08 0x01 'BOOTSEL' ck; the firmware verifies
checksum and magic and calls reset_usb_boot(). Exposed as
controller-uart-bridge --reboot-bootsel PORT and build.py --uart-port PORT,
which waits for the ROM loader before running picotool.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-22 10:41:51 -06:00
commit 6e0676d9df
8 changed files with 161 additions and 1 deletions

View file

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

View file

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

Binary file not shown.

Binary file not shown.

View file

@ -1,4 +1,5 @@
#include <stdio.h>
#include <string.h>
#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<uint8_t>(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)) {

View file

@ -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 <image>.[/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] = {}

View file

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

View file

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