From 7385c4cf3d6092f24fc96f60499817a177661b4b Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Wed, 28 Jan 2026 09:04:43 -0700 Subject: [PATCH 01/21] [ld2450] preserve precision of angle (#13600) --- esphome/components/ld2450/ld2450.cpp | 2 +- esphome/components/ld2450/sensor.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 07809023cd..ca8d918441 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -451,7 +451,7 @@ void LD2450Component::handle_periodic_data_() { int16_t ty = 0; int16_t td = 0; int16_t ts = 0; - int16_t angle = 0; + float angle = 0; uint8_t index = 0; Direction direction{DIRECTION_UNDEFINED}; bool is_moving = false; diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 3dee8bf470..ce58cedf11 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -143,6 +143,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ], icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=1, ), cv.Optional(CONF_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, From e1355de4cb85ccdcc716e812880dcfcc256a3115 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 06:06:33 -1000 Subject: [PATCH 02/21] [runtime_stats] Eliminate heap churn by using stack-allocated buffer for sorting (#13586) --- .../runtime_stats/runtime_stats.cpp | 59 ++++++++++++------- .../components/runtime_stats/runtime_stats.h | 12 ---- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 9a1e1a109a..410695da04 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -27,46 +27,61 @@ void RuntimeStatsCollector::record_component_time(Component *component, uint32_t } void RuntimeStatsCollector::log_stats_() { + // First pass: count active components + size_t count = 0; + for (const auto &it : this->component_stats_) { + if (it.second.get_period_count() > 0) { + count++; + } + } + ESP_LOGI(TAG, "Component Runtime Statistics\n" - " Period stats (last %" PRIu32 "ms):", - this->log_interval_); + " Period stats (last %" PRIu32 "ms): %zu active components", + this->log_interval_, count); - // First collect stats we want to display - std::vector stats_to_display; + if (count == 0) { + return; + } + // Stack buffer sized to actual active count (up to 256 components), heap fallback for larger + SmallBufferWithHeapFallback<256, Component *> buffer(count); + Component **sorted = buffer.get(); + + // Second pass: fill buffer with active components + size_t idx = 0; for (const auto &it : this->component_stats_) { - Component *component = it.first; - const ComponentRuntimeStats &stats = it.second; - if (stats.get_period_count() > 0) { - ComponentStatPair pair = {component, &stats}; - stats_to_display.push_back(pair); + if (it.second.get_period_count() > 0) { + sorted[idx++] = it.first; } } // Sort by period runtime (descending) - std::sort(stats_to_display.begin(), stats_to_display.end(), std::greater()); + std::sort(sorted, sorted + count, [this](Component *a, Component *b) { + return this->component_stats_[a].get_period_time_ms() > this->component_stats_[b].get_period_time_ms(); + }); // Log top components by period runtime - for (const auto &it : stats_to_display) { + for (size_t i = 0; i < count; i++) { + const auto &stats = this->component_stats_[sorted[i]]; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", - LOG_STR_ARG(it.component->get_component_log_str()), it.stats->get_period_count(), - it.stats->get_period_avg_time_ms(), it.stats->get_period_max_time_ms(), it.stats->get_period_time_ms()); + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.get_period_count(), stats.get_period_avg_time_ms(), + stats.get_period_max_time_ms(), stats.get_period_time_ms()); } - // Log total stats since boot - ESP_LOGI(TAG, " Total stats (since boot):"); + // Log total stats since boot (only for active components - idle ones haven't changed) + ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); // Re-sort by total runtime for all-time stats - std::sort(stats_to_display.begin(), stats_to_display.end(), - [](const ComponentStatPair &a, const ComponentStatPair &b) { - return a.stats->get_total_time_ms() > b.stats->get_total_time_ms(); - }); + std::sort(sorted, sorted + count, [this](Component *a, Component *b) { + return this->component_stats_[a].get_total_time_ms() > this->component_stats_[b].get_total_time_ms(); + }); - for (const auto &it : stats_to_display) { + for (size_t i = 0; i < count; i++) { + const auto &stats = this->component_stats_[sorted[i]]; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", - LOG_STR_ARG(it.component->get_component_log_str()), it.stats->get_total_count(), - it.stats->get_total_avg_time_ms(), it.stats->get_total_max_time_ms(), it.stats->get_total_time_ms()); + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.get_total_count(), stats.get_total_avg_time_ms(), + stats.get_total_max_time_ms(), stats.get_total_time_ms()); } } diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 56122364c2..c7fea7474b 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -5,7 +5,6 @@ #ifdef USE_RUNTIME_STATS #include -#include #include #include #include "esphome/core/helpers.h" @@ -77,17 +76,6 @@ class ComponentRuntimeStats { uint32_t total_max_time_ms_; }; -// For sorting components by run time -struct ComponentStatPair { - Component *component; - const ComponentRuntimeStats *stats; - - bool operator>(const ComponentStatPair &other) const { - // Sort by period time as that's what we're displaying in the logs - return stats->get_period_time_ms() > other.stats->get_period_time_ms(); - } -}; - class RuntimeStatsCollector { public: RuntimeStatsCollector(); From d86048cc2d238fe8339f6a637bd2e24d7ca61254 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 28 Jan 2026 17:41:04 +0100 Subject: [PATCH 03/21] [nrf52,zigbee] Address change (#13580) --- esphome/components/zigbee/__init__.py | 8 ++++++++ esphome/components/zigbee/const_zephyr.py | 1 + esphome/components/zigbee/zigbee_zephyr.py | 8 ++++++++ tests/components/zigbee/test.nrf52-xiao-ble.yaml | 1 + 4 files changed, 18 insertions(+) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 8179220507..1b1da78308 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -12,6 +12,7 @@ from esphome.core import CORE from esphome.types import ConfigType from .const_zephyr import ( + CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER, CONF_ON_JOIN, CONF_POWER_SOURCE, @@ -58,6 +59,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_POWER_SOURCE, default="DC_SOURCE"): cv.enum( POWER_SOURCE, upper=True ), + cv.Optional(CONF_IEEE802154_VENDOR_OUI): cv.All( + cv.Any( + cv.int_range(min=0x000000, max=0xFFFFFF), + cv.one_of(*["random"], lower=True), + ), + cv.requires_component("nrf52"), + ), } ).extend(cv.COMPONENT_SCHEMA), zigbee_set_core_data, diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 0372f22593..03c1bb546f 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -22,6 +22,7 @@ POWER_SOURCE = { "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", } +CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 67a11d3685..b3bd10bfab 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -49,6 +49,7 @@ from esphome.cpp_generator import ( from esphome.types import ConfigType from .const_zephyr import ( + CONF_IEEE802154_VENDOR_OUI, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_WIPE_ON_BOOT, @@ -152,6 +153,13 @@ async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_IP_ADDR_CHECK", False) zephyr_add_prj_conf("NET_UDP", False) + if CONF_IEEE802154_VENDOR_OUI in config: + zephyr_add_prj_conf("IEEE802154_VENDOR_OUI_ENABLE", True) + random_number = config[CONF_IEEE802154_VENDOR_OUI] + if random_number == "random": + random_number = random.randint(0x000000, 0xFFFFFF) + zephyr_add_prj_conf("IEEE802154_VENDOR_OUI", random_number) + if config[CONF_WIPE_ON_BOOT]: if config[CONF_WIPE_ON_BOOT] == "once": cg.add_define( diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index d2ce552de3..254f370ca7 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -3,3 +3,4 @@ zigbee: wipe_on_boot: once power_source: battery + ieee802154_vendor_oui: 0x231 From 87fcfc9d76a148807012742c35a6cb13a99f395f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 09:40:00 -1000 Subject: [PATCH 04/21] [wifi] Fix ESP8266 yield panic when WiFi scan fails (#13603) --- esphome/components/wifi/wifi_component_esp8266.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index be54038af8..c714afaad3 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -756,7 +756,10 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { if (status != OK) { ESP_LOGV(TAG, "Scan failed: %d", status); - this->retry_connect(); + // Don't call retry_connect() here - this callback runs in SDK system context + // where yield() cannot be called. Instead, just set scan_done_ and let + // check_scanning_finished() handle the empty scan_result_ from loop context. + this->scan_done_ = true; return; } From 455ade0dcad9ee029cdf3049538a53ab87b74c9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 09:41:42 -1000 Subject: [PATCH 05/21] [http_request] Fix empty body for chunked transfer encoding responses (#13599) --- .../http_request/http_request_arduino.cpp | 18 +++++++++++---- .../http_request/http_request_idf.cpp | 22 ++++++++++++++++--- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 8ec4d2bc4b..82538b2cb3 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -131,6 +131,10 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur } } + // HTTPClient::getSize() returns -1 for chunked transfer encoding (no Content-Length). + // When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit). + // The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the + // early return check (bytes_read_ >= content_length) will never trigger. int content_length = container->client_.getSize(); ESP_LOGD(TAG, "Content-Length: %d", content_length); container->content_length = (size_t) content_length; @@ -167,17 +171,23 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { } int available_data = stream_ptr->available(); - int bufsize = std::min(max_len, std::min(this->content_length - this->bytes_read_, (size_t) available_data)); + // For chunked transfer encoding, HTTPClient::getSize() returns -1, which becomes SIZE_MAX when + // cast to size_t. SIZE_MAX - bytes_read_ is still huge, so it won't limit the read. + size_t remaining = (this->content_length > 0) ? (this->content_length - this->bytes_read_) : max_len; + int bufsize = std::min(max_len, std::min(remaining, (size_t) available_data)); if (bufsize == 0) { this->duration_ms += (millis() - start); - // Check if we've read all expected content - if (this->bytes_read_ >= this->content_length) { + // Check if we've read all expected content (only valid when content_length is known and not SIZE_MAX) + // For chunked encoding (content_length == SIZE_MAX), we can't use this check + if (this->content_length > 0 && this->bytes_read_ >= this->content_length) { return 0; // All content read successfully } // No data available - check if connection is still open + // For chunked encoding, !connected() after reading means EOF (all chunks received) + // For known content_length with bytes_read_ < content_length, it means connection dropped if (!stream_ptr->connected()) { - return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed prematurely + return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed or EOF for chunked } return 0; // No data yet, caller should retry } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 680ae6c801..2b4dee953a 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -157,6 +157,8 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } container->feed_wdt(); + // esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header). + // The read() method handles content_length == 0 specially to support chunked responses. container->content_length = esp_http_client_fetch_headers(client); container->feed_wdt(); container->status_code = esp_http_client_get_status_code(client); @@ -225,14 +227,22 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c // // We normalize to HttpContainer::read() contract: // > 0: bytes read -// 0: no data yet / all content read (caller should check bytes_read vs content_length) +// 0: all content read (only returned when content_length is known and fully read) // < 0: error/connection closed +// +// Note on chunked transfer encoding: +// esp_http_client_fetch_headers() returns 0 for chunked responses (no Content-Length header). +// We handle this by skipping the content_length check when content_length is 0, +// allowing esp_http_client_read() to handle chunked decoding internally and signal EOF +// by returning 0. int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); // Check if we've already read all expected content - if (this->bytes_read_ >= this->content_length) { + // Skip this check when content_length is 0 (chunked transfer encoding or unknown length) + // For chunked responses, esp_http_client_read() will return 0 when all data is received + if (this->content_length > 0 && this->bytes_read_ >= this->content_length) { return 0; // All content read successfully } @@ -247,7 +257,13 @@ int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { return read_len_or_error; } - // Connection closed by server before all content received + // esp_http_client_read() returns 0 in two cases: + // 1. Known content_length: connection closed before all data received (error) + // 2. Chunked encoding (content_length == 0): end of stream reached (EOF) + // For case 1, returning HTTP_ERROR_CONNECTION_CLOSED is correct. + // For case 2, 0 indicates that all chunked data has already been delivered + // in previous successful read() calls, so treating this as a closed + // connection does not cause any loss of response data. if (read_len_or_error == 0) { return HTTP_ERROR_CONNECTION_CLOSED; } From 6f22509883c7e6d044269fc6f6c65b8d7d1ce4e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:42:05 -1000 Subject: [PATCH 06/21] Bump docker/login-action from 3.6.0 to 3.7.0 in the docker-actions group (#13606) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cd8db6181..479b01ee37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to docker hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} From 6a3205f4db225c997543140f91309eb9399b366d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 10:35:26 -1000 Subject: [PATCH 07/21] [globals] Convert restoring globals to PollingComponent to reduce CPU usage (#13345) --- esphome/components/globals/__init__.py | 42 +++++++++++++++---- .../components/globals/globals_component.h | 28 ++++++------- tests/components/globals/common.yaml | 1 + 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 633ccea66b..fc400c5dd1 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -9,30 +9,56 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CoroPriority, coroutine_with_priority +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] globals_ns = cg.esphome_ns.namespace("globals") GlobalsComponent = globals_ns.class_("GlobalsComponent", cg.Component) -RestoringGlobalsComponent = globals_ns.class_("RestoringGlobalsComponent", cg.Component) +RestoringGlobalsComponent = globals_ns.class_( + "RestoringGlobalsComponent", cg.PollingComponent +) RestoringGlobalStringComponent = globals_ns.class_( - "RestoringGlobalStringComponent", cg.Component + "RestoringGlobalStringComponent", cg.PollingComponent ) GlobalVarSetAction = globals_ns.class_("GlobalVarSetAction", automation.Action) CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" +# Base schema fields shared by both variants +_BASE_SCHEMA = { + cv.Required(CONF_ID): cv.declare_id(GlobalsComponent), + cv.Required(CONF_TYPE): cv.string_strict, + cv.Optional(CONF_INITIAL_VALUE): cv.string_strict, + cv.Optional(CONF_MAX_RESTORE_DATA_LENGTH): cv.int_range(0, 254), +} -MULTI_CONF = True -CONFIG_SCHEMA = cv.Schema( +# Non-restoring globals: regular Component (no polling needed) +_NON_RESTORING_SCHEMA = cv.Schema( { - cv.Required(CONF_ID): cv.declare_id(GlobalsComponent), - cv.Required(CONF_TYPE): cv.string_strict, - cv.Optional(CONF_INITIAL_VALUE): cv.string_strict, + **_BASE_SCHEMA, cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, - cv.Optional(CONF_MAX_RESTORE_DATA_LENGTH): cv.int_range(0, 254), } ).extend(cv.COMPONENT_SCHEMA) +# Restoring globals: PollingComponent with configurable update_interval +_RESTORING_SCHEMA = cv.Schema( + { + **_BASE_SCHEMA, + cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean, + } +).extend(cv.polling_component_schema("1s")) + + +def _globals_schema(config: ConfigType) -> ConfigType: + """Select schema based on restore_value setting.""" + if config.get(CONF_RESTORE_VALUE, False): + return _RESTORING_SCHEMA(config) + return _NON_RESTORING_SCHEMA(config) + + +MULTI_CONF = True +CONFIG_SCHEMA = _globals_schema + # Run with low priority so that namespaces are registered first @coroutine_with_priority(CoroPriority.LATE) diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 1d2a08937e..3db29bea35 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -5,8 +5,7 @@ #include "esphome/core/helpers.h" #include -namespace esphome { -namespace globals { +namespace esphome::globals { template class GlobalsComponent : public Component { public: @@ -24,13 +23,14 @@ template class GlobalsComponent : public Component { T value_{}; }; -template class RestoringGlobalsComponent : public Component { +template class RestoringGlobalsComponent : public PollingComponent { public: using value_type = T; - explicit RestoringGlobalsComponent() = default; - explicit RestoringGlobalsComponent(T initial_value) : value_(initial_value) {} + explicit RestoringGlobalsComponent() : PollingComponent(1000) {} + explicit RestoringGlobalsComponent(T initial_value) : PollingComponent(1000), value_(initial_value) {} explicit RestoringGlobalsComponent( - std::array::type, std::extent::value> initial_value) { + std::array::type, std::extent::value> initial_value) + : PollingComponent(1000) { memcpy(this->value_, initial_value.data(), sizeof(T)); } @@ -44,7 +44,7 @@ template class RestoringGlobalsComponent : public Component { float get_setup_priority() const override { return setup_priority::HARDWARE; } - void loop() override { store_value_(); } + void update() override { store_value_(); } void on_shutdown() override { store_value_(); } @@ -66,13 +66,14 @@ template class RestoringGlobalsComponent : public Component { }; // Use with string or subclasses of strings -template class RestoringGlobalStringComponent : public Component { +template class RestoringGlobalStringComponent : public PollingComponent { public: using value_type = T; - explicit RestoringGlobalStringComponent() = default; - explicit RestoringGlobalStringComponent(T initial_value) { this->value_ = initial_value; } + explicit RestoringGlobalStringComponent() : PollingComponent(1000) {} + explicit RestoringGlobalStringComponent(T initial_value) : PollingComponent(1000) { this->value_ = initial_value; } explicit RestoringGlobalStringComponent( - std::array::type, std::extent::value> initial_value) { + std::array::type, std::extent::value> initial_value) + : PollingComponent(1000) { memcpy(this->value_, initial_value.data(), sizeof(T)); } @@ -90,7 +91,7 @@ template class RestoringGlobalStringComponent : public C float get_setup_priority() const override { return setup_priority::HARDWARE; } - void loop() override { store_value_(); } + void update() override { store_value_(); } void on_shutdown() override { store_value_(); } @@ -144,5 +145,4 @@ template T &id(GlobalsComponent *value) { return value->value(); template T &id(RestoringGlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalStringComponent *value) { return value->value(); } -} // namespace globals -} // namespace esphome +} // namespace esphome::globals diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 224a91a270..efa3cba076 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -10,6 +10,7 @@ globals: type: int restore_value: true initial_value: "0" + update_interval: 5s - id: glob_float type: float restore_value: true From 6d8294c2d32234f4fd300af2f963347dee25952a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 29 Jan 2026 07:42:55 +1100 Subject: [PATCH 08/21] [workflows] Refactor auto-label-pr script into modular JS (#13582) --- .github/scripts/auto-label-pr/constants.js | 36 ++ .github/scripts/auto-label-pr/detectors.js | 302 ++++++++++ .github/scripts/auto-label-pr/index.js | 179 ++++++ .github/scripts/auto-label-pr/labels.js | 41 ++ .github/scripts/auto-label-pr/reviews.js | 124 ++++ .github/workflows/auto-label-pr.yml | 632 +-------------------- 6 files changed, 684 insertions(+), 630 deletions(-) create mode 100644 .github/scripts/auto-label-pr/constants.js create mode 100644 .github/scripts/auto-label-pr/detectors.js create mode 100644 .github/scripts/auto-label-pr/index.js create mode 100644 .github/scripts/auto-label-pr/labels.js create mode 100644 .github/scripts/auto-label-pr/reviews.js diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js new file mode 100644 index 0000000000..d5e42fa1b9 --- /dev/null +++ b/.github/scripts/auto-label-pr/constants.js @@ -0,0 +1,36 @@ +// Constants and markers for PR auto-labeling +module.exports = { + BOT_COMMENT_MARKER: '', + CODEOWNERS_MARKER: '', + TOO_BIG_MARKER: '', + + MANAGED_LABELS: [ + 'new-component', + 'new-platform', + 'new-target-platform', + 'merging-to-release', + 'merging-to-beta', + 'chained-pr', + 'core', + 'small-pr', + 'dashboard', + 'github-actions', + 'by-code-owner', + 'has-tests', + 'needs-tests', + 'needs-docs', + 'needs-codeowners', + 'too-big', + 'labeller-recheck', + 'bugfix', + 'new-feature', + 'breaking-change', + 'developer-breaking-change', + 'code-quality', + ], + + DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, + /esphome\/esphome-docs#\d+/ + ] +}; diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js new file mode 100644 index 0000000000..79025988dd --- /dev/null +++ b/.github/scripts/auto-label-pr/detectors.js @@ -0,0 +1,302 @@ +const fs = require('fs'); +const { DOCS_PR_PATTERNS } = require('./constants'); + +// Strategy: Merge branch detection +async function detectMergeBranch(context) { + const labels = new Set(); + const baseRef = context.payload.pull_request.base.ref; + + if (baseRef === 'release') { + labels.add('merging-to-release'); + } else if (baseRef === 'beta') { + labels.add('merging-to-beta'); + } else if (baseRef !== 'dev') { + labels.add('chained-pr'); + } + + return labels; +} + +// Strategy: Component and platform labeling +async function detectComponentPlatforms(changedFiles, apiData) { + const labels = new Set(); + const componentRegex = /^esphome\/components\/([^\/]+)\//; + const targetPlatformRegex = new RegExp(`^esphome\/components\/(${apiData.targetPlatforms.join('|')})/`); + + for (const file of changedFiles) { + const componentMatch = file.match(componentRegex); + if (componentMatch) { + labels.add(`component: ${componentMatch[1]}`); + } + + const platformMatch = file.match(targetPlatformRegex); + if (platformMatch) { + labels.add(`platform: ${platformMatch[1]}`); + } + } + + return labels; +} + +// Strategy: New component detection +async function detectNewComponents(prFiles) { + const labels = new Set(); + const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); + + for (const file of addedFiles) { + const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); + if (componentMatch) { + try { + const content = fs.readFileSync(file, 'utf8'); + if (content.includes('IS_TARGET_PLATFORM = True')) { + labels.add('new-target-platform'); + } + } catch (error) { + console.log(`Failed to read content of ${file}:`, error.message); + } + labels.add('new-component'); + } + } + + return labels; +} + +// Strategy: New platform detection +async function detectNewPlatforms(prFiles, apiData) { + const labels = new Set(); + const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); + + for (const file of addedFiles) { + const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); + if (platformFileMatch) { + const [, component, platform] = platformFileMatch; + if (apiData.platformComponents.includes(platform)) { + labels.add('new-platform'); + } + } + + const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); + if (platformDirMatch) { + const [, component, platform] = platformDirMatch; + if (apiData.platformComponents.includes(platform)) { + labels.add('new-platform'); + } + } + } + + return labels; +} + +// Strategy: Core files detection +async function detectCoreChanges(changedFiles) { + const labels = new Set(); + const coreFiles = changedFiles.filter(file => + file.startsWith('esphome/core/') || + (file.startsWith('esphome/') && file.split('/').length === 2) + ); + + if (coreFiles.length > 0) { + labels.add('core'); + } + + return labels; +} + +// Strategy: PR size detection +async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD) { + const labels = new Set(); + + if (totalChanges <= SMALL_PR_THRESHOLD) { + labels.add('small-pr'); + return labels; + } + + const testAdditions = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.additions || 0), 0); + const testDeletions = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.deletions || 0), 0); + + const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); + + // Don't add too-big if mega-pr label is already present + if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { + labels.add('too-big'); + } + + return labels; +} + +// Strategy: Dashboard changes +async function detectDashboardChanges(changedFiles) { + const labels = new Set(); + const dashboardFiles = changedFiles.filter(file => + file.startsWith('esphome/dashboard/') || + file.startsWith('esphome/components/dashboard_import/') + ); + + if (dashboardFiles.length > 0) { + labels.add('dashboard'); + } + + return labels; +} + +// Strategy: GitHub Actions changes +async function detectGitHubActionsChanges(changedFiles) { + const labels = new Set(); + const githubActionsFiles = changedFiles.filter(file => + file.startsWith('.github/workflows/') + ); + + if (githubActionsFiles.length > 0) { + labels.add('github-actions'); + } + + return labels; +} + +// Strategy: Code owner detection +async function detectCodeOwner(github, context, changedFiles) { + const labels = new Set(); + const { owner, repo } = context.repo; + + try { + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner, + repo, + path: 'CODEOWNERS', + }); + + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); + const prAuthor = context.payload.pull_request.user.login; + + const codeownersLines = codeownersContent.split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + + const codeownersRegexes = codeownersLines.map(line => { + const parts = line.split(/\s+/); + const pattern = parts[0]; + const owners = parts.slice(1); + + let regex; + if (pattern.endsWith('*')) { + const dir = pattern.slice(0, -1); + regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); + } else if (pattern.includes('*')) { + // First escape all regex special chars except *, then replace * with .* + const regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + regex = new RegExp(`^${regexPattern}$`); + } else { + regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); + } + + return { regex, owners }; + }); + + for (const file of changedFiles) { + for (const { regex, owners } of codeownersRegexes) { + if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) { + labels.add('by-code-owner'); + return labels; + } + } + } + } catch (error) { + console.log('Failed to read or parse CODEOWNERS file:', error.message); + } + + return labels; +} + +// Strategy: Test detection +async function detectTests(changedFiles) { + const labels = new Set(); + const testFiles = changedFiles.filter(file => file.startsWith('tests/')); + + if (testFiles.length > 0) { + labels.add('has-tests'); + } + + return labels; +} + +// Strategy: PR Template Checkbox detection +async function detectPRTemplateCheckboxes(context) { + const labels = new Set(); + const prBody = context.payload.pull_request.body || ''; + + console.log('Checking PR template checkboxes...'); + + // Check for checked checkboxes in the "Types of changes" section + const checkboxPatterns = [ + { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, + { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, + { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, + { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, + { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } + ]; + + for (const { pattern, label } of checkboxPatterns) { + if (pattern.test(prBody)) { + console.log(`Found checked checkbox for: ${label}`); + labels.add(label); + } + } + + return labels; +} + +// Strategy: Requirements detection +async function detectRequirements(allLabels, prFiles, context) { + const labels = new Set(); + + // Check for missing tests + if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) { + labels.add('needs-tests'); + } + + // Check for missing docs + if (allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) { + const prBody = context.payload.pull_request.body || ''; + const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); + + if (!hasDocsLink) { + labels.add('needs-docs'); + } + } + + // Check for missing CODEOWNERS + if (allLabels.has('new-component')) { + const codeownersModified = prFiles.some(file => + file.filename === 'CODEOWNERS' && + (file.status === 'modified' || file.status === 'added') && + (file.additions || 0) > 0 + ); + + if (!codeownersModified) { + labels.add('needs-codeowners'); + } + } + + return labels; +} + +module.exports = { + detectMergeBranch, + detectComponentPlatforms, + detectNewComponents, + detectNewPlatforms, + detectCoreChanges, + detectPRSize, + detectDashboardChanges, + detectGitHubActionsChanges, + detectCodeOwner, + detectTests, + detectPRTemplateCheckboxes, + detectRequirements +}; diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js new file mode 100644 index 0000000000..95ecfc4e33 --- /dev/null +++ b/.github/scripts/auto-label-pr/index.js @@ -0,0 +1,179 @@ +const { MANAGED_LABELS } = require('./constants'); +const { + detectMergeBranch, + detectComponentPlatforms, + detectNewComponents, + detectNewPlatforms, + detectCoreChanges, + detectPRSize, + detectDashboardChanges, + detectGitHubActionsChanges, + detectCodeOwner, + detectTests, + detectPRTemplateCheckboxes, + detectRequirements +} = require('./detectors'); +const { handleReviews } = require('./reviews'); +const { applyLabels, removeOldLabels } = require('./labels'); + +// Fetch API data +async function fetchApiData() { + try { + const response = await fetch('https://data.esphome.io/components.json'); + const componentsData = await response.json(); + return { + targetPlatforms: componentsData.target_platforms || [], + platformComponents: componentsData.platform_components || [] + }; + } catch (error) { + console.log('Failed to fetch components data from API:', error.message); + return { targetPlatforms: [], platformComponents: [] }; + } +} + +module.exports = async ({ github, context }) => { + // Environment variables + const SMALL_PR_THRESHOLD = parseInt(process.env.SMALL_PR_THRESHOLD); + const MAX_LABELS = parseInt(process.env.MAX_LABELS); + const TOO_BIG_THRESHOLD = parseInt(process.env.TOO_BIG_THRESHOLD); + const COMPONENT_LABEL_THRESHOLD = parseInt(process.env.COMPONENT_LABEL_THRESHOLD); + + // Global state + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + + // Get current labels and PR data + const { data: currentLabelsData } = await github.rest.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: pr_number + }); + const currentLabels = currentLabelsData.map(label => label.name); + const managedLabels = currentLabels.filter(label => + label.startsWith('component: ') || MANAGED_LABELS.includes(label) + ); + + // Check for mega-PR early - if present, skip most automatic labeling + const isMegaPR = currentLabels.includes('mega-pr'); + + // Get all PR files with automatic pagination + const prFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: pr_number + } + ); + + // Calculate data from PR files + const changedFiles = prFiles.map(file => file.filename); + const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); + const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); + const totalChanges = totalAdditions + totalDeletions; + + console.log('Current labels:', currentLabels.join(', ')); + console.log('Changed files:', changedFiles.length); + console.log('Total changes:', totalChanges); + if (isMegaPR) { + console.log('Mega-PR detected - applying limited labeling logic'); + } + + // Fetch API data + const apiData = await fetchApiData(); + const baseRef = context.payload.pull_request.base.ref; + + // Early exit for release and beta branches only + if (baseRef === 'release' || baseRef === 'beta') { + const branchLabels = await detectMergeBranch(context); + const finalLabels = Array.from(branchLabels); + + console.log('Computed labels (merge branch only):', finalLabels.join(', ')); + + // Apply labels + await applyLabels(github, context, finalLabels); + + // Remove old managed labels + await removeOldLabels(github, context, managedLabels, finalLabels); + + return; + } + + // Run all strategies + const [ + branchLabels, + componentLabels, + newComponentLabels, + newPlatformLabels, + coreLabels, + sizeLabels, + dashboardLabels, + actionsLabels, + codeOwnerLabels, + testLabels, + checkboxLabels, + ] = await Promise.all([ + detectMergeBranch(context), + detectComponentPlatforms(changedFiles, apiData), + detectNewComponents(prFiles), + detectNewPlatforms(prFiles, apiData), + detectCoreChanges(changedFiles), + detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD), + detectDashboardChanges(changedFiles), + detectGitHubActionsChanges(changedFiles), + detectCodeOwner(github, context, changedFiles), + detectTests(changedFiles), + detectPRTemplateCheckboxes(context), + ]); + + // Combine all labels + const allLabels = new Set([ + ...branchLabels, + ...componentLabels, + ...newComponentLabels, + ...newPlatformLabels, + ...coreLabels, + ...sizeLabels, + ...dashboardLabels, + ...actionsLabels, + ...codeOwnerLabels, + ...testLabels, + ...checkboxLabels, + ]); + + // Detect requirements based on all other labels + const requirementLabels = await detectRequirements(allLabels, prFiles, context); + for (const label of requirementLabels) { + allLabels.add(label); + } + + let finalLabels = Array.from(allLabels); + + // For mega-PRs, exclude component labels if there are too many + if (isMegaPR) { + const componentLabels = finalLabels.filter(label => label.startsWith('component: ')); + if (componentLabels.length > COMPONENT_LABEL_THRESHOLD) { + finalLabels = finalLabels.filter(label => !label.startsWith('component: ')); + console.log(`Mega-PR detected - excluding ${componentLabels.length} component labels (threshold: ${COMPONENT_LABEL_THRESHOLD})`); + } + } + + // Handle too many labels (only for non-mega PRs) + const tooManyLabels = finalLabels.length > MAX_LABELS; + const originalLabelCount = finalLabels.length; + + if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) { + finalLabels = ['too-big']; + } + + console.log('Computed labels:', finalLabels.join(', ')); + + // Handle reviews + await handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); + + // Apply labels + await applyLabels(github, context, finalLabels); + + // Remove old managed labels + await removeOldLabels(github, context, managedLabels, finalLabels); +}; diff --git a/.github/scripts/auto-label-pr/labels.js b/.github/scripts/auto-label-pr/labels.js new file mode 100644 index 0000000000..2268f7ded9 --- /dev/null +++ b/.github/scripts/auto-label-pr/labels.js @@ -0,0 +1,41 @@ +// Apply labels to PR +async function applyLabels(github, context, finalLabels) { + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + + if (finalLabels.length > 0) { + console.log(`Adding labels: ${finalLabels.join(', ')}`); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr_number, + labels: finalLabels + }); + } +} + +// Remove old managed labels +async function removeOldLabels(github, context, managedLabels, finalLabels) { + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + + const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label)); + for (const label of labelsToRemove) { + console.log(`Removing label: ${label}`); + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr_number, + name: label + }); + } catch (error) { + console.log(`Failed to remove label ${label}:`, error.message); + } + } +} + +module.exports = { + applyLabels, + removeOldLabels +}; diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js new file mode 100644 index 0000000000..a84e9ae5aa --- /dev/null +++ b/.github/scripts/auto-label-pr/reviews.js @@ -0,0 +1,124 @@ +const { + BOT_COMMENT_MARKER, + CODEOWNERS_MARKER, + TOO_BIG_MARKER, +} = require('./constants'); + +// Generate review messages +function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) { + const messages = []; + + // Too big message + if (finalLabels.includes('too-big')) { + const testAdditions = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.additions || 0), 0); + const testDeletions = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.deletions || 0), 0); + const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); + + const tooManyLabels = originalLabelCount > MAX_LABELS; + const tooManyChanges = nonTestChanges > TOO_BIG_THRESHOLD; + + let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; + + if (tooManyLabels && tooManyChanges) { + message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; + } else if (tooManyLabels) { + message += `This PR affects ${originalLabelCount} different components/areas.`; + } else { + message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; + } + + message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`; + message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`; + + messages.push(message); + } + + // CODEOWNERS message + if (finalLabels.includes('needs-codeowners')) { + const message = `${CODEOWNERS_MARKER}\n### 👥 Code Ownership\n\n` + + `Hey there @${prAuthor},\n` + + `Thanks for submitting this pull request! Can you add yourself as a codeowner for this integration? ` + + `This way we can notify you if a bug report for this integration is reported.\n\n` + + `In \`__init__.py\` of the integration, please add:\n\n` + + `\`\`\`python\nCODEOWNERS = ["@${prAuthor}"]\n\`\`\`\n\n` + + `And run \`script/build_codeowners.py\``; + + messages.push(message); + } + + return messages; +} + +// Handle reviews +async function handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) { + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + const prAuthor = context.payload.pull_request.user.login; + + const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD); + const hasReviewableLabels = finalLabels.some(label => + ['too-big', 'needs-codeowners'].includes(label) + ); + + const { data: reviews } = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: pr_number + }); + + const botReviews = reviews.filter(review => + review.user.type === 'Bot' && + review.state === 'CHANGES_REQUESTED' && + review.body && review.body.includes(BOT_COMMENT_MARKER) + ); + + if (hasReviewableLabels) { + const reviewBody = `${BOT_COMMENT_MARKER}\n\n${reviewMessages.join('\n\n---\n\n')}`; + + if (botReviews.length > 0) { + // Update existing review + await github.rest.pulls.updateReview({ + owner, + repo, + pull_number: pr_number, + review_id: botReviews[0].id, + body: reviewBody + }); + console.log('Updated existing bot review'); + } else { + // Create new review + await github.rest.pulls.createReview({ + owner, + repo, + pull_number: pr_number, + body: reviewBody, + event: 'REQUEST_CHANGES' + }); + console.log('Created new bot review'); + } + } else if (botReviews.length > 0) { + // Dismiss existing reviews + for (const review of botReviews) { + try { + await github.rest.pulls.dismissReview({ + owner, + repo, + pull_number: pr_number, + review_id: review.id, + message: 'Review dismissed: All requirements have been met' + }); + console.log(`Dismissed bot review ${review.id}`); + } catch (error) { + console.log(`Failed to dismiss review ${review.id}:`, error.message); + } + } + } +} + +module.exports = { + handleReviews +}; diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index d32d8e01c2..6fcb50b70a 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -36,633 +36,5 @@ jobs: with: github-token: ${{ steps.generate-token.outputs.token }} script: | - const fs = require('fs'); - - // Constants - const SMALL_PR_THRESHOLD = parseInt('${{ env.SMALL_PR_THRESHOLD }}'); - const MAX_LABELS = parseInt('${{ env.MAX_LABELS }}'); - const TOO_BIG_THRESHOLD = parseInt('${{ env.TOO_BIG_THRESHOLD }}'); - const COMPONENT_LABEL_THRESHOLD = parseInt('${{ env.COMPONENT_LABEL_THRESHOLD }}'); - const BOT_COMMENT_MARKER = ''; - const CODEOWNERS_MARKER = ''; - const TOO_BIG_MARKER = ''; - - const MANAGED_LABELS = [ - 'new-component', - 'new-platform', - 'new-target-platform', - 'merging-to-release', - 'merging-to-beta', - 'chained-pr', - 'core', - 'small-pr', - 'dashboard', - 'github-actions', - 'by-code-owner', - 'has-tests', - 'needs-tests', - 'needs-docs', - 'needs-codeowners', - 'too-big', - 'labeller-recheck', - 'bugfix', - 'new-feature', - 'breaking-change', - 'developer-breaking-change', - 'code-quality' - ]; - - const DOCS_PR_PATTERNS = [ - /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, - /esphome\/esphome-docs#\d+/ - ]; - - // Global state - const { owner, repo } = context.repo; - const pr_number = context.issue.number; - - // Get current labels and PR data - const { data: currentLabelsData } = await github.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: pr_number - }); - const currentLabels = currentLabelsData.map(label => label.name); - const managedLabels = currentLabels.filter(label => - label.startsWith('component: ') || MANAGED_LABELS.includes(label) - ); - - // Check for mega-PR early - if present, skip most automatic labeling - const isMegaPR = currentLabels.includes('mega-pr'); - - // Get all PR files with automatic pagination - const prFiles = await github.paginate( - github.rest.pulls.listFiles, - { - owner, - repo, - pull_number: pr_number - } - ); - - // Calculate data from PR files - const changedFiles = prFiles.map(file => file.filename); - const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); - const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); - const totalChanges = totalAdditions + totalDeletions; - - console.log('Current labels:', currentLabels.join(', ')); - console.log('Changed files:', changedFiles.length); - console.log('Total changes:', totalChanges); - if (isMegaPR) { - console.log('Mega-PR detected - applying limited labeling logic'); - } - - // Fetch API data - async function fetchApiData() { - try { - const response = await fetch('https://data.esphome.io/components.json'); - const componentsData = await response.json(); - return { - targetPlatforms: componentsData.target_platforms || [], - platformComponents: componentsData.platform_components || [] - }; - } catch (error) { - console.log('Failed to fetch components data from API:', error.message); - return { targetPlatforms: [], platformComponents: [] }; - } - } - - // Strategy: Merge branch detection - async function detectMergeBranch() { - const labels = new Set(); - const baseRef = context.payload.pull_request.base.ref; - - if (baseRef === 'release') { - labels.add('merging-to-release'); - } else if (baseRef === 'beta') { - labels.add('merging-to-beta'); - } else if (baseRef !== 'dev') { - labels.add('chained-pr'); - } - - return labels; - } - - // Strategy: Component and platform labeling - async function detectComponentPlatforms(apiData) { - const labels = new Set(); - const componentRegex = /^esphome\/components\/([^\/]+)\//; - const targetPlatformRegex = new RegExp(`^esphome\/components\/(${apiData.targetPlatforms.join('|')})/`); - - for (const file of changedFiles) { - const componentMatch = file.match(componentRegex); - if (componentMatch) { - labels.add(`component: ${componentMatch[1]}`); - } - - const platformMatch = file.match(targetPlatformRegex); - if (platformMatch) { - labels.add(`platform: ${platformMatch[1]}`); - } - } - - return labels; - } - - // Strategy: New component detection - async function detectNewComponents() { - const labels = new Set(); - const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); - - for (const file of addedFiles) { - const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); - if (componentMatch) { - try { - const content = fs.readFileSync(file, 'utf8'); - if (content.includes('IS_TARGET_PLATFORM = True')) { - labels.add('new-target-platform'); - } - } catch (error) { - console.log(`Failed to read content of ${file}:`, error.message); - } - labels.add('new-component'); - } - } - - return labels; - } - - // Strategy: New platform detection - async function detectNewPlatforms(apiData) { - const labels = new Set(); - const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); - - for (const file of addedFiles) { - const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); - if (platformFileMatch) { - const [, component, platform] = platformFileMatch; - if (apiData.platformComponents.includes(platform)) { - labels.add('new-platform'); - } - } - - const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); - if (platformDirMatch) { - const [, component, platform] = platformDirMatch; - if (apiData.platformComponents.includes(platform)) { - labels.add('new-platform'); - } - } - } - - return labels; - } - - // Strategy: Core files detection - async function detectCoreChanges() { - const labels = new Set(); - const coreFiles = changedFiles.filter(file => - file.startsWith('esphome/core/') || - (file.startsWith('esphome/') && file.split('/').length === 2) - ); - - if (coreFiles.length > 0) { - labels.add('core'); - } - - return labels; - } - - // Strategy: PR size detection - async function detectPRSize() { - const labels = new Set(); - - if (totalChanges <= SMALL_PR_THRESHOLD) { - labels.add('small-pr'); - return labels; - } - - const testAdditions = prFiles - .filter(file => file.filename.startsWith('tests/')) - .reduce((sum, file) => sum + (file.additions || 0), 0); - const testDeletions = prFiles - .filter(file => file.filename.startsWith('tests/')) - .reduce((sum, file) => sum + (file.deletions || 0), 0); - - const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); - - // Don't add too-big if mega-pr label is already present - if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { - labels.add('too-big'); - } - - return labels; - } - - // Strategy: Dashboard changes - async function detectDashboardChanges() { - const labels = new Set(); - const dashboardFiles = changedFiles.filter(file => - file.startsWith('esphome/dashboard/') || - file.startsWith('esphome/components/dashboard_import/') - ); - - if (dashboardFiles.length > 0) { - labels.add('dashboard'); - } - - return labels; - } - - // Strategy: GitHub Actions changes - async function detectGitHubActionsChanges() { - const labels = new Set(); - const githubActionsFiles = changedFiles.filter(file => - file.startsWith('.github/workflows/') - ); - - if (githubActionsFiles.length > 0) { - labels.add('github-actions'); - } - - return labels; - } - - // Strategy: Code owner detection - async function detectCodeOwner() { - const labels = new Set(); - - try { - const { data: codeownersFile } = await github.rest.repos.getContent({ - owner, - repo, - path: 'CODEOWNERS', - }); - - const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); - const prAuthor = context.payload.pull_request.user.login; - - const codeownersLines = codeownersContent.split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); - - const codeownersRegexes = codeownersLines.map(line => { - const parts = line.split(/\s+/); - const pattern = parts[0]; - const owners = parts.slice(1); - - let regex; - if (pattern.endsWith('*')) { - const dir = pattern.slice(0, -1); - regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); - } else if (pattern.includes('*')) { - // First escape all regex special chars except *, then replace * with .* - const regexPattern = pattern - .replace(/[.+?^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*'); - regex = new RegExp(`^${regexPattern}$`); - } else { - regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); - } - - return { regex, owners }; - }); - - for (const file of changedFiles) { - for (const { regex, owners } of codeownersRegexes) { - if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) { - labels.add('by-code-owner'); - return labels; - } - } - } - } catch (error) { - console.log('Failed to read or parse CODEOWNERS file:', error.message); - } - - return labels; - } - - // Strategy: Test detection - async function detectTests() { - const labels = new Set(); - const testFiles = changedFiles.filter(file => file.startsWith('tests/')); - - if (testFiles.length > 0) { - labels.add('has-tests'); - } - - return labels; - } - - // Strategy: PR Template Checkbox detection - async function detectPRTemplateCheckboxes() { - const labels = new Set(); - const prBody = context.payload.pull_request.body || ''; - - console.log('Checking PR template checkboxes...'); - - // Check for checked checkboxes in the "Types of changes" section - const checkboxPatterns = [ - { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, - { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, - { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, - { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, - { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } - ]; - - for (const { pattern, label } of checkboxPatterns) { - if (pattern.test(prBody)) { - console.log(`Found checked checkbox for: ${label}`); - labels.add(label); - } - } - - return labels; - } - - // Strategy: Requirements detection - async function detectRequirements(allLabels) { - const labels = new Set(); - - // Check for missing tests - if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) { - labels.add('needs-tests'); - } - - // Check for missing docs - if (allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) { - const prBody = context.payload.pull_request.body || ''; - const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); - - if (!hasDocsLink) { - labels.add('needs-docs'); - } - } - - // Check for missing CODEOWNERS - if (allLabels.has('new-component')) { - const codeownersModified = prFiles.some(file => - file.filename === 'CODEOWNERS' && - (file.status === 'modified' || file.status === 'added') && - (file.additions || 0) > 0 - ); - - if (!codeownersModified) { - labels.add('needs-codeowners'); - } - } - - return labels; - } - - // Generate review messages - function generateReviewMessages(finalLabels, originalLabelCount) { - const messages = []; - const prAuthor = context.payload.pull_request.user.login; - - // Too big message - if (finalLabels.includes('too-big')) { - const testAdditions = prFiles - .filter(file => file.filename.startsWith('tests/')) - .reduce((sum, file) => sum + (file.additions || 0), 0); - const testDeletions = prFiles - .filter(file => file.filename.startsWith('tests/')) - .reduce((sum, file) => sum + (file.deletions || 0), 0); - const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); - - const tooManyLabels = originalLabelCount > MAX_LABELS; - const tooManyChanges = nonTestChanges > TOO_BIG_THRESHOLD; - - let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; - - if (tooManyLabels && tooManyChanges) { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; - } else if (tooManyLabels) { - message += `This PR affects ${originalLabelCount} different components/areas.`; - } else { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; - } - - message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`; - message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`; - - messages.push(message); - } - - // CODEOWNERS message - if (finalLabels.includes('needs-codeowners')) { - const message = `${CODEOWNERS_MARKER}\n### 👥 Code Ownership\n\n` + - `Hey there @${prAuthor},\n` + - `Thanks for submitting this pull request! Can you add yourself as a codeowner for this integration? ` + - `This way we can notify you if a bug report for this integration is reported.\n\n` + - `In \`__init__.py\` of the integration, please add:\n\n` + - `\`\`\`python\nCODEOWNERS = ["@${prAuthor}"]\n\`\`\`\n\n` + - `And run \`script/build_codeowners.py\``; - - messages.push(message); - } - - return messages; - } - - // Handle reviews - async function handleReviews(finalLabels, originalLabelCount) { - const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount); - const hasReviewableLabels = finalLabels.some(label => - ['too-big', 'needs-codeowners'].includes(label) - ); - - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, - repo, - pull_number: pr_number - }); - - const botReviews = reviews.filter(review => - review.user.type === 'Bot' && - review.state === 'CHANGES_REQUESTED' && - review.body && review.body.includes(BOT_COMMENT_MARKER) - ); - - if (hasReviewableLabels) { - const reviewBody = `${BOT_COMMENT_MARKER}\n\n${reviewMessages.join('\n\n---\n\n')}`; - - if (botReviews.length > 0) { - // Update existing review - await github.rest.pulls.updateReview({ - owner, - repo, - pull_number: pr_number, - review_id: botReviews[0].id, - body: reviewBody - }); - console.log('Updated existing bot review'); - } else { - // Create new review - await github.rest.pulls.createReview({ - owner, - repo, - pull_number: pr_number, - body: reviewBody, - event: 'REQUEST_CHANGES' - }); - console.log('Created new bot review'); - } - } else if (botReviews.length > 0) { - // Dismiss existing reviews - for (const review of botReviews) { - try { - await github.rest.pulls.dismissReview({ - owner, - repo, - pull_number: pr_number, - review_id: review.id, - message: 'Review dismissed: All requirements have been met' - }); - console.log(`Dismissed bot review ${review.id}`); - } catch (error) { - console.log(`Failed to dismiss review ${review.id}:`, error.message); - } - } - } - } - - // Main execution - const apiData = await fetchApiData(); - const baseRef = context.payload.pull_request.base.ref; - - // Early exit for release and beta branches only - if (baseRef === 'release' || baseRef === 'beta') { - const branchLabels = await detectMergeBranch(); - const finalLabels = Array.from(branchLabels); - - console.log('Computed labels (merge branch only):', finalLabels.join(', ')); - - // Apply labels - if (finalLabels.length > 0) { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pr_number, - labels: finalLabels - }); - } - - // Remove old managed labels - const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label)); - for (const label of labelsToRemove) { - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr_number, - name: label - }); - } catch (error) { - console.log(`Failed to remove label ${label}:`, error.message); - } - } - - return; - } - - // Run all strategies - const [ - branchLabels, - componentLabels, - newComponentLabels, - newPlatformLabels, - coreLabels, - sizeLabels, - dashboardLabels, - actionsLabels, - codeOwnerLabels, - testLabels, - checkboxLabels - ] = await Promise.all([ - detectMergeBranch(), - detectComponentPlatforms(apiData), - detectNewComponents(), - detectNewPlatforms(apiData), - detectCoreChanges(), - detectPRSize(), - detectDashboardChanges(), - detectGitHubActionsChanges(), - detectCodeOwner(), - detectTests(), - detectPRTemplateCheckboxes() - ]); - - // Combine all labels - const allLabels = new Set([ - ...branchLabels, - ...componentLabels, - ...newComponentLabels, - ...newPlatformLabels, - ...coreLabels, - ...sizeLabels, - ...dashboardLabels, - ...actionsLabels, - ...codeOwnerLabels, - ...testLabels, - ...checkboxLabels - ]); - - // Detect requirements based on all other labels - const requirementLabels = await detectRequirements(allLabels); - for (const label of requirementLabels) { - allLabels.add(label); - } - - let finalLabels = Array.from(allLabels); - - // For mega-PRs, exclude component labels if there are too many - if (isMegaPR) { - const componentLabels = finalLabels.filter(label => label.startsWith('component: ')); - if (componentLabels.length > COMPONENT_LABEL_THRESHOLD) { - finalLabels = finalLabels.filter(label => !label.startsWith('component: ')); - console.log(`Mega-PR detected - excluding ${componentLabels.length} component labels (threshold: ${COMPONENT_LABEL_THRESHOLD})`); - } - } - - // Handle too many labels (only for non-mega PRs) - const tooManyLabels = finalLabels.length > MAX_LABELS; - const originalLabelCount = finalLabels.length; - - if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) { - finalLabels = ['too-big']; - } - - console.log('Computed labels:', finalLabels.join(', ')); - - // Handle reviews - await handleReviews(finalLabels, originalLabelCount); - - // Apply labels - if (finalLabels.length > 0) { - console.log(`Adding labels: ${finalLabels.join(', ')}`); - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pr_number, - labels: finalLabels - }); - } - - // Remove old managed labels - const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label)); - for (const label of labelsToRemove) { - console.log(`Removing label: ${label}`); - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr_number, - name: label - }); - } catch (error) { - console.log(`Failed to remove label ${label}:`, error.message); - } - } + const script = require('./.github/scripts/auto-label-pr/index.js'); + await script({ github, context }); From 03cfd87b163637fa225ce8da990394248a23c333 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 29 Jan 2026 07:44:21 +1100 Subject: [PATCH 09/21] [waveshare_epaper] Add deprecation message (#13583) --- esphome/components/waveshare_epaper/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/waveshare_epaper/__init__.py b/esphome/components/waveshare_epaper/__init__.py index c58ce8a01e..b410406a58 100644 --- a/esphome/components/waveshare_epaper/__init__.py +++ b/esphome/components/waveshare_epaper/__init__.py @@ -1 +1,6 @@ CODEOWNERS = ["@clydebarrow"] + +DEPRECATED_COMPONENT = """ +The 'waveshare_epaper' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'epaper_spi' component. +""" From 52d0f1cc682e6a77afaa699c9b2a989f2b8d20a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 11:51:40 -1000 Subject: [PATCH 10/21] reduce esp32 compile times --- esphome/components/adc/sensor.py | 5 +- esphome/components/esp32/__init__.py | 79 +++++++++++++++++++ esphome/components/esp32/const.py | 1 + .../components/esp32_rmt_led_strip/light.py | 4 + esphome/components/esp32_touch/__init__.py | 4 + esphome/components/ethernet/__init__.py | 4 + esphome/components/http_request/__init__.py | 3 + esphome/components/i2s_audio/__init__.py | 11 ++- esphome/components/mqtt/__init__.py | 4 +- esphome/components/neopixelbus/light.py | 11 ++- .../components/remote_receiver/__init__.py | 3 + .../components/remote_transmitter/__init__.py | 3 + 12 files changed, 127 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 64dd22b0c3..ba87495e9d 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -2,7 +2,7 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import get_esp32_variant, include_idf_component from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, @@ -118,6 +118,9 @@ async def to_code(config): cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE])) if CORE.is_esp32: + # Re-enable ESP-IDF's ADC driver (excluded by default to save compile time) + include_idf_component("esp_adc") + if attenuation := config.get(CONF_ATTENUATION): if attenuation == "auto": cg.add(var.set_autorange(cg.global_ns.true)) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index fccf0ed09f..006bd45bce 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -53,6 +53,7 @@ from .const import ( # noqa KEY_BOARD, KEY_COMPONENTS, KEY_ESP32, + KEY_EXCLUDE_COMPONENTS, KEY_EXTRA_BUILD_FILES, KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, @@ -114,6 +115,41 @@ COMPILER_OPTIMIZATIONS = { "SIZE": "CONFIG_COMPILER_OPTIMIZATION_SIZE", } +# ESP-IDF components that ESPHome never uses. +# Excluding these reduces compile time - the linker would discard them anyway. +# +# NOTE: It is fine to remove components from this list when adding new features, +# but when you do, make sure the component is still excluded via exclude_idf_component() +# when the new feature is not enabled, to keep compile times short for users who +# don't use that feature. +# +# Cannot be excluded (dependencies of required components): +# - "console": espressif/mdns unconditionally depends on it +# - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain +NEVER_USED_IDF_COMPONENTS = ( + "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "esp_adc", # ADC driver - only needed by adc component + "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus + "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch + "esp_eth", # Ethernet driver - only needed by ethernet component + "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality + "esp_http_client", # HTTP client - only needed by http_request component + "esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation + "esp_https_server", # HTTPS server - ESPHome has its own web server + "esp_lcd", # LCD controller drivers - ESPHome has its own display components + "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API + "espcoredump", # Core dump support - ESPHome has its own debug component + "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage + "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation + "perfmon", # Xtensa performance monitor - ESPHome has its own debug component + "protocomm", # Protocol communication for provisioning - unused by ESPHome + "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "unity", # Unit testing framework - ESPHome doesn't use IDF's testing + "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused + "wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation +) + # ESP32 (original) chip revision options # Setting minimum revision to 3.0 or higher: # - Reduces flash size by excluding workaround code for older chip bugs @@ -203,6 +239,9 @@ def set_core_data(config): ) CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] = {} CORE.data[KEY_ESP32][KEY_COMPONENTS] = {} + # Initialize with default exclusions - components can call include_idf_component() + # to re-enable any they need + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = set(NEVER_USED_IDF_COMPONENTS) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) @@ -328,6 +367,28 @@ def add_idf_component( } +def exclude_idf_component(name: str) -> None: + """Exclude an ESP-IDF component from the build. + + This reduces compile time by skipping components that are not needed. + The component will be passed to ESP-IDF's EXCLUDE_COMPONENTS cmake variable. + + Note: Components that are dependencies of other required components + cannot be excluded - ESP-IDF will still build them. + """ + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].add(name) + + +def include_idf_component(name: str) -> None: + """Remove an ESP-IDF component from the exclusion list. + + Call this from components that need an ESP-IDF component that is + excluded by default in NEVER_USED_IDF_COMPONENTS. This ensures the + component will be built when needed. + """ + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) + + def add_extra_script(stage: str, filename: str, path: Path): """Add an extra script to the project.""" key = f"{stage}:{filename}" @@ -982,6 +1043,19 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +@coroutine_with_priority(CoroPriority.FINAL) +async def _write_exclude_components() -> None: + """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" + if KEY_ESP32 not in CORE.data: + return + excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS) + if excluded: + exclude_list = ";".join(sorted(excluded)) + cg.add_platformio_option( + "board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}" + ) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -1324,6 +1398,11 @@ async def to_code(config): if conf[CONF_COMPONENTS]: CORE.add_job(_add_yaml_idf_components, conf[CONF_COMPONENTS]) + # Write EXCLUDE_COMPONENTS at FINAL priority after all components have had + # a chance to call include_idf_component() to re-enable components they need. + # Default exclusions are added in set_core_data() during config validation. + CORE.add_job(_write_exclude_components) + APP_PARTITION_SIZES = { "2MB": 0x0C0000, # 768 KB diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 9f8165818b..db3eddebd5 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -6,6 +6,7 @@ KEY_FLASH_SIZE = "flash_size" KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" +KEY_EXCLUDE_COMPONENTS = "exclude_components" KEY_REPO = "repo" KEY_REF = "ref" KEY_REFRESH = "refresh" diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 3be3c758f1..4eed459bd1 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -5,6 +5,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import esp32, light from esphome.components.const import CONF_USE_PSRAM +from esphome.components.esp32 import include_idf_component import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -129,6 +130,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) + include_idf_component("esp_driver_rmt") + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index c54ed8b9ea..f36a1171b7 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -6,6 +6,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, get_esp32_variant, gpio, + include_idf_component, ) import esphome.config_validation as cv from esphome.const import ( @@ -266,6 +267,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + # Re-enable ESP-IDF's touch sensor driver (excluded by default to save compile time) + include_idf_component("esp_driver_touch_sens") + touch = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(touch, config) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 1f2fe61fe1..aaba12b6d1 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + include_idf_component, ) from esphome.components.network import ip_address_literal from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface @@ -419,6 +420,9 @@ async def to_code(config): # Also disable WiFi/BT coexistence since WiFi is disabled add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) + include_idf_component("esp_eth") + if config[CONF_TYPE] == "LAN8670": # Add LAN867x 10BASE-T1S PHY support component add_idf_component(name="espressif/lan867x", ref="2.0.0") diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 07bc758037..eeed2dd411 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -155,6 +155,9 @@ async def to_code(config): cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + esp32.include_idf_component("esp_http_client") + cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) cg.add(var.set_verify_ssl(config[CONF_VERIFY_SSL])) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index d3128c5f4c..df7c464364 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -1,6 +1,11 @@ from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + get_esp32_variant, + include_idf_component, +) +from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -10,8 +15,6 @@ from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - add_idf_sdkconfig_option, - get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE @@ -272,6 +275,10 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + + # Re-enable ESP-IDF's I2S driver (excluded by default to save compile time) + include_idf_component("esp_driver_i2s") + if use_legacy(): cg.add_define("USE_I2S_LEGACY") diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index f53df5564c..db49a7c6c3 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -4,7 +4,7 @@ from esphome import automation from esphome.automation import Condition import esphome.codegen as cg from esphome.components import logger, socket -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import add_idf_sdkconfig_option, include_idf_component from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -360,6 +360,8 @@ async def to_code(config): # This enables low-latency MQTT event processing instead of waiting for select() timeout if CORE.is_esp32: socket.require_wake_loop_threadsafe() + # Re-enable ESP-IDF's mqtt component (excluded by default to save compile time) + include_idf_component("mqtt") cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index c77217243c..7e3de1df15 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -1,7 +1,12 @@ from esphome import pins import esphome.codegen as cg from esphome.components import light -from esphome.components.esp32 import VARIANT_ESP32C3, VARIANT_ESP32S3, get_esp32_variant +from esphome.components.esp32 import ( + VARIANT_ESP32C3, + VARIANT_ESP32S3, + get_esp32_variant, + include_idf_component, +) import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -205,6 +210,10 @@ async def to_code(config): has_white = "W" in config[CONF_TYPE] method = config[CONF_METHOD] + # Re-enable ESP-IDF's RMT driver if using RMT method (excluded by default) + if CORE.is_esp32 and method[CONF_TYPE] == METHOD_ESP32_RMT: + include_idf_component("esp_driver_rmt") + method_template = METHODS[method[CONF_TYPE]].to_code( method, config[CONF_VARIANT], config[CONF_INVERT] ) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index f5d89f2f0f..7d0634714c 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -170,6 +170,9 @@ CONFIG_SCHEMA = remote_base.validate_triggers( async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32: + # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) + esp32.include_idf_component("esp_driver_rmt") + var = cg.new_Pvariable(config[CONF_ID], pin) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) cg.add(var.set_receive_symbols(config[CONF_RECEIVE_SYMBOLS])) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index f182a1ec0d..46f4155234 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -112,6 +112,9 @@ async def digital_write_action_to_code(config, action_id, template_arg, args): async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32: + # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) + esp32.include_idf_component("esp_driver_rmt") + var = cg.new_Pvariable(config[CONF_ID], pin) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) cg.add(var.set_non_blocking(config[CONF_NON_BLOCKING])) From fd564352c8468d6ec8cf8c134f96e12313694a7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 13:03:59 -1000 Subject: [PATCH 11/21] lcd --- esphome/components/display/__init__.py | 7 ++++++- esphome/components/esp32/__init__.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index ccbeedcd2f..b480b84f6c 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, SCHEDULER_DONT_RUN, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority IS_PLATFORM_COMPONENT = True @@ -222,3 +222,8 @@ async def display_is_displaying_page_to_code(config, condition_id, template_arg, async def to_code(config): cg.add_global(display_ns.using) cg.add_define("USE_DISPLAY") + if CORE.is_esp32: + # Re-enable ESP-IDF's LCD driver (excluded by default to save compile time) + from esphome.components.esp32 import include_idf_component + + include_idf_component("esp_lcd") diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 006bd45bce..dbe6173528 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -137,7 +137,7 @@ NEVER_USED_IDF_COMPONENTS = ( "esp_http_client", # HTTP client - only needed by http_request component "esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation "esp_https_server", # HTTPS server - ESPHome has its own web server - "esp_lcd", # LCD controller drivers - ESPHome has its own display components + "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage From cd96c1fe18ca8cad34038dd64dc6cca9da38f074 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 13:07:37 -1000 Subject: [PATCH 12/21] rename --- esphome/components/esp32/__init__.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index dbe6173528..1d54dc771a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -115,18 +115,13 @@ COMPILER_OPTIMIZATIONS = { "SIZE": "CONFIG_COMPILER_OPTIMIZATION_SIZE", } -# ESP-IDF components that ESPHome never uses. -# Excluding these reduces compile time - the linker would discard them anyway. -# -# NOTE: It is fine to remove components from this list when adding new features, -# but when you do, make sure the component is still excluded via exclude_idf_component() -# when the new feature is not enabled, to keep compile times short for users who -# don't use that feature. +# ESP-IDF components excluded by default to reduce compile time. +# Components can be re-enabled by calling include_idf_component() in to_code(). # # Cannot be excluded (dependencies of required components): # - "console": espressif/mdns unconditionally depends on it # - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain -NEVER_USED_IDF_COMPONENTS = ( +DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing "esp_adc", # ADC driver - only needed by adc component "esp_driver_i2s", # I2S driver - only needed by i2s_audio component @@ -241,7 +236,7 @@ def set_core_data(config): CORE.data[KEY_ESP32][KEY_COMPONENTS] = {} # Initialize with default exclusions - components can call include_idf_component() # to re-enable any they need - CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = set(NEVER_USED_IDF_COMPONENTS) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = set(DEFAULT_EXCLUDED_IDF_COMPONENTS) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) @@ -383,7 +378,7 @@ def include_idf_component(name: str) -> None: """Remove an ESP-IDF component from the exclusion list. Call this from components that need an ESP-IDF component that is - excluded by default in NEVER_USED_IDF_COMPONENTS. This ensures the + excluded by default in DEFAULT_EXCLUDED_IDF_COMPONENTS. This ensures the component will be built when needed. """ CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) From a382383d83d6d375e1b042dab2e13cde172f2b5a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:08:45 +1100 Subject: [PATCH 13/21] [workflows] Add deprecation check (#13584) --- .github/scripts/auto-label-pr/constants.js | 2 + .github/scripts/auto-label-pr/detectors.js | 71 ++++++++++++++++++++++ .github/scripts/auto-label-pr/index.js | 10 ++- .github/scripts/auto-label-pr/reviews.js | 25 ++++++-- 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index d5e42fa1b9..bd60d8c766 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -3,6 +3,7 @@ module.exports = { BOT_COMMENT_MARKER: '', CODEOWNERS_MARKER: '', TOO_BIG_MARKER: '', + DEPRECATED_COMPONENT_MARKER: '', MANAGED_LABELS: [ 'new-component', @@ -27,6 +28,7 @@ module.exports = { 'breaking-change', 'developer-breaking-change', 'code-quality', + 'deprecated-component' ], DOCS_PR_PATTERNS: [ diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 79025988dd..f502a85666 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -251,6 +251,76 @@ async function detectPRTemplateCheckboxes(context) { return labels; } +// Strategy: Deprecated component detection +async function detectDeprecatedComponents(github, context, changedFiles) { + const labels = new Set(); + const deprecatedInfo = []; + const { owner, repo } = context.repo; + + // Compile regex once for better performance + const componentFileRegex = /^esphome\/components\/([^\/]+)\//; + + // Get files that are modified or added in components directory + const componentFiles = changedFiles.filter(file => componentFileRegex.test(file)); + + if (componentFiles.length === 0) { + return { labels, deprecatedInfo }; + } + + // Extract unique component names using the same regex + const components = new Set(); + for (const file of componentFiles) { + const match = file.match(componentFileRegex); + if (match) { + components.add(match[1]); + } + } + + // Get PR head to fetch files from the PR branch + const prNumber = context.payload.pull_request.number; + + // Check each component's __init__.py for DEPRECATED_COMPONENT constant + for (const component of components) { + const initFile = `esphome/components/${component}/__init__.py`; + try { + // Fetch file content from PR head using GitHub API + const { data: fileData } = await github.rest.repos.getContent({ + owner, + repo, + path: initFile, + ref: `refs/pull/${prNumber}/head` + }); + + // Decode base64 content + const content = Buffer.from(fileData.content, 'base64').toString('utf8'); + + // Look for DEPRECATED_COMPONENT = "message" or DEPRECATED_COMPONENT = 'message' + // Support single quotes, double quotes, and triple quotes (for multiline) + const doubleQuoteMatch = content.match(/DEPRECATED_COMPONENT\s*=\s*"""([\s\S]*?)"""/s) || + content.match(/DEPRECATED_COMPONENT\s*=\s*"((?:[^"\\]|\\.)*)"/); + const singleQuoteMatch = content.match(/DEPRECATED_COMPONENT\s*=\s*'''([\s\S]*?)'''/s) || + content.match(/DEPRECATED_COMPONENT\s*=\s*'((?:[^'\\]|\\.)*)'/); + const deprecatedMatch = doubleQuoteMatch || singleQuoteMatch; + + if (deprecatedMatch) { + labels.add('deprecated-component'); + deprecatedInfo.push({ + component: component, + message: deprecatedMatch[1].trim() + }); + console.log(`Found deprecated component: ${component}`); + } + } catch (error) { + // Only log if it's not a simple "file not found" error (404) + if (error.status !== 404) { + console.log(`Error reading ${initFile}:`, error.message); + } + } + } + + return { labels, deprecatedInfo }; +} + // Strategy: Requirements detection async function detectRequirements(allLabels, prFiles, context) { const labels = new Set(); @@ -298,5 +368,6 @@ module.exports = { detectCodeOwner, detectTests, detectPRTemplateCheckboxes, + detectDeprecatedComponents, detectRequirements }; diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 95ecfc4e33..483d2cb626 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -11,6 +11,7 @@ const { detectCodeOwner, detectTests, detectPRTemplateCheckboxes, + detectDeprecatedComponents, detectRequirements } = require('./detectors'); const { handleReviews } = require('./reviews'); @@ -112,6 +113,7 @@ module.exports = async ({ github, context }) => { codeOwnerLabels, testLabels, checkboxLabels, + deprecatedResult ] = await Promise.all([ detectMergeBranch(context), detectComponentPlatforms(changedFiles, apiData), @@ -124,8 +126,13 @@ module.exports = async ({ github, context }) => { detectCodeOwner(github, context, changedFiles), detectTests(changedFiles), detectPRTemplateCheckboxes(context), + detectDeprecatedComponents(github, context, changedFiles) ]); + // Extract deprecated component info + const deprecatedLabels = deprecatedResult.labels; + const deprecatedInfo = deprecatedResult.deprecatedInfo; + // Combine all labels const allLabels = new Set([ ...branchLabels, @@ -139,6 +146,7 @@ module.exports = async ({ github, context }) => { ...codeOwnerLabels, ...testLabels, ...checkboxLabels, + ...deprecatedLabels ]); // Detect requirements based on all other labels @@ -169,7 +177,7 @@ module.exports = async ({ github, context }) => { console.log('Computed labels:', finalLabels.join(', ')); // Handle reviews - await handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); + await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); // Apply labels await applyLabels(github, context, finalLabels); diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index a84e9ae5aa..906e2c456a 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -2,12 +2,29 @@ const { BOT_COMMENT_MARKER, CODEOWNERS_MARKER, TOO_BIG_MARKER, + DEPRECATED_COMPONENT_MARKER } = require('./constants'); // Generate review messages -function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) { +function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) { const messages = []; + // Deprecated component message + if (finalLabels.includes('deprecated-component') && deprecatedInfo && deprecatedInfo.length > 0) { + let message = `${DEPRECATED_COMPONENT_MARKER}\n### ⚠️ Deprecated Component\n\n`; + message += `Hey there @${prAuthor},\n`; + message += `This PR modifies one or more deprecated components. Please be aware:\n\n`; + + for (const info of deprecatedInfo) { + message += `#### Component: \`${info.component}\`\n`; + message += `${info.message}\n\n`; + } + + message += `Consider migrating to the recommended alternative if applicable.`; + + messages.push(message); + } + // Too big message if (finalLabels.includes('too-big')) { const testAdditions = prFiles @@ -54,14 +71,14 @@ function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalA } // Handle reviews -async function handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) { +async function handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) { const { owner, repo } = context.repo; const pr_number = context.issue.number; const prAuthor = context.payload.pull_request.user.login; - const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD); + const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD); const hasReviewableLabels = finalLabels.some(label => - ['too-big', 'needs-codeowners'].includes(label) + ['too-big', 'needs-codeowners', 'deprecated-component'].includes(label) ); const { data: reviews } = await github.rest.pulls.listReviews({ From 183081cfbca6f0fe654bc80ccefb68a98e3eb7a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 13:56:45 -1000 Subject: [PATCH 14/21] some stragglers --- esphome/components/audio/__init__.py | 5 ++++- esphome/components/nextion/display.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 6c721652e1..066dcc312e 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component +from esphome.components.esp32 import add_idf_component, include_idf_component import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE import esphome.final_validate as fv @@ -166,6 +166,9 @@ def final_validate_audio_schema( async def to_code(config): + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + include_idf_component("esp_http_client") + add_idf_component( name="esphome/esp-audio-libs", ref="2.0.3", diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index ffc509fc64..99c8f91429 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -149,6 +149,10 @@ async def nextion_set_brightness_to_code(config, action_id, template_arg, args): async def to_code(config): + if CORE.is_esp32: + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + esp32.include_idf_component("esp_http_client") + var = cg.new_Pvariable(config[CONF_ID]) await uart.register_uart_device(var, config) From a5f60750c244cc00d2e38064e7fa4b5d930f1358 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 17:07:41 -1000 Subject: [PATCH 15/21] [tx20] Eliminate heap allocations in wind sensor (#13298) --- esphome/components/tx20/tx20.cpp | 46 +++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index fd7b5fb03f..a6df61c053 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -2,7 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include namespace esphome { namespace tx20 { @@ -45,25 +45,25 @@ std::string Tx20Component::get_wind_cardinal_direction() const { return this->wi void Tx20Component::decode_and_publish_() { ESP_LOGVV(TAG, "Decode Tx20"); - std::string string_buffer; - std::string string_buffer_2; - std::vector bit_buffer; + std::array bit_buffer{}; + size_t bit_pos = 0; bool current_bit = true; + // Cap at MAX_BUFFER_SIZE - 1 to prevent out-of-bounds access (buffer_index can exceed MAX_BUFFER_SIZE in ISR) + const int max_buffer_index = + std::min(static_cast(this->store_.buffer_index), static_cast(MAX_BUFFER_SIZE - 1)); - for (int i = 1; i <= this->store_.buffer_index; i++) { - string_buffer_2 += to_string(this->store_.buffer[i]) + ", "; + for (int i = 1; i <= max_buffer_index; i++) { uint8_t repeat = this->store_.buffer[i] / TX20_BIT_TIME; // ignore segments at the end that were too short - string_buffer.append(repeat, current_bit ? '1' : '0'); - bit_buffer.insert(bit_buffer.end(), repeat, current_bit); + for (uint8_t j = 0; j < repeat && bit_pos < MAX_BUFFER_SIZE; j++) { + bit_buffer[bit_pos++] = current_bit; + } current_bit = !current_bit; } current_bit = !current_bit; - if (string_buffer.length() < MAX_BUFFER_SIZE) { - uint8_t remain = MAX_BUFFER_SIZE - string_buffer.length(); - string_buffer_2 += to_string(remain) + ", "; - string_buffer.append(remain, current_bit ? '1' : '0'); - bit_buffer.insert(bit_buffer.end(), remain, current_bit); + size_t bits_before_padding = bit_pos; + while (bit_pos < MAX_BUFFER_SIZE) { + bit_buffer[bit_pos++] = current_bit; } uint8_t tx20_sa = 0; @@ -108,8 +108,24 @@ void Tx20Component::decode_and_publish_() { // 2. Check received checksum matches calculated checksum // 3. Check that Wind Direction matches Wind Direction (Inverted) // 4. Check that Wind Speed matches Wind Speed (Inverted) - ESP_LOGVV(TAG, "BUFFER %s", string_buffer_2.c_str()); - ESP_LOGVV(TAG, "Decoded bits %s", string_buffer.c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + // Build debug strings from completed data + char debug_buf[320]; // buffer values: max 40 entries * 7 chars each + size_t debug_pos = 0; + for (int i = 1; i <= max_buffer_index; i++) { + debug_pos = buf_append_printf(debug_buf, sizeof(debug_buf), debug_pos, "%u, ", this->store_.buffer[i]); + } + if (bits_before_padding < MAX_BUFFER_SIZE) { + buf_append_printf(debug_buf, sizeof(debug_buf), debug_pos, "%zu, ", MAX_BUFFER_SIZE - bits_before_padding); + } + char bits_buf[MAX_BUFFER_SIZE + 1]; + for (size_t i = 0; i < MAX_BUFFER_SIZE; i++) { + bits_buf[i] = bit_buffer[i] ? '1' : '0'; + } + bits_buf[MAX_BUFFER_SIZE] = '\0'; + ESP_LOGVV(TAG, "BUFFER %s", debug_buf); + ESP_LOGVV(TAG, "Decoded bits %s", bits_buf); +#endif if (tx20_sa == 4) { if (chk == tx20_sd) { From 06ae11e002d86cd3485b9af276c1191f0563e8cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 17:49:29 -1000 Subject: [PATCH 16/21] address bot comemnt --- esphome/components/nextion/display.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 99c8f91429..bf22f7dd5f 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -149,10 +149,6 @@ async def nextion_set_brightness_to_code(config, action_id, template_arg, args): async def to_code(config): - if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - esp32.include_idf_component("esp_http_client") - var = cg.new_Pvariable(config[CONF_ID]) await uart.register_uart_device(var, config) @@ -181,6 +177,8 @@ async def to_code(config): cg.add_define("USE_NEXTION_TFT_UPLOAD") cg.add(var.set_tft_url(config[CONF_TFT_URL])) if CORE.is_esp32: + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + esp32.include_idf_component("esp_http_client") esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True From a10cc04e3b039486e163b250002e9bfa5b42a276 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 17:52:00 -1000 Subject: [PATCH 17/21] add user escape hatch --- esphome/components/esp32/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1d54dc771a..38444da9e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -87,6 +87,7 @@ IS_TARGET_PLATFORM = True CONF_ASSERTION_LEVEL = "assertion_level" CONF_COMPILER_OPTIMIZATION = "compiler_optimization" CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" +CONF_INCLUDE_IDF_COMPONENTS = "include_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" @@ -849,6 +850,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, + cv.Optional(CONF_INCLUDE_IDF_COMPONENTS, default=[]): cv.ensure_list( + cv.string_strict + ), } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -1264,6 +1268,11 @@ async def to_code(config): # Apply LWIP optimization settings advanced = conf[CONF_ADVANCED] + + # Re-include any IDF components the user explicitly requested + for component_name in advanced.get(CONF_INCLUDE_IDF_COMPONENTS, []): + include_idf_component(component_name) + # DHCP server: only disable if explicitly set to false # WiFi component handles its own optimization when AP mode is not used # When using Arduino with Ethernet, DHCP server functions must be available From 084113926cbb1ac70bc4cb758b86798e760256bb Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 28 Jan 2026 22:03:50 -0600 Subject: [PATCH 18/21] [es8156] Add `bits_per_sample` validation, comment code (#13612) --- esphome/components/es8156/audio_dac.py | 28 ++++++++++++++++++- esphome/components/es8156/es8156.cpp | 37 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/esphome/components/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index b9d8eae6b0..a805fb3f70 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -2,11 +2,14 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID +import esphome.final_validate as fv CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] +CONF_AUDIO_DAC = "audio_dac" + es8156_ns = cg.esphome_ns.namespace("es8156") ES8156 = es8156_ns.class_("ES8156", AudioDac, cg.Component, i2c.I2CDevice) @@ -21,6 +24,29 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config): + full_config = fv.full_config.get() + + # Check all speaker configurations for ones that reference this es8156 + speaker_configs = full_config.get("speaker", []) + for speaker_config in speaker_configs: + audio_dac_id = speaker_config.get(CONF_AUDIO_DAC) + if ( + audio_dac_id is not None + and audio_dac_id == config[CONF_ID] + and (bits_per_sample := speaker_config.get(CONF_BITS_PER_SAMPLE)) + is not None + and bits_per_sample > 24 + ): + raise cv.Invalid( + f"ES8156 does not support more than 24 bits per sample. " + f"The speaker referencing this audio_dac has bits_per_sample set to {bits_per_sample}." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/es8156/es8156.cpp b/esphome/components/es8156/es8156.cpp index e84252efe2..961dc24b29 100644 --- a/esphome/components/es8156/es8156.cpp +++ b/esphome/components/es8156/es8156.cpp @@ -17,24 +17,61 @@ static const char *const TAG = "es8156"; } void ES8156::setup() { + // REG02 MODE CONFIG 1: Enable software mode for I2C control of volume/mute + // Bit 2: SOFT_MODE_SEL=1 (software mode enabled) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG02_SCLK_MODE, 0x04)); + + // Analog system configuration (active-low power down bits, active-high enables) + // REG20 ANALOG SYSTEM: Configure analog signal path ES8156_ERROR_FAILED(this->write_byte(ES8156_REG20_ANALOG_SYS1, 0x2A)); + + // REG21 ANALOG SYSTEM: VSEL=0x1C (bias level ~120%), normal VREF ramp speed ES8156_ERROR_FAILED(this->write_byte(ES8156_REG21_ANALOG_SYS2, 0x3C)); + + // REG22 ANALOG SYSTEM: Line out mode (HPSW=0), OUT_MUTE=0 (not muted) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG22_ANALOG_SYS3, 0x00)); + + // REG24 ANALOG SYSTEM: Low power mode for VREFBUF, HPCOM, DACVRP; DAC normal power + // Bits 2:0 = 0x07: LPVREFBUF=1, LPHPCOM=1, LPDACVRP=1, LPDAC=0 ES8156_ERROR_FAILED(this->write_byte(ES8156_REG24_ANALOG_LP, 0x07)); + + // REG23 ANALOG SYSTEM: Lowest bias (IBIAS_SW=0), VMIDLVL=VDDA/2, normal impedance ES8156_ERROR_FAILED(this->write_byte(ES8156_REG23_ANALOG_SYS4, 0x00)); + // Timing and interface configuration + // REG0A/0B TIME CONTROL: Fast state machine transitions ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0A_TIME_CONTROL1, 0x01)); ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0B_TIME_CONTROL2, 0x01)); + + // REG11 SDP INTERFACE CONFIG: Default I2S format (24-bit, I2S mode) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG11_DAC_SDP, 0x00)); + + // REG19 EQ CONTROL 1: EQ disabled (EQ_ON=0), EQ_BAND_NUM=2 ES8156_ERROR_FAILED(this->write_byte(ES8156_REG19_EQ_CONTROL1, 0x20)); + // REG0D P2S CONTROL: Parallel-to-serial converter settings ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0D_P2S_CONTROL, 0x14)); + + // REG09 MISC CONTROL 2: Default settings ES8156_ERROR_FAILED(this->write_byte(ES8156_REG09_MISC_CONTROL2, 0x00)); + + // REG18 MISC CONTROL 3: Stereo channel routing, no inversion + // Bits 5:4 CHN_CROSS: 0=L→L/R→R, 1=L to both, 2=R to both, 3=swap L/R + // Bits 3:2: LCH_INV/RCH_INV channel inversion ES8156_ERROR_FAILED(this->write_byte(ES8156_REG18_MISC_CONTROL3, 0x00)); + + // REG08 CLOCK OFF: Enable all internal clocks (0x3F = all clock gates open) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG08_CLOCK_ON_OFF, 0x3F)); + + // REG00 RESET CONTROL: Reset sequence + // First: RST_DIG=1 (assert digital reset) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG00_RESET, 0x02)); + // Then: CSM_ON=1 (enable chip state machine), RST_DIG=1 ES8156_ERROR_FAILED(this->write_byte(ES8156_REG00_RESET, 0x03)); + + // REG25 ANALOG SYSTEM: Power up analog blocks + // VMIDSEL=2 (normal VMID operation), PDN_ANA=0, ENREFR=0, ENHPCOM=0 + // PDN_DACVREFGEN=0, PDN_VREFBUF=0, PDN_DAC=0 (all enabled) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG25_ANALOG_SYS5, 0x20)); } From d91ebd21134931db50b33ebb3076624d540312d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 18:04:27 -1000 Subject: [PATCH 19/21] test --- tests/components/esp32/test.esp32-idf.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index d38cdfe2fd..eb7e79ec1a 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -8,6 +8,8 @@ esp32: enable_lwip_bridge_interface: true disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization use_full_certificate_bundle: false # Test CMN bundle (default) + include_idf_components: + - freertos # Test escape hatch (freertos is always included anyway) wifi: ssid: MySSID From 3e9a6c582ee9ba8b74568c1deed35c96ed1162a2 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 28 Jan 2026 23:16:59 -0500 Subject: [PATCH 20/21] [mdns] Do not broadcast registration when using openthread component (#13592) --- esphome/components/mdns/mdns_esp32.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 3123f3b604..3e997402bc 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,6 +12,10 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; static void register_esp32(MDNSComponent *comp, StaticVector &services) { +#ifdef USE_OPENTHREAD + // OpenThread handles service registration via SRP client + // Services are compiled by MDNSComponent::compile_records_() and consumed by OpenThreadSrpComponent +#else esp_err_t err = mdns_init(); if (err != ESP_OK) { ESP_LOGW(TAG, "Init failed: %s", esp_err_to_name(err)); @@ -41,13 +45,16 @@ static void register_esp32(MDNSComponent *comp, StaticVectorsetup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { +#ifndef USE_OPENTHREAD mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent +#endif } } // namespace esphome::mdns From 74c84c874721806603cd9a2c9f18968bbbe25ed1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jan 2026 18:20:39 -1000 Subject: [PATCH 21/21] [esp32] Add advanced sdkconfig options to reduce build time and binary size (#13611) --- esphome/components/esp32/__init__.py | 116 ++++++++++++++++++ tests/components/esp32/test.esp32-idf.yaml | 8 ++ tests/components/esp32/test.esp32-p4-idf.yaml | 8 ++ tests/components/esp32/test.esp32-s3-idf.yaml | 8 ++ 4 files changed, 140 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index fccf0ed09f..4e425792dc 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -672,11 +672,25 @@ CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram" CONF_HEAP_IN_IRAM = "heap_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle" +CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs" +CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" +CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" +CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" +CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" +CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" +CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" +CONF_DISABLE_FATFS = "disable_fatfs" # VFS requirement tracking # Components that need VFS features can call require_vfs_select() or require_vfs_dir() KEY_VFS_SELECT_REQUIRED = "vfs_select_required" KEY_VFS_DIR_REQUIRED = "vfs_dir_required" +# Feature requirement tracking - components can call require_* functions to re-enable +# These are stored in CORE.data[KEY_ESP32] dict +KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" +KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" +KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" +KEY_FATFS_REQUIRED = "fatfs_required" def require_vfs_select() -> None: @@ -709,6 +723,43 @@ def require_full_certificate_bundle() -> None: CORE.data[KEY_ESP32][KEY_FULL_CERT_BUNDLE] = True +def require_usb_serial_jtag_secondary() -> None: + """Mark that USB Serial/JTAG secondary console is required by a component. + + Call this from components (e.g., logger) that need USB Serial/JTAG console output. + This prevents CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG from being disabled. + """ + CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True + + +def require_mbedtls_peer_cert() -> None: + """Mark that mbedTLS peer certificate retention is required by a component. + + Call this from components that need access to the peer certificate after + the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE + from being disabled. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True + + +def require_mbedtls_pkcs7() -> None: + """Mark that mbedTLS PKCS#7 support is required by a component. + + Call this from components that need PKCS#7 certificate validation. + This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True + + +def require_fatfs() -> None: + """Mark that FATFS support is required by a component. + + Call this from components that use FATFS (e.g., SD card, storage components). + This prevents FATFS from being disabled when disable_fatfs is set. + """ + CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -793,6 +844,16 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, + cv.Optional(CONF_DISABLE_DEBUG_STUBS, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_OCD_AWARE, default=True): cv.boolean, + cv.Optional( + CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY, default=True + ): cv.boolean, + cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -1316,6 +1377,61 @@ async def to_code(config): add_idf_sdkconfig_option(f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True) + # Disable OpenOCD debug stubs to save code size + # These are used for on-chip debugging with OpenOCD/JTAG, rarely needed for ESPHome + if advanced[CONF_DISABLE_DEBUG_STUBS]: + add_idf_sdkconfig_option("CONFIG_ESP_DEBUG_STUBS_ENABLE", False) + + # Disable OCD-aware exception handlers + # When enabled, the panic handler detects JTAG debugger and halts instead of resetting + # Most ESPHome users don't use JTAG debugging + if advanced[CONF_DISABLE_OCD_AWARE]: + add_idf_sdkconfig_option("CONFIG_ESP_DEBUG_OCDAWARE", False) + + # Disable USB Serial/JTAG secondary console + # Components like logger can call require_usb_serial_jtag_secondary() to re-enable + if CORE.data[KEY_ESP32].get(KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED, False): + add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG", True) + elif advanced[CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY]: + add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_SECONDARY_NONE", True) + + # Disable /dev/null VFS initialization + # ESPHome doesn't typically need /dev/null + if advanced[CONF_DISABLE_DEV_NULL_VFS]: + add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False) + + # Disable keeping peer certificate after TLS handshake + # Saves ~4KB heap per connection, but prevents certificate inspection after handshake + # Components that need it can call require_mbedtls_peer_cert() + if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) + elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]: + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) + + # Disable PKCS#7 support in mbedTLS + # Only needed for specific certificate validation scenarios + # Components that need it can call require_mbedtls_pkcs7() + if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False): + # Component called require_mbedtls_pkcs7() - enable regardless of user setting + add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True) + elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]: + add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False) + + # Disable regi2c control functions in IRAM + # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled + if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: + add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False) + + # Disable FATFS support + # Components that need FATFS (SD card, etc.) can call require_fatfs() + if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): + # Component called require_fatfs() - enable regardless of user setting + add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False) + add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) + elif advanced[CONF_DISABLE_FATFS]: + add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) + add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0) + for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index d38cdfe2fd..d4ab6b4a87 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -8,6 +8,14 @@ esp32: enable_lwip_bridge_interface: true disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization use_full_certificate_bundle: false # Test CMN bundle (default) + disable_debug_stubs: true + disable_ocd_aware: true + disable_usb_serial_jtag_secondary: true + disable_dev_null_vfs: true + disable_mbedtls_peer_cert: true + disable_mbedtls_pkcs7: true + disable_regi2c_in_iram: true + disable_fatfs: true wifi: ssid: MySSID diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index 00a4ceec27..d67787b3d5 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -10,6 +10,14 @@ esp32: ref: 2.7.0 advanced: enable_idf_experimental_features: yes + disable_debug_stubs: true + disable_ocd_aware: true + disable_usb_serial_jtag_secondary: true + disable_dev_null_vfs: true + disable_mbedtls_peer_cert: true + disable_mbedtls_pkcs7: true + disable_regi2c_in_iram: true + disable_fatfs: true ota: platform: esphome diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index 4ae5e6b999..7a3bbe55b3 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -5,6 +5,14 @@ esp32: advanced: execute_from_psram: true disable_libc_locks_in_iram: true # Test default RAM optimization enabled + disable_debug_stubs: true + disable_ocd_aware: true + disable_usb_serial_jtag_secondary: true + disable_dev_null_vfs: true + disable_mbedtls_peer_cert: true + disable_mbedtls_pkcs7: true + disable_regi2c_in_iram: true + disable_fatfs: true psram: mode: octal