From 5cc1f001fa4b0d5512a2da37aefa572354099ddb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:12:58 -0500 Subject: [PATCH 1/8] [light] Skip light_json_schema.cpp when json is not used (#18675) --- esphome/components/light/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 175f5b43cf..dbcc28d64a 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -7,6 +7,7 @@ import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -557,3 +558,10 @@ async def new_light(config, *args): @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(light_ns.using) + + +# light_json_schema.cpp is only used by mqtt and web_server, which both +# auto load json; USE_JSON alone is too broad since other components load it. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"light_json_schema.cpp": ("USE_MQTT", "USE_WEBSERVER")} +) From 0ecc7045fa3354e5195a749d3f42cd469ba7d3ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:13:34 -0500 Subject: [PATCH 2/8] [core] Parse the common on/off spellings in boolean env vars (#18667) --- esphome/helpers.py | 13 +++++++++++-- tests/unit_tests/test_helpers.py | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 7aa1a9a88c..b3102ca277 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -31,6 +31,13 @@ SockAddr = IPv4SockAddr | IPv6SockAddr _LOGGER = logging.getLogger(__name__) +# cv.boolean's closed spelling tables, shared with the env-knob parsing below +TRUTHY_BOOL_STRINGS = frozenset({"true", "yes", "on", "enable"}) +FALSY_BOOL_STRINGS = frozenset({"false", "no", "off", "disable"}) +# cv.boolean's spelling tables plus the 1/0 env convention +TRUTHY_ENV_STRINGS = TRUTHY_BOOL_STRINGS | {"1"} +FALSY_ENV_STRINGS = FALSY_BOOL_STRINGS | {"0"} + IS_MACOS = platform.system() == "Darwin" IS_WINDOWS = platform.system() == "Windows" IS_LINUX = platform.system() == "Linux" @@ -395,12 +402,14 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: def get_bool_env(var, default=False): + """Read a boolean env var: the ``cv.boolean`` spellings plus ``1``/``0``; + anything else falls through to ``bool(value)``.""" value = os.getenv(var, default) if isinstance(value, str): value = value.lower() - if value in ["1", "true"]: + if value in TRUTHY_ENV_STRINGS: return True - if value in ["0", "false"]: + if value in FALSY_ENV_STRINGS: return False return bool(value) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index eaa7d5a8dc..3160469063 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -171,6 +171,13 @@ def test_is_ip_address__valid(value): ("FOO", "fAlSe", True, False), ("FOO", "Yes", False, True), ("FOO", "123", False, True), + # cv.boolean's spellings; falsy rows use default=True on purpose + ("FOO", "on", False, True), + ("FOO", "enable", False, True), + ("FOO", "no", True, False), + ("FOO", "off", True, False), + ("FOO", "OFF", True, False), + ("FOO", "Disable", True, False), ), ) def test_get_bool_env(monkeypatch, var, value, default, expected): From 4510f104917fbd5f9a5ae4847a74afc3989ab44e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:13:57 -0500 Subject: [PATCH 3/8] [core] Treat a malformed build_info.json as stale instead of crashing (#18669) --- esphome/writer.py | 21 ++- tests/unit_tests/test_writer.py | 243 +++++++++++++++----------------- 2 files changed, 130 insertions(+), 134 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index 866377d2f5..d614204603 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -337,12 +337,29 @@ def copy_src_tree(): else: try: existing = json.loads(build_info_json_path.read_text(encoding="utf-8")) - if ( + if not isinstance(existing, dict) or ( existing.get("config_hash") != config_hash or existing.get("esphome_version") != __version__ ): + # Non-object JSON is stale like every other damage case sources_changed = True - except (json.JSONDecodeError, KeyError, OSError): + except FileNotFoundError: + # An absent build_info.json is stale, not damaged; rebuild quietly + sources_changed = True + except (ValueError, OSError) as err: + # ValueError covers both JSONDecodeError and UnicodeDecodeError; + # unlink so the regenerating write never re-reads the bad copy. + # "Unreadable" not "damaged": EACCES/EISDIR land here too + _LOGGER.warning("Regenerating unreadable build_info.json: %s", err) + try: + # missing_ok: a concurrent clean may have removed it already + build_info_json_path.unlink(missing_ok=True) + except OSError as unlink_err: + # The later write re-reads the file, so a kept unreadable copy + # fails again with a misattributed error; name the real cause + _LOGGER.warning( + "Could not remove unreadable build_info.json: %s", unlink_err + ) sources_changed = True # Write build_info header and JSON metadata diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 46e60ebd8e..c73a5c5789 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2041,6 +2041,38 @@ def test_copy_src_tree_writes_build_info_files( assert build_info_json["esphome_version"] == "2025.1.0-dev" +def _setup_build_info_mocks( + mock_core: MagicMock, + mock_iter_components: MagicMock, + mock_walk_files: MagicMock, + tmp_path: Path, +) -> Path: + """Point CORE at tmp_path and return the build_info.json path.""" + src_path = tmp_path / "src" + (src_path / "esphome" / "core").mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + mock_core.relative_src_path.side_effect = src_path.joinpath + mock_core.relative_build_path.side_effect = build_path.joinpath + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.comment = "" + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + mock_walk_files.return_value = [] + return build_path / "build_info.json" + + +def _run_copy_src_tree(version: str = "2025.1.0-dev") -> None: + with ( + patch("esphome.writer.__version__", version), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files") @@ -2050,59 +2082,17 @@ def test_copy_src_tree_detects_config_hash_change( mock_core: MagicMock, tmp_path: Path, ) -> None: - """Test copy_src_tree detects when config_hash changes.""" - # Setup directory structure - src_path = tmp_path / "src" - src_path.mkdir() - esphome_core_path = src_path / "esphome" / "core" - esphome_core_path.mkdir(parents=True) - build_path = tmp_path / "build" - build_path.mkdir() - - # Create existing build_info.json with different config_hash - build_info_json_path = build_path / "build_info.json" - build_info_json_path.write_text( - json.dumps( - { - "config_hash": 0x12345678, # Different from current - "build_time": 1700000000, - "build_time_str": "2023-11-14 22:13:20 +0000", - "esphome_version": "2025.1.0-dev", - } - ) + """A changed config_hash regenerates build_info after a steady-state run.""" + build_info_json_path = _setup_build_info_mocks( + mock_core, mock_iter_components, mock_walk_files, tmp_path ) + _run_copy_src_tree() + assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xDEADBEEF - # Create existing build_info_data.h - build_info_h_path = esphome_core_path / "build_info_data.h" - build_info_h_path.write_text("// old build_info_data.h") - - # Setup mocks - mock_core.relative_src_path.side_effect = src_path.joinpath - mock_core.relative_build_path.side_effect = build_path.joinpath - mock_core.defines = [] - mock_core.config_hash = 0xDEADBEEF # Different from existing - mock_core.comment = "" - mock_core.target_platform = "test_platform" - mock_core.config = {} - mock_iter_components.return_value = [] - mock_walk_files.return_value = [] - - with ( - patch("esphome.writer.__version__", "2025.1.0-dev"), - patch("esphome.writer.importlib.import_module") as mock_import, - ): - mock_import.side_effect = AttributeError - copy_src_tree() - - # Verify build_info files were updated due to config_hash change - assert build_info_h_path.exists() - build_info_cpp_path = esphome_core_path / "build_info_data.cpp" - assert build_info_cpp_path.exists() - new_content = build_info_cpp_path.read_text() - assert "0xdeadbeef" in new_content.lower() - - new_json = json.loads(build_info_json_path.read_text()) - assert new_json["config_hash"] == 0xDEADBEEF + # Second run only regenerates if the hash comparison detects the change + mock_core.config_hash = 0xC0FFEE + _run_copy_src_tree() + assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xC0FFEE @patch("esphome.writer.CORE") @@ -2114,104 +2104,93 @@ def test_copy_src_tree_detects_version_change( mock_core: MagicMock, tmp_path: Path, ) -> None: - """Test copy_src_tree detects when esphome_version changes.""" - # Setup directory structure - src_path = tmp_path / "src" - src_path.mkdir() - esphome_core_path = src_path / "esphome" / "core" - esphome_core_path.mkdir(parents=True) - build_path = tmp_path / "build" - build_path.mkdir() - - # Create existing build_info.json with different version - build_info_json_path = build_path / "build_info.json" - build_info_json_path.write_text( - json.dumps( - { - "config_hash": 0xDEADBEEF, - "build_time": 1700000000, - "build_time_str": "2023-11-14 22:13:20 +0000", - "esphome_version": "2024.12.0", # Old version - } - ) + """A changed esphome_version regenerates build_info after a steady-state run.""" + build_info_json_path = _setup_build_info_mocks( + mock_core, mock_iter_components, mock_walk_files, tmp_path ) - - # Create existing build_info_data.h - build_info_h_path = esphome_core_path / "build_info_data.h" - build_info_h_path.write_text("// old build_info_data.h") - - # Setup mocks - mock_core.relative_src_path.side_effect = src_path.joinpath - mock_core.relative_build_path.side_effect = build_path.joinpath - mock_core.defines = [] - mock_core.config_hash = 0xDEADBEEF - mock_core.comment = "" - mock_core.target_platform = "test_platform" - mock_core.config = {} - mock_iter_components.return_value = [] - mock_walk_files.return_value = [] - - with ( - patch("esphome.writer.__version__", "2025.1.0-dev"), # New version - patch("esphome.writer.importlib.import_module") as mock_import, - ): - mock_import.side_effect = AttributeError - copy_src_tree() - - # Verify build_info files were updated due to version change - assert build_info_h_path.exists() + # Pin version.h so only the build_info comparison can see the bump + with patch("esphome.writer.generate_version_h", return_value="// version.h\n"): + _run_copy_src_tree(version="2024.12.0") + _run_copy_src_tree(version="2025.1.0-dev") new_json = json.loads(build_info_json_path.read_text()) assert new_json["esphome_version"] == "2025.1.0-dev" +@pytest.mark.parametrize( + "damage", + (b"invalid json {{{", b"[]", b'\xff{"config_hash": 1}'), + ids=("invalid-json", "non-object", "non-utf8"), +) +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_regenerates_damaged_build_info( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, + damage: bytes, +) -> None: + """A damaged build_info.json reads as stale and is regenerated, not left in place.""" + build_info_json_path = _setup_build_info_mocks( + mock_core, mock_iter_components, mock_walk_files, tmp_path + ) + _run_copy_src_tree() + build_info_json_path.write_bytes(damage) + # Second run only rewrites the file if the damage branch fires + _run_copy_src_tree() + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["config_hash"] == 0xDEADBEEF + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files") -def test_copy_src_tree_handles_invalid_build_info_json( +def test_copy_src_tree_missing_build_info_rebuilds_quietly( mock_walk_files: MagicMock, mock_iter_components: MagicMock, mock_core: MagicMock, tmp_path: Path, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test copy_src_tree handles invalid build_info.json gracefully.""" - # Setup directory structure - src_path = tmp_path / "src" - src_path.mkdir() - esphome_core_path = src_path / "esphome" / "core" - esphome_core_path.mkdir(parents=True) - build_path = tmp_path / "build" - build_path.mkdir() + """An absent build_info.json regenerates without claiming damage.""" + build_info_json_path = _setup_build_info_mocks( + mock_core, mock_iter_components, mock_walk_files, tmp_path + ) + _run_copy_src_tree() + build_info_json_path.unlink() + _run_copy_src_tree() + assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xDEADBEEF + assert "unreadable" not in caplog.text - # Create invalid build_info.json - build_info_json_path = build_path / "build_info.json" + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_unremovable_damaged_build_info_is_logged( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed unlink of the damaged file names the real cause.""" + build_info_json_path = _setup_build_info_mocks( + mock_core, mock_iter_components, mock_walk_files, tmp_path + ) + _run_copy_src_tree() build_info_json_path.write_text("invalid json {{{") + real_unlink = Path.unlink - # Create existing build_info_data.h - build_info_h_path = esphome_core_path / "build_info_data.h" - build_info_h_path.write_text("// old build_info_data.h") + def fail_on_build_info(self: Path, missing_ok: bool = False) -> None: + if self.name == "build_info.json": + raise OSError("simulated EACCES") + real_unlink(self, missing_ok=missing_ok) - # Setup mocks - mock_core.relative_src_path.side_effect = src_path.joinpath - mock_core.relative_build_path.side_effect = build_path.joinpath - mock_core.defines = [] - mock_core.config_hash = 0xDEADBEEF - mock_core.comment = "" - mock_core.target_platform = "test_platform" - mock_core.config = {} - mock_iter_components.return_value = [] - mock_walk_files.return_value = [] - - with ( - patch("esphome.writer.__version__", "2025.1.0-dev"), - patch("esphome.writer.importlib.import_module") as mock_import, - ): - mock_import.side_effect = AttributeError - copy_src_tree() - - # Verify build_info files were created despite invalid JSON - assert build_info_h_path.exists() - new_json = json.loads(build_info_json_path.read_text()) - assert new_json["config_hash"] == 0xDEADBEEF + with patch.object(Path, "unlink", fail_on_build_info): + _run_copy_src_tree() + assert "Could not remove unreadable build_info.json" in caplog.text + assert json.loads(build_info_json_path.read_text())["config_hash"] == 0xDEADBEEF @patch("esphome.writer.CORE") From 25fb3983cb259f765c747c5d07c8e7afd187c2c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:14:40 -0500 Subject: [PATCH 4/8] [mqtt] Skip entity sources for entity types not in the config (#18677) --- esphome/components/mqtt/__init__.py | 38 +++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 9178bc79e5..3050ceb1a4 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -8,7 +8,10 @@ from esphome.components.esp32 import ( idf_version, include_builtin_idf_component, ) -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) import esphome.config_validation as cv from esphome.const import ( CONF_AVAILABILITY, @@ -640,7 +643,7 @@ async def mqtt_disable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_platform_filter = filter_source_files_from_platform( { "mqtt_backend_esp32.cpp": { PlatformFramework.ESP32_ARDUINO, @@ -648,3 +651,34 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, } ) + +# Each entity file is fully #ifdef'd on the USE_ define the core +# emits for entity platforms present in the config. +_define_filter = filter_source_files_from_defines( + { + "mqtt_alarm_control_panel.cpp": "USE_ALARM_CONTROL_PANEL", + "mqtt_binary_sensor.cpp": "USE_BINARY_SENSOR", + "mqtt_button.cpp": "USE_BUTTON", + "mqtt_climate.cpp": "USE_CLIMATE", + "mqtt_cover.cpp": "USE_COVER", + "mqtt_date.cpp": "USE_DATETIME_DATE", + "mqtt_datetime.cpp": "USE_DATETIME_DATETIME", + "mqtt_event.cpp": "USE_EVENT", + "mqtt_fan.cpp": "USE_FAN", + "mqtt_light.cpp": "USE_LIGHT", + "mqtt_lock.cpp": "USE_LOCK", + "mqtt_number.cpp": "USE_NUMBER", + "mqtt_select.cpp": "USE_SELECT", + "mqtt_sensor.cpp": "USE_SENSOR", + "mqtt_switch.cpp": "USE_SWITCH", + "mqtt_text.cpp": "USE_TEXT", + "mqtt_text_sensor.cpp": "USE_TEXT_SENSOR", + "mqtt_time.cpp": "USE_DATETIME_TIME", + "mqtt_update.cpp": "USE_UPDATE", + "mqtt_valve.cpp": "USE_VALVE", + } +) + + +def FILTER_SOURCE_FILES() -> list[str]: + return _platform_filter() + _define_filter() From 00bdbf8ed10d0442f0c04d81f5329ab3337f1e71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:15:21 -0500 Subject: [PATCH 5/8] [uart] Skip uart_debugger.cpp when no debug block is configured (#18678) --- esphome/components/uart/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7e3701bb07..78633bcf6a 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -5,7 +5,10 @@ import re from esphome import automation, pins import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) import esphome.config_validation as cv from esphome.const import ( CONF_AFTER, @@ -521,7 +524,7 @@ async def final_step(): cg.add_define("USE_UART_WAKE_LOOP_ON_RX") -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_platform_filter = filter_source_files_from_platform( { "uart_component_esp_idf.cpp": { PlatformFramework.ESP32_IDF, @@ -537,3 +540,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, } ) + +# uart_debugger.cpp is fully #ifdef'd on USE_UART_DEBUGGER, set only when a +# debug block is configured. +_define_filter = filter_source_files_from_defines( + {"uart_debugger.cpp": "USE_UART_DEBUGGER"} +) + + +def FILTER_SOURCE_FILES() -> list[str]: + return _platform_filter() + _define_filter() From a872626fba1d75859110059f63789cc3d8f0ac43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:15:54 -0500 Subject: [PATCH 6/8] [esp32_ble] Skip ble_advertising.cpp when advertising is not used (#18679) --- esphome/components/esp32_ble/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 79747c6f31..7e97111686 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -22,6 +22,7 @@ from esphome.components.esp32 import ( request_bluetooth, ) from esphome.components.esp32.const import VARIANT_ESP32C2 +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ENABLE_ON_BOOT, @@ -637,3 +638,10 @@ async def ble_disable_to_code( args: TemplateArgsType, ) -> MockObj: return cg.new_Pvariable(action_id, template_arg) + + +# ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set +# when advertising is enabled here or by esp32_ble_server / esp32_ble_beacon. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"ble_advertising.cpp": "USE_ESP32_BLE_ADVERTISING"} +) From bdf8dec028fe7a92a371a50e77b688a903fd7cc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:16:03 -0500 Subject: [PATCH 7/8] [i2s_audio] Skip SPDIF speaker sources when spdif_mode is off (#18676) --- esphome/components/i2s_audio/speaker/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 1849c376aa..4dc15681bf 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -1,6 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import audio, esp32, speaker +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -261,3 +262,13 @@ async def to_code(config: ConfigType) -> None: if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + + +# The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, +# set only when spdif_mode is enabled. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "spdif_encoder.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + "i2s_audio_spdif.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + } +) From 41171e61d183104a5cf733f6ebb278b82491f497 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 18:29:05 -0500 Subject: [PATCH 8/8] [core] Keep str_contains_ignore_case needle literals in flash on ESP8266 (#18574) --- esphome/core/helpers.cpp | 20 ++++++++++++++++++++ esphome/core/helpers.h | 12 +++++++++++- tests/components/core/helpers_test.cpp | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index ded8051df8..71e3c87e1e 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -233,6 +233,26 @@ bool str_contains_ignore_case_fallback(const char *haystack, const char *needle) return false; } +#ifdef USE_ESP8266 +// _P mirror of str_contains_ignore_case_fallback above; host tests cover only the fallback, +// so keep the two bodies in sync. +bool str_contains_ignore_case_p(const char *haystack, PGM_P needle) { + if (haystack == nullptr || needle == nullptr) { + return false; + } + const size_t needle_len = strlen_P(needle); + if (needle_len == 0) { + return true; + } + for (const char *p = haystack; *p != '\0'; p++) { + if (strncasecmp_P(p, needle, needle_len) == 0) { + return true; + } + } + return false; +} +#endif // USE_ESP8266 + // str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b13d92ccce..e60316d4ee 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -984,6 +984,15 @@ inline bool str_endswith_ignore_case(const std::string &str, const char *suffix) /// Fallback implementation for case insensitive substring comparison. bool str_contains_ignore_case_fallback(const char *haystack, const char *needle); +#ifdef USE_ESP8266 +/// ESP8266 internal implementation reading the needle from flash — prefer the +/// `str_contains_ignore_case` macro which wraps needle literals with PSTR() automatically. +bool str_contains_ignore_case_p(const char *haystack, PGM_P needle); +/// Case-insensitive check if needle string is contained in haystack (no heap allocation). +/// On ESP8266 the needle is wrapped with PSTR() so it stays in flash, which requires it to be +/// a string literal; a runtime needle needs str_contains_ignore_case_p behind #ifdef USE_ESP8266. +#define str_contains_ignore_case(haystack, needle) str_contains_ignore_case_p(haystack, PSTR(needle)) +#else /// Case-insensitive check if needle string is contained in haystack (no heap allocation). inline bool str_contains_ignore_case(const char *haystack, const char *needle) { if (!needle || !haystack) { @@ -991,7 +1000,7 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) { } // strcasestr is a GNU extension: newlib only declares it when _GNU_SOURCE is set. -// ESP32/ESP8266/host builds get it from their framework or from g++ on Linux; +// ESP32/host builds get it from their framework or from g++ on Linux; // LibreTiny, RP2 and Zephyr do not, so they use the hand-rolled fallback. #if defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) return str_contains_ignore_case_fallback(haystack, needle); @@ -999,6 +1008,7 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) { return strcasestr(haystack, needle) != nullptr; #endif // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) } +#endif // USE_ESP8266 // str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index d5219f9d47..521ae10e5c 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -121,7 +121,7 @@ TEST(StringContainsIgnoreCaseTest, MiscNotMatching) { TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) { const char *haystack = "Hello World"; - for (const char *needle : {"", "Hello", "hELLO", "Hell", "world", "Heaven", "Hello!", "d"}) { + for (const char *needle : {"", "Hello", "hELLO", "HELLO", "Hell", "world", "World", "Heaven", "Hello!", "d"}) { EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle)) << "needle: " << needle; }