mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 12:53:26 +00:00
[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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user