mirror of
https://github.com/esphome/esphome.git
synced 2026-09-10 14:57:32 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7b1c411e | ||
|
|
bcac422e9e | ||
|
|
c870ad328e | ||
|
|
1ba8b838da | ||
|
|
956c2a9780 | ||
|
|
5cc7719ae4 | ||
|
|
aab7177f07 | ||
|
|
7674170600 | ||
|
|
758189fe56 | ||
|
|
f4607cb521 | ||
|
|
0adfd08270 | ||
|
|
d96ad02b9f | ||
|
|
a6a0a404ae | ||
|
|
b8336cddf2 | ||
|
|
255d4c6b65 |
+232
-11
@@ -69,6 +69,7 @@ from esphome.util import (
|
|||||||
PICOTOOL_PACKAGE,
|
PICOTOOL_PACKAGE,
|
||||||
FlashImage,
|
FlashImage,
|
||||||
detect_rp2040_bootsel,
|
detect_rp2040_bootsel,
|
||||||
|
get_ltchiptool_path,
|
||||||
get_picotool_path,
|
get_picotool_path,
|
||||||
get_serial_ports,
|
get_serial_ports,
|
||||||
is_picotool_usb_permission_error,
|
is_picotool_usb_permission_error,
|
||||||
@@ -853,9 +854,15 @@ def upload_using_esptool(
|
|||||||
elif CORE.using_toolchain_esp_idf:
|
elif CORE.using_toolchain_esp_idf:
|
||||||
from esphome.espidf import toolchain
|
from esphome.espidf import toolchain
|
||||||
|
|
||||||
flash_images = [
|
# For ESP-IDF the upload is a single factory image at 0x0 (it bundles
|
||||||
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
# 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:
|
else:
|
||||||
from esphome.platformio import toolchain
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
@@ -931,9 +938,56 @@ def upload_using_esptool(
|
|||||||
return run_esptool(115200)
|
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:
|
def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||||
from esphome.platformio import toolchain
|
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
|
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||||
# the upload target, but 'nobuild' skips the build phase that creates it.
|
# the upload target, but 'nobuild' skips the build phase that creates it.
|
||||||
# Create it here so the upload doesn't fail.
|
# 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
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
idedata = toolchain.get_idedata(config)
|
idedata = toolchain.get_idedata(config)
|
||||||
firmware_elf = Path(idedata.firmware_elf_path)
|
|
||||||
|
|
||||||
if not firmware_elf.is_file():
|
# --prebuilt-dir ships canonical artifacts at the root of the directory,
|
||||||
_LOGGER.error(
|
# not the full PlatformIO build tree, so the ELF may not be present.
|
||||||
"Firmware ELF file not found at %s. "
|
# picotool's "load" target accepts .uf2 / .bin / .elf, so fall back to
|
||||||
"Make sure the project has been compiled first.",
|
# CORE.firmware_bin (which resolves to the prebuilt firmware.uf2 when set)
|
||||||
firmware_elf,
|
# 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
|
return 1
|
||||||
|
|
||||||
picotool = get_picotool_path(idedata.cc_path)
|
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.
|
# so progress bars display in real-time with \r updates.
|
||||||
# Capture stderr only so we can detect permission errors.
|
# Capture stderr only so we can detect permission errors.
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(picotool), "load", "-v", "-x", str(firmware_elf)],
|
[str(picotool), "load", "-v", "-x", str(firmware_file)],
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
check=False,
|
check=False,
|
||||||
@@ -1026,6 +1097,61 @@ def upload_using_picotool(config: ConfigType) -> int:
|
|||||||
return 0
|
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(
|
def _wait_for_serial_port(
|
||||||
port: str | None = None,
|
port: str | None = None,
|
||||||
timeout: float = 30.0,
|
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(
|
def upload_program(
|
||||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||||
) -> tuple[int, str | None]:
|
) -> tuple[int, str | None]:
|
||||||
host = devices[0]
|
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:
|
try:
|
||||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||||
if getattr(module, "upload_program")(config, args, host):
|
if getattr(module, "upload_program")(config, args, host):
|
||||||
@@ -1125,6 +1315,27 @@ def upload_program(
|
|||||||
if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266):
|
if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266):
|
||||||
file = getattr(args, "file", None)
|
file = getattr(args, "file", None)
|
||||||
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
|
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:
|
elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny:
|
||||||
exit_code = upload_using_platformio(config, host)
|
exit_code = upload_using_platformio(config, host)
|
||||||
# else: Unknown target platform, exit_code remains 1
|
# else: Unknown target platform, exit_code remains 1
|
||||||
@@ -2092,6 +2303,16 @@ def parse_args(argv):
|
|||||||
"--file",
|
"--file",
|
||||||
help="Manually specify the binary file to upload.",
|
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(
|
parser_upload.add_argument(
|
||||||
"--ota-platform",
|
"--ota-platform",
|
||||||
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
|
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
|
||||||
|
|||||||
@@ -500,9 +500,17 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
|||||||
mcumgr_device = host
|
mcumgr_device = host
|
||||||
|
|
||||||
if mcumgr_device:
|
if mcumgr_device:
|
||||||
firmware = Path(
|
# `esphome upload --prebuilt-dir <path>` ships the MCUboot-signed
|
||||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
# update image at the root of the prebuilt directory; prefer that so
|
||||||
).resolve()
|
# 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))
|
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
|
||||||
return True # Handled: mcumgr OTA upload
|
return True # Handled: mcumgr OTA upload
|
||||||
|
|
||||||
|
|||||||
@@ -567,6 +567,13 @@ class EsphomeCore:
|
|||||||
self.config_path: Path | None = None
|
self.config_path: Path | None = None
|
||||||
# The relative path to where all build files are stored
|
# The relative path to where all build files are stored
|
||||||
self.build_path: Path | None = None
|
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
|
# The validated configuration, this is None until the config has been validated
|
||||||
self.config: ConfigType | None = None
|
self.config: ConfigType | None = None
|
||||||
# The pending tasks in the task queue (mostly for C++ generation)
|
# The pending tasks in the task queue (mostly for C++ generation)
|
||||||
@@ -633,6 +640,7 @@ class EsphomeCore:
|
|||||||
self.data = {}
|
self.data = {}
|
||||||
self.config_path = None
|
self.config_path = None
|
||||||
self.build_path = None
|
self.build_path = None
|
||||||
|
self.prebuilt_dir = None
|
||||||
self.config = None
|
self.config = None
|
||||||
self.event_loop = _FakeEventLoop()
|
self.event_loop = _FakeEventLoop()
|
||||||
self.task_counter = 0
|
self.task_counter = 0
|
||||||
@@ -775,8 +783,33 @@ class EsphomeCore:
|
|||||||
def relative_piolibdeps_path(self, *path: str | Path) -> Path:
|
def relative_piolibdeps_path(self, *path: str | Path) -> Path:
|
||||||
return self.relative_build_path(".piolibdeps", *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
|
@property
|
||||||
def firmware_bin(self) -> Path:
|
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
|
# Check if using ESP-IDF toolchain
|
||||||
if self.using_toolchain_esp_idf:
|
if self.using_toolchain_esp_idf:
|
||||||
return self.relative_build_path("build", f"{self.name}.bin")
|
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
|
# 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
|
# build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the
|
||||||
# equivalent file as partitions.bin in the env-specific .pioenvs directory.
|
# 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:
|
if self.using_toolchain_esp_idf:
|
||||||
return self.relative_build_path(
|
return self.relative_build_path(
|
||||||
"build", "partition_table", "partition-table.bin"
|
"build", "partition_table", "partition-table.bin"
|
||||||
@@ -800,6 +839,8 @@ class EsphomeCore:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def bootloader_bin(self) -> Path:
|
def bootloader_bin(self) -> Path:
|
||||||
|
if (prebuilt := self.prebuilt_artifact_path("bootloader.bin")) is not None:
|
||||||
|
return prebuilt
|
||||||
if self.using_toolchain_esp_idf:
|
if self.using_toolchain_esp_idf:
|
||||||
return self.relative_build_path("build", "bootloader", "bootloader.bin")
|
return self.relative_build_path("build", "bootloader", "bootloader.bin")
|
||||||
return self.relative_pioenvs_path(self.name, "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)
|
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):
|
def _run_idedata(config):
|
||||||
args = ["-t", "idedata"]
|
args = ["-t", "idedata"]
|
||||||
stdout = run_platformio_cli_run(config, False, *args, capture_stdout=True)
|
stdout = run_platformio_cli_run(config, False, *args, capture_stdout=True)
|
||||||
@@ -101,7 +131,60 @@ def _run_idedata(config):
|
|||||||
raise
|
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):
|
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")
|
platformio_ini = CORE.relative_build_path("platformio.ini")
|
||||||
temp_idedata = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
temp_idedata = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -401,6 +402,46 @@ def get_serial_ports() -> list[SerialPort]:
|
|||||||
|
|
||||||
PICOTOOL_PACKAGE = "tool-picotool-rp2040-earlephilhower"
|
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:
|
def get_picotool_path(cc_path: str) -> Path | None:
|
||||||
"""Derive the picotool binary path from the PlatformIO toolchain cc_path.
|
"""Derive the picotool binary path from the PlatformIO toolchain cc_path.
|
||||||
|
|||||||
@@ -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"
|
"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):
|
def test_add_library__extracts_short_name_from_path(self, target):
|
||||||
"""Test add_library extracts short name from library paths like owner/lib."""
|
"""Test add_library extracts short name from library paths like owner/lib."""
|
||||||
target.data[const.KEY_CORE] = {
|
target.data[const.KEY_CORE] = {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ from esphome.__main__ import (
|
|||||||
show_logs,
|
show_logs,
|
||||||
upload_program,
|
upload_program,
|
||||||
upload_using_esptool,
|
upload_using_esptool,
|
||||||
|
upload_using_ltchiptool,
|
||||||
upload_using_picotool,
|
upload_using_picotool,
|
||||||
upload_using_platformio,
|
upload_using_platformio,
|
||||||
)
|
)
|
||||||
@@ -1135,6 +1136,7 @@ class MockArgs:
|
|||||||
ota_platform: str | None = None
|
ota_platform: str | None = None
|
||||||
partition_table: bool = False
|
partition_table: bool = False
|
||||||
bootloader: bool = False
|
bootloader: bool = False
|
||||||
|
prebuilt_dir: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def test_upload_program_serial_esp32(
|
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")
|
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(
|
@pytest.mark.parametrize(
|
||||||
"platform,device",
|
"platform,device",
|
||||||
[
|
[
|
||||||
@@ -1372,6 +1409,127 @@ def test_upload_program_serial_platformio_platforms(
|
|||||||
mock_upload_using_platformio.assert_called_once_with(config, device)
|
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(
|
def test_upload_using_platformio_creates_signed_bin_for_rp2040(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1412,6 +1570,226 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040(
|
|||||||
assert result == 0
|
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(
|
def test_upload_program_serial_upload_failed(
|
||||||
mock_upload_using_esptool: Mock,
|
mock_upload_using_esptool: Mock,
|
||||||
mock_get_port_type: Mock,
|
mock_get_port_type: Mock,
|
||||||
@@ -1434,6 +1812,145 @@ def test_upload_program_serial_upload_failed(
|
|||||||
mock_upload_using_esptool.assert_called_once()
|
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(
|
def test_upload_program_bootsel(
|
||||||
mock_upload_using_picotool: Mock,
|
mock_upload_using_picotool: Mock,
|
||||||
mock_get_port_type: 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"
|
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(
|
def test_run_idedata_parses_json_from_output(
|
||||||
setup_core: Path, mock_run_platformio_cli_run: Mock
|
setup_core: Path, mock_run_platformio_cli_run: Mock
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -582,6 +582,55 @@ def test_run_external_process_line_callbacks() -> None:
|
|||||||
assert any("from subprocess" in r for r in results)
|
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:
|
def test_get_picotool_path_found(tmp_path: Path) -> None:
|
||||||
"""Test picotool path derivation from cc_path."""
|
"""Test picotool path derivation from cc_path."""
|
||||||
# Create the expected directory structure
|
# Create the expected directory structure
|
||||||
|
|||||||
Reference in New Issue
Block a user