[cli] Auto-install PlatformIO platform when --prebuilt-dir needs its flash tool

Revert ltchiptool as a hard dependency so the vast majority of ESPHome
users (ESP32 / ESP8266 / nRF52 OTA) don't pay for libretiny tooling.
Instead, on-demand install just the PlatformIO platform package when a
--prebuilt-dir upload needs a flash tool that isn't on disk yet.

Mechanism: when upload_program receives --prebuilt-dir for a libretiny
or RP2040 target and the corresponding flash tool (ltchiptool / picotool)
isn't found by get_ltchiptool_path / _find_picotool, run the same prep
that 'esphome compile' would (write_cpp + write platformio.ini), then
invoke 'pio pkg install -e <env>' instead of 'pio run'. PlatformIO
downloads the platform without compiling, the flash tool ends up on disk,
and the upload dispatch picks it up on the next lookup.

After install, the tool path lookups (PATH + PIO penv for ltchiptool,
PIO packages dir for picotool) succeed and the upload helper takes over.
ESP32 / ESP8266 / nRF52 paths are unchanged (esptool is already bundled
in requirements.txt; smpclient too for nRF52 mcumgr).

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
This commit is contained in:
J. Nick Koston
2026-05-10 23:48:05 -05:00
parent 758189fe56
commit 7674170600
4 changed files with 180 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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