From 3c63ff5e367665bb41f665a66b217b076f4602a4 Mon Sep 17 00:00:00 2001
From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Date: Thu, 15 Jan 2026 14:27:26 +1100
Subject: [PATCH 1/9] [image] Correctly handle dimensions in physical units
(#13209)
---
esphome/components/image/__init__.py | 13 ++---
.../image/config/mm_dimensions.svg | 5 ++
tests/component_tests/image/test_init.py | 55 ++++++++++++++++++-
3 files changed, 63 insertions(+), 10 deletions(-)
create mode 100644 tests/component_tests/image/config/mm_dimensions.svg
diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py
index 3f8d909824..6ff75d7709 100644
--- a/esphome/components/image/__init__.py
+++ b/esphome/components/image/__init__.py
@@ -665,15 +665,10 @@ async def write_image(config, all_frames=False):
if is_svg_file(path):
import resvg_py
- if resize:
- width, height = resize
- # resvg-py allows rendering by width/height directly
- image_data = resvg_py.svg_to_bytes(
- svg_path=str(path), width=int(width), height=int(height)
- )
- else:
- # Default size
- image_data = resvg_py.svg_to_bytes(svg_path=str(path))
+ resize = resize or (None, None)
+ image_data = resvg_py.svg_to_bytes(
+ svg_path=str(path), width=resize[0], height=resize[1], dpi=100
+ )
# Convert bytes to Pillow Image
image = Image.open(io.BytesIO(image_data))
diff --git a/tests/component_tests/image/config/mm_dimensions.svg b/tests/component_tests/image/config/mm_dimensions.svg
new file mode 100644
index 0000000000..bb64433a4d
--- /dev/null
+++ b/tests/component_tests/image/config/mm_dimensions.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py
index 930bbac8d1..c9481a0e1d 100644
--- a/tests/component_tests/image/test_init.py
+++ b/tests/component_tests/image/test_init.py
@@ -5,17 +5,21 @@ from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
+from unittest.mock import MagicMock, patch
import pytest
from esphome import config_validation as cv
from esphome.components.image import (
+ CONF_INVERT_ALPHA,
+ CONF_OPAQUE,
CONF_TRANSPARENCY,
CONFIG_SCHEMA,
get_all_image_metadata,
get_image_metadata,
+ write_image,
)
-from esphome.const import CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE
+from esphome.const import CONF_DITHER, CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE
from esphome.core import CORE
@@ -350,3 +354,52 @@ def test_get_all_image_metadata_empty() -> None:
"get_all_image_metadata should always return a dict"
)
# Length could be 0 or more depending on what's in CORE at test time
+
+
+@pytest.fixture
+def mock_progmem_array():
+ """Mock progmem_array to avoid needing a proper ID object in tests."""
+ with patch("esphome.components.image.cg.progmem_array") as mock_progmem:
+ mock_progmem.return_value = MagicMock()
+ yield mock_progmem
+
+
+@pytest.mark.asyncio
+async def test_svg_with_mm_dimensions_succeeds(
+ component_config_path: Callable[[str], Path],
+ mock_progmem_array: MagicMock,
+) -> None:
+ """Test that SVG files with dimensions in mm are successfully processed."""
+ # Create a config for write_image without CONF_RESIZE
+ config = {
+ CONF_FILE: component_config_path("mm_dimensions.svg"),
+ CONF_TYPE: "BINARY",
+ CONF_TRANSPARENCY: CONF_OPAQUE,
+ CONF_DITHER: "NONE",
+ CONF_INVERT_ALPHA: False,
+ CONF_RAW_DATA_ID: "test_raw_data_id",
+ }
+
+ # This should succeed without raising an error
+ result = await write_image(config)
+
+ # Verify that write_image returns the expected tuple
+ assert isinstance(result, tuple), "write_image should return a tuple"
+ assert len(result) == 6, "write_image should return 6 values"
+
+ prog_arr, width, height, image_type, trans_value, frame_count = result
+
+ # Verify the dimensions are positive integers
+ # At 100 DPI, 10mm = ~39 pixels (10mm * 100dpi / 25.4mm_per_inch)
+ assert isinstance(width, int), "Width should be an integer"
+ assert isinstance(height, int), "Height should be an integer"
+ assert width > 0, "Width should be positive"
+ assert height > 0, "Height should be positive"
+ assert frame_count == 1, "Single image should have frame_count of 1"
+ # Verify we got reasonable dimensions from the mm-based SVG
+ assert 30 < width < 50, (
+ f"Width should be around 39 pixels for 10mm at 100dpi, got {width}"
+ )
+ assert 30 < height < 50, (
+ f"Height should be around 39 pixels for 10mm at 100dpi, got {height}"
+ )
From 0b5a3506ccccec9a53d2fff180dfdf4d6bfa1963 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 00:13:05 -1000
Subject: [PATCH 2/9] [core] Optimize and normalize entity state publishing
logs with >> format (#13236)
---
.../components/alarm_control_panel/alarm_control_panel.cpp | 3 ++-
esphome/components/binary_sensor/binary_sensor.cpp | 2 +-
esphome/components/climate/climate.cpp | 2 +-
esphome/components/cover/cover.cpp | 2 +-
esphome/components/datetime/date_entity.cpp | 2 +-
esphome/components/datetime/datetime_entity.cpp | 4 ++--
esphome/components/datetime/time_entity.cpp | 3 +--
esphome/components/event/event.cpp | 2 +-
esphome/components/fan/fan.cpp | 2 +-
esphome/components/lock/lock.cpp | 2 +-
esphome/components/number/number.cpp | 2 +-
esphome/components/select/select.cpp | 2 +-
esphome/components/sensor/sensor.cpp | 4 ++--
esphome/components/switch/switch.cpp | 2 +-
esphome/components/text/text.cpp | 4 ++--
esphome/components/text_sensor/text_sensor.cpp | 2 +-
esphome/components/update/update_entity.cpp | 2 +-
esphome/components/valve/valve.cpp | 2 +-
esphome/components/water_heater/water_heater.cpp | 2 +-
19 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp
index 89c0908a74..248b5065ad 100644
--- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp
+++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp
@@ -31,7 +31,8 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) {
this->last_update_ = millis();
if (state != this->current_state_) {
auto prev_state = this->current_state_;
- ESP_LOGD(TAG, "Set state to: %s, previous: %s", LOG_STR_ARG(alarm_control_panel_state_to_string(state)),
+ ESP_LOGD(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(),
+ LOG_STR_ARG(alarm_control_panel_state_to_string(state)),
LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state)));
this->current_state_ = state;
// Single state callback - triggers check get_state() for specific states
diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp
index 86b7350aa8..4fe2a019e0 100644
--- a/esphome/components/binary_sensor/binary_sensor.cpp
+++ b/esphome/components/binary_sensor/binary_sensor.cpp
@@ -44,7 +44,7 @@ bool BinarySensor::set_new_state(const optional &new_state) {
#if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_binary_sensor_update(this);
#endif
- ESP_LOGD(TAG, "'%s': %s", this->get_name().c_str(), ONOFFMAYBE(new_state));
+ ESP_LOGD(TAG, "'%s' >> %s", this->get_name().c_str(), ONOFFMAYBE(new_state));
return true;
}
return false;
diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp
index 7611d33cbf..816bd5dfcb 100644
--- a/esphome/components/climate/climate.cpp
+++ b/esphome/components/climate/climate.cpp
@@ -436,7 +436,7 @@ void Climate::save_state_() {
}
void Climate::publish_state() {
- ESP_LOGD(TAG, "'%s' - Sending state:", this->name_.c_str());
+ ESP_LOGD(TAG, "'%s' >>", this->name_.c_str());
auto traits = this->get_traits();
ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(climate_mode_to_string(this->mode)));
diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp
index feac9823b9..97b8c2213e 100644
--- a/esphome/components/cover/cover.cpp
+++ b/esphome/components/cover/cover.cpp
@@ -153,7 +153,7 @@ void Cover::publish_state(bool save) {
this->position = clamp(this->position, 0.0f, 1.0f);
this->tilt = clamp(this->tilt, 0.0f, 1.0f);
- ESP_LOGD(TAG, "'%s' - Publishing:", this->name_.c_str());
+ ESP_LOGD(TAG, "'%s' >>", this->name_.c_str());
auto traits = this->get_traits();
if (traits.get_supports_position()) {
ESP_LOGD(TAG, " Position: %.0f%%", this->position * 100.0f);
diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp
index c061bc81f7..c5ea051914 100644
--- a/esphome/components/datetime/date_entity.cpp
+++ b/esphome/components/datetime/date_entity.cpp
@@ -30,7 +30,7 @@ void DateEntity::publish_state() {
return;
}
this->set_has_state(true);
- ESP_LOGD(TAG, "'%s': Sending date %d-%d-%d", this->get_name().c_str(), this->year_, this->month_, this->day_);
+ ESP_LOGD(TAG, "'%s' >> %d-%d-%d", this->get_name().c_str(), this->year_, this->month_, this->day_);
this->state_callback_.call();
#if defined(USE_DATETIME_DATE) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_date_update(this);
diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp
index 694f9c5721..fd3901fcfc 100644
--- a/esphome/components/datetime/datetime_entity.cpp
+++ b/esphome/components/datetime/datetime_entity.cpp
@@ -45,8 +45,8 @@ void DateTimeEntity::publish_state() {
return;
}
this->set_has_state(true);
- ESP_LOGD(TAG, "'%s': Sending datetime %04u-%02u-%02u %02d:%02d:%02d", this->get_name().c_str(), this->year_,
- this->month_, this->day_, this->hour_, this->minute_, this->second_);
+ ESP_LOGD(TAG, "'%s' >> %04u-%02u-%02u %02d:%02d:%02d", this->get_name().c_str(), this->year_, this->month_,
+ this->day_, this->hour_, this->minute_, this->second_);
this->state_callback_.call();
#if defined(USE_DATETIME_DATETIME) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_datetime_update(this);
diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp
index 0e71c95238..d0b8875ed1 100644
--- a/esphome/components/datetime/time_entity.cpp
+++ b/esphome/components/datetime/time_entity.cpp
@@ -26,8 +26,7 @@ void TimeEntity::publish_state() {
return;
}
this->set_has_state(true);
- ESP_LOGD(TAG, "'%s': Sending time %02d:%02d:%02d", this->get_name().c_str(), this->hour_, this->minute_,
- this->second_);
+ ESP_LOGD(TAG, "'%s' >> %02d:%02d:%02d", this->get_name().c_str(), this->hour_, this->minute_, this->second_);
this->state_callback_.call();
#if defined(USE_DATETIME_TIME) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_time_update(this);
diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp
index 4c74a11388..8015f2255a 100644
--- a/esphome/components/event/event.cpp
+++ b/esphome/components/event/event.cpp
@@ -22,7 +22,7 @@ void Event::trigger(const std::string &event_type) {
return;
}
this->last_event_type_ = found;
- ESP_LOGD(TAG, "'%s' Triggered event '%s'", this->get_name().c_str(), this->last_event_type_);
+ ESP_LOGD(TAG, "'%s' >> '%s'", this->get_name().c_str(), this->last_event_type_);
this->event_callback_.call(event_type);
#if defined(USE_EVENT) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_event(this);
diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp
index 2e48d84eb9..02fde730eb 100644
--- a/esphome/components/fan/fan.cpp
+++ b/esphome/components/fan/fan.cpp
@@ -201,7 +201,7 @@ void Fan::publish_state() {
auto traits = this->get_traits();
ESP_LOGD(TAG,
- "'%s' - Sending state:\n"
+ "'%s' >>\n"
" State: %s",
this->name_.c_str(), ONOFF(this->state));
if (traits.supports_speed()) {
diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp
index 018f5113e3..aca6ec10f3 100644
--- a/esphome/components/lock/lock.cpp
+++ b/esphome/components/lock/lock.cpp
@@ -52,7 +52,7 @@ void Lock::publish_state(LockState state) {
this->state = state;
this->rtc_.save(&this->state);
- ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), LOG_STR_ARG(lock_state_to_string(state)));
+ ESP_LOGD(TAG, "'%s' >> %s", this->name_.c_str(), LOG_STR_ARG(lock_state_to_string(state)));
this->state_callback_.call();
#if defined(USE_LOCK) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_lock_update(this);
diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp
index 992100ead0..b0af604189 100644
--- a/esphome/components/number/number.cpp
+++ b/esphome/components/number/number.cpp
@@ -31,7 +31,7 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o
void Number::publish_state(float state) {
this->set_has_state(true);
this->state = state;
- ESP_LOGD(TAG, "'%s': Sending state %f", this->get_name().c_str(), state);
+ ESP_LOGD(TAG, "'%s' >> %.2f", this->get_name().c_str(), state);
this->state_callback_.call(state);
#if defined(USE_NUMBER) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_number_update(this);
diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp
index 3d70e94d47..91e27b30de 100644
--- a/esphome/components/select/select.cpp
+++ b/esphome/components/select/select.cpp
@@ -31,7 +31,7 @@ void Select::publish_state(size_t index) {
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->state = option; // Update deprecated member for backward compatibility
#pragma GCC diagnostic pop
- ESP_LOGD(TAG, "'%s': Sending state %s (index %zu)", this->get_name().c_str(), option, index);
+ ESP_LOGD(TAG, "'%s' >> %s (%zu)", this->get_name().c_str(), option, index);
this->state_callback_.call(index);
#if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_select_update(this);
diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp
index 64678f8d0c..9fdb7bbafd 100644
--- a/esphome/components/sensor/sensor.cpp
+++ b/esphome/components/sensor/sensor.cpp
@@ -126,8 +126,8 @@ float Sensor::get_raw_state() const { return this->raw_state; }
void Sensor::internal_send_state_to_frontend(float state) {
this->set_has_state(true);
this->state = state;
- ESP_LOGD(TAG, "'%s': Sending state %.5f %s with %d decimals of accuracy", this->get_name().c_str(), state,
- this->get_unit_of_measurement_ref().c_str(), this->get_accuracy_decimals());
+ ESP_LOGD(TAG, "'%s' >> %.*f %s", this->get_name().c_str(), std::max(0, (int) this->get_accuracy_decimals()), state,
+ this->get_unit_of_measurement_ref().c_str());
this->callback_.call(state);
#if defined(USE_SENSOR) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_sensor_update(this);
diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp
index 3c3a437ff3..069533fa78 100644
--- a/esphome/components/switch/switch.cpp
+++ b/esphome/components/switch/switch.cpp
@@ -62,7 +62,7 @@ void Switch::publish_state(bool state) {
if (restore_mode & RESTORE_MODE_PERSISTENT_MASK)
this->rtc_.save(&this->state);
- ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), ONOFF(this->state));
+ ESP_LOGD(TAG, "'%s' >> %s", this->name_.c_str(), ONOFF(this->state));
this->state_callback_.call(this->state);
#if defined(USE_SWITCH) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_switch_update(this);
diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp
index c2ade56f69..e3f74b685b 100644
--- a/esphome/components/text/text.cpp
+++ b/esphome/components/text/text.cpp
@@ -20,9 +20,9 @@ void Text::publish_state(const char *state, size_t len) {
this->state.assign(state, len);
}
if (this->traits.get_mode() == TEXT_MODE_PASSWORD) {
- ESP_LOGD(TAG, "'%s': Sending state " LOG_SECRET("'%s'"), this->get_name().c_str(), this->state.c_str());
+ ESP_LOGD(TAG, "'%s' >> " LOG_SECRET("'%s'"), this->get_name().c_str(), this->state.c_str());
} else {
- ESP_LOGD(TAG, "'%s': Sending state %s", this->get_name().c_str(), this->state.c_str());
+ ESP_LOGD(TAG, "'%s' >> '%s'", this->get_name().c_str(), this->state.c_str());
}
this->state_callback_.call(this->state);
#if defined(USE_TEXT) && defined(USE_CONTROLLER_REGISTRY)
diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp
index 66301564a4..86e2387dc7 100644
--- a/esphome/components/text_sensor/text_sensor.cpp
+++ b/esphome/components/text_sensor/text_sensor.cpp
@@ -116,7 +116,7 @@ void TextSensor::internal_send_state_to_frontend(const char *state, size_t len)
void TextSensor::notify_frontend_() {
this->set_has_state(true);
- ESP_LOGD(TAG, "'%s': Sending state '%s'", this->name_.c_str(), this->state.c_str());
+ ESP_LOGD(TAG, "'%s' >> '%s'", this->name_.c_str(), this->state.c_str());
this->callback_.call(this->state);
#if defined(USE_TEXT_SENSOR) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_text_sensor_update(this);
diff --git a/esphome/components/update/update_entity.cpp b/esphome/components/update/update_entity.cpp
index 6d13341a8a..515e4c2c18 100644
--- a/esphome/components/update/update_entity.cpp
+++ b/esphome/components/update/update_entity.cpp
@@ -10,7 +10,7 @@ static const char *const TAG = "update";
void UpdateEntity::publish_state() {
ESP_LOGD(TAG,
- "'%s' - Publishing:\n"
+ "'%s' >>\n"
" Current Version: %s",
this->name_.c_str(), this->update_info_.current_version.c_str());
diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp
index fed113afc2..a9086747ce 100644
--- a/esphome/components/valve/valve.cpp
+++ b/esphome/components/valve/valve.cpp
@@ -133,7 +133,7 @@ void Valve::add_on_state_callback(std::function &&f) { this->state_callb
void Valve::publish_state(bool save) {
this->position = clamp(this->position, 0.0f, 1.0f);
- ESP_LOGD(TAG, "'%s' - Publishing:", this->name_.c_str());
+ ESP_LOGD(TAG, "'%s' >>", this->name_.c_str());
auto traits = this->get_traits();
if (traits.get_supports_position()) {
ESP_LOGD(TAG, " Position: %.0f%%", this->position * 100.0f);
diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp
index d092203d06..7b947057e1 100644
--- a/esphome/components/water_heater/water_heater.cpp
+++ b/esphome/components/water_heater/water_heater.cpp
@@ -153,7 +153,7 @@ void WaterHeater::setup() {
void WaterHeater::publish_state() {
auto traits = this->get_traits();
ESP_LOGD(TAG,
- "'%s' - Sending state:\n"
+ "'%s' >>\n"
" Mode: %s",
this->name_.c_str(), LOG_STR_ARG(water_heater_mode_to_string(this->mode_)));
if (!std::isnan(this->current_temperature_)) {
From 9030dc9d4e8499feaa29e61eece3697afbc80172 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 07:38:18 -1000
Subject: [PATCH 3/9] [api] Fix state updates being sent to clients that did
not subscribe (#13237)
---
esphome/components/api/api_server.cpp | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index a4eeb4dd5e..a63d33f73b 100644
--- a/esphome/components/api/api_server.cpp
+++ b/esphome/components/api/api_server.cpp
@@ -241,8 +241,10 @@ void APIServer::handle_disconnect(APIConnection *conn) {}
void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \
if (obj->is_internal()) \
return; \
- for (auto &c : this->clients_) \
- c->send_##entity_name##_state(obj); \
+ for (auto &c : this->clients_) { \
+ if (c->flags_.state_subscription) \
+ c->send_##entity_name##_state(obj); \
+ } \
}
#ifdef USE_BINARY_SENSOR
@@ -321,8 +323,10 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater)
void APIServer::on_event(event::Event *obj) {
if (obj->is_internal())
return;
- for (auto &c : this->clients_)
- c->send_event(obj);
+ for (auto &c : this->clients_) {
+ if (c->flags_.state_subscription)
+ c->send_event(obj);
+ }
}
#endif
@@ -331,8 +335,10 @@ void APIServer::on_event(event::Event *obj) {
void APIServer::on_update(update::UpdateEntity *obj) {
if (obj->is_internal())
return;
- for (auto &c : this->clients_)
- c->send_update_state(obj);
+ for (auto &c : this->clients_) {
+ if (c->flags_.state_subscription)
+ c->send_update_state(obj);
+ }
}
#endif
From 737c2b8732b34cc04888206b301197247b0f831c Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 07:38:44 -1000
Subject: [PATCH 4/9] [core] Fix platform subcomponents not filtering source
files (#13208)
---
esphome/components/debug/sensor.py | 6 +++++-
esphome/components/debug/text_sensor.py | 6 +++++-
esphome/components/nextion/display.py | 7 ++++++-
esphome/components/remote_receiver/binary_sensor.py | 2 ++
4 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py
index 4484f15935..6a8e2cd828 100644
--- a/esphome/components/debug/sensor.py
+++ b/esphome/components/debug/sensor.py
@@ -17,7 +17,11 @@ from esphome.const import (
UNIT_PERCENT,
)
-from . import CONF_DEBUG_ID, DebugComponent
+from . import ( # noqa: F401 pylint: disable=unused-import
+ CONF_DEBUG_ID,
+ FILTER_SOURCE_FILES,
+ DebugComponent,
+)
DEPENDENCIES = ["debug"]
diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py
index 96ef231850..c69b8d9461 100644
--- a/esphome/components/debug/text_sensor.py
+++ b/esphome/components/debug/text_sensor.py
@@ -8,7 +8,11 @@ from esphome.const import (
ICON_RESTART,
)
-from . import CONF_DEBUG_ID, DebugComponent
+from . import ( # noqa: F401 pylint: disable=unused-import
+ CONF_DEBUG_ID,
+ FILTER_SOURCE_FILES,
+ DebugComponent,
+)
DEPENDENCIES = ["debug"]
diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py
index b95df55a61..0b4ba3a171 100644
--- a/esphome/components/nextion/display.py
+++ b/esphome/components/nextion/display.py
@@ -11,7 +11,12 @@ from esphome.const import (
)
from esphome.core import CORE, TimePeriod
-from . import Nextion, nextion_ns, nextion_ref
+from . import ( # noqa: F401 pylint: disable=unused-import
+ FILTER_SOURCE_FILES,
+ Nextion,
+ nextion_ns,
+ nextion_ref,
+)
from .base_component import (
CONF_AUTO_WAKE_ON_TOUCH,
CONF_COMMAND_SPACING,
diff --git a/esphome/components/remote_receiver/binary_sensor.py b/esphome/components/remote_receiver/binary_sensor.py
index 218b40d6cc..fe3e2af950 100644
--- a/esphome/components/remote_receiver/binary_sensor.py
+++ b/esphome/components/remote_receiver/binary_sensor.py
@@ -1,5 +1,7 @@
from esphome.components import binary_sensor, remote_base
+from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import
+
DEPENDENCIES = ["remote_receiver"]
CONFIG_SCHEMA = remote_base.validate_binary_sensor
From 1ad0969099bbd82263c1aa2e8f7546cb2777fb24 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 08:29:11 -1000
Subject: [PATCH 5/9] [core] Fix ESP32-S2/S3 hardware SHA crash by aligning
HashBase digest buffer (#13234)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
---
.../update/esp32_hosted_update.cpp | 6 ++----
.../components/esphome/ota/ota_esphome.cpp | 12 ++++-------
esphome/components/sha256/sha256.cpp | 20 +++++++++----------
esphome/components/sha256/sha256.h | 11 +++++-----
esphome/core/hash_base.h | 10 +++++++++-
5 files changed, 30 insertions(+), 29 deletions(-)
diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp
index 9f8ae3277e..d69a438578 100644
--- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp
+++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp
@@ -294,8 +294,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
}
// Stream firmware to coprocessor while computing SHA256
- // Hardware SHA acceleration requires 32-byte alignment on some chips (ESP32-S3 with IDF 5.5.x+)
- alignas(32) sha256::SHA256 hasher;
+ sha256::SHA256 hasher;
hasher.init();
uint8_t buffer[CHUNK_SIZE];
@@ -352,8 +351,7 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() {
}
// Verify SHA256 before writing
- // Hardware SHA acceleration requires 32-byte alignment on some chips (ESP32-S3 with IDF 5.5.x+)
- alignas(32) sha256::SHA256 hasher;
+ sha256::SHA256 hasher;
hasher.init();
hasher.add(this->firmware_data_, this->firmware_size_);
hasher.calculate();
diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp
index b2ae185687..df2ea98f2c 100644
--- a/esphome/components/esphome/ota/ota_esphome.cpp
+++ b/esphome/components/esphome/ota/ota_esphome.cpp
@@ -563,11 +563,9 @@ bool ESPHomeOTAComponent::handle_auth_send_() {
// [1+hex_size...1+2*hex_size-1]: cnonce (hex_size bytes) - client's nonce
// [1+2*hex_size...1+3*hex_size-1]: response (hex_size bytes) - client's hash
- // CRITICAL ESP32-S3 HARDWARE SHA ACCELERATION: Hash object must stay in same stack frame
+ // CRITICAL ESP32-S2/S3 HARDWARE SHA ACCELERATION: Hash object must stay in same stack frame
// (no passing to other functions). All hash operations must happen in this function.
- // NOTE: On ESP32-S3 with IDF 5.5.x, the SHA256 context must be properly aligned for
- // hardware SHA acceleration DMA operations.
- alignas(32) sha256::SHA256 hasher;
+ sha256::SHA256 hasher;
const size_t hex_size = hasher.get_size() * 2;
const size_t nonce_len = hasher.get_size() / 4;
@@ -639,11 +637,9 @@ bool ESPHomeOTAComponent::handle_auth_read_() {
const char *cnonce = nonce + hex_size;
const char *response = cnonce + hex_size;
- // CRITICAL ESP32-S3 HARDWARE SHA ACCELERATION: Hash object must stay in same stack frame
+ // CRITICAL ESP32-S2/S3 HARDWARE SHA ACCELERATION: Hash object must stay in same stack frame
// (no passing to other functions). All hash operations must happen in this function.
- // NOTE: On ESP32-S3 with IDF 5.5.x, the SHA256 context must be properly aligned for
- // hardware SHA acceleration DMA operations.
- alignas(32) sha256::SHA256 hasher;
+ sha256::SHA256 hasher;
hasher.init();
hasher.add(this->password_.c_str(), this->password_.length());
diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp
index 48559d7c73..23995e6534 100644
--- a/esphome/components/sha256/sha256.cpp
+++ b/esphome/components/sha256/sha256.cpp
@@ -10,26 +10,24 @@ namespace esphome::sha256 {
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
-// CRITICAL ESP32-S3 HARDWARE SHA ACCELERATION REQUIREMENTS (IDF 5.5.x):
+// CRITICAL ESP32 HARDWARE SHA ACCELERATION REQUIREMENTS (IDF 5.5.x):
//
-// The ESP32-S3 uses hardware DMA for SHA acceleration. The mbedtls_sha256_context structure contains
-// internal state that the DMA engine references. This imposes three critical constraints:
+// ESP32 variants (except original ESP32) use DMA-based hardware SHA acceleration that requires
+// 32-byte aligned digest buffers. This is handled automatically via HashBase::digest_ which has
+// alignas(32) on these platforms. Two additional constraints apply:
//
-// 1. ALIGNMENT: The SHA256 object MUST be declared with `alignas(32)` for proper DMA alignment.
-// Without this, the DMA engine may crash with an abort in sha_hal_read_digest().
-//
-// 2. NO VARIABLE LENGTH ARRAYS (VLAs): VLAs corrupt the stack layout, causing the DMA engine to
+// 1. NO VARIABLE LENGTH ARRAYS (VLAs): VLAs corrupt the stack layout, causing the DMA engine to
// write to incorrect memory locations. This results in null pointer dereferences and crashes.
// ALWAYS use fixed-size arrays (e.g., char buf[65], not char buf[size+1]).
//
-// 3. SAME STACK FRAME ONLY: The SHA256 object must be created and used entirely within the same
+// 2. SAME STACK FRAME ONLY: The SHA256 object must be created and used entirely within the same
// function. NEVER pass the SHA256 object or HashBase pointer to another function. When the stack
// frame changes (function call/return), the DMA references become invalid and will produce
// truncated hash output (20 bytes instead of 32) or corrupt memory.
//
// CORRECT USAGE:
// void my_function() {
-// alignas(32) sha256::SHA256 hasher; // Created locally with proper alignment
+// sha256::SHA256 hasher;
// hasher.init();
// hasher.add(data, len); // Any size, no chunking needed
// hasher.calculate();
@@ -37,9 +35,9 @@ namespace esphome::sha256 {
// // hasher destroyed when function returns
// }
//
-// INCORRECT USAGE (WILL FAIL ON ESP32-S3):
+// INCORRECT USAGE (WILL FAIL):
// void my_function() {
-// sha256::SHA256 hasher; // WRONG: Missing alignas(32)
+// sha256::SHA256 hasher;
// helper(&hasher); // WRONG: Passed to different stack frame
// }
// void helper(HashBase *h) {
diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h
index 17d80636f1..bafb359485 100644
--- a/esphome/components/sha256/sha256.h
+++ b/esphome/components/sha256/sha256.h
@@ -24,13 +24,14 @@ namespace esphome::sha256 {
/// SHA256 hash implementation.
///
-/// CRITICAL for ESP32-S3 with IDF 5.5.x hardware SHA acceleration:
-/// 1. SHA256 objects MUST be declared with `alignas(32)` for proper DMA alignment
-/// 2. The object MUST stay in the same stack frame (no passing to other functions)
-/// 3. NO Variable Length Arrays (VLAs) in the same function
+/// CRITICAL for ESP32 variants (except original) with IDF 5.5.x hardware SHA acceleration:
+/// 1. The object MUST stay in the same stack frame (no passing to other functions)
+/// 2. NO Variable Length Arrays (VLAs) in the same function
+///
+/// Note: Alignment is handled automatically via the HashBase::digest_ member.
///
/// Example usage:
-/// alignas(32) sha256::SHA256 hasher;
+/// sha256::SHA256 hasher;
/// hasher.init();
/// hasher.add(data, len);
/// hasher.calculate();
diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h
index 0c1c2dce33..606cd3080c 100644
--- a/esphome/core/hash_base.h
+++ b/esphome/core/hash_base.h
@@ -44,7 +44,15 @@ class HashBase {
virtual size_t get_size() const = 0;
protected:
- uint8_t digest_[32]; // Storage sized for max(MD5=16, SHA256=32) bytes
+// ESP32 variants with DMA-based hardware SHA (all except original ESP32) require 32-byte aligned buffers.
+// Original ESP32 uses a different hardware SHA implementation without DMA alignment requirements.
+// Other platforms (ESP8266, RP2040, LibreTiny) use software SHA and don't need alignment.
+// Storage sized for max(MD5=16, SHA256=32) bytes
+#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32)
+ alignas(32) uint8_t digest_[32];
+#else
+ uint8_t digest_[32];
+#endif
};
} // namespace esphome
From 3f6412ba07d36f1d32b8bfa6a8c99452aaecc598 Mon Sep 17 00:00:00 2001
From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Date: Thu, 15 Jan 2026 14:17:00 -0500
Subject: [PATCH 6/9] [safe_mode] Detect bootloader rollback support at runtime
(#13230)
Co-authored-by: Claude Opus 4.5
---
esphome/components/safe_mode/safe_mode.cpp | 23 +++++++++++++++++++---
esphome/components/safe_mode/safe_mode.h | 7 +++++++
2 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp
index ef6ebea247..f32511531a 100644
--- a/esphome/components/safe_mode/safe_mode.cpp
+++ b/esphome/components/safe_mode/safe_mode.cpp
@@ -9,7 +9,7 @@
#include
#include
-#ifdef USE_OTA_ROLLBACK
+#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
#include
#endif
@@ -26,6 +26,17 @@ void SafeModeComponent::dump_config() {
this->safe_mode_boot_is_good_after_ / 1000, // because milliseconds
this->safe_mode_num_attempts_,
this->safe_mode_enable_time_ / 1000); // because milliseconds
+#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
+ const char *state_str;
+ if (this->ota_state_ == ESP_OTA_IMG_NEW) {
+ state_str = "not supported";
+ } else if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) {
+ state_str = "supported";
+ } else {
+ state_str = "support unknown";
+ }
+ ESP_LOGCONFIG(TAG, " Bootloader rollback: %s", state_str);
+#endif
if (this->safe_mode_rtc_value_ > 1 && this->safe_mode_rtc_value_ != SafeModeComponent::ENTER_SAFE_MODE_MAGIC) {
auto remaining_restarts = this->safe_mode_num_attempts_ - this->safe_mode_rtc_value_;
@@ -36,7 +47,7 @@ void SafeModeComponent::dump_config() {
}
}
-#ifdef USE_OTA_ROLLBACK
+#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition();
if (last_invalid != nullptr) {
ESP_LOGW(TAG,
@@ -55,7 +66,7 @@ void SafeModeComponent::loop() {
ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter");
this->clean_rtc();
this->boot_successful_ = true;
-#ifdef USE_OTA_ROLLBACK
+#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
// Mark OTA partition as valid to prevent rollback
esp_ota_mark_app_valid_cancel_rollback();
#endif
@@ -90,6 +101,12 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
this->safe_mode_num_attempts_ = num_attempts;
this->rtc_ = global_preferences->make_preference(233825507UL, false);
+#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
+ // Check partition state to detect if bootloader supports rollback
+ const esp_partition_t *running = esp_ota_get_running_partition();
+ esp_ota_get_state_partition(running, &this->ota_state_);
+#endif
+
uint32_t rtc_val = this->read_rtc_();
this->safe_mode_rtc_value_ = rtc_val;
diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h
index 4aefd11458..d6f669f39f 100644
--- a/esphome/components/safe_mode/safe_mode.h
+++ b/esphome/components/safe_mode/safe_mode.h
@@ -5,6 +5,10 @@
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
+#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
+#include
+#endif
+
namespace esphome::safe_mode {
/// SafeModeComponent provides a safe way to recover from repeated boot failures
@@ -42,6 +46,9 @@ class SafeModeComponent : public Component {
// Group 1-byte members together to minimize padding
bool boot_successful_{false}; ///< set to true after boot is considered successful
uint8_t safe_mode_num_attempts_{0};
+#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
+ esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED};
+#endif
// Larger objects at the end
ESPPreferenceObject rtc_;
#ifdef USE_SAFE_MODE_CALLBACK
From f88cf1b83add8eae7896f029b1df32100e4a1868 Mon Sep 17 00:00:00 2001
From: John Stenger
Date: Thu, 15 Jan 2026 11:18:08 -0800
Subject: [PATCH 7/9] [qr_code] Allocate and free memory for QR code buffer
(#13161)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston
Co-authored-by: J. Nick Koston
---
esphome/components/qr_code/qr_code.cpp | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/esphome/components/qr_code/qr_code.cpp b/esphome/components/qr_code/qr_code.cpp
index c2db741e17..0322c8a141 100644
--- a/esphome/components/qr_code/qr_code.cpp
+++ b/esphome/components/qr_code/qr_code.cpp
@@ -27,7 +27,16 @@ void QrCode::set_ecc(qrcodegen_Ecc ecc) {
void QrCode::generate_qr_code() {
ESP_LOGV(TAG, "Generating QR code");
+
+#ifdef USE_ESP32
+ // ESP32 has 8KB stack, safe to allocate ~4KB buffer on stack
uint8_t tempbuffer[qrcodegen_BUFFER_LEN_MAX];
+#else
+ // Other platforms (ESP8266: 4KB, RP2040: 2KB, LibreTiny: ~4KB) have smaller stacks
+ // Allocate buffer on heap to avoid stack overflow
+ auto tempbuffer_owner = std::make_unique(qrcodegen_BUFFER_LEN_MAX);
+ uint8_t *tempbuffer = tempbuffer_owner.get();
+#endif
if (!qrcodegen_encodeText(this->value_.c_str(), tempbuffer, this->qr_, this->ecc_, qrcodegen_VERSION_MIN,
qrcodegen_VERSION_MAX, qrcodegen_Mask_AUTO, true)) {
From dacd185afbceb4a17d84a6bbd7896c5619b94c3b Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 09:56:35 -1000
Subject: [PATCH 8/9] [web_server][captive_portal] Change default compression
from Brotli to gzip (#13246)
---
esphome/components/captive_portal/__init__.py | 2 +-
esphome/components/web_server/__init__.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py
index 4b30dc5d16..049618219e 100644
--- a/esphome/components/captive_portal/__init__.py
+++ b/esphome/components/captive_portal/__init__.py
@@ -44,7 +44,7 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id(
web_server_base.WebServerBase
),
- cv.Optional(CONF_COMPRESSION, default="br"): cv.one_of("br", "gzip"),
+ cv.Optional(CONF_COMPRESSION, default="gzip"): cv.one_of("gzip", "br"),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on(
diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py
index 16ac9d054c..3f1e094afc 100644
--- a/esphome/components/web_server/__init__.py
+++ b/esphome/components/web_server/__init__.py
@@ -203,7 +203,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_OTA): cv.boolean,
cv.Optional(CONF_LOG, default=True): cv.boolean,
cv.Optional(CONF_LOCAL): cv.boolean,
- cv.Optional(CONF_COMPRESSION, default="br"): cv.one_of("br", "gzip"),
+ cv.Optional(CONF_COMPRESSION, default="gzip"): cv.one_of("gzip", "br"),
cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group),
}
).extend(cv.COMPONENT_SCHEMA),
From c151b2da67ab3a2ee2f9a999233323582370d97f Mon Sep 17 00:00:00 2001
From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Date: Thu, 15 Jan 2026 15:26:04 -0500
Subject: [PATCH 9/9] Bump version to 2026.1.0b2
---
Doxyfile | 2 +-
esphome/const.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Doxyfile b/Doxyfile
index e98eac6aa5..aa6a2f169e 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.0b1
+PROJECT_NUMBER = 2026.1.0b2
# 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 95ccfb9dee..e99fbb8283 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.0b1"
+__version__ = "2026.1.0b2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (