[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:
J. Nick Koston
2026-05-10 22:28:19 -05:00
parent e9cc10fedc
commit 255d4c6b65
6 changed files with 336 additions and 5 deletions
+83
View File
@@ -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] = {
+90
View File
@@ -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,
+55
View File
@@ -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: