[cli] Flash RP2040 serial prebuilt-dir uploads via 1200bps touch + picotool

Mirror the libretiny/ltchiptool shape for RP2040 serial when
--prebuilt-dir is set: open the user-supplied serial port at 1200 baud
(arduino-pico's USB CDC interprets the open/close as a request to reboot
into BOOTSEL), poll picotool until the BOOTSEL device shows up on the
USB bus, then dispatch to upload_using_picotool with the prebuilt .uf2.

This removes the last path that required --prebuilt-dir to contain a
platformio.ini + .pioenvs/<name>/ tree, so upload_using_platformio is
now only invoked when no prebuilt dir is set (i.e. the existing
compile+upload flow on a developer machine).

Issue: esphome/device-builder#572
This commit is contained in:
J. Nick Koston
2026-05-10 23:04:18 -05:00
parent b8336cddf2
commit a6a0a404ae
2 changed files with 191 additions and 19 deletions
+62 -19
View File
@@ -1006,25 +1006,9 @@ def upload_using_ltchiptool(config: ConfigType, port: str) -> int:
def upload_using_platformio(config: ConfigType, port: str) -> int:
from esphome import platformio_api
# --prebuilt-dir for RP2040 serial routes PlatformIO at the prebuilt tree
# by repointing CORE.build_path. The dashboard must ship a build tree
# shape (platformio.ini plus .pioenvs/<name>/...) under --prebuilt-dir
# because `pio run -t upload -t nobuild` reads platformio.ini and the
# env-specific .pioenvs/<name> directory. Upload is terminal so mutating
# build_path here has no downstream effect.
# (LibreTiny serial avoids this path entirely when --prebuilt-dir is set
# by going through upload_using_ltchiptool; RP2040 BOOTSEL goes through
# upload_using_picotool.)
if CORE.prebuilt_dir is not None:
platformio_ini = CORE.prebuilt_dir / "platformio.ini"
if not platformio_ini.is_file():
raise EsphomeError(
f"--prebuilt-dir {CORE.prebuilt_dir} is missing platformio.ini. "
"RP2040 serial uploads re-invoke PlatformIO, so the prebuilt "
"directory must contain platformio.ini plus the env-specific "
".pioenvs/<name>/ build tree."
)
CORE.build_path = CORE.prebuilt_dir
# `upload_program` routes around this helper when --prebuilt-dir is set
# (libretiny→ltchiptool, RP2040→1200bps-touch+picotool), so PlatformIO
# is only invoked when a local build tree is available.
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
# the upload target, but 'nobuild' skips the build phase that creates it.
@@ -1128,6 +1112,57 @@ def upload_using_picotool(config: ConfigType) -> int:
return 0
def _rp2040_serial_reset_to_bootsel(port: str, timeout: float = 10.0) -> bool:
"""Reboot an arduino-pico RP2040 from running firmware into BOOTSEL mode.
arduino-pico's USB CDC handler treats a 1200bps "touch" (open the port at
1200 baud, then close it) as a request to reboot into the rp2040
bootloader, exposing the device as a picotool-loadable BOOTSEL endpoint.
This is the same mechanism the arduino-pico PlatformIO recipe uses for
serial uploads, lifted out so --prebuilt-dir uploads don't need to
re-invoke PlatformIO.
Returns True once a BOOTSEL device shows up on the USB bus.
"""
import serial
_LOGGER.info("Rebooting %s into BOOTSEL via 1200bps touch...", port)
try:
ser = serial.Serial()
ser.baudrate = 1200
ser.port = port
ser.open()
# Small wait so the firmware sees the open before we close it; on a
# fast host the open+close can otherwise happen inside a single USB
# frame and the touch is missed.
time.sleep(0.1)
ser.close()
except (OSError, serial.SerialException) as err:
_LOGGER.error("Failed to open %s at 1200 baud for BOOTSEL reset: %s", port, err)
return False
picotool = _find_picotool()
if picotool is None:
_LOGGER.error(
"picotool not found; cannot detect BOOTSEL after 1200bps touch. "
"Ensure the RP2040 PlatformIO platform is installed (%s).",
PICOTOOL_PACKAGE,
)
return False
start = time.monotonic()
while time.monotonic() - start < timeout:
if detect_rp2040_bootsel(picotool).device_count > 0:
return True
time.sleep(0.2)
_LOGGER.error(
"RP2040 did not enter BOOTSEL within %.0fs after 1200bps touch on %s.",
timeout,
port,
)
return False
def _wait_for_serial_port(
port: str | None = None,
timeout: float = 30.0,
@@ -1247,6 +1282,14 @@ def upload_program(
# is purely additive and existing libretiny serial flows are
# unchanged.
exit_code = upload_using_ltchiptool(config, host)
elif CORE.target_platform == PLATFORM_RP2040 and CORE.prebuilt_dir is not None:
# RP2040 serial + --prebuilt-dir: 1200bps-touch reboot into BOOTSEL,
# then flash with picotool. Avoids the PlatformIO build-tree
# requirement that upload_using_platformio imposes, mirroring the
# libretiny→ltchiptool path. Without --prebuilt-dir the original
# PlatformIO path is preserved for back-compat.
if _rp2040_serial_reset_to_bootsel(host):
exit_code = upload_using_picotool(config)
elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny:
exit_code = upload_using_platformio(config, host)
# else: Unknown target platform, exit_code remains 1
+129
View File
@@ -1641,6 +1641,135 @@ def test_upload_program_serial_upload_failed(
mock_upload_using_esptool.assert_called_once()
def test_upload_program_rp2040_serial_with_prebuilt_dir_uses_picotool(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,
mock_check_permissions: Mock,
tmp_path: Path,
) -> None:
"""RP2040 serial + --prebuilt-dir reboots into BOOTSEL via 1200bps
touch and flashes with picotool, avoiding upload_using_platformio's
build-tree requirement."""
setup_core(platform=PLATFORM_RP2040)
mock_get_port_type.return_value = "SERIAL"
mock_upload_using_picotool.return_value = 0
prebuilt = tmp_path / "prebuilt"
prebuilt.mkdir()
(prebuilt / "firmware.uf2").write_bytes(b"uf2")
args = MockArgs(prebuilt_dir=str(prebuilt))
devices = ["/dev/ttyACM0"]
with (
patch(
"esphome.__main__._rp2040_serial_reset_to_bootsel",
return_value=True,
) as mock_reset,
patch("esphome.__main__.upload_using_platformio") as mock_pio,
):
exit_code, host = upload_program({}, args, devices)
assert exit_code == 0
assert host == "/dev/ttyACM0"
mock_reset.assert_called_once_with("/dev/ttyACM0")
mock_upload_using_picotool.assert_called_once_with({})
mock_pio.assert_not_called()
def test_upload_program_rp2040_serial_with_prebuilt_dir_reset_fails(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,
mock_check_permissions: Mock,
tmp_path: Path,
) -> None:
"""If 1200bps touch fails to put the RP2040 into BOOTSEL, the upload
must abort (not fall through to a generic exit_code = 1 with no
diagnostic)."""
setup_core(platform=PLATFORM_RP2040)
mock_get_port_type.return_value = "SERIAL"
prebuilt = tmp_path / "prebuilt"
prebuilt.mkdir()
args = MockArgs(prebuilt_dir=str(prebuilt))
with patch("esphome.__main__._rp2040_serial_reset_to_bootsel", return_value=False):
exit_code, host = upload_program({}, args, ["/dev/ttyACM0"])
assert exit_code == 1
assert host is None
mock_upload_using_picotool.assert_not_called()
def test_upload_program_rp2040_serial_without_prebuilt_dir_uses_platformio(
mock_upload_using_platformio: Mock,
mock_get_port_type: Mock,
mock_check_permissions: Mock,
) -> None:
"""Regression guard: without --prebuilt-dir, RP2040 serial still goes
through upload_using_platformio. The 1200bps-touch path is purely
additive."""
setup_core(platform=PLATFORM_RP2040)
mock_get_port_type.return_value = "SERIAL"
mock_upload_using_platformio.return_value = 0
with patch("esphome.__main__._rp2040_serial_reset_to_bootsel") as mock_reset:
exit_code, _ = upload_program({}, MockArgs(), ["/dev/ttyACM0"])
assert exit_code == 0
mock_upload_using_platformio.assert_called_once_with({}, "/dev/ttyACM0")
mock_reset.assert_not_called()
def test_rp2040_serial_reset_to_bootsel_success(tmp_path: Path) -> None:
"""The 1200bps-touch helper opens then closes the port at 1200 baud
(arduino-pico's USB CDC interprets that as a request to enter BOOTSEL),
then polls picotool until a BOOTSEL device shows up."""
from esphome.__main__ import _rp2040_serial_reset_to_bootsel
from esphome.util import BootselResult
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
mock_serial_instance = MagicMock()
with (
patch("serial.Serial", return_value=mock_serial_instance),
patch("esphome.__main__._find_picotool", return_value=tmp_path / "picotool"),
patch(
"esphome.__main__.detect_rp2040_bootsel",
return_value=BootselResult(device_count=1),
),
):
assert _rp2040_serial_reset_to_bootsel("/dev/ttyACM0") is True
# The open/close at 1200 baud is what triggers the reset; verify both happened.
assert mock_serial_instance.baudrate == 1200
assert mock_serial_instance.port == "/dev/ttyACM0"
mock_serial_instance.open.assert_called_once()
mock_serial_instance.close.assert_called_once()
def test_rp2040_serial_reset_to_bootsel_no_bootsel(tmp_path: Path) -> None:
"""When BOOTSEL never appears (e.g. firmware doesn't implement the
1200bps touch handler) return False with a clear timeout log instead
of hanging."""
from esphome.__main__ import _rp2040_serial_reset_to_bootsel
from esphome.util import BootselResult
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
with (
patch("serial.Serial", return_value=MagicMock()),
patch("esphome.__main__._find_picotool", return_value=tmp_path / "picotool"),
patch(
"esphome.__main__.detect_rp2040_bootsel",
return_value=BootselResult(device_count=0),
),
):
# Short timeout so the test doesn't sit on the wall clock.
assert _rp2040_serial_reset_to_bootsel("/dev/ttyACM0", timeout=0.1) is False
def test_upload_program_bootsel(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,