From 2290eb0dd2cac9d8b044aa011629dd1c134f2975 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:08:06 +0100 Subject: [PATCH 1/5] [light] Fix missing `ColorMode::BRIGHTNESS` case in logging (#11836) --- esphome/components/light/light_call.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 8365ac77cd..b15ff84b97 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -52,8 +52,10 @@ static void log_invalid_parameter(const char *name, const LogString *message) { } static const LogString *color_mode_to_human(ColorMode color_mode) { - if (color_mode == ColorMode::UNKNOWN) - return LOG_STR("Unknown"); + if (color_mode == ColorMode::ON_OFF) + return LOG_STR("On/Off"); + if (color_mode == ColorMode::BRIGHTNESS) + return LOG_STR("Brightness"); if (color_mode == ColorMode::WHITE) return LOG_STR("White"); if (color_mode == ColorMode::COLOR_TEMPERATURE) @@ -68,7 +70,7 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { return LOG_STR("RGB + cold/warm white"); if (color_mode == ColorMode::RGB_COLOR_TEMPERATURE) return LOG_STR("RGB + color temperature"); - return LOG_STR(""); + return LOG_STR("Unknown"); } // Helper to log percentage values From 67524e14eec345fecf60daf7251e67be7ff9a364 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:05:02 +0000 Subject: [PATCH 2/5] Bump pylint from 4.0.2 to 4.0.3 (#11894) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5c7cccaf25..8d4e0ca246 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.2 +pylint==4.0.3 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.14.4 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating From e49a943cf7cd93c5123ba4b4e10fc861d8b8f4a1 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:13:48 +0100 Subject: [PATCH 3/5] [wifi] Allow `use_psram` with Arduino (#11902) --- esphome/components/wifi/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4dbb425e4b..11bd7798e2 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,7 +12,6 @@ from esphome.components.network import ( from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.config_validation import only_with_esp_idf from esphome.const import ( CONF_AP, CONF_BSSID, @@ -352,7 +351,7 @@ CONFIG_SCHEMA = cv.All( single=True ), cv.Optional(CONF_USE_PSRAM): cv.All( - only_with_esp_idf, cv.requires_component("psram"), cv.boolean + cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), } ), From e8f2e91db3d166326429d7a7d3c690a52596f191 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Nov 2025 08:47:15 -0600 Subject: [PATCH 4/5] [sntp] Merge multiple instances to fix crash and undefined behavior --- esphome/components/sntp/time.py | 68 +++++ tests/component_tests/sntp/__init__.py | 1 + .../sntp/config/sntp_test.yaml | 22 ++ tests/component_tests/sntp/test_init.py | 238 ++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 tests/component_tests/sntp/__init__.py create mode 100644 tests/component_tests/sntp/config/sntp_test.yaml create mode 100644 tests/component_tests/sntp/test_init.py diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index d27fc9991d..69a2436d3d 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -1,9 +1,14 @@ +import logging + import esphome.codegen as cg from esphome.components import time as time_ +from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_PLATFORM, CONF_SERVERS, + CONF_TIME, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -12,13 +17,74 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["network"] + +CONF_SNTP = "sntp" + sntp_ns = cg.esphome_ns.namespace("sntp") SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] + +def _sntp_final_validate(config: ConfigType) -> None: + """Merge multiple SNTP instances into one, similar to OTA merging behavior.""" + full_conf = fv.full_config.get() + time_confs = full_conf.get(CONF_TIME, []) + + sntp_configs: list[ConfigType] = [] + other_time_configs: list[ConfigType] = [] + + for time_conf in time_confs: + if time_conf.get(CONF_PLATFORM) == CONF_SNTP: + sntp_configs.append(time_conf) + else: + other_time_configs.append(time_conf) + + if len(sntp_configs) <= 1: + return + + # Merge all SNTP configs into the first one + merged = sntp_configs[0] + for sntp_conf in sntp_configs[1:]: + # Validate that IDs are consistent if manually specified + if merged[CONF_ID].is_manual and sntp_conf[CONF_ID].is_manual: + raise cv.Invalid( + f"Found multiple SNTP configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, sntp_conf) + + # Deduplicate servers while preserving order + servers = merged[CONF_SERVERS] + unique_servers = list(dict.fromkeys(servers)) + + # Warn if we're dropping servers due to 3-server limit + if len(unique_servers) > 3: + dropped = unique_servers[3:] + unique_servers = unique_servers[:3] + _LOGGER.warning( + "SNTP supports maximum 3 servers. Dropped excess server(s): %s", + dropped, + ) + + merged[CONF_SERVERS] = unique_servers + + _LOGGER.warning( + "Found and merged %d SNTP time configurations into one instance", + len(sntp_configs), + ) + + # Replace time configs with merged SNTP + other time platforms + other_time_configs.append(merged) + full_conf[CONF_TIME] = other_time_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = cv.All( time_.TIME_SCHEMA.extend( { @@ -40,6 +106,8 @@ CONFIG_SCHEMA = cv.All( ), ) +FINAL_VALIDATE_SCHEMA = _sntp_final_validate + async def to_code(config): servers = config[CONF_SERVERS] diff --git a/tests/component_tests/sntp/__init__.py b/tests/component_tests/sntp/__init__.py new file mode 100644 index 0000000000..7d323a4980 --- /dev/null +++ b/tests/component_tests/sntp/__init__.py @@ -0,0 +1 @@ +"""Tests for SNTP component.""" diff --git a/tests/component_tests/sntp/config/sntp_test.yaml b/tests/component_tests/sntp/config/sntp_test.yaml new file mode 100644 index 0000000000..3942c9606b --- /dev/null +++ b/tests/component_tests/sntp/config/sntp_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: sntp-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "testssid" + password: "testpassword" + +# Test multiple SNTP instances that should be merged +time: + - platform: sntp + servers: + - 192.168.1.1 + - pool.ntp.org + - platform: sntp + servers: + - pool.ntp.org + - 192.168.1.2 diff --git a/tests/component_tests/sntp/test_init.py b/tests/component_tests/sntp/test_init.py new file mode 100644 index 0000000000..9197ff55d0 --- /dev/null +++ b/tests/component_tests/sntp/test_init.py @@ -0,0 +1,238 @@ +"""Tests for SNTP time configuration validation.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate +from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME +from esphome.core import ID +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("time_configs", "expected_count", "expected_servers", "warning_messages"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + } + ], + 1, + ["192.168.1.1", "pool.ntp.org"], + [], + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_preserves_order", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_3", is_manual=False), + CONF_SERVERS: ["pool3.ntp.org"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + [ + "SNTP supports maximum 3 servers. Dropped excess server(s): ['pool2.ntp.org', 'pool3.ntp.org']", + "Found and merged 3 SNTP time configurations into one instance", + ], + id="three_instances_drops_excess_servers", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: [ + "192.168.1.1", + "pool.ntp.org", + "pool.ntp.org", + "192.168.1.1", + ], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_multiple_duplicates", + ), + ], +) +def test_sntp_instance_merging( + time_configs: list[dict[str, Any]], + expected_count: int, + expected_servers: list[str], + warning_messages: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SNTP instance merging behavior.""" + # Create a mock full config with time configs + full_conf = {CONF_TIME: time_configs.copy()} + + # Set the context var + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + # Get the updated config + updated_conf = fv.full_config.get() + + # Check if merging occurred + if len(time_configs) > 1: + # Verify only one SNTP instance remains + sntp_instances = [ + tc + for tc in updated_conf[CONF_TIME] + if tc.get(CONF_PLATFORM) == CONF_SNTP + ] + assert len(sntp_instances) == expected_count + + # Verify server list + assert sntp_instances[0][CONF_SERVERS] == expected_servers + + # Verify warnings + for expected_msg in warning_messages: + assert any( + expected_msg in record.message for record in caplog.records + ), f"Expected warning message '{expected_msg}' not found in log" + else: + # Single instance should not trigger merging or warnings + assert len(caplog.records) == 0 + # Config should be unchanged + assert updated_conf[CONF_TIME] == time_configs + finally: + fv.full_config.reset(token) + + +def test_sntp_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + # Create configs with manual IDs that are inconsistent + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=True), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=True), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple SNTP configurations but id is inconsistent", + ): + _sntp_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> None: + """Test that SNTP merging doesn't affect other time platforms.""" + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: "homeassistant", + CONF_ID: ID("homeassistant_time", is_manual=False), + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + updated_conf = fv.full_config.get() + + # Should have 2 time platforms: 1 merged SNTP + 1 homeassistant + assert len(updated_conf[CONF_TIME]) == 2 + + # Find the platforms + platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]} + assert platforms == {CONF_SNTP, "homeassistant"} + + # Verify SNTP was merged + sntp_instances = [ + tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP + ] + assert len(sntp_instances) == 1 + assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"] + finally: + fv.full_config.reset(token) From 5f10fbc4f683db47324d9283bf949b46befe9173 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Nov 2025 08:59:05 -0600 Subject: [PATCH 5/5] [web_server.ota] Merge multiple instances to prevent undefined behavior --- esphome/components/web_server/ota/__init__.py | 56 +++++++- .../ota/test_web_server_ota.py | 123 ++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 4a98db8877..144d9e76cd 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -1,17 +1,69 @@ +import logging + import esphome.codegen as cg from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.config_helpers import merge_config import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] +CONF_WEB_SERVER = "web_server" + web_server_ns = cg.esphome_ns.namespace("web_server") WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent) + +def _web_server_ota_final_validate(config: ConfigType) -> None: + """Merge multiple web_server OTA instances into one. + + Multiple web_server OTA instances register duplicate HTTP handlers for /update, + causing undefined behavior. Merge them into a single instance. + """ + full_conf = fv.full_config.get() + ota_confs = full_conf.get(CONF_OTA, []) + + web_server_ota_configs: list[ConfigType] = [] + other_ota_configs: list[ConfigType] = [] + + for ota_conf in ota_confs: + if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER: + web_server_ota_configs.append(ota_conf) + else: + other_ota_configs.append(ota_conf) + + if len(web_server_ota_configs) <= 1: + return + + # Merge all web_server OTA configs into the first one + merged = web_server_ota_configs[0] + for ota_conf in web_server_ota_configs[1:]: + # Validate that IDs are consistent if manually specified + if merged[CONF_ID].is_manual and ota_conf[CONF_ID].is_manual: + raise cv.Invalid( + f"Found multiple web_server OTA configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, ota_conf) + + _LOGGER.warning( + "Found and merged %d web_server OTA configurations into one instance", + len(web_server_ota_configs), + ) + + # Replace OTA configs with merged web_server + other OTA platforms + other_ota_configs.append(merged) + full_conf[CONF_OTA] = other_ota_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = ( cv.Schema( { @@ -22,6 +74,8 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate + @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) async def to_code(config): diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 0d8ff6f134..d4630ff203 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -1,6 +1,21 @@ """Tests for the web_server OTA platform.""" +from __future__ import annotations + from collections.abc import Callable +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server.ota import ( + CONF_WEB_SERVER, + _web_server_ota_final_validate, +) +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM +from esphome.core import ID +import esphome.final_validate as fv def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: @@ -100,3 +115,111 @@ def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None: # Check web server OTA component is present assert "WebServerOTAComponent" in main_cpp assert "web_server::WebServerOTAComponent" in main_cpp + + +@pytest.mark.parametrize( + ("ota_configs", "expected_count", "warning_expected"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=False), + } + ], + 1, + False, + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 1, + True, + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: "esphome", + CONF_ID: ID("ota_esphome", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 2, + True, + id="mixed_platforms_web_server_merged", + ), + ], +) +def test_web_server_ota_instance_merging( + ota_configs: list[dict[str, Any]], + expected_count: int, + warning_expected: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test web_server OTA instance merging behavior.""" + full_conf = {CONF_OTA: ota_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + + # Verify total number of OTA platforms + assert len(updated_conf[CONF_OTA]) == expected_count + + # Verify warning + if warning_expected: + assert any( + "Found and merged" in record.message + and "web_server OTA" in record.message + for record in caplog.records + ), "Expected merge warning not found in log" + else: + assert len(caplog.records) == 0, "Unexpected warnings logged" + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple web_server OTA configurations but id is inconsistent", + ): + _web_server_ota_final_validate({}) + finally: + fv.full_config.reset(token)