mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 10:31:29 +00:00
[cli] Add esphome upload --prebuilt-dir <path>
Adds a --prebuilt-dir flag to esphome upload that points the per-platform upload helpers at a directory of prebuilt artifacts shipped from a paired build server, instead of re-deriving paths from the local build tree. Covers every upload-dispatch shape: - ESP32 / ESP8266 serial (esptool) reads firmware.bin + extras via the prebuilt idedata.json the dashboard ships next to the artifacts. - ESP32 / ESP8266 OTA (native API + web_server) reads CORE.firmware_bin, CORE.partition_table_bin and CORE.bootloader_bin which now consult the prebuilt-dir first. - RP2040 BOOTSEL (picotool) falls back from the idedata ELF (absent in the flat layout) to the prebuilt firmware.uf2. - RP2040 serial / libretiny serial / OTA (PlatformIO upload -t nobuild) point platformio at the prebuilt build tree via CORE.build_path so the -t upload -t nobuild path finds platformio.ini and .pioenvs/<name>/. Issue: esphome/device-builder#572
This commit is contained in:
@@ -870,6 +870,89 @@ 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.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.prebuilt_dir = tmp_path
|
||||
(tmp_path / "firmware.uf2").write_bytes(b"uf2")
|
||||
|
||||
assert target.firmware_bin == tmp_path / "firmware.uf2"
|
||||
|
||||
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] = {
|
||||
|
||||
@@ -1135,6 +1135,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(
|
||||
@@ -1412,6 +1413,95 @@ 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_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_api.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,
|
||||
|
||||
@@ -236,6 +236,61 @@ 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 = platformio_api._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_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 = platformio_api._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:
|
||||
|
||||
Reference in New Issue
Block a user