[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
+117
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:
+41
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