[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
This commit is contained in:
J. Nick Koston
2026-05-11 00:30:06 -05:00
parent aab7177f07
commit 5cc7719ae4
3 changed files with 31 additions and 22 deletions

View File

@@ -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(

View File

@@ -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):

View File

@@ -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,
):