[cli] Accept basenames in prebuilt idedata.json

The dashboard's source-routed runner ships idedata.json with bare
basenames in prog_path and extra.flash_images[*].path (the receiver's
build-host absolute paths don't resolve on the offloader, and the
in-memory Web Serial consumer keys by basename). Without this change,
the dashboard would have to write a fresh idedata.json into the staging
tmpdir on every install just to flip basenames to absolute paths.

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

Issue: esphome/device-builder#570
Issue: esphome/device-builder#572
This commit is contained in:
J. Nick Koston
2026-05-10 23:21:23 -05:00
parent 0adfd08270
commit f4607cb521
2 changed files with 111 additions and 6 deletions
+38 -6
View File
@@ -101,13 +101,43 @@ def _run_idedata(config):
raise
def _resolve_prebuilt_idedata_paths(data: dict, prebuilt_dir: Path) -> None:
"""Resolve relative paths in a prebuilt idedata.json against prebuilt_dir.
The dashboard's transparent-install pipeline rewrites ``prog_path`` and
``extra.flash_images[*].path`` to bare basenames before shipping the
tarball (the receiver's build-host absolute paths don't resolve on the
offloader). Accept both shapes: absolute paths pass through unchanged
so a hand-built --prebuilt-dir with absolute paths still works, and
bare basenames or 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 by virtue of running on the same machine
as ``esphome upload``.
"""
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):
# `esphome upload --prebuilt-dir` ships a pre-rendered idedata.json next
# to the artifacts. When present we use it verbatim: ``firmware_bin_path``
# (i.e. ``prog_path`` with the ``.bin`` suffix), ``firmware_elf_path``,
# ``cc_path`` and ``extra.flash_images[*].path`` are expected to be
# absolute paths that resolve under ``CORE.prebuilt_dir``. The dashboard
# is responsible for rewriting those paths when it stages the directory.
# to the artifacts. When present we use it as-is, with one rewrite pass:
# ``prog_path`` and ``extra.flash_images[*].path`` may be either absolute
# paths (hand-built directories) or bare basenames (the dashboard's wire
# format) -- relative paths are resolved against ``CORE.prebuilt_dir`` so
# both shapes work without the dashboard having to write a fresh
# idedata.json with absolute paths on every install.
#
# No schema validation or referenced-path existence check happens here;
# a malformed prebuilt idedata.json will surface as a downstream
# "file not found" from esptool / picotool. That's an acceptable trade
@@ -115,7 +145,9 @@ def _load_idedata(config):
if CORE.prebuilt_dir is not None:
prebuilt_idedata = CORE.prebuilt_dir / "idedata.json"
if prebuilt_idedata.is_file():
return json.loads(prebuilt_idedata.read_text(encoding="utf-8"))
data = json.loads(prebuilt_idedata.read_text(encoding="utf-8"))
_resolve_prebuilt_idedata_paths(data, CORE.prebuilt_dir)
return data
platformio_ini = CORE.relative_build_path("platformio.ini")
temp_idedata = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
+73
View File
@@ -263,6 +263,79 @@ def test_load_idedata_uses_prebuilt_dir_when_set(
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 = platformio_api._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()
abs_bootloader = "/somewhere/else/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 = platformio_api._load_idedata({"name": "test"})
assert result["prog_path"] == str(prebuilt_dir / "firmware.elf")
assert result["extra"]["flash_images"][0]["path"] == abs_bootloader
def test_load_idedata_falls_back_when_prebuilt_idedata_missing(
setup_core: Path, mock_run_platformio_cli_run: Mock
) -> None: