diff --git a/esphome/__main__.py b/esphome/__main__.py index 1f4d379bca..6d31a01eb9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1232,6 +1232,52 @@ def check_permissions(port: str): ) +def _ensure_platform_packages_for_prebuilt_upload(config: ConfigType) -> int: + """Make sure the per-platform flash tool is available before a + ``--prebuilt-dir`` upload dispatches. + + The libretiny + RP2040 prebuilt-dir paths use ``ltchiptool`` and + ``picotool`` respectively. Both binaries live inside the PlatformIO + platform package, not in esphome's Python deps. On a host that has + never compiled the target platform locally (the dashboard's + transparent-install scenario), they won't be on disk yet. + + Mirror what ``esphome compile`` would do up to the platform-install + step (codegen + write platformio.ini), then run ``pio pkg install`` + instead of ``pio run`` so the platform is downloaded without paying + for an actual compile. After this returns, the flash-tool lookups + (``get_ltchiptool_path``, ``_find_picotool``) succeed and the upload + helper takes over. + + Returns 0 if no install was needed or the install succeeded; non-zero + on failure so the upload bails out with a clear status. + """ + if CORE.is_libretiny: + if get_ltchiptool_path() is not None: + return 0 + tool_label = "ltchiptool" + elif CORE.target_platform == PLATFORM_RP2040: + if _find_picotool() is not None: + return 0 + tool_label = "picotool" + else: + return 0 + + _LOGGER.info( + "%s not found on this host; installing the PlatformIO %s " + "platform package so the upload can flash the prebuilt firmware...", + tool_label, + CORE.target_platform, + ) + rc = write_cpp(config) + if rc != 0: + return rc + + from esphome import platformio_api + + return platformio_api.run_pkg_install(config, CORE.verbose) + + def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> tuple[int, str | None]: @@ -1248,6 +1294,9 @@ def upload_program( if not prebuilt_path.is_dir(): raise EsphomeError(f"--prebuilt-dir {prebuilt_dir} is not a directory") CORE.prebuilt_dir = prebuilt_path + rc = _ensure_platform_packages_for_prebuilt_upload(config) + if rc != 0: + return rc, None try: module = importlib.import_module("esphome.components." + CORE.target_platform) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 7c063b57f3..b3619c7be4 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -84,6 +84,22 @@ def run_compile(config, verbose): return run_platformio_cli_run(config, verbose, *args) +def run_pkg_install(config, verbose) -> str | int: + """Install the PlatformIO platform + packages declared in platformio.ini + for ``CORE.name``'s env, without compiling. + + Used by ``esphome upload --prebuilt-dir`` on hosts that have never + compiled the target platform locally: the flash tools we need + (``ltchiptool`` for libretiny, ``picotool`` for RP2040) ship inside + the PlatformIO platform package, so we trigger a package install to + get them on disk without paying for a full compile. + """ + command = ["pkg", "install", "-e", CORE.name, "-d", str(CORE.build_path)] + if verbose: + command += ["-v"] + return run_platformio_cli(*command) + + def _run_idedata(config): args = ["-t", "idedata"] stdout = run_platformio_cli_run(config, False, *args, capture_stdout=True) diff --git a/requirements.txt b/requirements.txt index 28b298b79f..818453dc8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,6 @@ tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 -ltchiptool==4.14.1 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==44.23.0 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4dde900827..a0470b423c 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1428,6 +1428,11 @@ def test_upload_program_libretiny_serial_with_prebuilt_dir_uses_ltchiptool( devices = ["/dev/ttyUSB0"] with ( + # Pretend ltchiptool is already installed so we skip the pkg-install + # prep branch and exercise the dispatch we actually care about here. + patch( + "esphome.__main__.get_ltchiptool_path", return_value=tmp_path / "fake-lt" + ), patch("esphome.__main__.upload_using_ltchiptool", return_value=0) as mock_lt, patch("esphome.__main__.upload_using_platformio") as mock_pio, ): @@ -1591,6 +1596,105 @@ def test_upload_program_prebuilt_dir_sets_core_attr( assert CORE.prebuilt_dir == prebuilt +def test_upload_program_prebuilt_dir_installs_libretiny_platform_if_missing( + mock_get_port_type: Mock, + mock_check_permissions: Mock, + tmp_path: Path, +) -> None: + """The dashboard's transparent-install scenario: ltchiptool isn't on + disk yet (this host has never compiled a libretiny config locally), so + upload_program triggers `pio pkg install` to download the libretiny + platform package -- same prep as compile, no actual compile -- before + dispatching the upload helper.""" + setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path) + mock_get_port_type.return_value = "SERIAL" + + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + (prebuilt / "firmware.uf2").write_bytes(b"uf2") + args = MockArgs(prebuilt_dir=str(prebuilt)) + + with ( + # First call: ltchiptool missing -> install. After install, the + # upload_using_ltchiptool dispatch finds it (we mock that helper + # to skip the actual flash). + patch("esphome.__main__.get_ltchiptool_path", return_value=None) as mock_find, + patch("esphome.__main__.write_cpp", return_value=0) as mock_write_cpp, + patch( + "esphome.platformio_api.run_pkg_install", return_value=0 + ) as mock_pkg_install, + patch("esphome.__main__.upload_using_ltchiptool", return_value=0), + ): + exit_code, _ = upload_program({}, args, ["/dev/ttyUSB0"]) + + assert exit_code == 0 + mock_find.assert_called() + mock_write_cpp.assert_called_once() + mock_pkg_install.assert_called_once() + + +def test_upload_program_prebuilt_dir_skips_install_when_tool_present( + mock_get_port_type: Mock, + mock_check_permissions: Mock, + tmp_path: Path, +) -> None: + """When ltchiptool is already on disk (e.g. HA add-on dashboards with + PIO platforms pre-warmed), don't run codegen or pkg install -- that + would surprise the user with a heavy side effect on every upload.""" + setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path) + mock_get_port_type.return_value = "SERIAL" + + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + (prebuilt / "firmware.uf2").write_bytes(b"uf2") + args = MockArgs(prebuilt_dir=str(prebuilt)) + + with ( + patch( + "esphome.__main__.get_ltchiptool_path", + return_value=tmp_path / "ltchiptool", + ), + patch("esphome.__main__.write_cpp") as mock_write_cpp, + patch("esphome.platformio_api.run_pkg_install") as mock_pkg_install, + patch("esphome.__main__.upload_using_ltchiptool", return_value=0), + ): + exit_code, _ = upload_program({}, args, ["/dev/ttyUSB0"]) + + assert exit_code == 0 + mock_write_cpp.assert_not_called() + mock_pkg_install.assert_not_called() + + +def test_upload_program_prebuilt_dir_pkg_install_failure_aborts_upload( + mock_get_port_type: Mock, + mock_check_permissions: Mock, + tmp_path: Path, +) -> None: + """If `pio pkg install` fails (network error, registry outage, etc.), + bail out with that exit code rather than dispatching the upload helper + against an environment that's known to be missing the flash tool.""" + setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + mock_get_port_type.return_value = "SERIAL" + + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + args = MockArgs(prebuilt_dir=str(prebuilt)) + + with ( + patch("esphome.__main__._find_picotool", return_value=None), + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.platformio_api.run_pkg_install", return_value=2), + patch("esphome.__main__.upload_using_picotool") as mock_upload, + patch("esphome.__main__._rp2040_serial_reset_to_bootsel") as mock_reset, + ): + exit_code, host = upload_program({}, args, ["/dev/ttyACM0"]) + + assert exit_code == 2 + assert host is None + mock_upload.assert_not_called() + mock_reset.assert_not_called() + + def test_upload_program_prebuilt_dir_missing_raises( mock_get_port_type: Mock, mock_check_permissions: Mock, @@ -1697,6 +1801,11 @@ def test_upload_program_rp2040_serial_with_prebuilt_dir_uses_picotool( devices = ["/dev/ttyACM0"] with ( + # Pretend picotool is already installed so the pkg-install prep + # branch short-circuits and we exercise the dispatch we care about. + patch( + "esphome.__main__._find_picotool", return_value=tmp_path / "fake-picotool" + ), patch( "esphome.__main__._rp2040_serial_reset_to_bootsel", return_value=True, @@ -1728,7 +1837,12 @@ def test_upload_program_rp2040_serial_with_prebuilt_dir_reset_fails( prebuilt.mkdir() args = MockArgs(prebuilt_dir=str(prebuilt)) - with patch("esphome.__main__._rp2040_serial_reset_to_bootsel", return_value=False): + with ( + patch( + "esphome.__main__._find_picotool", return_value=tmp_path / "fake-picotool" + ), + patch("esphome.__main__._rp2040_serial_reset_to_bootsel", return_value=False), + ): exit_code, host = upload_program({}, args, ["/dev/ttyACM0"]) assert exit_code == 1