diff --git a/esphome/__main__.py b/esphome/__main__.py index 491d6ffc699..7398d1ab79a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -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//...) under --prebuilt-dir - # because `pio run -t upload -t nobuild` reads platformio.ini and the - # env-specific .pioenvs/ 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// 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 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index f1b317c0e58..cc732c95f56 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -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,