diff --git a/esphome/espidf/bootloader.py b/esphome/espidf/bootloader.py index 07fb3c3415b..65237a371ac 100644 --- a/esphome/espidf/bootloader.py +++ b/esphome/espidf/bootloader.py @@ -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 diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 58abfa57e0b..fd84791e1d7 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -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] diff --git a/tests/unit_tests/test_espidf_bootloader.py b/tests/unit_tests/test_espidf_bootloader.py index 0108771c13c..2a86e76b21c 100644 --- a/tests/unit_tests/test_espidf_bootloader.py +++ b/tests/unit_tests/test_espidf_bootloader.py @@ -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 diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 73b951d11d6..878c93a00de 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -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"