diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 48bb7bf6a1..8ea08b37d2 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3249,7 +3249,13 @@ def _write_sdkconfig(): if write_file_if_changed(internal_path, contents): # internal changed, update real one write_file_if_changed(sdk_path, contents) - clean_build(clear_pio_cache=False) + if not CORE.using_toolchain_esp_idf: + # PIO's dependency tracking under-declares sdkconfig inputs + # (ldgen, linker scripts); without a clean the image can be + # unbootable (esphome#15336). The esp-idf toolchain tracks + # sdkconfig via IDF's cmake and has_outdated_files(), so a + # reconfigure suffices there; everything else fails safe. + clean_build(clear_pio_cache=False) def _write_idf_component_yml(): diff --git a/tests/unit_tests/components/esp32/test_sdkconfig.py b/tests/unit_tests/components/esp32/test_sdkconfig.py new file mode 100644 index 0000000000..b5a562f4d1 --- /dev/null +++ b/tests/unit_tests/components/esp32/test_sdkconfig.py @@ -0,0 +1,72 @@ +"""Tests for the esp32 sdkconfig write and its toolchain-gated clean.""" + +from __future__ import annotations + +import os +from pathlib import Path +import time +from unittest.mock import patch + +import pytest + +from esphome.components.esp32 import _write_sdkconfig +from esphome.components.esp32.const import KEY_SDKCONFIG_OPTIONS +from esphome.const import KEY_CORE, KEY_ESP32, KEY_FRAMEWORK_VERSION, Toolchain +from esphome.core import CORE +from esphome.espidf.toolchain import has_outdated_files + + +def _setup_core(tmp_path: Path, toolchain: Toolchain | None) -> None: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path + CORE.toolchain = toolchain + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {"CONFIG_X": "y"}} + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: "5.5.5"} + + +def _seed_configured_build(tmp_path: Path) -> None: + """A settled native build: configure outputs predate what comes next.""" + build = tmp_path / "build" + (build / "config").mkdir(parents=True) + (build / "config" / "sdkconfig.h").write_text("") + (build / "CMakeCache.txt").write_text("") + (build / "build.ninja").write_text("") + # Explicitly older than what the test writes next: has_outdated_files() + # compares st_mtime with a strict >, so same-tick writes would pass + past = time.time() - 60 + for f in build.rglob("*"): + os.utime(f, (past, past)) + + +@pytest.mark.parametrize( + ("toolchain", "clean_expected"), + [(Toolchain.ESP_IDF, False), (Toolchain.PLATFORMIO, True), (None, True)], +) +def test_write_sdkconfig_cleans_only_on_platformio( + tmp_path: Path, toolchain: Toolchain | None, clean_expected: bool +) -> None: + """A changed sdkconfig forces a full clean only under PlatformIO; the + esp-idf toolchain reconfigures via has_outdated_files() instead; an + unresolved toolchain fails safe onto the clean.""" + _setup_core(tmp_path, toolchain) + _seed_configured_build(tmp_path) + with ( + patch.object(CORE, "name", "test"), + patch("esphome.components.esp32.clean_build") as clean, + ): + _write_sdkconfig() + assert "CONFIG_X" in CORE.relative_build_path("sdkconfig.test").read_text() + assert clean.called is clean_expected + if clean_expected: + clean.assert_called_once_with(clear_pio_cache=False) + # The change must still trigger a reconfigure: the internal + # sdkconfig snapshot is now newer than build/CMakeCache.txt + assert has_outdated_files() is True + clean.reset_mock() + # A settled configure restamps the cache; an unchanged rewrite + # must then neither clean nor mark the build stale + future = time.time() + 60 + os.utime(CORE.relative_build_path("build/CMakeCache.txt"), (future, future)) + _write_sdkconfig() + clean.assert_not_called() + assert has_outdated_files() is False