diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c758b3ef8f..2edd69c6c0 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -294,6 +294,7 @@ async def to_code(config): if config[CONF_ADVERTISING]: cg.add_define("USE_ESP32_BLE_ADVERTISING") + cg.add_define("USE_ESP32_BLE_UUID") @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index aa0a688fa0..bfa1b726f1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -212,7 +212,7 @@ def validate_use_legacy(value): f"All i2s_audio components must set {CONF_USE_LEGACY} to the same value." ) if (not value[CONF_USE_LEGACY]) and (CORE.using_arduino): - raise cv.Invalid("Arduino supports only the legacy i2s driver.") + raise cv.Invalid("Arduino supports only the legacy i2s driver") _use_legacy_driver = value[CONF_USE_LEGACY] return value diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player/__init__.py index ad6665a5f5..316ce7c48b 100644 --- a/esphome/components/i2s_audio/media_player/__init__.py +++ b/esphome/components/i2s_audio/media_player/__init__.py @@ -92,7 +92,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(_): if not use_legacy(): - raise cv.Invalid("I2S media player is only compatible with legacy i2s driver.") + raise cv.Invalid("I2S media player is only compatible with legacy i2s driver") FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 0f02ba6c3a..f919199c60 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -122,7 +122,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config): if not use_legacy() and config[CONF_ADC_TYPE] == "internal": - raise cv.Invalid("Internal ADC is only compatible with legacy i2s driver.") + raise cv.Invalid("Internal ADC is only compatible with legacy i2s driver") FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index cb7b876a40..98322d3a18 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -163,7 +163,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config): if not use_legacy(): if config[CONF_DAC_TYPE] == "internal": - raise cv.Invalid("Internal DAC is only compatible with legacy i2s driver.") + raise cv.Invalid("Internal DAC is only compatible with legacy i2s driver") if config[CONF_I2S_COMM_FMT] == "stand_max": raise cv.Invalid( "I2S standard max format only implemented with legacy i2s driver." diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index cde8752157..8cd7115368 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -201,7 +201,7 @@ def _validate_manifest_version(manifest_data): else: raise cv.Invalid("Invalid manifest version") else: - raise cv.Invalid("Invalid manifest file, missing 'version' key.") + raise cv.Invalid("Invalid manifest file, missing 'version' key") def _process_http_source(config): @@ -421,7 +421,7 @@ def _feature_step_size_validate(config): if features_step_size is None: features_step_size = model_step_size elif features_step_size != model_step_size: - raise cv.Invalid("Cannot load models with different features step sizes.") + raise cv.Invalid("Cannot load models with different features step sizes") FINAL_VALIDATE_SCHEMA = cv.All( diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 3ae7b980d3..69ea0a53c6 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -147,7 +147,7 @@ def _read_audio_file_and_type(file_config): elif file_source == TYPE_WEB: path = _compute_local_file_path(conf_file) else: - raise cv.Invalid("Unsupported file source.") + raise cv.Invalid("Unsupported file source") with open(path, "rb") as f: data = f.read() @@ -219,7 +219,7 @@ def _validate_supported_local_file(config): for file_config in config.get(CONF_FILES, []): _, media_file_type = _read_audio_file_and_type(file_config) if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]): - raise cv.Invalid("Unsupported local media file.") + raise cv.Invalid("Unsupported local media file") if not config[CONF_CODEC_SUPPORT_ENABLED] and str(media_file_type) != str( audio.AUDIO_FILE_TYPE_ENUM["WAV"] ): diff --git a/esphome/writer.py b/esphome/writer.py index b5c834722a..17fd3cfc48 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -86,7 +86,10 @@ def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool: if old.src_version != new.src_version: return True - return old.build_path != new.build_path + if old.build_path != new.build_path: + return True + # Check if any components have been removed + return bool(old.loaded_integrations - new.loaded_integrations) def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool: @@ -108,7 +111,14 @@ def update_storage_json(): return if storage_should_clean(old, new): - _LOGGER.info("Core config, version changed, cleaning build files...") + if old and old.loaded_integrations - new.loaded_integrations: + removed = old.loaded_integrations - new.loaded_integrations + _LOGGER.info( + "Components removed (%s), cleaning build files...", + ", ".join(sorted(removed)), + ) + else: + _LOGGER.info("Core config or version changed, cleaning build files...") clean_build() elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py new file mode 100644 index 0000000000..7f9f97826f --- /dev/null +++ b/tests/unit_tests/test_writer.py @@ -0,0 +1,139 @@ +"""Test writer module functionality.""" + +from collections.abc import Callable +from typing import Any + +import pytest + +from esphome.storage_json import StorageJSON +from esphome.writer import storage_should_clean + + +@pytest.fixture +def create_storage() -> Callable[..., StorageJSON]: + """Factory fixture to create StorageJSON instances.""" + + def _create( + loaded_integrations: list[str] | None = None, **kwargs: Any + ) -> StorageJSON: + return StorageJSON( + storage_version=kwargs.get("storage_version", 1), + name=kwargs.get("name", "test"), + friendly_name=kwargs.get("friendly_name", "Test Device"), + comment=kwargs.get("comment"), + esphome_version=kwargs.get("esphome_version", "2025.1.0"), + src_version=kwargs.get("src_version", 1), + address=kwargs.get("address", "test.local"), + web_port=kwargs.get("web_port", 80), + target_platform=kwargs.get("target_platform", "ESP32"), + build_path=kwargs.get("build_path", "/build"), + firmware_bin_path=kwargs.get("firmware_bin_path", "/firmware.bin"), + loaded_integrations=set(loaded_integrations or []), + loaded_platforms=kwargs.get("loaded_platforms", set()), + no_mdns=kwargs.get("no_mdns", False), + framework=kwargs.get("framework", "arduino"), + core_platform=kwargs.get("core_platform", "esp32"), + ) + + return _create + + +def test_storage_should_clean_when_old_is_none( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when old storage is None.""" + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(None, new) is True + + +def test_storage_should_clean_when_src_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when src_version changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], src_version=1) + new = create_storage(loaded_integrations=["api", "wifi"], src_version=2) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_build_path_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when build_path changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], build_path="/build1") + new = create_storage(loaded_integrations=["api", "wifi"], build_path="/build2") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_component_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when a component is removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "bluetooth_proxy", "esp32_ble_tracker"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "esp32_ble_tracker"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_multiple_components_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when multiple components are removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "ota", "web_server", "logger"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_not_clean_when_nothing_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when nothing changes.""" + old = create_storage(loaded_integrations=["api", "wifi", "logger"]) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_component_added( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when a component is only added.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=["api", "wifi", "ota"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_other_fields_change( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when non-relevant fields change.""" + old = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="Old Name", + esphome_version="2024.12.0", + ) + new = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="New Name", + esphome_version="2025.1.0", + ) + assert storage_should_clean(old, new) is False + + +def test_storage_edge_case_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has integrations but new has none.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=[]) + assert storage_should_clean(old, new) is True + + +def test_storage_edge_case_from_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has no integrations but new has some.""" + old = create_storage(loaded_integrations=[]) + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(old, new) is False