From 48a9c1cd67c56ff17ea095f98815e114e8473035 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 11:12:35 -0600 Subject: [PATCH 01/36] [mqtt] Remove broken ESP8266 ssl_fingerprints option (#14182) --- esphome/__main__.py | 14 --------- esphome/components/mqtt/__init__.py | 21 -------------- .../components/mqtt/mqtt_backend_esp8266.h | 5 ---- .../components/mqtt/mqtt_backend_libretiny.h | 5 ---- esphome/components/mqtt/mqtt_client.cpp | 7 ----- esphome/components/mqtt/mqtt_client.h | 15 ---------- esphome/const.py | 1 - esphome/mqtt.py | 29 ++----------------- 8 files changed, 2 insertions(+), 95 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c86b5604e1..488955f503 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -944,12 +944,6 @@ def command_clean_all(args: ArgsProtocol) -> int | None: return 0 -def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: - from esphome import mqtt - - return mqtt.get_fingerprint(config) - - def command_version(args: ArgsProtocol) -> int | None: safe_print(f"Version: {const.__version__}") return 0 @@ -1237,7 +1231,6 @@ POST_CONFIG_ACTIONS = { "run": command_run, "clean": command_clean, "clean-mqtt": command_clean_mqtt, - "mqtt-fingerprint": command_mqtt_fingerprint, "idedata": command_idedata, "rename": command_rename, "discover": command_discover, @@ -1451,13 +1444,6 @@ def parse_args(argv): ) parser_wizard.add_argument("configuration", help="Your YAML configuration file.") - parser_fingerprint = subparsers.add_parser( - "mqtt-fingerprint", help="Get the SSL fingerprint from a MQTT broker." - ) - parser_fingerprint.add_argument( - "configuration", help="Your YAML configuration file(s).", nargs="+" - ) - subparsers.add_parser("version", help="Print the ESPHome version and exit.") parser_clean = subparsers.add_parser( diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index fe153fedfa..44e8836487 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,5 +1,3 @@ -import re - from esphome import automation from esphome.automation import Condition import esphome.codegen as cg @@ -46,7 +44,6 @@ from esphome.const import ( CONF_RETAIN, CONF_SHUTDOWN_MESSAGE, CONF_SKIP_CERT_CN_CHECK, - CONF_SSL_FINGERPRINTS, CONF_STATE_TOPIC, CONF_SUBSCRIBE_QOS, CONF_TOPIC, @@ -221,13 +218,6 @@ def validate_config(value): return out -def validate_fingerprint(value): - value = cv.string(value) - if re.match(r"^[0-9a-f]{40}$", value) is None: - raise cv.Invalid("fingerprint must be valid SHA1 hash") - return value - - def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: """Register socket needs for MQTT component.""" # MQTT needs 1 socket for the broker connection @@ -291,9 +281,6 @@ CONFIG_SCHEMA = cv.All( ), validate_message_just_topic, ), - cv.Optional(CONF_SSL_FINGERPRINTS): cv.All( - cv.only_on_esp8266, cv.ensure_list(validate_fingerprint) - ), cv.Optional(CONF_KEEPALIVE, default="15s"): cv.positive_time_period_seconds, cv.Optional( CONF_REBOOT_TIMEOUT, default="15min" @@ -444,14 +431,6 @@ async def to_code(config): if CONF_LEVEL in log_topic: cg.add(var.set_log_level(logger.LOG_LEVELS[log_topic[CONF_LEVEL]])) - if CONF_SSL_FINGERPRINTS in config: - for fingerprint in config[CONF_SSL_FINGERPRINTS]: - arr = [ - cg.RawExpression(f"0x{fingerprint[i : i + 2]}") for i in range(0, 40, 2) - ] - cg.add(var.add_ssl_fingerprint(arr)) - cg.add_build_flag("-DASYNC_TCP_SSL_ENABLED=1") - cg.add(var.set_keep_alive(config[CONF_KEEPALIVE])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) diff --git a/esphome/components/mqtt/mqtt_backend_esp8266.h b/esphome/components/mqtt/mqtt_backend_esp8266.h index 470d1e6a8b..0bf5b510a4 100644 --- a/esphome/components/mqtt/mqtt_backend_esp8266.h +++ b/esphome/components/mqtt/mqtt_backend_esp8266.h @@ -21,11 +21,6 @@ class MQTTBackendESP8266 final : public MQTTBackend { } void set_server(network::IPAddress ip, uint16_t port) final { mqtt_client_.setServer(ip, port); } void set_server(const char *host, uint16_t port) final { mqtt_client_.setServer(host, port); } -#if ASYNC_TCP_SSL_ENABLED - void set_secure(bool secure) { mqtt_client.setSecure(secure); } - void add_server_fingerprint(const uint8_t *fingerprint) { mqtt_client.addServerFingerprint(fingerprint); } -#endif - void set_on_connect(std::function &&callback) final { this->mqtt_client_.onConnect(std::move(callback)); } diff --git a/esphome/components/mqtt/mqtt_backend_libretiny.h b/esphome/components/mqtt/mqtt_backend_libretiny.h index 24bf018a90..5fa3406193 100644 --- a/esphome/components/mqtt/mqtt_backend_libretiny.h +++ b/esphome/components/mqtt/mqtt_backend_libretiny.h @@ -21,11 +21,6 @@ class MQTTBackendLibreTiny final : public MQTTBackend { } void set_server(network::IPAddress ip, uint16_t port) final { mqtt_client_.setServer(IPAddress(ip), port); } void set_server(const char *host, uint16_t port) final { mqtt_client_.setServer(host, port); } -#if ASYNC_TCP_SSL_ENABLED - void set_secure(bool secure) { mqtt_client.setSecure(secure); } - void add_server_fingerprint(const uint8_t *fingerprint) { mqtt_client.addServerFingerprint(fingerprint); } -#endif - void set_on_connect(std::function &&callback) final { this->mqtt_client_.onConnect(std::move(callback)); } diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 90b423c386..2fb094f370 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -746,13 +746,6 @@ void MQTTClientComponent::set_on_disconnect(mqtt_on_disconnect_callback_t &&call this->on_disconnect_.add(std::move(callback_copy)); } -#if ASYNC_TCP_SSL_ENABLED -void MQTTClientComponent::add_ssl_fingerprint(const std::array &fingerprint) { - this->mqtt_backend_.setSecure(true); - this->mqtt_backend_.addServerFingerprint(fingerprint.data()); -} -#endif - MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // MQTTMessageTrigger diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 38bc0b4da3..7a51989a10 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -142,21 +142,6 @@ class MQTTClientComponent : public Component bool is_discovery_enabled() const; bool is_discovery_ip_enabled() const; -#if ASYNC_TCP_SSL_ENABLED - /** Add a SSL fingerprint to use for TCP SSL connections to the MQTT broker. - * - * To use this feature you first have to globally enable the `ASYNC_TCP_SSL_ENABLED` define flag. - * This function can be called multiple times and any certificate that matches any of the provided fingerprints - * will match. Calling this method will also automatically disable all non-ssl connections. - * - * @warning This is *not* secure and *not* how SSL is usually done. You'll have to add - * a separate fingerprint for every certificate you use. Additionally, the hashing - * algorithm used here due to the constraints of the MCU, SHA1, is known to be insecure. - * - * @param fingerprint The SSL fingerprint as a 20 value long std::array. - */ - void add_ssl_fingerprint(const std::array &fingerprint); -#endif #ifdef USE_ESP32 void set_ca_certificate(const char *cert) { this->mqtt_backend_.set_ca_certificate(cert); } void set_cl_certificate(const char *cert) { this->mqtt_backend_.set_cl_certificate(cert); } diff --git a/esphome/const.py b/esphome/const.py index 7d15964eab..ea8d2b73be 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -943,7 +943,6 @@ CONF_SPI = "spi" CONF_SPI_ID = "spi_id" CONF_SPIKE_REJECTION = "spike_rejection" CONF_SSID = "ssid" -CONF_SSL_FINGERPRINTS = "ssl_fingerprints" CONF_STARTUP_DELAY = "startup_delay" CONF_STATE = "state" CONF_STATE_CLASS = "state_class" diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 042df12d67..cbf78bd3f6 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -1,6 +1,5 @@ import contextlib from datetime import datetime -import hashlib import json import logging import ssl @@ -22,14 +21,12 @@ from esphome.const import ( CONF_PASSWORD, CONF_PORT, CONF_SKIP_CERT_CN_CHECK, - CONF_SSL_FINGERPRINTS, CONF_TOPIC, CONF_TOPIC_PREFIX, CONF_USERNAME, ) -from esphome.core import CORE, EsphomeError +from esphome.core import EsphomeError from esphome.helpers import get_int_env, get_str_env -from esphome.log import AnsiFore, color from esphome.types import ConfigType from esphome.util import safe_print @@ -102,9 +99,7 @@ def prepare( elif username: client.username_pw_set(username, password) - if config[CONF_MQTT].get(CONF_SSL_FINGERPRINTS) or config[CONF_MQTT].get( - CONF_CERTIFICATE_AUTHORITY - ): + if config[CONF_MQTT].get(CONF_CERTIFICATE_AUTHORITY): context = ssl.create_default_context( cadata=config[CONF_MQTT].get(CONF_CERTIFICATE_AUTHORITY) ) @@ -283,23 +278,3 @@ def clear_topic(config, topic, username=None, password=None, client_id=None): client.publish(msg.topic, None, retain=True) return initialize(config, [topic], on_message, None, username, password, client_id) - - -# From marvinroger/async-mqtt-client -> scripts/get-fingerprint/get-fingerprint.py -def get_fingerprint(config): - addr = str(config[CONF_MQTT][CONF_BROKER]), int(config[CONF_MQTT][CONF_PORT]) - _LOGGER.info("Getting fingerprint from %s:%s", addr[0], addr[1]) - try: - cert_pem = ssl.get_server_certificate(addr) - except OSError as err: - _LOGGER.error("Unable to connect to server: %s", err) - return 1 - cert_der = ssl.PEM_cert_to_DER_cert(cert_pem) - - sha1 = hashlib.sha1(cert_der).hexdigest() - - safe_print(f"SHA1 Fingerprint: {color(AnsiFore.CYAN, sha1)}") - safe_print( - f"Copy the string above into mqtt.ssl_fingerprints section of {CORE.config_path}" - ) - return 0 From b5c36140faf58ba8966ad127f5b14670c6a3d2bb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:40:43 -0500 Subject: [PATCH 02/36] [sprinkler] Fix millis overflow and underflow bugs (#14299) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/sprinkler/sprinkler.cpp | 81 +++++++++++----------- esphome/components/sprinkler/sprinkler.h | 4 +- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9e423c1760..d82d7baaf6 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -84,32 +84,30 @@ SprinklerValveOperator::SprinklerValveOperator(SprinklerValve *valve, Sprinkler : controller_(controller), valve_(valve) {} void SprinklerValveOperator::loop() { + // Use wrapping subtraction so 32-bit millis() rollover is handled correctly: + // (now - start) yields the true elapsed time even across the 49.7-day boundary. uint32_t now = App.get_loop_component_start_time(); - if (now >= this->start_millis_) { // dummy check - switch (this->state_) { - case STARTING: - if (now > (this->start_millis_ + this->start_delay_)) { - this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state - } - break; + switch (this->state_) { + case STARTING: + if ((now - *this->start_millis_) > this->start_delay_) { + this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state + } + break; - case ACTIVE: - if (now > (this->start_millis_ + this->start_delay_ + this->run_duration_)) { - this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down - } - break; + case ACTIVE: + if ((now - *this->start_millis_) > (this->start_delay_ + this->run_duration_)) { + this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down + } + break; - case STOPPING: - if (now > (this->stop_millis_ + this->stop_delay_)) { - this->kill_(); // stop_delay_has been exceeded, ensure all valves are off - } - break; + case STOPPING: + if ((now - *this->stop_millis_) > this->stop_delay_) { + this->kill_(); // stop_delay_has been exceeded, ensure all valves are off + } + break; - default: - break; - } - } else { // perhaps millis() rolled over...or something else is horribly wrong! - this->stop(); // bail out (TODO: handle this highly unlikely situation better...) + default: + break; } } @@ -124,11 +122,11 @@ void SprinklerValveOperator::set_valve(SprinklerValve *valve) { if (this->state_ != IDLE) { // Only kill if not already idle this->kill_(); // ensure everything is off before we let go! } - this->state_ = IDLE; // reset state - this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it - this->start_millis_ = 0; // reset because (new) valve has not been started yet - this->stop_millis_ = 0; // reset because (new) valve has not been started yet - this->valve_ = valve; // finally, set the pointer to the new valve + this->state_ = IDLE; // reset state + this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it + this->start_millis_.reset(); // reset because (new) valve has not been started yet + this->stop_millis_.reset(); // reset because (new) valve has not been started yet + this->valve_ = valve; // finally, set the pointer to the new valve } } @@ -162,7 +160,7 @@ void SprinklerValveOperator::start() { } else { this->run_(); // there is no start_delay_, so just start the pump and valve } - this->stop_millis_ = 0; + this->stop_millis_.reset(); this->start_millis_ = millis(); // save the time the start request was made } @@ -189,22 +187,25 @@ void SprinklerValveOperator::stop() { uint32_t SprinklerValveOperator::run_duration() { return this->run_duration_ / 1000; } uint32_t SprinklerValveOperator::time_remaining() { - if (this->start_millis_ == 0) { + if (!this->start_millis_.has_value()) { return this->run_duration(); // hasn't been started yet } - if (this->stop_millis_) { - if (this->stop_millis_ - this->start_millis_ >= this->start_delay_ + this->run_duration_) { + if (this->stop_millis_.has_value()) { + uint32_t elapsed = *this->stop_millis_ - *this->start_millis_; + if (elapsed >= this->start_delay_ + this->run_duration_) { return 0; // valve was active for more than its configured duration, so we are done - } else { - // we're stopped; return time remaining - return (this->run_duration_ - (this->stop_millis_ - this->start_millis_)) / 1000; } + if (elapsed <= this->start_delay_) { + return this->run_duration_ / 1000; // stopped during start delay, full run duration remains + } + return (this->run_duration_ - (elapsed - this->start_delay_)) / 1000; } - auto completed_millis = this->start_millis_ + this->start_delay_ + this->run_duration_; - if (completed_millis > millis()) { - return (completed_millis - millis()) / 1000; // running now + uint32_t elapsed = millis() - *this->start_millis_; + uint32_t total_duration = this->start_delay_ + this->run_duration_; + if (elapsed < total_duration) { + return (total_duration - elapsed) / 1000; // running now } return 0; // run completed } @@ -593,7 +594,7 @@ void Sprinkler::set_repeat(optional repeat) { if (this->repeat_number_ == nullptr) { return; } - if (this->repeat_number_->state == repeat.value()) { + if (this->repeat_number_->state == repeat.value_or(0)) { return; } auto call = this->repeat_number_->make_call(); @@ -793,7 +794,7 @@ void Sprinkler::start_single_valve(const optional valve_number, optional void Sprinkler::queue_valve(optional valve_number, optional run_duration) { if (valve_number.has_value()) { if (this->is_a_valid_valve(valve_number.value()) && (this->queued_valves_.size() < this->max_queue_size_)) { - SprinklerQueueItem item{valve_number.value(), run_duration.value()}; + SprinklerQueueItem item{valve_number.value(), run_duration.value_or(0)}; this->queued_valves_.insert(this->queued_valves_.begin(), item); ESP_LOGD(TAG, "Valve %zu placed into queue with run duration of %" PRIu32 " seconds", valve_number.value_or(0), run_duration.value_or(0)); @@ -1080,7 +1081,7 @@ uint32_t Sprinkler::total_cycle_time_enabled_incomplete_valves() { } } - if (incomplete_valve_count >= enabled_valve_count) { + if (incomplete_valve_count > 0 && incomplete_valve_count >= enabled_valve_count) { incomplete_valve_count--; } if (incomplete_valve_count) { diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index a3cdef5b1a..2598a5606a 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -141,8 +141,8 @@ class SprinklerValveOperator { uint32_t start_delay_{0}; uint32_t stop_delay_{0}; uint32_t run_duration_{0}; - uint64_t start_millis_{0}; - uint64_t stop_millis_{0}; + optional start_millis_{}; + optional stop_millis_{}; Sprinkler *controller_{nullptr}; SprinklerValve *valve_{nullptr}; SprinklerState state_{IDLE}; From 97b712da9866f9a1d7ef46a1ea5bf1184fdb41cb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:48:05 -0500 Subject: [PATCH 03/36] [cc1101] Transition through IDLE in begin_tx/begin_rx for reliable state changes (#14321) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/cc1101/cc1101.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index b6973da78d..51aa88b8f7 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -242,6 +242,9 @@ void CC1101Component::begin_tx() { if (this->gdo0_pin_ != nullptr) { this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); } + // Transition through IDLE to bypass CCA (Clear Channel Assessment) which can + // block TX entry when strobing from RX, and to ensure FS_AUTOCAL calibration + this->enter_idle_(); if (!this->enter_tx_()) { ESP_LOGW(TAG, "Failed to enter TX state!"); } @@ -252,6 +255,8 @@ void CC1101Component::begin_rx() { if (this->gdo0_pin_ != nullptr) { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); } + // Transition through IDLE to ensure FS_AUTOCAL calibration occurs + this->enter_idle_(); if (!this->enter_rx_()) { ESP_LOGW(TAG, "Failed to enter RX state!"); } From 840859ab7cf323bbaa5d1d508c20dfced0d6f3e8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:17:04 -0500 Subject: [PATCH 04/36] [zigbee] Fix codegen ordering for basic/identify attribute lists (#14343) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 3 ++- esphome/components/zigbee/zigbee_zephyr.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 7e917a9d70..a327cc2988 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -8,7 +8,7 @@ from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data from esphome.components.zephyr.const import KEY_BOOTLOADER import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERNAL, CONF_NAME -from esphome.core import CORE +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType from .const_zephyr import ( @@ -96,6 +96,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config: ConfigType) -> None: cg.add_define("USE_ZIGBEE") if CORE.using_zephyr: diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 0b6daa9476..a1e6ad3097 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -179,6 +179,13 @@ async def zephyr_to_code(config: ConfigType) -> None: "USE_ZIGBEE_WIPE_ON_BOOT_MAGIC", random.randint(0x000001, 0xFFFFFF) ) cg.add_define("USE_ZIGBEE_WIPE_ON_BOOT") + + # Generate attribute lists before any await that could yield (e.g., build_automation + # waiting for variables from other components). If the hub's priority decays while + # yielding, deferred entity jobs may add cluster list globals that reference these + # attribute lists before they're declared. + await _attr_to_code(config) + var = cg.new_Pvariable(config[CONF_ID]) if on_join_config := config.get(CONF_ON_JOIN): @@ -186,7 +193,6 @@ async def zephyr_to_code(config: ConfigType) -> None: await cg.register_component(var, config) - await _attr_to_code(config) CORE.add_job(_ctx_to_code, config) From 641914cdbe646747437f76b428991d028684222e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 17:27:51 -1000 Subject: [PATCH 05/36] [uart] Revert UART0 default pin workarounds (fixed in ESP-IDF 5.5.2) (#14363) --- .../uart/uart_component_esp_idf.cpp | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index ea7a09fee6..8699d37d7a 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -9,7 +9,6 @@ #include "esphome/core/gpio.h" #include "driver/gpio.h" #include "soc/gpio_num.h" -#include "soc/uart_pins.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -19,13 +18,6 @@ namespace esphome::uart { static const char *const TAG = "uart.idf"; -/// Check if a pin number matches one of the default UART0 GPIO pins. -/// These pins may have residual state from the boot console that requires -/// explicit reset before UART reconfiguration (ESP-IDF issue #17459). -static constexpr bool is_default_uart0_pin(int8_t pin_num) { - return pin_num == U0TXD_GPIO_NUM || pin_num == U0RXD_GPIO_NUM; -} - uart_config_t IDFUARTComponent::get_config_() { uart_parity_t parity = UART_PARITY_DISABLE; if (this->parity_ == UART_CONFIG_PARITY_EVEN) { @@ -149,34 +141,12 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; - int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; - int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; - - // Workaround for ESP-IDF issue: https://github.com/espressif/esp-idf/issues/17459 - // Commit 9ed617fb17 removed gpio_func_sel() calls from uart_set_pin(), which breaks - // UART on default UART0 pins that may have residual state from boot console. - // Reset these pins before configuring UART to ensure they're in a clean state. - if (is_default_uart0_pin(tx)) { - gpio_reset_pin(static_cast(tx)); - } - if (is_default_uart0_pin(rx)) { - gpio_reset_pin(static_cast(rx)); - } - - // Setup pins after reset to configure GPIO direction and pull resistors. - // For UART0 default pins, setup() must always be called because gpio_reset_pin() - // above sets GPIO_MODE_DISABLE which disables the input buffer. Without setup(), - // uart_set_pin() on ESP-IDF 5.4.2+ does not re-enable the input buffer for - // IOMUX-connected pins, so the RX pin cannot receive data (see issue #10132). - // For other pins, only call setup() if pull or open-drain flags are set to avoid - // disturbing the default pin state which breaks some external components (#11823). auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; } const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; - if (is_default_uart0_pin(pin->get_pin()) || (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { pin->setup(); } }; @@ -186,6 +156,10 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; + int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; + uint32_t invert = 0; if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { invert |= UART_SIGNAL_TXD_INV; From 91250fd46cc27ba1f36830b48ef3d6cb61c69970 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:37:04 +1100 Subject: [PATCH 06/36] [mipi_dsi] Fix Waveshare P4 7B board config (#14372) --- esphome/components/mipi_dsi/models/waveshare.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/mipi_dsi/models/waveshare.py b/esphome/components/mipi_dsi/models/waveshare.py index bf4f9063bb..61829ca9c1 100644 --- a/esphome/components/mipi_dsi/models/waveshare.py +++ b/esphome/components/mipi_dsi/models/waveshare.py @@ -90,8 +90,6 @@ DriverChip( (0xE9, 0xC8, 0x10, 0x0A, 0x00, 0x00, 0x80, 0x81, 0x12, 0x31, 0x23, 0x4F, 0x86, 0xA0, 0x00, 0x47, 0x08, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x98, 0x02, 0x8B, 0xAF, 0x46, 0x02, 0x88, 0x88, 0x88, 0x88, 0x88, 0x98, 0x13, 0x8B, 0xAF, 0x57, 0x13, 0x88, 0x88, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), (0xEA, 0x97, 0x0C, 0x09, 0x09, 0x09, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9F, 0x31, 0x8B, 0xA8, 0x31, 0x75, 0x88, 0x88, 0x88, 0x88, 0x88, 0x9F, 0x20, 0x8B, 0xA8, 0x20, 0x64, 0x88, 0x88, 0x88, 0x88, 0x88, 0x23, 0x00, 0x00, 0x02, 0x71, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x80, 0x81, 0x00, 0x00, 0x00, 0x00), (0xEF, 0xFF, 0xFF, 0x01), - (0x11, 0x00), - (0x29, 0x00), ], ) @@ -109,6 +107,7 @@ DriverChip( lane_bit_rate="900Mbps", no_transform=True, color_order="RGB", + reset_pin=33, initsequence=[ (0x80, 0x8B), (0x81, 0x78), From c9c99a22e0374d5d47a1fc131ce86384a5c038e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:10:53 -0500 Subject: [PATCH 07/36] [core] Defer entity automation codegen to prevent sibling ID deadlocks (#14381) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/binary_sensor/__init__.py | 36 +++++++++++-------- esphome/components/number/__init__.py | 31 +++++++++------- esphome/components/sensor/__init__.py | 37 +++++++++++--------- esphome/components/switch/__init__.py | 16 ++++++--- esphome/components/text_sensor/__init__.py | 19 ++++++---- 5 files changed, 83 insertions(+), 56 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index c38d6b78d3..036d78da73 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -550,21 +550,8 @@ def binary_sensor_schema( return _BINARY_SENSOR_SCHEMA.extend(schema) -async def setup_binary_sensor_core_(var, config): - await setup_entity(var, config, "binary_sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) - trigger = config.get(CONF_TRIGGER_ON_INITIAL_STATE, False) or config.get( - CONF_PUBLISH_INITIAL_STATE, False - ) - cg.add(var.set_trigger_on_initial_state(trigger)) - if inverted := config.get(CONF_INVERTED): - cg.add(var.set_inverted(inverted)) - if filters_config := config.get(CONF_FILTERS): - filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) - cg.add(var.add_filters(filters)) - +@coroutine_with_priority(CoroPriority.AUTOMATION) +async def _build_binary_sensor_automations(var, config): for conf in config.get(CONF_ON_PRESS, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) @@ -616,6 +603,25 @@ async def setup_binary_sensor_core_(var, config): conf, ) + +async def setup_binary_sensor_core_(var, config): + await setup_entity(var, config, "binary_sensor") + + if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: + cg.add(var.set_device_class(device_class)) + trigger = config.get(CONF_TRIGGER_ON_INITIAL_STATE, False) or config.get( + CONF_PUBLISH_INITIAL_STATE, False + ) + cg.add(var.set_trigger_on_initial_state(trigger)) + if inverted := config.get(CONF_INVERTED): + cg.add(var.set_inverted(inverted)) + if filters_config := config.get(CONF_FILTERS): + cg.add_define("USE_BINARY_SENSOR_FILTER") + filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) + cg.add(var.add_filters(filters)) + + CORE.add_job(_build_binary_sensor_automations, var, config) + if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index b23da7799f..d12ec7463b 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -240,6 +240,23 @@ def number_schema( return _NUMBER_SCHEMA.extend(schema) +@coroutine_with_priority(CoroPriority.AUTOMATION) +async def _build_number_automations(var, config): + for conf in config.get(CONF_ON_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(float, "x")], conf) + for conf in config.get(CONF_ON_VALUE_RANGE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await cg.register_component(trigger, conf) + if CONF_ABOVE in conf: + template_ = await cg.templatable(conf[CONF_ABOVE], [(float, "x")], float) + cg.add(trigger.set_min(template_)) + if CONF_BELOW in conf: + template_ = await cg.templatable(conf[CONF_BELOW], [(float, "x")], float) + cg.add(trigger.set_max(template_)) + await automation.build_automation(trigger, [(float, "x")], conf) + + async def setup_number_core_( var, config, *, min_value: float, max_value: float, step: float ): @@ -254,19 +271,7 @@ async def setup_number_core_( if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: cg.add(var.traits.set_mode(config[CONF_MODE])) - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) - for conf in config.get(CONF_ON_VALUE_RANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await cg.register_component(trigger, conf) - if CONF_ABOVE in conf: - template_ = await cg.templatable(conf[CONF_ABOVE], [(float, "x")], float) - cg.add(trigger.set_min(template_)) - if CONF_BELOW in conf: - template_ = await cg.templatable(conf[CONF_BELOW], [(float, "x")], float) - cg.add(trigger.set_max(template_)) - await automation.build_automation(trigger, [(float, "x")], conf) + CORE.add_job(_build_number_automations, var, config) if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None: cg.add(var.traits.set_unit_of_measurement(unit_of_measurement)) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 03784ba76b..1e5f16a81d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -888,6 +888,26 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +@coroutine_with_priority(CoroPriority.AUTOMATION) +async def _build_sensor_automations(var, config): + for conf in config.get(CONF_ON_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(float, "x")], conf) + for conf in config.get(CONF_ON_RAW_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(float, "x")], conf) + for conf in config.get(CONF_ON_VALUE_RANGE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await cg.register_component(trigger, conf) + if (above := conf.get(CONF_ABOVE)) is not None: + template_ = await cg.templatable(above, [(float, "x")], float) + cg.add(trigger.set_min(template_)) + if (below := conf.get(CONF_BELOW)) is not None: + template_ = await cg.templatable(below, [(float, "x")], float) + cg.add(trigger.set_max(template_)) + await automation.build_automation(trigger, [(float, "x")], conf) + + async def setup_sensor_core_(var, config): await setup_entity(var, config, "sensor") @@ -906,22 +926,7 @@ async def setup_sensor_core_(var, config): filters = await build_filters(config[CONF_FILTERS]) cg.add(var.set_filters(filters)) - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) - for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) - for conf in config.get(CONF_ON_VALUE_RANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await cg.register_component(trigger, conf) - if (above := conf.get(CONF_ABOVE)) is not None: - template_ = await cg.templatable(above, [(float, "x")], float) - cg.add(trigger.set_min(template_)) - if (below := conf.get(CONF_BELOW)) is not None: - template_ = await cg.templatable(below, [(float, "x")], float) - cg.add(trigger.set_max(template_)) - await automation.build_automation(trigger, [(float, "x")], conf) + CORE.add_job(_build_sensor_automations, var, config) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 7424d7c92f..cfc5e2b6e8 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -141,11 +141,8 @@ def switch_schema( return _SWITCH_SCHEMA.extend(schema) -async def setup_switch_core_(var, config): - await setup_entity(var, config, "switch") - - if (inverted := config.get(CONF_INVERTED)) is not None: - cg.add(var.set_inverted(inverted)) +@coroutine_with_priority(CoroPriority.AUTOMATION) +async def _build_switch_automations(var, config): for conf in config.get(CONF_ON_STATE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [(bool, "x")], conf) @@ -156,6 +153,15 @@ async def setup_switch_core_(var, config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) + +async def setup_switch_core_(var, config): + await setup_entity(var, config, "switch") + + if (inverted := config.get(CONF_INVERTED)) is not None: + cg.add(var.set_inverted(inverted)) + + CORE.add_job(_build_switch_automations, var, config) + if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 0d22400a8e..58c293e67b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -197,6 +197,17 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +@coroutine_with_priority(CoroPriority.AUTOMATION) +async def _build_text_sensor_automations(var, config): + for conf in config.get(CONF_ON_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + + for conf in config.get(CONF_ON_RAW_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + + async def setup_text_sensor_core_(var, config): await setup_entity(var, config, "text_sensor") @@ -207,13 +218,7 @@ async def setup_text_sensor_core_(var, config): filters = await build_filters(config[CONF_FILTERS]) cg.add(var.set_filters(filters)) - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) - - for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + CORE.add_job(_build_text_sensor_automations, var, config) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) From 0ac61cbb9bed23de4b97611a1b78e3e303267201 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Mar 2026 10:23:10 -1000 Subject: [PATCH 08/36] [improv_serial] Add missing USE_IMPROV_SERIAL define to fix WiFi scan filtering (#14359) --- esphome/components/improv_serial/__init__.py | 1 + esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 4 ++-- esphome/components/wifi/wifi_component_esp8266.cpp | 2 +- esphome/components/wifi/wifi_component_esp_idf.cpp | 2 +- esphome/components/wifi/wifi_component_libretiny.cpp | 2 +- esphome/components/wifi/wifi_component_pico_w.cpp | 9 +++++++-- esphome/core/defines.h | 1 + 8 files changed, 15 insertions(+), 8 deletions(-) diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 9a2ac2f40f..4266f5b78b 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -43,3 +43,4 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await improv_base.setup_improv_core(var, config, "improv_serial") + cg.add_define("USE_IMPROV_SERIAL") diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 61d05d7635..fbc1e946bb 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2048,7 +2048,7 @@ bool WiFiComponent::can_proceed() { #endif void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } -bool WiFiComponent::is_connected() { +bool WiFiComponent::is_connected() const { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ac28a1bc81..2e285289e7 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -430,7 +430,7 @@ class WiFiComponent : public Component { void set_reboot_timeout(uint32_t reboot_timeout); - bool is_connected(); + bool is_connected() const; void set_power_save_mode(WiFiPowerSaveMode power_save); void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; } @@ -665,7 +665,7 @@ class WiFiComponent : public Component { bool wifi_apply_hostname_(); bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); - WiFiSTAConnectStatus wifi_sta_connect_status_(); + WiFiSTAConnectStatus wifi_sta_connect_status_() const; bool wifi_scan_start_(bool passive); #ifdef USE_WIFI_AP diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index cbf7d7d80f..7fe090c45c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -626,7 +626,7 @@ void WiFiComponent::wifi_pre_setup_() { this->wifi_mode_(false, false); } -WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { +WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { station_status_t status = wifi_station_get_connect_status(); if (status == STATION_GOT_IP) return WiFiSTAConnectStatus::CONNECTED; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 52ee482121..f594c13afe 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -914,7 +914,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } } -WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { +WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { if (s_sta_connected && this->got_ipv4_address_) { #if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) if (this->num_ipv6_addresses_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT) { diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 2cc05928af..71cc419107 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -621,7 +621,7 @@ void WiFiComponent::wifi_pre_setup_() { // Make sure WiFi is in clean state before anything starts this->wifi_mode_(false, false); } -WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { +WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { // Use state machine instead of querying WiFi.status() directly // State is updated in main loop from queued events, ensuring thread safety switch (s_sta_state) { diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1baf21e2b2..7a93de5728 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -115,8 +115,13 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "UNKNOWN"; } -WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { - int status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); +WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { + // Use cyw43_wifi_link_status instead of cyw43_tcpip_link_status because the Arduino + // framework's __wrap_cyw43_cb_tcpip_init is a no-op — the SDK's internal netif + // (cyw43_state.netif[]) is never initialized. cyw43_tcpip_link_status checks that netif's + // flags and would only fall through to cyw43_wifi_link_status when the flags aren't set. + // Using cyw43_wifi_link_status directly gives us the actual WiFi radio join state. + int status = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA); switch (status) { case CYW43_LINK_JOIN: case CYW43_LINK_NOIP: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bfa33e4e59..e7d5caf7c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -52,6 +52,7 @@ #define USE_HOMEASSISTANT_TIME #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_IMAGE +#define USE_IMPROV_SERIAL #define USE_IMPROV_SERIAL_NEXT_URL #define USE_INFRARED #define USE_IR_RF From d2a819eb77c966dc2d4675c49a7023ffa40d80a1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:52:06 -0500 Subject: [PATCH 09/36] [uart] Fix flow_control_pin inverted flag ignored on ESP-IDF (#14410) Co-authored-by: Claude Opus 4.6 --- esphome/components/uart/uart_component_esp_idf.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 8699d37d7a..7e34f835cd 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -167,6 +167,9 @@ void IDFUARTComponent::load_settings(bool dump_config) { if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { invert |= UART_SIGNAL_RXD_INV; } + if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { + invert |= UART_SIGNAL_RTS_INV; + } err = uart_set_line_inverse(this->uart_num_, invert); if (err != ESP_OK) { From dc56cd1d1fc39c2ca0beb5bfb8b0e19b8e4ff4a1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:57:52 +1300 Subject: [PATCH 10/36] Bump version to 2026.2.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 2de0460ef1..5f351c1bbb 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.2.2 +PROJECT_NUMBER = 2026.2.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index ea8d2b73be..aaa34b2fd1 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.2.2" +__version__ = "2026.2.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From d1de50c0e513431943607f678fc3f661aa605dd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 11:11:04 -1000 Subject: [PATCH 11/36] [core] Add ESP8266 support to wake_loop_any_context() (#14392) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 2 +- esphome/components/socket/socket.h | 5 +++-- esphome/core/application.h | 12 +++++++++++- esphome/core/component.cpp | 9 +++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 430356592f..d697bd47a5 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -36,7 +36,7 @@ void socket_delay(uint32_t ms) { esp_delay(ms, []() { return !s_socket_woke; }); } -void socket_wake() { +void IRAM_ATTR socket_wake() { s_socket_woke = true; esp_schedule(); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 86a4f0cba9..546d278260 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -86,8 +86,9 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s /// On ESP8266, lwip callbacks set a flag and call esp_schedule() to wake the delay. void socket_delay(uint32_t ms); -/// Called by lwip callbacks to signal socket activity and wake delay. -void socket_wake(); +/// Signal socket/IO activity and wake the main loop from esp_delay() early. +/// ISR-safe: uses IRAM_ATTR internally and only sets a volatile flag + esp_schedule(). +void socket_wake(); // NOLINT(readability-redundant-declaration) #endif } // namespace esphome::socket diff --git a/esphome/core/application.h b/esphome/core/application.h index 13fd0180ab..63d59c555e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -34,7 +34,11 @@ #endif #endif #endif // USE_SOCKET_SELECT_SUPPORT - +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +namespace esphome::socket { +void socket_wake(); // NOLINT(readability-redundant-declaration) +} // namespace esphome::socket +#endif #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif @@ -530,6 +534,12 @@ class Application { #endif #endif +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) + /// Wake the main event loop from any context (ISR, thread, or main loop). + /// On ESP8266: sets the socket wake flag and calls esp_schedule() to exit esp_delay() early. + static void IRAM_ATTR wake_loop_any_context() { socket::socket_wake(); } +#endif + protected: friend Component; #ifdef USE_SOCKET_SELECT_SUPPORT diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index a71aa8b3a3..53cb50a44c 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -323,10 +323,11 @@ void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { // 8. Race condition with main loop is handled by clearing flag before processing this->pending_enable_loop_ = true; App.has_pending_enable_loop_requests_ = true; -#if defined(USE_LWIP_FAST_SELECT) && defined(USE_ESP32) - // Wake the main loop if sleeping in ulTaskNotifyTake(). Without this, - // the main loop would not wake until the select timeout expires (~16ms). - // Uses xPortInIsrContext() to choose the correct FreeRTOS notify API. +#if (defined(USE_LWIP_FAST_SELECT) && defined(USE_ESP32)) || (defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)) + // Wake the main loop from sleep. Without this, the main loop would not + // wake until the select/delay timeout expires (~16ms). + // ESP32: uses xPortInIsrContext() to choose the correct FreeRTOS notify API. + // ESP8266: sets socket wake flag and calls esp_schedule() to exit esp_delay() early. Application::wake_loop_any_context(); #endif } From 3615a7b90c13f2bb100d5ea3241795aec5c7512b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 11:42:25 -1000 Subject: [PATCH 12/36] [core] Eliminate __udivdi3 in millis() on ESP32 and RP2040 (#14409) --- esphome/components/esp32/core.cpp | 4 +- esphome/components/rp2040/core.cpp | 4 +- esphome/core/helpers.h | 38 ++++++++++++ .../fixtures/micros_to_millis.yaml | 61 +++++++++++++++++++ tests/integration/test_micros_to_millis.py | 46 ++++++++++++++ 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/micros_to_millis.yaml create mode 100644 tests/integration/test_micros_to_millis.py diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 59b791da40..7ebbba609e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -22,8 +22,8 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { void HOT yield() { vPortYield(); } -uint32_t IRAM_ATTR HOT millis() { return (uint32_t) (esp_timer_get_time() / 1000ULL); } -uint64_t HOT millis_64() { return static_cast(esp_timer_get_time()) / 1000ULL; } +uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } +uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 6386d53292..a15ee7e263 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -11,8 +11,8 @@ namespace esphome { void HOT yield() { ::yield(); } -uint64_t millis_64() { return time_us_64() / 1000ULL; } -uint32_t HOT millis() { return static_cast(millis_64()); } +uint64_t millis_64() { return micros_to_millis(time_us_64()); } +uint32_t HOT millis() { return micros_to_millis(time_us_64()); } void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t HOT micros() { return ::micros(); } void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c68cb549bb..ae505a2d8a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -599,6 +599,44 @@ template constexpr uint32_t fnv1a_hash_extend(uint32_t hash, T constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } +/// Convert a 64-bit microsecond count to milliseconds without calling +/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). +/// +/// Returns uint32_t by default (for millis()), or uint64_t when requested +/// (for millis_64()). The only difference is whether hi * Q is truncated +/// to 32 bits or widened to 64. +/// +/// On 32-bit targets, GCC does not optimize 64-bit constant division into a +/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 +/// (free divide-by-8), then use the Euclidean division identity to decompose +/// the remaining 64-bit divide-by-125 into a single 32-bit division: +/// +/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] +/// 2^32 = Q * 125 + R (34359738 * 125 + 46) +/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 +/// +/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal +/// (mulhu + shift), so no division instruction is emitted. +/// +/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). +/// +/// See: https://en.wikipedia.org/wiki/Euclidean_division +/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html +template inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) { + constexpr uint32_t d = 125U; + constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 + constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 + // 1000 = 8 * 125; divide-by-8 is a free shift + uint64_t x = us >> 3; + uint32_t lo = static_cast(x); + uint32_t hi = static_cast(x >> 32); + // Combine remainder term: hi * (2^32 % 125) + lo + uint32_t adj = hi * r + lo; + // If adj overflowed, the true value is 2^32 + adj; apply the identity again + // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q + return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); +} + /// Return a random 32-bit unsigned integer. uint32_t random_uint32(); /// Return a random float between 0 and 1. diff --git a/tests/integration/fixtures/micros_to_millis.yaml b/tests/integration/fixtures/micros_to_millis.yaml new file mode 100644 index 0000000000..d11808c43a --- /dev/null +++ b/tests/integration/fixtures/micros_to_millis.yaml @@ -0,0 +1,61 @@ +esphome: + name: micros-to-millis-test + platformio_options: + build_flags: + - "-DDEBUG" + on_boot: + - lambda: |- + using esphome::micros_to_millis; + const char *TAG = "MTM"; + int pass = 0, fail = 0; + + auto check = [&](const char *name, uint64_t us) { + uint32_t got = micros_to_millis(us); + uint32_t want = (uint32_t)(us / 1000ULL); + if (got == want) { pass++; } + else { ESP_LOGE(TAG, "%s FAILED: got=%u want=%u", name, got, want); fail++; } + }; + + // Basic values + check("zero", 0); + check("below_1ms", 999); + check("exactly_1ms", 1000); + check("above_1ms", 1001); + + // Shift boundary (1000 = 8 * 125, exercises the >>3 shift) + check("shift_7999", 7999); + check("shift_8000", 8000); + check("shift_8001", 8001); + + // 32-bit boundary + check("u32max_minus1", 0xFFFFFFFEULL); + check("u32max", 0xFFFFFFFFULL); + check("u32max_plus1", 0x100000000ULL); + + // Realistic uptimes + check("30_days", 2592000000000ULL); + check("1_year", 31536000000000ULL); + + // Carry path: construct x = us>>3 with specific hi/lo that trigger adj overflow + { uint64_t x = (603ULL << 32) | 0xFFFFFFFFU; check("carry_603", x << 3); } + { uint64_t x = (5000ULL << 32) | 0xFFFFFFFFU; check("carry_5000", x << 3); } + + // Carry boundary: exact transition where adj overflows (hi=1000, R=46) + { + uint32_t hi = 1000; + uint32_t thr = 0xFFFFFFFFU - hi * 46U; + uint64_t h = (uint64_t)hi << 32; + check("carry_before", (h | (thr - 1)) << 3); + check("carry_at", (h | thr) << 3); + check("carry_after", (h | (thr + 1)) << 3); + } + + // Mod-8 variations (exercises the >>3 truncation) + for (int i = 0; i < 8; i++) { check("mod8", 2592000000000ULL + i); } + + if (fail == 0) { ESP_LOGI(TAG, "ALL_PASSED %d tests", pass); } + else { ESP_LOGE(TAG, "%d FAILED out of %d", fail, pass + fail); } + +host: +api: +logger: diff --git a/tests/integration/test_micros_to_millis.py b/tests/integration/test_micros_to_millis.py new file mode 100644 index 0000000000..9960d6b017 --- /dev/null +++ b/tests/integration/test_micros_to_millis.py @@ -0,0 +1,46 @@ +"""Integration test for micros_to_millis Euclidean decomposition.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_micros_to_millis( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that micros_to_millis matches reference uint64 division.""" + + all_passed = asyncio.Event() + failures: list[str] = [] + + def on_log_line(line: str) -> None: + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + if "ALL_PASSED" in clean_line: + all_passed.set() + elif "FAILED" in clean_line and "[MTM" in clean_line: + failures.append(clean_line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "micros-to-millis-test" + + try: + await asyncio.wait_for(all_passed.wait(), timeout=2.0) + except TimeoutError: + if failures: + pytest.fail(f"micros_to_millis failures: {failures}") + pytest.fail("micros_to_millis test timed out") + + assert not failures, f"micros_to_millis failures: {failures}" From 5510b45f3bfa45e3204de607e0a3815b42623606 Mon Sep 17 00:00:00 2001 From: Lino Schmidt <72667500+LinoSchmidt@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:43:06 +0100 Subject: [PATCH 13/36] [const] Move CONF_WATCHDOG (#14415) --- esphome/components/as5600/__init__.py | 2 +- esphome/components/as5600/sensor/__init__.py | 1 - esphome/const.py | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index acb1c4d9db..b141329e94 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_POWER_MODE, CONF_RANGE, + CONF_WATCHDOG, ) CODEOWNERS = ["@ammmze"] @@ -57,7 +58,6 @@ FAST_FILTER = { CONF_RAW_ANGLE = "raw_angle" CONF_RAW_POSITION = "raw_position" -CONF_WATCHDOG = "watchdog" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" CONF_START_POSITION = "start_position" diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 1491852e07..e84733a484 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -23,7 +23,6 @@ AS5600Sensor = as5600_ns.class_("AS5600Sensor", sensor.Sensor, cg.PollingCompone CONF_RAW_ANGLE = "raw_angle" CONF_RAW_POSITION = "raw_position" -CONF_WATCHDOG = "watchdog" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" CONF_PWM_FREQUENCY = "pwm_frequency" diff --git a/esphome/const.py b/esphome/const.py index 7262a106d8..bbd85ca66b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1094,6 +1094,7 @@ CONF_WAND_ID = "wand_id" CONF_WARM_WHITE = "warm_white" CONF_WARM_WHITE_COLOR_TEMPERATURE = "warm_white_color_temperature" CONF_WARMUP_TIME = "warmup_time" +CONF_WATCHDOG = "watchdog" CONF_WATCHDOG_THRESHOLD = "watchdog_threshold" CONF_WATCHDOG_TIMEOUT = "watchdog_timeout" CONF_WATER_HEATER = "water_heater" From 727fa073777cb758dd580fd9ea31a664fc58133f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:44:53 -1000 Subject: [PATCH 14/36] Bump github/codeql-action from 4.32.4 to 4.32.5 (#14416) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5d7c32eaa9..4bd018b5c9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 with: category: "/language:${{matrix.language}}" From 2e623fd6c3f83870ddeac768b855cbf3ba51e517 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 11:48:50 -1000 Subject: [PATCH 15/36] [tests] Fix flaky log assertion race in oversized payload tests (#14414) --- tests/integration/test_oversized_payloads.py | 69 ++++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/tests/integration/test_oversized_payloads.py b/tests/integration/test_oversized_payloads.py index 8bf890261a..be488347aa 100644 --- a/tests/integration/test_oversized_payloads.py +++ b/tests/integration/test_oversized_payloads.py @@ -17,10 +17,10 @@ async def test_oversized_payload_plaintext( ) -> None: """Test that oversized payloads (>32768 bytes) from client cause disconnection without crashing.""" process_exited = False - helper_log_found = False + helper_log_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, helper_log_found + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -30,7 +30,7 @@ async def test_oversized_payload_plaintext( and "Bad packet: message size" in line and "exceeds maximum" in line ): - helper_log_found = True + helper_log_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect() as (client, disconnect_event): @@ -54,10 +54,13 @@ async def test_oversized_payload_plaintext( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected HELPER_LOG message - assert helper_log_found, ( - "Expected to see HELPER_LOG about message size exceeding maximum" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see HELPER_LOG about message size exceeding maximum" + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect() as (client2, _): @@ -77,10 +80,10 @@ async def test_oversized_protobuf_message_id_plaintext( This tests the message type limit - message IDs must fit in a uint16_t (0-65535). """ process_exited = False - helper_log_found = False + helper_log_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, helper_log_found + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -90,7 +93,7 @@ async def test_oversized_protobuf_message_id_plaintext( and "Bad packet: message type" in line and "exceeds maximum" in line ): - helper_log_found = True + helper_log_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect() as (client, disconnect_event): @@ -114,10 +117,13 @@ async def test_oversized_protobuf_message_id_plaintext( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected HELPER_LOG message - assert helper_log_found, ( - "Expected to see HELPER_LOG about message type exceeding maximum" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see HELPER_LOG about message type exceeding maximum" + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect() as (client2, _): @@ -135,10 +141,10 @@ async def test_oversized_payload_noise( """Test that oversized payloads from client cause disconnection without crashing with noise encryption.""" noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" process_exited = False - helper_log_found = False + helper_log_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, helper_log_found + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -149,7 +155,7 @@ async def test_oversized_payload_noise( and "Bad packet: message size" in line and "exceeds maximum" in line ): - helper_log_found = True + helper_log_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -177,10 +183,13 @@ async def test_oversized_payload_noise( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected HELPER_LOG message - assert helper_log_found, ( - "Expected to see HELPER_LOG about message size exceeding maximum" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see HELPER_LOG about message size exceeding maximum" + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -274,10 +283,10 @@ async def test_noise_corrupt_encrypted_frame( """ noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" process_exited = False - cipherstate_failed = False + cipherstate_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, cipherstate_failed + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -290,7 +299,7 @@ async def test_noise_corrupt_encrypted_frame( "[W][api.connection" in line and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line ): - cipherstate_failed = True + cipherstate_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -326,10 +335,14 @@ async def test_noise_corrupt_encrypted_frame( assert not process_exited, ( "ESPHome process should not crash on corrupt encrypted frames" ) - # Verify we saw the expected log message about decryption failure - assert cipherstate_failed, ( - "Expected to see log about noise_cipherstate_decrypt failure or CIPHERSTATE_DECRYPT_FAILED" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(cipherstate_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see log about noise_cipherstate_decrypt failure" + " or CIPHERSTATE_DECRYPT_FAILED" + ) # Verify we can still reconnect after handling the corrupt frame async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( From 7a87348855aa7d49dc77926868717e0343bdb47a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:49:14 -0500 Subject: [PATCH 16/36] [ci] Skip PR title check for dependabot PRs (#14418) Co-authored-by: Claude Opus 4.6 --- .github/workflows/pr-title-check.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index f23c2c870e..198b9a6b25 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -26,14 +26,19 @@ jobs: } = require('./.github/scripts/detect-tags.js'); const title = context.payload.pull_request.title; + const author = context.payload.pull_request.user.login; + + // Skip bot PRs (e.g. dependabot) - they have their own title format + if (author === 'dependabot[bot]') { + return; + } // Block titles starting with "word:" or "word(scope):" patterns const commitStylePattern = /^\w+(\(.*?\))?[!]?\s*:/; if (commitStylePattern.test(title)) { core.setFailed( `PR title should not start with a "prefix:" style format.\n` + - `Please use the format: [component] Brief description\n` + - `Example: [pn532] Add health checking and auto-reset` + `Please use the format: [component] Brief description\n` ); return; } From 97d713ee6477c003a8241b0452c1bcf2d6557289 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 2 Mar 2026 19:16:38 -0600 Subject: [PATCH 17/36] [media_source] Add new Media Source platform component (#14417) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/media_source/__init__.py | 40 +++++ .../components/media_source/media_source.h | 159 ++++++++++++++++++ esphome/core/defines.h | 1 + 4 files changed, 201 insertions(+) create mode 100644 esphome/components/media_source/__init__.py create mode 100644 esphome/components/media_source/media_source.h diff --git a/CODEOWNERS b/CODEOWNERS index 4c97b7f99d..21bee125c6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -316,6 +316,7 @@ esphome/components/mcp9808/* @k7hpn esphome/components/md5/* @esphome/core esphome/components/mdns/* @esphome/core esphome/components/media_player/* @jesserockz +esphome/components/media_source/* @kahrendt esphome/components/micro_wake_word/* @jesserockz @kahrendt esphome/components/micronova/* @edenhaus @jorre05 esphome/components/microphone/* @jesserockz @kahrendt diff --git a/esphome/components/media_source/__init__.py b/esphome/components/media_source/__init__.py new file mode 100644 index 0000000000..43256db4af --- /dev/null +++ b/esphome/components/media_source/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE +from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObjClass + +CODEOWNERS = ["@kahrendt"] + +AUTO_LOAD = ["audio"] + +IS_PLATFORM_COMPONENT = True + +media_source_ns = cg.esphome_ns.namespace("media_source") + +MediaSource = media_source_ns.class_("MediaSource") + + +async def register_media_source(var, config): + if not CORE.has_id(config[CONF_ID]): + var = cg.Pvariable(config[CONF_ID], var) + CORE.register_platform_component("media_source", var) + return var + + +_MEDIA_SOURCE_SCHEMA = cv.Schema({}) + + +def media_source_schema( + class_: MockObjClass, +) -> cv.Schema: + schema = {cv.GenerateID(CONF_ID): cv.declare_id(class_)} + + return _MEDIA_SOURCE_SCHEMA.extend(schema) + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config): + cg.add_global(media_source_ns.using) + cg.add_define("USE_MEDIA_SOURCE") diff --git a/esphome/components/media_source/media_source.h b/esphome/components/media_source/media_source.h new file mode 100644 index 0000000000..688c27134f --- /dev/null +++ b/esphome/components/media_source/media_source.h @@ -0,0 +1,159 @@ +#pragma once + +#include "esphome/components/audio/audio.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::media_source { + +enum class MediaSourceState : uint8_t { + IDLE, // Not playing, ready to accept play_uri + PLAYING, // Currently playing media + PAUSED, // Playback paused, can be resumed + ERROR, // Error occurred during playback; sources are responsible for logging their own error details +}; + +/// @brief Commands that are sent from the orchestrator to a media source +enum class MediaSourceCommand : uint8_t { + // All sources should support these basic commands. + PLAY, + PAUSE, + STOP, + + // Only sources with internal playlists will handle these; simple sources should ignore them. + NEXT, + PREVIOUS, + CLEAR_PLAYLIST, + REPEAT_ALL, + REPEAT_ONE, + REPEAT_OFF, + SHUFFLE, + UNSHUFFLE, +}; + +/// @brief Callbacks from a MediaSource to its orchestrator +class MediaSourceListener { + public: + virtual ~MediaSourceListener() = default; + + // Callbacks that all sources use to send data and state changes to the orchestrator. + /// @brief Send audio data to the listener + virtual size_t write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) = 0; + /// @brief Notify listener of state changes + virtual void report_state(MediaSourceState state) = 0; + + // Callbacks from smart sources requesting the orchestrator to change volume, mute, or start a new URI. + // Simple sources never invoke these. + /// @brief Request the orchestrator to change volume + virtual void request_volume(float volume) {} + /// @brief Request the orchestrator to change mute state + virtual void request_mute(bool is_muted) {} + /// @brief Request the orchestrator to play a new URI + virtual void request_play_uri(const std::string &uri) {} +}; + +/// @brief Abstract base class for media sources +/// MediaSource provides audio data to an orchestrator via the MediaSourceListener interface. It also receives commands +/// from the orchestrator to control playback. +class MediaSource { + public: + virtual ~MediaSource() = default; + + // === Playback Control === + + /// @brief Start playing the given URI + /// Sources should validate the URI and state, returning false if the source is busy. + /// The orchestrator is responsible for stopping active sources before starting a new one. + /// @param uri URI to play; e.g., "http://stream_url" + /// @return true if playback started successfully, false otherwise + virtual bool play_uri(const std::string &uri) = 0; + + /// @brief Handle playback commands (pause, stop, next, etc.) + /// @param command Command to execute + virtual void handle_command(MediaSourceCommand command) = 0; + + /// @brief Whether this source manages its own playlist internally + /// Smart sources that handle next/previous/repeat/shuffle should override this to return true. + virtual bool has_internal_playlist() const { return false; } + + // === State Access === + + /// @brief Get current playback state (must only be called from the main loop) + /// @return Current state of this source + MediaSourceState get_state() const { return this->state_; } + + // === URI Matching === + + /// @brief Check if this source can handle the given URI + /// Each source must override this to match its supported URI scheme(s). + /// @param uri URI to check + /// @return true if this source can handle the URI + virtual bool can_handle(const std::string &uri) const = 0; + + // === Listener: Source -> Orchestrator === + + /// @brief Set the listener that receives callbacks from this source + /// @param listener Pointer to the MediaSourceListener implementation + void set_listener(MediaSourceListener *listener) { this->listener_ = listener; } + + /// @brief Check if a listener has been registered + bool has_listener() const { return this->listener_ != nullptr; } + + /// @brief Write audio data to the listener + /// @param data Pointer to audio data buffer (not modified by this method) + /// @param length Number of bytes to write + /// @param timeout_ms Milliseconds to wait if the listener can't accept data immediately + /// @param stream_info Audio stream format information + /// @return Number of bytes written, or 0 if no listener is set + size_t write_output(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) { + if (this->listener_ != nullptr) { + return this->listener_->write_audio(data, length, timeout_ms, stream_info); + } + return 0; + } + + // === Callbacks: Orchestrator -> Source === + + /// @brief Notify the source that volume changed + /// Simple sources ignore this. Override for smart sources that track volume state. + /// @param volume New volume level (0.0 to 1.0) + virtual void notify_volume_changed(float volume) {} + + /// @brief Notify the source that mute state changed + /// Simple sources ignore this. Override for smart sources that track mute state. + /// @param is_muted New mute state + virtual void notify_mute_changed(bool is_muted) {} + + /// @brief Notify the source about audio that has been played + /// Called when the speaker reports that audio frames have been written to the DAC. + /// Sources can override this to track playback progress for synchronization. + /// @param frames Number of audio frames that were played + /// @param timestamp System time in microseconds when the frames finished writing to the DAC + virtual void notify_audio_played(uint32_t frames, int64_t timestamp) {} + + protected: + /// @brief Update state and notify listener (must only be called from the main loop) + /// This is the only way to change state_, ensuring listener notifications always fire. + /// Sources running FreeRTOS tasks should signal via event groups and call this from loop(). + /// @param state New state to set + void set_state_(MediaSourceState state) { + if (this->state_ != state) { + this->state_ = state; + if (this->listener_ != nullptr) { + this->listener_->report_state(state); + } + } + } + + private: + // Private to enforce the invariant that listener notifications always fire on state changes. + // All state transitions must go through set_state_() which couples the update with notification. + MediaSourceState state_{MediaSourceState::IDLE}; + MediaSourceListener *listener_{nullptr}; +}; + +} // namespace esphome::media_source diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8c78afa7d4..7fbc5a0b53 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -106,6 +106,7 @@ #define MDNS_DYNAMIC_TXT_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER +#define USE_MEDIA_SOURCE #define USE_NEXTION_TFT_UPLOAD #define USE_NUMBER #define USE_OUTPUT From c77241940b701e8a8c5709374340c80b75e7e7c6 Mon Sep 17 00:00:00 2001 From: melak Date: Tue, 3 Mar 2026 02:24:00 +0100 Subject: [PATCH 18/36] [lps22] Add support for the LPS22DF variant (#14397) --- esphome/components/lps22/lps22.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 7fc5774b08..592b7faaf0 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -8,6 +8,7 @@ static constexpr const char *const TAG = "lps22"; static constexpr uint8_t WHO_AM_I = 0x0F; static constexpr uint8_t LPS22HB_ID = 0xB1; static constexpr uint8_t LPS22HH_ID = 0xB3; +static constexpr uint8_t LPS22DF_ID = 0xB4; static constexpr uint8_t CTRL_REG2 = 0x11; static constexpr uint8_t CTRL_REG2_ONE_SHOT_MASK = 0b1; static constexpr uint8_t STATUS = 0x27; @@ -24,8 +25,8 @@ static constexpr float TEMPERATURE_SCALE = 0.01f; void LPS22Component::setup() { uint8_t value = 0x00; this->read_register(WHO_AM_I, &value, 1); - if (value != LPS22HB_ID && value != LPS22HH_ID) { - ESP_LOGW(TAG, "device IDs as %02x, which isn't a known LPS22HB or LPS22HH ID", value); + if (value != LPS22HB_ID && value != LPS22HH_ID && value != LPS22DF_ID) { + ESP_LOGW(TAG, "device IDs as %02x, which isn't a known LPS22HB/HH/DF ID", value); this->mark_failed(); } } From ae49b673218a9c5857c6cc8670a8fa4185d6c8ee Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Mon, 2 Mar 2026 18:47:40 -0700 Subject: [PATCH 19/36] [ld2450] Clear all related sensors when a target is not being tracked (#13602) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/ld2450/ld2450.cpp | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 583918e5f5..eb17cc7de7 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -474,15 +474,12 @@ void LD2450Component::handle_periodic_data_() { is_moving = false; // tx is used for further calculations, so always needs to be populated tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - SAFE_PUBLISH_SENSOR(this->move_x_sensors_[index], tx); // Y start = TARGET_Y + index * 8; ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - SAFE_PUBLISH_SENSOR(this->move_y_sensors_[index], ty); // RESOLUTION start = TARGET_RESOLUTION + index * 8; res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; - SAFE_PUBLISH_SENSOR(this->move_resolution_sensors_[index], res); #endif // SPEED start = TARGET_SPEED + index * 8; @@ -491,9 +488,6 @@ void LD2450Component::handle_periodic_data_() { is_moving = true; moving_target_count++; } -#ifdef USE_SENSOR - SAFE_PUBLISH_SENSOR(this->move_speed_sensors_[index], ts); -#endif // DISTANCE // Optimized: use already decoded tx and ty values, replace pow() with multiplication int32_t x_squared = (int32_t) tx * tx; @@ -503,10 +497,23 @@ void LD2450Component::handle_periodic_data_() { target_count++; } #ifdef USE_SENSOR - SAFE_PUBLISH_SENSOR(this->move_distance_sensors_[index], td); - // ANGLE - atan2f computes angle from Y axis directly, no sqrt/division needed - angle = atan2f(static_cast(-tx), static_cast(ty)) * (180.0f / std::numbers::pi_v); - SAFE_PUBLISH_SENSOR(this->move_angle_sensors_[index], angle); + if (td == 0) { + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_x_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_y_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_resolution_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_speed_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_distance_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_angle_sensors_[index]); + } else { + SAFE_PUBLISH_SENSOR(this->move_x_sensors_[index], tx); + SAFE_PUBLISH_SENSOR(this->move_y_sensors_[index], ty); + SAFE_PUBLISH_SENSOR(this->move_resolution_sensors_[index], res); + SAFE_PUBLISH_SENSOR(this->move_speed_sensors_[index], ts); + SAFE_PUBLISH_SENSOR(this->move_distance_sensors_[index], td); + // ANGLE - atan2f computes angle from Y axis directly, no sqrt/division needed + angle = atan2f(static_cast(-tx), static_cast(ty)) * (180.0f / std::numbers::pi_v); + SAFE_PUBLISH_SENSOR(this->move_angle_sensors_[index], angle); + } #endif #ifdef USE_TEXT_SENSOR // DIRECTION From db15b94cd7b0a2f323572ca66c172246c28752a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 17:17:20 -1000 Subject: [PATCH 20/36] [core] Inline HighFrequencyLoopRequester::is_high_frequency() (#14423) --- esphome/core/helpers.cpp | 1 - esphome/core/helpers.h | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 6d801e7ebc..c75799fe57 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -794,7 +794,6 @@ void HighFrequencyLoopRequester::stop() { num_requests--; this->started_ = false; } -bool HighFrequencyLoopRequester::is_high_frequency() { return num_requests > 0; } std::string get_mac_address() { uint8_t mac[6]; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index ae505a2d8a..187b383f65 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1732,7 +1732,7 @@ class HighFrequencyLoopRequester { void stop(); /// Check whether the loop is running continuously. - static bool is_high_frequency(); + static bool is_high_frequency() { return num_requests > 0; } protected: bool started_{false}; From 60d66ca2dcdc59a351c51a070c5fd326d84d2fda Mon Sep 17 00:00:00 2001 From: schrob <83939986+schdro@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:28:01 +0100 Subject: [PATCH 21/36] [openthread] Add tx power option (#14200) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/openthread/__init__.py | 30 ++++++++++++++++++- esphome/components/openthread/openthread.cpp | 3 ++ esphome/components/openthread/openthread.h | 2 ++ .../components/openthread/openthread_esp.cpp | 6 ++++ .../openthread/test.esp32-c6-idf.yaml | 1 + 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 5861c3db3f..5c64cf31dc 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -4,13 +4,20 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32H2, add_idf_sdkconfig_option, + get_esp32_variant, include_builtin_idf_component, only_on_variant, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ENABLE_IPV6, CONF_ID, CONF_USE_ADDRESS +from esphome.const import ( + CONF_CHANNEL, + CONF_ENABLE_IPV6, + CONF_ID, + CONF_OUTPUT_POWER, + CONF_USE_ADDRESS, +) from esphome.core import CORE, TimePeriodMilliseconds import esphome.final_validate as fv from esphome.types import ConfigType @@ -45,6 +52,20 @@ CONF_DEVICE_TYPES = [ ] +def _validate_txpower(value): + if CORE.is_esp32: + variant = get_esp32_variant() + + # HW limits: Datasheet section "802.15.4 RF Transmitter (TX) Characteristics" + # Further regulatory/soft limit may apply, e.g. by region + if variant in (VARIANT_ESP32C6, VARIANT_ESP32C5): + return cv.int_range(min=-15, max=20)(value) + if variant == VARIANT_ESP32H2: + return cv.int_range(min=-24, max=20)(value) + + return value # Unsupported, fail later with clear error + + def set_sdkconfig_options(config): # and expose options for using SPI/UART RCPs add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) @@ -155,6 +176,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_TLV): cv.string_strict, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, + cv.Optional(CONF_OUTPUT_POWER): cv.All( + cv.decibel, + _validate_txpower, + ), } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -197,4 +222,7 @@ async def to_code(config): cg.add(srp.set_mdns(mdns_component)) await cg.register_component(srp, config) + if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: + cg.add(ot.set_output_power(output_power)) + set_sdkconfig_options(config) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index d22a14aeae..92897a7e96 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -43,6 +43,9 @@ void OpenThreadComponent::dump_config() { ESP_LOGCONFIG(TAG, " Device is configured as Minimal End Device (MED)"); } #endif + if (this->output_power_.has_value()) { + ESP_LOGCONFIG(TAG, " Output power: %" PRId8 "dBm", *this->output_power_); + } } bool OpenThreadComponent::is_connected() { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 9e429f289b..728847afa5 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -38,6 +38,7 @@ class OpenThreadComponent : public Component { #if CONFIG_OPENTHREAD_MTD void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } #endif + void set_output_power(int8_t output_power) { this->output_power_ = output_power; } protected: std::optional get_omr_address_(InstanceLock &lock); @@ -45,6 +46,7 @@ class OpenThreadComponent : public Component { #if CONFIG_OPENTHREAD_MTD uint32_t poll_period_{0}; #endif + std::optional output_power_{}; bool teardown_started_{false}; bool teardown_complete_{false}; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 9dd68a1ccc..2af78b729f 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -135,6 +135,12 @@ void OpenThreadComponent::ot_main() { TRUEFALSE(link_mode_config.mRxOnWhenIdle)); #endif + if (this->output_power_.has_value()) { + if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); + } + } + // Run the main loop #if CONFIG_OPENTHREAD_CLI esp_openthread_cli_create_task(); diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 9df63b2f29..77abc433c1 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -13,3 +13,4 @@ openthread: force_dataset: true use_address: open-thread-test.local poll_period: 20sec + output_power: 1dBm From c4fa476c3c3455a01712a71e2a2d7a8e18d1882d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:45:28 +1300 Subject: [PATCH 22/36] Bump version to 2026.2.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 5f351c1bbb..61dd690f97 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.2.3 +PROJECT_NUMBER = 2026.2.4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index aaa34b2fd1..173e2b7be6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.2.3" +__version__ = "2026.2.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 1b5bf2c84875977030d073228d31153fe7af64e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 16:39:01 -1000 Subject: [PATCH 23/36] [wifi] Revert cyw43_wifi_link_status change for RP2040 The switch from cyw43_tcpip_link_status to cyw43_wifi_link_status was intended for 2026.3.0 alongside the arduino-pico 5.5.0 framework update but was accidentally included in 2026.2.3. With the old framework (3.9.4), cyw43_wifi_link_status never returns CYW43_LINK_UP, so the CONNECTED state is unreachable. The device connects to WiFi but the status stays at CONNECTING until timeout, causing a connect/disconnect loop. Fixes https://github.com/esphome/esphome/issues/14422 --- esphome/components/wifi/wifi_component_pico_w.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 7a93de5728..b09cff76ec 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -116,12 +116,7 @@ const char *get_disconnect_reason_str(uint8_t reason) { } WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { - // Use cyw43_wifi_link_status instead of cyw43_tcpip_link_status because the Arduino - // framework's __wrap_cyw43_cb_tcpip_init is a no-op — the SDK's internal netif - // (cyw43_state.netif[]) is never initialized. cyw43_tcpip_link_status checks that netif's - // flags and would only fall through to cyw43_wifi_link_status when the flags aren't set. - // Using cyw43_wifi_link_status directly gives us the actual WiFi radio join state. - int status = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA); + int status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); switch (status) { case CYW43_LINK_JOIN: case CYW43_LINK_NOIP: From 903c67c99499ebdfb60c68816a3cd61114f44897 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:16:54 +1300 Subject: [PATCH 24/36] Revert "[wifi] Revert cyw43_wifi_link_status change for RP2040" This reverts commit 1b5bf2c84875977030d073228d31153fe7af64e5. --- esphome/components/wifi/wifi_component_pico_w.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index ad07e1ff25..270425d8c2 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -121,7 +121,12 @@ const char *get_disconnect_reason_str(uint8_t reason) { } WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { - int status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); + // Use cyw43_wifi_link_status instead of cyw43_tcpip_link_status because the Arduino + // framework's __wrap_cyw43_cb_tcpip_init is a no-op — the SDK's internal netif + // (cyw43_state.netif[]) is never initialized. cyw43_tcpip_link_status checks that netif's + // flags and would only fall through to cyw43_wifi_link_status when the flags aren't set. + // Using cyw43_wifi_link_status directly gives us the actual WiFi radio join state. + int status = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA); switch (status) { case CYW43_LINK_JOIN: // WiFi joined, check if we have an IP address via the Arduino framework's WiFi class From cfde0613bbed38f9ce3d86affcde5fd0b300e972 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:53:18 +1100 Subject: [PATCH 25/36] [const][uart][usb_uart][weikai][core] Move constants to components/const (#14430) --- esphome/components/const/__init__.py | 3 +++ esphome/components/uart/__init__.py | 4 +--- esphome/components/usb_uart/__init__.py | 4 +--- esphome/components/weikai/__init__.py | 4 +--- esphome/const.py | 3 --- .../fixtures/external_components/uart_mock/__init__.py | 4 +--- 6 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3201db5dfd..059bf3f26a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,14 +8,17 @@ BYTE_ORDER_BIG = "big_endian" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" +CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" +CONF_PARITY = "parity" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" ICON_CURRENT_DC = "mdi:current-dc" diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 69db4b44aa..3bc4263b31 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -4,6 +4,7 @@ import re from esphome import automation, pins import esphome.codegen as cg +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -11,7 +12,6 @@ from esphome.const import ( CONF_BAUD_RATE, CONF_BYTES, CONF_DATA, - CONF_DATA_BITS, CONF_DEBUG, CONF_DELIMITER, CONF_DIRECTION, @@ -21,12 +21,10 @@ from esphome.const import ( CONF_ID, CONF_LAMBDA, CONF_NUMBER, - CONF_PARITY, CONF_PORT, CONF_RX_BUFFER_SIZE, CONF_RX_PIN, CONF_SEQUENCE, - CONF_STOP_BITS, CONF_TIMEOUT, CONF_TRIGGER_ID, CONF_TX_PIN, diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index cc69c0cb5a..f0ee53d028 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import socket +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.uart import UARTComponent from esphome.components.usb_host import register_usb_client, usb_device_schema import esphome.config_validation as cv @@ -7,12 +8,9 @@ from esphome.const import ( CONF_BAUD_RATE, CONF_BUFFER_SIZE, CONF_CHANNELS, - CONF_DATA_BITS, CONF_DEBUG, CONF_DUMMY_RECEIVER, CONF_ID, - CONF_PARITY, - CONF_STOP_BITS, ) from esphome.cpp_types import Component diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index 66cd4ce12a..bc80f167ef 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -1,18 +1,16 @@ import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, CONF_CHANNEL, - CONF_DATA_BITS, CONF_ID, CONF_INPUT, CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, - CONF_PARITY, - CONF_STOP_BITS, ) CODEOWNERS = ["@DrCoolZic"] diff --git a/esphome/const.py b/esphome/const.py index bbd85ca66b..d5625f6a54 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -280,7 +280,6 @@ CONF_CUSTOM_PRESETS = "custom_presets" CONF_CYCLE = "cycle" CONF_DALLAS_ID = "dallas_id" CONF_DATA = "data" -CONF_DATA_BITS = "data_bits" CONF_DATA_PIN = "data_pin" CONF_DATA_PINS = "data_pins" CONF_DATA_RATE = "data_rate" @@ -760,7 +759,6 @@ CONF_PAGE_ID = "page_id" CONF_PAGES = "pages" CONF_PANASONIC = "panasonic" CONF_PARAMETERS = "parameters" -CONF_PARITY = "parity" CONF_PASSWORD = "password" CONF_PATH = "path" CONF_PATTERN = "pattern" @@ -963,7 +961,6 @@ CONF_STEP_PIN = "step_pin" CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" -CONF_STOP_BITS = "stop_bits" CONF_STORE_BASELINE = "store_baseline" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" diff --git a/tests/integration/fixtures/external_components/uart_mock/__init__.py b/tests/integration/fixtures/external_components/uart_mock/__init__.py index dea8c38551..8deab4c21e 100644 --- a/tests/integration/fixtures/external_components/uart_mock/__init__.py +++ b/tests/integration/fixtures/external_components/uart_mock/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.uart import ( CONF_RX_FULL_THRESHOLD, CONF_RX_TIMEOUT, @@ -12,14 +13,11 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, CONF_DATA, - CONF_DATA_BITS, CONF_DEBUG, CONF_DELAY, CONF_ID, CONF_INTERVAL, - CONF_PARITY, CONF_RX_BUFFER_SIZE, - CONF_STOP_BITS, CONF_TRIGGER_ID, ) from esphome.core import ID From b6f0bb9b6bcf12f7021a4904df24954712ab0f78 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 3 Mar 2026 07:59:01 -0800 Subject: [PATCH 26/36] [speaker] Add off on capability to media player (#9295) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Kevin Ahrendt --- .../speaker/media_player/__init__.py | 5 ++ .../media_player/speaker_media_player.cpp | 61 +++++++++++++++++++ .../media_player/speaker_media_player.h | 3 + esphome/core/defines.h | 1 + .../speaker/common-media_player_off_on.yaml | 18 ++++++ .../media_player_off_on.esp32-idf.yaml | 9 +++ 6 files changed, 97 insertions(+) create mode 100644 tests/components/speaker/common-media_player_off_on.yaml create mode 100644 tests/components/speaker/media_player_off_on.esp32-idf.yaml diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index b302bd9b23..42ca762858 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_FORMAT, CONF_ID, CONF_NUM_CHANNELS, + CONF_ON_TURN_OFF, + CONF_ON_TURN_ON, CONF_PATH, CONF_RAW_DATA_ID, CONF_SAMPLE_RATE, @@ -401,6 +403,9 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config): + if CONF_ON_TURN_OFF in config or CONF_ON_TURN_ON in config: + cg.add_define("USE_SPEAKER_MEDIA_PLAYER_ON_OFF", True) + var = await media_player.new_media_player(config) await cg.register_component(var, config) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index fdf6bf66cd..3f5cb2fda6 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -51,7 +51,11 @@ static const UBaseType_t ANNOUNCEMENT_PIPELINE_TASK_PRIORITY = 1; static const char *const TAG = "speaker_media_player"; void SpeakerMediaPlayer::setup() { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + state = media_player::MEDIA_PLAYER_STATE_OFF; +#else state = media_player::MEDIA_PLAYER_STATE_IDLE; +#endif this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand)); @@ -128,6 +132,12 @@ void SpeakerMediaPlayer::watch_media_commands_() { bool enqueue = media_command.enqueue.has_value() && media_command.enqueue.value(); if (media_command.url.has_value() || media_command.file.has_value()) { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state == media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_ON; + publish_state(); + } +#endif PlaylistItem playlist_item; if (media_command.url.has_value()) { playlist_item.url = *media_command.url.value(); @@ -184,6 +194,12 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (media_command.command.has_value()) { switch (media_command.command.value()) { case media_player::MEDIA_PLAYER_COMMAND_PLAY: +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state == media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_ON; + publish_state(); + } +#endif if ((this->media_pipeline_ != nullptr) && (this->is_paused_)) { this->media_pipeline_->set_pause_state(false); } @@ -195,10 +211,26 @@ void SpeakerMediaPlayer::watch_media_commands_() { } this->is_paused_ = true; break; +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + case media_player::MEDIA_PLAYER_COMMAND_TURN_ON: + if (this->state == media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_ON; + this->publish_state(); + } + break; + case media_player::MEDIA_PLAYER_COMMAND_TURN_OFF: + this->is_turn_off_ = true; + // Intentional Fall-through +#endif case media_player::MEDIA_PLAYER_COMMAND_STOP: // Pipelines do not stop immediately after calling the stop command, so confirm its stopped before unpausing. // This avoids an audible short segment playing after receiving the stop command in a paused state. +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value()) || + (this->is_turn_off_ && this->announcement_pipeline_state_ != AudioPipelineState::STOPPED)) { +#else if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { +#endif if (this->announcement_pipeline_ != nullptr) { this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); @@ -366,7 +398,13 @@ void SpeakerMediaPlayer::loop() { } } else { if (this->is_paused_) { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_PAUSED; + } +#else this->state = media_player::MEDIA_PLAYER_STATE_PAUSED; +#endif } else if (this->media_pipeline_state_ == AudioPipelineState::PLAYING) { this->state = media_player::MEDIA_PLAYER_STATE_PLAYING; } else if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { @@ -399,7 +437,13 @@ void SpeakerMediaPlayer::loop() { } } } else { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + } +#else this->state = media_player::MEDIA_PLAYER_STATE_IDLE; +#endif } } } @@ -409,6 +453,20 @@ void SpeakerMediaPlayer::loop() { this->publish_state(); ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); } +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->is_turn_off_ && (this->state == media_player::MEDIA_PLAYER_STATE_PAUSED || + this->state == media_player::MEDIA_PLAYER_STATE_IDLE)) { + this->is_turn_off_ = false; + if (this->state == media_player::MEDIA_PLAYER_STATE_PAUSED) { + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } + this->state = media_player::MEDIA_PLAYER_STATE_OFF; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } +#endif } void SpeakerMediaPlayer::play_file(audio::AudioFile *media_file, bool announcement, bool enqueue) { @@ -481,6 +539,9 @@ media_player::MediaPlayerTraits SpeakerMediaPlayer::get_traits() { if (!this->single_pipeline_()) { traits.set_supports_pause(true); } +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + traits.set_supports_turn_off_on(true); +#endif if (this->announcement_format_.has_value()) { traits.get_supported_formats().push_back(this->announcement_format_.value()); diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 6796fc9c00..3fa6f47b84 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -144,6 +144,9 @@ class SpeakerMediaPlayer : public Component, bool is_paused_{false}; bool is_muted_{false}; +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + bool is_turn_off_{false}; +#endif uint8_t unpause_media_remaining_{0}; uint8_t unpause_announcement_remaining_{0}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7fbc5a0b53..8d778edf2a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -233,6 +233,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WAKE_LOOP_THREADSAFE #define USE_SPEAKER +#define USE_SPEAKER_MEDIA_PLAYER_ON_OFF #define USE_SPI #define USE_VOICE_ASSISTANT #define USE_WEBSERVER diff --git a/tests/components/speaker/common-media_player_off_on.yaml b/tests/components/speaker/common-media_player_off_on.yaml new file mode 100644 index 0000000000..a5bea62c84 --- /dev/null +++ b/tests/components/speaker/common-media_player_off_on.yaml @@ -0,0 +1,18 @@ +<<: !include common.yaml + +media_player: + - platform: speaker + id: speaker_media_player_id + announcement_pipeline: + speaker: speaker_id + buffer_size: 1000000 + volume_increment: 0.02 + volume_max: 0.95 + volume_min: 0.0 + task_stack_in_psram: true + on_turn_on: + then: + - logger.log: "Turn On Media Player" + on_turn_off: + then: + - logger.log: "Turn Off Media Player" diff --git a/tests/components/speaker/media_player_off_on.esp32-idf.yaml b/tests/components/speaker/media_player_off_on.esp32-idf.yaml new file mode 100644 index 0000000000..2d5eefff19 --- /dev/null +++ b/tests/components/speaker/media_player_off_on.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO23 + +<<: !include common-media_player_off_on.yaml From d53ff7892a85d623f7c116c253075d82b2fd0a95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 07:03:02 -1000 Subject: [PATCH 27/36] [socket] Cache lwip_sock pointers and inline ready() chain (#14408) --- .../components/socket/bsd_sockets_impl.cpp | 33 +++++++------- esphome/components/socket/bsd_sockets_impl.h | 7 +++ .../components/socket/lwip_sockets_impl.cpp | 33 +++++++------- esphome/components/socket/lwip_sockets_impl.h | 7 +++ esphome/components/socket/socket.cpp | 6 ++- esphome/components/socket/socket.h | 45 +++++++++++++++++-- esphome/core/application.cpp | 43 ++++++++++++------ esphome/core/application.h | 32 ++++++------- esphome/core/lwip_fast_select.c | 28 +++++------- esphome/core/lwip_fast_select.h | 45 ++++++++++++++++--- 10 files changed, 190 insertions(+), 89 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 92ecfc692b..aea7c776c6 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -11,11 +11,15 @@ namespace esphome::socket { BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { this->fd_ = fd; - // Register new socket with the application for select() if monitoring requested - if (monitor_loop && this->fd_ >= 0) { - // Only set loop_monitored_ to true if registration succeeds - this->loop_monitored_ = App.register_socket_fd(this->fd_); - } + if (!monitor_loop || this->fd_ < 0) + return; +#ifdef USE_LWIP_FAST_SELECT + // Cache lwip_sock pointer and register for monitoring (hooks callback internally) + this->cached_sock_ = esphome_lwip_get_sock(this->fd_); + this->loop_monitored_ = App.register_socket(this->cached_sock_); +#else + this->loop_monitored_ = App.register_socket_fd(this->fd_); +#endif } BSDSocketImpl::~BSDSocketImpl() { @@ -26,10 +30,17 @@ BSDSocketImpl::~BSDSocketImpl() { int BSDSocketImpl::close() { if (!this->closed_) { - // Unregister from select() before closing if monitored + // Unregister before closing to avoid dangling pointer in monitored set +#ifdef USE_LWIP_FAST_SELECT + if (this->loop_monitored_) { + App.unregister_socket(this->cached_sock_); + this->cached_sock_ = nullptr; + } +#else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } +#endif int ret = ::close(this->fd_); this->closed_ = true; return ret; @@ -48,8 +59,6 @@ int BSDSocketImpl::setblocking(bool blocking) { return 0; } -bool BSDSocketImpl::ready() const { return socket_ready_fd(this->fd_, this->loop_monitored_); } - size_t BSDSocketImpl::getpeername_to(std::span buf) { struct sockaddr_storage storage; socklen_t len = sizeof(storage); @@ -86,14 +95,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -std::unique_ptr socket_listen(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, false); -} - -std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, true); -} - } // namespace esphome::socket #endif // USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index d9ed9dc567..9ebbe72002 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -13,6 +13,10 @@ #include #endif +#ifdef USE_LWIP_FAST_SELECT +struct lwip_sock; +#endif + namespace esphome::socket { class BSDSocketImpl { @@ -105,6 +109,9 @@ class BSDSocketImpl { protected: int fd_{-1}; +#ifdef USE_LWIP_FAST_SELECT + struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() +#endif bool closed_{false}; bool loop_monitored_{false}; }; diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 0322820ef4..2fad429e0f 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -11,11 +11,15 @@ namespace esphome::socket { LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { this->fd_ = fd; - // Register new socket with the application for select() if monitoring requested - if (monitor_loop && this->fd_ >= 0) { - // Only set loop_monitored_ to true if registration succeeds - this->loop_monitored_ = App.register_socket_fd(this->fd_); - } + if (!monitor_loop || this->fd_ < 0) + return; +#ifdef USE_LWIP_FAST_SELECT + // Cache lwip_sock pointer and register for monitoring (hooks callback internally) + this->cached_sock_ = esphome_lwip_get_sock(this->fd_); + this->loop_monitored_ = App.register_socket(this->cached_sock_); +#else + this->loop_monitored_ = App.register_socket_fd(this->fd_); +#endif } LwIPSocketImpl::~LwIPSocketImpl() { @@ -26,10 +30,17 @@ LwIPSocketImpl::~LwIPSocketImpl() { int LwIPSocketImpl::close() { if (!this->closed_) { - // Unregister from select() before closing if monitored + // Unregister before closing to avoid dangling pointer in monitored set +#ifdef USE_LWIP_FAST_SELECT + if (this->loop_monitored_) { + App.unregister_socket(this->cached_sock_); + this->cached_sock_ = nullptr; + } +#else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } +#endif int ret = lwip_close(this->fd_); this->closed_ = true; return ret; @@ -48,8 +59,6 @@ int LwIPSocketImpl::setblocking(bool blocking) { return 0; } -bool LwIPSocketImpl::ready() const { return socket_ready_fd(this->fd_, this->loop_monitored_); } - size_t LwIPSocketImpl::getpeername_to(std::span buf) { struct sockaddr_storage storage; socklen_t len = sizeof(storage); @@ -86,14 +95,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -std::unique_ptr socket_listen(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, false); -} - -std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, true); -} - } // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index d6699aded2..c579219863 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -9,6 +9,10 @@ #include "esphome/core/helpers.h" #include "headers.h" +#ifdef USE_LWIP_FAST_SELECT +struct lwip_sock; +#endif + namespace esphome::socket { class LwIPSocketImpl { @@ -71,6 +75,9 @@ class LwIPSocketImpl { protected: int fd_{-1}; +#ifdef USE_LWIP_FAST_SELECT + struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() +#endif bool closed_{false}; bool loop_monitored_{false}; }; diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index c04671c7ee..bfb6ae8e13 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -8,7 +8,7 @@ namespace esphome::socket { -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) // Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). // Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); } @@ -89,6 +89,9 @@ std::unique_ptr socket_ip(int type, int protocol) { #endif /* USE_NETWORK_IPV6 */ } +#ifdef USE_SOCKET_IMPL_LWIP_TCP +// LWIP_TCP has separate Socket/ListenSocket types — needs out-of-line factory. +// BSD and LWIP_SOCKETS define this inline in socket.h. std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { #if USE_NETWORK_IPV6 return socket_listen_loop_monitored(AF_INET6, type, protocol); @@ -96,6 +99,7 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { return socket_listen_loop_monitored(AF_INET, type, protocol); #endif /* USE_NETWORK_IPV6 */ } +#endif socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 546d278260..0884e4ba3e 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -6,6 +6,10 @@ #include "esphome/core/optional.h" #include "headers.h" +#ifdef USE_LWIP_FAST_SELECT +#include "esphome/core/lwip_fast_select.h" +#endif + #if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) // Include only the active implementation's header. @@ -36,12 +40,29 @@ using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; #endif -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_LWIP_FAST_SELECT +/// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. +inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { + return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); +} +#elif defined(USE_SOCKET_SELECT_SUPPORT) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored); #endif +// Inline ready() — defined here because it depends on socket_ready/socket_ready_fd +// declared above, while the impl headers are included before those declarations. +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +inline bool Socket::ready() const { +#ifdef USE_LWIP_FAST_SELECT + return socket_ready(this->cached_sock_, this->loop_monitored_); +#else + return socket_ready_fd(this->fd_, this->loop_monitored_); +#endif +} +#endif + /// Create a socket of the given domain, type and protocol. std::unique_ptr socket(int domain, int type, int protocol); /// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol. @@ -56,11 +77,29 @@ std::unique_ptr socket_ip(int type, int protocol); std::unique_ptr socket_loop_monitored(int domain, int type, int protocol); /// Create a listening socket of the given domain, type and protocol. -std::unique_ptr socket_listen(int domain, int type, int protocol); /// Create a listening socket and monitor it for data in the main loop. -std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); /// Create a listening socket in the newest available IP domain and monitor it. +#ifdef USE_SOCKET_IMPL_LWIP_TCP +// LWIP_TCP has separate Socket/ListenSocket types — needs distinct factory functions. +std::unique_ptr socket_listen(int domain, int type, int protocol); +std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); std::unique_ptr socket_ip_loop_monitored(int type, int protocol); +#else +// BSD and LWIP_SOCKETS: Socket == ListenSocket, so listen variants just delegate. +inline std::unique_ptr socket_listen(int domain, int type, int protocol) { + return socket(domain, type, protocol); +} +inline std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { + return socket_loop_monitored(domain, type, protocol); +} +inline std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { +#if USE_NETWORK_IPV6 + return socket_loop_monitored(AF_INET6, type, protocol); +#else + return socket_loop_monitored(AF_INET, type, protocol); +#endif +} +#endif /// Set a sockaddr to the specified address and port for the IP version used by socket_ip(). /// @param addr Destination sockaddr structure diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 26cd670629..8c2ba58c86 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -552,7 +552,32 @@ void Application::after_loop_tasks_() { this->in_loop_ = false; } -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_LWIP_FAST_SELECT +bool Application::register_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + if (sock == nullptr) + return false; + esphome_lwip_hook_socket(sock); + this->monitored_sockets_.push_back(sock); + return true; +} + +void Application::unregister_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { + if (this->monitored_sockets_[i] != sock) + continue; + + // Swap with last element and pop - O(1) removal since order doesn't matter. + // No need to unhook the netconn callback — all LwIP sockets share the same + // static event_callback, and the socket will be closed by the caller. + if (i < this->monitored_sockets_.size() - 1) + this->monitored_sockets_[i] = this->monitored_sockets_.back(); + this->monitored_sockets_.pop_back(); + return; + } +} +#elif defined(USE_SOCKET_SELECT_SUPPORT) bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking @@ -571,15 +596,10 @@ bool Application::register_socket_fd(int fd) { #endif this->socket_fds_.push_back(fd); -#ifdef USE_LWIP_FAST_SELECT - // Hook the socket's netconn callback for instant wake on receive events - esphome_lwip_hook_socket(fd); -#else this->socket_fds_changed_ = true; if (fd > this->max_fd_) { this->max_fd_ = fd; } -#endif return true; } @@ -595,13 +615,9 @@ void Application::unregister_socket_fd(int fd) { continue; // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback on fast select platforms — all LwIP - // sockets share the same static event_callback, and the socket will be closed - // by the caller. if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); -#ifndef USE_LWIP_FAST_SELECT this->socket_fds_changed_ = true; // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { @@ -611,7 +627,6 @@ void Application::unregister_socket_fd(int fd) { this->max_fd_ = sock_fd; } } -#endif return; } } @@ -621,7 +636,7 @@ void Application::unregister_socket_fd(int fd) { void Application::yield_with_select_(uint32_t delay_ms) { // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_LWIP_FAST_SELECT) - // Fast path (ESP32/LibreTiny): reads rcvevent directly via lwip_socket_dbg_get_socket(). + // Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers. // Safe because this runs on the main loop which owns socket lifetime (create, read, close). if (delay_ms == 0) [[unlikely]] { yield(); @@ -632,8 +647,8 @@ void Application::yield_with_select_(uint32_t delay_ms) { // If a socket still has unread data (rcvevent > 0) but the task notification was already // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. // This scan preserves select() semantics: return immediately when any fd is ready. - for (int fd : this->socket_fds_) { - if (esphome_lwip_socket_has_data(fd)) { + for (struct lwip_sock *sock : this->monitored_sockets_) { + if (esphome_lwip_socket_has_data(sock)) { yield(); return; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 63d59c555e..40f8a00edd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -500,14 +500,20 @@ class Application { Scheduler scheduler; - /// Register/unregister a socket file descriptor to be monitored for read events. -#ifdef USE_SOCKET_SELECT_SUPPORT - /// These functions update the fd_set used by select() in the main loop. + /// Register/unregister a socket to be monitored for read events. /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. +#ifdef USE_LWIP_FAST_SELECT + /// Fast select path: hooks netconn callback and registers for monitoring. + /// @return true if registration was successful, false if sock is null + bool register_socket(struct lwip_sock *sock); + void unregister_socket(struct lwip_sock *sock); +#elif defined(USE_SOCKET_SELECT_SUPPORT) + /// Fallback select() path: monitors file descriptors. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); +#endif #ifdef USE_WAKE_LOOP_THREADSAFE /// Wake the main event loop from another FreeRTOS task. @@ -532,7 +538,6 @@ class Application { static void IRAM_ATTR wake_loop_any_context() { esphome_lwip_wake_main_loop_any_context(); } #endif #endif -#endif #if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) /// Wake the main event loop from any context (ISR, thread, or main loop). @@ -542,23 +547,14 @@ class Application { protected: friend Component; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) friend bool socket::socket_ready_fd(int fd, bool loop_monitored); #endif friend void ::setup(); friend void ::original_setup(); -#ifdef USE_SOCKET_SELECT_SUPPORT - /// Fast path for Socket::ready() via friendship - skips negative fd check. - /// Main loop only — with USE_LWIP_FAST_SELECT, reads rcvevent via - /// lwip_socket_dbg_get_socket(), which has no refcount; safe only because - /// the main loop owns socket lifetime (creates, reads, and closes sockets - /// on the same thread). -#ifdef USE_LWIP_FAST_SELECT - bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); } -#else +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } -#endif #endif /// Register a component, detecting loop() override at compile time. @@ -620,8 +616,12 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_LWIP_FAST_SELECT + std::vector monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read +#elif defined(USE_SOCKET_SELECT_SUPPORT) std::vector socket_fds_; // Vector of all monitored socket file descriptors +#endif +#ifdef USE_SOCKET_SELECT_SUPPORT #if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 989f66e9be..c578a9aae9 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -140,8 +140,10 @@ _Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access"); _Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access"); -// rcvevent must fit in a single atomic read -_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access"); +// rcvevent must be exactly 2 bytes (s16_t) — the inline in lwip_fast_select.h reads it as int16_t. +// If lwIP changes this to int or similar, the offset assert would still pass but the load width would be wrong. +_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) == 2, + "rcvevent size changed — update int16_t cast in esphome_lwip_socket_has_data() in lwip_fast_select.h"); // Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V/ARM. // Misaligned access would not be atomic even if the size is <= 4 bytes. @@ -150,6 +152,10 @@ _Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == _Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0, "lwip_sock.rcvevent must be naturally aligned for atomic access"); +// Verify the hardcoded offset used in the header's inline esphome_lwip_socket_has_data(). +_Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET, + "lwip_sock.rcvevent offset changed — update ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET in lwip_fast_select.h"); + // Task handle for the main loop — written once in init(), read from TCP/IP and background tasks. static TaskHandle_t s_main_loop_task = NULL; @@ -194,23 +200,11 @@ static inline struct lwip_sock *get_sock(int fd) { return sock; } -bool esphome_lwip_socket_has_data(int fd) { - struct lwip_sock *sock = get_sock(fd); - if (sock == NULL) - return false; - // volatile prevents the compiler from caching/reordering this cross-thread read. - // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a - // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value - // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on - // Xtensa/RISC-V/ARM and cannot produce torn values. - return *(volatile s16_t *) &sock->rcvevent > 0; +struct lwip_sock *esphome_lwip_get_sock(int fd) { + return get_sock(fd); } -void esphome_lwip_hook_socket(int fd) { - struct lwip_sock *sock = get_sock(fd); - if (sock == NULL) - return; - +void esphome_lwip_hook_socket(struct lwip_sock *sock) { // Save original callback once — all LwIP sockets share the same static event_callback // (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM). if (s_original_callback == NULL) { diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 6fce34fd76..46c6b711cd 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -4,6 +4,17 @@ // Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. #include +#include + +// Forward declare lwip_sock for C++ callers that store cached pointers. +// The full definition is only available in the .c file (lwip/priv/sockets_priv.h +// conflicts with C++ compilation units). +struct lwip_sock; + +// Byte offset of rcvevent (s16_t) within struct lwip_sock. +// Verified at compile time in lwip_fast_select.c via _Static_assert. +// Anonymous enum for a compile-time constant that works in both C and C++. +enum { ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET = 8 }; #ifdef __cplusplus extern "C" { @@ -13,16 +24,38 @@ extern "C" { /// Saves the current task handle for xTaskNotifyGive() wake notifications. void esphome_lwip_fast_select_init(void); -/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket). -/// Uses lwip_socket_dbg_get_socket() — a direct array lookup without the refcount that -/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime: -/// both has_data reads and socket close/unregister happen on the main loop thread. -bool esphome_lwip_socket_has_data(int fd); +/// Look up a LwIP socket struct from a file descriptor. +/// Returns NULL if fd is invalid or the socket/netconn is not initialized. +/// Use this at registration time to cache the pointer for esphome_lwip_socket_has_data(). +struct lwip_sock *esphome_lwip_get_sock(int fd); + +/// Check if a cached LwIP socket has data ready via unlocked hint read of rcvevent. +/// This avoids lwIP core lock contention between the main loop (CPU0) and +/// streaming/networking work (CPU1). Correctness is preserved because callers +/// already handle EWOULDBLOCK on nonblocking sockets — a stale hint simply causes +/// a harmless retry on the next loop iteration. In practice, stale reads have not +/// been observed across multi-day testing, but the design does not depend on that. +/// +/// The sock pointer must have been obtained from esphome_lwip_get_sock() and must +/// remain valid (caller owns socket lifetime — no concurrent close). +/// Hot path: inlined volatile 16-bit load — no function call overhead. +/// Uses offset-based access because lwip/priv/sockets_priv.h conflicts with C++. +/// The offset and size are verified at compile time in lwip_fast_select.c. +static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { + // Unlocked hint read — no lwIP core lock needed. + // volatile prevents the compiler from caching/reordering this cross-thread read. + // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a + // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value + // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on + // Xtensa/RISC-V/ARM and cannot produce torn values. + return *(volatile int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +} /// Hook a socket's netconn callback to notify the main loop task on receive events. /// Wraps the original event_callback with one that also calls xTaskNotifyGive(). /// Must be called from the main loop after socket creation. -void esphome_lwip_hook_socket(int fd); +/// The sock pointer must have been obtained from esphome_lwip_get_sock(). +void esphome_lwip_hook_socket(struct lwip_sock *sock); /// Wake the main loop task from another FreeRTOS task — costs <1 us. /// NOT ISR-safe — must only be called from task context. From 1f1b20f4feacd769279b673be5301c1d39f41ed3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 07:03:24 -1000 Subject: [PATCH 28/36] [core] Pack entity string properties into PROGMEM-indexed uint8_t fields (#14171) --- .../alarm_control_panel/__init__.py | 2 +- esphome/components/api/api_connection.cpp | 4 +- esphome/components/binary_sensor/__init__.py | 12 +- .../components/binary_sensor/binary_sensor.h | 2 +- esphome/components/button/__init__.py | 12 +- esphome/components/button/button.h | 2 +- esphome/components/climate/__init__.py | 3 +- esphome/components/cover/__init__.py | 12 +- esphome/components/cover/cover.h | 2 +- esphome/components/datetime/__init__.py | 3 +- esphome/components/esp32/core.cpp | 1 + esphome/components/esp8266/core.cpp | 3 + esphome/components/event/__init__.py | 12 +- esphome/components/event/event.h | 2 +- esphome/components/fan/__init__.py | 3 +- esphome/components/host/core.cpp | 1 + esphome/components/infrared/__init__.py | 2 +- esphome/components/libretiny/core.cpp | 1 + esphome/components/light/__init__.py | 7 +- esphome/components/lock/__init__.py | 3 +- esphome/components/media_player/__init__.py | 2 +- esphome/components/mqtt/mqtt_number.cpp | 4 +- esphome/components/number/__init__.py | 16 +- esphome/components/number/number.cpp | 4 +- esphome/components/number/number_traits.h | 6 +- esphome/components/rp2040/core.cpp | 3 + esphome/components/select/__init__.py | 3 +- esphome/components/sensor/__init__.py | 16 +- esphome/components/sensor/sensor.h | 2 +- esphome/components/sprinkler/sprinkler.cpp | 4 +- esphome/components/switch/__init__.py | 12 +- esphome/components/switch/switch.h | 2 +- esphome/components/text/__init__.py | 3 +- esphome/components/text_sensor/__init__.py | 12 +- esphome/components/text_sensor/text_sensor.h | 2 +- esphome/components/update/__init__.py | 12 +- esphome/components/update/update_entity.h | 2 +- esphome/components/valve/__init__.py | 12 +- esphome/components/valve/valve.h | 2 +- esphome/components/water_heater/__init__.py | 3 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/zephyr/core.cpp | 1 + esphome/core/defines.h | 2 + esphome/core/entity_base.cpp | 64 ++--- esphome/core/entity_base.h | 100 ++++---- esphome/core/entity_helpers.py | 227 +++++++++++++++++- esphome/core/hal.h | 1 + script/clang-tidy | 1 + tests/component_tests/sensor/test_sensor.py | 2 +- .../text_sensor/test_text_sensor.py | 4 +- tests/unit_tests/core/test_entity_helpers.py | 110 +++++++-- 51 files changed, 519 insertions(+), 206 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b1e2252ce7..b855586152 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -186,8 +186,8 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( ) +@setup_entity("alarm_control_panel") async def setup_alarm_control_panel_core_(var, config): - await setup_entity(var, config, "alarm_control_panel") for conf in config.get(CONF_ON_STATE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 738dd1ef05..59476fac25 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -773,9 +773,9 @@ uint16_t APIConnection::try_send_number_state(EntityBase *entity, APIConnection uint16_t APIConnection::try_send_number_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *number = static_cast(entity); ListEntitiesNumberResponse msg; - msg.unit_of_measurement = number->traits.get_unit_of_measurement_ref(); + msg.unit_of_measurement = number->get_unit_of_measurement_ref(); msg.mode = static_cast(number->traits.get_mode()); - msg.device_class = number->traits.get_device_class_ref(); + msg.device_class = number->get_device_class_ref(); msg.min_value = number->traits.get_min_value(); msg.max_value = number->traits.get_max_value(); msg.step = number->traits.get_step(); diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 036d78da73..1f64118560 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -60,7 +60,11 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -604,11 +608,9 @@ async def _build_binary_sensor_automations(var, config): ) +@setup_entity("binary_sensor") async def setup_binary_sensor_core_(var, config): - await setup_entity(var, config, "binary_sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) trigger = config.get(CONF_TRIGGER_ON_INITIAL_STATE, False) or config.get( CONF_PUBLISH_INITIAL_STATE, False ) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 4b655e1bd1..6ae5d04bcb 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -30,7 +30,7 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi * The sub classes should notify the front-end of new states via the publish_state() method which * handles inverted inputs for you. */ -class BinarySensor : public StatefulEntityBase, public EntityBase_DeviceClass { +class BinarySensor : public StatefulEntityBase { public: explicit BinarySensor(){}; diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 94816a0974..12d9ebaba6 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -18,7 +18,11 @@ from esphome.const import ( DEVICE_CLASS_UPDATE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -84,15 +88,13 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +@setup_entity("button") async def setup_button_core_(var, config): - await setup_entity(var, config, "button") - for conf in config.get(CONF_ON_PRESS, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - if device_class := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index be6e080917..0f7576a419 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -22,7 +22,7 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o * * A button is just a momentary switch that does not have a state, only a trigger. */ -class Button : public EntityBase, public EntityBase_DeviceClass { +class Button : public EntityBase { public: /** Press this button. This is called by the front-end. * diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 2150a30c3e..1f449ad2a4 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -268,9 +268,8 @@ def climate_schema( return _CLIMATE_SCHEMA.extend(schema) +@setup_entity("climate") async def setup_climate_core_(var, config): - await setup_entity(var, config, "climate") - visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 17095f41f6..c330241f4d 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -37,7 +37,11 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, TemplateArgsType @@ -190,11 +194,9 @@ def cover_schema( return _COVER_SCHEMA.extend(schema) +@setup_entity("cover") async def setup_cover_core_(var, config): - await setup_entity(var, config, "cover") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if CONF_ON_OPEN in config: _LOGGER.warning( diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 0af48f75de..8cf9aa092a 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -107,7 +107,7 @@ const LogString *cover_operation_to_str(CoverOperation op); * to control all values of the cover. Also implement get_traits() to return what operations * the cover supports. */ -class Cover : public EntityBase, public EntityBase_DeviceClass { +class Cover : public EntityBase { public: explicit Cover(); diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 602db3827a..74c9d594f7 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -134,9 +134,8 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: return _DATETIME_SCHEMA.extend(schema) +@setup_entity("datetime") async def setup_datetime_core_(var, config): - await setup_entity(var, config, "datetime") - if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 7ebbba609e..46c000562e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -48,6 +48,7 @@ void arch_init() { void HOT arch_feed_wdt() { esp_task_wdt_reset(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index b665124d66..159ec20e77 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -34,6 +34,9 @@ void HOT arch_feed_wdt() { system_soft_wdt_feed(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } +const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} uint16_t progmem_read_uint16(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 8fac7a279c..14cc1505ad 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -18,7 +18,11 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@nohat"] @@ -85,17 +89,15 @@ def event_schema( return _EVENT_SCHEMA.extend(schema) +@setup_entity("event") async def setup_event_core_(var, config, *, event_types: list[str]): - await setup_entity(var, config, "event") - for conf in config.get(CONF_ON_EVENT, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [(cg.StringRef, "event_type")], conf) cg.add(var.set_event_types(event_types)) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index a7451407bb..5b6a94b47c 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -20,7 +20,7 @@ namespace event { LOG_ENTITY_DEVICE_CLASS(TAG, prefix, *(obj)); \ } -class Event : public EntityBase, public EntityBase_DeviceClass { +class Event : public EntityBase { public: void trigger(const std::string &event_type); diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index e839df6aee..da28c577c8 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -222,9 +222,8 @@ def validate_preset_modes(value): return value +@setup_entity("fan") async def setup_fan_core_(var, config): - await setup_entity(var, config, "fan") - cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index cb2b2e19d7..d5c61ec986 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -59,6 +59,7 @@ void HOT arch_feed_wdt() { } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { struct timespec spec; diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 5c759d6fd9..6a2a72fa5d 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -45,9 +45,9 @@ def infrared_schema(class_: type[cg.MockObjClass]) -> cv.Schema: ) +@setup_entity("infrared") async def setup_infrared_core_(var: cg.Pvariable, config: ConfigType) -> None: """Set up core infrared configuration.""" - await setup_entity(var, config, "infrared") async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 74b33a30a0..893a79440a 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -36,6 +36,7 @@ void HOT arch_feed_wdt() { lt_wdt_feed(); } uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } } // namespace esphome diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 40382bbda7..4403281116 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -243,9 +243,8 @@ def validate_color_temperature_channels(value): return value -async def setup_light_core_(light_var, output_var, config): - await setup_entity(light_var, config, "light") - +@setup_entity("light") +async def setup_light_core_(light_var, config, output_var): cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE])) if (initial_state_config := config.get(CONF_INITIAL_STATE)) is not None: @@ -312,7 +311,7 @@ async def register_light(output_var, config): cg.add(cg.App.register_light(light_var)) CORE.register_platform_component("light", light_var) await cg.register_component(light_var, config) - await setup_light_core_(light_var, output_var, config) + await setup_light_core_(light_var, config, output_var) async def new_light(config, *args): diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 9d893d3ad9..e37092756f 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -91,9 +91,8 @@ def lock_schema( return _LOCK_SCHEMA.extend(schema) +@setup_entity("lock") async def _setup_lock_core(var, config): - await setup_entity(var, config, "lock") - for conf in config.get(CONF_ON_LOCK, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index b2afbe5e58..051e386eaf 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -96,8 +96,8 @@ VolumeSetAction = media_player_ns.class_( ) +@setup_entity("media_player") async def setup_media_player_core_(var, config): - await setup_entity(var, config, "media_player") for conf_key, _ in _STATE_TRIGGERS: for conf in config.get(conf_key, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index fdc909fcc9..a2734f2beb 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -48,7 +48,7 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_MAX] = traits.get_max_value(); root[MQTT_STEP] = traits.get_step(); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto unit_of_measurement = this->number_->traits.get_unit_of_measurement_ref(); + const auto unit_of_measurement = this->number_->get_unit_of_measurement_ref(); if (!unit_of_measurement.empty()) { root[MQTT_UNIT_OF_MEASUREMENT] = unit_of_measurement; } @@ -57,7 +57,7 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_MODE] = NumberMqttModeStrings::get_progmem_str(static_cast(mode), static_cast(NUMBER_MODE_BOX)); } - const auto device_class = this->number_->traits.get_device_class_ref(); + const auto device_class = this->number_->get_device_class_ref(); if (!device_class.empty()) { root[MQTT_DEVICE_CLASS] = device_class; } diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 2238f2c037..0570ac0b1e 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,7 +79,12 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, + setup_unit_of_measurement, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -257,11 +262,10 @@ async def _build_number_automations(var, config): await automation.build_automation(trigger, [(float, "x")], conf) +@setup_entity("number") async def setup_number_core_( var, config, *, min_value: float, max_value: float, step: float ): - await setup_entity(var, config, "number") - cg.add(var.traits.set_min_value(min_value)) cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) @@ -273,10 +277,8 @@ async def setup_number_core_( CORE.add_job(_build_number_automations, var, config) - if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None: - cg.add(var.traits.set_unit_of_measurement(unit_of_measurement)) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.traits.set_device_class(device_class)) + setup_device_class(config) + setup_unit_of_measurement(config) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 1c4126496c..c0653c3b30 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -15,8 +15,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); LOG_ENTITY_ICON(tag, prefix, *obj); - LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj->traits); - LOG_ENTITY_DEVICE_CLASS(tag, prefix, obj->traits); + LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, *obj); + LOG_ENTITY_DEVICE_CLASS(tag, prefix, *obj); } void Number::publish_state(float state) { diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index 5ccbb9ba48..f855813c9b 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -1,7 +1,7 @@ #pragma once -#include "esphome/core/entity_base.h" -#include "esphome/core/helpers.h" +#include +#include namespace esphome::number { @@ -11,7 +11,7 @@ enum NumberMode : uint8_t { NUMBER_MODE_SLIDER = 2, }; -class NumberTraits : public EntityBase_DeviceClass, public EntityBase_UnitOfMeasurement { +class NumberTraits { public: // Set/get the number value boundaries. void set_min_value(float min_value) { min_value_ = min_value; } diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index a15ee7e263..63b154d80d 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -34,6 +34,9 @@ void HOT arch_feed_wdt() { watchdog_update(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } +const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } uint32_t HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index c114b140a9..b2c17f59ac 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -92,9 +92,8 @@ def select_schema( return _SELECT_SCHEMA.extend(schema) +@setup_entity("select") async def setup_select_core_(var, config, *, options: list[str]): - await setup_entity(var, config, "select") - cg.add(var.traits.set_options(options)) for conf in config.get(CONF_ON_VALUE, []): diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 338aaae0b5..4be6ed1b84 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -106,7 +106,12 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, + setup_unit_of_measurement, +) from esphome.cpp_generator import MockObj, MockObjClass from esphome.util import Registry @@ -908,15 +913,12 @@ async def _build_sensor_automations(var, config): await automation.build_automation(trigger, [(float, "x")], conf) +@setup_entity("sensor") async def setup_sensor_core_(var, config): - await setup_entity(var, config, "sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) + setup_unit_of_measurement(config) if (state_class := config.get(CONF_STATE_CLASS)) is not None: cg.add(var.set_state_class(state_class)) - if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None: - cg.add(var.set_unit_of_measurement(unit_of_measurement)) if (accuracy_decimals := config.get(CONF_ACCURACY_DECIMALS)) is not None: cg.add(var.set_accuracy_decimals(accuracy_decimals)) # Only set force_update if True (default is False) diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 54e75ee2a1..197896f6f6 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -44,7 +44,7 @@ const LogString *state_class_to_string(StateClass state_class); * * A sensor has unit of measurement and can use publish_state to send out a new value with the specified accuracy. */ -class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBase_UnitOfMeasurement { +class Sensor : public EntityBase { public: explicit Sensor(); diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index d82d7baaf6..44fb9092bc 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -567,7 +567,7 @@ void Sprinkler::set_valve_run_duration(const optional valve_number, cons return; } auto call = this->valve_[valve_number.value()].run_duration_number->make_call(); - if (this->valve_[valve_number.value()].run_duration_number->traits.get_unit_of_measurement_ref() == MIN_STR) { + if (this->valve_[valve_number.value()].run_duration_number->get_unit_of_measurement_ref() == MIN_STR) { call.set_value(run_duration.value() / 60.0); } else { call.set_value(run_duration.value()); @@ -649,7 +649,7 @@ uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { return 0; } if (this->valve_[valve_number].run_duration_number != nullptr) { - if (this->valve_[valve_number].run_duration_number->traits.get_unit_of_measurement_ref() == MIN_STR) { + if (this->valve_[valve_number].run_duration_number->get_unit_of_measurement_ref() == MIN_STR) { return static_cast(roundf(this->valve_[valve_number].run_duration_number->state * 60)); } else { return static_cast(roundf(this->valve_[valve_number].run_duration_number->state)); diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 6f1be7d53d..bbafc54bd1 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( DEVICE_CLASS_SWITCH, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -154,9 +158,8 @@ async def _build_switch_automations(var, config): await automation.build_automation(trigger, [], conf) +@setup_entity("switch") async def setup_switch_core_(var, config): - await setup_entity(var, config, "switch") - if (inverted := config.get(CONF_INVERTED)) is not None: cg.add(var.set_inverted(inverted)) @@ -169,8 +172,7 @@ async def setup_switch_core_(var, config): if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) await zigbee.setup_switch(var, config) diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 982c640cf9..c4f8525793 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -35,7 +35,7 @@ enum SwitchRestoreMode : uint8_t { * A switch is basically just a combination of a binary sensor (for reporting switch values) * and a write_state method that writes a state to the hardware. */ -class Switch : public EntityBase, public EntityBase_DeviceClass { +class Switch : public EntityBase { public: explicit Switch(); diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 61f7119cad..224f4580d4 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -84,6 +84,7 @@ def text_schema( return _TEXT_SCHEMA.extend(schema) +@setup_entity("text") async def setup_text_core_( var, config, @@ -92,8 +93,6 @@ async def setup_text_core_( max_length: int | None, pattern: str | None, ): - await setup_entity(var, config, "text") - cg.add(var.traits.set_min_length(min_length)) cg.add(var.traits.set_max_length(max_length)) if pattern is not None: diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 2edf202cd2..97f394ecf7 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -21,7 +21,11 @@ from esphome.const import ( DEVICE_CLASS_TIMESTAMP, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -208,11 +212,9 @@ async def _build_text_sensor_automations(var, config): await automation.build_automation(trigger, [(cg.std_string, "x")], conf) +@setup_entity("text_sensor") async def setup_text_sensor_core_(var, config): - await setup_entity(var, config, "text_sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 9916aa63b2..d26cfade96 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -25,7 +25,7 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text public: \ void set_##name##_text_sensor(text_sensor::TextSensor *text_sensor) { this->name##_text_sensor_ = text_sensor; } -class TextSensor : public EntityBase, public EntityBase_DeviceClass { +class TextSensor : public EntityBase { public: std::string state; diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index e146f7e685..c36a4ab769 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -15,7 +15,11 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@jesserockz"] @@ -87,11 +91,9 @@ def update_schema( return _UPDATE_SCHEMA.extend(schema) +@setup_entity("update") async def setup_update_core_(var, config): - await setup_entity(var, config, "update") - - if device_class_config := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class_config)) + setup_device_class(config) if on_update_available := config.get(CONF_ON_UPDATE_AVAILABLE): await automation.build_automation( diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index 405346bee4..82eaacaf76 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -29,7 +29,7 @@ enum UpdateState : uint8_t { const LogString *update_state_to_string(UpdateState state); -class UpdateEntity : public EntityBase, public EntityBase_DeviceClass { +class UpdateEntity : public EntityBase { public: void publish_state(); diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 73e907eb0f..22cd01988d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( DEVICE_CLASS_WATER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -129,11 +133,9 @@ def valve_schema( return _VALVE_SCHEMA.extend(schema) +@setup_entity("valve") async def _setup_valve_core(var, config): - await setup_entity(var, config, "valve") - - if device_class_config := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class_config)) + setup_device_class(config) for conf in config.get(CONF_ON_OPEN, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index cd46144372..aab819a778 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -101,7 +101,7 @@ const LogString *valve_operation_to_str(ValveOperation op); * to control all values of the valve. Also implement get_traits() to return what operations * the valve supports. */ -class Valve : public EntityBase, public EntityBase_DeviceClass { +class Valve : public EntityBase { public: explicit Valve(); diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index db32c2d919..58cf5a4054 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -69,10 +69,9 @@ def water_heater_schema( return _WATER_HEATER_SCHEMA.extend(schema) +@setup_entity("water_heater") async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> None: """Set up the core water heater properties in C++.""" - await setup_entity(var, config, "water_heater") - visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 47e427c0d1..6b94a103cc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1139,7 +1139,7 @@ json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float v json::JsonBuilder builder; JsonObject root = builder.root(); - const auto uom_ref = obj->traits.get_unit_of_measurement_ref(); + const auto uom_ref = obj->get_unit_of_measurement_ref(); const int8_t accuracy = step_to_accuracy_decimals(obj->traits.get_step()); // Need two buffers: one for value, one for state with UOM diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index cf3ea70245..eee7fb3f4f 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -60,6 +60,7 @@ void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } Mutex::Mutex() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d778edf2a..07afefd91a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -44,7 +44,9 @@ #define USE_DEEP_SLEEP #define USE_DEVICES #define USE_DISPLAY +#define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON +#define USE_ENTITY_UNIT_OF_MEASUREMENT #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index f6a7ec1dfd..eafc04f92a 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -45,24 +45,42 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { } } -// Entity Icon -std::string EntityBase::get_icon() const { -#ifdef USE_ENTITY_ICON - if (this->icon_c_str_ == nullptr) { - return ""; - } - return this->icon_c_str_; +// Weak default lookup functions — overridden by generated code in main.cpp +__attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } +__attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } +__attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ""; } + +// Entity device class (from index) +StringRef EntityBase::get_device_class_ref() const { +#ifdef USE_ENTITY_DEVICE_CLASS + return StringRef(entity_device_class_lookup(this->device_class_idx_)); #else - return ""; + return StringRef(entity_device_class_lookup(0)); #endif } -void EntityBase::set_icon(const char *icon) { -#ifdef USE_ENTITY_ICON - this->icon_c_str_ = icon; +std::string EntityBase::get_device_class() const { return std::string(this->get_device_class_ref().c_str()); } + +// Entity unit of measurement (from index) +StringRef EntityBase::get_unit_of_measurement_ref() const { +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + return StringRef(entity_uom_lookup(this->uom_idx_)); #else - // No-op when USE_ENTITY_ICON is not defined + return StringRef(entity_uom_lookup(0)); #endif } +std::string EntityBase::get_unit_of_measurement() const { + return std::string(this->get_unit_of_measurement_ref().c_str()); +} + +// Entity icon (from index) +StringRef EntityBase::get_icon_ref() const { +#ifdef USE_ENTITY_ICON + return StringRef(entity_icon_lookup(this->icon_idx_)); +#else + return StringRef(entity_icon_lookup(0)); +#endif +} +std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); } // Entity Object ID - computed on-demand from name std::string EntityBase::get_object_id() const { @@ -134,24 +152,6 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve return global_preferences->make_preference(size, key); } -std::string EntityBase_DeviceClass::get_device_class() { - if (this->device_class_ == nullptr) { - return ""; - } - return this->device_class_; -} - -void EntityBase_DeviceClass::set_device_class(const char *device_class) { this->device_class_ = device_class; } - -std::string EntityBase_UnitOfMeasurement::get_unit_of_measurement() { - if (this->unit_of_measurement_ == nullptr) - return ""; - return this->unit_of_measurement_; -} -void EntityBase_UnitOfMeasurement::set_unit_of_measurement(const char *unit_of_measurement) { - this->unit_of_measurement_ = unit_of_measurement; -} - #ifdef USE_ENTITY_ICON void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_icon_ref().empty()) { @@ -160,13 +160,13 @@ void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) } #endif -void log_entity_device_class(const char *tag, const char *prefix, const EntityBase_DeviceClass &obj) { +void log_entity_device_class(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_device_class_ref().empty()) { ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj.get_device_class_ref().c_str()); } } -void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase_UnitOfMeasurement &obj) { +void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_unit_of_measurement_ref().empty()) { ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj.get_unit_of_measurement_ref().c_str()); } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index cbc07cc44c..042eebb40f 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -14,6 +14,12 @@ namespace esphome { +// Extern lookup functions for entity string tables. +// Generated code provides strong definitions; weak defaults return "". +extern const char *entity_device_class_lookup(uint8_t index); +extern const char *entity_uom_lookup(uint8_t index); +extern const char *entity_icon_lookup(uint8_t index); + // Maximum device name length - keep in sync with validate_hostname() in esphome/core/config.py static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; @@ -89,20 +95,41 @@ class EntityBase { this->flags_.entity_category = static_cast(entity_category); } + // Set entity string table indices — one call per entity from codegen. + // Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits) + void set_entity_strings([[maybe_unused]] uint32_t packed) { +#ifdef USE_ENTITY_DEVICE_CLASS + this->device_class_idx_ = packed & 0xFF; +#endif +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + this->uom_idx_ = (packed >> 8) & 0xFF; +#endif +#ifdef USE_ENTITY_ICON + this->icon_idx_ = (packed >> 16) & 0xFF; +#endif + } + + // Get device class as StringRef (from packed index) + StringRef get_device_class_ref() const; + /// Get the device class as std::string (deprecated, prefer get_device_class_ref()) + ESPDEPRECATED("Use get_device_class_ref() instead for better performance (avoids string copy). Will be removed in " + "ESPHome 2026.9.0", + "2026.3.0") + std::string get_device_class() const; + // Get unit of measurement as StringRef (from packed index) + StringRef get_unit_of_measurement_ref() const; + /// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref()) + ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " + "removed in ESPHome 2026.9.0", + "2026.3.0") + std::string get_unit_of_measurement() const; + // Get/set this entity's icon ESPDEPRECATED( "Use get_icon_ref() instead for better performance (avoids string copy). Will be removed in ESPHome 2026.5.0", "2025.11.0") std::string get_icon() const; - void set_icon(const char *icon); - StringRef get_icon_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); -#ifdef USE_ENTITY_ICON - return this->icon_c_str_ == nullptr ? EMPTY_STRING : StringRef(this->icon_c_str_); -#else - return EMPTY_STRING; -#endif - } + StringRef get_icon_ref() const; #ifdef USE_DEVICES // Get/set this entity's device id @@ -173,9 +200,6 @@ class EntityBase { void calc_object_id_(); StringRef name_; -#ifdef USE_ENTITY_ICON - const char *icon_c_str_{nullptr}; -#endif uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; @@ -190,44 +214,16 @@ class EntityBase { uint8_t entity_category : 2; // Supports up to 4 categories uint8_t reserved : 2; // Reserved for future use } flags_{}; -}; - -class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) - public: - /// Get the device class, using the manual override if set. - ESPDEPRECATED("Use get_device_class_ref() instead for better performance (avoids string copy). Will be removed in " - "ESPHome 2026.5.0", - "2025.11.0") - std::string get_device_class(); - /// Manually set the device class. - void set_device_class(const char *device_class); - /// Get the device class as StringRef - StringRef get_device_class_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); - return this->device_class_ == nullptr ? EMPTY_STRING : StringRef(this->device_class_); - } - - protected: - const char *device_class_{nullptr}; ///< Device class override -}; - -class EntityBase_UnitOfMeasurement { // NOLINT(readability-identifier-naming) - public: - /// Get the unit of measurement, using the manual override if set. - ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " - "removed in ESPHome 2026.5.0", - "2025.11.0") - std::string get_unit_of_measurement(); - /// Manually set the unit of measurement. - void set_unit_of_measurement(const char *unit_of_measurement); - /// Get the unit of measurement as StringRef - StringRef get_unit_of_measurement_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); - return this->unit_of_measurement_ == nullptr ? EMPTY_STRING : StringRef(this->unit_of_measurement_); - } - - protected: - const char *unit_of_measurement_{nullptr}; ///< Unit of measurement override + // String table indices — packed into the 3 padding bytes after flags_ +#ifdef USE_ENTITY_DEVICE_CLASS + uint8_t device_class_idx_{}; +#endif +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + uint8_t uom_idx_{}; +#endif +#ifdef USE_ENTITY_ICON + uint8_t icon_idx_{}; +#endif }; /// Log entity icon if set (for use in dump_config) @@ -240,10 +236,10 @@ inline void log_entity_icon(const char *, const char *, const EntityBase &) {} #endif /// Log entity device class if set (for use in dump_config) #define LOG_ENTITY_DEVICE_CLASS(tag, prefix, obj) log_entity_device_class(tag, prefix, obj) -void log_entity_device_class(const char *tag, const char *prefix, const EntityBase_DeviceClass &obj); +void log_entity_device_class(const char *tag, const char *prefix, const EntityBase &obj); /// Log entity unit of measurement if set (for use in dump_config) #define LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj) log_entity_unit_of_measurement(tag, prefix, obj) -void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase_UnitOfMeasurement &obj); +void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj); /** * An entity that has a state. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index c1801c0bda..551e35df65 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,9 +1,12 @@ from collections.abc import Callable +from dataclasses import dataclass, field +import functools import logging import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ENTITY_CATEGORY, @@ -11,15 +14,184 @@ from esphome.const import ( CONF_ID, CONF_INTERNAL, CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, add, get_variable +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, RawStatement, add, get_variable import esphome.final_validate as fv -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) +DOMAIN = "entity_string_pool" + +# Private config keys for storing registered string indices +_KEY_DC_IDX = "_entity_dc_idx" +_KEY_UOM_IDX = "_entity_uom_idx" +_KEY_ICON_IDX = "_entity_icon_idx" + +# Bit layout for set_entity_strings(packed) — must match C++ setter in entity_base.h: +# [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) +_DC_SHIFT = 0 +_UOM_SHIFT = 8 +_ICON_SHIFT = 16 + +# Maximum unique strings per category (8-bit index, 0 = not set) +_MAX_DEVICE_CLASSES = 0xFF # 255 +_MAX_UNITS = 0xFF # 255 +_MAX_ICONS = 0xFF # 255 + + +@dataclass +class EntityStringPool: + """Pool of entity string properties for PROGMEM pointer tables. + + Strings are registered during to_code() and assigned 1-based indices. + Index 0 means "not set" (empty string). At render time, the pool + generates C++ PROGMEM pointer table + lookup function per category. + """ + + device_classes: dict[str, int] = field(default_factory=dict) + units: dict[str, int] = field(default_factory=dict) + icons: dict[str, int] = field(default_factory=dict) + tables_registered: bool = False + + +def _get_pool() -> EntityStringPool: + """Get or create the entity string pool from CORE.data.""" + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = EntityStringPool() + return CORE.data[DOMAIN] + + +def _ensure_tables_registered() -> None: + """Schedule the table generation job (once).""" + pool = _get_pool() + if pool.tables_registered: + return + pool.tables_registered = True + CORE.add_job(_generate_tables_job) + + +def _generate_category_code( + table_var: str, + lookup_fn: str, + strings: dict[str, int], +) -> str: + """Generate C++ code for one string category (PROGMEM pointer table + lookup). + + Uses a PROGMEM array of string pointers. On ESP8266, pointers are stored + in flash (via PROGMEM) and read with progmem_read_ptr(). String literals + themselves remain in RAM but benefit from linker string deduplication. + Index 0 means "not set" and returns empty string. + """ + if not strings: + return "" + + sorted_strings = sorted(strings.items(), key=lambda x: x[1]) + entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings) + count = len(sorted_strings) + + return ( + f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n" + f"const char *{lookup_fn}(uint8_t index) {{\n" + f' if (index == 0 || index > {count}) return "";\n' + f" return progmem_read_ptr(&{table_var}[index - 1]);\n" + f"}}\n" + ) + + +_CATEGORY_CONFIGS = ( + ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes"), + ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units"), + ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons"), +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _generate_tables_job() -> None: + """Generate all entity string table C++ code as a FINAL-priority job. + + Runs after all component to_code() calls have registered their strings. + """ + pool = _get_pool() + parts = ["namespace esphome {"] + for table_var, lookup_fn, attr in _CATEGORY_CONFIGS: + code = _generate_category_code(table_var, lookup_fn, getattr(pool, attr)) + if code: + parts.append(code) + parts.append("} // namespace esphome") + cg.add_global(RawStatement("\n".join(parts))) + + +def _register_string( + value: str, category: dict[str, int], max_count: int, category_name: str +) -> int: + """Register a string in a category dict and return its 1-based index. + + Returns 0 if value is empty/None (meaning "not set"). + """ + if not value: + return 0 + if value in category: + return category[value] + idx = len(category) + 1 + if idx > max_count: + raise ValueError( + f"Too many unique {category_name} values (max {max_count}), got {idx}: '{value}'" + ) + category[value] = idx + _ensure_tables_registered() + return idx + + +def register_device_class(value: str) -> int: + """Register a device_class string and return its 1-based index.""" + return _register_string( + value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class" + ) + + +def register_unit_of_measurement(value: str) -> int: + """Register a unit_of_measurement string and return its 1-based index.""" + return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement") + + +def register_icon(value: str) -> int: + """Register an icon string and return its 1-based index.""" + return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") + + +def setup_device_class(config: ConfigType) -> None: + """Register config's device_class and store its index for finalize_entity_strings.""" + idx = register_device_class(config.get(CONF_DEVICE_CLASS, "")) + if idx: + cg.add_define("USE_ENTITY_DEVICE_CLASS") + config[_KEY_DC_IDX] = idx + + +def setup_unit_of_measurement(config: ConfigType) -> None: + """Register config's unit_of_measurement and store its index for finalize_entity_strings.""" + idx = register_unit_of_measurement(config.get(CONF_UNIT_OF_MEASUREMENT, "")) + if idx: + cg.add_define("USE_ENTITY_UNIT_OF_MEASUREMENT") + config[_KEY_UOM_IDX] = idx + + +def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: + """Emit a single set_entity_strings() call with all packed indices. + + Call this at the end of each component's setup function, after + setup_entity() and any register_device_class/register_unit_of_measurement calls. + """ + dc_idx = config.get(_KEY_DC_IDX, 0) + uom_idx = config.get(_KEY_UOM_IDX, 0) + icon_idx = config.get(_KEY_ICON_IDX, 0) + packed = (dc_idx << _DC_SHIFT) | (uom_idx << _UOM_SHIFT) | (icon_idx << _ICON_SHIFT) + if packed != 0: + add(var.set_entity_strings(packed)) + def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None @@ -64,8 +236,48 @@ def get_base_entity_object_id( return sanitize(snake_case(base_str)) -async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: - """Set up generic properties of an Entity. +def setup_entity(var_or_platform, config=None, platform=None): + """Set up entity properties — works as both decorator and direct call. + + Decorator mode:: + + @setup_entity("sensor") + async def setup_sensor_core_(var, config): + setup_device_class(config) + setup_unit_of_measurement(config) + ... + + Direct call mode (for entities with no extra string properties):: + + await setup_entity(var, config, "camera") + """ + if isinstance(var_or_platform, str) and config is None: + # Decorator mode: @setup_entity("sensor") + platform = var_or_platform + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper( + var: MockObj, config: ConfigType, *args, **kwargs + ) -> None: + await _setup_entity_impl(var, config, platform) + await func(var, config, *args, **kwargs) + finalize_entity_strings(var, config) + + return wrapper + + return decorator + + # Direct call mode: await setup_entity(var, config, "camera") + async def _do() -> None: + await _setup_entity_impl(var_or_platform, config, platform) + finalize_entity_strings(var_or_platform, config) + + return _do() + + +async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> None: + """Set up generic properties of an Entity (internal implementation). This function sets up the common entity properties like name, icon, entity category, etc. @@ -92,12 +304,15 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: add(var.set_disabled_by_default(True)) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) + icon_idx = 0 if CONF_ICON in config: # Add USE_ENTITY_ICON define when icons are used cg.add_define("USE_ENTITY_ICON") - add(var.set_icon(config[CONF_ICON])) + icon_idx = register_icon(config[CONF_ICON]) if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) + # Store icon index for finalize_entity_strings + config[_KEY_ICON_IDX] = icon_idx def inherit_property_from(property_to_inherit, parent_id_property, transform=None): diff --git a/esphome/core/hal.h b/esphome/core/hal.h index ef45be629d..c2c9b1a325 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -42,6 +42,7 @@ void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); uint8_t progmem_read_byte(const uint8_t *addr); +const char *progmem_read_ptr(const char *const *addr); uint16_t progmem_read_uint16(const uint16_t *addr); } // namespace esphome diff --git a/script/clang-tidy b/script/clang-tidy index 17bcafacc7..9c2899026d 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -79,6 +79,7 @@ def clang_options(idedata): "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", + "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", "-DPROGMEM=", "-DPGM_P=const char *", "-DPSTR(s)=(s)", diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 35ce1f4e11..221e7edf2c 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -11,4 +11,4 @@ def test_sensor_device_class_set(generate_main): main_cpp = generate_main("tests/component_tests/sensor/test_sensor.yaml") # Then - assert 's_1->set_device_class("voltage");' in main_cpp + assert "s_1->set_entity_strings(" in main_cpp diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 1593d0b6d8..4aaebe04d1 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -54,5 +54,5 @@ def test_text_sensor_device_class_set(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_2->set_device_class("timestamp");' in main_cpp - assert 'ts_3->set_device_class("date");' in main_cpp + assert "ts_2->set_entity_strings(" in main_cpp + assert "ts_3->set_entity_strings(" in main_cpp diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index a58d4784ce..a5cfad5ab6 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -11,6 +11,7 @@ from esphome.config_validation import Invalid from esphome.const import ( CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, + CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_INTERNAL, @@ -18,6 +19,8 @@ from esphome.const import ( ) from esphome.core import CORE, ID, entity_helpers from esphome.core.entity_helpers import ( + _register_string, + _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, setup_entity, @@ -305,7 +308,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> CONF_NAME: "Temperature", CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var1, config1, "sensor") + await _setup_entity_impl(var1, config1, "sensor") # Get object ID from first entity object_id1 = extract_object_id_from_expressions(added_expressions) @@ -319,7 +322,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> CONF_NAME: "Humidity", CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var2, config2, "sensor") + await _setup_entity_impl(var2, config2, "sensor") # Get object ID from second entity object_id2 = extract_object_id_from_expressions(added_expressions) @@ -354,7 +357,7 @@ async def test_setup_entity_different_platforms( object_ids: list[str] = [] for var, platform in platforms: added_expressions.clear() - await setup_entity(var, config, platform) + await _setup_entity_impl(var, config, platform) object_id = extract_object_id_from_expressions(added_expressions) object_ids.append(object_id) @@ -416,7 +419,7 @@ async def test_setup_entity_with_devices( object_ids: list[str] = [] for var, config in [(sensor1, config1), (sensor2, config2)]: added_expressions.clear() - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) object_ids.append(object_id) @@ -438,7 +441,7 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) # Should use friendly name @@ -460,7 +463,7 @@ async def test_setup_entity_special_characters( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) # Special characters should be sanitized @@ -471,7 +474,7 @@ async def test_setup_entity_special_characters( async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None: """Test setup_entity sets icon correctly.""" - added_expressions = setup_test_environment + setup_test_environment # noqa: F841 - fixture initializes CORE state var = MockObj("sensor1") @@ -481,12 +484,10 @@ async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None CONF_ICON: "mdi:thermometer", } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") - # Check icon was set - assert any( - 'sensor1.set_icon("mdi:thermometer")' in expr for expr in added_expressions - ) + # Check icon index was stored in config for finalize_entity_strings + assert config.get("_entity_icon_idx", 0) > 0 @pytest.mark.asyncio @@ -504,7 +505,7 @@ async def test_setup_entity_disabled_by_default( CONF_DISABLED_BY_DEFAULT: True, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # Check disabled_by_default was set assert any( @@ -790,7 +791,7 @@ async def test_setup_entity_empty_name_with_device( CONF_DEVICE_ID: device_id, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") entity_helpers.get_variable = original_get_variable @@ -826,7 +827,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( @@ -858,7 +859,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( @@ -891,9 +892,84 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( f"Expected set_name with hash 0, got {added_expressions}" ) + + +def test_register_string_overflow() -> None: + """Test _register_string raises ValueError when max count is exceeded.""" + category: dict[str, int] = {} + for i in range(3): + _register_string(f"val_{i}", category, 3, "test") + with pytest.raises(ValueError, match="Too many unique test values"): + _register_string("overflow", category, 3, "test") + + +@pytest.mark.asyncio +async def test_setup_entity_with_entity_category( + setup_test_environment: list[str], +) -> None: + """Test setup_entity sets entity_category correctly.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ENTITY_CATEGORY: "diagnostic", + } + await _setup_entity_impl(var, config, "sensor") + assert any( + 'set_entity_category("diagnostic")' in expr for expr in added_expressions + ) + + +@pytest.mark.asyncio +async def test_setup_entity_direct_call(setup_test_environment: list[str]) -> None: + """Test setup_entity in direct call mode (legacy / backward compat).""" + added_expressions = setup_test_environment + + var = MockObj("camera1") + config = { + CONF_NAME: "My Camera", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ICON: "mdi:camera", + } + + # Direct call mode: await setup_entity(var, config, "camera") + await setup_entity(var, config, "camera") + + # Should have called set_name + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "my_camera" + + # Icon index should have been stored and finalized + assert config.get("_entity_icon_idx", 0) > 0 + + +@pytest.mark.asyncio +async def test_setup_entity_decorator_mode(setup_test_environment: list[str]) -> None: + """Test setup_entity in decorator mode.""" + added_expressions = setup_test_environment + + body_called = False + + @setup_entity("sensor") + async def my_setup(var, config): + nonlocal body_called + body_called = True + + var = MockObj("sensor1") + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + } + + await my_setup(var, config) + + assert body_called + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "temperature" From 78602ccacb33dcf3acdad5a55be497af52cc4287 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 07:03:50 -1000 Subject: [PATCH 29/36] [ci] Add lint check to prevent powf in core and base entity platforms (#14126) --- esphome/core/helpers.cpp | 4 ++-- esphome/core/helpers.h | 4 ++-- script/ci-custom.py | 48 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index c75799fe57..00b447ebf2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -706,7 +706,7 @@ float gamma_correct(float value, float gamma) { if (gamma <= 0.0f) return value; - return powf(value, gamma); + return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0 } float gamma_uncorrect(float value, float gamma) { if (value <= 0.0f) @@ -714,7 +714,7 @@ float gamma_uncorrect(float value, float gamma) { if (gamma <= 0.0f) return value; - return powf(value, 1 / gamma); + return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0 } void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 187b383f65..6ce5de4975 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -512,8 +512,8 @@ template class SmallBufferWithHeapFallb ///@{ /// Compute 10^exp using iterative multiplication/division. -/// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. -/// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. +/// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. // NOLINT +/// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. // NOLINT inline float pow10_int(int8_t exp) { float result = 1.0f; if (exp >= 0) { diff --git a/script/ci-custom.py b/script/ci-custom.py index f428eb0821..b60d7d7740 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -841,6 +841,54 @@ def lint_no_scanf(fname, match): ) +# Base entity platforms - these are linked into most builds and should not +# pull in powf/__ieee754_powf (~2.3KB flash). +BASE_ENTITY_PLATFORMS = [ + "alarm_control_panel", + "binary_sensor", + "button", + "climate", + "cover", + "datetime", + "event", + "fan", + "light", + "lock", + "media_player", + "number", + "select", + "sensor", + "switch", + "text", + "text_sensor", + "update", + "valve", + "water_heater", +] + +# Directories protected from powf: core + all base entity platforms +POWF_PROTECTED_DIRS = ["esphome/core"] + [ + f"esphome/components/{p}" for p in BASE_ENTITY_PLATFORMS +] + + +@lint_re_check( + r"[^\w]powf\s*\(" + CPP_RE_EOL, + include=[ + f"{d}/*.{ext}" for d in POWF_PROTECTED_DIRS for ext in ["h", "cpp", "tcc"] + ], +) +def lint_no_powf_in_core(fname, match): + return ( + f"{highlight('powf()')} pulls in __ieee754_powf (~2.3KB flash) and is not allowed in " + f"core or base entity platform code. These files are linked into every build.\n" + f"Please use alternatives:\n" + f" - {highlight('pow10_int(exp)')} for integer powers of 10 (from helpers.h)\n" + f" - Precomputed lookup tables for gamma/non-integer exponents\n" + f"(If powf is strictly necessary, add `// NOLINT` to the line)" + ) + + LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL) LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])') LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') From 4f69c487daa565dbe43a87f5b489f3dd40a3ff68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 07:04:12 -1000 Subject: [PATCH 30/36] [bk72xx] Fix ~100ms loop stalls by raising main task priority (#14420) --- esphome/components/libretiny/core.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 893a79440a..6bb2d9dcc1 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -7,6 +7,9 @@ #include "esphome/core/helpers.h" #include "preferences.h" +#include +#include + void setup(); void loop(); @@ -22,6 +25,22 @@ void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } void arch_init() { libretiny::setup_preferences(); lt_wdt_enable(10000L); +#ifdef USE_BK72XX + // BK72xx SDK creates the main Arduino task at priority 3, which is lower than + // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop + // stalls whenever WiFi background processing runs, because the main task + // cannot resume until every higher-priority task finishes. + // + // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). + // + // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the + // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. + // This is safe because ESPHome yields voluntarily via yield_with_select_() and + // the Arduino mainTask yield() after each loop() iteration. + static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; + static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); + vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); +#endif #if LT_GPIO_RECOVER lt_gpio_recover(); #endif From b209c903bb6991c1242d40e347e3625f594bdaab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 07:05:15 -1000 Subject: [PATCH 31/36] [core] Inline trivial Component state accessors (#14425) --- esphome/core/component.cpp | 8 -------- esphome/core/component.h | 12 ++++++------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 53cb50a44c..4ccc747819 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -233,7 +233,6 @@ void Component::call_dump_config_() { } } -uint8_t Component::get_component_state() const { return this->component_state_; } void Component::call() { uint8_t state = this->component_state_ & COMPONENT_STATE_MASK; switch (state) { @@ -339,9 +338,6 @@ void Component::reset_to_construction_state() { this->status_clear_error(); } } -bool Component::is_in_loop_state() const { - return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP; -} void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), 0, std::move(f)); } @@ -380,16 +376,12 @@ void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std: App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); #pragma GCC diagnostic pop } -bool Component::is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; } bool Component::is_ready() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP || (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE || (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_SETUP; } -bool Component::is_idle() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE; } bool Component::can_proceed() { return true; } -bool Component::status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } -bool Component::status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } bool Component::set_status_flag_(uint8_t flag) { if ((this->component_state_ & flag) != 0) return false; diff --git a/esphome/core/component.h b/esphome/core/component.h index d8102ea670..e5127b0c9f 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -142,7 +142,7 @@ class Component { */ virtual void on_powerdown() {} - uint8_t get_component_state() const; + uint8_t get_component_state() const { return this->component_state_; } /** Reset this component back to the construction state to allow setup to run again. * @@ -154,7 +154,7 @@ class Component { * * @return True if in loop state, false otherwise. */ - bool is_in_loop_state() const; + bool is_in_loop_state() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP; } /** Check if this component is idle. * Being idle means being in LOOP_DONE state. @@ -162,7 +162,7 @@ class Component { * * @return True if the component is idle */ - bool is_idle() const; + bool is_idle() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE; } /** Mark this component as failed. Any future timeouts/intervals/setup/loop will no longer be called. * @@ -230,15 +230,15 @@ class Component { */ void enable_loop_soon_any_context(); - bool is_failed() const; + bool is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; } bool is_ready() const; virtual bool can_proceed(); - bool status_has_warning() const; + bool status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } - bool status_has_error() const; + bool status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } void status_set_warning(const char *message = nullptr); void status_set_warning(const LogString *message); From 95544dddf8bd2171ff42da909fe9cd9786a10710 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 07:11:47 -1000 Subject: [PATCH 32/36] [ci] Add code-owner-approved label workflow (#14421) --- .github/scripts/auto-label-pr/detectors.js | 47 +----- .github/scripts/codeowners.js | 143 ++++++++++++++++ .../workflows/codeowner-approved-label.yml | 158 ++++++++++++++++++ .../workflows/codeowner-review-request.yml | 118 ++++--------- 4 files changed, 342 insertions(+), 124 deletions(-) create mode 100644 .github/scripts/codeowners.js create mode 100644 .github/workflows/codeowner-approved-label.yml diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 80d8847bc1..832fcb41db 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -7,6 +7,7 @@ const { hasDashboardChanges, hasGitHubActionsChanges, } = require('../detect-tags'); +const { loadCodeowners, getEffectiveOwners } = require('../codeowners'); // Strategy: Merge branch detection async function detectMergeBranch(context) { @@ -148,51 +149,15 @@ async function detectGitHubActionsChanges(changedFiles) { // 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 codeownersPatterns = loadCodeowners(); 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; - } - } + // Check if PR author is a codeowner of any changed file + const effective = getEffectiveOwners(changedFiles, codeownersPatterns); + if (effective.users.has(prAuthor)) { + labels.add('by-code-owner'); } } catch (error) { console.log('Failed to read or parse CODEOWNERS file:', error.message); diff --git a/.github/scripts/codeowners.js b/.github/scripts/codeowners.js new file mode 100644 index 0000000000..9a10391699 --- /dev/null +++ b/.github/scripts/codeowners.js @@ -0,0 +1,143 @@ +// Shared CODEOWNERS parsing and matching utilities. +// +// Used by: +// - codeowner-review-request.yml +// - codeowner-approved-label.yml +// - auto-label-pr/detectors.js (detectCodeOwner) + +/** + * Convert a CODEOWNERS glob pattern to a RegExp. + * + * Handles **, *, and ? wildcards after escaping regex-special characters. + */ +function globToRegex(pattern) { + let regexStr = pattern + .replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') + .replace(/\*\*/g, '\x00GLOBSTAR\x00') // protect ** from next replace + .replace(/\*/g, '[^/]*') // single star + .replace(/\x00GLOBSTAR\x00/g, '.*') // restore globstar + .replace(/\?/g, '.'); + return new RegExp('^' + regexStr + '$'); +} + +/** + * Parse raw CODEOWNERS file content into an array of + * { pattern, regex, owners } objects. + * + * Each `owners` entry is the raw string from the file (e.g. "@user" or + * "@esphome/core"). + */ +function parseCodeowners(content) { + const lines = content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + + const patterns = []; + for (const line of lines) { + const parts = line.split(/\s+/); + if (parts.length < 2) continue; + + const pattern = parts[0]; + const owners = parts.slice(1); + const regex = globToRegex(pattern); + patterns.push({ pattern, regex, owners }); + } + return patterns; +} + +/** + * Fetch and parse the CODEOWNERS file via the GitHub API. + * + * @param {object} github - octokit instance from actions/github-script + * @param {string} owner - repo owner + * @param {string} repo - repo name + * @param {string} [ref] - git ref (SHA / branch) to read from + * @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>} + */ +async function fetchCodeowners(github, owner, repo, ref) { + const params = { owner, repo, path: 'CODEOWNERS' }; + if (ref) params.ref = ref; + + const { data: file } = await github.rest.repos.getContent(params); + const content = Buffer.from(file.content, 'base64').toString('utf8'); + return parseCodeowners(content); +} + +/** + * Classify raw owner strings into individual users and teams. + * + * @param {string[]} rawOwners - e.g. ["@user1", "@esphome/core"] + * @returns {{ users: string[], teams: string[] }} + * users – login names without "@" + * teams – team slugs without the "org/" prefix + */ +function classifyOwners(rawOwners) { + const users = []; + const teams = []; + for (const o of rawOwners) { + const clean = o.startsWith('@') ? o.slice(1) : o; + if (clean.includes('/')) { + teams.push(clean.split('/')[1]); + } else { + users.push(clean); + } + } + return { users, teams }; +} + +/** + * For each file, find its effective codeowners using GitHub's + * "last match wins" semantics, then union across all files. + * + * @param {string[]} files - list of file paths + * @param {Array} codeownersPatterns - from parseCodeowners / fetchCodeowners + * @returns {{ users: Set, teams: Set, matchedFileCount: number }} + */ +function getEffectiveOwners(files, codeownersPatterns) { + const users = new Set(); + const teams = new Set(); + let matchedFileCount = 0; + + for (const file of files) { + // Last matching pattern wins for each file + let effectiveOwners = null; + for (const { regex, owners } of codeownersPatterns) { + if (regex.test(file)) { + effectiveOwners = owners; + } + } + if (effectiveOwners) { + matchedFileCount++; + const classified = classifyOwners(effectiveOwners); + for (const u of classified.users) users.add(u); + for (const t of classified.teams) teams.add(t); + } + } + + return { users, teams, matchedFileCount }; +} + +/** + * Read and parse the CODEOWNERS file from disk. + * + * Use this when the repo is already checked out (avoids an API call). + * + * @param {string} [repoRoot='.'] - path to the repo root + * @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>} + */ +function loadCodeowners(repoRoot = '.') { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(repoRoot, 'CODEOWNERS'), 'utf8'); + return parseCodeowners(content); +} + +module.exports = { + globToRegex, + parseCodeowners, + fetchCodeowners, + loadCodeowners, + classifyOwners, + getEffectiveOwners +}; diff --git a/.github/workflows/codeowner-approved-label.yml b/.github/workflows/codeowner-approved-label.yml new file mode 100644 index 0000000000..217ae06419 --- /dev/null +++ b/.github/workflows/codeowner-approved-label.yml @@ -0,0 +1,158 @@ +# This workflow adds/removes a 'code-owner-approved' label when a +# component-specific codeowner approves (or dismisses) a PR. +# This helps maintainers prioritize PRs that have codeowner sign-off. +# +# Only component-specific codeowners count — the catch-all @esphome/core +# team is excluded so the label reflects domain-expert approval. + +name: Codeowner Approved Label + +on: + pull_request_review: + types: [submitted, dismissed] + +permissions: + pull-requests: write + contents: read + +jobs: + codeowner-approved: + name: Run + if: ${{ github.repository == 'esphome/esphome' }} + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Check codeowner approval and update label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr_number = context.payload.pull_request.number; + const LABEL_NAME = 'code-owner-approved'; + + console.log(`Processing PR #${pr_number} for codeowner approval label`); + + try { + // Get the list of changed files in this PR (with pagination) + const prFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: pr_number + } + ); + + const changedFiles = prFiles.map(file => file.filename); + console.log(`Found ${changedFiles.length} changed files`); + + if (changedFiles.length === 0) { + console.log('No changed files found, skipping'); + return; + } + + // Parse CODEOWNERS from the checked-out base branch + const codeownersPatterns = loadCodeowners(); + + // Get effective owners using last-match-wins semantics + const effective = getEffectiveOwners(changedFiles, codeownersPatterns); + + // Only keep individual component-specific codeowners (exclude teams) + const componentCodeowners = effective.users; + + console.log(`Component-specific codeowners for changed files: ${Array.from(componentCodeowners).join(', ') || '(none)'}`); + + if (componentCodeowners.size === 0) { + console.log('No component-specific codeowners found for changed files'); + // Remove label if present since there are no component codeowners + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr_number, + name: LABEL_NAME + }); + console.log(`Removed '${LABEL_NAME}' label (no component codeowners)`); + } catch (error) { + if (error.status !== 404) { + console.log(`Failed to remove label: ${error.message}`); + } + } + return; + } + + // Get all reviews on the PR + const reviews = await github.paginate( + github.rest.pulls.listReviews, + { + owner, + repo, + pull_number: pr_number + } + ); + + // Get the latest review per user (reviews are returned chronologically) + const latestReviewByUser = new Map(); + for (const review of reviews) { + // Skip bot reviews and comment-only reviews + if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue; + latestReviewByUser.set(review.user.login, review); + } + + // Check if any component-specific codeowner has an active approval + let hasCodeownerApproval = false; + for (const [login, review] of latestReviewByUser) { + if (review.state === 'APPROVED' && componentCodeowners.has(login)) { + console.log(`Codeowner '${login}' has approved`); + hasCodeownerApproval = true; + break; + } + } + + // Get current labels to check if label is already present + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: pr_number + }); + const hasLabel = currentLabels.some(label => label.name === LABEL_NAME); + + if (hasCodeownerApproval && !hasLabel) { + // Add the label + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr_number, + labels: [LABEL_NAME] + }); + console.log(`Added '${LABEL_NAME}' label`); + } else if (!hasCodeownerApproval && hasLabel) { + // Remove the label + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr_number, + name: LABEL_NAME + }); + console.log(`Removed '${LABEL_NAME}' label`); + } catch (error) { + if (error.status !== 404) { + console.log(`Failed to remove label: ${error.message}`); + } + } + } else { + console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`); + } + + } catch (error) { + console.error(error); + core.setFailed(`Failed to process codeowner approval label: ${error.message}`); + } diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 6f4351b298..02bf0e4a29 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -24,10 +24,17 @@ jobs: if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + - name: Request reviews from component codeowners uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | + const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); + const owner = context.repo.owner; const repo = context.repo.repo; const pr_number = context.payload.pull_request.number; @@ -38,12 +45,15 @@ jobs: const BOT_COMMENT_MARKER = ''; try { - // Get the list of changed files in this PR - const { data: files } = await github.rest.pulls.listFiles({ - owner, - repo, - pull_number: pr_number - }); + // Get the list of changed files in this PR (with pagination) + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: pr_number + } + ); const changedFiles = files.map(file => file.filename); console.log(`Found ${changedFiles.length} changed files`); @@ -53,32 +63,10 @@ jobs: return; } - // Fetch CODEOWNERS file from root - const { data: codeownersFile } = await github.rest.repos.getContent({ - owner, - repo, - path: 'CODEOWNERS', - ref: context.payload.pull_request.base.sha - }); - const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); + // Parse CODEOWNERS from the checked-out base branch + const codeownersPatterns = loadCodeowners(); - // Parse CODEOWNERS file to extract all patterns and their owners - const codeownersLines = codeownersContent.split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); - - const codeownersPatterns = []; - - // Convert CODEOWNERS pattern to regex (robust glob handling) - function globToRegex(pattern) { - // Escape regex special characters except for glob wildcards - let regexStr = pattern - .replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars - .replace(/\*\*/g, '.*') // globstar - .replace(/\*/g, '[^/]*') // single star - .replace(/\?/g, '.'); // question mark - return new RegExp('^' + regexStr + '$'); - } + console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`); // Helper function to create comment body function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) { @@ -93,50 +81,11 @@ jobs: } } - for (const line of codeownersLines) { - const parts = line.split(/\s+/); - if (parts.length < 2) continue; - - const pattern = parts[0]; - const owners = parts.slice(1); - - // Use robust glob-to-regex conversion - const regex = globToRegex(pattern); - codeownersPatterns.push({ pattern, regex, owners }); - } - - console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`); - - // Match changed files against CODEOWNERS patterns - const matchedOwners = new Set(); - const matchedTeams = new Set(); - const fileMatches = new Map(); // Track which files matched which patterns - - for (const file of changedFiles) { - for (const { pattern, regex, owners } of codeownersPatterns) { - if (regex.test(file)) { - console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`); - - if (!fileMatches.has(file)) { - fileMatches.set(file, []); - } - fileMatches.get(file).push({ pattern, owners }); - - // Add owners to the appropriate set (remove @ prefix) - for (const owner of owners) { - const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner; - if (cleanOwner.includes('/')) { - // Team mention (org/team-name) - const teamName = cleanOwner.split('/')[1]; - matchedTeams.add(teamName); - } else { - // Individual user - matchedOwners.add(cleanOwner); - } - } - } - } - } + // Match changed files against CODEOWNERS patterns using last-match-wins semantics + const effective = getEffectiveOwners(changedFiles, codeownersPatterns); + const matchedOwners = effective.users; + const matchedTeams = effective.teams; + const matchedFileCount = effective.matchedFileCount; if (matchedOwners.size === 0 && matchedTeams.size === 0) { console.log('No codeowners found for any changed files'); @@ -170,11 +119,14 @@ jobs: } // Check for completed reviews to avoid re-requesting users who have already reviewed - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, - repo, - pull_number: pr_number - }); + const reviews = await github.paginate( + github.rest.pulls.listReviews, + { + owner, + repo, + pull_number: pr_number + } + ); const reviewedUsers = new Set(); reviews.forEach(review => { @@ -247,7 +199,7 @@ jobs: } const totalReviewers = reviewersList.length + teamsList.length; - console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`); + console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${matchedFileCount} matched files`); // Request reviews try { @@ -279,7 +231,7 @@ jobs: // Only add a comment if there are new codeowners to mention (not previously pinged) if (reviewersList.length > 0 || teamsList.length > 0) { - const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true); + const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, true); await github.rest.issues.createComment({ owner, @@ -297,7 +249,7 @@ jobs: // Only try to add a comment if there are new codeowners to mention if (reviewersList.length > 0 || teamsList.length > 0) { - const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false); + const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false); try { await github.rest.issues.createComment({ From 380c0db0205db56960f3869b341fe20629c4a4f8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:49:38 +1100 Subject: [PATCH 33/36] [usb_uart] Don't claim interrupt interface for ch34x (#14431) --- esphome/components/usb_uart/ch34x.cpp | 9 +++++++++ esphome/components/usb_uart/usb_uart.cpp | 14 +++++++++----- esphome/components/usb_uart/usb_uart.h | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 7fa964c0cb..e6e52a9e2a 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -75,6 +75,15 @@ void USBUartTypeCH34X::enable_channels() { } this->start_channels(); } + +std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { + auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare + for (auto &cdc_dev : result) { + cdc_dev.interrupt_interface_number = 0xFF; + } + return result; +} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index de81bfc587..5c0397b2cb 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -20,6 +20,7 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in // look for an interface with an interrupt endpoint (notify), and one with two bulk endpoints (data in/out) CdcEps eps{}; eps.bulk_interface_number = 0xFF; + eps.interrupt_interface_number = 0xFF; for (;;) { const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx++, 0, &conf_offset); if (!intf_desc) { @@ -130,7 +131,7 @@ size_t RingBuffer::pop(uint8_t *data, size_t len) { } void USBUartChannel::write_array(const uint8_t *data, size_t len) { if (!this->initialised_.load()) { - ESP_LOGV(TAG, "Channel not initialised - write ignored"); + ESP_LOGD(TAG, "Channel not initialised - write ignored"); return; } #ifdef USE_UART_DEBUGGER @@ -415,14 +416,15 @@ void USBUartTypeCdcAcm::on_connected() { // Claim the communication (interrupt) interface so CDC class requests are accepted // by the device. Some CDC ACM implementations (e.g. EFR32 NCP) require this before // they enable data flow on the bulk endpoints. - if (channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { + if (channel->cdc_dev_.interrupt_interface_number != 0xFF && + channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); if (err_comm != ESP_OK) { ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, esp_err_to_name(err_comm)); + channel->cdc_dev_.interrupt_interface_number = 0xFF; // Mark as unavailable, but continue anyway } else { - channel->cdc_dev_.comm_interface_claimed = true; ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); } } @@ -436,6 +438,7 @@ void USBUartTypeCdcAcm::on_connected() { return; } } + this->status_clear_error(); this->enable_channels(); } @@ -453,9 +456,10 @@ void USBUartTypeCdcAcm::on_disconnected() { usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); } - if (channel->cdc_dev_.comm_interface_claimed) { + if (channel->cdc_dev_.interrupt_interface_number != 0xFF && + channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.comm_interface_claimed = false; + channel->cdc_dev_.interrupt_interface_number = 0xFF; } usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number); // Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 9a9fe1c2ca..0d471e46f6 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -32,7 +32,6 @@ struct CdcEps { const usb_ep_desc_t *out_ep; uint8_t bulk_interface_number; uint8_t interrupt_interface_number; - bool comm_interface_claimed{false}; }; enum UARTParityOptions { @@ -192,6 +191,7 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { protected: void enable_channels() override; + std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; }; } // namespace esphome::usb_uart From 96793a99ce12fe46e349ca1a165869a9f29bb842 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Tue, 3 Mar 2026 21:55:56 +0100 Subject: [PATCH 34/36] [rtttl] add new codeowner (#14440) --- CODEOWNERS | 2 +- esphome/components/rtttl/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 21bee125c6..b22f85b71d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -412,7 +412,7 @@ esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 -esphome/components/rtttl/* @glmnet +esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco esphome/components/rx8130/* @beormund diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index ebbe5366aa..19412bb454 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -17,7 +17,7 @@ import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) -CODEOWNERS = ["@glmnet"] +CODEOWNERS = ["@glmnet", "@ximex"] CONF_RTTTL = "rtttl" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" From ee78d7a0c05b8f3d6878751c95cd5c9dae232690 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:42:41 -0500 Subject: [PATCH 35/36] [tests] Fix integration test race condition in PlatformIO cache init (#14435) Co-authored-by: Claude Opus 4.6 --- tests/integration/conftest.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 36df1bc83e..b7f7fc60b3 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -73,11 +73,6 @@ def shared_platformio_cache() -> Generator[Path]: test_cache_dir = Path.home() / ".esphome-integration-tests" cache_dir = test_cache_dir / "platformio" - # Create the temp directory that PlatformIO uses to avoid race conditions - # This ensures it exists and won't be deleted by parallel processes - platformio_tmp_dir = cache_dir / ".cache" / "tmp" - platformio_tmp_dir.mkdir(parents=True, exist_ok=True) - # Use a lock file in the home directory to ensure only one process initializes the cache # This is needed when running with pytest-xdist # The lock file must be in a directory that already exists to avoid race conditions @@ -87,8 +82,9 @@ def shared_platformio_cache() -> Generator[Path]: with open(lock_file, "w") as lock_fd: fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) - # Check if cache needs initialization while holding the lock - if not cache_dir.exists() or not any(cache_dir.iterdir()): + # Check if the native platform is installed (the actual indicator of a populated cache) + native_platform = cache_dir / "platforms" / "native" + if not native_platform.exists(): # Create the test cache directory if it doesn't exist test_cache_dir.mkdir(exist_ok=True) From 989330d6bc5caa2b94950b55d3811c5ee2c51be3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:54:40 +1300 Subject: [PATCH 36/36] [globals] Fix handling of string booleans in yaml (#14447) --- esphome/components/globals/__init__.py | 2 +- tests/components/globals/common.yaml | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index fe11a93a4b..fe83b1ea7c 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -51,7 +51,7 @@ _RESTORING_SCHEMA = cv.Schema( def _globals_schema(config: ConfigType) -> ConfigType: """Select schema based on restore_value setting.""" - if config.get(CONF_RESTORE_VALUE, False): + if cv.boolean(config.get(CONF_RESTORE_VALUE, False)): return _RESTORING_SCHEMA(config) return _NON_RESTORING_SCHEMA(config) diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index efa3cba076..35dca0624f 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -27,3 +27,14 @@ globals: type: bool restore_value: false initial_value: "false" + # Test restore_value with string "false" - should be converted to bool false + - id: glob_no_restore_string_false + type: int + restore_value: "false" + initial_value: "42" + # Test restore_value with string "true" - should be converted to bool true + - id: glob_restore_string_true + type: int + restore_value: "true" + initial_value: "99" + update_interval: 5s