[espidf] Fail the factory image when its bootloader is missing

This commit is contained in:
J. Nick Koston
2026-09-29 20:42:35 +02:00
parent 273bafd988
commit 91b0b8669e
4 changed files with 50 additions and 10 deletions
+11 -3
View File
@@ -355,6 +355,11 @@ def ensure_cached_bootloader(verbose: bool = False) -> bool:
def _install_cached(app_config: dict, compiler: str, verbose: bool) -> Path | None:
"""The installed build/bootloader dir, building and publishing on a miss;
None means fall back to the in-tree build."""
from esphome.espidf.framework import read_idf_version_txt
if not read_idf_version_txt(toolchain._get_idf_path()):
_LOGGER.debug("Framework version.txt missing; cannot key the bootloader")
return None
if (names := _load_config_names()) is not None:
entry = _cache_root() / _compute_key(_key_payload(names, app_config, compiler))
if (entry / "bootloader.bin").is_file():
@@ -389,14 +394,16 @@ def _install_cached(app_config: dict, compiler: str, verbose: bool) -> Path | No
return _install_into_build(entry)
def inject_bootloader_flash_file(flash_data: dict, build_dir: Path) -> None:
def inject_bootloader_flash_file(flash_data: dict, build_dir: Path) -> bool:
"""Add the cached bootloader to a flasher_args ``flash_files`` map.
Gated on the tree's mode: IDF also omits the entry on purpose for some
secure-boot builds, and those must stay exactly as IDF wrote them.
False means a cached-mode tree is missing pieces and the factory image
would not boot.
"""
if not tree_uses_cached_bootloader(build_dir):
return
return True
app_config = toolchain._load_sdkconfig_json(build_dir)
if (
app_config is None
@@ -404,6 +411,7 @@ def inject_bootloader_flash_file(flash_data: dict, build_dir: Path) -> None:
or not (build_dir / "bootloader" / "bootloader.bin").is_file()
):
_LOGGER.error("Cached bootloader missing; factory image has no bootloader")
return
return False
flash_files = flash_data.setdefault("flash_files", {})
flash_files[hex(_bootloader_offset(app_config))] = "bootloader/bootloader.bin"
return True
+2 -1
View File
@@ -854,7 +854,8 @@ def create_factory_bin() -> bool:
from esphome.espidf import bootloader
bootloader.inject_bootloader_flash_file(flash_data, build_dir)
if not bootloader.inject_bootloader_flash_file(flash_data, build_dir):
return False
# Get flash size from config
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
+21 -6
View File
@@ -466,6 +466,7 @@ def _orchestration_env(tmp_path: Path) -> Iterator[Path]:
patch.object(bootloader, "_cache_root", return_value=root),
patch.object(toolchain, "_resolved_c_compiler", return_value="/tc/gcc"),
patch.object(bootloader, "_key_payload", return_value={}),
patch("esphome.espidf.framework.read_idf_version_txt", return_value="v5.5.5"),
patch.object(bootloader, "_compute_key", return_value="deadbeefdeadbeef"),
):
yield root
@@ -550,6 +551,17 @@ def test_ensure_rejects_an_unreproducible_bootloader(
assert not list(root.glob(".build-*"))
def test_ensure_soft_fails_without_a_version_stamp(tmp_path: Path) -> None:
"""An unidentifiable framework install must not be keyed or cached."""
with (
_orchestration_env(tmp_path),
patch("esphome.espidf.framework.read_idf_version_txt", return_value=""),
patch.object(bootloader, "_build_standalone") as mock_build,
):
assert bootloader.ensure_cached_bootloader() is False
mock_build.assert_not_called()
def test_ensure_soft_fails_when_the_probe_build_fails(tmp_path: Path) -> None:
rcs = iter([0, 2])
@@ -618,14 +630,14 @@ def _flash_build(tmp_path: Path, cached: bool = True, offset: int = 0x1000) -> P
def test_inject_adds_cached_bootloader(tmp_path: Path) -> None:
build = _flash_build(tmp_path)
flash_data = {"flash_files": {"0x10000": "dev.bin"}}
bootloader.inject_bootloader_flash_file(flash_data, build)
assert bootloader.inject_bootloader_flash_file(flash_data, build) is True
assert flash_data["flash_files"]["0x1000"] == "bootloader/bootloader.bin"
def test_inject_uses_configured_offset(tmp_path: Path) -> None:
build = _flash_build(tmp_path, offset=0)
flash_data: dict = {}
bootloader.inject_bootloader_flash_file(flash_data, build)
assert bootloader.inject_bootloader_flash_file(flash_data, build) is True
assert flash_data["flash_files"] == {"0x0": "bootloader/bootloader.bin"}
@@ -634,7 +646,7 @@ def test_inject_noop_on_a_stock_tree(tmp_path: Path) -> None:
not in cached mode must stay exactly as IDF wrote it."""
build = _flash_build(tmp_path, cached=False)
flash_data: dict = {"flash_files": {}}
bootloader.inject_bootloader_flash_file(flash_data, build)
assert bootloader.inject_bootloader_flash_file(flash_data, build) is True
assert flash_data == {"flash_files": {}}
@@ -649,7 +661,10 @@ def test_tree_uses_cached_bootloader(tmp_path: Path) -> None:
def test_inject_noop_without_cmakecache(tmp_path: Path) -> None:
flash_data: dict = {"flash_files": {}}
bootloader.inject_bootloader_flash_file(flash_data, tmp_path / "missing")
assert (
bootloader.inject_bootloader_flash_file(flash_data, tmp_path / "missing")
is True
)
assert flash_data == {"flash_files": {}}
@@ -661,9 +676,9 @@ def test_inject_missing_pieces_log_an_error(
(build / "config" / "sdkconfig.json").write_text("{}") # no offset
flash_data: dict = {"flash_files": {}}
with caplog.at_level("ERROR"):
bootloader.inject_bootloader_flash_file(flash_data, build)
assert bootloader.inject_bootloader_flash_file(flash_data, build) is False
(build / "config" / "sdkconfig.json").unlink()
with caplog.at_level("ERROR"):
bootloader.inject_bootloader_flash_file(flash_data, build)
assert bootloader.inject_bootloader_flash_file(flash_data, build) is False
assert flash_data == {"flash_files": {}}
assert caplog.text.count("factory image has no bootloader") == 2
+16
View File
@@ -1222,6 +1222,22 @@ def test_create_factory_bin_merges_cached_bootloader(setup_core: Path) -> None:
assert offset_index < cmd.index("0x10000")
def test_create_factory_bin_fails_when_the_cached_bootloader_is_missing(
setup_core: Path,
) -> None:
"""A factory image without its bootloader would not boot; fail instead."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
"ESPHOME_USE_CACHED_BOOTLOADER:UNINITIALIZED=1\n"
)
(build / "flasher_args.json").write_text('{"flash_files": {}}')
with patch.object(toolchain.subprocess, "run") as mock_run:
assert toolchain.create_factory_bin() is False
mock_run.assert_not_called()
def test_idf_target_from_variant(setup_core: Path) -> None:
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32C6"}
assert toolchain._idf_target() == "esp32c6"