From 919afa1553a40bd9c756d871a87b55a77de7bfb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 11:47:59 -0600 Subject: [PATCH 01/19] [web_server_base] Fix RP2040 compilation when Crypto-no-arduino is present (#13887) --- esphome/components/web_server_base/__init__.py | 13 +++++++++++++ .../web_server_base/fix_rp2040_hash.py.script | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 esphome/components/web_server_base/fix_rp2040_hash.py.script diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 11408ae2604..7986ac964df 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,8 +1,11 @@ +from pathlib import Path + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.helpers import copy_file_if_changed CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -49,5 +52,15 @@ async def to_code(config): CORE.add_platformio_option( "lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"] ) + # ESPAsyncWebServer uses Hash library for sha1() on RP2040 + cg.add_library("Hash", None) + # Fix Hash.h include conflict: Crypto-no-arduino (used by dsmr) + # provides a Hash.h that shadows the framework's Hash library. + # Prepend the framework Hash path so it's found first. + copy_file_if_changed( + Path(__file__).parent / "fix_rp2040_hash.py.script", + CORE.relative_build_path("fix_rp2040_hash.py"), + ) + cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") diff --git a/esphome/components/web_server_base/fix_rp2040_hash.py.script b/esphome/components/web_server_base/fix_rp2040_hash.py.script new file mode 100644 index 00000000000..2cf24569de6 --- /dev/null +++ b/esphome/components/web_server_base/fix_rp2040_hash.py.script @@ -0,0 +1,11 @@ +# ESPAsyncWebServer includes expecting the Arduino-Pico framework's Hash +# library (which provides sha1() functions). However, the Crypto-no-arduino library +# (used by dsmr) also provides a Hash.h that can shadow the framework version when +# PlatformIO's chain+ LDF mode auto-discovers it as a dependency. +# Prepend the framework Hash path to CXXFLAGS so it is found first. +import os + +Import("env") +framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico") +hash_src = os.path.join(framework_dir, "libraries", "Hash", "src") +env.Prepend(CXXFLAGS=["-I" + hash_src]) From 04a6238c7b2decb53e89066ce0da49d6e56b34ff Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:49:58 -0500 Subject: [PATCH 02/19] [esp32] Set UV_CACHE_DIR inside data dir so Clean All clears it (#13888) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 90f6035abaa..6f5011246cb 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1435,6 +1435,10 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + # Set the uv cache inside the data dir so "Clean All" clears it. + # Avoids persistent corrupted cache from mid-stream download failures. + os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache")) + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") From c658d7b57faa41def0808c11d6fbd046abd4eb6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:02:02 -0600 Subject: [PATCH 03/19] [api] Merge auth check into base read_message, eliminate APIServerConnection (#13873) --- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2_service.cpp | 41 +++--- esphome/components/api/api_pb2_service.h | 5 - script/api_protobuf/api_protobuf.py | 156 ++++++++++----------- 4 files changed, 91 insertions(+), 113 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index ae7f864568b..7f738a9bfdb 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -28,7 +28,7 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); -class APIConnection final : public APIServerConnection { +class APIConnection final : public APIServerConnectionBase { public: friend class APIServer; friend class ListEntitiesIterator; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 1c04eacc82b..2d15deb90d8 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -21,6 +21,23 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name) { #endif void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { + // Check authentication/connection requirements + switch (msg_type) { + case HelloRequest::MESSAGE_TYPE: // No setup required + case DisconnectRequest::MESSAGE_TYPE: // No setup required + case PingRequest::MESSAGE_TYPE: // No setup required + break; + case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only + if (!this->check_connection_setup_()) { + return; + } + break; + default: + if (!this->check_authenticated_()) { + return; + } + break; + } switch (msg_type) { case HelloRequest::MESSAGE_TYPE: { HelloRequest msg; @@ -623,28 +640,4 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } } -void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { - // Check authentication/connection requirements for messages - switch (msg_type) { - case HelloRequest::MESSAGE_TYPE: // No setup required - case DisconnectRequest::MESSAGE_TYPE: // No setup required - case PingRequest::MESSAGE_TYPE: // No setup required - break; // Skip all checks for these messages - case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only - if (!this->check_connection_setup_()) { - return; // Connection not setup - } - break; - default: - // All other messages require authentication (which includes connection check) - if (!this->check_authenticated_()) { - return; // Authentication failed - } - break; - } - - // Call base implementation to process the message - APIServerConnectionBase::read_message(msg_size, msg_type, msg_data); -} - } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 4dc6ce27d09..1441507406d 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -228,9 +228,4 @@ class APIServerConnectionBase : public ProtoService { void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; -class APIServerConnection : public APIServerConnectionBase { - protected: - void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; -}; - } // namespace esphome::api diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 8673996a259..ece0b5692f4 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2881,9 +2881,82 @@ static const char *const TAG = "api.service"; cases = list(RECEIVE_CASES.items()) cases.sort() + + serv = file.service[0] + + # Build a mapping of message input types to their authentication requirements + message_auth_map: dict[str, bool] = {} + message_conn_map: dict[str, bool] = {} + + for m in serv.method: + inp = m.input_type[1:] + needs_conn = get_opt(m, pb.needs_setup_connection, True) + needs_auth = get_opt(m, pb.needs_authentication, True) + + # Store authentication requirements for message types + message_auth_map[inp] = needs_auth + message_conn_map[inp] = needs_conn + + # Categorize messages by their authentication requirements + no_conn_ids: set[int] = set() + conn_only_ids: set[int] = set() + + for id_, (_, _, case_msg_name) in cases: + if case_msg_name in message_auth_map: + needs_auth = message_auth_map[case_msg_name] + needs_conn = message_conn_map[case_msg_name] + + if not needs_conn: + no_conn_ids.add(id_) + elif not needs_auth: + conn_only_ids.add(id_) + + # Helper to generate case statements with ifdefs + def generate_cases(ids: set[int], comment: str) -> str: + result = "" + for id_ in sorted(ids): + _, ifdef, msg_name = RECEIVE_CASES[id_] + if ifdef: + result += f"#ifdef {ifdef}\n" + result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" + if ifdef: + result += "#endif\n" + return result + + # Generate read_message with auth check before dispatch hpp += " protected:\n" hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" + out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" + + # Auth check block before dispatch switch + out += " // Check authentication/connection requirements\n" + if no_conn_ids or conn_only_ids: + out += " switch (msg_type) {\n" + + if no_conn_ids: + out += generate_cases(no_conn_ids, "// No setup required") + out += " break;\n" + + if conn_only_ids: + out += generate_cases(conn_only_ids, "// Connection setup only") + out += " if (!this->check_connection_setup_()) {\n" + out += " return;\n" + out += " }\n" + out += " break;\n" + + out += " default:\n" + out += " if (!this->check_authenticated_()) {\n" + out += " return;\n" + out += " }\n" + out += " break;\n" + out += " }\n" + else: + out += " if (!this->check_authenticated_()) {\n" + out += " return;\n" + out += " }\n" + + # Dispatch switch out += " switch (msg_type) {\n" for i, (case, ifdef, message_name) in cases: if ifdef is not None: @@ -2902,89 +2975,6 @@ static const char *const TAG = "api.service"; cpp += out hpp += "};\n" - serv = file.service[0] - class_name = "APIServerConnection" - hpp += "\n" - hpp += f"class {class_name} : public {class_name}Base {{\n" - hpp_protected = "" - cpp += "\n" - - # Build a mapping of message input types to their authentication requirements - message_auth_map: dict[str, bool] = {} - message_conn_map: dict[str, bool] = {} - - for m in serv.method: - inp = m.input_type[1:] - needs_conn = get_opt(m, pb.needs_setup_connection, True) - needs_auth = get_opt(m, pb.needs_authentication, True) - - # Store authentication requirements for message types - message_auth_map[inp] = needs_auth - message_conn_map[inp] = needs_conn - - # Generate optimized read_message with authentication checking - # Categorize messages by their authentication requirements - no_conn_ids: set[int] = set() - conn_only_ids: set[int] = set() - - for id_, (_, _, case_msg_name) in cases: - if case_msg_name in message_auth_map: - needs_auth = message_auth_map[case_msg_name] - needs_conn = message_conn_map[case_msg_name] - - if not needs_conn: - no_conn_ids.add(id_) - elif not needs_auth: - conn_only_ids.add(id_) - - # Generate override if we have messages that skip checks - if no_conn_ids or conn_only_ids: - # Helper to generate case statements with ifdefs - def generate_cases(ids: set[int], comment: str) -> str: - result = "" - for id_ in sorted(ids): - _, ifdef, msg_name = RECEIVE_CASES[id_] - if ifdef: - result += f"#ifdef {ifdef}\n" - result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" - if ifdef: - result += "#endif\n" - return result - - hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" - - cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" - cpp += " // Check authentication/connection requirements for messages\n" - cpp += " switch (msg_type) {\n" - - # Messages that don't need any checks - if no_conn_ids: - cpp += generate_cases(no_conn_ids, "// No setup required") - cpp += " break; // Skip all checks for these messages\n" - - # Messages that only need connection setup - if conn_only_ids: - cpp += generate_cases(conn_only_ids, "// Connection setup only") - cpp += " if (!this->check_connection_setup_()) {\n" - cpp += " return; // Connection not setup\n" - cpp += " }\n" - cpp += " break;\n" - - cpp += " default:\n" - cpp += " // All other messages require authentication (which includes connection check)\n" - cpp += " if (!this->check_authenticated_()) {\n" - cpp += " return; // Authentication failed\n" - cpp += " }\n" - cpp += " break;\n" - cpp += " }\n\n" - cpp += " // Call base implementation to process the message\n" - cpp += f" {class_name}Base::read_message(msg_size, msg_type, msg_data);\n" - cpp += "}\n" - - hpp += " protected:\n" - hpp += hpp_protected - hpp += "};\n" - hpp += """\ } // namespace esphome::api From 2383b6b8b461d14594d9cd08199d7bc3db444a8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:05:32 -0600 Subject: [PATCH 04/19] [core] Deprecate set_retry, cancel_retry, and RetryResult (#13845) --- esphome/core/component.cpp | 19 +++++++++- esphome/core/component.h | 71 ++++++++++++-------------------------- esphome/core/scheduler.cpp | 7 ++++ esphome/core/scheduler.h | 23 +++++++++--- 4 files changed, 66 insertions(+), 54 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index f09a39d2bbb..6d8d1c57aff 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -152,7 +152,10 @@ void Component::set_retry(const std::string &name, uint32_t initial_wait_time, u void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +#pragma GCC diagnostic pop } bool Component::cancel_retry(const std::string &name) { // NOLINT @@ -163,7 +166,10 @@ bool Component::cancel_retry(const std::string &name) { // NOLINT } bool Component::cancel_retry(const char *name) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" return App.scheduler.cancel_retry(this, name); +#pragma GCC diagnostic pop } void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT @@ -203,10 +209,18 @@ bool Component::cancel_interval(uint32_t id) { return App.scheduler.cancel_inter void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" App.scheduler.set_retry(this, id, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +#pragma GCC diagnostic pop } -bool Component::cancel_retry(uint32_t id) { return App.scheduler.cancel_retry(this, id); } +bool Component::cancel_retry(uint32_t id) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return App.scheduler.cancel_retry(this, id); +#pragma GCC diagnostic pop +} void Component::call_loop() { this->loop(); } void Component::call_setup() { this->setup(); } @@ -371,7 +385,10 @@ void Component::set_interval(uint32_t interval, std::function &&f) { // } void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +#pragma GCC diagnostic pop } bool Component::is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; } bool Component::is_ready() const { diff --git a/esphome/core/component.h b/esphome/core/component.h index 97f2afe1a4e..c3582e23b16 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -68,6 +68,7 @@ extern const uint8_t STATUS_LED_OK; extern const uint8_t STATUS_LED_WARNING; extern const uint8_t STATUS_LED_ERROR; +// Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; extern const uint16_t WARN_IF_BLOCKING_OVER_MS; @@ -347,68 +348,40 @@ class Component { bool cancel_interval(const char *name); // NOLINT bool cancel_interval(uint32_t id); // NOLINT - /** Set an retry function with a unique name. Empty name means no cancelling possible. - * - * This will call the retry function f on the next scheduler loop. f should return RetryResult::DONE if - * it is successful and no repeat is required. Otherwise, returning RetryResult::RETRY will call f - * again in the future. - * - * The first retry of f happens after `initial_wait_time` milliseconds. The delay between retries is - * increased by multiplying by `backoff_increase_factor` each time. If no backoff_increase_factor is - * supplied (default = 1.0), the wait time will stay constant. - * - * The retry function f needs to accept a single argument: the number of attempts remaining. On the - * final retry of f, this value will be 0. - * - * This retry function can also be cancelled by name via cancel_retry(). - * - * IMPORTANT: Do not rely on this having correct timing. This is only called from - * loop() and therefore can be significantly delayed. - * - * REMARK: It is an error to supply a negative or zero `backoff_increase_factor`, and 1.0 will be used instead. - * - * REMARK: The interval between retries is stored into a `uint32_t`, so this doesn't behave correctly - * if `initial_wait_time * (backoff_increase_factor ** (max_attempts - 2))` overflows. - * - * @param name The identifier for this retry function. - * @param initial_wait_time The time in ms before f is called again - * @param max_attempts The maximum number of executions - * @param f The function (or lambda) that should be called - * @param backoff_increase_factor time between retries is multiplied by this factor on every retry after the first - * @see cancel_retry() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - /** Set a retry function with a numeric ID (zero heap allocation). - * - * @param id The numeric identifier for this retry function - * @param initial_wait_time The wait time after the first execution - * @param max_attempts The max number of attempts - * @param f The function to call - * @param backoff_increase_factor The factor to increase the retry interval by - */ + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT float backoff_increase_factor = 1.0f); // NOLINT - /** Cancel a retry function. - * - * @param name The identifier for this retry function. - * @return Whether a retry function was deleted. - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(const std::string &name); // NOLINT - bool cancel_retry(const char *name); // NOLINT - bool cancel_retry(uint32_t id); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") + bool cancel_retry(const char *name); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") + bool cancel_retry(uint32_t id); // NOLINT /** Set a timeout function with a unique name. * diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 6797640f54c..a5e308829a1 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -252,6 +252,11 @@ bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); } +// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation. +// Remove before 2026.8.0 along with all retry code. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + struct RetryArgs { // Ordered to minimize padding on 32-bit systems std::function func; @@ -364,6 +369,8 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id); } +#pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings + optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). // It performs cleanup and accesses items_[0] without holding a lock, which is only diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7de1023e6df..20b069f3f08 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -72,18 +72,30 @@ class Scheduler { bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); - /// Set a retry with a numeric ID (zero heap allocation) + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(Component *component, const std::string &name); + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(Component *component, const char *name); + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(Component *component, uint32_t id); // Calculate when the next scheduled item should run @@ -231,11 +243,14 @@ class Scheduler { uint32_t hash_or_id, uint32_t delay, std::function func, bool is_retry = false, bool skip_cancel = false); - // Common implementation for retry + // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" void set_retry_common_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor); +#pragma GCC diagnostic pop // Common implementation for cancel_retry bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); From 3b0df145b72f8df89b690ab8b3aaa7175d4d8089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:05:59 -0600 Subject: [PATCH 05/19] [cse7766] Batch UART reads to reduce loop overhead (#13817) --- esphome/components/cse7766/cse7766.cpp | 48 +++++++++++++++++--------- esphome/components/cse7766/cse7766.h | 4 ++- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index df4872deacf..45abd3ca3df 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -7,7 +7,6 @@ namespace esphome { namespace cse7766 { static const char *const TAG = "cse7766"; -static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); @@ -16,25 +15,39 @@ void CSE7766Component::loop() { this->raw_data_index_ = 0; } - if (this->available() == 0) { + // Early return prevents updating last_transmission_ when no data is available. + int avail = this->available(); + if (avail <= 0) { return; } this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->raw_data_[this->raw_data_index_]); - if (!this->check_byte_()) { - this->raw_data_index_ = 0; - this->status_set_warning(); - continue; - } - if (this->raw_data_index_ == 23) { - this->parse_data_(); - this->status_clear_warning(); + // Read all available bytes in batches to reduce UART call overhead. + // At 4800 baud (~480 bytes/sec) with ~122 Hz loop rate, typically ~4 bytes per call. + uint8_t buf[CSE7766_RAW_DATA_SIZE]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; } + avail -= to_read; - this->raw_data_index_ = (this->raw_data_index_ + 1) % 24; + for (size_t i = 0; i < to_read; i++) { + this->raw_data_[this->raw_data_index_] = buf[i]; + if (!this->check_byte_()) { + this->raw_data_index_ = 0; + this->status_set_warning(); + continue; + } + + if (this->raw_data_index_ == CSE7766_RAW_DATA_SIZE - 1) { + this->parse_data_(); + this->status_clear_warning(); + } + + this->raw_data_index_ = (this->raw_data_index_ + 1) % CSE7766_RAW_DATA_SIZE; + } } } @@ -53,14 +66,15 @@ bool CSE7766Component::check_byte_() { return true; } - if (index == 23) { + if (index == CSE7766_RAW_DATA_SIZE - 1) { uint8_t checksum = 0; - for (uint8_t i = 2; i < 23; i++) { + for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) { checksum += this->raw_data_[i]; } - if (checksum != this->raw_data_[23]) { - ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, this->raw_data_[23]); + if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) { + ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, + this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]); return false; } return true; diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index efddccd3c5d..66a4e04633f 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -8,6 +8,8 @@ namespace esphome { namespace cse7766 { +static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; + class CSE7766Component : public Component, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } @@ -33,7 +35,7 @@ class CSE7766Component : public Component, public uart::UARTDevice { this->raw_data_[start_index + 2]); } - uint8_t raw_data_[24]; + uint8_t raw_data_[CSE7766_RAW_DATA_SIZE]; uint8_t raw_data_index_{0}; uint32_t last_transmission_{0}; sensor::Sensor *voltage_sensor_{nullptr}; From c7883cb5ae6f69ca7de1f4281c5661b41f98fb95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:06:38 -0600 Subject: [PATCH 06/19] [ld2450] Batch UART reads to reduce loop overhead (#13818) --- esphome/components/ld2450/ld2450.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index ca8d918441a..38ba0d7f96b 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -276,8 +276,19 @@ void LD2450Component::dump_config() { } void LD2450Component::loop() { - while (this->available()) { - this->readline_(this->read()); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } } } From 50fe8e51f9dfb610e74049fcc0db440e22301d54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:07:28 -0600 Subject: [PATCH 07/19] [ld2412] Batch UART reads to reduce loop overhead (#13819) --- esphome/components/ld2412/ld2412.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index c2f441e472c..f8ceee78ebb 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -310,8 +310,19 @@ void LD2412Component::restart_and_read_all_info() { } void LD2412Component::loop() { - while (this->available()) { - this->readline_(this->read()); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } } } From c43d3889b041ee1322232b1ea089c5cb5544d738 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:07:42 -0600 Subject: [PATCH 08/19] [modbus] Use stack buffer instead of heap vector in send() (#13853) --- esphome/components/modbus/modbus.cpp | 39 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5e9387b8432..357cd48e116 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,39 +219,50 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address return; } - std::vector data; - data.push_back(address); - data.push_back(function_code); + static constexpr size_t ADDR_SIZE = 1; + static constexpr size_t FC_SIZE = 1; + static constexpr size_t START_ADDR_SIZE = 2; + static constexpr size_t NUM_ENTITIES_SIZE = 2; + static constexpr size_t BYTE_COUNT_SIZE = 1; + static constexpr size_t MAX_PAYLOAD_SIZE = std::numeric_limits::max(); + static constexpr size_t CRC_SIZE = 2; + static constexpr size_t MAX_FRAME_SIZE = + ADDR_SIZE + FC_SIZE + START_ADDR_SIZE + NUM_ENTITIES_SIZE + BYTE_COUNT_SIZE + MAX_PAYLOAD_SIZE + CRC_SIZE; + uint8_t data[MAX_FRAME_SIZE]; + size_t pos = 0; + + data[pos++] = address; + data[pos++] = function_code; if (this->role == ModbusRole::CLIENT) { - data.push_back(start_address >> 8); - data.push_back(start_address >> 0); + data[pos++] = start_address >> 8; + data[pos++] = start_address >> 0; if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data.push_back(number_of_entities >> 8); - data.push_back(number_of_entities >> 0); + data[pos++] = number_of_entities >> 8; + data[pos++] = number_of_entities >> 0; } } if (payload != nullptr) { if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple - data.push_back(payload_len); // Byte count is required for write + data[pos++] = payload_len; // Byte count is required for write } else { payload_len = 2; // Write single register or coil } for (int i = 0; i < payload_len; i++) { - data.push_back(payload[i]); + data[pos++] = payload[i]; } } - auto crc = crc16(data.data(), data.size()); - data.push_back(crc >> 0); - data.push_back(crc >> 8); + auto crc = crc16(data, pos); + data[pos++] = crc >> 0; + data[pos++] = crc >> 8; if (this->flow_control_pin_ != nullptr) this->flow_control_pin_->digital_write(true); - this->write_array(data); + this->write_array(data, pos); this->flush(); if (this->flow_control_pin_ != nullptr) @@ -261,7 +272,7 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Modbus write: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); + ESP_LOGV(TAG, "Modbus write: %s", format_hex_pretty_to(hex_buf, data, pos)); } // Helper function for lambdas From d33f23dc43dad9c7891f2789e811dec248d83e5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:07:55 -0600 Subject: [PATCH 09/19] [ld2410] Batch UART reads to reduce loop overhead (#13820) --- esphome/components/ld2410/ld2410.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 5294f7cd36b..b57b1d99788 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -275,8 +275,19 @@ void LD2410Component::restart_and_read_all_info() { } void LD2410Component::loop() { - while (this->available()) { - this->readline_(this->read()); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } } } From 8b24112be5aadb2952d7dff58cbfd05341349e29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:14:48 -0600 Subject: [PATCH 10/19] [pipsolar] Batch UART reads to reduce per-loop overhead (#13829) --- esphome/components/pipsolar/pipsolar.cpp | 64 +++++++++++++++--------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index bafd5273da2..d7b37f61300 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -13,9 +13,12 @@ void Pipsolar::setup() { } void Pipsolar::empty_uart_buffer_() { - uint8_t byte; - while (this->available()) { - this->read_byte(&byte); + uint8_t buf[64]; + int avail; + while ((avail = this->available()) > 0) { + if (!this->read_array(buf, std::min(static_cast(avail), sizeof(buf)))) { + break; + } } } @@ -94,32 +97,47 @@ void Pipsolar::loop() { } if (this->state_ == STATE_COMMAND || this->state_ == STATE_POLL) { - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - - // make sure data and null terminator fit in buffer - if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { - this->read_pos_ = 0; - this->empty_uart_buffer_(); - ESP_LOGW(TAG, "response data too long, discarding."); + int avail = this->available(); + while (avail > 0) { + uint8_t buf[64]; + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { break; } - this->read_buffer_[this->read_pos_] = byte; - this->read_pos_++; + avail -= to_read; + bool done = false; + for (size_t i = 0; i < to_read; i++) { + uint8_t byte = buf[i]; - // end of answer - if (byte == 0x0D) { - this->read_buffer_[this->read_pos_] = 0; - this->empty_uart_buffer_(); - if (this->state_ == STATE_POLL) { - this->state_ = STATE_POLL_COMPLETE; + // make sure data and null terminator fit in buffer + if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { + this->read_pos_ = 0; + this->empty_uart_buffer_(); + ESP_LOGW(TAG, "response data too long, discarding."); + done = true; + break; } - if (this->state_ == STATE_COMMAND) { - this->state_ = STATE_COMMAND_COMPLETE; + this->read_buffer_[this->read_pos_] = byte; + this->read_pos_++; + + // end of answer + if (byte == 0x0D) { + this->read_buffer_[this->read_pos_] = 0; + this->empty_uart_buffer_(); + if (this->state_ == STATE_POLL) { + this->state_ = STATE_POLL_COMPLETE; + } + if (this->state_ == STATE_COMMAND) { + this->state_ = STATE_COMMAND_COMPLETE; + } + done = true; + break; } } - } // available + if (done) { + break; + } + } } if (this->state_ == STATE_COMMAND) { if (millis() - this->command_start_millis_ > esphome::pipsolar::Pipsolar::COMMAND_TIMEOUT) { From 623f33c9f9ea480d35d258b34c2fca427ce54ae4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:04 -0600 Subject: [PATCH 11/19] [rd03d] Batch UART reads to reduce per-loop overhead (#13830) --- esphome/components/rd03d/rd03d.cpp | 67 +++++++++++++++++------------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index 090e4dcf324..e4dbdf41cb6 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -1,4 +1,5 @@ #include "rd03d.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -80,37 +81,47 @@ void RD03DComponent::dump_config() { } void RD03DComponent::loop() { - while (this->available()) { - uint8_t byte = this->read(); - ESP_LOGVV(TAG, "Received byte: 0x%02X, buffer_pos: %d", byte, this->buffer_pos_); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t i = 0; i < to_read; i++) { + uint8_t byte = buf[i]; + ESP_LOGVV(TAG, "Received byte: 0x%02X, buffer_pos: %d", byte, this->buffer_pos_); - // Check if we're looking for frame header - if (this->buffer_pos_ < FRAME_HEADER_SIZE) { - if (byte == FRAME_HEADER[this->buffer_pos_]) { - this->buffer_[this->buffer_pos_++] = byte; - } else if (byte == FRAME_HEADER[0]) { - // Start over if we see a potential new header - this->buffer_[0] = byte; - this->buffer_pos_ = 1; - } else { + // Check if we're looking for frame header + if (this->buffer_pos_ < FRAME_HEADER_SIZE) { + if (byte == FRAME_HEADER[this->buffer_pos_]) { + this->buffer_[this->buffer_pos_++] = byte; + } else if (byte == FRAME_HEADER[0]) { + // Start over if we see a potential new header + this->buffer_[0] = byte; + this->buffer_pos_ = 1; + } else { + this->buffer_pos_ = 0; + } + continue; + } + + // Accumulate data bytes + this->buffer_[this->buffer_pos_++] = byte; + + // Check if we have a complete frame + if (this->buffer_pos_ == FRAME_SIZE) { + // Validate footer + if (this->buffer_[FRAME_SIZE - 2] == FRAME_FOOTER[0] && this->buffer_[FRAME_SIZE - 1] == FRAME_FOOTER[1]) { + this->process_frame_(); + } else { + ESP_LOGW(TAG, "Invalid frame footer: 0x%02X 0x%02X (expected 0x55 0xCC)", this->buffer_[FRAME_SIZE - 2], + this->buffer_[FRAME_SIZE - 1]); + } this->buffer_pos_ = 0; } - continue; - } - - // Accumulate data bytes - this->buffer_[this->buffer_pos_++] = byte; - - // Check if we have a complete frame - if (this->buffer_pos_ == FRAME_SIZE) { - // Validate footer - if (this->buffer_[FRAME_SIZE - 2] == FRAME_FOOTER[0] && this->buffer_[FRAME_SIZE - 1] == FRAME_FOOTER[1]) { - this->process_frame_(); - } else { - ESP_LOGW(TAG, "Invalid frame footer: 0x%02X 0x%02X (expected 0x55 0xCC)", this->buffer_[FRAME_SIZE - 2], - this->buffer_[FRAME_SIZE - 1]); - } - this->buffer_pos_ = 0; } } } From e7a900fbaa082c906dcb54f438a876da11763456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:15 -0600 Subject: [PATCH 12/19] [rf_bridge] Batch UART reads to reduce per-loop overhead (#13831) --- esphome/components/rf_bridge/rf_bridge.cpp | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 8105767485a..e33c13aafe8 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -136,14 +136,21 @@ void RFBridgeComponent::loop() { this->last_bridge_byte_ = now; } - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - if (this->parse_bridge_byte_(byte)) { - ESP_LOGVV(TAG, "Parsed: 0x%02X", byte); - this->last_bridge_byte_ = now; - } else { - this->rx_buffer_.clear(); + int avail = this->available(); + while (avail > 0) { + uint8_t buf[64]; + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t i = 0; i < to_read; i++) { + if (this->parse_bridge_byte_(buf[i])) { + ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]); + this->last_bridge_byte_ = now; + } else { + this->rx_buffer_.clear(); + } } } } From e176cf50abb2acfb911d72c8f1c9445530542a5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:28 -0600 Subject: [PATCH 13/19] [dfplayer] Batch UART reads to reduce per-loop overhead (#13832) --- esphome/components/dfplayer/dfplayer.cpp | 264 ++++++++++++----------- 1 file changed, 137 insertions(+), 127 deletions(-) diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 70bd42e1a54..48c06be5581 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -1,4 +1,5 @@ #include "dfplayer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -131,140 +132,149 @@ void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) { } void DFPlayer::loop() { - // Read message - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t bi = 0; bi < to_read; bi++) { + uint8_t byte = buf[bi]; - if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH) - this->read_pos_ = 0; + if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH) + this->read_pos_ = 0; - switch (this->read_pos_) { - case 0: // Start mark - if (byte != 0x7E) - continue; - break; - case 1: // Version - if (byte != 0xFF) { - ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte); - this->read_pos_ = 0; - continue; - } - break; - case 2: // Buffer length - if (byte != 0x06) { - ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte); - this->read_pos_ = 0; - continue; - } - break; - case 9: // End byte + switch (this->read_pos_) { + case 0: // Start mark + if (byte != 0x7E) + continue; + break; + case 1: // Version + if (byte != 0xFF) { + ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + break; + case 2: // Buffer length + if (byte != 0x06) { + ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + break; + case 9: // End byte #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char byte_sequence[100]; - byte_sequence[0] = '\0'; - for (size_t i = 0; i < this->read_pos_ + 1; ++i) { - snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ", - this->read_buffer_[i]); - } - ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence); + char byte_sequence[100]; + byte_sequence[0] = '\0'; + for (size_t i = 0; i < this->read_pos_ + 1; ++i) { + snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ", + this->read_buffer_[i]); + } + ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence); #endif - if (byte != 0xEF) { - ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte); + if (byte != 0xEF) { + ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + // Parse valid received command + uint8_t cmd = this->read_buffer_[3]; + uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6]; + + ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument); + + switch (cmd) { + case 0x3A: + if (argument == 1) { + ESP_LOGI(TAG, "USB loaded"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card loaded"); + } + break; + case 0x3B: + if (argument == 1) { + ESP_LOGI(TAG, "USB unloaded"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card unloaded"); + } + break; + case 0x3F: + if (argument == 1) { + ESP_LOGI(TAG, "USB available"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card available"); + } else if (argument == 3) { + ESP_LOGI(TAG, "USB, TF Card available"); + } + break; + case 0x40: + ESP_LOGV(TAG, "Nack"); + this->ack_set_is_playing_ = false; + this->ack_reset_is_playing_ = false; + switch (argument) { + case 0x01: + ESP_LOGE(TAG, "Module is busy or uninitialized"); + break; + case 0x02: + ESP_LOGE(TAG, "Module is in sleep mode"); + break; + case 0x03: + ESP_LOGE(TAG, "Serial receive error"); + break; + case 0x04: + ESP_LOGE(TAG, "Checksum incorrect"); + break; + case 0x05: + ESP_LOGE(TAG, "Specified track is out of current track scope"); + this->is_playing_ = false; + break; + case 0x06: + ESP_LOGE(TAG, "Specified track is not found"); + this->is_playing_ = false; + break; + case 0x07: + ESP_LOGE(TAG, + "Insertion error (an inserting operation only can be done when a track is being played)"); + break; + case 0x08: + ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)"); + break; + case 0x09: + ESP_LOGE(TAG, "Entered into sleep mode"); + this->is_playing_ = false; + break; + } + break; + case 0x41: + ESP_LOGV(TAG, "Ack ok"); + this->is_playing_ |= this->ack_set_is_playing_; + this->is_playing_ &= !this->ack_reset_is_playing_; + this->ack_set_is_playing_ = false; + this->ack_reset_is_playing_ = false; + break; + case 0x3C: + ESP_LOGV(TAG, "Playback finished (USB drive)"); + this->is_playing_ = false; + this->on_finished_playback_callback_.call(); + case 0x3D: + ESP_LOGV(TAG, "Playback finished (SD card)"); + this->is_playing_ = false; + this->on_finished_playback_callback_.call(); + break; + default: + ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument); + } + this->sent_cmd_ = 0; this->read_pos_ = 0; continue; - } - // Parse valid received command - uint8_t cmd = this->read_buffer_[3]; - uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6]; - - ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument); - - switch (cmd) { - case 0x3A: - if (argument == 1) { - ESP_LOGI(TAG, "USB loaded"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card loaded"); - } - break; - case 0x3B: - if (argument == 1) { - ESP_LOGI(TAG, "USB unloaded"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card unloaded"); - } - break; - case 0x3F: - if (argument == 1) { - ESP_LOGI(TAG, "USB available"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card available"); - } else if (argument == 3) { - ESP_LOGI(TAG, "USB, TF Card available"); - } - break; - case 0x40: - ESP_LOGV(TAG, "Nack"); - this->ack_set_is_playing_ = false; - this->ack_reset_is_playing_ = false; - switch (argument) { - case 0x01: - ESP_LOGE(TAG, "Module is busy or uninitialized"); - break; - case 0x02: - ESP_LOGE(TAG, "Module is in sleep mode"); - break; - case 0x03: - ESP_LOGE(TAG, "Serial receive error"); - break; - case 0x04: - ESP_LOGE(TAG, "Checksum incorrect"); - break; - case 0x05: - ESP_LOGE(TAG, "Specified track is out of current track scope"); - this->is_playing_ = false; - break; - case 0x06: - ESP_LOGE(TAG, "Specified track is not found"); - this->is_playing_ = false; - break; - case 0x07: - ESP_LOGE(TAG, "Insertion error (an inserting operation only can be done when a track is being played)"); - break; - case 0x08: - ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)"); - break; - case 0x09: - ESP_LOGE(TAG, "Entered into sleep mode"); - this->is_playing_ = false; - break; - } - break; - case 0x41: - ESP_LOGV(TAG, "Ack ok"); - this->is_playing_ |= this->ack_set_is_playing_; - this->is_playing_ &= !this->ack_reset_is_playing_; - this->ack_set_is_playing_ = false; - this->ack_reset_is_playing_ = false; - break; - case 0x3C: - ESP_LOGV(TAG, "Playback finished (USB drive)"); - this->is_playing_ = false; - this->on_finished_playback_callback_.call(); - case 0x3D: - ESP_LOGV(TAG, "Playback finished (SD card)"); - this->is_playing_ = false; - this->on_finished_playback_callback_.call(); - break; - default: - ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument); - } - this->sent_cmd_ = 0; - this->read_pos_ = 0; - continue; + } + this->read_buffer_[this->read_pos_] = byte; + this->read_pos_++; } - this->read_buffer_[this->read_pos_] = byte; - this->read_pos_++; } } void DFPlayer::dump_config() { From a5ee4510433c247e8eebe982ff82d587a2e303f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:17:58 -0600 Subject: [PATCH 14/19] [tuya] Batch UART reads to reduce per-loop overhead (#13827) --- esphome/components/tuya/tuya.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 2812fb6ad6d..9ee4c09b860 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -31,10 +31,19 @@ void Tuya::setup() { } void Tuya::loop() { - while (this->available()) { - uint8_t c; - this->read_byte(&c); - this->handle_char_(c); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->handle_char_(buf[i]); + } } process_command_queue_(); } From 8fffe7453dd4cca340808964e8cd7ea24c8002df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:18:12 -0600 Subject: [PATCH 15/19] [seeed_mr24hpc1/mr60fda2/mr60bha2] Batch UART reads to reduce per-loop overhead (#13825) --- .../seeed_mr24hpc1/seeed_mr24hpc1.cpp | 17 ++++++++++----- .../seeed_mr60bha2/seeed_mr60bha2.cpp | 21 ++++++++++++------- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 17 ++++++++++----- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 08d83f93908..3f2103b4013 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -106,12 +106,19 @@ void MR24HPC1Component::update_() { // main loop void MR24HPC1Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->r24_split_data_frame_(byte); // split data frame + for (size_t i = 0; i < to_read; i++) { + this->r24_split_data_frame_(buf[i]); // split data frame + } } if ((this->s_output_info_switch_flag_ == OUTPUT_SWTICH_OFF) && diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index b9ce1f91519..d95e13241d3 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -30,14 +30,21 @@ void MR60BHA2Component::dump_config() { // main loop void MR60BHA2Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->rx_message_.push_back(byte); - if (!this->validate_message_()) { - this->rx_message_.clear(); + for (size_t i = 0; i < to_read; i++) { + this->rx_message_.push_back(buf[i]); + if (!this->validate_message_()) { + this->rx_message_.clear(); + } } } } diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index b5b5b4d05ac..441ee2b5c27 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -49,12 +49,19 @@ void MR60FDA2Component::setup() { // main loop void MR60FDA2Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->split_frame_(byte); // split data frame + for (size_t i = 0; i < to_read; i++) { + this->split_frame_(buf[i]); // split data frame + } } } From 4a9ff48f0246a4bd6131f19a76fce42db9af8781 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:20:50 -0600 Subject: [PATCH 16/19] [nextion] Batch UART reads to reduce loop overhead (#13823) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index fd6ce0a24b2..56bbc840fba 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -397,11 +397,17 @@ bool Nextion::remove_from_q_(bool report_empty) { } void Nextion::process_serial_() { - uint8_t d; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - while (this->available()) { - read_byte(&d); - this->command_data_ += d; + this->command_data_.append(reinterpret_cast(buf), to_read); } } // nextion.tech/instruction-set/ From cd55eb927ddba835775dd71f2da7e2cdb7b59ea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:21:15 -0600 Subject: [PATCH 17/19] [modbus] Batch UART reads to reduce loop overhead (#13822) --- esphome/components/modbus/modbus.cpp | 29 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 357cd48e116..c1f5635028c 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -19,16 +19,25 @@ void Modbus::setup() { void Modbus::loop() { const uint32_t now = App.get_loop_component_start_time(); - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - if (this->parse_modbus_byte_(byte)) { - this->last_modbus_byte_ = now; - } else { - size_t at = this->rx_buffer_.size(); - if (at > 0) { - ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse failed", at); - this->rx_buffer_.clear(); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + if (this->parse_modbus_byte_(buf[i])) { + this->last_modbus_byte_ = now; + } else { + size_t at = this->rx_buffer_.size(); + if (at > 0) { + ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse failed", at); + this->rx_buffer_.clear(); + } } } } From 41a9588d811088ad72e9e6655c29256aa597b0c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:26:06 -0600 Subject: [PATCH 18/19] [i2c] Replace switch with if-else to avoid CSWTCH table in RAM (#13815) --- esphome/components/i2c/i2c_bus_arduino.cpp | 34 ++++++++++------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index e7288301478..edd6b81588f 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -134,25 +134,23 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe for (size_t j = 0; j != read_count; j++) read_buffer[j] = wire_->read(); } - switch (status) { - case 0: - return ERROR_OK; - case 1: - // transmit buffer not large enough - ESP_LOGVV(TAG, "TX failed: buffer not large enough"); - return ERROR_UNKNOWN; - case 2: - case 3: - ESP_LOGVV(TAG, "TX failed: not acknowledged: %d", status); - return ERROR_NOT_ACKNOWLEDGED; - case 5: - ESP_LOGVV(TAG, "TX failed: timeout"); - return ERROR_UNKNOWN; - case 4: - default: - ESP_LOGVV(TAG, "TX failed: unknown error %u", status); - return ERROR_UNKNOWN; + // Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266 + if (status == 0) + return ERROR_OK; + if (status == 1) { + ESP_LOGVV(TAG, "TX failed: buffer not large enough"); + return ERROR_UNKNOWN; } + if (status == 2 || status == 3) { + ESP_LOGVV(TAG, "TX failed: not acknowledged: %u", status); + return ERROR_NOT_ACKNOWLEDGED; + } + if (status == 5) { + ESP_LOGVV(TAG, "TX failed: timeout"); + return ERROR_UNKNOWN; + } + ESP_LOGVV(TAG, "TX failed: unknown error %u", status); + return ERROR_UNKNOWN; } /// Perform I2C bus recovery, see: From e4ea016d1e6a7cb7a93f12c6cd2c1e419858c899 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:26:19 -0600 Subject: [PATCH 19/19] [ci] Block new std::to_string() usage, suggest snprintf alternatives (#13369) --- .../components/api/homeassistant_service.h | 4 +- esphome/components/esp32_ble/ble_uuid.h | 2 +- .../voice_assistant/voice_assistant.h | 2 +- script/ci-custom.py | 47 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 8ee23c75fee..2322d96eefc 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -25,7 +25,9 @@ template class TemplatableStringValue : public TemplatableValue static std::string value_to_string(T &&val) { return to_string(std::forward(val)); } + template static std::string value_to_string(T &&val) { + return to_string(std::forward(val)); // NOLINT + } // Overloads for string types - needed because std::to_string doesn't support them static std::string value_to_string(char *val) { diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 6c8ef7bfd9f..503fde6945d 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -48,7 +48,7 @@ class ESPBTUUID { // Remove before 2026.8.0 ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const; + std::string to_string() const; // NOLINT const char *to_str(std::span output) const; protected: diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 2a5f3a55a77..0ef7ecc81a2 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -83,7 +83,7 @@ struct Timer { } // Remove before 2026.8.0 ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const { + std::string to_string() const { // NOLINT char buffer[TO_STR_BUFFER_SIZE]; return this->to_str(buffer); } diff --git a/script/ci-custom.py b/script/ci-custom.py index b5bec74fa72..8c405b04aea 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -756,6 +756,53 @@ def lint_no_sprintf(fname, match): ) +@lint_re_check( + # Match std::to_string() or unqualified to_string() calls + # The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string + # Use negative lookbehind for unqualified calls to avoid matching: + # - Function definitions: "const char *to_string(" or "std::string to_string(" + # - Method definitions: "Class::to_string(" + # - Method calls: ".to_string(" or "->to_string(" + # - Other identifiers: "_to_string(" + # Also explicitly match std::to_string since : is in the lookbehind + r"(?:(?:])to_string|std\s*::\s*to_string)\s*\(" + CPP_RE_EOL, + include=cpp_include, + exclude=[ + # Vendored library + "esphome/components/http_request/httplib.h", + # Deprecated helpers that return std::string + "esphome/core/helpers.cpp", + # The using declaration itself + "esphome/core/helpers.h", + # Test fixtures - not production embedded code + "tests/integration/fixtures/*", + ], +) +def lint_no_std_to_string(fname, match): + return ( + f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) " + f"allocates heap memory. On long-running embedded devices, repeated heap allocations " + f"fragment memory over time.\n" + f"Please use {highlight('snprintf()')} with a stack buffer instead.\n" + f"\n" + f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n" + f" uint8_t: 4 chars - %u (or PRIu8)\n" + f" int8_t: 5 chars - %d (or PRId8)\n" + f" uint16_t: 6 chars - %u (or PRIu16)\n" + f" int16_t: 7 chars - %d (or PRId16)\n" + f" uint32_t: 11 chars - %" + "PRIu32\n" + " int32_t: 12 chars - %" + "PRId32\n" + " uint64_t: 21 chars - %" + "PRIu64\n" + " int64_t: 21 chars - %" + "PRId64\n" + f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n" + f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n" + f"\n" + f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n" + f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n' + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ) + + @lint_re_check( # Match scanf family functions: scanf, sscanf, fscanf, vscanf, vsscanf, vfscanf # Also match std:: prefixed versions