[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
+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,