From eea7e9edffe8bdf321c3e8042c0c7614b926b16b Mon Sep 17 00:00:00 2001 From: Jas Strong Date: Thu, 5 Feb 2026 00:06:52 -0800 Subject: [PATCH 01/14] [rd03d] Revert incorrect field order swap (#13769) Co-authored-by: jas --- esphome/components/rd03d/rd03d.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index ba05abe8e0..090e4dcf32 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -132,18 +132,15 @@ void RD03DComponent::process_frame_() { // Header is 4 bytes, each target is 8 bytes uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE); - // Extract raw bytes for this target - // Note: Despite datasheet Table 5-2 showing order as X, Y, Speed, Resolution, - // actual radar output has Resolution before Speed (verified empirically - - // stationary targets were showing non-zero speed with original field order) + // Extract raw bytes for this target (per datasheet Table 5-2: X, Y, Speed, Resolution) uint8_t x_low = this->buffer_[offset + 0]; uint8_t x_high = this->buffer_[offset + 1]; uint8_t y_low = this->buffer_[offset + 2]; uint8_t y_high = this->buffer_[offset + 3]; - uint8_t res_low = this->buffer_[offset + 4]; - uint8_t res_high = this->buffer_[offset + 5]; - uint8_t speed_low = this->buffer_[offset + 6]; - uint8_t speed_high = this->buffer_[offset + 7]; + uint8_t speed_low = this->buffer_[offset + 4]; + uint8_t speed_high = this->buffer_[offset + 5]; + uint8_t res_low = this->buffer_[offset + 6]; + uint8_t res_high = this->buffer_[offset + 7]; // Decode values per RD-03D format int16_t x = decode_value(x_low, x_high); From 9eee4c992450bcfaec1e7516f96ccacad7b09a4f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:00:16 -0500 Subject: [PATCH 02/14] [core] Add capacity check to register_component_ (#13778) Co-authored-by: Claude Opus 4.5 --- esphome/core/application.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 55eb25ce09..76ce976131 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -81,6 +81,10 @@ void Application::register_component_(Component *comp) { return; } } + if (this->components_.size() >= ESPHOME_COMPONENT_COUNT) { + ESP_LOGE(TAG, "Cannot register component %s - at capacity!", LOG_STR_ARG(comp->get_component_log_str())); + return; + } this->components_.push_back(comp); } void Application::setup() { From 438a0c428985b9d8c37ac2e036d7a955809a8793 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 6 Feb 2026 04:09:48 -0500 Subject: [PATCH 03/14] [ota] Fix CLI upload option shown when only http_request platform configured (#13784) Co-authored-by: Claude Opus 4.5 --- esphome/__main__.py | 9 +++- tests/unit_tests/test_main.py | 85 ++++++++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6cec481abc..e49a1eea9d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -287,8 +287,13 @@ def has_api() -> bool: def has_ota() -> bool: - """Check if OTA is available.""" - return CONF_OTA in CORE.config + """Check if OTA upload is available (requires platform: esphome).""" + if CONF_OTA not in CORE.config: + return False + return any( + ota_item.get(CONF_PLATFORM) == CONF_ESPHOME + for ota_item in CORE.config[CONF_OTA] + ) def has_mqtt_ip_lookup() -> bool: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3268f7ee87..c9aa446323 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( has_mqtt_ip_lookup, has_mqtt_logging, has_non_ip_address, + has_ota, has_resolvable_address, mqtt_get_ip, run_esphome, @@ -332,7 +333,9 @@ def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: def test_choose_upload_log_host_with_ota_list() -> None: """Test with OTA as the only item in the list.""" - setup_core(config={CONF_OTA: {}}, address="192.168.1.100") + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) result = choose_upload_log_host( default=["OTA"], @@ -345,7 +348,7 @@ def test_choose_upload_log_host_with_ota_list() -> None: @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: """Test with OTA list falling back to MQTT when no address.""" - setup_core(config={CONF_OTA: {}, "mqtt": {}}) + setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], "mqtt": {}}) result = choose_upload_log_host( default=["OTA"], @@ -408,7 +411,9 @@ def test_choose_upload_log_host_with_serial_device_with_ports( def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: """Test OTA device when OTA is configured.""" - setup_core(config={CONF_OTA: {}}, address="192.168.1.100") + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) result = choose_upload_log_host( default="OTA", @@ -475,7 +480,9 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: @pytest.mark.usefixtures("mock_choose_prompt") def test_choose_upload_log_host_multiple_devices() -> None: """Test with multiple devices including special identifiers.""" - setup_core(config={CONF_OTA: {}}, address="192.168.1.100") + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] @@ -514,7 +521,9 @@ def test_choose_upload_log_host_no_defaults_with_serial_ports( @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_no_defaults_with_ota() -> None: """Test interactive mode with OTA option.""" - setup_core(config={CONF_OTA: {}}, address="192.168.1.100") + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) with patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100" @@ -575,7 +584,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options( ) -> None: """Test interactive mode with all options available.""" setup_core( - config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + config={ + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, address="192.168.1.100", ) @@ -604,7 +617,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging( ) -> None: """Test interactive mode with all options available.""" setup_core( - config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + config={ + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, address="192.168.1.100", ) @@ -632,7 +649,9 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging( @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_check_default_matches() -> None: """Test when check_default matches an available option.""" - setup_core(config={CONF_OTA: {}}, address="192.168.1.100") + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) result = choose_upload_log_host( default=None, @@ -704,7 +723,10 @@ def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: def test_choose_upload_log_host_ota_both_conditions() -> None: """Test OTA device when both OTA and API are configured and enabled.""" - setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100") + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}}, + address="192.168.1.100", + ) result = choose_upload_log_host( default="OTA", @@ -719,7 +741,7 @@ def test_choose_upload_log_host_ota_ip_all_options() -> None: """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" setup_core( config={ - CONF_OTA: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}, CONF_MQTT: { CONF_BROKER: "mqtt.local", @@ -744,7 +766,7 @@ def test_choose_upload_log_host_ota_local_all_options() -> None: """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" setup_core( config={ - CONF_OTA: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}, CONF_MQTT: { CONF_BROKER: "mqtt.local", @@ -769,7 +791,7 @@ def test_choose_upload_log_host_ota_ip_all_options_logging() -> None: """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" setup_core( config={ - CONF_OTA: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}, CONF_MQTT: { CONF_BROKER: "mqtt.local", @@ -794,7 +816,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None: """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" setup_core( config={ - CONF_OTA: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}, CONF_MQTT: { CONF_BROKER: "mqtt.local", @@ -817,7 +839,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None: @pytest.mark.usefixtures("mock_no_mqtt_logging") def test_choose_upload_log_host_no_address_with_ota_config() -> None: """Test OTA device when OTA is configured but no address is set.""" - setup_core(config={CONF_OTA: {}}) + setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) with pytest.raises( EsphomeError, match="All specified devices .* could not be resolved" @@ -1532,10 +1554,43 @@ def test_has_mqtt() -> None: assert has_mqtt() is False # Test with other components but no MQTT - setup_core(config={CONF_API: {}, CONF_OTA: {}}) + setup_core(config={CONF_API: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) assert has_mqtt() is False +def test_has_ota() -> None: + """Test has_ota function. + + The has_ota function should only return True when OTA is configured + with platform: esphome, not when only platform: http_request is configured. + This is because CLI OTA upload only works with the esphome platform. + """ + # Test with OTA esphome platform configured + setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + assert has_ota() is True + + # Test with OTA http_request platform only (should return False) + # This is the bug scenario from issue #13783 + setup_core(config={CONF_OTA: [{CONF_PLATFORM: "http_request"}]}) + assert has_ota() is False + + # Test without OTA configured + setup_core(config={}) + assert has_ota() is False + + # Test with multiple OTA platforms including esphome + setup_core( + config={ + CONF_OTA: [{CONF_PLATFORM: "http_request"}, {CONF_PLATFORM: CONF_ESPHOME}] + } + ) + assert has_ota() is True + + # Test with empty OTA list + setup_core(config={CONF_OTA: []}) + assert has_ota() is False + + def test_get_port_type() -> None: """Test get_port_type function.""" From c1455ccc29815b20aa814c82b14cf23c2fac8ca1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Feb 2026 22:19:20 +0100 Subject: [PATCH 04/14] [dashboard] Close WebSocket after process exit to prevent zombie connections (#13834) --- esphome/dashboard/web_server.py | 1 + tests/dashboard/test_web_server.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index f94d8eea22..da50279864 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -317,6 +317,7 @@ class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandl # Check if the proc was not forcibly closed _LOGGER.info("Process exited with return code %s", returncode) self.write_message({"event": "exit", "code": returncode}) + self.close() def on_close(self) -> None: # Check if proc exists (if 'start' has been run) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 10ca6061e6..7642876ee5 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -29,7 +29,7 @@ from esphome.dashboard.entries import ( bool_to_entry_state, ) from esphome.dashboard.models import build_importable_device_dict -from esphome.dashboard.web_server import DashboardSubscriber +from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket from esphome.zeroconf import DiscoveredImport from .common import get_fixture_path @@ -1654,3 +1654,25 @@ async def test_websocket_check_origin_multiple_trusted_domains( assert data["event"] == "initial_state" finally: ws.close() + + +def test_proc_on_exit_calls_close() -> None: + """Test _proc_on_exit sends exit event and closes the WebSocket.""" + handler = Mock(spec=EsphomeCommandWebSocket) + handler._is_closed = False + + EsphomeCommandWebSocket._proc_on_exit(handler, 0) + + handler.write_message.assert_called_once_with({"event": "exit", "code": 0}) + handler.close.assert_called_once() + + +def test_proc_on_exit_skips_when_already_closed() -> None: + """Test _proc_on_exit does nothing when WebSocket is already closed.""" + handler = Mock(spec=EsphomeCommandWebSocket) + handler._is_closed = True + + EsphomeCommandWebSocket._proc_on_exit(handler, 0) + + handler.write_message.assert_not_called() + handler.close.assert_not_called() From a5dc4b0fce2c0b2b279abb6cc8eaa49b642d19d4 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 8 Feb 2026 18:52:05 +0100 Subject: [PATCH 05/14] [nrf52,logger] fix printk (#13874) --- esphome/components/logger/logger_zephyr.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 41f53beec0..c0fb5c502b 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -68,7 +68,7 @@ void HOT Logger::write_msg_(const char *msg, size_t len) { #ifdef CONFIG_PRINTK // Requires the debug component and an active SWD connection. // It is used for pyocd rtt -t nrf52840 - k_str_out(const_cast(msg), len); + printk("%.*s", static_cast(len), msg); #endif if (this->uart_dev_ == nullptr) { return; From 0b047c334d3142753169ccb308ae2dda0a7c32b6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:53:43 +1100 Subject: [PATCH 06/14] [lvgl] Fix crash with unconfigured `top_layer` (#13846) --- esphome/components/lvgl/schemas.py | 1 + tests/components/lvgl/test.host.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 45d933c00e..2aeeedbd10 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -436,6 +436,7 @@ def container_schema(widget_type: WidgetType, extras=None): schema = schema.extend(widget_type.schema) def validator(value): + value = value or {} return append_layout_schema(schema, value)(value) return validator diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 00a8cd8c01..f84156c9d8 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -20,6 +20,8 @@ lvgl: - id: lvgl_0 default_font: space16 displays: sdl0 + top_layer: + - id: lvgl_1 displays: sdl1 on_idle: From 1f761902b6f17984216b7a690f0bb68fc3d2afee Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:49:58 -0500 Subject: [PATCH 07/14] [esp32] Set UV_CACHE_DIR inside data dir so Clean All clears it (#13888) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7fb708dea4..3a330a3722 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1026,6 +1026,10 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) + # Set the uv cache inside the data dir so "Clean All" clears it. + # Avoids persistent corrupted cache from mid-stream download failures. + os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache")) + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_platformio_option("framework", "espidf") cg.add_build_flag("-DUSE_ESP_IDF") From 4168e8c30d6cf7f6d7e572e827c1851b956853f3 Mon Sep 17 00:00:00 2001 From: Sean Kelly Date: Mon, 9 Feb 2026 16:30:37 -0800 Subject: [PATCH 08/14] [aqi] Fix AQI calculation for specific pm2.5 or pm10 readings (#13770) --- esphome/components/aqi/aqi_calculator.h | 38 +++++++++++++++++++----- esphome/components/aqi/caqi_calculator.h | 30 ++++++++++++++++--- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/esphome/components/aqi/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h index 993504c1e9..d624af0432 100644 --- a/esphome/components/aqi/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include "abstract_aqi_calculator.h" @@ -14,7 +15,11 @@ class AQICalculator : public AbstractAQICalculator { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); - return static_cast(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index)); + float aqi = std::max(pm2_5_index, pm10_0_index); + if (aqi < 0.0f) { + aqi = 0.0f; + } + return static_cast(std::lround(aqi)); } protected: @@ -22,13 +27,27 @@ class AQICalculator : public AbstractAQICalculator { static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}}; - static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {{0.0f, 9.0f}, {9.1f, 35.4f}, - {35.5f, 55.4f}, {55.5f, 125.4f}, - {125.5f, 225.4f}, {225.5f, std::numeric_limits::max()}}; + static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { + // clang-format off + {0.0f, 9.1f}, + {9.1f, 35.5f}, + {35.5f, 55.5f}, + {55.5f, 125.5f}, + {125.5f, 225.5f}, + {225.5f, std::numeric_limits::max()} + // clang-format on + }; - static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {{0.0f, 54.0f}, {55.0f, 154.0f}, - {155.0f, 254.0f}, {255.0f, 354.0f}, - {355.0f, 424.0f}, {425.0f, std::numeric_limits::max()}}; + static constexpr float PM10_0_GRID[NUM_LEVELS][2] = { + // clang-format off + {0.0f, 55.0f}, + {55.0f, 155.0f}, + {155.0f, 255.0f}, + {255.0f, 355.0f}, + {355.0f, 425.0f}, + {425.0f, std::numeric_limits::max()} + // clang-format on + }; static float calculate_index(float value, const float array[NUM_LEVELS][2]) { int grid_index = get_grid_index(value, array); @@ -45,7 +64,10 @@ class AQICalculator : public AbstractAQICalculator { static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - if (value >= array[i][0] && value <= array[i][1]) { + const bool in_range = + (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive + : (value < array[i][1])); // others exclusive on hi + if (in_range) { return i; } } diff --git a/esphome/components/aqi/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h index d2ec4bb98f..fe2efe7059 100644 --- a/esphome/components/aqi/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include "abstract_aqi_calculator.h" @@ -12,7 +13,11 @@ class CAQICalculator : public AbstractAQICalculator { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); - return static_cast(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index)); + float aqi = std::max(pm2_5_index, pm10_0_index); + if (aqi < 0.0f) { + aqi = 0.0f; + } + return static_cast(std::lround(aqi)); } protected: @@ -21,10 +26,24 @@ class CAQICalculator : public AbstractAQICalculator { static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}}; static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { - {0.0f, 15.0f}, {15.1f, 30.0f}, {30.1f, 55.0f}, {55.1f, 110.0f}, {110.1f, std::numeric_limits::max()}}; + // clang-format off + {0.0f, 15.1f}, + {15.1f, 30.1f}, + {30.1f, 55.1f}, + {55.1f, 110.1f}, + {110.1f, std::numeric_limits::max()} + // clang-format on + }; static constexpr float PM10_0_GRID[NUM_LEVELS][2] = { - {0.0f, 25.0f}, {25.1f, 50.0f}, {50.1f, 90.0f}, {90.1f, 180.0f}, {180.1f, std::numeric_limits::max()}}; + // clang-format off + {0.0f, 25.1f}, + {25.1f, 50.1f}, + {50.1f, 90.1f}, + {90.1f, 180.1f}, + {180.1f, std::numeric_limits::max()} + // clang-format on + }; static float calculate_index(float value, const float array[NUM_LEVELS][2]) { int grid_index = get_grid_index(value, array); @@ -42,7 +61,10 @@ class CAQICalculator : public AbstractAQICalculator { static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - if (value >= array[i][0] && value <= array[i][1]) { + const bool in_range = + (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive + : (value < array[i][1])); // others exclusive on hi + if (in_range) { return i; } } From a99f75ca7102036544a3285f615a830fb7b01975 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:45:06 +1300 Subject: [PATCH 09/14] Bump version to 2026.1.5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 140ac06565..356739412b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.1.4 +PROJECT_NUMBER = 2026.1.5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 862c7e37e6..e5c1162834 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.1.4" +__version__ = "2026.1.5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 548b7e5dab6a92aa103c7662647b647e02d4d580 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:04:12 -0500 Subject: [PATCH 10/14] [esp32] Fix ESP32-P4 test: replace stale esp_hosted component ref (#13920) Co-authored-by: Claude Opus 4.6 --- tests/components/esp32/test.esp32-p4-idf.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index d67787b3d5..bc054f5aee 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -6,8 +6,8 @@ esp32: type: esp-idf components: - espressif/mdns^1.8.2 - - name: espressif/esp_hosted - ref: 2.7.0 + - name: espressif/button + ref: 4.1.5 advanced: enable_idf_experimental_features: yes disable_debug_stubs: true From b4707344d322473550f8fb46024949ce57eaf033 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:44:12 -0500 Subject: [PATCH 11/14] [esp32] Upgrade uv to 0.10.1 and increase HTTP retries (#13918) Co-authored-by: Claude Opus 4.6 --- docker/Dockerfile | 2 +- esphome/components/esp32/__init__.py | 8 -------- esphome/platformio_api.py | 2 ++ 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 348a503bc8..8ebdd1e49b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -23,7 +23,7 @@ RUN if command -v apk > /dev/null; then \ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -RUN pip install --no-cache-dir -U pip uv==0.6.14 +RUN pip install --no-cache-dir -U pip uv==0.10.1 COPY requirements.txt / diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d3c343b9db..a680a78951 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1436,14 +1436,6 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) - # Set the uv cache inside the data dir so "Clean All" clears it. - # Avoids persistent corrupted cache from mid-stream download failures. - os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache")) - - # Set the uv cache inside the data dir so "Clean All" clears it. - # Avoids persistent corrupted cache from mid-stream download failures. - os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache")) - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index e66f9a2c97..d42f89d029 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -133,6 +133,8 @@ def run_platformio_cli(*args, **kwargs) -> str | int: ) # Suppress Python syntax warnings from third-party scripts during compilation os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") + # Increase uv retry count to handle transient network errors (default is 3) + os.environ.setdefault("UV_HTTP_RETRIES", "10") cmd = ["platformio"] + list(args) if not CORE.verbose: From 58659e4893851cfd21167a34fda25dd768c85bd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 18:48:13 -0600 Subject: [PATCH 12/14] [mdns] Throttle MDNS.update() polling on ESP8266 and RP2040 (#13917) --- esphome/components/mdns/mdns_component.h | 25 +++++++++++++++++++++--- esphome/components/mdns/mdns_esp8266.cpp | 11 ++++++++--- esphome/components/mdns/mdns_rp2040.cpp | 11 ++++++++--- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f696cfff1c..32f8f16ec1 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -45,9 +45,28 @@ class MDNSComponent : public Component { void setup() override; void dump_config() override; -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_ARDUINO) - void loop() override; -#endif + // Polling interval for MDNS.update() on platforms that require it (ESP8266, RP2040). + // + // On these platforms, MDNS.update() calls _process(true) which only manages timer-driven + // state machines (probe/announce timeouts and service query cache TTLs). Incoming mDNS + // packets are handled independently via the lwIP onRx UDP callback and are NOT affected + // by how often update() is called. + // + // The shortest internal timer is the 250ms probe interval (RFC 6762 Section 8.1). + // Announcement intervals are 1000ms and cache TTL checks are on the order of seconds + // to minutes. A 50ms polling interval provides sufficient resolution for all timers + // while completely removing mDNS from the per-iteration loop list. + // + // In steady state (after the ~8 second boot probe/announce phase completes), update() + // checks timers that are set to never expire, making every call pure overhead. + // + // Tasmota uses a 50ms main loop cycle with mDNS working correctly, confirming this + // interval is safe in production. + // + // By using set_interval() instead of overriding loop(), the component is excluded from + // the main loop list via has_overridden_loop(), eliminating all per-iteration overhead + // including virtual dispatch. + static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index dcbe5ebd52..295a408cbd 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -36,9 +36,14 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); } - -void MDNSComponent::loop() { MDNS.update(); } +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp8266); + // Schedule MDNS.update() via set_interval() instead of overriding loop(). + // This removes the component from the per-iteration loop list entirely, + // eliminating virtual dispatch overhead on every main loop cycle. + // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +} void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index e4a9b60cdb..05d991c1fa 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -35,9 +35,14 @@ static void register_rp2040(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_rp2040); } - -void MDNSComponent::loop() { MDNS.update(); } +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_rp2040); + // Schedule MDNS.update() via set_interval() instead of overriding loop(). + // This removes the component from the per-iteration loop list entirely, + // eliminating virtual dispatch overhead on every main loop cycle. + // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +} void MDNSComponent::on_shutdown() { MDNS.close(); From e3fbbb2e9984f35f7e4fd9d13a0777b9d4c6f036 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 20:45:05 -0600 Subject: [PATCH 13/14] [light] Eliminate redundant clamp in LightCall::validate_() After clamp_and_log_if_invalid() clamps the value in-place, the LightColorValues setter's clamp() is guaranteed to be a no-op. For 5 of 9 fields the compiler was inlining the setter's clamp, generating ~18 bytes of redundant float compare + conditional move per field. Use friend access to assign directly to LightColorValues fields, bypassing the setter. Saves ~204 bytes of flash on ESP32. --- esphome/components/light/light_call.cpp | 25 ++++++++++--------- esphome/components/light/light_color_values.h | 2 ++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 3b4e136ba5..0291b2c3c6 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -270,22 +270,23 @@ LightColorValues LightCall::validate_() { if (this->has_state()) v.set_state(this->state_); -#define VALIDATE_AND_APPLY(field, setter, name_str, ...) \ + // clamp_and_log_if_invalid already clamps in-place, so assign directly + // to avoid redundant clamp code from the setter being inlined. +#define VALIDATE_AND_APPLY(field, name_str, ...) \ if (this->has_##field()) { \ clamp_and_log_if_invalid(name, this->field##_, LOG_STR(name_str), ##__VA_ARGS__); \ - v.setter(this->field##_); \ + v.field##_ = this->field##_; \ } - VALIDATE_AND_APPLY(brightness, set_brightness, "Brightness") - VALIDATE_AND_APPLY(color_brightness, set_color_brightness, "Color brightness") - VALIDATE_AND_APPLY(red, set_red, "Red") - VALIDATE_AND_APPLY(green, set_green, "Green") - VALIDATE_AND_APPLY(blue, set_blue, "Blue") - VALIDATE_AND_APPLY(white, set_white, "White") - VALIDATE_AND_APPLY(cold_white, set_cold_white, "Cold white") - VALIDATE_AND_APPLY(warm_white, set_warm_white, "Warm white") - VALIDATE_AND_APPLY(color_temperature, set_color_temperature, "Color temperature", traits.get_min_mireds(), - traits.get_max_mireds()) + VALIDATE_AND_APPLY(brightness, "Brightness") + VALIDATE_AND_APPLY(color_brightness, "Color brightness") + VALIDATE_AND_APPLY(red, "Red") + VALIDATE_AND_APPLY(green, "Green") + VALIDATE_AND_APPLY(blue, "Blue") + VALIDATE_AND_APPLY(white, "White") + VALIDATE_AND_APPLY(cold_white, "Cold white") + VALIDATE_AND_APPLY(warm_white, "Warm white") + VALIDATE_AND_APPLY(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds()) #undef VALIDATE_AND_APPLY diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 97756b9f26..b26deff0c3 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -276,6 +276,8 @@ class LightColorValues { /// Set the warm white property of these light color values. In range 0.0 to 1.0. void set_warm_white(float warm_white) { this->warm_white_ = clamp(warm_white, 0.0f, 1.0f); } + friend class LightCall; + protected: float state_; ///< ON / OFF, float for transition float brightness_; From 0b02476e8a99bae1122b73af70d37f3734534938 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 21:01:34 -0600 Subject: [PATCH 14/14] [light] Eliminate redundant clamp in LightCall::validate_() and normalize_color() After clamp_and_log_if_invalid() clamps the value in-place, the LightColorValues setter's clamp() is guaranteed to be a no-op. For 5 of 9 fields the compiler was inlining the setter's clamp, generating ~18 bytes of redundant float compare + conditional move per field. Use friend access to assign directly to LightColorValues fields, bypassing the setter. Also apply the same optimization to normalize_color() where division by max_value guarantees results stay in [0,1]. --- esphome/components/light/light_color_values.h | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index b26deff0c3..dc23263312 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -95,15 +95,18 @@ class LightColorValues { */ void normalize_color() { if (this->color_mode_ & ColorCapability::RGB) { - float max_value = fmaxf(this->get_red(), fmaxf(this->get_green(), this->get_blue())); + float max_value = fmaxf(this->red_, fmaxf(this->green_, this->blue_)); + // Assign directly to avoid redundant clamp in set_red/green/blue. + // Values are guaranteed in [0,1]: inputs are already clamped to [0,1], + // and dividing by max_value (the largest) keeps results in [0,1]. if (max_value == 0.0f) { - this->set_red(1.0f); - this->set_green(1.0f); - this->set_blue(1.0f); + this->red_ = 1.0f; + this->green_ = 1.0f; + this->blue_ = 1.0f; } else { - this->set_red(this->get_red() / max_value); - this->set_green(this->get_green() / max_value); - this->set_blue(this->get_blue() / max_value); + this->red_ /= max_value; + this->green_ /= max_value; + this->blue_ /= max_value; } } }