mirror of
https://github.com/esphome/esphome.git
synced 2026-09-19 02:58:38 +00:00
[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:
+62
-19
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user