From 9da2c08f363df712eb601cde4eebf25eebfc37f4 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 01/60] [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 78aee4f49815f970d8ce427bd7e2c1db96f5231b Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Wed, 14 Jan 2026 19:48:55 -1000
Subject: [PATCH 02/60] [web_server] Remove unused button_state_json_generator
(#13235)
---
esphome/components/web_server/web_server.cpp | 3 ---
esphome/components/web_server/web_server.h | 2 +-
2 files changed, 1 insertion(+), 4 deletions(-)
diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp
index 0525c93096..cf984ea247 100644
--- a/esphome/components/web_server/web_server.cpp
+++ b/esphome/components/web_server/web_server.cpp
@@ -753,9 +753,6 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM
}
request->send(404);
}
-std::string WebServer::button_state_json_generator(WebServer *web_server, void *source) {
- return web_server->button_json_((button::Button *) (source), DETAIL_STATE);
-}
std::string WebServer::button_all_json_generator(WebServer *web_server, void *source) {
return web_server->button_json_((button::Button *) (source), DETAIL_ALL);
}
diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h
index 91625476f4..b1a495ebef 100644
--- a/esphome/components/web_server/web_server.h
+++ b/esphome/components/web_server/web_server.h
@@ -295,7 +295,7 @@ class WebServer : public Controller,
/// Handle a button request under '/button//press'.
void handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match);
- static std::string button_state_json_generator(WebServer *web_server, void *source);
+ // Buttons are stateless, so there is no button_state_json_generator
static std::string button_all_json_generator(WebServer *web_server, void *source);
#endif
From 49c881d067ac901c0a24d1dee5d4d053d08f93be Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 00:13:05 -1000
Subject: [PATCH 03/60] [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 9d42bfd161a6058b5ae9eaa4a41d7a1c64db0290 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 07:38:18 -1000
Subject: [PATCH 04/60] [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 22a4ec69c2c531394b22fbbf4b7e03f1dea2ab04 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 07:38:44 -1000
Subject: [PATCH 05/60] [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 9003844eda7d7ddeea60cab318e914beea59f471 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 08:29:11 -1000
Subject: [PATCH 06/60] [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 0dc5a7c9a42da5107ab0a9a7eb8c6d36020b0f5d 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 07/60] [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 6380458d78f64840fd9f3b487b71e951cb3b56a2 Mon Sep 17 00:00:00 2001
From: John Stenger
Date: Thu, 15 Jan 2026 11:18:08 -0800
Subject: [PATCH 08/60] [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 41dceb76ec3bea38e787ba0cdb6cf44d9de8a77e Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 09:56:35 -1000
Subject: [PATCH 09/60] [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 04273501016f7a60961893c3da19bbf0211a9625 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 15 Jan 2026 09:59:40 -1000
Subject: [PATCH 10/60] Bump ruff from 0.14.11 to 0.14.12 (#13244)
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 092a06fd66..e0bc943ab4 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -1,6 +1,6 @@
pylint==4.0.4
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
-ruff==0.14.11 # also change in .pre-commit-config.yaml when updating
+ruff==0.14.12 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
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 11/60] [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 12/60] [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 13/60] [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 14/60] [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 15/60] [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 16/60] [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 17/60] [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 18/60] [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 19/60] 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 = (
From 00cc9e44b61b2ae8b275dbdc35479e0149f2389f Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 10:38:24 -1000
Subject: [PATCH 20/60] [analyze_memory] Fix ELF section mapping for RTL87xx
and LN882X platforms (#13213)
---
esphome/analyze_memory/const.py | 40 ++++++++++++++++++++++++++---
script/determine-jobs.py | 17 +++++++++---
tests/script/test_determine_jobs.py | 22 ++++++++++++++++
3 files changed, 73 insertions(+), 6 deletions(-)
diff --git a/esphome/analyze_memory/const.py b/esphome/analyze_memory/const.py
index 9933bd77fd..aadc6a231c 100644
--- a/esphome/analyze_memory/const.py
+++ b/esphome/analyze_memory/const.py
@@ -9,10 +9,44 @@ ESPHOME_COMPONENT_PATTERN = re.compile(r"esphome::([a-zA-Z0-9_]+)::")
# Maps standard section names to their various platform-specific variants
# Note: Order matters! More specific patterns (.bss) must come before general ones (.dram)
# because ESP-IDF uses names like ".dram0.bss" which would match ".dram" otherwise
+#
+# Platform-specific sections:
+# - ESP8266/ESP32: .iram*, .dram*
+# - LibreTiny RTL87xx: .xip.code_* (flash), .ram.code_* (RAM)
+# - LibreTiny BK7231: .itcm.code (fast RAM), .vectors (interrupt vectors)
+# - LibreTiny LN882X: .flash_text, .flash_copy* (flash code)
SECTION_MAPPING = {
- ".text": frozenset([".text", ".iram"]),
- ".rodata": frozenset([".rodata"]),
- ".bss": frozenset([".bss"]), # Must be before .data to catch ".dram0.bss"
+ ".text": frozenset(
+ [
+ ".text",
+ ".iram",
+ # LibreTiny RTL87xx XIP (eXecute In Place) flash code
+ ".xip.code",
+ # LibreTiny RTL87xx RAM code
+ ".ram.code_text",
+ # LibreTiny BK7231 fast RAM code and vectors
+ ".itcm.code",
+ ".vectors",
+ # LibreTiny LN882X flash code
+ ".flash_text",
+ ".flash_copy",
+ ]
+ ),
+ ".rodata": frozenset(
+ [
+ ".rodata",
+ # LibreTiny RTL87xx read-only data in RAM
+ ".ram.code_rodata",
+ ]
+ ),
+ # .bss patterns - must be before .data to catch ".dram0.bss"
+ ".bss": frozenset(
+ [
+ ".bss",
+ # LibreTiny LN882X BSS
+ ".bss_ram",
+ ]
+ ),
".data": frozenset([".data", ".dram"]),
}
diff --git a/script/determine-jobs.py b/script/determine-jobs.py
index a61c9bf08d..7ecbfb225e 100755
--- a/script/determine-jobs.py
+++ b/script/determine-jobs.py
@@ -90,6 +90,8 @@ class Platform(StrEnum):
ESP32_S2_IDF = "esp32-s2-idf"
ESP32_S3_IDF = "esp32-s3-idf"
BK72XX_ARD = "bk72xx-ard" # LibreTiny BK7231N
+ RTL87XX_ARD = "rtl87xx-ard" # LibreTiny RTL8720x
+ LN882X_ARD = "ln882x-ard" # LibreTiny LN882x
RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico
@@ -122,8 +124,8 @@ PLATFORM_SPECIFIC_COMPONENTS = frozenset(
# fastest build times, most sensitive to code size changes
# 3. ESP32 IDF - Primary ESP32 platform, most representative of modern ESPHome
# 4-6. Other ESP32 variants - Less commonly used but still supported
-# 7. BK72XX - LibreTiny platform (good for detecting LibreTiny-specific changes)
-# 8. RP2040 - Raspberry Pi Pico platform
+# 7-9. LibreTiny platforms (BK72XX, RTL87XX, LN882X) - good for detecting LibreTiny-specific changes
+# 10. RP2040 - Raspberry Pi Pico platform
MEMORY_IMPACT_PLATFORM_PREFERENCE = [
Platform.ESP32_C6_IDF, # ESP32-C6 IDF (newest, supports Thread/Zigbee)
Platform.ESP8266_ARD, # ESP8266 Arduino (most memory constrained, fastest builds)
@@ -132,6 +134,8 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [
Platform.ESP32_S2_IDF, # ESP32-S2 IDF
Platform.ESP32_S3_IDF, # ESP32-S3 IDF
Platform.BK72XX_ARD, # LibreTiny BK7231N
+ Platform.RTL87XX_ARD, # LibreTiny RTL8720x
+ Platform.LN882X_ARD, # LibreTiny LN882x
Platform.RP2040_ARD, # Raspberry Pi Pico
]
@@ -411,6 +415,8 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None:
- wifi_component_esp8266.cpp, *_esp8266.h -> ESP8266_ARD
- *_esp32*.cpp -> ESP32 IDF (generic)
- *_libretiny.cpp, *_bk72*.* -> BK72XX (LibreTiny)
+ - *_rtl87*.* -> RTL87XX (LibreTiny Realtek)
+ - *_ln882*.* -> LN882X (LibreTiny Lightning)
- *_pico.cpp, *_rp2040.* -> RP2040_ARD
Args:
@@ -444,7 +450,12 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None:
if "esp32" in filename_lower:
return Platform.ESP32_IDF
- # LibreTiny (via 'libretiny' pattern or BK72xx-specific files)
+ # LibreTiny platforms (check specific variants before generic libretiny)
+ # Check specific variants first to handle paths like libretiny/wifi_rtl87xx.cpp
+ if "rtl87" in filename_lower:
+ return Platform.RTL87XX_ARD
+ if "ln882" in filename_lower:
+ return Platform.LN882X_ARD
if "libretiny" in filename_lower or "bk72" in filename_lower:
return Platform.BK72XX_ARD
diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py
index bd20cb3e21..52025513a8 100644
--- a/tests/script/test_determine_jobs.py
+++ b/tests/script/test_determine_jobs.py
@@ -1472,6 +1472,24 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) ->
determine_jobs.Platform.BK72XX_ARD,
),
("esphome/components/ble/ble_bk72xx.cpp", determine_jobs.Platform.BK72XX_ARD),
+ # RTL87xx (LibreTiny Realtek) detection
+ (
+ "tests/components/logger/test.rtl87xx-ard.yaml",
+ determine_jobs.Platform.RTL87XX_ARD,
+ ),
+ (
+ "esphome/components/libretiny/wifi_rtl87xx.cpp",
+ determine_jobs.Platform.RTL87XX_ARD,
+ ),
+ # LN882x (LibreTiny Lightning) detection
+ (
+ "tests/components/logger/test.ln882x-ard.yaml",
+ determine_jobs.Platform.LN882X_ARD,
+ ),
+ (
+ "esphome/components/libretiny/wifi_ln882x.cpp",
+ determine_jobs.Platform.LN882X_ARD,
+ ),
# RP2040 / Raspberry Pi Pico detection
("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
@@ -1501,6 +1519,10 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) ->
"esp32_in_name",
"libretiny",
"bk72xx",
+ "rtl87xx_test_yaml",
+ "rtl87xx_wifi",
+ "ln882x_test_yaml",
+ "ln882x_wifi",
"rp2040_gpio",
"rp2040_wifi",
"pico_i2c",
From 535c3eb2a221a62e68612cc7fa4322866e6f5fb9 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 11:32:02 -1000
Subject: [PATCH 21/60] [sprinkler] Fix scheduler deprecation warnings and heap
churn with FixedVector (#13251)
---
esphome/components/sprinkler/sprinkler.cpp | 6 ++++--
esphome/components/sprinkler/sprinkler.h | 5 +++--
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp
index ca9f85abd8..2813b4450b 100644
--- a/esphome/components/sprinkler/sprinkler.cpp
+++ b/esphome/components/sprinkler/sprinkler.cpp
@@ -332,6 +332,7 @@ Sprinkler::Sprinkler(const std::string &name) {
// The `name` is needed to set timers up, hence non-default constructor
// replaces `set_name()` method previously existed
this->name_ = name;
+ this->timer_.init(2);
this->timer_.push_back({this->name_ + "sm", false, 0, 0, std::bind(&Sprinkler::sm_timer_callback_, this)});
this->timer_.push_back({this->name_ + "vs", false, 0, 0, std::bind(&Sprinkler::valve_selection_callback_, this)});
}
@@ -1574,7 +1575,8 @@ const LogString *Sprinkler::state_as_str_(SprinklerState state) {
void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) {
if (this->timer_duration_(timer_index) > 0) {
- this->set_timeout(this->timer_[timer_index].name, this->timer_duration_(timer_index),
+ // FixedVector ensures timer_ can't be resized, so .c_str() pointers remain valid
+ this->set_timeout(this->timer_[timer_index].name.c_str(), this->timer_duration_(timer_index),
this->timer_cbf_(timer_index));
this->timer_[timer_index].start_time = millis();
this->timer_[timer_index].active = true;
@@ -1585,7 +1587,7 @@ void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) {
bool Sprinkler::cancel_timer_(const SprinklerTimerIndex timer_index) {
this->timer_[timer_index].active = false;
- return this->cancel_timeout(this->timer_[timer_index].name);
+ return this->cancel_timeout(this->timer_[timer_index].name.c_str());
}
bool Sprinkler::timer_active_(const SprinklerTimerIndex timer_index) { return this->timer_[timer_index].active; }
diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h
index 25e2d42446..273c0e9208 100644
--- a/esphome/components/sprinkler/sprinkler.h
+++ b/esphome/components/sprinkler/sprinkler.h
@@ -3,6 +3,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
+#include "esphome/core/helpers.h"
#include "esphome/components/number/number.h"
#include "esphome/components/switch/switch.h"
@@ -553,8 +554,8 @@ class Sprinkler : public Component {
/// Sprinkler valve operator objects
std::vector valve_op_{2};
- /// Valve control timers
- std::vector timer_{};
+ /// Valve control timers - FixedVector enforces that this can never grow beyond init() size
+ FixedVector timer_;
/// Other Sprinkler instances we should be aware of (used to check if pumps are in use)
std::vector other_controllers_;
From 2eabc1b96b839c4afc48a3d23441ebfcbff0187d Mon Sep 17 00:00:00 2001
From: Keith Burzinski
Date: Thu, 15 Jan 2026 20:22:05 -0600
Subject: [PATCH 22/60] [helpers] Add base85 support (#13254)
Co-authored-by: J. Nick Koston
---
esphome/core/helpers.cpp | 49 ++++++++++++++++++++++++++++++++++++++++
esphome/core/helpers.h | 8 +++++++
2 files changed, 57 insertions(+)
diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp
index 309407fbec..b5bf849c30 100644
--- a/esphome/core/helpers.cpp
+++ b/esphome/core/helpers.cpp
@@ -617,6 +617,55 @@ std::vector base64_decode(const std::string &encoded_string) {
return ret;
}
+/// Encode int32 to 5 base85 characters + null terminator
+/// Standard ASCII85 alphabet: '!' (33) = 0 through 'u' (117) = 84
+inline void base85_encode_int32(int32_t value, std::span output) {
+ uint32_t v = static_cast(value);
+ // Encode least significant digit first, then reverse
+ for (int i = 4; i >= 0; i--) {
+ output[i] = static_cast('!' + (v % 85));
+ v /= 85;
+ }
+ output[5] = '\0';
+}
+
+/// Decode 5 base85 characters to int32
+inline bool base85_decode_int32(const char *input, int32_t &out) {
+ uint8_t c0 = static_cast(input[0] - '!');
+ uint8_t c1 = static_cast(input[1] - '!');
+ uint8_t c2 = static_cast(input[2] - '!');
+ uint8_t c3 = static_cast(input[3] - '!');
+ uint8_t c4 = static_cast(input[4] - '!');
+
+ // Each digit must be 0-84. Since uint8_t wraps, chars below '!' become > 84
+ if (c0 > 84 || c1 > 84 || c2 > 84 || c3 > 84 || c4 > 84)
+ return false;
+
+ // 85^4 = 52200625, 85^3 = 614125, 85^2 = 7225, 85^1 = 85
+ out = static_cast(c0 * 52200625u + c1 * 614125u + c2 * 7225u + c3 * 85u + c4);
+ return true;
+}
+
+/// Decode base85 string directly into vector (no intermediate buffer)
+bool base85_decode_int32_vector(const std::string &base85, std::vector &out) {
+ size_t len = base85.size();
+ if (len % 5 != 0)
+ return false;
+
+ out.clear();
+ const char *ptr = base85.data();
+ const char *end = ptr + len;
+
+ while (ptr < end) {
+ int32_t value;
+ if (!base85_decode_int32(ptr, value))
+ return false;
+ out.push_back(value);
+ ptr += 5;
+ }
+ return true;
+}
+
// Colors
float gamma_correct(float value, float gamma) {
diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h
index 2e9c0e6b13..d5a04b7eb1 100644
--- a/esphome/core/helpers.h
+++ b/esphome/core/helpers.h
@@ -1086,6 +1086,14 @@ std::vector base64_decode(const std::string &encoded_string);
size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len);
size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len);
+/// Size of buffer needed for base85 encoded int32 (5 chars + null terminator)
+static constexpr size_t BASE85_INT32_ENCODED_SIZE = 6;
+
+void base85_encode_int32(int32_t value, std::span output);
+
+bool base85_decode_int32(const char *input, int32_t &out);
+bool base85_decode_int32_vector(const std::string &base85, std::vector &out);
+
///@}
/// @name Colors
From d2528af649a59a7c7d63d423863f223ca281707e Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 16:48:44 -1000
Subject: [PATCH 23/60] [dallas_temp] Use const char* for set_timeout to fix
deprecation warning and heap churn (#13250)
---
esphome/components/dallas_temp/dallas_temp.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp
index a1b684abbf..13f2fa59bd 100644
--- a/esphome/components/dallas_temp/dallas_temp.cpp
+++ b/esphome/components/dallas_temp/dallas_temp.cpp
@@ -44,7 +44,7 @@ void DallasTemperatureSensor::update() {
this->send_command_(DALLAS_COMMAND_START_CONVERSION);
- this->set_timeout(this->get_address_name(), this->millis_to_wait_for_conversion_(), [this] {
+ this->set_timeout(this->get_address_name().c_str(), this->millis_to_wait_for_conversion_(), [this] {
if (!this->read_scratch_pad_() || !this->check_scratch_pad_()) {
this->publish_state(NAN);
return;
From 4eda9e965f9bb444bbfd82e624fd1b551e111837 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 16:49:01 -1000
Subject: [PATCH 24/60] [api] Fix clock conflicts when multiple clients
connected to homeassistant time (#13253)
---
esphome/components/api/api_server.cpp | 4 +++-
esphome/components/time/real_time_clock.cpp | 12 ++++++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index a63d33f73b..ed97c3b9a2 100644
--- a/esphome/components/api/api_server.cpp
+++ b/esphome/components/api/api_server.cpp
@@ -558,8 +558,10 @@ bool APIServer::clear_noise_psk(bool make_active) {
#ifdef USE_HOMEASSISTANT_TIME
void APIServer::request_time() {
for (auto &client : this->clients_) {
- if (!client->flags_.remove && client->is_authenticated())
+ if (!client->flags_.remove && client->is_authenticated()) {
client->send_time_request();
+ return; // Only request from one client to avoid clock conflicts
+ }
}
}
#endif
diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp
index 639af4457f..f217d14c55 100644
--- a/esphome/components/time/real_time_clock.cpp
+++ b/esphome/components/time/real_time_clock.cpp
@@ -31,6 +31,18 @@ void RealTimeClock::dump_config() {
void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
ESP_LOGVV(TAG, "Got epoch %" PRIu32, epoch);
+ // Skip if time is already synchronized to avoid unnecessary writes, log spam,
+ // and prevent clock jumping backwards due to network latency
+ constexpr time_t min_valid_epoch = 1546300800; // January 1, 2019
+ time_t current_time = this->timestamp_now();
+ // Check if time is valid (year >= 2019) before comparing
+ if (current_time >= min_valid_epoch) {
+ // Unsigned subtraction handles wraparound correctly, then cast to signed
+ int32_t diff = static_cast(epoch - static_cast(current_time));
+ if (diff >= -1 && diff <= 1) {
+ return;
+ }
+ }
// Update UTC epoch time.
#ifdef USE_ZEPHYR
struct timespec ts;
From b1230ec6bb47d08e422d62917a90d24a6bc42f6e Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 16:49:19 -1000
Subject: [PATCH 25/60] [esp32_ble_client] Reduce GATT data event logging to
prevent firmware update failures (#13252)
---
.../esp32_ble_client/ble_client_base.cpp | 38 +++++++++++--------
.../esp32_ble_client/ble_client_base.h | 3 +-
2 files changed, 25 insertions(+), 16 deletions(-)
diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp
index 149fcc79d5..01f79156a9 100644
--- a/esphome/components/esp32_ble_client/ble_client_base.cpp
+++ b/esphome/components/esp32_ble_client/ble_client_base.cpp
@@ -193,10 +193,18 @@ void BLEClientBase::log_event_(const char *name) {
ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name);
}
-void BLEClientBase::log_gattc_event_(const char *name) {
+void BLEClientBase::log_gattc_lifecycle_event_(const char *name) {
ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_, name);
}
+void BLEClientBase::log_gattc_data_event_(const char *name) {
+ // Data transfer events are logged at VERBOSE level because logging to UART creates
+ // delays that cause timing issues during time-sensitive BLE operations. This is
+ // especially problematic during pairing or firmware updates which require rapid
+ // writes to many characteristics - the log spam can cause these operations to fail.
+ ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_, name);
+}
+
void BLEClientBase::log_gattc_warning_(const char *operation, esp_gatt_status_t status) {
ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_, operation, status);
}
@@ -280,7 +288,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_OPEN_EVT: {
if (!this->check_addr(param->open.remote_bda))
return false;
- this->log_gattc_event_("OPEN");
+ this->log_gattc_lifecycle_event_("OPEN");
// conn_id was already set in ESP_GATTC_CONNECT_EVT
this->service_count_ = 0;
@@ -331,7 +339,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_CONNECT_EVT: {
if (!this->check_addr(param->connect.remote_bda))
return false;
- this->log_gattc_event_("CONNECT");
+ this->log_gattc_lifecycle_event_("CONNECT");
this->conn_id_ = param->connect.conn_id;
// Start MTU negotiation immediately as recommended by ESP-IDF examples
// (gatt_client, ble_throughput) which call esp_ble_gattc_send_mtu_req in
@@ -376,7 +384,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_CLOSE_EVT: {
if (this->conn_id_ != param->close.conn_id)
return false;
- this->log_gattc_event_("CLOSE");
+ this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_state(espbt::ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
@@ -404,7 +412,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (this->conn_id_ != param->search_cmpl.conn_id)
return false;
- this->log_gattc_event_("SEARCH_CMPL");
+ this->log_gattc_lifecycle_event_("SEARCH_CMPL");
// For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery
// This balances performance with bandwidth usage after the critical discovery phase
if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
@@ -431,35 +439,35 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_READ_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
- this->log_gattc_event_("READ_DESCR");
+ this->log_gattc_data_event_("READ_DESCR");
break;
}
case ESP_GATTC_WRITE_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
- this->log_gattc_event_("WRITE_DESCR");
+ this->log_gattc_data_event_("WRITE_DESCR");
break;
}
case ESP_GATTC_WRITE_CHAR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
- this->log_gattc_event_("WRITE_CHAR");
+ this->log_gattc_data_event_("WRITE_CHAR");
break;
}
case ESP_GATTC_READ_CHAR_EVT: {
if (this->conn_id_ != param->read.conn_id)
return false;
- this->log_gattc_event_("READ_CHAR");
+ this->log_gattc_data_event_("READ_CHAR");
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (this->conn_id_ != param->notify.conn_id)
return false;
- this->log_gattc_event_("NOTIFY");
+ this->log_gattc_data_event_("NOTIFY");
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
- this->log_gattc_event_("REG_FOR_NOTIFY");
+ this->log_gattc_data_event_("REG_FOR_NOTIFY");
if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
// Client is responsible for flipping the descriptor value
@@ -491,7 +499,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
esp_err_t status =
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, desc_result.handle, sizeof(notify_en),
(uint8_t *) ¬ify_en, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
- ESP_LOGD(TAG, "Wrote notify descriptor %d, properties=%d", notify_en, char_result.properties);
+ ESP_LOGV(TAG, "Wrote notify descriptor %d, properties=%d", notify_en, char_result.properties);
if (status) {
this->log_gattc_warning_("esp_ble_gattc_write_char_descr", status);
}
@@ -499,13 +507,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
- this->log_gattc_event_("UNREG_FOR_NOTIFY");
+ this->log_gattc_data_event_("UNREG_FOR_NOTIFY");
break;
}
default:
- // ideally would check all other events for matching conn_id
- ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_, event);
+ // Unknown events logged at VERBOSE to avoid UART delays during time-sensitive operations
+ ESP_LOGV(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_, event);
break;
}
return true;
diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h
index 92c7444ee1..c52f0e5d2d 100644
--- a/esphome/components/esp32_ble_client/ble_client_base.h
+++ b/esphome/components/esp32_ble_client/ble_client_base.h
@@ -127,7 +127,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
// 6 bytes used, 2 bytes padding
void log_event_(const char *name);
- void log_gattc_event_(const char *name);
+ void log_gattc_lifecycle_event_(const char *name);
+ void log_gattc_data_event_(const char *name);
void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
From 42491569c87f8be4ff7a4dd813256b2e7d9893b1 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 17:53:53 -1000
Subject: [PATCH 26/60] [analyze_memory] Add nRF52/Zephyr platform support for
memory analysis (#13249)
---
esphome/analyze_memory/__init__.py | 8 ++-
esphome/analyze_memory/const.py | 18 ++++++-
esphome/analyze_memory/helpers.py | 8 +--
esphome/analyze_memory/toolchain.py | 84 ++++++++++++++++++++++++++++-
script/determine-jobs.py | 9 +++-
tests/script/test_determine_jobs.py | 30 +++++++++++
6 files changed, 148 insertions(+), 9 deletions(-)
diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py
index 9c935c78fa..63ef0e74ed 100644
--- a/esphome/analyze_memory/__init__.py
+++ b/esphome/analyze_memory/__init__.py
@@ -22,7 +22,7 @@ from .helpers import (
map_section_name,
parse_symbol_line,
)
-from .toolchain import find_tool, run_tool
+from .toolchain import find_tool, resolve_tool_path, run_tool
if TYPE_CHECKING:
from esphome.platformio_api import IDEData
@@ -132,6 +132,12 @@ class MemoryAnalyzer:
readelf_path = readelf_path or idedata.readelf_path
_LOGGER.debug("Using toolchain paths from PlatformIO idedata")
+ # Validate paths exist, fall back to find_tool if they don't
+ # This handles cases like Zephyr where cc_path doesn't include full path
+ # and the toolchain prefix may differ (e.g., arm-zephyr-eabi- vs arm-none-eabi-)
+ objdump_path = resolve_tool_path("objdump", objdump_path, objdump_path)
+ readelf_path = resolve_tool_path("readelf", readelf_path, objdump_path)
+
self.objdump_path = objdump_path or "objdump"
self.readelf_path = readelf_path or "readelf"
self.external_components = external_components or set()
diff --git a/esphome/analyze_memory/const.py b/esphome/analyze_memory/const.py
index aadc6a231c..83547b1eb5 100644
--- a/esphome/analyze_memory/const.py
+++ b/esphome/analyze_memory/const.py
@@ -15,6 +15,7 @@ ESPHOME_COMPONENT_PATTERN = re.compile(r"esphome::([a-zA-Z0-9_]+)::")
# - LibreTiny RTL87xx: .xip.code_* (flash), .ram.code_* (RAM)
# - LibreTiny BK7231: .itcm.code (fast RAM), .vectors (interrupt vectors)
# - LibreTiny LN882X: .flash_text, .flash_copy* (flash code)
+# - Zephyr/nRF52: text, rodata, datas, bss (no leading dots)
SECTION_MAPPING = {
".text": frozenset(
[
@@ -30,6 +31,9 @@ SECTION_MAPPING = {
# LibreTiny LN882X flash code
".flash_text",
".flash_copy",
+ # Zephyr/nRF52 sections (no leading dots)
+ "text",
+ "rom_start",
]
),
".rodata": frozenset(
@@ -37,6 +41,8 @@ SECTION_MAPPING = {
".rodata",
# LibreTiny RTL87xx read-only data in RAM
".ram.code_rodata",
+ # Zephyr/nRF52 sections (no leading dots)
+ "rodata",
]
),
# .bss patterns - must be before .data to catch ".dram0.bss"
@@ -45,9 +51,19 @@ SECTION_MAPPING = {
".bss",
# LibreTiny LN882X BSS
".bss_ram",
+ # Zephyr/nRF52 sections (no leading dots)
+ "bss",
+ "noinit",
+ ]
+ ),
+ ".data": frozenset(
+ [
+ ".data",
+ ".dram",
+ # Zephyr/nRF52 sections (no leading dots)
+ "datas",
]
),
- ".data": frozenset([".data", ".dram"]),
}
# Section to ComponentMemory attribute mapping
diff --git a/esphome/analyze_memory/helpers.py b/esphome/analyze_memory/helpers.py
index cb503b37c5..a6ca7e7f0d 100644
--- a/esphome/analyze_memory/helpers.py
+++ b/esphome/analyze_memory/helpers.py
@@ -94,13 +94,13 @@ def parse_symbol_line(line: str) -> tuple[str, str, int, str] | None:
return None
# Find section, size, and name
+ # Try each part as a potential section name
for i, part in enumerate(parts):
- if not part.startswith("."):
- continue
-
+ # Skip parts that are clearly flags, addresses, or other metadata
+ # Sections start with '.' (standard ELF) or are known section names (Zephyr)
section = map_section_name(part)
if not section:
- break
+ continue
# Need at least size field after section
if i + 1 >= len(parts):
diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py
index 23d85e9700..3a8a5f7be4 100644
--- a/esphome/analyze_memory/toolchain.py
+++ b/esphome/analyze_memory/toolchain.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+import os
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING
@@ -17,10 +18,82 @@ TOOLCHAIN_PREFIXES = [
"xtensa-lx106-elf-", # ESP8266
"xtensa-esp32-elf-", # ESP32
"xtensa-esp-elf-", # ESP32 (newer IDF)
+ "arm-zephyr-eabi-", # nRF52/Zephyr SDK
+ "arm-none-eabi-", # Generic ARM (RP2040, etc.)
"", # System default (no prefix)
]
+def _find_in_platformio_packages(tool_name: str) -> str | None:
+ """Search for a tool in PlatformIO package directories.
+
+ This handles cases like Zephyr SDK where tools are installed in nested
+ directories that aren't in PATH.
+
+ Args:
+ tool_name: Name of the tool (e.g., "readelf", "objdump")
+
+ Returns:
+ Full path to the tool or None if not found
+ """
+ # Get PlatformIO packages directory
+ platformio_home = Path(os.path.expanduser("~/.platformio/packages"))
+ if not platformio_home.exists():
+ return None
+
+ # Search patterns for toolchains that might contain the tool
+ # Order matters - more specific patterns first
+ search_patterns = [
+ # Zephyr SDK deeply nested structure (4 levels)
+ # e.g., toolchain-gccarmnoneeabi/zephyr-sdk-0.17.4/arm-zephyr-eabi/bin/arm-zephyr-eabi-objdump
+ f"toolchain-*/*/*/bin/*-{tool_name}",
+ # Zephyr SDK nested structure (3 levels)
+ f"toolchain-*/*/bin/*-{tool_name}",
+ f"toolchain-*/bin/*-{tool_name}",
+ # Standard PlatformIO toolchain structure
+ f"toolchain-*/bin/*{tool_name}",
+ ]
+
+ for pattern in search_patterns:
+ matches = list(platformio_home.glob(pattern))
+ if matches:
+ # Sort to get consistent results, prefer arm-zephyr-eabi over arm-none-eabi
+ matches.sort(key=lambda p: ("zephyr" not in str(p), str(p)))
+ tool_path = str(matches[0])
+ _LOGGER.debug("Found %s in PlatformIO packages: %s", tool_name, tool_path)
+ return tool_path
+
+ return None
+
+
+def resolve_tool_path(
+ tool_name: str,
+ derived_path: str | None,
+ objdump_path: str | None = None,
+) -> str | None:
+ """Resolve a tool path, falling back to find_tool if derived path doesn't exist.
+
+ Args:
+ tool_name: Name of the tool (e.g., "objdump", "readelf")
+ derived_path: Path derived from idedata (may not exist for some platforms)
+ objdump_path: Path to objdump binary to derive other tool paths from
+
+ Returns:
+ Resolved path to the tool, or the original derived_path if it exists
+ """
+ if derived_path and not Path(derived_path).exists():
+ found = find_tool(tool_name, objdump_path)
+ if found:
+ _LOGGER.debug(
+ "Derived %s path %s not found, using %s",
+ tool_name,
+ derived_path,
+ found,
+ )
+ return found
+ return derived_path
+
+
def find_tool(
tool_name: str,
objdump_path: str | None = None,
@@ -28,7 +101,8 @@ def find_tool(
"""Find a toolchain tool by name.
First tries to derive the tool path from objdump_path (if provided),
- then falls back to searching for platform-specific tools.
+ then searches PlatformIO package directories (for cross-compile toolchains),
+ and finally falls back to searching for platform-specific tools in PATH.
Args:
tool_name: Name of the tool (e.g., "objdump", "nm", "c++filt")
@@ -47,7 +121,13 @@ def find_tool(
_LOGGER.debug("Found %s at: %s", tool_name, potential_path)
return potential_path
- # Try platform-specific tools
+ # Search in PlatformIO packages directory first (handles Zephyr SDK, etc.)
+ # This must come before PATH search because system tools (e.g., /usr/bin/objdump)
+ # are for the host architecture, not the target (ARM, Xtensa, etc.)
+ if found := _find_in_platformio_packages(tool_name):
+ return found
+
+ # Try platform-specific tools in PATH (fallback for when tools are installed globally)
for prefix in TOOLCHAIN_PREFIXES:
cmd = f"{prefix}{tool_name}"
try:
diff --git a/script/determine-jobs.py b/script/determine-jobs.py
index 7ecbfb225e..318ac04a7d 100755
--- a/script/determine-jobs.py
+++ b/script/determine-jobs.py
@@ -93,6 +93,7 @@ class Platform(StrEnum):
RTL87XX_ARD = "rtl87xx-ard" # LibreTiny RTL8720x
LN882X_ARD = "ln882x-ard" # LibreTiny LN882x
RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico
+ NRF52_ZEPHYR = "nrf52-adafruit" # Nordic nRF52 (Zephyr)
# Memory impact analysis constants
@@ -112,7 +113,7 @@ PLATFORM_SPECIFIC_COMPONENTS = frozenset(
"rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny)
"ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny)
"host", # Host platform (for testing on development machine)
- "nrf52", # Nordic nRF52 platform implementation
+ "nrf52", # Nordic nRF52 platform implementation (uses Zephyr)
}
)
@@ -126,6 +127,7 @@ PLATFORM_SPECIFIC_COMPONENTS = frozenset(
# 4-6. Other ESP32 variants - Less commonly used but still supported
# 7-9. LibreTiny platforms (BK72XX, RTL87XX, LN882X) - good for detecting LibreTiny-specific changes
# 10. RP2040 - Raspberry Pi Pico platform
+# 11. nRF52 - Nordic nRF52 with Zephyr (good for detecting Zephyr-specific changes)
MEMORY_IMPACT_PLATFORM_PREFERENCE = [
Platform.ESP32_C6_IDF, # ESP32-C6 IDF (newest, supports Thread/Zigbee)
Platform.ESP8266_ARD, # ESP8266 Arduino (most memory constrained, fastest builds)
@@ -137,6 +139,7 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [
Platform.RTL87XX_ARD, # LibreTiny RTL8720x
Platform.LN882X_ARD, # LibreTiny LN882x
Platform.RP2040_ARD, # Raspberry Pi Pico
+ Platform.NRF52_ZEPHYR, # Nordic nRF52 (Zephyr)
]
@@ -463,6 +466,10 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None:
if "pico" in filename_lower or "rp2040" in filename_lower:
return Platform.RP2040_ARD
+ # nRF52 / Zephyr
+ if "nrf52" in filename_lower or "zephyr" in filename_lower:
+ return Platform.NRF52_ZEPHYR
+
return None
diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py
index 52025513a8..61ef8985df 100644
--- a/tests/script/test_determine_jobs.py
+++ b/tests/script/test_determine_jobs.py
@@ -1499,6 +1499,23 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) ->
"tests/components/rp2040/test.rp2040-ard.yaml",
determine_jobs.Platform.RP2040_ARD,
),
+ # nRF52 / Zephyr detection
+ (
+ "tests/components/logger/test.nrf52-adafruit.yaml",
+ determine_jobs.Platform.NRF52_ZEPHYR,
+ ),
+ (
+ "esphome/components/nrf52/gpio.cpp",
+ determine_jobs.Platform.NRF52_ZEPHYR,
+ ),
+ (
+ "esphome/components/zephyr/core.cpp",
+ determine_jobs.Platform.NRF52_ZEPHYR,
+ ),
+ (
+ "esphome/components/zephyr_ble_server/ble_server.cpp",
+ determine_jobs.Platform.NRF52_ZEPHYR,
+ ),
# No platform hint (generic files)
("esphome/components/wifi/wifi.cpp", None),
("esphome/components/sensor/sensor.h", None),
@@ -1528,6 +1545,10 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) ->
"pico_i2c",
"pico_spi",
"rp2040_test_yaml",
+ "nrf52_test_yaml",
+ "nrf52_gpio",
+ "zephyr_core",
+ "zephyr_ble_server",
"generic_wifi_no_hint",
"generic_sensor_no_hint",
"core_helpers_no_hint",
@@ -1554,6 +1575,11 @@ def test_detect_platform_hint_from_filename(
("file_ESP8266.cpp", determine_jobs.Platform.ESP8266_ARD),
# ESP32 with different cases
("file_ESP32.cpp", determine_jobs.Platform.ESP32_IDF),
+ # nRF52/Zephyr with different cases
+ ("file_NRF52.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
+ ("file_Nrf52.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
+ ("file_ZEPHYR.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
+ ("file_Zephyr.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
],
ids=[
"rp2040_uppercase",
@@ -1562,6 +1588,10 @@ def test_detect_platform_hint_from_filename(
"pico_titlecase",
"esp8266_uppercase",
"esp32_uppercase",
+ "nrf52_uppercase",
+ "nrf52_mixedcase",
+ "zephyr_uppercase",
+ "zephyr_titlecase",
],
)
def test_detect_platform_hint_from_filename_case_insensitive(
From b37cb812a71a70082a40018a9ad50a56ae6dbd4f Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 18:03:11 -1000
Subject: [PATCH 27/60] [core] Add buf_append_printf helper for safe buffer
formatting (#13258)
---
esphome/core/helpers.h | 51 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h
index d5a04b7eb1..9dc289c743 100644
--- a/esphome/core/helpers.h
+++ b/esphome/core/helpers.h
@@ -1,8 +1,11 @@
#pragma once
+#include
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -18,6 +21,7 @@
#ifdef USE_ESP8266
#include
+#include
#endif
#ifdef USE_RP2040
@@ -568,6 +572,53 @@ std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt,
/// sprintf-like function returning std::string.
std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...);
+#ifdef USE_ESP8266
+// ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM)
+// Format strings must be wrapped with PSTR() macro
+/// Safely append formatted string to buffer, returning new position (capped at size).
+/// @param buf Output buffer
+/// @param size Total buffer size
+/// @param pos Current position in buffer
+/// @param fmt Format string (must be in PROGMEM on ESP8266)
+/// @return New position after appending (capped at size on overflow)
+inline size_t buf_append_printf_p(char *buf, size_t size, size_t pos, PGM_P fmt, ...) {
+ if (pos >= size) {
+ return size;
+ }
+ va_list args;
+ va_start(args, fmt);
+ int written = vsnprintf_P(buf + pos, size - pos, fmt, args);
+ va_end(args);
+ if (written < 0) {
+ return pos; // encoding error
+ }
+ return std::min(pos + static_cast(written), size);
+}
+#define buf_append_printf(buf, size, pos, fmt, ...) buf_append_printf_p(buf, size, pos, PSTR(fmt), ##__VA_ARGS__)
+#else
+/// Safely append formatted string to buffer, returning new position (capped at size).
+/// Handles snprintf edge cases: negative returns (encoding errors) and truncation.
+/// @param buf Output buffer
+/// @param size Total buffer size
+/// @param pos Current position in buffer
+/// @param fmt printf-style format string
+/// @return New position after appending (capped at size on overflow)
+__attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf, size_t size, size_t pos,
+ const char *fmt, ...) {
+ if (pos >= size) {
+ return size;
+ }
+ va_list args;
+ va_start(args, fmt);
+ int written = vsnprintf(buf + pos, size - pos, fmt, args);
+ va_end(args);
+ if (written < 0) {
+ return pos; // encoding error
+ }
+ return std::min(pos + static_cast(written), size);
+}
+#endif
+
/// Concatenate a name with a separator and suffix using an efficient stack-based approach.
/// This avoids multiple heap allocations during string construction.
/// Maximum name length supported is 120 characters for friendly names.
From 14b7539094833cd5fab4e09852cce72639399dad Mon Sep 17 00:00:00 2001
From: Keith Burzinski
Date: Thu, 15 Jan 2026 22:04:21 -0600
Subject: [PATCH 28/60] [infrared, remote_base] Optimize IR transmit path for
`web_server` base85 data (#13238)
---
esphome/components/infrared/infrared.cpp | 21 ++++++++-
esphome/components/infrared/infrared.h | 43 +++++++++++++++----
.../components/remote_base/remote_base.cpp | 4 ++
esphome/components/remote_base/remote_base.h | 5 +++
4 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp
index 5f8d63926a..294d69e523 100644
--- a/esphome/components/infrared/infrared.cpp
+++ b/esphome/components/infrared/infrared.cpp
@@ -18,7 +18,15 @@ InfraredCall &InfraredCall::set_carrier_frequency(uint32_t frequency) {
InfraredCall &InfraredCall::set_raw_timings(const std::vector &timings) {
this->raw_timings_ = &timings;
- this->packed_data_ = nullptr; // Clear packed if vector is set
+ this->packed_data_ = nullptr;
+ this->base85_ptr_ = nullptr;
+ return *this;
+}
+
+InfraredCall &InfraredCall::set_raw_timings_base85(const std::string &base85) {
+ this->base85_ptr_ = &base85;
+ this->raw_timings_ = nullptr;
+ this->packed_data_ = nullptr;
return *this;
}
@@ -26,7 +34,8 @@ InfraredCall &InfraredCall::set_raw_timings_packed(const uint8_t *data, uint16_t
this->packed_data_ = data;
this->packed_length_ = length;
this->packed_count_ = count;
- this->raw_timings_ = nullptr; // Clear vector if packed is set
+ this->raw_timings_ = nullptr;
+ this->base85_ptr_ = nullptr;
return *this;
}
@@ -92,6 +101,14 @@ void Infrared::control(const InfraredCall &call) {
call.get_packed_count());
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%u, repeat=%u", call.get_packed_count(),
call.get_repeat_count());
+ } else if (call.is_base85()) {
+ // Decode base85 directly into transmit buffer (zero heap allocations)
+ if (!transmit_data->set_data_from_base85(call.get_base85_data())) {
+ ESP_LOGE(TAG, "Invalid base85 data");
+ return;
+ }
+ ESP_LOGD(TAG, "Transmitting base85 raw timings: count=%zu, repeat=%u", transmit_data->get_data().size(),
+ call.get_repeat_count());
} else {
// From vector (lambdas/automations)
transmit_data->set_data(call.get_raw_timings());
diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h
index 3a891301f4..ba426c9daa 100644
--- a/esphome/components/infrared/infrared.h
+++ b/esphome/components/infrared/infrared.h
@@ -28,12 +28,29 @@ class InfraredCall {
/// Set the carrier frequency in Hz
InfraredCall &set_carrier_frequency(uint32_t frequency);
- /// Set the raw timings (positive = mark, negative = space)
- /// Note: The timings vector must outlive the InfraredCall (zero-copy reference)
+
+ // ===== Raw Timings Methods =====
+ // All set_raw_timings_* methods store pointers/references to external data.
+ // The referenced data must remain valid until perform() completes.
+ // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous
+ // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone!
+
+ /// Set the raw timings from a vector (positive = mark, negative = space)
+ /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform().
+ /// @note Usage: Primarily for lambdas/automations where the vector is in scope.
InfraredCall &set_raw_timings(const std::vector &timings);
- /// Set the raw timings from packed protobuf sint32 data (zero-copy from wire)
- /// Note: The data must outlive the InfraredCall
+
+ /// Set the raw timings from base85-encoded int32 data
+ /// @note Lifetime: Stores a pointer to the string. The string must outlive perform().
+ /// @note Usage: For web_server where the encoded string is on the stack.
+ /// @note Decoding happens at perform() time, directly into the transmit buffer.
+ InfraredCall &set_raw_timings_base85(const std::string &base85);
+
+ /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded)
+ /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform().
+ /// @note Usage: For API component where data comes directly from the protobuf message.
InfraredCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count);
+
/// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.)
InfraredCall &set_repeat_count(uint32_t count);
@@ -42,12 +59,18 @@ class InfraredCall {
/// Get the carrier frequency
const optional &get_carrier_frequency() const { return this->carrier_frequency_; }
- /// Get the raw timings (only valid if set via set_raw_timings, not packed)
+ /// Get the raw timings (only valid if set via set_raw_timings, not packed or base85)
const std::vector &get_raw_timings() const { return *this->raw_timings_; }
- /// Check if raw timings have been set (either vector or packed)
- bool has_raw_timings() const { return this->raw_timings_ != nullptr || this->packed_data_ != nullptr; }
+ /// Check if raw timings have been set (vector, packed, or base85)
+ bool has_raw_timings() const {
+ return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base85_ptr_ != nullptr;
+ }
/// Check if using packed data format
bool is_packed() const { return this->packed_data_ != nullptr; }
+ /// Check if using base85 data format
+ bool is_base85() const { return this->base85_ptr_ != nullptr; }
+ /// Get the base85 data string
+ const std::string &get_base85_data() const { return *this->base85_ptr_; }
/// Get packed data (only valid if set via set_raw_timings_packed)
const uint8_t *get_packed_data() const { return this->packed_data_; }
uint16_t get_packed_length() const { return this->packed_length_; }
@@ -59,9 +82,11 @@ class InfraredCall {
uint32_t repeat_count_{1};
Infrared *parent_;
optional carrier_frequency_;
- // Vector-based timings (for lambdas/automations)
+ // Pointer to vector-based timings (caller-owned, must outlive perform())
const std::vector *raw_timings_{nullptr};
- // Packed protobuf timings (for API zero-copy)
+ // Pointer to base85-encoded string (caller-owned, must outlive perform())
+ const std::string *base85_ptr_{nullptr};
+ // Pointer to packed protobuf buffer (caller-owned, must outlive perform())
const uint8_t *packed_data_{nullptr};
uint16_t packed_length_{0};
uint16_t packed_count_{0};
diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp
index 2f1c107bf4..e3d9463243 100644
--- a/esphome/components/remote_base/remote_base.cpp
+++ b/esphome/components/remote_base/remote_base.cpp
@@ -159,6 +159,10 @@ void RemoteTransmitData::set_data_from_packed_sint32(const uint8_t *data, size_t
}
}
+bool RemoteTransmitData::set_data_from_base85(const std::string &base85) {
+ return base85_decode_int32_vector(base85, this->data_);
+}
+
/* RemoteTransmitterBase */
void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) {
diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h
index a11e0271af..2d7642cc31 100644
--- a/esphome/components/remote_base/remote_base.h
+++ b/esphome/components/remote_base/remote_base.h
@@ -36,6 +36,11 @@ class RemoteTransmitData {
/// @param len Length of the buffer in bytes
/// @param count Number of values (for reserve optimization)
void set_data_from_packed_sint32(const uint8_t *data, size_t len, size_t count);
+ /// Set data from base85-encoded int32 values
+ /// Decodes directly into internal buffer (zero heap allocations)
+ /// @param base85 Base85-encoded string (5 chars per int32 value)
+ /// @return true if successful, false if decode failed or invalid size
+ bool set_data_from_base85(const std::string &base85);
void reset() {
this->data_.clear();
this->carrier_frequency_ = 0;
From 8263a8273ff034a2f8c6684914c6da6906a45001 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 18:08:26 -1000
Subject: [PATCH 29/60] [debug] Add min_free heap sensor for ESP32 and
LibreTiny, add fragmentation for ESP32 (#13231)
---
esphome/components/debug/debug_component.h | 10 ++++--
esphome/components/debug/debug_esp32.cpp | 13 +++++++-
esphome/components/debug/debug_libretiny.cpp | 3 ++
esphome/components/debug/sensor.py | 31 +++++++++++++++++--
tests/components/debug/common.yaml | 2 ++
tests/components/debug/test.bk72xx-ard.yaml | 5 +++
tests/components/debug/test.esp32-ard.yaml | 7 +++++
tests/components/debug/test.esp32-idf.yaml | 4 +++
tests/components/debug/test.esp32-s2-idf.yaml | 7 +++++
tests/components/debug/test.esp8266-ard.yaml | 5 +++
tests/components/debug/test.ln882x-ard.yaml | 5 +++
tests/components/debug/test.rtl87xx-ard.yaml | 6 ++++
12 files changed, 93 insertions(+), 5 deletions(-)
create mode 100644 tests/components/debug/test.rtl87xx-ard.yaml
diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h
index 5783bc5418..6cf52d890c 100644
--- a/esphome/components/debug/debug_component.h
+++ b/esphome/components/debug/debug_component.h
@@ -74,8 +74,11 @@ class DebugComponent : public PollingComponent {
#ifdef USE_SENSOR
void set_free_sensor(sensor::Sensor *free_sensor) { free_sensor_ = free_sensor; }
void set_block_sensor(sensor::Sensor *block_sensor) { block_sensor_ = block_sensor; }
-#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)
+#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32)
void set_fragmentation_sensor(sensor::Sensor *fragmentation_sensor) { fragmentation_sensor_ = fragmentation_sensor; }
+#endif
+#if defined(USE_ESP32) || defined(USE_LIBRETINY)
+ void set_min_free_sensor(sensor::Sensor *min_free_sensor) { min_free_sensor_ = min_free_sensor; }
#endif
void set_loop_time_sensor(sensor::Sensor *loop_time_sensor) { loop_time_sensor_ = loop_time_sensor; }
#ifdef USE_ESP32
@@ -97,8 +100,11 @@ class DebugComponent : public PollingComponent {
sensor::Sensor *free_sensor_{nullptr};
sensor::Sensor *block_sensor_{nullptr};
-#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)
+#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32)
sensor::Sensor *fragmentation_sensor_{nullptr};
+#endif
+#if defined(USE_ESP32) || defined(USE_LIBRETINY)
+ sensor::Sensor *min_free_sensor_{nullptr};
#endif
sensor::Sensor *loop_time_sensor_{nullptr};
#ifdef USE_ESP32
diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp
index ebb6abf4da..8c41011f7d 100644
--- a/esphome/components/debug/debug_esp32.cpp
+++ b/esphome/components/debug/debug_esp32.cpp
@@ -234,8 +234,19 @@ size_t DebugComponent::get_device_info_(std::span
void DebugComponent::update_platform_() {
#ifdef USE_SENSOR
+ uint32_t max_alloc = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL);
if (this->block_sensor_ != nullptr) {
- this->block_sensor_->publish_state(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
+ this->block_sensor_->publish_state(max_alloc);
+ }
+ if (this->min_free_sensor_ != nullptr) {
+ this->min_free_sensor_->publish_state(heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL));
+ }
+ if (this->fragmentation_sensor_ != nullptr) {
+ uint32_t free_heap = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
+ if (free_heap > 0) {
+ float fragmentation = 100.0f - (100.0f * max_alloc / free_heap);
+ this->fragmentation_sensor_->publish_state(fragmentation);
+ }
}
if (this->psram_sensor_ != nullptr) {
this->psram_sensor_->publish_state(heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp
index 4f07a4cc17..aae27c8ca2 100644
--- a/esphome/components/debug/debug_libretiny.cpp
+++ b/esphome/components/debug/debug_libretiny.cpp
@@ -51,6 +51,9 @@ void DebugComponent::update_platform_() {
if (this->block_sensor_ != nullptr) {
this->block_sensor_->publish_state(lt_heap_get_max_alloc());
}
+ if (this->min_free_sensor_ != nullptr) {
+ this->min_free_sensor_->publish_state(lt_heap_get_min_free());
+ }
#endif
}
diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py
index 6a8e2cd828..0a716d666e 100644
--- a/esphome/components/debug/sensor.py
+++ b/esphome/components/debug/sensor.py
@@ -11,6 +11,9 @@ from esphome.const import (
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_COUNTER,
ICON_TIMER,
+ PLATFORM_BK72XX,
+ PLATFORM_LN882X,
+ PLATFORM_RTL87XX,
UNIT_BYTES,
UNIT_HERTZ,
UNIT_MILLISECOND,
@@ -25,6 +28,7 @@ from . import ( # noqa: F401 pylint: disable=unused-import
DEPENDENCIES = ["debug"]
+CONF_MIN_FREE = "min_free"
CONF_PSRAM = "psram"
CONFIG_SCHEMA = {
@@ -42,8 +46,14 @@ CONFIG_SCHEMA = {
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_FRAGMENTATION): cv.All(
- cv.only_on_esp8266,
- cv.require_framework_version(esp8266_arduino=cv.Version(2, 5, 2)),
+ cv.Any(
+ cv.All(
+ cv.only_on_esp8266,
+ cv.require_framework_version(esp8266_arduino=cv.Version(2, 5, 2)),
+ ),
+ cv.only_on_esp32,
+ msg="This feature is only available on ESP8266 (Arduino 2.5.2+) and ESP32",
+ ),
sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
icon=ICON_COUNTER,
@@ -51,6 +61,19 @@ CONFIG_SCHEMA = {
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
),
+ cv.Optional(CONF_MIN_FREE): cv.All(
+ cv.Any(
+ cv.only_on_esp32,
+ cv.only_on([PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX]),
+ msg="This feature is only available on ESP32 and LibreTiny (BK72xx, LN882x, RTL87xx)",
+ ),
+ sensor.sensor_schema(
+ unit_of_measurement=UNIT_BYTES,
+ icon=ICON_COUNTER,
+ accuracy_decimals=0,
+ entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
+ ),
+ ),
cv.Optional(CONF_LOOP_TIME): sensor.sensor_schema(
unit_of_measurement=UNIT_MILLISECOND,
icon=ICON_TIMER,
@@ -93,6 +116,10 @@ async def to_code(config):
sens = await sensor.new_sensor(fragmentation_conf)
cg.add(debug_component.set_fragmentation_sensor(sens))
+ if min_free_conf := config.get(CONF_MIN_FREE):
+ sens = await sensor.new_sensor(min_free_conf)
+ cg.add(debug_component.set_min_free_sensor(sens))
+
if loop_time_conf := config.get(CONF_LOOP_TIME):
sens = await sensor.new_sensor(loop_time_conf)
cg.add(debug_component.set_loop_time_sensor(sens))
diff --git a/tests/components/debug/common.yaml b/tests/components/debug/common.yaml
index d9a61f8df0..59ba39c3a4 100644
--- a/tests/components/debug/common.yaml
+++ b/tests/components/debug/common.yaml
@@ -11,6 +11,8 @@ sensor:
- platform: debug
free:
name: "Heap Free"
+ block:
+ name: "Heap Block"
loop_time:
name: "Loop Time"
cpu_frequency:
diff --git a/tests/components/debug/test.bk72xx-ard.yaml b/tests/components/debug/test.bk72xx-ard.yaml
index dade44d145..fdae374788 100644
--- a/tests/components/debug/test.bk72xx-ard.yaml
+++ b/tests/components/debug/test.bk72xx-ard.yaml
@@ -1 +1,6 @@
<<: !include common.yaml
+
+sensor:
+ - platform: debug
+ min_free:
+ name: "Heap Min Free"
diff --git a/tests/components/debug/test.esp32-ard.yaml b/tests/components/debug/test.esp32-ard.yaml
index 8e19a4d627..8f93b0925e 100644
--- a/tests/components/debug/test.esp32-ard.yaml
+++ b/tests/components/debug/test.esp32-ard.yaml
@@ -2,3 +2,10 @@
esp32:
cpu_frequency: 240MHz
+
+sensor:
+ - platform: debug
+ fragmentation:
+ name: "Heap Fragmentation"
+ min_free:
+ name: "Heap Min Free"
diff --git a/tests/components/debug/test.esp32-idf.yaml b/tests/components/debug/test.esp32-idf.yaml
index f7483a54b3..6a9996ad06 100644
--- a/tests/components/debug/test.esp32-idf.yaml
+++ b/tests/components/debug/test.esp32-idf.yaml
@@ -9,5 +9,9 @@ sensor:
name: "Heap Free"
psram:
name: "Free PSRAM"
+ fragmentation:
+ name: "Heap Fragmentation"
+ min_free:
+ name: "Heap Min Free"
psram:
diff --git a/tests/components/debug/test.esp32-s2-idf.yaml b/tests/components/debug/test.esp32-s2-idf.yaml
index dade44d145..80919b0bab 100644
--- a/tests/components/debug/test.esp32-s2-idf.yaml
+++ b/tests/components/debug/test.esp32-s2-idf.yaml
@@ -1 +1,8 @@
<<: !include common.yaml
+
+sensor:
+ - platform: debug
+ fragmentation:
+ name: "Heap Fragmentation"
+ min_free:
+ name: "Heap Min Free"
diff --git a/tests/components/debug/test.esp8266-ard.yaml b/tests/components/debug/test.esp8266-ard.yaml
index dade44d145..1398087bf0 100644
--- a/tests/components/debug/test.esp8266-ard.yaml
+++ b/tests/components/debug/test.esp8266-ard.yaml
@@ -1 +1,6 @@
<<: !include common.yaml
+
+sensor:
+ - platform: debug
+ fragmentation:
+ name: "Heap Fragmentation"
diff --git a/tests/components/debug/test.ln882x-ard.yaml b/tests/components/debug/test.ln882x-ard.yaml
index dade44d145..fdae374788 100644
--- a/tests/components/debug/test.ln882x-ard.yaml
+++ b/tests/components/debug/test.ln882x-ard.yaml
@@ -1 +1,6 @@
<<: !include common.yaml
+
+sensor:
+ - platform: debug
+ min_free:
+ name: "Heap Min Free"
diff --git a/tests/components/debug/test.rtl87xx-ard.yaml b/tests/components/debug/test.rtl87xx-ard.yaml
new file mode 100644
index 0000000000..fdae374788
--- /dev/null
+++ b/tests/components/debug/test.rtl87xx-ard.yaml
@@ -0,0 +1,6 @@
+<<: !include common.yaml
+
+sensor:
+ - platform: debug
+ min_free:
+ name: "Heap Min Free"
From 68affe0b9c0c99f91c1e3ee456c1d233bad20607 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 18:55:32 -1000
Subject: [PATCH 30/60] [core] Add --device hint when DNS resolution fails
(#13240)
---
esphome/__main__.py | 7 ++++++-
esphome/espota2.py | 2 ++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/esphome/__main__.py b/esphome/__main__.py
index 3849a585ca..545464be10 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -222,8 +222,13 @@ def choose_upload_log_host(
else:
resolved.append(device)
if not resolved:
+ if CORE.dashboard:
+ hint = "If you know the IP, set 'use_address' in your network config."
+ else:
+ hint = "If you know the IP, try --device "
raise EsphomeError(
- f"All specified devices {defaults} could not be resolved. Is the device connected to the network?"
+ f"All specified devices {defaults} could not be resolved. "
+ f"Is the device connected to the network? {hint}"
)
return resolved
diff --git a/esphome/espota2.py b/esphome/espota2.py
index 6349ad0fa8..95dd602ad2 100644
--- a/esphome/espota2.py
+++ b/esphome/espota2.py
@@ -400,6 +400,8 @@ def run_ota_impl_(
"Error resolving IP address of %s. Is it connected to WiFi?",
remote_host,
)
+ if not CORE.dashboard:
+ _LOGGER.error("(If you know the IP, try --device )")
_LOGGER.error(
"(If this error persists, please set a static IP address: "
"https://esphome.io/components/wifi/#manual-ips)"
From 5b37d2fb274bd8e873cb7bbe9dc6fedf9259e767 Mon Sep 17 00:00:00 2001
From: Keith Burzinski
Date: Fri, 16 Jan 2026 02:55:24 -0600
Subject: [PATCH 31/60] [helpers] Support `base64url` encoding (#13264)
---
esphome/core/helpers.cpp | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp
index b5bf849c30..96b2d46d78 100644
--- a/esphome/core/helpers.cpp
+++ b/esphome/core/helpers.cpp
@@ -487,19 +487,26 @@ static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
-// Helper function to find the index of a base64 character in the lookup table.
+// Helper function to find the index of a base64/base64url character in the lookup table.
// Returns the character's position (0-63) if found, or 0 if not found.
+// Supports both standard base64 (+/) and base64url (-_) alphabets.
// NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters.
// This is safe because is_base64() is ALWAYS checked before calling this function,
// preventing invalid characters from ever reaching here. The base64_decode function
// stops processing at the first invalid character due to the is_base64() check in its
// while loop condition, making this edge case harmless in practice.
static inline uint8_t base64_find_char(char c) {
+ // Handle base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63)
+ if (c == '-')
+ return 62;
+ if (c == '_')
+ return 63;
const char *pos = strchr(BASE64_CHARS, c);
return pos ? (pos - BASE64_CHARS) : 0;
}
-static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/')); }
+// Check if character is valid base64 or base64url
+static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/') || (c == '-') || (c == '_')); }
std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); }
From 4906f87751a4670e08f4e254be647cc96b389fb8 Mon Sep 17 00:00:00 2001
From: Remco van Essen
Date: Fri, 16 Jan 2026 11:17:32 +0100
Subject: [PATCH 32/60] [mipi_dsi] add JC8012P4A1 (#13241)
---
esphome/components/mipi_dsi/models/guition.py | 221 ++++++++++++++++++
1 file changed, 221 insertions(+)
diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py
index cd566633f9..db13c7f6cc 100644
--- a/esphome/components/mipi_dsi/models/guition.py
+++ b/esphome/components/mipi_dsi/models/guition.py
@@ -101,4 +101,225 @@ DriverChip(
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x00),
]
)
+
+# jc8012P4A1 Driver Configuration (jd9365)
+# Using parameters from esp_lcd_jd9365.h and the working full init sequence
+# ----------------------------------------------------------------------------------------------------------------------
+# * Resolution: 800x1280
+# * PCLK Frequency: 60 MHz
+# * DSI Lane Bit Rate: 1 Gbps (using 2-Lane DSI configuration)
+# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40)
+# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20)
+# ----------------------------------------------------------------------------------------------------------------------
+DriverChip(
+ "JC8012P4A1",
+ width=800,
+ height=1280,
+ hsync_back_porch=20,
+ hsync_pulse_width=20,
+ hsync_front_porch=40,
+ vsync_back_porch=8,
+ vsync_pulse_width=4,
+ vsync_front_porch=20,
+ pclk_frequency="60MHz",
+ lane_bit_rate="1Gbps",
+ swap_xy=cv.UNDEFINED,
+ color_order="RGB",
+ reset_pin=27,
+ initsequence=[
+ (0xE0, 0x00),
+ (0xE1, 0x93),
+ (0xE2, 0x65),
+ (0xE3, 0xF8),
+ (0x80, 0x01),
+ (0xE0, 0x01),
+ (0x00, 0x00),
+ (0x01, 0x39),
+ (0x03, 0x10),
+ (0x04, 0x41),
+ (0x0C, 0x74),
+ (0x17, 0x00),
+ (0x18, 0xD7),
+ (0x19, 0x00),
+ (0x1A, 0x00),
+ (0x1B, 0xD7),
+ (0x1C, 0x00),
+ (0x24, 0xFE),
+ (0x35, 0x26),
+ (0x37, 0x69),
+ (0x38, 0x05),
+ (0x39, 0x06),
+ (0x3A, 0x08),
+ (0x3C, 0x78),
+ (0x3D, 0xFF),
+ (0x3E, 0xFF),
+ (0x3F, 0xFF),
+ (0x40, 0x06),
+ (0x41, 0xA0),
+ (0x43, 0x14),
+ (0x44, 0x0B),
+ (0x45, 0x30),
+ (0x4B, 0x04),
+ (0x55, 0x02),
+ (0x57, 0x89),
+ (0x59, 0x0A),
+ (0x5A, 0x28),
+ (0x5B, 0x15),
+ (0x5D, 0x50),
+ (0x5E, 0x37),
+ (0x5F, 0x29),
+ (0x60, 0x1E),
+ (0x61, 0x1D),
+ (0x62, 0x12),
+ (0x63, 0x1A),
+ (0x64, 0x08),
+ (0x65, 0x25),
+ (0x66, 0x26),
+ (0x67, 0x28),
+ (0x68, 0x49),
+ (0x69, 0x3A),
+ (0x6A, 0x43),
+ (0x6B, 0x3A),
+ (0x6C, 0x3B),
+ (0x6D, 0x32),
+ (0x6E, 0x1F),
+ (0x6F, 0x0E),
+ (0x70, 0x50),
+ (0x71, 0x37),
+ (0x72, 0x29),
+ (0x73, 0x1E),
+ (0x74, 0x1D),
+ (0x75, 0x12),
+ (0x76, 0x1A),
+ (0x77, 0x08),
+ (0x78, 0x25),
+ (0x79, 0x26),
+ (0x7A, 0x28),
+ (0x7B, 0x49),
+ (0x7C, 0x3A),
+ (0x7D, 0x43),
+ (0x7E, 0x3A),
+ (0x7F, 0x3B),
+ (0x80, 0x32),
+ (0x81, 0x1F),
+ (0x82, 0x0E),
+ (0xE0, 0x02),
+ (0x00, 0x1F),
+ (0x01, 0x1F),
+ (0x02, 0x52),
+ (0x03, 0x51),
+ (0x04, 0x50),
+ (0x05, 0x4B),
+ (0x06, 0x4A),
+ (0x07, 0x49),
+ (0x08, 0x48),
+ (0x09, 0x47),
+ (0x0A, 0x46),
+ (0x0B, 0x45),
+ (0x0C, 0x44),
+ (0x0D, 0x40),
+ (0x0E, 0x41),
+ (0x0F, 0x1F),
+ (0x10, 0x1F),
+ (0x11, 0x1F),
+ (0x12, 0x1F),
+ (0x13, 0x1F),
+ (0x14, 0x1F),
+ (0x15, 0x1F),
+ (0x16, 0x1F),
+ (0x17, 0x1F),
+ (0x18, 0x52),
+ (0x19, 0x51),
+ (0x1A, 0x50),
+ (0x1B, 0x4B),
+ (0x1C, 0x4A),
+ (0x1D, 0x49),
+ (0x1E, 0x48),
+ (0x1F, 0x47),
+ (0x20, 0x46),
+ (0x21, 0x45),
+ (0x22, 0x44),
+ (0x23, 0x40),
+ (0x24, 0x41),
+ (0x25, 0x1F),
+ (0x26, 0x1F),
+ (0x27, 0x1F),
+ (0x28, 0x1F),
+ (0x29, 0x1F),
+ (0x2A, 0x1F),
+ (0x2B, 0x1F),
+ (0x2C, 0x1F),
+ (0x2D, 0x1F),
+ (0x2E, 0x52),
+ (0x2F, 0x40),
+ (0x30, 0x41),
+ (0x31, 0x48),
+ (0x32, 0x49),
+ (0x33, 0x4A),
+ (0x34, 0x4B),
+ (0x35, 0x44),
+ (0x36, 0x45),
+ (0x37, 0x46),
+ (0x38, 0x47),
+ (0x39, 0x51),
+ (0x3A, 0x50),
+ (0x3B, 0x1F),
+ (0x3C, 0x1F),
+ (0x3D, 0x1F),
+ (0x3E, 0x1F),
+ (0x3F, 0x1F),
+ (0x40, 0x1F),
+ (0x41, 0x1F),
+ (0x42, 0x1F),
+ (0x43, 0x1F),
+ (0x44, 0x52),
+ (0x45, 0x40),
+ (0x46, 0x41),
+ (0x47, 0x48),
+ (0x48, 0x49),
+ (0x49, 0x4A),
+ (0x4A, 0x4B),
+ (0x4B, 0x44),
+ (0x4C, 0x45),
+ (0x4D, 0x46),
+ (0x4E, 0x47),
+ (0x4F, 0x51),
+ (0x50, 0x50),
+ (0x51, 0x1F),
+ (0x52, 0x1F),
+ (0x53, 0x1F),
+ (0x54, 0x1F),
+ (0x55, 0x1F),
+ (0x56, 0x1F),
+ (0x57, 0x1F),
+ (0x58, 0x40),
+ (0x59, 0x00),
+ (0x5A, 0x00),
+ (0x5B, 0x10),
+ (0x5C, 0x05),
+ (0x5D, 0x50),
+ (0x5E, 0x01),
+ (0x5F, 0x02),
+ (0x60, 0x50),
+ (0x61, 0x06),
+ (0x62, 0x04),
+ (0x63, 0x03),
+ (0x64, 0x64),
+ (0x65, 0x65),
+ (0x66, 0x0B),
+ (0x67, 0x73),
+ (0x68, 0x07),
+ (0x69, 0x06),
+ (0x6A, 0x64),
+ (0x6B, 0x08),
+ (0x6C, 0x00),
+ (0x6D, 0x32),
+ (0x6E, 0x08),
+ (0xE0, 0x04),
+ (0x2C, 0x6B),
+ (0x35, 0x08),
+ (0x37, 0x00),
+ (0xE0, 0x00),
+ ]
+)
# fmt: on
From 16adae7359983dac9b9732109a65217b13797d11 Mon Sep 17 00:00:00 2001
From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com>
Date: Sat, 17 Jan 2026 01:19:09 +1000
Subject: [PATCH 33/60] [ntc, resistance] change log level to verbose (#13268)
---
esphome/components/ntc/ntc.cpp | 2 +-
esphome/components/resistance/resistance_sensor.cpp | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp
index b08f84029b..cc500ba429 100644
--- a/esphome/components/ntc/ntc.cpp
+++ b/esphome/components/ntc/ntc.cpp
@@ -23,7 +23,7 @@ void NTC::process_(float value) {
double v = this->a_ + this->b_ * lr + this->c_ * lr * lr * lr;
auto temp = float(1.0 / v - 273.15);
- ESP_LOGD(TAG, "'%s' - Temperature: %.1f°C", this->name_.c_str(), temp);
+ ESP_LOGV(TAG, "'%s' - Temperature: %.1f°C", this->name_.c_str(), temp);
this->publish_state(temp);
}
diff --git a/esphome/components/resistance/resistance_sensor.cpp b/esphome/components/resistance/resistance_sensor.cpp
index 6e57214449..706a059de3 100644
--- a/esphome/components/resistance/resistance_sensor.cpp
+++ b/esphome/components/resistance/resistance_sensor.cpp
@@ -39,7 +39,7 @@ void ResistanceSensor::process_(float value) {
}
res *= this->resistor_;
- ESP_LOGD(TAG, "'%s' - Resistance %.1fΩ", this->name_.c_str(), res);
+ ESP_LOGV(TAG, "'%s' - Resistance %.1fΩ", this->name_.c_str(), res);
this->publish_state(res);
}
From 916b028fb276718294fe356673a4a18a707d0599 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Fri, 16 Jan 2026 08:30:22 -1000
Subject: [PATCH 34/60] [mqtt] Replace sprintf with snprintf for friendly name
hash (#13262)
---
esphome/components/mqtt/mqtt_component.cpp | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp
index 20c111de43..8e4b3437ab 100644
--- a/esphome/components/mqtt/mqtt_component.cpp
+++ b/esphome/components/mqtt/mqtt_component.cpp
@@ -189,8 +189,7 @@ bool MQTTComponent::send_discovery_() {
StringRef object_id = this->get_default_object_id_to_(object_id_buf);
if (discovery_info.unique_id_generator == MQTT_MAC_ADDRESS_UNIQUE_ID_GENERATOR) {
char friendly_name_hash[9];
- sprintf(friendly_name_hash, "%08" PRIx32, fnv1_hash(this->friendly_name_()));
- friendly_name_hash[8] = 0; // ensure the hash-string ends with null
+ snprintf(friendly_name_hash, sizeof(friendly_name_hash), "%08" PRIx32, fnv1_hash(this->friendly_name_()));
// Format: mac-component_type-hash (e.g. "aabbccddeeff-sensor-12345678")
// MAC (12) + "-" (1) + domain (max 20) + "-" (1) + hash (8) + null (1) = 43
char unique_id[MAC_ADDRESS_BUFFER_SIZE + ESPHOME_DOMAIN_MAX_LEN + 11];
From bc78f80f779c78fb4afa7efad313bd8f10d90420 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 16 Jan 2026 09:36:29 -1000
Subject: [PATCH 35/60] Bump actions/cache from 5.0.1 to 5.0.2 (#13276)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/ci.yml | 30 +++++++++++++++---------------
1 file changed, 15 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 434aa388f7..81d3c826d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,7 +47,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
# yamllint disable-line rule:line-length
@@ -157,7 +157,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
- uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -193,7 +193,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -223,7 +223,7 @@ jobs:
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
- uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -245,7 +245,7 @@ jobs:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -334,14 +334,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
@@ -413,14 +413,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -502,14 +502,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -735,7 +735,7 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true'
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -759,7 +759,7 @@ jobs:
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -800,7 +800,7 @@ jobs:
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
- uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -847,7 +847,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
From 3057a0484fb2e68639c43305a7fc8ac22d1be6ea Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 16 Jan 2026 09:36:42 -1000
Subject: [PATCH 36/60] Bump actions/cache from 5.0.1 to 5.0.2 in
/.github/actions/restore-python (#13277)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/actions/restore-python/action.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml
index 75586fd854..370c8bcc46 100644
--- a/.github/actions/restore-python/action.yml
+++ b/.github/actions/restore-python/action.yml
@@ -22,7 +22,7 @@ runs:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
# yamllint disable-line rule:line-length
From 6832efbacca0d64cc53cb68a811a332eaee0b62b Mon Sep 17 00:00:00 2001
From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Date: Fri, 16 Jan 2026 15:24:28 -0500
Subject: [PATCH 37/60] Add Claude Code PR workflow skill (#13271)
Co-authored-by: Claude Opus 4.5
---
.claude/skills/pr-workflow/SKILL.md | 96 +++++++++++++++++++++++++++++
1 file changed, 96 insertions(+)
create mode 100644 .claude/skills/pr-workflow/SKILL.md
diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md
new file mode 100644
index 0000000000..4ec2551804
--- /dev/null
+++ b/.claude/skills/pr-workflow/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: pr-workflow
+description: Create pull requests for esphome. Use when creating PRs, submitting changes, or preparing contributions.
+allowed-tools: Read, Bash, Glob, Grep
+---
+
+# ESPHome PR Workflow
+
+When creating a pull request for esphome, follow these steps:
+
+## 1. Create Branch from Upstream
+
+Always base your branch on **upstream** (not origin/fork) to ensure you have the latest code:
+
+```bash
+git fetch upstream
+git checkout -b upstream/dev
+```
+
+## 2. Read the PR Template
+
+Before creating a PR, read `.github/PULL_REQUEST_TEMPLATE.md` to understand required fields.
+
+## 3. Create the PR
+
+Use `gh pr create` with the **full template** filled in. Never skip or abbreviate sections.
+
+Required fields:
+- **What does this implement/fix?**: Brief description of changes
+- **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.)
+- **Related issue**: Use `fixes ` syntax if applicable
+- **Pull request in esphome-docs**: Link if docs are needed
+- **Test Environment**: Check platforms you tested on
+- **Example config.yaml**: Include working example YAML
+- **Checklist**: Verify code is tested and tests added
+
+## 4. Example PR Body
+
+```markdown
+# What does this implement/fix?
+
+
+
+## Types of changes
+
+- [ ] Bugfix (non-breaking change which fixes an issue)
+- [x] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] Developer breaking change (an API change that could break external components)
+- [ ] Code quality improvements to existing code or addition of tests
+- [ ] Other
+
+**Related issue or feature (if applicable):**
+
+- fixes https://github.com/esphome/esphome/issues/XXX
+
+**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
+
+- esphome/esphome-docs#XXX
+
+## Test Environment
+
+- [x] ESP32
+- [x] ESP32 IDF
+- [ ] ESP8266
+- [ ] RP2040
+- [ ] BK72xx
+- [ ] RTL87xx
+- [ ] LN882x
+- [ ] nRF52840
+
+## Example entry for `config.yaml`:
+
+```yaml
+# Example config.yaml
+component_name:
+ id: my_component
+ option: value
+```
+
+## Checklist:
+ - [x] The code change is tested and works locally.
+ - [x] Tests have been added to verify that the new code works (under `tests/` folder).
+
+If user exposed functionality or configuration variables are added/changed:
+ - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).
+```
+
+## 5. Push and Create PR
+
+```bash
+git push -u origin
+gh pr create --repo esphome/esphome --base dev --title "[component] Brief description"
+```
+
+Title should be prefixed with the component name in brackets, e.g. `[safe_mode] Add feature`.
From a6808841389508ba06bd01ba32ea059c12dc032d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 16 Jan 2026 20:29:02 +0000
Subject: [PATCH 38/60] Bump ruff from 0.14.12 to 0.14.13 (#13275)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston
---
.pre-commit-config.yaml | 2 +-
requirements_test.txt | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3295cf070a..06f9bf2a5b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
- rev: v0.14.11
+ rev: v0.14.13
hooks:
# Run the linter.
- id: ruff
diff --git a/requirements_test.txt b/requirements_test.txt
index e0bc943ab4..d93a5d108f 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -1,6 +1,6 @@
pylint==4.0.4
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
-ruff==0.14.12 # also change in .pre-commit-config.yaml when updating
+ruff==0.14.13 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
From c5e4a608849e2bb956bd20da40d1229c1543b4f5 Mon Sep 17 00:00:00 2001
From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Date: Sat, 17 Jan 2026 08:35:40 +1100
Subject: [PATCH 39/60] [select] Add condition for testing select option
(#13267)
Co-authored-by: J. Nick Koston
---
esphome/components/select/__init__.py | 45 +++++++++++++++++++++-
esphome/components/select/automation.h | 30 +++++++++++++++
tests/components/template/common-base.yaml | 12 ++++++
3 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py
index 7c50fe02c0..c51131a292 100644
--- a/esphome/components/select/__init__.py
+++ b/esphome/components/select/__init__.py
@@ -8,17 +8,20 @@ from esphome.const import (
CONF_ICON,
CONF_ID,
CONF_INDEX,
+ CONF_LAMBDA,
CONF_MODE,
CONF_MQTT_ID,
CONF_ON_VALUE,
CONF_OPERATION,
CONF_OPTION,
+ CONF_OPTIONS,
CONF_TRIGGER_ID,
CONF_WEB_SERVER,
)
-from esphome.core import CORE, CoroPriority, coroutine_with_priority
+from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
-from esphome.cpp_generator import MockObjClass
+from esphome.cpp_generator import MockObjClass, TemplateArguments
+from esphome.cpp_types import global_ns
CODEOWNERS = ["@esphome/core"]
IS_PLATFORM_COMPONENT = True
@@ -38,6 +41,9 @@ SelectSetAction = select_ns.class_("SelectSetAction", automation.Action)
SelectSetIndexAction = select_ns.class_("SelectSetIndexAction", automation.Action)
SelectOperationAction = select_ns.class_("SelectOperationAction", automation.Action)
+# Conditions
+SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition)
+
# Enums
SelectOperation = select_ns.enum("SelectOperation")
SELECT_OPERATION_OPTIONS = {
@@ -165,6 +171,41 @@ async def select_set_index_to_code(config, action_id, template_arg, args):
return var
+@automation.register_condition(
+ "select.is",
+ SelectIsCondition,
+ OPERATION_BASE_SCHEMA.extend(
+ {
+ cv.Optional(CONF_OPTIONS): cv.All(
+ cv.ensure_list(cv.string_strict), cv.Length(min=1)
+ ),
+ cv.Optional(CONF_LAMBDA): cv.returning_lambda,
+ }
+ ).add_extra(cv.has_exactly_one_key(CONF_OPTIONS, CONF_LAMBDA)),
+)
+async def select_is_to_code(config, condition_id, template_arg, args):
+ paren = await cg.get_variable(config[CONF_ID])
+ if options := config.get(CONF_OPTIONS):
+ # List of constant options
+ # Create a constexpr and pass that with a template length
+ arr_id = ID(
+ f"{condition_id}_data",
+ is_declaration=True,
+ type=global_ns.namespace("constexpr char * const"),
+ )
+ arg = cg.static_const_array(arr_id, cg.ArrayInitializer(*options))
+ template_arg = TemplateArguments(len(options), *template_arg)
+ else:
+ # Lambda
+ arg = await cg.process_lambda(
+ config[CONF_LAMBDA],
+ [(global_ns.namespace("StringRef &").operator("const"), "current")] + args,
+ return_type=cg.bool_,
+ )
+ template_arg = TemplateArguments(0, *template_arg)
+ return cg.new_Pvariable(condition_id, template_arg, paren, arg)
+
+
@automation.register_action(
"select.operation",
SelectOperationAction,
diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h
index dda5403557..81e8a3561d 100644
--- a/esphome/components/select/automation.h
+++ b/esphome/components/select/automation.h
@@ -66,4 +66,34 @@ template class SelectOperationAction : public Action {
Select *select_;
};
+template class SelectIsCondition : public Condition {
+ public:
+ SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {}
+
+ bool check(const Ts &...x) override {
+ auto current = this->parent_->current_option();
+ for (size_t i = 0; i != N; i++) {
+ if (current == this->option_list_[i]) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ protected:
+ Select *parent_;
+ const char *const *option_list_;
+};
+
+template class SelectIsCondition<0, Ts...> : public Condition {
+ public:
+ SelectIsCondition(Select *parent, std::function &&f)
+ : parent_(parent), f_(f) {}
+
+ bool check(const Ts &...x) override { return this->f_(this->parent_->current_option(), x...); }
+
+ protected:
+ Select *parent_;
+ std::function f_;
+};
} // namespace esphome::select
diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml
index 134ad4d046..3b888c3d19 100644
--- a/tests/components/template/common-base.yaml
+++ b/tests/components/template/common-base.yaml
@@ -53,6 +53,17 @@ binary_sensor:
// Garage Door is closed.
return false;
}
+ - platform: template
+ id: select_binary_sensor
+ name: Select is one or two
+ condition:
+ any:
+ - select.is:
+ id: template_select
+ options: [one, two]
+ - select.is:
+ id: template_select
+ lambda: return current == id(template_text).state;
- platform: template
id: other_binary_sensor
name: "Garage Door Closed"
@@ -320,6 +331,7 @@ valve:
text:
- platform: template
+ id: template_text
name: "Template text"
optimistic: true
min_length: 0
From 52ac9e18616a1a8688745badcf09520c0370ef7c Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Fri, 16 Jan 2026 12:56:47 -1000
Subject: [PATCH 40/60] [remote_base] Replace unsafe sprintf with
buf_append_printf; fix buffer overflow (#13257)
Co-authored-by: Keith Burzinski
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../components/remote_base/aeha_protocol.cpp | 4 +--
.../components/remote_base/raw_protocol.cpp | 27 ++++++++----------
.../components/remote_base/remote_base.cpp | 28 ++++++++-----------
3 files changed, 25 insertions(+), 34 deletions(-)
diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp
index 04fe731817..3b926e7981 100644
--- a/esphome/components/remote_base/aeha_protocol.cpp
+++ b/esphome/components/remote_base/aeha_protocol.cpp
@@ -85,8 +85,8 @@ optional AEHAProtocol::decode(RemoteReceiveData src) {
std::string AEHAProtocol::format_data_(const std::vector &data) {
std::string out;
for (uint8_t byte : data) {
- char buf[6];
- sprintf(buf, "0x%02X,", byte);
+ char buf[8]; // "0x%02X," = 5 chars + null + margin
+ snprintf(buf, sizeof(buf), "0x%02X,", byte);
out += buf;
}
out.pop_back();
diff --git a/esphome/components/remote_base/raw_protocol.cpp b/esphome/components/remote_base/raw_protocol.cpp
index ef0cb8454e..7e6be3b77e 100644
--- a/esphome/components/remote_base/raw_protocol.cpp
+++ b/esphome/components/remote_base/raw_protocol.cpp
@@ -1,4 +1,5 @@
#include "raw_protocol.h"
+#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
@@ -8,36 +9,30 @@ static const char *const TAG = "remote.raw";
bool RawDumper::dump(RemoteReceiveData src) {
char buffer[256];
- uint32_t buffer_offset = 0;
- buffer_offset += sprintf(buffer, "Received Raw: ");
+ size_t pos = buf_append_printf(buffer, sizeof(buffer), 0, "Received Raw: ");
for (int32_t i = 0; i < src.size() - 1; i++) {
const int32_t value = src[i];
- const uint32_t remaining_length = sizeof(buffer) - buffer_offset;
- int written;
+ size_t prev_pos = pos;
if (i + 1 < src.size() - 1) {
- written = snprintf(buffer + buffer_offset, remaining_length, "%" PRId32 ", ", value);
+ pos = buf_append_printf(buffer, sizeof(buffer), pos, "%" PRId32 ", ", value);
} else {
- written = snprintf(buffer + buffer_offset, remaining_length, "%" PRId32, value);
+ pos = buf_append_printf(buffer, sizeof(buffer), pos, "%" PRId32, value);
}
- if (written < 0 || written >= int(remaining_length)) {
- // write failed, flush...
- buffer[buffer_offset] = '\0';
+ if (pos >= sizeof(buffer) - 1) {
+ // buffer full, flush and continue
+ buffer[prev_pos] = '\0';
ESP_LOGI(TAG, "%s", buffer);
- buffer_offset = 0;
- written = sprintf(buffer, " ");
if (i + 1 < src.size() - 1) {
- written += sprintf(buffer + written, "%" PRId32 ", ", value);
+ pos = buf_append_printf(buffer, sizeof(buffer), 0, " %" PRId32 ", ", value);
} else {
- written += sprintf(buffer + written, "%" PRId32, value);
+ pos = buf_append_printf(buffer, sizeof(buffer), 0, " %" PRId32, value);
}
}
-
- buffer_offset += written;
}
- if (buffer_offset != 0) {
+ if (pos != 0) {
ESP_LOGI(TAG, "%s", buffer);
}
return true;
diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp
index e3d9463243..53c9c38c7d 100644
--- a/esphome/components/remote_base/remote_base.cpp
+++ b/esphome/components/remote_base/remote_base.cpp
@@ -1,4 +1,5 @@
#include "remote_base.h"
+#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include
@@ -169,36 +170,31 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) {
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
const auto &vec = this->temp_.get_data();
char buffer[256];
- uint32_t buffer_offset = 0;
- buffer_offset += sprintf(buffer, "Sending times=%" PRIu32 " wait=%" PRIu32 "ms: ", send_times, send_wait);
+ size_t pos = buf_append_printf(buffer, sizeof(buffer), 0,
+ "Sending times=%" PRIu32 " wait=%" PRIu32 "ms: ", send_times, send_wait);
for (size_t i = 0; i < vec.size(); i++) {
const int32_t value = vec[i];
- const uint32_t remaining_length = sizeof(buffer) - buffer_offset;
- int written;
+ size_t prev_pos = pos;
if (i + 1 < vec.size()) {
- written = snprintf(buffer + buffer_offset, remaining_length, "%" PRId32 ", ", value);
+ pos = buf_append_printf(buffer, sizeof(buffer), pos, "%" PRId32 ", ", value);
} else {
- written = snprintf(buffer + buffer_offset, remaining_length, "%" PRId32, value);
+ pos = buf_append_printf(buffer, sizeof(buffer), pos, "%" PRId32, value);
}
- if (written < 0 || written >= int(remaining_length)) {
- // write failed, flush...
- buffer[buffer_offset] = '\0';
+ if (pos >= sizeof(buffer) - 1) {
+ // buffer full, flush and continue
+ buffer[prev_pos] = '\0';
ESP_LOGVV(TAG, "%s", buffer);
- buffer_offset = 0;
- written = sprintf(buffer, " ");
if (i + 1 < vec.size()) {
- written += sprintf(buffer + written, "%" PRId32 ", ", value);
+ pos = buf_append_printf(buffer, sizeof(buffer), 0, " %" PRId32 ", ", value);
} else {
- written += sprintf(buffer + written, "%" PRId32, value);
+ pos = buf_append_printf(buffer, sizeof(buffer), 0, " %" PRId32, value);
}
}
-
- buffer_offset += written;
}
- if (buffer_offset != 0) {
+ if (pos != 0) {
ESP_LOGVV(TAG, "%s", buffer);
}
#endif
From 58a9e30017b7094c9cf8bfb0739b610ba5bcd450 Mon Sep 17 00:00:00 2001
From: Keith Burzinski
Date: Fri, 16 Jan 2026 17:05:19 -0600
Subject: [PATCH 41/60] [helpers] Add `base64_decode_int32_vector` function
(#13289)
Co-authored-by: J. Nick Koston
---
esphome/core/helpers.cpp | 40 ++++++++++++++++++++++++++++++++++++++++
esphome/core/helpers.h | 6 ++++++
2 files changed, 46 insertions(+)
diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp
index 96b2d46d78..5cad2308df 100644
--- a/esphome/core/helpers.cpp
+++ b/esphome/core/helpers.cpp
@@ -624,6 +624,46 @@ std::vector base64_decode(const std::string &encoded_string) {
return ret;
}
+/// Decode base64/base64url string directly into vector of little-endian int32 values
+/// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted)
+/// @param out Output vector (cleared and filled with decoded int32 values)
+/// @return true if successful, false if decode failed or invalid size
+bool base64_decode_int32_vector(const std::string &base64, std::vector &out) {
+ // Decode in chunks to minimize stack usage
+ constexpr size_t chunk_bytes = 48; // 12 int32 values
+ constexpr size_t chunk_chars = 64; // 48 * 4/3 = 64 chars
+ uint8_t chunk[chunk_bytes];
+
+ out.clear();
+
+ const uint8_t *input = reinterpret_cast(base64.data());
+ size_t remaining = base64.size();
+ size_t pos = 0;
+
+ while (remaining > 0) {
+ size_t chars_to_decode = std::min(remaining, chunk_chars);
+ size_t decoded_len = base64_decode(input + pos, chars_to_decode, chunk, chunk_bytes);
+
+ if (decoded_len == 0)
+ return false;
+
+ // Parse little-endian int32 values
+ for (size_t i = 0; i + 3 < decoded_len; i += 4) {
+ int32_t timing = static_cast(encode_uint32(chunk[i + 3], chunk[i + 2], chunk[i + 1], chunk[i]));
+ out.push_back(timing);
+ }
+
+ // Check for incomplete int32 in last chunk
+ if (remaining <= chunk_chars && (decoded_len % 4) != 0)
+ return false;
+
+ pos += chars_to_decode;
+ remaining -= chars_to_decode;
+ }
+
+ return !out.empty();
+}
+
/// Encode int32 to 5 base85 characters + null terminator
/// Standard ASCII85 alphabet: '!' (33) = 0 through 'u' (117) = 84
inline void base85_encode_int32(int32_t value, std::span output) {
diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h
index 9dc289c743..000762c9bf 100644
--- a/esphome/core/helpers.h
+++ b/esphome/core/helpers.h
@@ -1137,6 +1137,12 @@ std::vector base64_decode(const std::string &encoded_string);
size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len);
size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len);
+/// Decode base64/base64url string directly into vector of little-endian int32 values
+/// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted)
+/// @param out Output vector (cleared and filled with decoded int32 values)
+/// @return true if successful, false if decode failed or invalid size
+bool base64_decode_int32_vector(const std::string &base64, std::vector &out);
+
/// Size of buffer needed for base85 encoded int32 (5 chars + null terminator)
static constexpr size_t BASE85_INT32_ENCODED_SIZE = 6;
From f7ad324d81175881b3997833a110904d2df1ac0a Mon Sep 17 00:00:00 2001
From: Keith Burzinski
Date: Fri, 16 Jan 2026 18:15:27 -0600
Subject: [PATCH 42/60] [infrared, remote_base] Replace `base85` with
`base64url` for web server infrared transmissions (#13265)
---
esphome/components/infrared/infrared.cpp | 27 ++++++++++++-------
esphome/components/infrared/infrared.h | 24 ++++++++---------
.../components/remote_base/remote_base.cpp | 6 ++---
esphome/components/remote_base/remote_base.h | 8 +++---
4 files changed, 36 insertions(+), 29 deletions(-)
diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp
index 294d69e523..4431869951 100644
--- a/esphome/components/infrared/infrared.cpp
+++ b/esphome/components/infrared/infrared.cpp
@@ -19,12 +19,12 @@ InfraredCall &InfraredCall::set_carrier_frequency(uint32_t frequency) {
InfraredCall &InfraredCall::set_raw_timings(const std::vector &timings) {
this->raw_timings_ = &timings;
this->packed_data_ = nullptr;
- this->base85_ptr_ = nullptr;
+ this->base64url_ptr_ = nullptr;
return *this;
}
-InfraredCall &InfraredCall::set_raw_timings_base85(const std::string &base85) {
- this->base85_ptr_ = &base85;
+InfraredCall &InfraredCall::set_raw_timings_base64url(const std::string &base64url) {
+ this->base64url_ptr_ = &base64url;
this->raw_timings_ = nullptr;
this->packed_data_ = nullptr;
return *this;
@@ -35,7 +35,7 @@ InfraredCall &InfraredCall::set_raw_timings_packed(const uint8_t *data, uint16_t
this->packed_length_ = length;
this->packed_count_ = count;
this->raw_timings_ = nullptr;
- this->base85_ptr_ = nullptr;
+ this->base64url_ptr_ = nullptr;
return *this;
}
@@ -101,13 +101,22 @@ void Infrared::control(const InfraredCall &call) {
call.get_packed_count());
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%u, repeat=%u", call.get_packed_count(),
call.get_repeat_count());
- } else if (call.is_base85()) {
- // Decode base85 directly into transmit buffer (zero heap allocations)
- if (!transmit_data->set_data_from_base85(call.get_base85_data())) {
- ESP_LOGE(TAG, "Invalid base85 data");
+ } else if (call.is_base64url()) {
+ // Decode base64url (URL-safe) into transmit buffer
+ if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
+ ESP_LOGE(TAG, "Invalid base64url data");
return;
}
- ESP_LOGD(TAG, "Transmitting base85 raw timings: count=%zu, repeat=%u", transmit_data->get_data().size(),
+ // Sanity check: validate timing values are within reasonable bounds
+ constexpr int32_t max_timing_us = 500000; // 500ms absolute max
+ for (int32_t timing : transmit_data->get_data()) {
+ int32_t abs_timing = timing < 0 ? -timing : timing;
+ if (abs_timing > max_timing_us) {
+ ESP_LOGE(TAG, "Invalid timing value: %d µs (max %d)", timing, max_timing_us);
+ return;
+ }
+ }
+ ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%u", transmit_data->get_data().size(),
call.get_repeat_count());
} else {
// From vector (lambdas/automations)
diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h
index ba426c9daa..59535f499a 100644
--- a/esphome/components/infrared/infrared.h
+++ b/esphome/components/infrared/infrared.h
@@ -40,11 +40,11 @@ class InfraredCall {
/// @note Usage: Primarily for lambdas/automations where the vector is in scope.
InfraredCall &set_raw_timings(const std::vector &timings);
- /// Set the raw timings from base85-encoded int32 data
+ /// Set the raw timings from base64url-encoded little-endian int32 data
/// @note Lifetime: Stores a pointer to the string. The string must outlive perform().
- /// @note Usage: For web_server where the encoded string is on the stack.
+ /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_').
/// @note Decoding happens at perform() time, directly into the transmit buffer.
- InfraredCall &set_raw_timings_base85(const std::string &base85);
+ InfraredCall &set_raw_timings_base64url(const std::string &base64url);
/// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded)
/// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform().
@@ -59,18 +59,18 @@ class InfraredCall {
/// Get the carrier frequency
const optional &get_carrier_frequency() const { return this->carrier_frequency_; }
- /// Get the raw timings (only valid if set via set_raw_timings, not packed or base85)
+ /// Get the raw timings (only valid if set via set_raw_timings)
const std::vector &get_raw_timings() const { return *this->raw_timings_; }
- /// Check if raw timings have been set (vector, packed, or base85)
+ /// Check if raw timings have been set (any format)
bool has_raw_timings() const {
- return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base85_ptr_ != nullptr;
+ return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr;
}
/// Check if using packed data format
bool is_packed() const { return this->packed_data_ != nullptr; }
- /// Check if using base85 data format
- bool is_base85() const { return this->base85_ptr_ != nullptr; }
- /// Get the base85 data string
- const std::string &get_base85_data() const { return *this->base85_ptr_; }
+ /// Check if using base64url data format
+ bool is_base64url() const { return this->base64url_ptr_ != nullptr; }
+ /// Get the base64url data string
+ const std::string &get_base64url_data() const { return *this->base64url_ptr_; }
/// Get packed data (only valid if set via set_raw_timings_packed)
const uint8_t *get_packed_data() const { return this->packed_data_; }
uint16_t get_packed_length() const { return this->packed_length_; }
@@ -84,8 +84,8 @@ class InfraredCall {
optional carrier_frequency_;
// Pointer to vector-based timings (caller-owned, must outlive perform())
const std::vector *raw_timings_{nullptr};
- // Pointer to base85-encoded string (caller-owned, must outlive perform())
- const std::string *base85_ptr_{nullptr};
+ // Pointer to base64url-encoded string (caller-owned, must outlive perform())
+ const std::string *base64url_ptr_{nullptr};
// Pointer to packed protobuf buffer (caller-owned, must outlive perform())
const uint8_t *packed_data_{nullptr};
uint16_t packed_length_{0};
diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp
index 53c9c38c7d..b4a549f0be 100644
--- a/esphome/components/remote_base/remote_base.cpp
+++ b/esphome/components/remote_base/remote_base.cpp
@@ -2,8 +2,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-#include
-
namespace esphome {
namespace remote_base {
@@ -160,8 +158,8 @@ void RemoteTransmitData::set_data_from_packed_sint32(const uint8_t *data, size_t
}
}
-bool RemoteTransmitData::set_data_from_base85(const std::string &base85) {
- return base85_decode_int32_vector(base85, this->data_);
+bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) {
+ return base64_decode_int32_vector(base64url, this->data_);
}
/* RemoteTransmitterBase */
diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h
index 2d7642cc31..0cac28506f 100644
--- a/esphome/components/remote_base/remote_base.h
+++ b/esphome/components/remote_base/remote_base.h
@@ -36,11 +36,11 @@ class RemoteTransmitData {
/// @param len Length of the buffer in bytes
/// @param count Number of values (for reserve optimization)
void set_data_from_packed_sint32(const uint8_t *data, size_t len, size_t count);
- /// Set data from base85-encoded int32 values
- /// Decodes directly into internal buffer (zero heap allocations)
- /// @param base85 Base85-encoded string (5 chars per int32 value)
+ /// Set data from base64url-encoded little-endian int32 values
+ /// Base64url is URL-safe: uses '-' instead of '+', '_' instead of '/'
+ /// @param base64url Base64url-encoded string of little-endian int32 values
/// @return true if successful, false if decode failed or invalid size
- bool set_data_from_base85(const std::string &base85);
+ bool set_data_from_base64url(const std::string &base64url);
void reset() {
this->data_.clear();
this->carrier_frequency_ = 0;
From 510c874061cd73ec95afcd26ab4f814463776d9e Mon Sep 17 00:00:00 2001
From: Keith Burzinski
Date: Fri, 16 Jan 2026 19:23:41 -0600
Subject: [PATCH 43/60] [helpers] Remove `base85` functions (#13266)
---
esphome/core/helpers.cpp | 49 ----------------------------------------
esphome/core/helpers.h | 8 -------
2 files changed, 57 deletions(-)
diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp
index 5cad2308df..5de1c70562 100644
--- a/esphome/core/helpers.cpp
+++ b/esphome/core/helpers.cpp
@@ -664,55 +664,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector
return !out.empty();
}
-/// Encode int32 to 5 base85 characters + null terminator
-/// Standard ASCII85 alphabet: '!' (33) = 0 through 'u' (117) = 84
-inline void base85_encode_int32(int32_t value, std::span output) {
- uint32_t v = static_cast(value);
- // Encode least significant digit first, then reverse
- for (int i = 4; i >= 0; i--) {
- output[i] = static_cast('!' + (v % 85));
- v /= 85;
- }
- output[5] = '\0';
-}
-
-/// Decode 5 base85 characters to int32
-inline bool base85_decode_int32(const char *input, int32_t &out) {
- uint8_t c0 = static_cast(input[0] - '!');
- uint8_t c1 = static_cast(input[1] - '!');
- uint8_t c2 = static_cast(input[2] - '!');
- uint8_t c3 = static_cast(input[3] - '!');
- uint8_t c4 = static_cast(input[4] - '!');
-
- // Each digit must be 0-84. Since uint8_t wraps, chars below '!' become > 84
- if (c0 > 84 || c1 > 84 || c2 > 84 || c3 > 84 || c4 > 84)
- return false;
-
- // 85^4 = 52200625, 85^3 = 614125, 85^2 = 7225, 85^1 = 85
- out = static_cast(c0 * 52200625u + c1 * 614125u + c2 * 7225u + c3 * 85u + c4);
- return true;
-}
-
-/// Decode base85 string directly into vector (no intermediate buffer)
-bool base85_decode_int32_vector(const std::string &base85, std::vector &out) {
- size_t len = base85.size();
- if (len % 5 != 0)
- return false;
-
- out.clear();
- const char *ptr = base85.data();
- const char *end = ptr + len;
-
- while (ptr < end) {
- int32_t value;
- if (!base85_decode_int32(ptr, value))
- return false;
- out.push_back(value);
- ptr += 5;
- }
- return true;
-}
-
// Colors
float gamma_correct(float value, float gamma) {
diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h
index 000762c9bf..0acc6bdc60 100644
--- a/esphome/core/helpers.h
+++ b/esphome/core/helpers.h
@@ -1143,14 +1143,6 @@ size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *b
/// @return true if successful, false if decode failed or invalid size
bool base64_decode_int32_vector(const std::string &base64, std::vector &out);
-/// Size of buffer needed for base85 encoded int32 (5 chars + null terminator)
-static constexpr size_t BASE85_INT32_ENCODED_SIZE = 6;
-
-void base85_encode_int32(int32_t value, std::span output);
-
-bool base85_decode_int32(const char *input, int32_t &out);
-bool base85_decode_int32_vector(const std::string &base85, std::vector &out);
-
///@}
/// @name Colors
From 69d7b6e9210390051318bd8e6410727689de08d6 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Fri, 16 Jan 2026 15:46:15 -1000
Subject: [PATCH 44/60] [api] Use subtraction for protobuf bounds checking
(#13306)
---
esphome/components/api/proto.cpp | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp
index eac26997cf..2a0ddf91db 100644
--- a/esphome/components/api/proto.cpp
+++ b/esphome/components/api/proto.cpp
@@ -48,14 +48,14 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
}
uint32_t field_length = res->as_uint32();
ptr += consumed;
- if (ptr + field_length > end) {
+ if (field_length > static_cast(end - ptr)) {
return count; // Out of bounds
}
ptr += field_length;
break;
}
case WIRE_TYPE_FIXED32: { // 32-bit - skip 4 bytes
- if (ptr + 4 > end) {
+ if (end - ptr < 4) {
return count;
}
ptr += 4;
@@ -110,7 +110,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
}
uint32_t field_length = res->as_uint32();
ptr += consumed;
- if (ptr + field_length > end) {
+ if (field_length > static_cast(end - ptr)) {
ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer));
return;
}
@@ -121,7 +121,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
break;
}
case WIRE_TYPE_FIXED32: { // 32-bit
- if (ptr + 4 > end) {
+ if (end - ptr < 4) {
ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer));
return;
}
From bbe11555183d544850c22a160ca93bac70e2e57d Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Fri, 16 Jan 2026 16:08:04 -1000
Subject: [PATCH 45/60] [web_server] Skip defer on ESP8266 where callbacks
already run in main loop (#13261)
---
esphome/components/web_server/web_server.cpp | 134 ++++++++++---------
esphome/components/web_server/web_server.h | 8 ++
2 files changed, 81 insertions(+), 61 deletions(-)
diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp
index cf984ea247..0e71d82233 100644
--- a/esphome/components/web_server/web_server.cpp
+++ b/esphome/components/web_server/web_server.cpp
@@ -658,6 +658,24 @@ std::string WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std
#endif
#ifdef USE_SWITCH
+enum SwitchAction : uint8_t { SWITCH_ACTION_NONE, SWITCH_ACTION_TOGGLE, SWITCH_ACTION_TURN_ON, SWITCH_ACTION_TURN_OFF };
+
+static void execute_switch_action(switch_::Switch *obj, SwitchAction action) {
+ switch (action) {
+ case SWITCH_ACTION_TOGGLE:
+ obj->toggle();
+ break;
+ case SWITCH_ACTION_TURN_ON:
+ obj->turn_on();
+ break;
+ case SWITCH_ACTION_TURN_OFF:
+ obj->turn_off();
+ break;
+ default:
+ break;
+ }
+}
+
void WebServer::on_switch_update(switch_::Switch *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
@@ -676,34 +694,22 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM
return;
}
- // Handle action methods with single defer and response
- enum SwitchAction { NONE, TOGGLE, TURN_ON, TURN_OFF };
- SwitchAction action = NONE;
+ SwitchAction action = SWITCH_ACTION_NONE;
if (match.method_equals(ESPHOME_F("toggle"))) {
- action = TOGGLE;
+ action = SWITCH_ACTION_TOGGLE;
} else if (match.method_equals(ESPHOME_F("turn_on"))) {
- action = TURN_ON;
+ action = SWITCH_ACTION_TURN_ON;
} else if (match.method_equals(ESPHOME_F("turn_off"))) {
- action = TURN_OFF;
+ action = SWITCH_ACTION_TURN_OFF;
}
- if (action != NONE) {
- this->defer([obj, action]() {
- switch (action) {
- case TOGGLE:
- obj->toggle();
- break;
- case TURN_ON:
- obj->turn_on();
- break;
- case TURN_OFF:
- obj->turn_off();
- break;
- default:
- break;
- }
- });
+ if (action != SWITCH_ACTION_NONE) {
+#ifdef USE_ESP8266
+ execute_switch_action(obj, action);
+#else
+ this->defer([obj, action]() { execute_switch_action(obj, action); });
+#endif
request->send(200);
} else {
request->send(404);
@@ -743,7 +749,7 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM
std::string data = this->button_json_(obj, detail);
request->send(200, "application/json", data.c_str());
} else if (match.method_equals(ESPHOME_F("press"))) {
- this->defer([obj]() { obj->press(); });
+ DEFER_ACTION(obj, obj->press());
request->send(200);
return;
} else {
@@ -828,7 +834,7 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc
std::string data = this->fan_json_(obj, detail);
request->send(200, "application/json", data.c_str());
} else if (match.method_equals(ESPHOME_F("toggle"))) {
- this->defer([obj]() { obj->toggle().perform(); });
+ DEFER_ACTION(obj, obj->toggle().perform());
request->send(200);
} else {
bool is_on = match.method_equals(ESPHOME_F("turn_on"));
@@ -859,7 +865,7 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc
return;
}
}
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
}
return;
@@ -909,7 +915,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
std::string data = this->light_json_(obj, detail);
request->send(200, "application/json", data.c_str());
} else if (match.method_equals(ESPHOME_F("toggle"))) {
- this->defer([obj]() { obj->toggle().perform(); });
+ DEFER_ACTION(obj, obj->toggle().perform());
request->send(200);
} else {
bool is_on = match.method_equals(ESPHOME_F("turn_on"));
@@ -938,7 +944,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
parse_string_param_(request, ESPHOME_F("effect"), call, &decltype(call)::set_effect);
}
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
}
return;
@@ -1027,7 +1033,7 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa
parse_float_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position);
parse_float_param_(request, ESPHOME_F("tilt"), call, &decltype(call)::set_tilt);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1086,7 +1092,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM
auto call = obj->make_call();
parse_float_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_value);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1159,7 +1165,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat
parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_date);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1223,7 +1229,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat
parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_time);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1286,7 +1292,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur
parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_datetime);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1346,7 +1352,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat
auto call = obj->make_call();
parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_value);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1404,7 +1410,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM
auto call = obj->make_call();
parse_string_param_(request, ESPHOME_F("option"), call, &decltype(call)::set_option);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1473,7 +1479,7 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
parse_float_param_(request, ESPHOME_F("target_temperature_low"), call, &decltype(call)::set_target_temperature_low);
parse_float_param_(request, ESPHOME_F("target_temperature"), call, &decltype(call)::set_target_temperature);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1589,6 +1595,24 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con
#endif
#ifdef USE_LOCK
+enum LockAction : uint8_t { LOCK_ACTION_NONE, LOCK_ACTION_LOCK, LOCK_ACTION_UNLOCK, LOCK_ACTION_OPEN };
+
+static void execute_lock_action(lock::Lock *obj, LockAction action) {
+ switch (action) {
+ case LOCK_ACTION_LOCK:
+ obj->lock();
+ break;
+ case LOCK_ACTION_UNLOCK:
+ obj->unlock();
+ break;
+ case LOCK_ACTION_OPEN:
+ obj->open();
+ break;
+ default:
+ break;
+ }
+}
+
void WebServer::on_lock_update(lock::Lock *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
@@ -1607,34 +1631,22 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat
return;
}
- // Handle action methods with single defer and response
- enum LockAction { NONE, LOCK, UNLOCK, OPEN };
- LockAction action = NONE;
+ LockAction action = LOCK_ACTION_NONE;
if (match.method_equals(ESPHOME_F("lock"))) {
- action = LOCK;
+ action = LOCK_ACTION_LOCK;
} else if (match.method_equals(ESPHOME_F("unlock"))) {
- action = UNLOCK;
+ action = LOCK_ACTION_UNLOCK;
} else if (match.method_equals(ESPHOME_F("open"))) {
- action = OPEN;
+ action = LOCK_ACTION_OPEN;
}
- if (action != NONE) {
- this->defer([obj, action]() {
- switch (action) {
- case LOCK:
- obj->lock();
- break;
- case UNLOCK:
- obj->unlock();
- break;
- case OPEN:
- obj->open();
- break;
- default:
- break;
- }
- });
+ if (action != LOCK_ACTION_NONE) {
+#ifdef USE_ESP8266
+ execute_lock_action(obj, action);
+#else
+ this->defer([obj, action]() { execute_lock_action(obj, action); });
+#endif
request->send(200);
} else {
request->send(404);
@@ -1717,7 +1729,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa
parse_float_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1796,7 +1808,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques
return;
}
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -1872,7 +1884,7 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons
// Parse on/off parameter
parse_bool_param_(request, ESPHOME_F("is_on"), base_call, &water_heater::WaterHeaterCall::set_on);
- this->defer([call]() mutable { call.perform(); });
+ DEFER_ACTION(call, call.perform());
request->send(200);
return;
}
@@ -2032,7 +2044,7 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM
return;
}
- this->defer([obj]() mutable { obj->perform(); });
+ DEFER_ACTION(obj, obj->perform());
request->send(200);
return;
}
diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h
index b1a495ebef..c434d664cf 100644
--- a/esphome/components/web_server/web_server.h
+++ b/esphome/components/web_server/web_server.h
@@ -42,6 +42,14 @@ using ParamNameType = const __FlashStringHelper *;
using ParamNameType = const char *;
#endif
+// ESP8266 is single-threaded, so actions can execute directly in request context.
+// Multi-core platforms need to defer to main loop thread for thread safety.
+#ifdef USE_ESP8266
+#define DEFER_ACTION(capture, action) action
+#else
+#define DEFER_ACTION(capture, action) this->defer([capture]() mutable { action; })
+#endif
+
/// Result of matching a URL against an entity
struct EntityMatchResult {
bool matched; ///< True if entity matched the URL
From e54d5ee8980dbf138813ae678289280c0f95fdad Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Fri, 16 Jan 2026 17:16:38 -1000
Subject: [PATCH 46/60] [hmac_sha256] Replace unsafe sprintf with format_hex_to
(#13290)
---
esphome/components/hmac_sha256/hmac_sha256.cpp | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp
index cf5daf63af..2146e961bc 100644
--- a/esphome/components/hmac_sha256/hmac_sha256.cpp
+++ b/esphome/components/hmac_sha256/hmac_sha256.cpp
@@ -1,4 +1,3 @@
-#include
#include
#include "hmac_sha256.h"
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST)
@@ -26,9 +25,7 @@ void HmacSHA256::calculate() { mbedtls_md_hmac_finish(&this->ctx_, this->digest_
void HmacSHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, SHA256_DIGEST_SIZE); }
void HmacSHA256::get_hex(char *output) {
- for (size_t i = 0; i < SHA256_DIGEST_SIZE; i++) {
- sprintf(output + (i * 2), "%02x", this->digest_[i]);
- }
+ format_hex_to(output, SHA256_DIGEST_SIZE * 2 + 1, this->digest_, SHA256_DIGEST_SIZE);
}
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
From 92808a09c7548a6284e53ae94ffcf61e717ff89c Mon Sep 17 00:00:00 2001
From: Stuart Parmenter
Date: Fri, 16 Jan 2026 19:17:36 -0800
Subject: [PATCH 47/60] [hub75] Bump esp-hub75 version to 0.3.0 (#13243)
---
esphome/components/hub75/display.py | 58 ++++++++++++++++++++++++-----
esphome/idf_component.yml | 4 +-
2 files changed, 51 insertions(+), 11 deletions(-)
diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py
index 20c731e730..0eeb4bba33 100644
--- a/esphome/components/hub75/display.py
+++ b/esphome/components/hub75/display.py
@@ -1,3 +1,4 @@
+import logging
from typing import Any
from esphome import automation, pins
@@ -18,13 +19,16 @@ from esphome.const import (
CONF_ROTATION,
CONF_UPDATE_INTERVAL,
)
-from esphome.core import ID
+from esphome.core import ID, EnumValue
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
+from esphome.helpers import add_class_to_obj
from esphome.types import ConfigType
from . import boards, hub75_ns
+_LOGGER = logging.getLogger(__name__)
+
DEPENDENCIES = ["esp32"]
CODEOWNERS = ["@stuartparmenter"]
@@ -120,13 +124,51 @@ PANEL_LAYOUTS = {
}
Hub75ScanWiring = cg.global_ns.enum("Hub75ScanWiring", is_class=True)
-SCAN_PATTERNS = {
+SCAN_WIRINGS = {
"STANDARD_TWO_SCAN": Hub75ScanWiring.STANDARD_TWO_SCAN,
- "FOUR_SCAN_16PX_HIGH": Hub75ScanWiring.FOUR_SCAN_16PX_HIGH,
- "FOUR_SCAN_32PX_HIGH": Hub75ScanWiring.FOUR_SCAN_32PX_HIGH,
- "FOUR_SCAN_64PX_HIGH": Hub75ScanWiring.FOUR_SCAN_64PX_HIGH,
+ "SCAN_1_4_16PX_HIGH": Hub75ScanWiring.SCAN_1_4_16PX_HIGH,
+ "SCAN_1_8_32PX_HIGH": Hub75ScanWiring.SCAN_1_8_32PX_HIGH,
+ "SCAN_1_8_40PX_HIGH": Hub75ScanWiring.SCAN_1_8_40PX_HIGH,
+ "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH,
}
+# Deprecated scan wiring names - mapped to new names
+DEPRECATED_SCAN_WIRINGS = {
+ "FOUR_SCAN_16PX_HIGH": "SCAN_1_4_16PX_HIGH",
+ "FOUR_SCAN_32PX_HIGH": "SCAN_1_8_32PX_HIGH",
+ "FOUR_SCAN_64PX_HIGH": "SCAN_1_8_64PX_HIGH",
+}
+
+
+def _validate_scan_wiring(value):
+ """Validate scan_wiring with deprecation warnings for old names."""
+ value = cv.string(value).upper().replace(" ", "_")
+
+ # Check if using deprecated name
+ # Remove deprecated names in 2026.7.0
+ if value in DEPRECATED_SCAN_WIRINGS:
+ new_name = DEPRECATED_SCAN_WIRINGS[value]
+ _LOGGER.warning(
+ "Scan wiring '%s' is deprecated and will be removed in ESPHome 2026.7.0. "
+ "Please use '%s' instead.",
+ value,
+ new_name,
+ )
+ value = new_name
+
+ # Validate against allowed values
+ if value not in SCAN_WIRINGS:
+ raise cv.Invalid(
+ f"Unknown scan wiring '{value}'. "
+ f"Valid options are: {', '.join(sorted(SCAN_WIRINGS.keys()))}"
+ )
+
+ # Return as EnumValue like cv.enum does
+ result = add_class_to_obj(value, EnumValue)
+ result.enum_value = SCAN_WIRINGS[value]
+ return result
+
+
Hub75ClockSpeed = cg.global_ns.enum("Hub75ClockSpeed", is_class=True)
CLOCK_SPEEDS = {
"8MHZ": Hub75ClockSpeed.HZ_8M,
@@ -382,9 +424,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_LAYOUT_COLS): cv.positive_int,
cv.Optional(CONF_LAYOUT): cv.enum(PANEL_LAYOUTS, upper=True, space="_"),
# Panel hardware configuration
- cv.Optional(CONF_SCAN_WIRING): cv.enum(
- SCAN_PATTERNS, upper=True, space="_"
- ),
+ cv.Optional(CONF_SCAN_WIRING): _validate_scan_wiring,
cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True),
# Display configuration
cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean,
@@ -547,7 +587,7 @@ def _build_config_struct(
async def to_code(config: ConfigType) -> None:
add_idf_component(
name="esphome/esp-hub75",
- ref="0.2.2",
+ ref="0.3.0",
)
# Set compile-time configuration via build flags (so external library sees them)
diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml
index 045b3f9168..5903e68e8e 100644
--- a/esphome/idf_component.yml
+++ b/esphome/idf_component.yml
@@ -28,8 +28,8 @@ dependencies:
rules:
- if: "target in [esp32s2, esp32s3, esp32p4]"
esphome/esp-hub75:
- version: 0.2.2
+ version: 0.3.0
rules:
- - if: "target in [esp32, esp32s2, esp32s3, esp32p4]"
+ - if: "target in [esp32, esp32s2, esp32s3, esp32c6, esp32p4]"
esp32async/asynctcp:
version: 3.4.91
From 1f4221abfa5b6298dba5fa8ee9ca4f770cad9023 Mon Sep 17 00:00:00 2001
From: Mike Ford <60777900+HLFCode@users.noreply.github.com>
Date: Sat, 17 Jan 2026 03:18:48 +0000
Subject: [PATCH 48/60] [http_request] Unable to handle chunked responses
(#7884)
Co-authored-by: J. Nick Koston
---
esphome/components/http_request/http_request.h | 4 +---
.../components/http_request/http_request_idf.cpp | 16 +++++-----------
2 files changed, 6 insertions(+), 14 deletions(-)
diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h
index 1b5fd9f00e..a8c2cdfc63 100644
--- a/esphome/components/http_request/http_request.h
+++ b/esphome/components/http_request/http_request.h
@@ -242,9 +242,7 @@ template class HttpRequestSendAction : public Action {
return;
}
- size_t content_length = container->content_length;
- size_t max_length = std::min(content_length, this->max_response_buffer_size_);
-
+ size_t max_length = this->max_response_buffer_size_;
#ifdef USE_HTTP_REQUEST_RESPONSE
if (this->capture_response_.value(x...)) {
std::string response_body;
diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp
index 725a9c1c1e..1de947ba5b 100644
--- a/esphome/components/http_request/http_request_idf.cpp
+++ b/esphome/components/http_request/http_request_idf.cpp
@@ -213,18 +213,12 @@ int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
- int bufsize = std::min(max_len, this->content_length - this->bytes_read_);
-
- if (bufsize == 0) {
- this->duration_ms += (millis() - start);
- return 0;
+ this->feed_wdt();
+ int read_len = esp_http_client_read(this->client_, (char *) buf, max_len);
+ this->feed_wdt();
+ if (read_len > 0) {
+ this->bytes_read_ += read_len;
}
-
- this->feed_wdt();
- int read_len = esp_http_client_read(this->client_, (char *) buf, bufsize);
- this->feed_wdt();
- this->bytes_read_ += read_len;
-
this->duration_ms += (millis() - start);
return read_len;
From 9caf78aa7e9fcad5767ae841d416495d9a6ecd8e Mon Sep 17 00:00:00 2001
From: Kevin Ahrendt
Date: Tue, 13 Jan 2026 19:42:36 +0000
Subject: [PATCH 49/60] [i2s_audio] Bugfix: Buffer overflow in software volume
control (#13190)
---
esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp
index 53e378c41e..c934d12d65 100644
--- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp
+++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp
@@ -340,8 +340,8 @@ void I2SAudioSpeaker::speaker_task(void *params) {
const uint32_t read_delay =
(this_speaker->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
- uint8_t *new_data = transfer_buffer->get_buffer_end(); // track start of any newly copied bytes
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
+ uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
if (bytes_read > 0) {
if (this_speaker->q15_volume_factor_ < INT16_MAX) {
From 6f29dbd6f123ea9fa95f702b0ff6b4f8a4015477 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Fri, 16 Jan 2026 15:46:15 -1000
Subject: [PATCH 50/60] [api] Use subtraction for protobuf bounds checking
(#13306)
---
esphome/components/api/proto.cpp | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp
index 4f0d0846d7..b496ad5110 100644
--- a/esphome/components/api/proto.cpp
+++ b/esphome/components/api/proto.cpp
@@ -48,14 +48,14 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
}
uint32_t field_length = res->as_uint32();
ptr += consumed;
- if (ptr + field_length > end) {
+ if (field_length > static_cast(end - ptr)) {
return count; // Out of bounds
}
ptr += field_length;
break;
}
case WIRE_TYPE_FIXED32: { // 32-bit - skip 4 bytes
- if (ptr + 4 > end) {
+ if (end - ptr < 4) {
return count;
}
ptr += 4;
@@ -110,7 +110,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
}
uint32_t field_length = res->as_uint32();
ptr += consumed;
- if (ptr + field_length > end) {
+ if (field_length > static_cast(end - ptr)) {
ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer));
return;
}
@@ -121,7 +121,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
break;
}
case WIRE_TYPE_FIXED32: { // 32-bit
- if (ptr + 4 > end) {
+ if (end - ptr < 4) {
ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer));
return;
}
From ec7f72e2802a3b3a20f83b90c94b791a441524fa Mon Sep 17 00:00:00 2001
From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Date: Fri, 16 Jan 2026 22:24:05 -0500
Subject: [PATCH 51/60] Bump version to 2025.12.7
---
Doxyfile | 2 +-
esphome/const.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Doxyfile b/Doxyfile
index a23e21dd0d..7638fa4317 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 = 2025.12.6
+PROJECT_NUMBER = 2025.12.7
# 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 6771b9f265..e8f9c932cd 100644
--- a/esphome/const.py
+++ b/esphome/const.py
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
-__version__ = "2025.12.6"
+__version__ = "2025.12.7"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
From f88e8fc43b6deb87c1f9f516cc14961f5510d097 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 11:32:02 -1000
Subject: [PATCH 52/60] [sprinkler] Fix scheduler deprecation warnings and heap
churn with FixedVector (#13251)
---
esphome/components/sprinkler/sprinkler.cpp | 6 ++++--
esphome/components/sprinkler/sprinkler.h | 5 +++--
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp
index ca9f85abd8..2813b4450b 100644
--- a/esphome/components/sprinkler/sprinkler.cpp
+++ b/esphome/components/sprinkler/sprinkler.cpp
@@ -332,6 +332,7 @@ Sprinkler::Sprinkler(const std::string &name) {
// The `name` is needed to set timers up, hence non-default constructor
// replaces `set_name()` method previously existed
this->name_ = name;
+ this->timer_.init(2);
this->timer_.push_back({this->name_ + "sm", false, 0, 0, std::bind(&Sprinkler::sm_timer_callback_, this)});
this->timer_.push_back({this->name_ + "vs", false, 0, 0, std::bind(&Sprinkler::valve_selection_callback_, this)});
}
@@ -1574,7 +1575,8 @@ const LogString *Sprinkler::state_as_str_(SprinklerState state) {
void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) {
if (this->timer_duration_(timer_index) > 0) {
- this->set_timeout(this->timer_[timer_index].name, this->timer_duration_(timer_index),
+ // FixedVector ensures timer_ can't be resized, so .c_str() pointers remain valid
+ this->set_timeout(this->timer_[timer_index].name.c_str(), this->timer_duration_(timer_index),
this->timer_cbf_(timer_index));
this->timer_[timer_index].start_time = millis();
this->timer_[timer_index].active = true;
@@ -1585,7 +1587,7 @@ void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) {
bool Sprinkler::cancel_timer_(const SprinklerTimerIndex timer_index) {
this->timer_[timer_index].active = false;
- return this->cancel_timeout(this->timer_[timer_index].name);
+ return this->cancel_timeout(this->timer_[timer_index].name.c_str());
}
bool Sprinkler::timer_active_(const SprinklerTimerIndex timer_index) { return this->timer_[timer_index].active; }
diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h
index 25e2d42446..273c0e9208 100644
--- a/esphome/components/sprinkler/sprinkler.h
+++ b/esphome/components/sprinkler/sprinkler.h
@@ -3,6 +3,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
+#include "esphome/core/helpers.h"
#include "esphome/components/number/number.h"
#include "esphome/components/switch/switch.h"
@@ -553,8 +554,8 @@ class Sprinkler : public Component {
/// Sprinkler valve operator objects
std::vector valve_op_{2};
- /// Valve control timers
- std::vector timer_{};
+ /// Valve control timers - FixedVector enforces that this can never grow beyond init() size
+ FixedVector timer_;
/// Other Sprinkler instances we should be aware of (used to check if pumps are in use)
std::vector other_controllers_;
From 973fc4c5dc14b5d42326dd007c7e04e89aa8042a Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 16:48:44 -1000
Subject: [PATCH 53/60] [dallas_temp] Use const char* for set_timeout to fix
deprecation warning and heap churn (#13250)
---
esphome/components/dallas_temp/dallas_temp.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp
index a1b684abbf..13f2fa59bd 100644
--- a/esphome/components/dallas_temp/dallas_temp.cpp
+++ b/esphome/components/dallas_temp/dallas_temp.cpp
@@ -44,7 +44,7 @@ void DallasTemperatureSensor::update() {
this->send_command_(DALLAS_COMMAND_START_CONVERSION);
- this->set_timeout(this->get_address_name(), this->millis_to_wait_for_conversion_(), [this] {
+ this->set_timeout(this->get_address_name().c_str(), this->millis_to_wait_for_conversion_(), [this] {
if (!this->read_scratch_pad_() || !this->check_scratch_pad_()) {
this->publish_state(NAN);
return;
From edb303e495b073a33433c5212ed417a120b24a11 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 16:49:01 -1000
Subject: [PATCH 54/60] [api] Fix clock conflicts when multiple clients
connected to homeassistant time (#13253)
---
esphome/components/api/api_server.cpp | 4 +++-
esphome/components/time/real_time_clock.cpp | 12 ++++++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index a63d33f73b..ed97c3b9a2 100644
--- a/esphome/components/api/api_server.cpp
+++ b/esphome/components/api/api_server.cpp
@@ -558,8 +558,10 @@ bool APIServer::clear_noise_psk(bool make_active) {
#ifdef USE_HOMEASSISTANT_TIME
void APIServer::request_time() {
for (auto &client : this->clients_) {
- if (!client->flags_.remove && client->is_authenticated())
+ if (!client->flags_.remove && client->is_authenticated()) {
client->send_time_request();
+ return; // Only request from one client to avoid clock conflicts
+ }
}
}
#endif
diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp
index 639af4457f..f217d14c55 100644
--- a/esphome/components/time/real_time_clock.cpp
+++ b/esphome/components/time/real_time_clock.cpp
@@ -31,6 +31,18 @@ void RealTimeClock::dump_config() {
void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
ESP_LOGVV(TAG, "Got epoch %" PRIu32, epoch);
+ // Skip if time is already synchronized to avoid unnecessary writes, log spam,
+ // and prevent clock jumping backwards due to network latency
+ constexpr time_t min_valid_epoch = 1546300800; // January 1, 2019
+ time_t current_time = this->timestamp_now();
+ // Check if time is valid (year >= 2019) before comparing
+ if (current_time >= min_valid_epoch) {
+ // Unsigned subtraction handles wraparound correctly, then cast to signed
+ int32_t diff = static_cast(epoch - static_cast(current_time));
+ if (diff >= -1 && diff <= 1) {
+ return;
+ }
+ }
// Update UTC epoch time.
#ifdef USE_ZEPHYR
struct timespec ts;
From 50aa4b1992d627659126d76a63c65763abbfddfe Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Thu, 15 Jan 2026 16:49:19 -1000
Subject: [PATCH 55/60] [esp32_ble_client] Reduce GATT data event logging to
prevent firmware update failures (#13252)
---
.../esp32_ble_client/ble_client_base.cpp | 38 +++++++++++--------
.../esp32_ble_client/ble_client_base.h | 3 +-
2 files changed, 25 insertions(+), 16 deletions(-)
diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp
index 149fcc79d5..01f79156a9 100644
--- a/esphome/components/esp32_ble_client/ble_client_base.cpp
+++ b/esphome/components/esp32_ble_client/ble_client_base.cpp
@@ -193,10 +193,18 @@ void BLEClientBase::log_event_(const char *name) {
ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name);
}
-void BLEClientBase::log_gattc_event_(const char *name) {
+void BLEClientBase::log_gattc_lifecycle_event_(const char *name) {
ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_, name);
}
+void BLEClientBase::log_gattc_data_event_(const char *name) {
+ // Data transfer events are logged at VERBOSE level because logging to UART creates
+ // delays that cause timing issues during time-sensitive BLE operations. This is
+ // especially problematic during pairing or firmware updates which require rapid
+ // writes to many characteristics - the log spam can cause these operations to fail.
+ ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_, name);
+}
+
void BLEClientBase::log_gattc_warning_(const char *operation, esp_gatt_status_t status) {
ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_, operation, status);
}
@@ -280,7 +288,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_OPEN_EVT: {
if (!this->check_addr(param->open.remote_bda))
return false;
- this->log_gattc_event_("OPEN");
+ this->log_gattc_lifecycle_event_("OPEN");
// conn_id was already set in ESP_GATTC_CONNECT_EVT
this->service_count_ = 0;
@@ -331,7 +339,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_CONNECT_EVT: {
if (!this->check_addr(param->connect.remote_bda))
return false;
- this->log_gattc_event_("CONNECT");
+ this->log_gattc_lifecycle_event_("CONNECT");
this->conn_id_ = param->connect.conn_id;
// Start MTU negotiation immediately as recommended by ESP-IDF examples
// (gatt_client, ble_throughput) which call esp_ble_gattc_send_mtu_req in
@@ -376,7 +384,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_CLOSE_EVT: {
if (this->conn_id_ != param->close.conn_id)
return false;
- this->log_gattc_event_("CLOSE");
+ this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_state(espbt::ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
@@ -404,7 +412,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (this->conn_id_ != param->search_cmpl.conn_id)
return false;
- this->log_gattc_event_("SEARCH_CMPL");
+ this->log_gattc_lifecycle_event_("SEARCH_CMPL");
// For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery
// This balances performance with bandwidth usage after the critical discovery phase
if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
@@ -431,35 +439,35 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
case ESP_GATTC_READ_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
- this->log_gattc_event_("READ_DESCR");
+ this->log_gattc_data_event_("READ_DESCR");
break;
}
case ESP_GATTC_WRITE_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
- this->log_gattc_event_("WRITE_DESCR");
+ this->log_gattc_data_event_("WRITE_DESCR");
break;
}
case ESP_GATTC_WRITE_CHAR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
- this->log_gattc_event_("WRITE_CHAR");
+ this->log_gattc_data_event_("WRITE_CHAR");
break;
}
case ESP_GATTC_READ_CHAR_EVT: {
if (this->conn_id_ != param->read.conn_id)
return false;
- this->log_gattc_event_("READ_CHAR");
+ this->log_gattc_data_event_("READ_CHAR");
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (this->conn_id_ != param->notify.conn_id)
return false;
- this->log_gattc_event_("NOTIFY");
+ this->log_gattc_data_event_("NOTIFY");
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
- this->log_gattc_event_("REG_FOR_NOTIFY");
+ this->log_gattc_data_event_("REG_FOR_NOTIFY");
if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
// Client is responsible for flipping the descriptor value
@@ -491,7 +499,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
esp_err_t status =
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, desc_result.handle, sizeof(notify_en),
(uint8_t *) ¬ify_en, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
- ESP_LOGD(TAG, "Wrote notify descriptor %d, properties=%d", notify_en, char_result.properties);
+ ESP_LOGV(TAG, "Wrote notify descriptor %d, properties=%d", notify_en, char_result.properties);
if (status) {
this->log_gattc_warning_("esp_ble_gattc_write_char_descr", status);
}
@@ -499,13 +507,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
- this->log_gattc_event_("UNREG_FOR_NOTIFY");
+ this->log_gattc_data_event_("UNREG_FOR_NOTIFY");
break;
}
default:
- // ideally would check all other events for matching conn_id
- ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_, event);
+ // Unknown events logged at VERBOSE to avoid UART delays during time-sensitive operations
+ ESP_LOGV(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_, event);
break;
}
return true;
diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h
index 92c7444ee1..c52f0e5d2d 100644
--- a/esphome/components/esp32_ble_client/ble_client_base.h
+++ b/esphome/components/esp32_ble_client/ble_client_base.h
@@ -127,7 +127,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
// 6 bytes used, 2 bytes padding
void log_event_(const char *name);
- void log_gattc_event_(const char *name);
+ void log_gattc_lifecycle_event_(const char *name);
+ void log_gattc_data_event_(const char *name);
void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
From e1800d2fe25561979a64be1d3dcbc471f17eefa6 Mon Sep 17 00:00:00 2001
From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com>
Date: Sat, 17 Jan 2026 01:19:09 +1000
Subject: [PATCH 56/60] [ntc, resistance] change log level to verbose (#13268)
---
esphome/components/ntc/ntc.cpp | 2 +-
esphome/components/resistance/resistance_sensor.cpp | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp
index b08f84029b..cc500ba429 100644
--- a/esphome/components/ntc/ntc.cpp
+++ b/esphome/components/ntc/ntc.cpp
@@ -23,7 +23,7 @@ void NTC::process_(float value) {
double v = this->a_ + this->b_ * lr + this->c_ * lr * lr * lr;
auto temp = float(1.0 / v - 273.15);
- ESP_LOGD(TAG, "'%s' - Temperature: %.1f°C", this->name_.c_str(), temp);
+ ESP_LOGV(TAG, "'%s' - Temperature: %.1f°C", this->name_.c_str(), temp);
this->publish_state(temp);
}
diff --git a/esphome/components/resistance/resistance_sensor.cpp b/esphome/components/resistance/resistance_sensor.cpp
index 6e57214449..706a059de3 100644
--- a/esphome/components/resistance/resistance_sensor.cpp
+++ b/esphome/components/resistance/resistance_sensor.cpp
@@ -39,7 +39,7 @@ void ResistanceSensor::process_(float value) {
}
res *= this->resistor_;
- ESP_LOGD(TAG, "'%s' - Resistance %.1fΩ", this->name_.c_str(), res);
+ ESP_LOGV(TAG, "'%s' - Resistance %.1fΩ", this->name_.c_str(), res);
this->publish_state(res);
}
From d8463f48136aa94c870184a77ce71a8848b9ac50 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston"
Date: Fri, 16 Jan 2026 17:16:38 -1000
Subject: [PATCH 57/60] [hmac_sha256] Replace unsafe sprintf with format_hex_to
(#13290)
---
esphome/components/hmac_sha256/hmac_sha256.cpp | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp
index cf5daf63af..2146e961bc 100644
--- a/esphome/components/hmac_sha256/hmac_sha256.cpp
+++ b/esphome/components/hmac_sha256/hmac_sha256.cpp
@@ -1,4 +1,3 @@
-#include
#include
#include "hmac_sha256.h"
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST)
@@ -26,9 +25,7 @@ void HmacSHA256::calculate() { mbedtls_md_hmac_finish(&this->ctx_, this->digest_
void HmacSHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, SHA256_DIGEST_SIZE); }
void HmacSHA256::get_hex(char *output) {
- for (size_t i = 0; i < SHA256_DIGEST_SIZE; i++) {
- sprintf(output + (i * 2), "%02x", this->digest_[i]);
- }
+ format_hex_to(output, SHA256_DIGEST_SIZE * 2 + 1, this->digest_, SHA256_DIGEST_SIZE);
}
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
From 60e333db088c0de3c2a2f8fe26f50f08ce0aca60 Mon Sep 17 00:00:00 2001
From: Stuart Parmenter
Date: Fri, 16 Jan 2026 19:17:36 -0800
Subject: [PATCH 58/60] [hub75] Bump esp-hub75 version to 0.3.0 (#13243)
---
esphome/components/hub75/display.py | 58 ++++++++++++++++++++++++-----
esphome/idf_component.yml | 4 +-
2 files changed, 51 insertions(+), 11 deletions(-)
diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py
index 20c731e730..0eeb4bba33 100644
--- a/esphome/components/hub75/display.py
+++ b/esphome/components/hub75/display.py
@@ -1,3 +1,4 @@
+import logging
from typing import Any
from esphome import automation, pins
@@ -18,13 +19,16 @@ from esphome.const import (
CONF_ROTATION,
CONF_UPDATE_INTERVAL,
)
-from esphome.core import ID
+from esphome.core import ID, EnumValue
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
+from esphome.helpers import add_class_to_obj
from esphome.types import ConfigType
from . import boards, hub75_ns
+_LOGGER = logging.getLogger(__name__)
+
DEPENDENCIES = ["esp32"]
CODEOWNERS = ["@stuartparmenter"]
@@ -120,13 +124,51 @@ PANEL_LAYOUTS = {
}
Hub75ScanWiring = cg.global_ns.enum("Hub75ScanWiring", is_class=True)
-SCAN_PATTERNS = {
+SCAN_WIRINGS = {
"STANDARD_TWO_SCAN": Hub75ScanWiring.STANDARD_TWO_SCAN,
- "FOUR_SCAN_16PX_HIGH": Hub75ScanWiring.FOUR_SCAN_16PX_HIGH,
- "FOUR_SCAN_32PX_HIGH": Hub75ScanWiring.FOUR_SCAN_32PX_HIGH,
- "FOUR_SCAN_64PX_HIGH": Hub75ScanWiring.FOUR_SCAN_64PX_HIGH,
+ "SCAN_1_4_16PX_HIGH": Hub75ScanWiring.SCAN_1_4_16PX_HIGH,
+ "SCAN_1_8_32PX_HIGH": Hub75ScanWiring.SCAN_1_8_32PX_HIGH,
+ "SCAN_1_8_40PX_HIGH": Hub75ScanWiring.SCAN_1_8_40PX_HIGH,
+ "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH,
}
+# Deprecated scan wiring names - mapped to new names
+DEPRECATED_SCAN_WIRINGS = {
+ "FOUR_SCAN_16PX_HIGH": "SCAN_1_4_16PX_HIGH",
+ "FOUR_SCAN_32PX_HIGH": "SCAN_1_8_32PX_HIGH",
+ "FOUR_SCAN_64PX_HIGH": "SCAN_1_8_64PX_HIGH",
+}
+
+
+def _validate_scan_wiring(value):
+ """Validate scan_wiring with deprecation warnings for old names."""
+ value = cv.string(value).upper().replace(" ", "_")
+
+ # Check if using deprecated name
+ # Remove deprecated names in 2026.7.0
+ if value in DEPRECATED_SCAN_WIRINGS:
+ new_name = DEPRECATED_SCAN_WIRINGS[value]
+ _LOGGER.warning(
+ "Scan wiring '%s' is deprecated and will be removed in ESPHome 2026.7.0. "
+ "Please use '%s' instead.",
+ value,
+ new_name,
+ )
+ value = new_name
+
+ # Validate against allowed values
+ if value not in SCAN_WIRINGS:
+ raise cv.Invalid(
+ f"Unknown scan wiring '{value}'. "
+ f"Valid options are: {', '.join(sorted(SCAN_WIRINGS.keys()))}"
+ )
+
+ # Return as EnumValue like cv.enum does
+ result = add_class_to_obj(value, EnumValue)
+ result.enum_value = SCAN_WIRINGS[value]
+ return result
+
+
Hub75ClockSpeed = cg.global_ns.enum("Hub75ClockSpeed", is_class=True)
CLOCK_SPEEDS = {
"8MHZ": Hub75ClockSpeed.HZ_8M,
@@ -382,9 +424,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_LAYOUT_COLS): cv.positive_int,
cv.Optional(CONF_LAYOUT): cv.enum(PANEL_LAYOUTS, upper=True, space="_"),
# Panel hardware configuration
- cv.Optional(CONF_SCAN_WIRING): cv.enum(
- SCAN_PATTERNS, upper=True, space="_"
- ),
+ cv.Optional(CONF_SCAN_WIRING): _validate_scan_wiring,
cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True),
# Display configuration
cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean,
@@ -547,7 +587,7 @@ def _build_config_struct(
async def to_code(config: ConfigType) -> None:
add_idf_component(
name="esphome/esp-hub75",
- ref="0.2.2",
+ ref="0.3.0",
)
# Set compile-time configuration via build flags (so external library sees them)
diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml
index 045b3f9168..5903e68e8e 100644
--- a/esphome/idf_component.yml
+++ b/esphome/idf_component.yml
@@ -28,8 +28,8 @@ dependencies:
rules:
- if: "target in [esp32s2, esp32s3, esp32p4]"
esphome/esp-hub75:
- version: 0.2.2
+ version: 0.3.0
rules:
- - if: "target in [esp32, esp32s2, esp32s3, esp32p4]"
+ - if: "target in [esp32, esp32s2, esp32s3, esp32c6, esp32p4]"
esp32async/asynctcp:
version: 3.4.91
From 2947642ca5452b0aaa70958eac28a598bcb8b625 Mon Sep 17 00:00:00 2001
From: Mike Ford <60777900+HLFCode@users.noreply.github.com>
Date: Sat, 17 Jan 2026 03:18:48 +0000
Subject: [PATCH 59/60] [http_request] Unable to handle chunked responses
(#7884)
Co-authored-by: J. Nick Koston
---
esphome/components/http_request/http_request.h | 4 +---
.../components/http_request/http_request_idf.cpp | 16 +++++-----------
2 files changed, 6 insertions(+), 14 deletions(-)
diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h
index 1b5fd9f00e..a8c2cdfc63 100644
--- a/esphome/components/http_request/http_request.h
+++ b/esphome/components/http_request/http_request.h
@@ -242,9 +242,7 @@ template class HttpRequestSendAction : public Action {
return;
}
- size_t content_length = container->content_length;
- size_t max_length = std::min(content_length, this->max_response_buffer_size_);
-
+ size_t max_length = this->max_response_buffer_size_;
#ifdef USE_HTTP_REQUEST_RESPONSE
if (this->capture_response_.value(x...)) {
std::string response_body;
diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp
index 725a9c1c1e..1de947ba5b 100644
--- a/esphome/components/http_request/http_request_idf.cpp
+++ b/esphome/components/http_request/http_request_idf.cpp
@@ -213,18 +213,12 @@ int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
- int bufsize = std::min(max_len, this->content_length - this->bytes_read_);
-
- if (bufsize == 0) {
- this->duration_ms += (millis() - start);
- return 0;
+ this->feed_wdt();
+ int read_len = esp_http_client_read(this->client_, (char *) buf, max_len);
+ this->feed_wdt();
+ if (read_len > 0) {
+ this->bytes_read_ += read_len;
}
-
- this->feed_wdt();
- int read_len = esp_http_client_read(this->client_, (char *) buf, bufsize);
- this->feed_wdt();
- this->bytes_read_ += read_len;
-
this->duration_ms += (millis() - start);
return read_len;
From 19514ccdf46cba78fac445be095ec91d34260519 Mon Sep 17 00:00:00 2001
From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Date: Fri, 16 Jan 2026 23:05:59 -0500
Subject: [PATCH 60/60] Bump version to 2026.1.0b3
---
Doxyfile | 2 +-
esphome/const.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Doxyfile b/Doxyfile
index aa6a2f169e..4b38bb779e 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.0b2
+PROJECT_NUMBER = 2026.1.0b3
# 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 e99fbb8283..35ce8b3cd6 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.0b2"
+__version__ = "2026.1.0b3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (