From d206c75b0b815c1cdd5d4f0741f0b04f22918138 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 19:20:44 -0600 Subject: [PATCH 1/5] [logger] Fix loop disable optimization using wrong preprocessor guard (#14158) --- esphome/components/logger/logger.cpp | 13 ++++++------- esphome/components/logger/logger.h | 10 +++++----- esphome/components/logger/logger_zephyr.cpp | 2 +- esphome/core/defines.h | 4 ++++ 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 87963c5fc5..22a95e4835 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -170,19 +170,19 @@ void Logger::init_log_buffer(size_t total_buffer_size) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - allocated once, never freed this->log_buffer_ = new logger::TaskLogBuffer(total_buffer_size); -// Zephyr needs loop working to check when CDC port is open -#if !(defined(USE_ZEPHYR) || defined(USE_LOGGER_USB_CDC)) - // Start with loop disabled when using task buffer (unless using USB CDC on ESP32) +#if !(defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)) + // Start with loop disabled when using task buffer // The loop will be enabled automatically when messages arrive + // Zephyr with USB CDC needs loop active to poll port readiness via cdc_loop_() this->disable_loop_when_buffer_empty_(); #endif } #endif -#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_USB_CDC)) +#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)) void Logger::loop() { this->process_messages_(); -#if defined(USE_ZEPHYR) && defined(USE_LOGGER_USB_CDC) +#if defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) this->cdc_loop_(); #endif } @@ -204,8 +204,7 @@ void Logger::process_messages_() { this->write_log_buffer_to_console_(buf); } } -// Zephyr needs loop working to check when CDC port is open -#if !(defined(USE_ZEPHYR) || defined(USE_LOGGER_USB_CDC)) +#if !(defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)) else { // No messages to process, disable loop if appropriate // This reduces overhead when there's no async logging activity diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index c6c379f6c6..8bf1edebb8 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -147,7 +147,7 @@ class Logger : public Component { #ifdef USE_ESPHOME_TASK_LOG_BUFFER void init_log_buffer(size_t total_buffer_size); #endif -#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_USB_CDC)) +#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)) void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. @@ -229,7 +229,7 @@ class Logger : public Component { void log_vprintf_non_main_thread_(uint8_t level, const char *tag, int line, const char *format, va_list args, const char *thread_name); #endif -#if defined(USE_ZEPHYR) && defined(USE_LOGGER_USB_CDC) +#if defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) void cdc_loop_(); #endif void process_messages_(); @@ -465,9 +465,9 @@ class Logger : public Component { inline RecursionGuard make_non_main_task_guard_() { return RecursionGuard(non_main_task_recursion_guard_); } #endif -// Zephyr needs loop working to check when CDC port is open -#if defined(USE_ESPHOME_TASK_LOG_BUFFER) && !(defined(USE_ZEPHYR) || defined(USE_LOGGER_USB_CDC)) - // Disable loop when task buffer is empty (with USB CDC check on ESP32) +#if defined(USE_ESPHOME_TASK_LOG_BUFFER) && !(defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)) + // Disable loop when task buffer is empty + // Zephyr with USB CDC needs loop active to poll port readiness via cdc_loop_() inline void disable_loop_when_buffer_empty_() { // Thread safety note: This is safe even if another task calls enable_loop_soon_any_context() // concurrently. If that happens between our check and disable_loop(), the enable request diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index d6193ff36b..c2d24d6efc 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -34,7 +34,7 @@ __attribute__((section(".noinit"))) struct { static const char *const TAG = "logger"; -#ifdef USE_LOGGER_USB_CDC +#ifdef USE_LOGGER_UART_SELECTION_USB_CDC void Logger::cdc_loop_() { if (this->uart_ != UART_SELECTION_USB_CDC || this->uart_dev_ == nullptr) { return; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c82b222a3d..5109dd36f4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -270,10 +270,12 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC +#define USE_LOGGER_UART_SELECTION_USB_CDC #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) #define USE_LOGGER_USB_CDC +#define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG #endif #endif @@ -335,6 +337,8 @@ #ifdef USE_NRF52 #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_LOGGER_EARLY_MESSAGE +#define USE_LOGGER_UART_SELECTION_USB_CDC +#define USE_LOGGER_USB_CDC #define USE_LOGGER_WAIT_FOR_CDC #define USE_NRF52_DFU #define USE_NRF52_REG0_VOUT 5 From 35037d1a5b7c5ad7f4dbabaaa60dab2e7d76af01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 19:20:58 -0600 Subject: [PATCH 2/5] [core] Deduplicate base64 encode/decode logic (#14143) Co-authored-by: Claude Opus 4.6 --- esphome/core/helpers.cpp | 88 ++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 48 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 09e755ca71..9f850b5df8 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -545,38 +545,36 @@ static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } +// Encode 3 input bytes to 4 base64 characters, append 'count' to ret. +static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { + char char_array_4[4]; + char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; + char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); + char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); + char_array_4[3] = char_array_3[2] & 0x3f; + + for (int j = 0; j < count; j++) + ret += BASE64_CHARS[static_cast(char_array_4[j])]; +} + std::string base64_encode(const uint8_t *buf, size_t buf_len) { std::string ret; int i = 0; - int j = 0; char char_array_3[3]; - char char_array_4[4]; while (buf_len--) { char_array_3[i++] = *(buf++); if (i == 3) { - char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; - char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); - char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); - char_array_4[3] = char_array_3[2] & 0x3f; - - for (i = 0; (i < 4); i++) - ret += BASE64_CHARS[static_cast(char_array_4[i])]; + base64_encode_triple(char_array_3, 4, ret); i = 0; } } if (i) { - for (j = i; j < 3; j++) + for (int j = i; j < 3; j++) char_array_3[j] = '\0'; - char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; - char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); - char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); - char_array_4[3] = char_array_3[2] & 0x3f; - - for (j = 0; (j < i + 1); j++) - ret += BASE64_CHARS[static_cast(char_array_4[j])]; + base64_encode_triple(char_array_3, i + 1, ret); while ((i++ < 3)) ret += '='; @@ -589,13 +587,33 @@ size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); } +// Decode 4 base64 characters to up to 'count' output bytes, returns true if truncated. +static inline bool base64_decode_quad(uint8_t *char_array_4, int count, uint8_t *buf, size_t buf_len, size_t &out) { + for (int i = 0; i < 4; i++) + char_array_4[i] = base64_find_char(char_array_4[i]); + + uint8_t char_array_3[3]; + char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); + char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); + char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; + + bool truncated = false; + for (int j = 0; j < count; j++) { + if (out < buf_len) { + buf[out++] = char_array_3[j]; + } else { + truncated = true; + } + } + return truncated; +} + size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len) { size_t in_len = encoded_len; int i = 0; - int j = 0; size_t in = 0; size_t out = 0; - uint8_t char_array_4[4], char_array_3[3]; + uint8_t char_array_4[4]; bool truncated = false; // SAFETY: The loop condition checks is_base64() before processing each character. @@ -605,42 +623,16 @@ size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *b char_array_4[i++] = encoded_data[in]; in++; if (i == 4) { - for (i = 0; i < 4; i++) - char_array_4[i] = base64_find_char(char_array_4[i]); - - char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); - char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); - char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - - for (i = 0; i < 3; i++) { - if (out < buf_len) { - buf[out++] = char_array_3[i]; - } else { - truncated = true; - } - } + truncated |= base64_decode_quad(char_array_4, 3, buf, buf_len, out); i = 0; } } if (i) { - for (j = i; j < 4; j++) + for (int j = i; j < 4; j++) char_array_4[j] = 0; - for (j = 0; j < 4; j++) - char_array_4[j] = base64_find_char(char_array_4[j]); - - char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); - char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); - char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - - for (j = 0; j < i - 1; j++) { - if (out < buf_len) { - buf[out++] = char_array_3[j]; - } else { - truncated = true; - } - } + truncated |= base64_decode_quad(char_array_4, i - 1, buf, buf_len, out); } if (truncated) { From a3f279c1cf150f6908b84df9976e72821995e7b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 19:21:14 -0600 Subject: [PATCH 3/5] [usb_host] Implement disable_loop/enable_loop pattern for USB components (#14163) --- esphome/components/usb_host/usb_host.h | 21 +++++++++++-------- .../components/usb_host/usb_host_client.cpp | 17 ++++++++++++++- esphome/components/usb_uart/usb_uart.cpp | 11 +++++++++- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index d1ec356613..a6a97d0bd7 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -73,12 +73,12 @@ static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than mai // used to report a transfer status struct TransferStatus { - bool success; - uint16_t error_code; uint8_t *data; size_t data_len; - uint8_t endpoint; void *user_data; + uint16_t error_code; + uint8_t endpoint; + bool success; }; using transfer_cb_t = std::function; @@ -127,7 +127,7 @@ class USBClient : public Component { friend class USBHost; public: - USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid), trq_in_use_(0) {} + USBClient(uint16_t vid, uint16_t pid) : trq_in_use_(0), vid_(vid), pid_(pid) {} void setup() override; void loop() override; // setup must happen after the host bus has been setup @@ -148,6 +148,10 @@ class USBClient : public Component { EventPool event_pool; protected: + // Process USB events from the queue. Returns true if any work was done. + // Subclasses should call this instead of USBClient::loop() to combine + // with their own work check for a single disable_loop() decision. + bool process_usb_events_(); void handle_open_state_(); TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); @@ -161,20 +165,19 @@ class USBClient : public Component { static void usb_task_fn(void *arg); [[noreturn]] void usb_task_loop() const; + // Members ordered to minimize struct padding on 32-bit platforms + TransferRequest requests_[MAX_REQUESTS]{}; TaskHandle_t usb_task_handle_{nullptr}; - usb_host_client_handle_t handle_{}; usb_device_handle_t device_handle_{}; int device_addr_{-1}; int state_{USB_CLIENT_INIT}; - uint16_t vid_{}; - uint16_t pid_{}; // Lock-free pool management using atomic bitmask (no dynamic allocation) // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available // Supports multiple concurrent consumers and producers (both threads can allocate/deallocate) - // Bitmask type automatically selected: uint16_t for <= 16 slots, uint32_t for 17-32 slots std::atomic trq_in_use_; - TransferRequest requests_[MAX_REQUESTS]{}; + uint16_t vid_{}; + uint16_t pid_{}; }; class USBHost : public Component { public: diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 0612d7a841..a9be38fb03 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -197,6 +197,9 @@ static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void * // Push to lock-free queue (always succeeds since pool size == queue size) client->event_queue.push(event); + // Re-enable component loop to process the queued event + client->enable_loop_soon_any_context(); + // Wake main loop immediately to process USB event instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); @@ -243,10 +246,13 @@ void USBClient::usb_task_loop() const { } } -void USBClient::loop() { +bool USBClient::process_usb_events_() { + bool had_work = false; + // Process any events from the USB task UsbEvent *event; while ((event = this->event_queue.pop()) != nullptr) { + had_work = true; switch (event->type) { case EVENT_DEVICE_NEW: this->on_opened(event->data.device_new.address); @@ -266,8 +272,17 @@ void USBClient::loop() { } if (this->state_ == USB_CLIENT_OPEN) { + had_work = true; this->handle_open_state_(); } + + return had_work; +} + +void USBClient::loop() { + if (!this->process_usb_events_()) { + this->disable_loop(); + } } void USBClient::handle_open_state_() { diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index edd01c26c6..5c2806c456 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -172,11 +172,12 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) { } void USBUartComponent::setup() { USBClient::setup(); } void USBUartComponent::loop() { - USBClient::loop(); + bool had_work = this->process_usb_events_(); // Process USB data from the lock-free queue UsbDataChunk *chunk; while ((chunk = this->usb_data_queue_.pop()) != nullptr) { + had_work = true; auto *channel = chunk->channel; #ifdef USE_UART_DEBUGGER @@ -198,6 +199,11 @@ void USBUartComponent::loop() { if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u USB data chunks due to buffer overflow", dropped); } + + // Disable loop when idle. Callbacks re-enable via enable_loop_soon_any_context(). + if (!had_work) { + this->disable_loop(); + } } void USBUartComponent::dump_config() { USBClient::dump_config(); @@ -264,6 +270,9 @@ void USBUartComponent::start_input(USBUartChannel *channel) { // Push always succeeds because pool size == queue size this->usb_data_queue_.push(chunk); + // Re-enable component loop to process the queued data + this->enable_loop_soon_any_context(); + // Wake main loop immediately to process USB data instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); From 0e38acd67a178ca6490284093c7034e8bfde5532 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 19:21:56 -0600 Subject: [PATCH 4/5] [api] Warn when clients connect with outdated API version (#14145) --- esphome/components/api/api_connection.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5a7994a322..5b02bee537 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1534,6 +1534,12 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(), this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_); + // TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal) + if (!this->client_supports_api_version(1, 14)) { + ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(), + this->client_api_version_major_, this->client_api_version_minor_); + } + HelloResponse resp; resp.api_version_major = 1; resp.api_version_minor = 14; From c6c01f85af9290843f99146c2509231eb6cbdabb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 19:29:41 -0600 Subject: [PATCH 5/5] [setup_heap_stats] Add component to track per-component heap allocation during boot Tracks heap usage per component during both construction and setup() phases. Logs deltas inline as components are registered and set up, then prints a sorted summary after setup() completes. Storage is freed after the summary. This is a diagnostic tool - not intended for production use. It was used to discover the logger's unused UART event queue allocation (#14168). --- .../components/setup_heap_stats/__init__.py | 13 ++ .../setup_heap_stats/setup_heap_stats.cpp | 124 ++++++++++++++++++ .../setup_heap_stats/setup_heap_stats.h | 56 ++++++++ esphome/core/application.cpp | 23 ++++ esphome/core/application.h | 10 ++ esphome/core/component.cpp | 13 ++ esphome/core/defines.h | 1 + tests/components/setup_heap_stats/common.yaml | 1 + .../setup_heap_stats/test.bk72xx-ard.yaml | 1 + .../setup_heap_stats/test.esp32-idf.yaml | 1 + .../setup_heap_stats/test.esp8266-ard.yaml | 1 + .../setup_heap_stats/test.rp2040-ard.yaml | 1 + .../setup_heap_stats/test.rtl87xx-ard.yaml | 1 + 13 files changed, 246 insertions(+) create mode 100644 esphome/components/setup_heap_stats/__init__.py create mode 100644 esphome/components/setup_heap_stats/setup_heap_stats.cpp create mode 100644 esphome/components/setup_heap_stats/setup_heap_stats.h create mode 100644 tests/components/setup_heap_stats/common.yaml create mode 100644 tests/components/setup_heap_stats/test.bk72xx-ard.yaml create mode 100644 tests/components/setup_heap_stats/test.esp32-idf.yaml create mode 100644 tests/components/setup_heap_stats/test.esp8266-ard.yaml create mode 100644 tests/components/setup_heap_stats/test.rp2040-ard.yaml create mode 100644 tests/components/setup_heap_stats/test.rtl87xx-ard.yaml diff --git a/esphome/components/setup_heap_stats/__init__.py b/esphome/components/setup_heap_stats/__init__.py new file mode 100644 index 0000000000..9422390a4f --- /dev/null +++ b/esphome/components/setup_heap_stats/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +import esphome.config_validation as cv + +CODEOWNERS = ["@bdraco"] + +CONFIG_SCHEMA = cv.All( + cv.Schema({}), + cv.only_on(["esp32", "esp8266", "rp2040", "bk72xx", "rtl87xx", "ln882x"]), +) + + +async def to_code(config): + cg.add_define("USE_SETUP_HEAP_STATS") diff --git a/esphome/components/setup_heap_stats/setup_heap_stats.cpp b/esphome/components/setup_heap_stats/setup_heap_stats.cpp new file mode 100644 index 0000000000..e908cefff4 --- /dev/null +++ b/esphome/components/setup_heap_stats/setup_heap_stats.cpp @@ -0,0 +1,124 @@ +#include "setup_heap_stats.h" + +#ifdef USE_SETUP_HEAP_STATS + +#include +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 +#include +#endif +#ifdef USE_ESP8266 +#include +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome { + +namespace setup_heap_stats { + +static const char *const TAG = "setup_heap_stats"; + +SetupHeapStatsCollector::SetupHeapStatsCollector(uint32_t initial_baseline) { + global_setup_heap_stats = this; + uint32_t before_alloc = get_free_internal_heap(); + this->entries_ = new ComponentHeapEntry[ESPHOME_COMPONENT_COUNT]; // NOLINT + uint32_t after_alloc = get_free_internal_heap(); + // Use the pre_setup() baseline but subtract our own entries allocation + // so the collector's overhead is not attributed to the first component + this->last_heap_snapshot_ = initial_baseline - (before_alloc - after_alloc); +} + +uint32_t SetupHeapStatsCollector::get_free_internal_heap() { +#ifdef USE_ESP32 + return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); +#elif defined(USE_ESP8266) + return ESP.getFreeHeap(); // NOLINT(readability-static-accessed-through-instance) +#elif defined(USE_RP2040) + return rp2040.getFreeHeap(); +#elif defined(USE_LIBRETINY) + return lt_heap_get_free(); +#else + return 0; +#endif +} + +void SetupHeapStatsCollector::record_component_registered(Component *comp) { + uint32_t current_heap = get_free_internal_heap(); + int32_t delta = static_cast(this->last_heap_snapshot_) - static_cast(current_heap); + this->last_heap_snapshot_ = current_heap; + + if (this->entry_count_ < ESPHOME_COMPONENT_COUNT) { + this->entries_[this->entry_count_].component = comp; + this->entries_[this->entry_count_].construction_delta = delta; + this->entries_[this->entry_count_].setup_delta = 0; + this->entry_count_++; + } + + ESP_LOGI(TAG, "Constructed %s: %" PRId32 " bytes (free: %" PRIu32 ")", LOG_STR_ARG(comp->get_component_log_str()), + delta, current_heap); +} + +void SetupHeapStatsCollector::record_before_setup(Component *comp) { + this->setup_component_ = comp; + this->setup_before_heap_ = get_free_internal_heap(); +} + +void SetupHeapStatsCollector::record_after_setup(Component *comp) { + if (this->setup_component_ != comp) + return; + + uint32_t current_heap = get_free_internal_heap(); + int32_t delta = static_cast(this->setup_before_heap_) - static_cast(current_heap); + + // Find the entry for this component and update setup delta + for (uint16_t i = 0; i < this->entry_count_; i++) { + if (this->entries_[i].component == comp) { + this->entries_[i].setup_delta = delta; + break; + } + } + + ESP_LOGI(TAG, "Setup %s: %" PRId32 " bytes (free: %" PRIu32 ")", LOG_STR_ARG(comp->get_component_log_str()), delta, + current_heap); + + this->setup_component_ = nullptr; +} + +void SetupHeapStatsCollector::log_summary() { + if (this->entries_ == nullptr || this->entry_count_ == 0) + return; + + // Sort by total (construction + setup) descending + std::sort(this->entries_, this->entries_ + this->entry_count_, + [](const ComponentHeapEntry &a, const ComponentHeapEntry &b) { + return (a.construction_delta + a.setup_delta) > (b.construction_delta + b.setup_delta); + }); + + ESP_LOGI(TAG, "Setup Heap Stats Summary (sorted by total, construction + setup):"); + for (uint16_t i = 0; i < this->entry_count_; i++) { + const auto &entry = this->entries_[i]; + int32_t total = entry.construction_delta + entry.setup_delta; + if (total == 0 && entry.construction_delta == 0 && entry.setup_delta == 0) + continue; + ESP_LOGI(TAG, " %s: %" PRId32 " bytes (construction: %" PRId32 ", setup: %" PRId32 ")", + LOG_STR_ARG(entry.component->get_component_log_str()), total, entry.construction_delta, entry.setup_delta); + } + + // Free storage + delete[] this->entries_; // NOLINT + this->entries_ = nullptr; + this->entry_count_ = 0; +} + +} // namespace setup_heap_stats + +setup_heap_stats::SetupHeapStatsCollector *global_setup_heap_stats = + nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome + +#endif // USE_SETUP_HEAP_STATS diff --git a/esphome/components/setup_heap_stats/setup_heap_stats.h b/esphome/components/setup_heap_stats/setup_heap_stats.h new file mode 100644 index 0000000000..10ca91146d --- /dev/null +++ b/esphome/components/setup_heap_stats/setup_heap_stats.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_SETUP_HEAP_STATS + +#include + +namespace esphome { + +class Component; // Forward declaration + +namespace setup_heap_stats { + +struct ComponentHeapEntry { + Component *component; + int32_t construction_delta; + int32_t setup_delta; +}; + +class SetupHeapStatsCollector { + public: + explicit SetupHeapStatsCollector(uint32_t initial_baseline); + + /// Called from register_component_() to record heap delta since last registration + void record_component_registered(Component *comp); + + /// Called before component->call_setup() + void record_before_setup(Component *comp); + + /// Called after component->call_setup() + void record_after_setup(Component *comp); + + /// Log sorted summary and free storage + void log_summary(); + + /// Get free internal heap size (platform-specific) + static uint32_t get_free_internal_heap(); + + protected: + ComponentHeapEntry *entries_{nullptr}; + uint16_t entry_count_{0}; + uint32_t last_heap_snapshot_{0}; + // Temporary storage for before/after setup measurement + Component *setup_component_{nullptr}; + uint32_t setup_before_heap_{0}; +}; + +} // namespace setup_heap_stats + +extern setup_heap_stats::SetupHeapStatsCollector + *global_setup_heap_stats; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome + +#endif // USE_SETUP_HEAP_STATS diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b216233f9b..7e9b90a80b 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -21,6 +21,9 @@ #ifdef USE_STATUS_LED #include "esphome/components/status_led/status_led.h" #endif +#ifdef USE_SETUP_HEAP_STATS +#include "esphome/components/setup_heap_stats/setup_heap_stats.h" +#endif #if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) #include "esphome/components/socket/socket.h" @@ -86,6 +89,14 @@ void Application::register_component_(Component *comp) { return; } this->components_.push_back(comp); +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats == nullptr) { + // Lazily create collector on first component registration + // Pass the baseline captured in pre_setup() so the first component's cost is measured + new setup_heap_stats::SetupHeapStatsCollector(this->setup_heap_stats_baseline_); + } + global_setup_heap_stats->record_component_registered(comp); +#endif } void Application::setup() { ESP_LOGI(TAG, "Running through setup()"); @@ -137,6 +148,12 @@ void Application::setup() { ESP_LOGI(TAG, "setup() finished successfully!"); +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats != nullptr) { + global_setup_heap_stats->log_summary(); + } +#endif + #ifdef USE_SETUP_PRIORITY_OVERRIDE // Clear setup priority overrides to free memory clear_setup_priority_overrides(); @@ -747,4 +764,10 @@ void Application::get_build_time_string(std::span buf buffer[buffer.size() - 1] = '\0'; } +#ifdef USE_SETUP_HEAP_STATS +void Application::init_setup_heap_stats_baseline_() { + this->setup_heap_stats_baseline_ = setup_heap_stats::SetupHeapStatsCollector::get_free_internal_heap(); +} +#endif + } // namespace esphome diff --git a/esphome/core/application.h b/esphome/core/application.h index 5b3e3dfed6..6cca5f197d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -134,6 +134,9 @@ class Application { this->name_ = name; this->friendly_name_ = friendly_name; } +#ifdef USE_SETUP_HEAP_STATS + this->init_setup_heap_stats_baseline_(); +#endif } #ifdef USE_DEVICES @@ -548,6 +551,10 @@ class Application { inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) #endif +#ifdef USE_SETUP_HEAP_STATS + void init_setup_heap_stats_baseline_(); +#endif + // === Member variables ordered by size to minimize padding === // Pointer-sized members first @@ -585,6 +592,9 @@ class Application { // 4-byte members uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; +#ifdef USE_SETUP_HEAP_STATS + uint32_t setup_heap_stats_baseline_{0}; +#endif #ifdef USE_SOCKET_SELECT_SUPPORT int max_fd_{-1}; // Highest file descriptor number for select() diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index b458ea2a84..66e72472f4 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -12,6 +12,9 @@ #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif +#ifdef USE_SETUP_HEAP_STATS +#include "esphome/components/setup_heap_stats/setup_heap_stats.h" +#endif namespace esphome { @@ -241,8 +244,18 @@ void Component::call() { ESP_LOGV(TAG, "Setup %s", LOG_STR_ARG(this->get_component_log_str())); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t start_time = millis(); +#endif +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats != nullptr) { + global_setup_heap_stats->record_before_setup(this); + } #endif this->call_setup(); +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats != nullptr) { + global_setup_heap_stats->record_after_setup(this); + } +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t setup_time = millis() - start_time; // Only log at CONFIG level if setup took longer than the blocking threshold diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5109dd36f4..57005ac414 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -110,6 +110,7 @@ #define USE_SAFE_MODE_CALLBACK #define USE_SELECT #define USE_SENSOR +#define USE_SETUP_HEAP_STATS #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR diff --git a/tests/components/setup_heap_stats/common.yaml b/tests/components/setup_heap_stats/common.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/common.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.bk72xx-ard.yaml b/tests/components/setup_heap_stats/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.esp32-idf.yaml b/tests/components/setup_heap_stats/test.esp32-idf.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.esp32-idf.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.esp8266-ard.yaml b/tests/components/setup_heap_stats/test.esp8266-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.esp8266-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.rp2040-ard.yaml b/tests/components/setup_heap_stats/test.rp2040-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.rp2040-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.rtl87xx-ard.yaml b/tests/components/setup_heap_stats/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.rtl87xx-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: