diff --git a/esphome/__main__.py b/esphome/__main__.py index 2894eb6742..491d6ffc69 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -68,6 +68,7 @@ from esphome.util import ( PICOTOOL_PACKAGE, FlashImage, detect_rp2040_bootsel, + get_ltchiptool_path, get_picotool_path, get_serial_ports, is_picotool_usb_permission_error, @@ -965,21 +966,61 @@ def upload_using_esptool( return run_esptool(115200) +def upload_using_ltchiptool(config: ConfigType, port: str) -> int: + """Upload a libretiny ``.uf2`` directly via ``ltchiptool flash write``. + + Bypasses PlatformIO so the dashboard's transparent install can flash a + libretiny device from just a prebuilt ``.uf2`` and a serial port; no + local build tree, ``platformio.ini`` or libretiny package install on the + machine running ``esphome upload`` is required (ltchiptool itself is + still required, but it ships with the libretiny PlatformIO platform or + can be ``pip install``-ed independently). + + Chip family is auto-detected from the UF2 header so we don't need to + plumb through ``CORE.data[KEY_LIBRETINY][KEY_FAMILY]``. + """ + firmware = CORE.firmware_bin + if not firmware.is_file(): + _LOGGER.error( + "LibreTiny firmware file not found at %s. " + "Make sure the project has been compiled first or that " + "--prebuilt-dir points at a directory containing firmware.uf2.", + firmware, + ) + return 1 + + ltchiptool = get_ltchiptool_path() + if ltchiptool is None: + _LOGGER.error( + "ltchiptool not found. Install the libretiny PlatformIO platform " + "(esphome compile with a libretiny board pulls it in) or " + "run `pip install ltchiptool` in this environment." + ) + return 1 + + _LOGGER.info("Uploading firmware to LibreTiny device via ltchiptool...") + cmd = [str(ltchiptool), "flash", "write", "-d", port, str(firmware)] + return run_external_process(*cmd) + + def upload_using_platformio(config: ConfigType, port: str) -> int: from esphome import platformio_api - # --prebuilt-dir for libretiny / RP2040 serial routes PlatformIO at the - # prebuilt tree by repointing CORE.build_path. The dashboard must ship a - # build tree shape (platformio.ini plus .pioenvs//...) under - # --prebuilt-dir for this path because PlatformIO needs platformio.ini and - # the env-specific .pioenvs/ directory to run -t upload -t nobuild. - # Upload is terminal so mutating build_path here has no downstream effect. + # --prebuilt-dir for RP2040 serial routes PlatformIO at the prebuilt tree + # by repointing CORE.build_path. The dashboard must ship a build tree + # shape (platformio.ini plus .pioenvs//...) under --prebuilt-dir + # because `pio run -t upload -t nobuild` reads platformio.ini and the + # env-specific .pioenvs/ directory. Upload is terminal so mutating + # build_path here has no downstream effect. + # (LibreTiny serial avoids this path entirely when --prebuilt-dir is set + # by going through upload_using_ltchiptool; RP2040 BOOTSEL goes through + # upload_using_picotool.) if CORE.prebuilt_dir is not None: platformio_ini = CORE.prebuilt_dir / "platformio.ini" if not platformio_ini.is_file(): raise EsphomeError( f"--prebuilt-dir {CORE.prebuilt_dir} is missing platformio.ini. " - "Uploads on this platform re-invoke PlatformIO, so the prebuilt " + "RP2040 serial uploads re-invoke PlatformIO, so the prebuilt " "directory must contain platformio.ini plus the env-specific " ".pioenvs// build tree." ) @@ -1199,6 +1240,13 @@ def upload_program( if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) + elif CORE.is_libretiny and CORE.prebuilt_dir is not None: + # Dashboard transparent-install case: flash a prebuilt .uf2 with + # ltchiptool directly so the build tree isn't required. Without + # --prebuilt-dir, the normal PlatformIO path is still used so this + # is purely additive and existing libretiny serial flows are + # unchanged. + exit_code = upload_using_ltchiptool(config, host) elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 diff --git a/esphome/util.py b/esphome/util.py index 39ce7c0963..97428af233 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -5,6 +5,7 @@ import io import logging from pathlib import Path import re +import shutil import subprocess import sys from typing import TYPE_CHECKING, Any @@ -401,6 +402,39 @@ def get_serial_ports() -> list[SerialPort]: PICOTOOL_PACKAGE = "tool-picotool-rp2040-earlephilhower" +# PlatformIO ships ltchiptool inside a libretiny-specific virtualenv under +# `~/.platformio/penv/.libretiny/`, separate from the toolchain packages +# layout that picotool uses, so it needs its own lookup helper. +LTCHIPTOOL_PIO_PENV_NAME = ".libretiny" + + +def get_ltchiptool_path() -> Path | None: + """Find the ltchiptool executable. + + Order: + 1. ``ltchiptool`` on PATH (pip-installed system-wide or in a virtualenv). + 2. PlatformIO's libretiny penv at ``~/.platformio/penv/.libretiny/bin`` + (where platform-libretiny installs it during its package init). + + Returns None if neither is available; callers should surface an + actionable error pointing the user at one of those install paths. + """ + on_path = shutil.which("ltchiptool") + if on_path is not None: + return Path(on_path) + binary_name = "ltchiptool.exe" if sys.platform == "win32" else "ltchiptool" + pio_penv = ( + Path.home() + / ".platformio" + / "penv" + / LTCHIPTOOL_PIO_PENV_NAME + / "bin" + / binary_name + ) + if pio_penv.is_file(): + return pio_penv + return None + def get_picotool_path(cc_path: str) -> Path | None: """Derive the picotool binary path from the PlatformIO toolchain cc_path. diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ff8394e2dd..f1b317c0e5 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -51,6 +51,7 @@ from esphome.__main__ import ( show_logs, upload_program, upload_using_esptool, + upload_using_ltchiptool, upload_using_picotool, upload_using_platformio, ) @@ -1373,6 +1374,122 @@ def test_upload_program_serial_platformio_platforms( mock_upload_using_platformio.assert_called_once_with(config, device) +def test_upload_program_libretiny_serial_with_prebuilt_dir_uses_ltchiptool( + mock_get_port_type: Mock, + mock_check_permissions: Mock, + tmp_path: Path, +) -> None: + """Verify LibreTiny serial + --prebuilt-dir bypasses upload_using_platformio + entirely so the dashboard doesn't have to ship a PlatformIO build tree; + ltchiptool can flash the prebuilt .uf2 directly. Verify the dispatch.""" + setup_core(platform=PLATFORM_BK72XX) + 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)) + devices = ["/dev/ttyUSB0"] + + with ( + patch("esphome.__main__.upload_using_ltchiptool", return_value=0) as mock_lt, + patch("esphome.__main__.upload_using_platformio") as mock_pio, + ): + exit_code, host = upload_program({}, args, devices) + + assert exit_code == 0 + assert host == "/dev/ttyUSB0" + mock_lt.assert_called_once_with({}, "/dev/ttyUSB0") + mock_pio.assert_not_called() + + +def test_upload_program_libretiny_serial_without_prebuilt_dir_uses_platformio( + mock_upload_using_platformio: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Regression guard: without --prebuilt-dir, libretiny serial still goes + through upload_using_platformio. The ltchiptool path is purely additive.""" + setup_core(platform=PLATFORM_BK72XX) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_platformio.return_value = 0 + + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + with patch("esphome.__main__.upload_using_ltchiptool") as mock_lt: + exit_code, _ = upload_program({}, args, devices) + + assert exit_code == 0 + mock_upload_using_platformio.assert_called_once_with({}, "/dev/ttyUSB0") + mock_lt.assert_not_called() + + +def test_upload_using_ltchiptool_success(tmp_path: Path) -> None: + """Verify the ltchiptool helper invokes the binary with the firmware uf2 path.""" + setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path) + + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + firmware_uf2 = prebuilt / "firmware.uf2" + firmware_uf2.write_bytes(b"uf2-data") + CORE.prebuilt_dir = prebuilt + + fake_ltchiptool = tmp_path / "ltchiptool" + fake_ltchiptool.touch() + + with ( + patch("esphome.__main__.get_ltchiptool_path", return_value=fake_ltchiptool), + patch("esphome.__main__.run_external_process", return_value=0) as mock_run, + ): + exit_code = upload_using_ltchiptool({}, "/dev/ttyUSB0") + + assert exit_code == 0 + mock_run.assert_called_once_with( + str(fake_ltchiptool), + "flash", + "write", + "-d", + "/dev/ttyUSB0", + str(firmware_uf2), + ) + + +def test_upload_using_ltchiptool_missing_firmware(tmp_path: Path) -> None: + """Surface a clear error when the resolved firmware path doesn't exist, + rather than letting ltchiptool fail with an obscure 'no such file'.""" + setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path) + + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + # No firmware.uf2 written. + CORE.prebuilt_dir = prebuilt + + with patch("esphome.__main__.get_ltchiptool_path") as mock_find: + exit_code = upload_using_ltchiptool({}, "/dev/ttyUSB0") + + assert exit_code == 1 + # get_ltchiptool_path is short-circuited; firmware-missing fails first. + mock_find.assert_not_called() + + +def test_upload_using_ltchiptool_not_found(tmp_path: Path) -> None: + """When ltchiptool isn't installed anywhere we know how to find, surface + an actionable install hint instead of an ENOENT from subprocess.""" + setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path) + + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + (prebuilt / "firmware.uf2").write_bytes(b"uf2") + CORE.prebuilt_dir = prebuilt + + with patch("esphome.__main__.get_ltchiptool_path", return_value=None): + exit_code = upload_using_ltchiptool({}, "/dev/ttyUSB0") + + assert exit_code == 1 + + def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 581b1aca99..689df1719d 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -582,6 +582,47 @@ def test_run_external_process_line_callbacks() -> None: assert any("from subprocess" in r for r in results) +def test_get_ltchiptool_path_on_path(tmp_path: Path) -> None: + """Verify ltchiptool installed via pip (system or venv) wins over the PIO + penv lookup; that's how a dashboard packaging strategy is most likely to + install it.""" + fake_ltchiptool = tmp_path / "ltchiptool" + fake_ltchiptool.touch() + + with patch("esphome.util.shutil.which", return_value=str(fake_ltchiptool)): + assert util.get_ltchiptool_path() == fake_ltchiptool + + +def test_get_ltchiptool_path_pio_penv(tmp_path: Path) -> None: + """Fall back to PlatformIO's libretiny penv when ltchiptool isn't on + PATH; this is the install location platform-libretiny uses.""" + fake_home = tmp_path / "home" + binary_name = "ltchiptool.exe" if sys.platform == "win32" else "ltchiptool" + pio_bin = fake_home / ".platformio" / "penv" / ".libretiny" / "bin" + pio_bin.mkdir(parents=True) + expected = pio_bin / binary_name + expected.touch() + + with ( + patch("esphome.util.shutil.which", return_value=None), + patch("esphome.util.Path.home", return_value=fake_home), + ): + assert util.get_ltchiptool_path() == expected + + +def test_get_ltchiptool_path_not_found(tmp_path: Path) -> None: + """Return None when ltchiptool isn't anywhere we know how to find so + callers can surface an install hint instead of guessing a wrong path.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + + with ( + patch("esphome.util.shutil.which", return_value=None), + patch("esphome.util.Path.home", return_value=fake_home), + ): + assert util.get_ltchiptool_path() is None + + def test_get_picotool_path_found(tmp_path: Path) -> None: """Test picotool path derivation from cc_path.""" # Create the expected directory structure