Compare commits

...

15 Commits

Author SHA1 Message Date
J. Nick Koston
5a7b1c411e [cli] DRY up --prebuilt-dir helpers
Two small simplifications surfaced by re-reading the diff with a 'is
this DRY?' lens:

- Extract _missing_prebuilt_flash_tool(): the
  _ensure_platform_packages_for_prebuilt_upload helper had two
  near-identical 'if platform check; if finder() is not None return 0;
  tool_label = ...' blocks. Lift the platform->(tool_name) selection
  into a tiny helper so the early-return is one line and the install
  body is no longer interleaved with the platform dispatch.

- Collapse the two near-duplicate 'firmware not found' return points in
  upload_using_picotool. Both branches just selected an error message
  and returned 1; reorder so the message selection happens first and
  there's a single 'return 1'.

No behavior change; pure refactor. 587 unit tests pass.

Issue: esphome/device-builder#572
2026-05-11 16:00:42 -05:00
J. Nick Koston
bcac422e9e [cli] Address self-review on --prebuilt-dir
- CORE.firmware_bin priority is now platform-aware: RP2040 + libretiny
  prefer firmware.uf2 over firmware.bin (picotool / ltchiptool need the
  UF2 header for address/family info); ESP* prefer firmware.bin. The
  prior code returned .bin first for all platforms, which would have
  silently flashed the wrong artifact on RP2040 if a hand-staged dir
  shipped both files. New tests guard both directions.

- _rp2040_serial_reset_to_bootsel: check picotool exists *before*
  triggering the 1200bps touch. If picotool is missing, the touch
  would have left the device stranded in BOOTSEL with nothing able to
  flash it; with this order the device stays on the old firmware and
  can be retried.

- upload_using_ltchiptool: error message now mentions both firmware.uf2
  and firmware.bin since CORE.firmware_bin resolves either.

- prepare_platform_for_upload: return type tightened to int (capture_stdout
  is hardcoded False; assert the run helper returns int so a future caller
  that flips capture_stdout fails loudly instead of silently treating a
  string as success). Caller in __main__.py is now a one-liner.

- _load_idedata: narrative comment in the prebuilt branch shortened.
  _resolve_prebuilt_idedata_paths docstring now notes the
  POSIX-absolute-on-Windows quirk ("/foo/bar" is rooted but not absolute
  on win32; hand-staged dirs need OS-appropriate absolute paths).

- New defensive-coverage tests: _resolve_prebuilt_idedata_paths with
  missing prog_path, no extra section, empty flash_images list.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-11 13:32:36 -05:00
J. Nick Koston
c870ad328e [cli] Fix Windows pytest: use platform-absolute path in --prebuilt-dir test
The 'absolute paths pass through unchanged' test for _load_idedata
hardcoded a POSIX-style absolute path ('/somewhere/else/bootloader.bin').
On Windows, Path.is_absolute() returns False for that shape -- absolute
paths there require a drive letter -- so _resolve_prebuilt_idedata_paths
classified it as relative and prepended CORE.prebuilt_dir, breaking the
test's assertion.

Replace the hardcoded POSIX path with a tmp_path-rooted one so it's
platform-absolute on every runner. No production code change; this is a
test-only fix surfaced by the windows-latest pytest matrix on PR #16348.

Note: the macOS 3.14 failure on the same CI run is a flaky timing test
(tests/dashboard/test_web_server.py::test_dashboard_subscriber_entries_update_interval,
50ms sleep expecting 2+ iterations at 10ms) unrelated to this PR.

Issue: esphome/device-builder#572
2026-05-11 11:09:36 -05:00
J. Nick Koston
1ba8b838da [cli] Address Copilot review on --prebuilt-dir
- get_ltchiptool_path: use 'Scripts/' on Windows, 'bin/' elsewhere when
  falling back to PlatformIO's libretiny penv. CPython venvs put scripts
  under Scripts/ on win32 and bin/ on POSIX; the prior hardcoded 'bin'
  would never have found ltchiptool on Windows.
- _load_idedata: wrap json.loads on the prebuilt idedata.json in a
  try/except and re-raise as EsphomeError with a one-line diagnostic so
  the failure mode is a clean error instead of an unhandled
  JSONDecodeError stack trace. Update the surrounding comment to match
  the new behavior.
- CORE.prebuilt_dir docstring: drop the dead 'docs/architecture/...'
  pointer (no docs/ tree in this repo); point at esphome-docs#6600 and
  device-builder#572 instead.

New regression test:
- test_load_idedata_prebuilt_malformed_json_raises_esphomeerror

Updated test:
- test_get_ltchiptool_path_pio_penv now uses Scripts/ on win32 to match
  the new platform-aware lookup.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-11 10:58:17 -05:00
J. Nick Koston
956c2a9780 Merge remote-tracking branch 'upstream/dev' into core-prebuilt-dir-upload
# Conflicts:
#	esphome/__main__.py
2026-05-11 10:55:31 -05:00
J. Nick Koston
5cc7719ae4 [cli] Use pio run -t idedata to set up libretiny penv on cold hosts
The prior commit invoked 'pio pkg install' for the on-demand prep step,
which installs the libretiny platform package but does NOT recreate the
~/.platformio/penv/.libretiny/ virtualenv -- that's set up by libretiny's
ConfigurePythonVenv SCons step, which only fires during 'pio run'. So a
cold host (penv missing) still failed to find ltchiptool after the install
returned 'Already up-to-date'.

Wet test against bw15-device.yaml on a host with the libretiny penv
deleted:
  esphome upload bw15-device.yaml --device /dev/null --prebuilt-dir ...
  INFO ltchiptool not found on this host; installing the PlatformIO
       rtl87xx platform package ...
  Resolving bw15-device dependencies...
  Already up-to-date.
  ERROR ltchiptool not found. ...

Replace pkg install with 'pio run -t idedata'. The idedata target runs
SConscript without the actual compile target, so penv creation (libretiny)
and tool-package install (RP2040) happen as side effects of the SCons
configure phase -- but the build itself is skipped. Cost is a few seconds
of SCons configure, paid once per cold host.

Rename run_pkg_install -> prepare_platform_for_upload to match the new
semantics.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-11 00:30:06 -05:00
J. Nick Koston
aab7177f07 [cli] Add write_cpp-failure test for --prebuilt-dir pkg-install prep
Symmetric with test_upload_program_prebuilt_dir_pkg_install_failure_aborts_upload:
covers the early-return path when write_cpp fails (codegen error, disk
full, validation regression) before pkg install runs. Closes the
coverage matrix for _ensure_platform_packages_for_prebuilt_upload's
two failure points.

Issue: esphome/device-builder#572
2026-05-10 23:58:16 -05:00
J. Nick Koston
7674170600 [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
2026-05-10 23:48:05 -05:00
J. Nick Koston
758189fe56 [deps] Bundle ltchiptool for libretiny prebuilt-dir uploads
`esphome upload --prebuilt-dir` on a libretiny device routes through
ltchiptool to bypass the PlatformIO build-tree requirement. On hosts
that have never compiled a libretiny config locally (the dashboard's
transparent-install use case) the libretiny PlatformIO platform's
penv at ~/.platformio/penv/.libretiny/bin/ltchiptool doesn't exist
yet, leaving the upload to fail with an actionable but unwelcome
"install ltchiptool" hint.

Bundle ltchiptool as a direct dependency, mirroring esptool which is
already shipped even though only ESP users need it. `pip install
esphome` is now sufficient for libretiny serial uploads from a
prebuilt artifact set; get_ltchiptool_path's PATH lookup picks up
the pip-installed binary first, with the PIO penv fallback still in
place for environments that vendor esphome without the extra.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-10 23:40:56 -05:00
J. Nick Koston
f4607cb521 [cli] Accept basenames in prebuilt idedata.json
The dashboard's source-routed runner ships idedata.json with bare
basenames in prog_path and extra.flash_images[*].path (the receiver's
build-host absolute paths don't resolve on the offloader, and the
in-memory Web Serial consumer keys by basename). Without this change,
the dashboard would have to write a fresh idedata.json into the staging
tmpdir on every install just to flip basenames to absolute paths.

Resolve relative paths in the prebuilt idedata against CORE.prebuilt_dir
so both the dashboard's wire format and a hand-built directory with
absolute paths work. cc_path is left alone because it points at a
PlatformIO toolchain binary outside the prebuilt dir; the offloader's
own PIO install provides the matching binary.

Issue: esphome/device-builder#570
Issue: esphome/device-builder#572
2026-05-10 23:21:23 -05:00
J. Nick Koston
0adfd08270 [cli] Address review feedback on --prebuilt-dir
- upload_using_ltchiptool: rename unused config arg to _config and note
  the signature parity with upload_using_platformio/upload_using_picotool
  so dispatch stays symmetric.
- upload_program: explicit exit_code = 1 when _rp2040_serial_reset_to_bootsel
  fails, instead of relying on the function-level default.
- upload_using_picotool: distinct error message for the prebuilt-dir case
  that names both candidates (idedata ELF + prebuilt firmware) instead of
  just pointing at the missing ELF.
- _load_idedata: document the prebuilt-dir idedata.json contract more
  loudly (absolute paths under prebuilt_dir, no schema validation, dashboard
  owns the rewrites).
- --prebuilt-dir help text: drop the placeholder docs URL; describe the
  dashboard-internal intent inline so users who hit the flag in --help
  understand they don't want it.
- New test: upload_using_esptool with ESP-IDF toolchain + --prebuilt-dir
  uses <prebuilt-dir>/firmware.factory.bin at offset 0x0.

Issue: esphome/device-builder#572
2026-05-10 23:18:48 -05:00
J. Nick Koston
d96ad02b9f [nrf52] Honor --prebuilt-dir for mcumgr/BLE OTA uploads
nRF52 has its own upload_program that runs before the default dispatch
in esphome.__main__. The mcumgr/BLE OTA path reads the MCUboot-signed
update image from CORE.relative_pioenvs_path(name, 'zephyr',
'app_update.bin'), which assumes a local Zephyr build tree.

Consult CORE.prebuilt_artifact_path('app_update.bin') first so the
dashboard's transparent BLE install on a Bluetooth proxy can flash a
prebuilt update image without compiling locally.

Serial uploads on non-MCUboot bootloaders still go through
adafruit-nrfutil via _upload_using_platformio and are out of scope here;
they need their own bypass to work with --prebuilt-dir (tracked
separately).

Issue: esphome/device-builder#572
2026-05-10 23:07:04 -05:00
J. Nick Koston
a6a0a404ae [cli] Flash RP2040 serial prebuilt-dir uploads via 1200bps touch + picotool
Mirror the libretiny/ltchiptool shape for RP2040 serial when
--prebuilt-dir is set: open the user-supplied serial port at 1200 baud
(arduino-pico's USB CDC interprets the open/close as a request to reboot
into BOOTSEL), poll picotool until the BOOTSEL device shows up on the
USB bus, then dispatch to upload_using_picotool with the prebuilt .uf2.

This removes the last path that required --prebuilt-dir to contain a
platformio.ini + .pioenvs/<name>/ tree, so upload_using_platformio is
now only invoked when no prebuilt dir is set (i.e. the existing
compile+upload flow on a developer machine).

Issue: esphome/device-builder#572
2026-05-10 23:04:18 -05:00
J. Nick Koston
b8336cddf2 [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
2026-05-10 22:52:17 -05:00
J. Nick Koston
255d4c6b65 [cli] Add esphome upload --prebuilt-dir <path>
Adds a --prebuilt-dir flag to esphome upload that points the per-platform
upload helpers at a directory of prebuilt artifacts shipped from a paired
build server, instead of re-deriving paths from the local build tree.

Covers every upload-dispatch shape:
- ESP32 / ESP8266 serial (esptool) reads firmware.bin + extras via the
  prebuilt idedata.json the dashboard ships next to the artifacts.
- ESP32 / ESP8266 OTA (native API + web_server) reads CORE.firmware_bin,
  CORE.partition_table_bin and CORE.bootloader_bin which now consult the
  prebuilt-dir first.
- RP2040 BOOTSEL (picotool) falls back from the idedata ELF (absent in
  the flat layout) to the prebuilt firmware.uf2.
- RP2040 serial / libretiny serial / OTA (PlatformIO upload -t nobuild)
  point platformio at the prebuilt build tree via CORE.build_path so the
  -t upload -t nobuild path finds platformio.ini and .pioenvs/<name>/.

Issue: esphome/device-builder#572
2026-05-10 22:28:19 -05:00
10 changed files with 1322 additions and 14 deletions

View File

@@ -69,6 +69,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,
@@ -853,9 +854,15 @@ def upload_using_esptool(
elif CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
flash_images = [
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
]
# For ESP-IDF the upload is a single factory image at 0x0 (it bundles
# bootloader + partitions + app). The prebuilt-dir form ships that
# single file at the canonical name so the dashboard can flash it
# without re-deriving the ESP-IDF build path.
factory_path = (
CORE.prebuilt_artifact_path("firmware.factory.bin")
or toolchain.get_factory_firmware_path()
)
flash_images = [FlashImage(path=factory_path, offset="0x0")]
else:
from esphome.platformio import toolchain
@@ -931,9 +938,56 @@ 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]``.
The ``_config`` parameter is unused but kept for signature parity with
``upload_using_platformio`` / ``upload_using_picotool`` so the dispatch
in ``upload_program`` can stay symmetric.
"""
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 "
"(or firmware.bin -- libretiny emits the same UF2 content under "
"both names).",
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.platformio import toolchain
# `upload_program` routes around this helper when --prebuilt-dir is set
# (libretiny→ltchiptool, RP2040→1200bps-touch+picotool), so PlatformIO
# is only invoked when a local build tree is available.
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
# the upload target, but 'nobuild' skips the build phase that creates it.
# Create it here so the upload doesn't fail.
@@ -972,14 +1026,31 @@ def upload_using_picotool(config: ConfigType) -> int:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
firmware_elf = Path(idedata.firmware_elf_path)
if not firmware_elf.is_file():
_LOGGER.error(
"Firmware ELF file not found at %s. "
"Make sure the project has been compiled first.",
firmware_elf,
)
# --prebuilt-dir ships canonical artifacts at the root of the directory,
# not the full PlatformIO build tree, so the ELF may not be present.
# picotool's "load" target accepts .uf2 / .bin / .elf, so fall back to
# CORE.firmware_bin (which resolves to the prebuilt firmware.uf2 when set)
# when no ELF is available.
elf_path = Path(idedata.firmware_elf_path)
if elf_path.is_file():
firmware_file: Path = elf_path
elif CORE.prebuilt_dir is not None and CORE.firmware_bin.is_file():
firmware_file = CORE.firmware_bin
else:
if CORE.prebuilt_dir is not None:
_LOGGER.error(
"No firmware found for picotool: neither %s (from idedata) "
"nor %s (prebuilt) exists.",
elf_path,
CORE.firmware_bin,
)
else:
_LOGGER.error(
"Firmware ELF file not found at %s. "
"Make sure the project has been compiled first.",
elf_path,
)
return 1
picotool = get_picotool_path(idedata.cc_path)
@@ -997,7 +1068,7 @@ def upload_using_picotool(config: ConfigType) -> int:
# so progress bars display in real-time with \r updates.
# Capture stderr only so we can detect permission errors.
result = subprocess.run(
[str(picotool), "load", "-v", "-x", str(firmware_elf)],
[str(picotool), "load", "-v", "-x", str(firmware_file)],
stderr=subprocess.PIPE,
timeout=60,
check=False,
@@ -1026,6 +1097,61 @@ def upload_using_picotool(config: ConfigType) -> int:
return 0
def _rp2040_serial_reset_to_bootsel(port: str, timeout: float = 10.0) -> bool:
"""Reboot an arduino-pico RP2040 from running firmware into BOOTSEL mode.
arduino-pico's USB CDC handler treats a 1200bps "touch" (open the port at
1200 baud, then close it) as a request to reboot into the rp2040
bootloader, exposing the device as a picotool-loadable BOOTSEL endpoint.
This is the same mechanism the arduino-pico PlatformIO recipe uses for
serial uploads, lifted out so --prebuilt-dir uploads don't need to
re-invoke PlatformIO.
Returns True once a BOOTSEL device shows up on the USB bus.
"""
import serial
# Look picotool up *before* triggering the reset. If it's missing, the
# touch would leave the device stranded in BOOTSEL with nothing able to
# flash it; bail out early so the user can recover (the device is still
# running the old firmware and re-enumerates as the same serial port).
picotool = _find_picotool()
if picotool is None:
_LOGGER.error(
"picotool not found; cannot flash RP2040 after BOOTSEL reset. "
"Ensure the RP2040 PlatformIO platform is installed (%s).",
PICOTOOL_PACKAGE,
)
return False
_LOGGER.info("Rebooting %s into BOOTSEL via 1200bps touch...", port)
try:
ser = serial.Serial()
ser.baudrate = 1200
ser.port = port
ser.open()
# Small wait so the firmware sees the open before we close it; on a
# fast host the open+close can otherwise happen inside a single USB
# frame and the touch is missed.
time.sleep(0.1)
ser.close()
except (OSError, serial.SerialException) as err:
_LOGGER.error("Failed to open %s at 1200 baud for BOOTSEL reset: %s", port, err)
return False
start = time.monotonic()
while time.monotonic() - start < timeout:
if detect_rp2040_bootsel(picotool).device_count > 0:
return True
time.sleep(0.2)
_LOGGER.error(
"RP2040 did not enter BOOTSEL within %.0fs after 1200bps touch on %s.",
timeout,
port,
)
return False
def _wait_for_serial_port(
port: str | None = None,
timeout: float = 30.0,
@@ -1083,10 +1209,74 @@ def check_permissions(port: str):
)
def _missing_prebuilt_flash_tool() -> str | None:
"""Return the name of the platform-specific flash tool that's expected
but not yet on disk, or None if no install step is needed.
ESP* paths use bundled esptool / smpclient. Libretiny + RP2040 paths
use ltchiptool / picotool, which ship inside the PlatformIO platform
package and aren't pip-installable on their own.
"""
if CORE.is_libretiny and get_ltchiptool_path() is None:
return "ltchiptool"
if CORE.target_platform == PLATFORM_RP2040 and _find_picotool() is None:
return "picotool"
return None
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.
On a host that has never compiled the target platform locally (the
dashboard's transparent-install scenario), the flash tool isn't on
disk yet. Mirror what ``esphome compile`` would do up to the
platform-install step (codegen + write platformio.ini), then run
``pio run -t idedata`` so the platform is downloaded without paying
for an actual compile.
Returns 0 if no install was needed or the install succeeded; non-zero
on failure so the upload bails out with a clear status.
"""
tool_name = _missing_prebuilt_flash_tool()
if tool_name is None:
return 0
_LOGGER.info(
"%s not found on this host; configuring the PlatformIO %s "
"platform so the upload can flash the prebuilt firmware...",
tool_name,
CORE.target_platform,
)
rc = write_cpp(config)
if rc != 0:
return rc
from esphome.platformio import toolchain
return toolchain.prepare_platform_for_upload(config, CORE.verbose)
def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
# --prebuilt-dir routes every per-platform upload helper at a directory of
# prebuilt artifacts instead of the local build tree. Validate once here
# so failures surface before we dispatch into platform-specific code that
# would otherwise produce confusing "file not found" errors deep in the
# esptool / picotool / PlatformIO call stacks.
prebuilt_dir = getattr(args, "prebuilt_dir", None)
if prebuilt_dir is not None:
prebuilt_path = Path(prebuilt_dir).expanduser()
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)
if getattr(module, "upload_program")(config, args, host):
@@ -1125,6 +1315,27 @@ 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 and CORE.prebuilt_dir is not None:
# RP2040 serial + --prebuilt-dir: 1200bps-touch reboot into BOOTSEL,
# then flash with picotool. Avoids the PlatformIO build-tree
# requirement that upload_using_platformio imposes, mirroring the
# libretiny→ltchiptool path. Without --prebuilt-dir the original
# PlatformIO path is preserved for back-compat.
if _rp2040_serial_reset_to_bootsel(host):
exit_code = upload_using_picotool(config)
else:
# Touch reset failed: the helper already logged a specific
# error (port open failure, picotool missing, or BOOTSEL
# never enumerated). Be explicit about the failed exit code
# rather than relying on the function-level default of 1.
exit_code = 1
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
@@ -2092,6 +2303,16 @@ def parse_args(argv):
"--file",
help="Manually specify the binary file to upload.",
)
parser_upload.add_argument(
"--prebuilt-dir",
help=(
"Advanced: directory of prebuilt artifacts to flash instead of "
"re-deriving paths from the local build tree. Intended for the "
"ESPHome dashboard's transparent install flow on hosts that "
"don't compile firmware themselves. End users should not need "
"this flag."
),
)
parser_upload.add_argument(
"--ota-platform",
choices=[CONF_ESPHOME, CONF_WEB_SERVER],

View File

@@ -500,9 +500,17 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
mcumgr_device = host
if mcumgr_device:
firmware = Path(
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
).resolve()
# `esphome upload --prebuilt-dir <path>` ships the MCUboot-signed
# update image at the root of the prebuilt directory; prefer that so
# the dashboard's transparent BLE install on a Bluetooth proxy works
# without a local Zephyr build tree.
prebuilt = CORE.prebuilt_artifact_path("app_update.bin")
if prebuilt is not None:
firmware = prebuilt.resolve()
else:
firmware = Path(
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
).resolve()
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
return True # Handled: mcumgr OTA upload

View File

@@ -567,6 +567,13 @@ class EsphomeCore:
self.config_path: Path | None = None
# The relative path to where all build files are stored
self.build_path: Path | None = None
# Directory of prebuilt artifacts for `esphome upload --prebuilt-dir`.
# When set, firmware/partition/bootloader resolution and the idedata
# cache prefer files under this directory over the local build tree.
# User-facing docs live in esphome/esphome-docs#6600
# (`guides/cli.mdx`); see esphome/device-builder#572 for the
# cross-platform layout contract that motivates the flag.
self.prebuilt_dir: Path | None = None
# The validated configuration, this is None until the config has been validated
self.config: ConfigType | None = None
# The pending tasks in the task queue (mostly for C++ generation)
@@ -633,6 +640,7 @@ class EsphomeCore:
self.data = {}
self.config_path = None
self.build_path = None
self.prebuilt_dir = None
self.config = None
self.event_loop = _FakeEventLoop()
self.task_counter = 0
@@ -775,8 +783,33 @@ class EsphomeCore:
def relative_piolibdeps_path(self, *path: str | Path) -> Path:
return self.relative_build_path(".piolibdeps", *path)
def prebuilt_artifact_path(self, *names: str) -> Path | None:
# Return the first existing prebuilt artifact among ``names``, or None
# when no prebuilt dir is configured or none of the candidates exist.
# Callers pass canonical basenames (e.g. "firmware.bin", "firmware.uf2")
# and the upload helpers fall back to the local build tree when this
# returns None.
if self.prebuilt_dir is None:
return None
for name in names:
candidate = self.prebuilt_dir / name
if candidate.is_file():
return candidate
return None
@property
def firmware_bin(self) -> Path:
# Prebuilt override priority is platform-aware: on RP2040 and libretiny
# the canonical flash artifact is the UF2 (raw firmware.bin won't have
# the address/family header picotool / ltchiptool need); on ESP* it's
# firmware.bin. When the dashboard ships only the canonical name, the
# other entry is a harmless no-op.
if self.is_rp2040 or self.is_libretiny:
prebuilt_names = ("firmware.uf2", "firmware.bin")
else:
prebuilt_names = ("firmware.bin", "firmware.uf2")
if (prebuilt := self.prebuilt_artifact_path(*prebuilt_names)) is not None:
return prebuilt
# Check if using ESP-IDF toolchain
if self.using_toolchain_esp_idf:
return self.relative_build_path("build", f"{self.name}.bin")
@@ -792,6 +825,12 @@ class EsphomeCore:
# Native ESP-IDF (--toolchain esp-idf): the partition table image is emitted under
# build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the
# equivalent file as partitions.bin in the env-specific .pioenvs directory.
if (
prebuilt := self.prebuilt_artifact_path(
"partitions.bin", "partition-table.bin"
)
) is not None:
return prebuilt
if self.using_toolchain_esp_idf:
return self.relative_build_path(
"build", "partition_table", "partition-table.bin"
@@ -800,6 +839,8 @@ class EsphomeCore:
@property
def bootloader_bin(self) -> Path:
if (prebuilt := self.prebuilt_artifact_path("bootloader.bin")) is not None:
return prebuilt
if self.using_toolchain_esp_idf:
return self.relative_build_path("build", "bootloader", "bootloader.bin")
return self.relative_pioenvs_path(self.name, "bootloader.bin")

View File

@@ -84,6 +84,36 @@ def run_compile(config, verbose):
return run_platformio_cli_run(config, verbose, *args)
def prepare_platform_for_upload(config, verbose) -> int:
"""Configure the PlatformIO build environment for ``CORE.name`` without
compiling, so platform-specific flashing tools end up on disk.
Used by ``esphome upload --prebuilt-dir`` on hosts that have never
compiled the target platform locally. Runs ``pio run -t idedata``: the
target fires SConscript (which on libretiny triggers
``ConfigurePythonVenv`` and pip-installs ``ltchiptool`` into the
platform's virtualenv, and on RP2040 installs the picotool tool
package) but skips the actual compile, so this is much cheaper than a
full build.
The idedata JSON gets emitted to stdout as a side effect of the
target; we don't filter it out because the install runs once per cold
host and the trailing JSON blob is harmless noise.
Returns the subprocess exit code; non-zero means the platform install
failed and the upload caller should abort.
"""
# ``capture_stdout`` is intentionally False so run_platformio_cli_run
# returns the int exit code (str only when capture_stdout=True).
result = run_platformio_cli_run(config, verbose, "-t", "idedata")
# Belt-and-suspenders: assert we got an int rather than silently
# treating a captured stdout string as success.
assert isinstance(result, int), (
f"prepare_platform_for_upload expected int, got {type(result).__name__}"
)
return result
def _run_idedata(config):
args = ["-t", "idedata"]
stdout = run_platformio_cli_run(config, False, *args, capture_stdout=True)
@@ -101,7 +131,60 @@ def _run_idedata(config):
raise
def _resolve_prebuilt_idedata_paths(data: dict, prebuilt_dir: Path) -> None:
"""Resolve relative paths in a prebuilt idedata.json against prebuilt_dir.
The dashboard's transparent-install pipeline rewrites ``prog_path`` and
``extra.flash_images[*].path`` to bare basenames before shipping the
tarball (the receiver's build-host absolute paths don't resolve on the
offloader). Accept both shapes: absolute paths pass through unchanged
so a hand-built ``--prebuilt-dir`` still works, and basenames / other
relative paths resolve to ``prebuilt_dir / p``. Mutates ``data`` in
place.
``cc_path`` is left alone because it points at a PlatformIO toolchain
binary (``~/.platformio/packages/...``) that lives outside the prebuilt
dir; the offloader's local PIO install provides the matching binary.
Absoluteness follows ``pathlib.Path.is_absolute`` semantics: on Windows
a leading slash without a drive letter (e.g. ``/foo/bar``) is rooted
but **not** absolute, and will be resolved against ``prebuilt_dir``.
Hand-staged directories should use OS-appropriate absolute paths if
they want passthrough.
"""
prog = data.get("prog_path")
if prog is not None and not Path(prog).is_absolute():
data["prog_path"] = str(prebuilt_dir / prog)
extra = data.get("extra")
if isinstance(extra, dict):
for image in extra.get("flash_images", []) or []:
path = image.get("path") if isinstance(image, dict) else None
if path is not None and not Path(path).is_absolute():
image["path"] = str(prebuilt_dir / path)
def _load_idedata(config):
# --prebuilt-dir override: read the shipped idedata.json, resolve any
# relative paths inside it against the prebuilt dir, return it directly.
# Malformed JSON -> EsphomeError (clean one-line diagnostic, not a raw
# JSONDecodeError trace). No schema or path-existence validation; any
# missing artifact surfaces later as a "file not found" from the
# downstream flasher.
if CORE.prebuilt_dir is not None:
prebuilt_idedata = CORE.prebuilt_dir / "idedata.json"
if prebuilt_idedata.is_file():
try:
data = json.loads(prebuilt_idedata.read_text(encoding="utf-8"))
except json.JSONDecodeError as err:
raise EsphomeError(
f"Failed to parse {prebuilt_idedata}: {err}. The dashboard "
"must stage a syntactically valid idedata.json under "
"--prebuilt-dir; the upload cannot proceed without it."
) from err
_resolve_prebuilt_idedata_paths(data, CORE.prebuilt_dir)
return data
platformio_ini = CORE.relative_build_path("platformio.ini")
temp_idedata = CORE.relative_internal_path("idedata", f"{CORE.name}.json")

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,46 @@ 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 (where platform-libretiny installs it
during its package init). The script subdirectory follows the
CPython venv convention: ``Scripts/`` on Windows, ``bin/``
elsewhere.
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)
if sys.platform == "win32":
bin_subdir = "Scripts"
binary_name = "ltchiptool.exe"
else:
bin_subdir = "bin"
binary_name = "ltchiptool"
pio_penv = (
Path.home()
/ ".platformio"
/ "penv"
/ LTCHIPTOOL_PIO_PENV_NAME
/ bin_subdir
/ 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

@@ -0,0 +1,52 @@
"""Tests for the nrf52 component's custom upload_program."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from esphome.core import CORE
def test_nrf52_upload_program_prebuilt_dir_for_ble_ota(tmp_path: Path) -> None:
"""`esphome upload --prebuilt-dir <path>` for an nRF52 Bluetooth proxy
ships the MCUboot-signed update at <prebuilt-dir>/app_update.bin so the
dashboard's transparent BLE install doesn't need a local Zephyr build
tree. Verify the nrf52 upload_program picks up that path instead of
`CORE.relative_pioenvs_path(..., "zephyr", "app_update.bin")`.
"""
prebuilt = tmp_path / "prebuilt"
prebuilt.mkdir()
expected_firmware = prebuilt / "app_update.bin"
expected_firmware.write_bytes(b"signed-mcuboot-image")
CORE.prebuilt_dir = prebuilt
CORE.name = "bt-proxy"
captured_firmware: list[Path] = []
async def fake_upload(device: str, firmware: Path) -> None:
captured_firmware.append(firmware)
# Patch the heavy nrf52 surface so we exercise only the firmware-path
# resolution: BLE scan returns a fake device, smpmgr_upload records the
# firmware path it would have flashed.
with (
patch(
"esphome.components.nrf52.ota.smpmgr_scan",
new=AsyncMock(return_value="AA:BB:CC:DD:EE:FF"),
),
patch(
"esphome.components.nrf52.ota.smpmgr_upload",
new=fake_upload,
),
patch(
"esphome.components.nrf52.ble_logger.is_mac_address",
return_value=False,
),
):
from esphome.components.nrf52 import upload_program
handled = upload_program({}, MagicMock(), "BLE")
assert handled is True
assert captured_firmware == [expected_firmware.resolve()]

View File

@@ -894,6 +894,119 @@ class TestEsphomeCore:
"foo/build/.pioenvs/test-device/bootloader.bin"
)
def test_prebuilt_artifact_path__none_when_unset(self, target):
"""prebuilt_artifact_path is the gate on every prebuilt-dir override.
When --prebuilt-dir is not set, every consumer must fall back to the
local build tree."""
assert target.prebuilt_artifact_path("firmware.bin") is None
def test_prebuilt_artifact_path__returns_first_existing(self, target, tmp_path):
"""firmware.bin (ESP) and firmware.uf2 (RP2040/libretiny) are both
canonical names, so callers pass them in priority order and get the
first one that actually exists."""
target.prebuilt_dir = tmp_path
(tmp_path / "firmware.uf2").write_bytes(b"uf2")
assert (
target.prebuilt_artifact_path("firmware.bin", "firmware.uf2")
== tmp_path / "firmware.uf2"
)
def test_prebuilt_artifact_path__none_when_no_candidate_exists(
self, target, tmp_path
):
"""If --prebuilt-dir is set but the directory is empty for the asked
names, return None so the caller falls through to the local build
tree rather than asserting."""
target.prebuilt_dir = tmp_path
assert target.prebuilt_artifact_path("firmware.bin") is None
def test_firmware_bin__prebuilt_override(self, target, tmp_path):
"""CORE.firmware_bin is the single resolution point every OTA path
reads. With --prebuilt-dir set and firmware.bin present, it must
return the prebuilt path instead of the (non-existent) local build
path."""
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
target.prebuilt_dir = tmp_path
(tmp_path / "firmware.bin").write_bytes(b"fw")
assert target.firmware_bin == tmp_path / "firmware.bin"
def test_firmware_bin__prebuilt_override_uf2(self, target, tmp_path):
"""RP2040 / libretiny ship firmware.uf2; firmware_bin returns it when
firmware.bin is absent so the dashboard only needs to ship one file."""
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "bk72xx"}
target.prebuilt_dir = tmp_path
(tmp_path / "firmware.uf2").write_bytes(b"uf2")
assert target.firmware_bin == tmp_path / "firmware.uf2"
def test_firmware_bin__prebuilt_prefers_uf2_on_rp2040(self, target, tmp_path):
"""Critical for RP2040: when both firmware.bin and firmware.uf2 are
staged, return the .uf2. picotool's load command needs the UF2
header (address + family); a raw .bin would flash to the wrong
offset (or refuse to flash). The dashboard's wire format ships only
the canonical name, but a hand-staged dir might carry both."""
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: const.PLATFORM_RP2040}
target.prebuilt_dir = tmp_path
(tmp_path / "firmware.bin").write_bytes(b"raw")
(tmp_path / "firmware.uf2").write_bytes(b"uf2-wrapped")
assert target.firmware_bin == tmp_path / "firmware.uf2"
def test_firmware_bin__prebuilt_prefers_bin_on_esp32(self, target, tmp_path):
"""Mirror of the above for ESP32: the canonical artifact is .bin
(esptool flashes raw images at fixed offsets); if both are shipped
the .uf2 is the irrelevant one."""
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
target.prebuilt_dir = tmp_path
(tmp_path / "firmware.bin").write_bytes(b"raw")
(tmp_path / "firmware.uf2").write_bytes(b"uf2-wrapped")
assert target.firmware_bin == tmp_path / "firmware.bin"
def test_partition_table_bin__prebuilt_override(self, target, tmp_path):
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
target.prebuilt_dir = tmp_path
(tmp_path / "partitions.bin").write_bytes(b"pt")
assert target.partition_table_bin == tmp_path / "partitions.bin"
def test_bootloader_bin__prebuilt_override(self, target, tmp_path):
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
target.prebuilt_dir = tmp_path
(tmp_path / "bootloader.bin").write_bytes(b"bl")
assert target.bootloader_bin == tmp_path / "bootloader.bin"
def test_firmware_bin__prebuilt_dir_set_but_file_missing_falls_through(
self, target, tmp_path
):
"""Setting --prebuilt-dir alone must not break devices that don't ship
every artifact (e.g. OTA-only uploads with no bootloader.bin). When
the canonical file isn't in the directory, fall back to the local
build path so the caller's existing error messages still apply."""
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
target.prebuilt_dir = tmp_path
# No firmware.bin in tmp_path on purpose.
assert target.firmware_bin == Path(
"foo/build/.pioenvs/test-device/firmware.bin"
)
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {

View File

@@ -50,6 +50,7 @@ from esphome.__main__ import (
show_logs,
upload_program,
upload_using_esptool,
upload_using_ltchiptool,
upload_using_picotool,
upload_using_platformio,
)
@@ -1135,6 +1136,7 @@ class MockArgs:
ota_platform: str | None = None
partition_table: bool = False
bootloader: bool = False
prebuilt_dir: str | None = None
def test_upload_program_serial_esp32(
@@ -1341,6 +1343,41 @@ def test_upload_using_esptool_with_file_path(
assert firmware_path.endswith("custom_firmware.bin")
def test_upload_using_esptool_idf_prebuilt_factory_bin(
tmp_path: Path,
mock_run_external_command_main: Mock,
) -> None:
"""ESP-IDF builds flash a single ``firmware.factory.bin`` (bootloader +
partitions + app bundled) at offset 0x0. With --prebuilt-dir set the
esptool helper must pick up ``<prebuilt-dir>/firmware.factory.bin``
instead of ``CORE.relative_build_path("build", "firmware.factory.bin")``
so the dashboard can flash an ESP-IDF device without a local build."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32}
prebuilt = tmp_path / "prebuilt"
prebuilt.mkdir()
factory_bin = prebuilt / "firmware.factory.bin"
factory_bin.write_bytes(b"\x00" * 1024)
CORE.prebuilt_dir = prebuilt
config = {CONF_ESPHOME: {"platformio_options": {}}}
result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
assert result == 0
cmd_list = list(mock_run_external_command_main.call_args[0][1:])
# ESP-IDF prebuilt: single image at offset 0x0 pointing at the prebuilt
# factory file, not the local build tree's <build>/firmware.factory.bin.
write_flash_idx = cmd_list.index("write-flash")
offset_idx = write_flash_idx + 4
assert cmd_list[offset_idx] == "0x0"
path_arg = cmd_list[offset_idx + 1]
assert path_arg == str(factory_bin)
@pytest.mark.parametrize(
"platform,device",
[
@@ -1372,6 +1409,127 @@ 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 (
# 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,
):
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:
@@ -1412,6 +1570,226 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040(
assert result == 0
def test_upload_program_prebuilt_dir_sets_core_attr(
mock_upload_using_esptool: Mock,
mock_get_port_type: Mock,
mock_check_permissions: Mock,
tmp_path: Path,
) -> None:
"""--prebuilt-dir must stash the validated path on CORE before dispatching
so that all per-platform helpers and CORE.firmware_bin / etc. resolve to
the prebuilt artifacts."""
setup_core(platform=PLATFORM_ESP32)
mock_get_port_type.return_value = "SERIAL"
mock_upload_using_esptool.return_value = 0
prebuilt = tmp_path / "artifacts"
prebuilt.mkdir()
config = {}
args = MockArgs(prebuilt_dir=str(prebuilt))
devices = ["/dev/ttyUSB0"]
exit_code, _ = upload_program(config, args, devices)
assert exit_code == 0
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.toolchain.prepare_platform_for_upload", return_value=0
) as mock_prep,
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_prep.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.toolchain.prepare_platform_for_upload") as mock_prep,
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_prep.assert_not_called()
def test_upload_program_prebuilt_dir_write_cpp_failure_aborts_upload(
mock_get_port_type: Mock,
mock_check_permissions: Mock,
tmp_path: Path,
) -> None:
"""Codegen failure (write_cpp returning non-zero) must short-circuit
before pkg install runs; pkg install against a broken platformio.ini
would just fail less informatively. Symmetric with the pkg-install
failure test below."""
setup_core(platform=PLATFORM_BK72XX, 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__.get_ltchiptool_path", return_value=None),
patch("esphome.__main__.write_cpp", return_value=3),
patch("esphome.platformio.toolchain.prepare_platform_for_upload") as mock_prep,
patch("esphome.__main__.upload_using_ltchiptool") as mock_upload,
):
exit_code, host = upload_program({}, args, ["/dev/ttyUSB0"])
assert exit_code == 3
assert host is None
mock_prep.assert_not_called()
mock_upload.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.toolchain.prepare_platform_for_upload", 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,
tmp_path: Path,
) -> None:
"""Catch a missing --prebuilt-dir before dispatching to a per-platform
helper; deferring would surface as a confusing "file not found" deep in
esptool / picotool / PlatformIO."""
setup_core(platform=PLATFORM_ESP32)
mock_get_port_type.return_value = "SERIAL"
missing = tmp_path / "does-not-exist"
config = {}
args = MockArgs(prebuilt_dir=str(missing))
devices = ["/dev/ttyUSB0"]
with pytest.raises(EsphomeError, match="not a directory"):
upload_program(config, args, devices)
def test_upload_using_picotool_falls_back_to_firmware_bin_when_elf_missing(
tmp_path: Path,
) -> None:
"""`--prebuilt-dir` ships a flat firmware.uf2, not the build tree's ELF.
picotool load accepts uf2/bin/elf, so when the idedata ELF is missing
fall through to CORE.firmware_bin (the prebuilt .uf2) instead of failing.
"""
setup_core(platform=PLATFORM_RP2040, 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
# idedata points at an ELF that doesn't exist on disk (typical for
# prebuilt-dir flat layouts).
mock_idedata = MagicMock()
mock_idedata.firmware_elf_path = str(tmp_path / "build" / "firmware.elf")
mock_idedata.cc_path = "/fake/path/gcc"
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = b""
# Stub the picotool lookup to short-circuit the toolchain probe.
with (
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
patch(
"esphome.__main__.get_picotool_path",
return_value=tmp_path / "picotool",
),
patch("subprocess.run", return_value=mock_result) as mock_run,
):
exit_code = upload_using_picotool({})
assert exit_code == 0
# Verify picotool was handed the prebuilt .uf2, not the missing ELF.
cmd = mock_run.call_args[0][0]
assert str(firmware_uf2) in cmd
def test_upload_program_serial_upload_failed(
mock_upload_using_esptool: Mock,
mock_get_port_type: Mock,
@@ -1434,6 +1812,145 @@ def test_upload_program_serial_upload_failed(
mock_upload_using_esptool.assert_called_once()
def test_upload_program_rp2040_serial_with_prebuilt_dir_uses_picotool(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,
mock_check_permissions: Mock,
tmp_path: Path,
) -> None:
"""RP2040 serial + --prebuilt-dir reboots into BOOTSEL via 1200bps
touch and flashes with picotool, avoiding upload_using_platformio's
build-tree requirement."""
setup_core(platform=PLATFORM_RP2040)
mock_get_port_type.return_value = "SERIAL"
mock_upload_using_picotool.return_value = 0
prebuilt = tmp_path / "prebuilt"
prebuilt.mkdir()
(prebuilt / "firmware.uf2").write_bytes(b"uf2")
args = MockArgs(prebuilt_dir=str(prebuilt))
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,
) as mock_reset,
patch("esphome.__main__.upload_using_platformio") as mock_pio,
):
exit_code, host = upload_program({}, args, devices)
assert exit_code == 0
assert host == "/dev/ttyACM0"
mock_reset.assert_called_once_with("/dev/ttyACM0")
mock_upload_using_picotool.assert_called_once_with({})
mock_pio.assert_not_called()
def test_upload_program_rp2040_serial_with_prebuilt_dir_reset_fails(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,
mock_check_permissions: Mock,
tmp_path: Path,
) -> None:
"""If 1200bps touch fails to put the RP2040 into BOOTSEL, the upload
must abort (not fall through to a generic exit_code = 1 with no
diagnostic)."""
setup_core(platform=PLATFORM_RP2040)
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=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
assert host is None
mock_upload_using_picotool.assert_not_called()
def test_upload_program_rp2040_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, RP2040 serial still goes
through upload_using_platformio. The 1200bps-touch path is purely
additive."""
setup_core(platform=PLATFORM_RP2040)
mock_get_port_type.return_value = "SERIAL"
mock_upload_using_platformio.return_value = 0
with patch("esphome.__main__._rp2040_serial_reset_to_bootsel") as mock_reset:
exit_code, _ = upload_program({}, MockArgs(), ["/dev/ttyACM0"])
assert exit_code == 0
mock_upload_using_platformio.assert_called_once_with({}, "/dev/ttyACM0")
mock_reset.assert_not_called()
def test_rp2040_serial_reset_to_bootsel_success(tmp_path: Path) -> None:
"""The 1200bps-touch helper opens then closes the port at 1200 baud
(arduino-pico's USB CDC interprets that as a request to enter BOOTSEL),
then polls picotool until a BOOTSEL device shows up."""
from esphome.__main__ import _rp2040_serial_reset_to_bootsel
from esphome.util import BootselResult
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
mock_serial_instance = MagicMock()
with (
patch("serial.Serial", return_value=mock_serial_instance),
patch("esphome.__main__._find_picotool", return_value=tmp_path / "picotool"),
patch(
"esphome.__main__.detect_rp2040_bootsel",
return_value=BootselResult(device_count=1),
),
):
assert _rp2040_serial_reset_to_bootsel("/dev/ttyACM0") is True
# The open/close at 1200 baud is what triggers the reset; verify both happened.
assert mock_serial_instance.baudrate == 1200
assert mock_serial_instance.port == "/dev/ttyACM0"
mock_serial_instance.open.assert_called_once()
mock_serial_instance.close.assert_called_once()
def test_rp2040_serial_reset_to_bootsel_no_bootsel(tmp_path: Path) -> None:
"""When BOOTSEL never appears (e.g. firmware doesn't implement the
1200bps touch handler) return False with a clear timeout log instead
of hanging."""
from esphome.__main__ import _rp2040_serial_reset_to_bootsel
from esphome.util import BootselResult
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
with (
patch("serial.Serial", return_value=MagicMock()),
patch("esphome.__main__._find_picotool", return_value=tmp_path / "picotool"),
patch(
"esphome.__main__.detect_rp2040_bootsel",
return_value=BootselResult(device_count=0),
),
):
# Short timeout so the test doesn't sit on the wall clock.
assert _rp2040_serial_reset_to_bootsel("/dev/ttyACM0", timeout=0.1) is False
def test_upload_program_bootsel(
mock_upload_using_picotool: Mock,
mock_get_port_type: Mock,

View File

@@ -236,6 +236,189 @@ def test_load_idedata_regenerates_on_corrupted_cache(
assert result["prog_path"] == "/new/firmware.elf"
def test_load_idedata_uses_prebuilt_dir_when_set(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:
"""`esphome upload --prebuilt-dir <path>` is expected to ship the rendered
idedata.json next to the artifacts and bypass PlatformIO entirely. Verify
that _load_idedata returns the prebuilt copy verbatim without consulting
platformio.ini mtime or invoking ``platformio run -t idedata``."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.name = "test"
prebuilt_dir = setup_core / "prebuilt"
prebuilt_dir.mkdir()
prebuilt_idedata = prebuilt_dir / "idedata.json"
prebuilt_idedata.write_text(
json.dumps({"prog_path": str(prebuilt_dir / "firmware.elf")})
)
CORE.prebuilt_dir = prebuilt_dir
result = toolchain._load_idedata({"name": "test"})
assert result["prog_path"] == str(prebuilt_dir / "firmware.elf")
# Never re-runs PlatformIO when prebuilt idedata is supplied: the dashboard
# ships these artifacts from a paired build server with no local PIO tree.
mock_run_platformio_cli_run.assert_not_called()
def test_load_idedata_resolves_basenames_against_prebuilt_dir(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:
"""The dashboard's wire format ships idedata.json with bare basenames in
prog_path and extra.flash_images[*].path (the receiver's build-host
absolute paths don't resolve on the offloader). Verify upstream resolves
those basenames against CORE.prebuilt_dir so the offloader doesn't have
to write a fresh idedata.json on every install. Tracks the request in
esphome/device-builder#570."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.name = "test"
prebuilt_dir = setup_core / "prebuilt"
prebuilt_dir.mkdir()
prebuilt_idedata = prebuilt_dir / "idedata.json"
prebuilt_idedata.write_text(
json.dumps(
{
"prog_path": "firmware.elf",
"extra": {
"flash_images": [
{"path": "bootloader.bin", "offset": "0x1000"},
{"path": "partitions.bin", "offset": "0x8000"},
]
},
}
)
)
CORE.prebuilt_dir = prebuilt_dir
result = toolchain._load_idedata({"name": "test"})
assert result["prog_path"] == str(prebuilt_dir / "firmware.elf")
images = result["extra"]["flash_images"]
assert images[0]["path"] == str(prebuilt_dir / "bootloader.bin")
assert images[1]["path"] == str(prebuilt_dir / "partitions.bin")
mock_run_platformio_cli_run.assert_not_called()
def test_load_idedata_absolute_paths_in_prebuilt_pass_through(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:
"""A hand-built --prebuilt-dir with absolute paths in idedata.json still
works: absolute paths pass through unchanged so we don't break the
documented-but-rarer shape just to support the dashboard's wire format.
"""
CORE.build_path = str(setup_core / "build" / "test")
CORE.name = "test"
prebuilt_dir = setup_core / "prebuilt"
prebuilt_dir.mkdir()
# Use a tmp-rooted absolute path so the test passes on Windows too;
# Path.is_absolute() requires a drive letter on win32, so a bare
# "/somewhere/else/..." string would be classified as relative there
# and trigger the wrong code path.
abs_bootloader = str(setup_core / "elsewhere" / "bootloader.bin")
prebuilt_idedata = prebuilt_dir / "idedata.json"
prebuilt_idedata.write_text(
json.dumps(
{
"prog_path": str(prebuilt_dir / "firmware.elf"),
"extra": {
"flash_images": [
{"path": abs_bootloader, "offset": "0x1000"},
]
},
}
)
)
CORE.prebuilt_dir = prebuilt_dir
result = toolchain._load_idedata({"name": "test"})
assert result["prog_path"] == str(prebuilt_dir / "firmware.elf")
assert result["extra"]["flash_images"][0]["path"] == abs_bootloader
def test_resolve_prebuilt_idedata_paths_missing_prog_path(tmp_path: Path) -> None:
"""Defensive: a prebuilt idedata.json without prog_path (e.g. when the
dashboard only ships extra.flash_images) must not raise -- the resolver
skips fields that aren't present so partial-idedata shapes don't crash
upload dispatch."""
data = {"extra": {"flash_images": []}, "cc_path": "/some/cc"}
toolchain._resolve_prebuilt_idedata_paths(data, tmp_path)
assert "prog_path" not in data
assert data["cc_path"] == "/some/cc" # left alone
def test_resolve_prebuilt_idedata_paths_no_extra_section(tmp_path: Path) -> None:
"""Defensive: idedata.json without an `extra` section at all (some
platforms don't ship flash_images) must not crash."""
data = {"prog_path": "firmware.elf"}
toolchain._resolve_prebuilt_idedata_paths(data, tmp_path)
assert data["prog_path"] == str(tmp_path / "firmware.elf")
def test_resolve_prebuilt_idedata_paths_empty_flash_images(tmp_path: Path) -> None:
"""Defensive: empty extra.flash_images (libretiny / ESP8266 / nRF52 ship
this; whole image is one file at offset 0x0). Resolver must not raise."""
data = {
"prog_path": "firmware.elf",
"extra": {"flash_images": []},
}
toolchain._resolve_prebuilt_idedata_paths(data, tmp_path)
assert data["prog_path"] == str(tmp_path / "firmware.elf")
assert data["extra"]["flash_images"] == []
def test_load_idedata_prebuilt_malformed_json_raises_esphomeerror(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:
"""A malformed prebuilt idedata.json must surface as a one-line
EsphomeError, not an unhandled JSONDecodeError stack trace; the
dashboard's transparent-install flow needs a clean diagnostic to
bubble back to the operator, not the contents of the broken file."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.name = "test"
prebuilt_dir = setup_core / "prebuilt"
prebuilt_dir.mkdir()
(prebuilt_dir / "idedata.json").write_text("{not valid json")
CORE.prebuilt_dir = prebuilt_dir
with pytest.raises(EsphomeError, match="Failed to parse"):
toolchain._load_idedata({"name": "test"})
mock_run_platformio_cli_run.assert_not_called()
def test_load_idedata_falls_back_when_prebuilt_idedata_missing(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:
"""If --prebuilt-dir is set but the directory has no idedata.json, the
normal local-build-tree path runs. Lets the dashboard skip idedata for
OTA-only uploads (where firmware.bin alone is enough) without breaking."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.name = "test"
prebuilt_dir = setup_core / "prebuilt"
prebuilt_dir.mkdir()
# Intentionally no idedata.json in prebuilt_dir.
CORE.prebuilt_dir = prebuilt_dir
platformio_ini = setup_core / "build" / "test" / "platformio.ini"
platformio_ini.parent.mkdir(parents=True, exist_ok=True)
platformio_ini.write_text("content")
mock_run_platformio_cli_run.return_value = json.dumps(
{"prog_path": "/local/firmware.elf"}
)
result = toolchain._load_idedata({"name": "test"})
assert result["prog_path"] == "/local/firmware.elf"
mock_run_platformio_cli_run.assert_called_once()
def test_run_idedata_parses_json_from_output(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None:

View File

@@ -582,6 +582,55 @@ 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. The
script subdir follows the CPython venv convention -- ``Scripts/``
on Windows, ``bin/`` elsewhere -- which matches what PlatformIO
creates."""
fake_home = tmp_path / "home"
if sys.platform == "win32":
bin_subdir = "Scripts"
binary_name = "ltchiptool.exe"
else:
bin_subdir = "bin"
binary_name = "ltchiptool"
pio_bin = fake_home / ".platformio" / "penv" / ".libretiny" / bin_subdir
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