[cli] Flash libretiny prebuilt-dir uploads via ltchiptool

The libretiny upload path on `upload_program` SERIAL dispatch re-invokes
PlatformIO (`pio run -t upload -t nobuild`), which needs a full build
tree and `platformio.ini`. That makes it incompatible with a dashboard
that only has prebuilt artifacts.

Bypass PlatformIO for the libretiny+SERIAL+--prebuilt-dir case by calling
`ltchiptool flash write -d <port> <firmware.uf2>` directly. The .uf2
encodes the chip family in its header so no extra config is needed.

ltchiptool ships with the libretiny PlatformIO platform under
~/.platformio/penv/.libretiny/bin/ltchiptool; `get_ltchiptool_path()`
prefers PATH first (pip install ltchiptool) and falls back to the
PlatformIO penv. Without --prebuilt-dir the existing PlatformIO path
remains in place, so this is purely additive.

Issue: esphome/device-builder#572
This commit is contained in:
J. Nick Koston
2026-05-10 22:52:17 -05:00
parent 255d4c6b65
commit b8336cddf2
4 changed files with 247 additions and 7 deletions

View File

@@ -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 <yaml> 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/<name>/...) under
# --prebuilt-dir for this path because PlatformIO needs platformio.ini and
# the env-specific .pioenvs/<name> 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/<name>/...) under --prebuilt-dir
# because `pio run -t upload -t nobuild` reads platformio.ini and the
# env-specific .pioenvs/<name> 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/<name>/ 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

View File

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

View File

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

View File

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