mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 22:45:55 +00:00
Compare commits
15 Commits
integratio
...
core-prebu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7b1c411e | ||
|
|
bcac422e9e | ||
|
|
c870ad328e | ||
|
|
1ba8b838da | ||
|
|
956c2a9780 | ||
|
|
5cc7719ae4 | ||
|
|
aab7177f07 | ||
|
|
7674170600 | ||
|
|
758189fe56 | ||
|
|
f4607cb521 | ||
|
|
0adfd08270 | ||
|
|
d96ad02b9f | ||
|
|
a6a0a404ae | ||
|
|
b8336cddf2 | ||
|
|
255d4c6b65 |
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
52
tests/unit_tests/components/test_nrf52.py
Normal file
52
tests/unit_tests/components/test_nrf52.py
Normal 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()]
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user