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