From 5cc7719ae47ddae687d136e32f1ee35de4ffd81d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 May 2026 00:30:06 -0500 Subject: [PATCH] [cli] Use pio run -t idedata to set up libretiny penv on cold hosts The prior commit invoked 'pio pkg install' for the on-demand prep step, which installs the libretiny platform package but does NOT recreate the ~/.platformio/penv/.libretiny/ virtualenv -- that's set up by libretiny's ConfigurePythonVenv SCons step, which only fires during 'pio run'. So a cold host (penv missing) still failed to find ltchiptool after the install returned 'Already up-to-date'. Wet test against bw15-device.yaml on a host with the libretiny penv deleted: esphome upload bw15-device.yaml --device /dev/null --prebuilt-dir ... INFO ltchiptool not found on this host; installing the PlatformIO rtl87xx platform package ... Resolving bw15-device dependencies... Already up-to-date. ERROR ltchiptool not found. ... Replace pkg install with 'pio run -t idedata'. The idedata target runs SConscript without the actual compile target, so penv creation (libretiny) and tool-package install (RP2040) happen as side effects of the SCons configure phase -- but the build itself is skipped. Cost is a few seconds of SCons configure, paid once per cold host. Rename run_pkg_install -> prepare_platform_for_upload to match the new semantics. Issue: esphome/device-builder#572 Issue: esphome/device-builder#570 --- esphome/__main__.py | 9 ++++++--- esphome/platformio_api.py | 28 +++++++++++++++++----------- tests/unit_tests/test_main.py | 16 ++++++++-------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6d31a01eb9..06336c842c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1264,8 +1264,8 @@ def _ensure_platform_packages_for_prebuilt_upload(config: ConfigType) -> int: return 0 _LOGGER.info( - "%s not found on this host; installing the PlatformIO %s " - "platform package so the upload can flash the prebuilt firmware...", + "%s not found on this host; configuring the PlatformIO %s " + "platform so the upload can flash the prebuilt firmware...", tool_label, CORE.target_platform, ) @@ -1275,7 +1275,10 @@ def _ensure_platform_packages_for_prebuilt_upload(config: ConfigType) -> int: from esphome import platformio_api - return platformio_api.run_pkg_install(config, CORE.verbose) + result = platformio_api.prepare_platform_for_upload(config, CORE.verbose) + # prepare_platform_for_upload returns str on capture_stdout=True or int + # on success/failure; in our call we don't capture stdout, so it's int. + return result if isinstance(result, int) else 0 def upload_program( diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index b3619c7be4..7f56ee86f7 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -84,20 +84,26 @@ 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. +def prepare_platform_for_upload(config, verbose) -> str | int: + """Configure the PlatformIO build environment for ``CORE.name`` without + compiling, so platform-specific flashing tools end up on disk. 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. + compiled the target platform locally. ``pio pkg install`` alone isn't + enough on libretiny: the platform package downloads cleanly, but + ``ltchiptool`` lives in a platform-managed virtualenv at + ``~/.platformio/penv/.libretiny/`` that's only created by libretiny's + ``ConfigurePythonVenv`` SCons step, which runs during ``pio run`` (not + ``pio pkg install``). So we run ``pio run -t idedata`` instead: it + triggers SConscript -- creating the penv on libretiny, installing the + picotool tool package on RP2040 -- but skips the actual compile target + so this is much cheaper than a full build. + + The idedata JSON gets emitted to stdout as a side effect of the + target; we don't filter it out -- the install runs once per cold host + and the trailing JSON blob is harmless noise. """ - command = ["pkg", "install", "-e", CORE.name, "-d", str(CORE.build_path)] - if verbose: - command += ["-v"] - return run_platformio_cli(*command) + return run_platformio_cli_run(config, verbose, "-t", "idedata") def _run_idedata(config): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e1e7d6dd67..7162b72947 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1621,8 +1621,8 @@ def test_upload_program_prebuilt_dir_installs_libretiny_platform_if_missing( 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, + "esphome.platformio_api.prepare_platform_for_upload", return_value=0 + ) as mock_prep, patch("esphome.__main__.upload_using_ltchiptool", return_value=0), ): exit_code, _ = upload_program({}, args, ["/dev/ttyUSB0"]) @@ -1630,7 +1630,7 @@ def test_upload_program_prebuilt_dir_installs_libretiny_platform_if_missing( assert exit_code == 0 mock_find.assert_called() mock_write_cpp.assert_called_once() - mock_pkg_install.assert_called_once() + mock_prep.assert_called_once() def test_upload_program_prebuilt_dir_skips_install_when_tool_present( @@ -1655,14 +1655,14 @@ def test_upload_program_prebuilt_dir_skips_install_when_tool_present( 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.platformio_api.prepare_platform_for_upload") as mock_prep, 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() + mock_prep.assert_not_called() def test_upload_program_prebuilt_dir_write_cpp_failure_aborts_upload( @@ -1684,14 +1684,14 @@ def test_upload_program_prebuilt_dir_write_cpp_failure_aborts_upload( with ( patch("esphome.__main__.get_ltchiptool_path", return_value=None), patch("esphome.__main__.write_cpp", return_value=3), - patch("esphome.platformio_api.run_pkg_install") as mock_pkg_install, + patch("esphome.platformio_api.prepare_platform_for_upload") as mock_prep, patch("esphome.__main__.upload_using_ltchiptool") as mock_upload, ): exit_code, host = upload_program({}, args, ["/dev/ttyUSB0"]) assert exit_code == 3 assert host is None - mock_pkg_install.assert_not_called() + mock_prep.assert_not_called() mock_upload.assert_not_called() @@ -1713,7 +1713,7 @@ def test_upload_program_prebuilt_dir_pkg_install_failure_aborts_upload( 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.platformio_api.prepare_platform_for_upload", return_value=2), patch("esphome.__main__.upload_using_picotool") as mock_upload, patch("esphome.__main__._rp2040_serial_reset_to_bootsel") as mock_reset, ):